mirror of
https://github.com/Swiftgram/Telegram-iOS.git
synced 2026-02-09 04:44:55 +00:00
2221 lines
96 KiB
Swift
2221 lines
96 KiB
Swift
import Foundation
|
||
import UIKit
|
||
import AsyncDisplayKit
|
||
import Display
|
||
import ComponentFlow
|
||
import SwiftSignalKit
|
||
import Postbox
|
||
import TelegramCore
|
||
import Markdown
|
||
import TextFormat
|
||
import TelegramPresentationData
|
||
import ViewControllerComponent
|
||
import SheetComponent
|
||
import BalancedTextComponent
|
||
import MultilineTextComponent
|
||
import MultilineTextWithEntitiesComponent
|
||
import BundleIconComponent
|
||
import ButtonComponent
|
||
import AccountContext
|
||
import PresentationDataUtils
|
||
import ListSectionComponent
|
||
import TelegramStringFormatting
|
||
import UndoUI
|
||
import ListActionItemComponent
|
||
import PresentationDataUtils
|
||
import BalanceNeededScreen
|
||
import GlassBarButtonComponent
|
||
import GlassBackgroundComponent
|
||
import StarsBalanceOverlayComponent
|
||
import LottieComponent
|
||
import LottieComponentResourceContent
|
||
import EdgeEffect
|
||
import PlainButtonComponent
|
||
|
||
private let amountTag = GenericComponentViewTag()
|
||
|
||
private final class SheetContent: CombinedComponent {
|
||
typealias EnvironmentType = ViewControllerComponentContainer.Environment
|
||
|
||
let context: AccountContext
|
||
let gameInfo: EmojiGameInfo.Info
|
||
let controller: () -> ViewController?
|
||
let dismiss: () -> Void
|
||
|
||
init(
|
||
context: AccountContext,
|
||
gameInfo: EmojiGameInfo.Info,
|
||
controller: @escaping () -> ViewController?,
|
||
dismiss: @escaping () -> Void
|
||
) {
|
||
self.context = context
|
||
self.gameInfo = gameInfo
|
||
self.controller = controller
|
||
self.dismiss = dismiss
|
||
}
|
||
|
||
static func ==(lhs: SheetContent, rhs: SheetContent) -> Bool {
|
||
return true
|
||
}
|
||
|
||
static var body: (CombinedComponentContext<SheetContent>) -> CGSize {
|
||
let description = Child(BalancedTextComponent.self)
|
||
let resultsTitle = Child(MultilineTextComponent.self)
|
||
let results = Child(VStack<Empty>.self)
|
||
let resultsFooter = Child(MultilineTextWithEntitiesComponent.self)
|
||
let amountSection = Child(ListSectionComponent.self)
|
||
let button = Child(ButtonComponent.self)
|
||
|
||
let body: (CombinedComponentContext<SheetContent>) -> CGSize = { (context: CombinedComponentContext<SheetContent>) -> CGSize in
|
||
let environment = context.environment[EnvironmentType.self]
|
||
let component = context.component
|
||
let state = context.state
|
||
|
||
state.component = component
|
||
|
||
let controller = environment.controller
|
||
|
||
let theme = environment.theme.withModalBlocksBackground()
|
||
let strings = environment.strings
|
||
let presentationData = component.context.sharedContext.currentPresentationData.with { $0 }
|
||
|
||
let sideInset: CGFloat = 16.0 + environment.safeInsets.left
|
||
let textSideInset: CGFloat = 32.0 + environment.safeInsets.left
|
||
var contentSize = CGSize(width: context.availableSize.width, height: 75.0)
|
||
|
||
let textFont = Font.regular(15.0)
|
||
let boldTextFont = Font.semibold(15.0)
|
||
let textColor = theme.actionSheet.primaryTextColor
|
||
let linkColor = theme.actionSheet.controlAccentColor
|
||
let markdownAttributes = MarkdownAttributes(body: MarkdownAttributeSet(font: textFont, textColor: textColor), bold: MarkdownAttributeSet(font: boldTextFont, textColor: textColor), link: MarkdownAttributeSet(font: textFont, textColor: linkColor), linkAttribute: { contents in
|
||
return (TelegramTextAttributes.URL, contents)
|
||
})
|
||
|
||
let description = description.update(
|
||
component: BalancedTextComponent(
|
||
text: .markdown(text: strings.EmojiStake_Description, attributes: markdownAttributes),
|
||
horizontalAlignment: .center,
|
||
maximumNumberOfLines: 0,
|
||
lineSpacing: 0.2
|
||
),
|
||
availableSize: CGSize(width: context.availableSize.width - textSideInset * 2.0, height: context.availableSize.height),
|
||
transition: .immediate
|
||
)
|
||
context.add(description
|
||
.position(CGPoint(x: context.availableSize.width / 2.0, y: contentSize.height + description.size.height * 0.5))
|
||
)
|
||
contentSize.height += description.size.height
|
||
contentSize.height += 32.0
|
||
|
||
let resultsTitle = resultsTitle.update(
|
||
component: MultilineTextComponent(text: .plain(NSAttributedString(
|
||
string: strings.EmojiStake_ResultsTitle.uppercased(),
|
||
font: Font.regular(presentationData.listsFontSize.itemListBaseHeaderFontSize),
|
||
textColor: theme.list.freeTextColor
|
||
))),
|
||
availableSize: context.availableSize,
|
||
transition: .immediate
|
||
)
|
||
context.add(resultsTitle
|
||
.position(CGPoint(x: textSideInset + resultsTitle.size.width * 0.5, y: contentSize.height + resultsTitle.size.height * 0.5))
|
||
)
|
||
contentSize.height += resultsTitle.size.height
|
||
contentSize.height += 6.0
|
||
|
||
let resultSpacing: CGFloat = 8.0
|
||
let resultSize = CGSize(width: (context.availableSize.width - sideInset * 2.0 - resultSpacing * 3.0) / 4.0, height: 64.0)
|
||
let doubleResultSize = CGSize(width: resultSize.width * 2.0 + resultSpacing, height: resultSize.height)
|
||
|
||
var resultValue1: Int32 = 0
|
||
var resultValue2: Int32 = 300
|
||
var resultValue3: Int32 = 600
|
||
var resultValue4: Int32 = 1300
|
||
var resultValue5: Int32 = 1600
|
||
var resultValue6: Int32 = 2000
|
||
var resultValue7: Int32 = 20000
|
||
if context.component.gameInfo.parameters.count == 7 {
|
||
resultValue1 = context.component.gameInfo.parameters[0]
|
||
resultValue2 = context.component.gameInfo.parameters[1]
|
||
resultValue3 = context.component.gameInfo.parameters[2]
|
||
resultValue4 = context.component.gameInfo.parameters[3]
|
||
resultValue5 = context.component.gameInfo.parameters[4]
|
||
resultValue6 = context.component.gameInfo.parameters[5]
|
||
resultValue7 = context.component.gameInfo.parameters[6]
|
||
}
|
||
|
||
let results = results.update(
|
||
component: VStack([
|
||
AnyComponentWithIdentity(id: "first", component: AnyComponent(
|
||
HStack([
|
||
AnyComponentWithIdentity(id: 1, component: AnyComponent(
|
||
ResultCellComponent(context: component.context, theme: environment.theme, files: state.emojiFiles.flatMap { [$0[1]] }, value: resultValue1, size: resultSize)
|
||
)),
|
||
AnyComponentWithIdentity(id: 2, component: AnyComponent(
|
||
ResultCellComponent(context: component.context, theme: environment.theme, files: state.emojiFiles.flatMap { [$0[2]] }, value: resultValue2, size: resultSize)
|
||
)),
|
||
AnyComponentWithIdentity(id: 3, component: AnyComponent(
|
||
ResultCellComponent(context: component.context, theme: environment.theme, files: state.emojiFiles.flatMap { [$0[3]] }, value: resultValue3, size: resultSize)
|
||
)),
|
||
AnyComponentWithIdentity(id: 4, component: AnyComponent(
|
||
ResultCellComponent(context: component.context, theme: environment.theme, files: state.emojiFiles.flatMap { [$0[4]] }, value: resultValue4, size: resultSize)
|
||
))
|
||
], spacing: resultSpacing)
|
||
)),
|
||
AnyComponentWithIdentity(id: "second", component: AnyComponent(
|
||
HStack([
|
||
AnyComponentWithIdentity(id: 5, component: AnyComponent(
|
||
ResultCellComponent(context: component.context, theme: environment.theme, files: state.emojiFiles.flatMap { [$0[5]] }, value: resultValue5, size: resultSize)
|
||
)),
|
||
AnyComponentWithIdentity(id: 6, component: AnyComponent(
|
||
ResultCellComponent(context: component.context, theme: environment.theme, files: state.emojiFiles.flatMap { [$0[6]] }, value: resultValue6, size: resultSize)
|
||
)),
|
||
AnyComponentWithIdentity(id: 7, component: AnyComponent(
|
||
ResultCellComponent(context: component.context, theme: environment.theme, files: state.emojiFiles.flatMap { [$0[6], $0[6], $0[6]] }, value: resultValue7, size: doubleResultSize)
|
||
)),
|
||
], spacing: resultSpacing)
|
||
))
|
||
], spacing: resultSpacing),
|
||
availableSize: CGSize(width: context.availableSize.width - sideInset * 2.0, height: context.availableSize.height),
|
||
transition: context.transition
|
||
)
|
||
context.add(results
|
||
.position(CGPoint(x: context.availableSize.width / 2.0, y: contentSize.height + results.size.height * 0.5))
|
||
)
|
||
contentSize.height += results.size.height
|
||
contentSize.height += 7.0
|
||
|
||
let resultsFooterAttributedText = NSMutableAttributedString(
|
||
string: strings.EmojiStake_StreakInfo,
|
||
font: Font.regular(13.0),
|
||
textColor: theme.list.freeTextColor
|
||
)
|
||
if let emojiFile = state.emojiFiles?[6] {
|
||
let range = (resultsFooterAttributedText.string as NSString).range(of: "#")
|
||
if range.location != NSNotFound {
|
||
resultsFooterAttributedText.addAttribute(ChatTextInputAttributes.customEmoji, value: ChatTextInputTextCustomEmojiAttribute(interactivelySelectedFromPackId: nil, fileId: 0, file: emojiFile, custom: .dice, enableAnimation: false), range: range)
|
||
}
|
||
}
|
||
|
||
let resultsFooter = resultsFooter.update(
|
||
component: MultilineTextWithEntitiesComponent(
|
||
context: component.context,
|
||
animationCache: component.context.animationCache,
|
||
animationRenderer: component.context.animationRenderer,
|
||
placeholderColor: .clear,
|
||
text: .plain(resultsFooterAttributedText),
|
||
maximumNumberOfLines: 0,
|
||
enableLooping: true
|
||
),
|
||
availableSize: context.availableSize,
|
||
transition: .immediate
|
||
)
|
||
context.add(resultsFooter
|
||
.position(CGPoint(x: textSideInset + resultsFooter.size.width * 0.5, y: contentSize.height + resultsFooter.size.height * 0.5))
|
||
)
|
||
contentSize.height += resultsFooter.size.height
|
||
contentSize.height += 39.0
|
||
|
||
if state.cachedChevronImage == nil || state.cachedChevronImage?.1 !== environment.theme {
|
||
state.cachedChevronImage = (generateTintedImage(image: UIImage(bundleImageName: "Contact List/SubtitleArrow"), color: environment.theme.list.itemAccentColor)!, environment.theme)
|
||
}
|
||
|
||
let configuration = EmojiGameStakeConfiguration.with(appConfiguration: component.context.currentAppConfiguration.with { $0 })
|
||
var amountLabel = ""
|
||
if let tonUsdRate = configuration.tonUsdRate, let value = state.amount?.value, value > 0 {
|
||
amountLabel = "~\(formatTonUsdValue(value, divide: true, rate: tonUsdRate, dateTimeFormat: environment.dateTimeFormat))"
|
||
}
|
||
|
||
let amountItems: [AnyComponentWithIdentity<Empty>] = [
|
||
AnyComponentWithIdentity(
|
||
id: "amount",
|
||
component: AnyComponent(
|
||
AmountFieldComponent(
|
||
textColor: theme.list.itemPrimaryTextColor,
|
||
secondaryColor: theme.list.itemSecondaryTextColor,
|
||
placeholderColor: theme.list.itemPlaceholderTextColor,
|
||
accentColor: theme.list.itemAccentColor,
|
||
value: state.amount?.value,
|
||
minValue: 0,
|
||
forceMinValue: false,
|
||
allowZero: true,
|
||
maxValue: nil,
|
||
placeholderText: strings.EmojiStake_StakePlaceholder,
|
||
labelText: amountLabel,
|
||
currency: .ton,
|
||
dateTimeFormat: presentationData.dateTimeFormat,
|
||
amountUpdated: { [weak state] amount in
|
||
state?.amount = amount.flatMap { StarsAmount(value: $0, nanos: 0) }
|
||
state?.updated()
|
||
},
|
||
tag: amountTag
|
||
)
|
||
)
|
||
),
|
||
AnyComponentWithIdentity(id: "presets", component: AnyComponent(
|
||
AmountPresetsListItemComponent(
|
||
context: component.context,
|
||
theme: theme,
|
||
values: configuration.suggestedAmounts,
|
||
valueSelected: { [weak state] value in
|
||
guard let state else {
|
||
return
|
||
}
|
||
state.amount = StarsAmount(value: value, nanos: 0)
|
||
if let controller = controller() as? EmojiGameStakeScreen {
|
||
controller.dismissInput()
|
||
state.updated()
|
||
controller.resetValue()
|
||
}
|
||
}
|
||
)
|
||
))
|
||
]
|
||
|
||
let amountSection = amountSection.update(
|
||
component: ListSectionComponent(
|
||
theme: theme,
|
||
style: .glass,
|
||
header: AnyComponent(MultilineTextComponent(
|
||
text: .plain(NSAttributedString(
|
||
string: strings.EmojiStake_StakeTitle.uppercased(),
|
||
font: Font.regular(presentationData.listsFontSize.itemListBaseHeaderFontSize),
|
||
textColor: theme.list.freeTextColor
|
||
)),
|
||
maximumNumberOfLines: 0
|
||
)),
|
||
footer: nil,
|
||
items: amountItems
|
||
),
|
||
environment: {},
|
||
availableSize: CGSize(width: context.availableSize.width - sideInset * 2.0, height: .greatestFiniteMagnitude),
|
||
transition: .immediate
|
||
)
|
||
context.add(amountSection
|
||
.position(CGPoint(x: context.availableSize.width / 2.0, y: contentSize.height + amountSection.size.height / 2.0))
|
||
.clipsToBounds(true)
|
||
.cornerRadius(10.0)
|
||
)
|
||
contentSize.height += amountSection.size.height
|
||
contentSize.height += 24.0
|
||
|
||
|
||
var buttonItems: [AnyComponentWithIdentity<Empty>] = []
|
||
buttonItems.append(AnyComponentWithIdentity(id: "icon", component: AnyComponent(BundleIconComponent(name: "Premium/Dice", tintColor: theme.list.itemCheckColors.foregroundColor))))
|
||
buttonItems.append(AnyComponentWithIdentity(id: "label", component: AnyComponent(Text(text: environment.strings.EmojiStake_Roll, font: Font.semibold(17.0), color: theme.list.itemCheckColors.foregroundColor))))
|
||
|
||
let buttonInsets = ContainerViewLayout.concentricInsets(bottomInset: environment.safeInsets.bottom, innerDiameter: 52.0, sideInset: 30.0)
|
||
let button = button.update(
|
||
component: ButtonComponent(
|
||
background: ButtonComponent.Background(
|
||
style: .glass,
|
||
color: theme.list.itemCheckColors.fillColor,
|
||
foreground: theme.list.itemCheckColors.foregroundColor,
|
||
pressedColor: theme.list.itemCheckColors.fillColor.withMultipliedAlpha(0.9)
|
||
),
|
||
content: AnyComponentWithIdentity(
|
||
id: AnyHashable(0),
|
||
component: AnyComponent(HStack(buttonItems, spacing: 7.0))
|
||
),
|
||
action: { [weak state] in
|
||
if let state, let amount = state.amount, let controller = controller() as? EmojiGameStakeScreen {
|
||
controller.complete(amount: amount)
|
||
}
|
||
}
|
||
),
|
||
availableSize: CGSize(width: context.availableSize.width - buttonInsets.left - buttonInsets.right, height: 52.0),
|
||
transition: .immediate
|
||
)
|
||
context.add(button
|
||
.position(CGPoint(x: context.availableSize.width / 2.0, y: contentSize.height + button.size.height / 2.0))
|
||
)
|
||
contentSize.height += button.size.height
|
||
|
||
if environment.inputHeight > 0.0 {
|
||
contentSize.height += 15.0
|
||
contentSize.height += max(environment.inputHeight, environment.safeInsets.bottom)
|
||
} else {
|
||
contentSize.height += buttonInsets.bottom
|
||
}
|
||
|
||
return contentSize
|
||
}
|
||
|
||
return body
|
||
}
|
||
|
||
final class State: ComponentState {
|
||
fileprivate let context: AccountContext
|
||
|
||
fileprivate var component: SheetContent
|
||
|
||
fileprivate var forceUpdateAmount = false
|
||
fileprivate var amount: StarsAmount?
|
||
fileprivate var currency: CurrencyAmount.Currency = .ton
|
||
|
||
var cachedChevronImage: (UIImage, PresentationTheme)?
|
||
|
||
var emojiFiles: [TelegramMediaFile]?
|
||
var emojiFilesDisposable: Disposable?
|
||
|
||
init(component: SheetContent) {
|
||
self.context = component.context
|
||
self.component = component
|
||
|
||
let amount: StarsAmount? = StarsAmount(value: component.gameInfo.previousStake, nanos: 0)
|
||
let currency: CurrencyAmount.Currency = .ton
|
||
|
||
self.currency = currency
|
||
self.amount = amount
|
||
|
||
super.init()
|
||
|
||
self.emojiFilesDisposable = (self.context.engine.stickers.loadedStickerPack(reference: .dice("🎲"), forceActualized: false)
|
||
|> mapToSignal { stickerPack -> Signal<[TelegramMediaFile], NoError> in
|
||
switch stickerPack {
|
||
case let .result(_, items, _):
|
||
var emojiStickers: [TelegramMediaFile] = []
|
||
for item in items {
|
||
emojiStickers.append(item.file._parse())
|
||
}
|
||
return .single(emojiStickers)
|
||
default:
|
||
return .complete()
|
||
}
|
||
}
|
||
|> deliverOnMainQueue).start(next: { [weak self] files in
|
||
guard let self else {
|
||
return
|
||
}
|
||
self.emojiFiles = files
|
||
self.updated()
|
||
})
|
||
}
|
||
|
||
deinit {
|
||
self.emojiFilesDisposable?.dispose()
|
||
}
|
||
}
|
||
|
||
func makeState() -> State {
|
||
return State(component: self)
|
||
}
|
||
}
|
||
|
||
private final class EmojiGameStakeSheetComponent: CombinedComponent {
|
||
typealias EnvironmentType = ViewControllerComponentContainer.Environment
|
||
|
||
private let context: AccountContext
|
||
private let gameInfo: EmojiGameInfo.Info
|
||
|
||
init(
|
||
context: AccountContext,
|
||
gameInfo: EmojiGameInfo.Info
|
||
) {
|
||
self.context = context
|
||
self.gameInfo = gameInfo
|
||
}
|
||
|
||
static func ==(lhs: EmojiGameStakeSheetComponent, rhs: EmojiGameStakeSheetComponent) -> Bool {
|
||
return true
|
||
}
|
||
|
||
static var body: Body {
|
||
let sheet = Child(ResizableSheetComponent<(EnvironmentType)>.self)
|
||
let animateOut = StoredActionSlot(Action<Void>.self)
|
||
|
||
return { context in
|
||
let environment = context.environment[EnvironmentType.self]
|
||
|
||
let controller = environment.controller
|
||
|
||
let dismiss: (Bool) -> Void = { animated in
|
||
if animated {
|
||
animateOut.invoke(Action { _ in
|
||
if let controller = controller() {
|
||
controller.dismiss(completion: nil)
|
||
}
|
||
})
|
||
} else {
|
||
if let controller = controller() {
|
||
controller.dismiss(completion: nil)
|
||
}
|
||
}
|
||
}
|
||
|
||
let theme = environment.theme.withModalBlocksBackground()
|
||
|
||
var buttonItems: [AnyComponentWithIdentity<Empty>] = []
|
||
buttonItems.append(AnyComponentWithIdentity(id: "icon", component: AnyComponent(Image(image: PresentationResourcesItemList.itemListRoundTopupIcon(environment.theme), tintColor: theme.list.itemCheckColors.foregroundColor, size: CGSize(width: 16.0, height: 18.0)))))
|
||
buttonItems.append(AnyComponentWithIdentity(id: "label", component: AnyComponent(Text(text: environment.strings.EmojiStake_Roll, font: Font.semibold(17.0), color: theme.list.itemCheckColors.foregroundColor))))
|
||
|
||
let sheet = sheet.update(
|
||
component: ResizableSheetComponent<EnvironmentType>(
|
||
content: AnyComponent<EnvironmentType>(SheetContent(
|
||
context: context.component.context,
|
||
gameInfo: context.component.gameInfo,
|
||
controller: {
|
||
return controller()
|
||
},
|
||
dismiss: {
|
||
dismiss(true)
|
||
}
|
||
)),
|
||
titleItem: AnyComponent(
|
||
Text(text: environment.strings.EmojiStake_Title, font: Font.bold(17.0), color: theme.list.itemPrimaryTextColor)
|
||
),
|
||
leftItem: AnyComponent(
|
||
GlassBarButtonComponent(
|
||
size: CGSize(width: 40.0, height: 40.0),
|
||
backgroundColor: theme.rootController.navigationBar.glassBarButtonBackgroundColor,
|
||
isDark: theme.overallDarkAppearance,
|
||
state: .generic,
|
||
component: AnyComponentWithIdentity(id: "close", component: AnyComponent(
|
||
BundleIconComponent(
|
||
name: "Navigation/Close",
|
||
tintColor: theme.chat.inputPanel.panelControlColor
|
||
)
|
||
)),
|
||
action: { _ in
|
||
dismiss(true)
|
||
}
|
||
)
|
||
),
|
||
bottomItem: AnyComponent(
|
||
ButtonComponent(
|
||
background: ButtonComponent.Background(
|
||
style: .glass,
|
||
color: theme.list.itemCheckColors.fillColor,
|
||
foreground: theme.list.itemCheckColors.foregroundColor,
|
||
pressedColor: theme.list.itemCheckColors.fillColor.withMultipliedAlpha(0.9)
|
||
),
|
||
content: AnyComponentWithIdentity(
|
||
id: AnyHashable(0),
|
||
component: AnyComponent(HStack(buttonItems, spacing: 7.0))
|
||
),
|
||
isEnabled: true,
|
||
displaysProgress: false,
|
||
action: {
|
||
dismiss(true)
|
||
}
|
||
)
|
||
),
|
||
backgroundColor: .color(theme.list.blocksBackgroundColor),
|
||
animateOut: animateOut
|
||
),
|
||
environment: {
|
||
environment
|
||
ResizableSheetComponentEnvironment(
|
||
theme: theme,
|
||
statusBarHeight: environment.statusBarHeight,
|
||
safeInsets: environment.safeInsets,
|
||
metrics: environment.metrics,
|
||
deviceMetrics: environment.deviceMetrics,
|
||
isDisplaying: environment.value.isVisible,
|
||
isCentered: environment.metrics.widthClass == .regular,
|
||
regularMetricsSize: CGSize(width: 430.0, height: 900.0),
|
||
dismiss: { animated in
|
||
dismiss(animated)
|
||
}
|
||
)
|
||
},
|
||
availableSize: context.availableSize,
|
||
transition: context.transition
|
||
)
|
||
|
||
context.add(sheet
|
||
.position(CGPoint(x: context.availableSize.width / 2.0, y: context.availableSize.height / 2.0))
|
||
)
|
||
|
||
return context.availableSize
|
||
}
|
||
}
|
||
}
|
||
|
||
public final class EmojiGameStakeScreen: ViewControllerComponentContainer {
|
||
private let context: AccountContext
|
||
fileprivate let completion: (StarsAmount) -> Void
|
||
|
||
fileprivate let balanceOverlay = ComponentView<Empty>()
|
||
private var showBalance = true
|
||
|
||
public init(
|
||
context: AccountContext,
|
||
gameInfo: EmojiGameInfo.Info,
|
||
completion: @escaping (StarsAmount) -> Void
|
||
) {
|
||
self.context = context
|
||
self.completion = completion
|
||
|
||
super.init(
|
||
context: context,
|
||
component: EmojiGameStakeSheetComponent(
|
||
context: context,
|
||
gameInfo: gameInfo
|
||
),
|
||
navigationBarAppearance: .none,
|
||
statusBarStyle: .ignore,
|
||
theme: .default
|
||
)
|
||
|
||
self.navigationPresentation = .flatModal
|
||
|
||
self.context.tonContext?.load(force: true)
|
||
}
|
||
|
||
required public init(coder aDecoder: NSCoder) {
|
||
fatalError("init(coder:) has not been implemented")
|
||
}
|
||
|
||
fileprivate func dismissInput() {
|
||
if let view = self.node.hostView.findTaggedView(tag: amountTag) as? AmountFieldComponent.View {
|
||
view.deactivateInput()
|
||
}
|
||
}
|
||
|
||
fileprivate func resetValue() {
|
||
if let view = self.node.hostView.findTaggedView(tag: amountTag) as? AmountFieldComponent.View {
|
||
view.resetValue()
|
||
}
|
||
}
|
||
|
||
public func dismissAnimated() {
|
||
if let view = self.node.hostView.findTaggedView(tag: ResizableSheetComponent<ViewControllerComponentContainer.Environment>.View.Tag()) as? ResizableSheetComponent<ViewControllerComponentContainer.Environment>.View {
|
||
view.dismissAnimated()
|
||
}
|
||
}
|
||
|
||
func complete(amount: StarsAmount) {
|
||
if let tonState = self.context.tonContext?.currentState, tonState.balance < amount {
|
||
let needed = amount - tonState.balance
|
||
var fragmentUrl = "https://fragment.com/ads/topup"
|
||
if let data = self.context.currentAppConfiguration.with({ $0 }).data, let value = data["ton_topup_url"] as? String {
|
||
fragmentUrl = value
|
||
}
|
||
self.push(BalanceNeededScreen(
|
||
context: self.context,
|
||
amount: needed,
|
||
buttonAction: { [weak self] in
|
||
self?.context.sharedContext.applicationBindings.openUrl(fragmentUrl)
|
||
}
|
||
))
|
||
} else {
|
||
self.completion(amount)
|
||
self.dismissAnimated()
|
||
}
|
||
}
|
||
|
||
func dismissBalanceOverlay() {
|
||
if let view = self.balanceOverlay.view, view.superview != nil {
|
||
view.alpha = 0.0
|
||
view.layer.animateScale(from: 1.0, to: 0.8, duration: 0.4)
|
||
view.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, completion: { _ in
|
||
view.removeFromSuperview()
|
||
view.alpha = 1.0
|
||
})
|
||
}
|
||
}
|
||
|
||
public override func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) {
|
||
super.containerLayoutUpdated(layout, transition: transition)
|
||
|
||
if self.showBalance {
|
||
let context = self.context
|
||
let insets = layout.insets(options: .statusBar)
|
||
let balanceSize = self.balanceOverlay.update(
|
||
transition: .immediate,
|
||
component: AnyComponent(
|
||
StarsBalanceOverlayComponent(
|
||
context: context,
|
||
peerId: context.account.peerId,
|
||
theme: context.sharedContext.currentPresentationData.with { $0 }.theme,
|
||
currency: .ton,
|
||
action: { [weak self] in
|
||
guard let self else {
|
||
return
|
||
}
|
||
var fragmentUrl = "https://fragment.com/ads/topup"
|
||
if let data = context.currentAppConfiguration.with({ $0 }).data, let value = data["ton_topup_url"] as? String {
|
||
fragmentUrl = value
|
||
}
|
||
context.sharedContext.applicationBindings.openUrl(fragmentUrl)
|
||
|
||
self.dismissAnimated()
|
||
}
|
||
)
|
||
),
|
||
environment: {},
|
||
containerSize: layout.size
|
||
)
|
||
if let view = self.balanceOverlay.view {
|
||
if view.superview == nil {
|
||
self.view.addSubview(view)
|
||
|
||
view.layer.animatePosition(from: CGPoint(x: 0.0, y: -64.0), to: .zero, duration: 0.4, timingFunction: kCAMediaTimingFunctionSpring, additive: true)
|
||
view.layer.animateSpring(from: 0.8 as NSNumber, to: 1.0 as NSNumber, keyPath: "transform.scale", duration: 0.5, initialVelocity: 0.0, removeOnCompletion: true, additive: false, completion: nil)
|
||
view.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.25)
|
||
}
|
||
view.frame = CGRect(origin: CGPoint(x: floorToScreenPixels((layout.size.width - balanceSize.width) / 2.0), y: insets.top + 5.0), size: balanceSize)
|
||
}
|
||
} else if let view = self.balanceOverlay.view, view.superview != nil {
|
||
view.alpha = 0.0
|
||
view.layer.animateScale(from: 1.0, to: 0.8, duration: 0.4)
|
||
view.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, completion: { _ in
|
||
view.removeFromSuperview()
|
||
view.alpha = 1.0
|
||
})
|
||
}
|
||
}
|
||
}
|
||
|
||
private final class AmountFieldStarsFormatter: NSObject, UITextFieldDelegate {
|
||
private let currency: CurrencyAmount.Currency
|
||
private let dateTimeFormat: PresentationDateTimeFormat
|
||
|
||
private let textField: UITextField
|
||
private let minValue: Int64
|
||
private let forceMinValue: Bool
|
||
private let allowZero: Bool
|
||
private let maxValue: Int64
|
||
private let updated: (Int64) -> Void
|
||
private let isEmptyUpdated: (Bool) -> Void
|
||
private let animateError: () -> Void
|
||
private let focusUpdated: (Bool) -> Void
|
||
|
||
init?(textField: UITextField, currency: CurrencyAmount.Currency, dateTimeFormat: PresentationDateTimeFormat, minValue: Int64, forceMinValue: Bool, allowZero: Bool, maxValue: Int64, updated: @escaping (Int64) -> Void, isEmptyUpdated: @escaping (Bool) -> Void, animateError: @escaping () -> Void, focusUpdated: @escaping (Bool) -> Void) {
|
||
self.textField = textField
|
||
self.currency = currency
|
||
self.dateTimeFormat = dateTimeFormat
|
||
self.minValue = minValue
|
||
self.forceMinValue = forceMinValue
|
||
self.allowZero = allowZero
|
||
self.maxValue = maxValue
|
||
self.updated = updated
|
||
self.isEmptyUpdated = isEmptyUpdated
|
||
self.animateError = animateError
|
||
self.focusUpdated = focusUpdated
|
||
|
||
super.init()
|
||
}
|
||
|
||
func amountFrom(text: String) -> Int64 {
|
||
var amount: Int64?
|
||
if !text.isEmpty {
|
||
switch self.currency {
|
||
case .stars:
|
||
if let value = Int64(text) {
|
||
amount = value
|
||
}
|
||
case .ton:
|
||
let scale: Int64 = 1_000_000_000 // 10⁹ (one “nano”)
|
||
if let decimalSeparator = self.dateTimeFormat.decimalSeparator.first, let dot = text.firstIndex(of: decimalSeparator) {
|
||
// Slices for the parts on each side of the dot
|
||
var wholeSlice = String(text[..<dot])
|
||
if wholeSlice.isEmpty {
|
||
wholeSlice = "0"
|
||
}
|
||
let fractionSlice = text[text.index(after: dot)...]
|
||
|
||
// Make the fractional string exactly 9 characters long
|
||
var fractionStr = String(fractionSlice)
|
||
if fractionStr.count > 9 {
|
||
fractionStr = String(fractionStr.prefix(9)) // trim extra digits
|
||
} else {
|
||
fractionStr = fractionStr.padding(
|
||
toLength: 9, withPad: "0", startingAt: 0) // pad with zeros
|
||
}
|
||
|
||
// Convert and combine
|
||
if let whole = Int64(wholeSlice),
|
||
let frac = Int64(fractionStr) {
|
||
|
||
let whole = min(whole, Int64.max / scale)
|
||
|
||
amount = whole * scale + frac
|
||
}
|
||
} else if let whole = Int64(text) { // string had no dot at all
|
||
let whole = min(whole, Int64.max / scale)
|
||
|
||
amount = whole * scale
|
||
}
|
||
}
|
||
}
|
||
return amount ?? 0
|
||
}
|
||
|
||
func onTextChanged(text: String) {
|
||
self.updated(self.amountFrom(text: text))
|
||
self.isEmptyUpdated(text.isEmpty)
|
||
}
|
||
|
||
func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
|
||
var acceptZero = false
|
||
if case .ton = self.currency, self.minValue < 1_000_000_000 {
|
||
acceptZero = true
|
||
}
|
||
|
||
var newText = ((textField.text ?? "") as NSString).replacingCharacters(in: range, with: string)
|
||
if newText.contains(where: { c in
|
||
switch c {
|
||
case "0", "1", "2", "3", "4", "5", "6", "7", "8", "9":
|
||
return false
|
||
default:
|
||
if case .ton = self.currency {
|
||
if let decimalSeparator = self.dateTimeFormat.decimalSeparator.first, c == decimalSeparator {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
}) {
|
||
return false
|
||
}
|
||
if let decimalSeparator = self.dateTimeFormat.decimalSeparator.first, newText.count(where: { $0 == decimalSeparator }) > 1 {
|
||
return false
|
||
}
|
||
|
||
switch self.currency {
|
||
case .stars:
|
||
if (newText == "0" && !acceptZero) || (newText.count > 1 && newText.hasPrefix("0")) {
|
||
newText.removeFirst()
|
||
textField.text = newText
|
||
self.onTextChanged(text: newText)
|
||
return false
|
||
}
|
||
case .ton:
|
||
var fixedText = false
|
||
if let decimalSeparator = self.dateTimeFormat.decimalSeparator.first, let index = newText.firstIndex(of: decimalSeparator) {
|
||
let fractionalString = newText[newText.index(after: index)...]
|
||
if fractionalString.count > 2 {
|
||
newText = String(newText[newText.startIndex ..< newText.index(index, offsetBy: 3)])
|
||
fixedText = true
|
||
}
|
||
}
|
||
|
||
if newText == self.dateTimeFormat.decimalSeparator {
|
||
if !acceptZero {
|
||
newText.removeFirst()
|
||
} else {
|
||
newText = "0\(newText)"
|
||
}
|
||
fixedText = true
|
||
}
|
||
|
||
if (newText == "0" && !acceptZero) || (newText.count > 1 && newText.hasPrefix("0") && !newText.hasPrefix("0\(self.dateTimeFormat.decimalSeparator)")) {
|
||
newText.removeFirst()
|
||
fixedText = true
|
||
}
|
||
|
||
if fixedText {
|
||
textField.text = newText
|
||
self.onTextChanged(text: newText)
|
||
return false
|
||
}
|
||
}
|
||
|
||
let amount: Int64 = self.amountFrom(text: newText)
|
||
if self.forceMinValue && amount < self.minValue {
|
||
switch self.currency {
|
||
case .stars:
|
||
textField.text = "\(self.minValue)"
|
||
case .ton:
|
||
textField.text = "\(formatTonAmountText(self.minValue, dateTimeFormat: PresentationDateTimeFormat(timeFormat: self.dateTimeFormat.timeFormat, dateFormat: self.dateTimeFormat.dateFormat, dateSeparator: "", dateSuffix: "", requiresFullYear: false, decimalSeparator: self.dateTimeFormat.decimalSeparator, groupingSeparator: ""), maxDecimalPositions: nil))"
|
||
}
|
||
self.onTextChanged(text: self.textField.text ?? "")
|
||
self.animateError()
|
||
return false
|
||
} else if amount > self.maxValue {
|
||
switch self.currency {
|
||
case .stars:
|
||
textField.text = "\(self.maxValue)"
|
||
case .ton:
|
||
textField.text = "\(formatTonAmountText(self.maxValue, dateTimeFormat: PresentationDateTimeFormat(timeFormat: self.dateTimeFormat.timeFormat, dateFormat: self.dateTimeFormat.dateFormat, dateSeparator: "", dateSuffix: "", requiresFullYear: false, decimalSeparator: self.dateTimeFormat.decimalSeparator, groupingSeparator: ""), maxDecimalPositions: nil))"
|
||
}
|
||
self.onTextChanged(text: self.textField.text ?? "")
|
||
self.animateError()
|
||
return false
|
||
}
|
||
|
||
self.onTextChanged(text: newText)
|
||
|
||
return true
|
||
}
|
||
}
|
||
|
||
public final class AmountFieldComponent: Component {
|
||
public typealias EnvironmentType = Empty
|
||
|
||
let textColor: UIColor
|
||
let secondaryColor: UIColor
|
||
let placeholderColor: UIColor
|
||
let accentColor: UIColor
|
||
let value: Int64?
|
||
let minValue: Int64?
|
||
let forceMinValue: Bool
|
||
let allowZero: Bool
|
||
let maxValue: Int64?
|
||
let placeholderText: String
|
||
let textFieldOffset: CGPoint
|
||
let labelText: String?
|
||
let currency: CurrencyAmount.Currency
|
||
let dateTimeFormat: PresentationDateTimeFormat
|
||
let amountUpdated: (Int64?) -> Void
|
||
let tag: AnyObject?
|
||
|
||
public init(
|
||
textColor: UIColor,
|
||
secondaryColor: UIColor,
|
||
placeholderColor: UIColor,
|
||
accentColor: UIColor,
|
||
value: Int64?,
|
||
minValue: Int64?,
|
||
forceMinValue: Bool,
|
||
allowZero: Bool,
|
||
maxValue: Int64?,
|
||
placeholderText: String,
|
||
textFieldOffset: CGPoint = .zero,
|
||
labelText: String?,
|
||
currency: CurrencyAmount.Currency,
|
||
dateTimeFormat: PresentationDateTimeFormat,
|
||
amountUpdated: @escaping (Int64?) -> Void,
|
||
tag: AnyObject? = nil
|
||
) {
|
||
self.textColor = textColor
|
||
self.secondaryColor = secondaryColor
|
||
self.placeholderColor = placeholderColor
|
||
self.accentColor = accentColor
|
||
self.value = value
|
||
self.minValue = minValue
|
||
self.forceMinValue = forceMinValue
|
||
self.allowZero = allowZero
|
||
self.maxValue = maxValue
|
||
self.placeholderText = placeholderText
|
||
self.textFieldOffset = textFieldOffset
|
||
self.labelText = labelText
|
||
self.currency = currency
|
||
self.dateTimeFormat = dateTimeFormat
|
||
self.amountUpdated = amountUpdated
|
||
self.tag = tag
|
||
}
|
||
|
||
public static func ==(lhs: AmountFieldComponent, rhs: AmountFieldComponent) -> Bool {
|
||
if lhs.textColor != rhs.textColor {
|
||
return false
|
||
}
|
||
if lhs.secondaryColor != rhs.secondaryColor {
|
||
return false
|
||
}
|
||
if lhs.placeholderColor != rhs.placeholderColor {
|
||
return false
|
||
}
|
||
if lhs.accentColor != rhs.accentColor {
|
||
return false
|
||
}
|
||
if lhs.value != rhs.value {
|
||
return false
|
||
}
|
||
if lhs.minValue != rhs.minValue {
|
||
return false
|
||
}
|
||
if lhs.allowZero != rhs.allowZero {
|
||
return false
|
||
}
|
||
if lhs.maxValue != rhs.maxValue {
|
||
return false
|
||
}
|
||
if lhs.placeholderText != rhs.placeholderText {
|
||
return false
|
||
}
|
||
if lhs.labelText != rhs.labelText {
|
||
return false
|
||
}
|
||
if lhs.currency != rhs.currency {
|
||
return false
|
||
}
|
||
return true
|
||
}
|
||
|
||
public final class View: UIView, ListSectionComponent.ChildView, UITextFieldDelegate, ComponentTaggedView {
|
||
public func matches(tag: Any) -> Bool {
|
||
if let component = self.component, let componentTag = component.tag {
|
||
let tag = tag as AnyObject
|
||
if componentTag === tag {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
private let placeholderView: ComponentView<Empty>
|
||
private let icon = ComponentView<Empty>()
|
||
private let textField: TextFieldNodeView
|
||
private var starsFormatter: AmountFieldStarsFormatter?
|
||
private var tonFormatter: AmountFieldStarsFormatter?
|
||
private let labelView: ComponentView<Empty>
|
||
|
||
private var component: AmountFieldComponent?
|
||
private weak var state: EmptyComponentState?
|
||
private var isUpdating: Bool = false
|
||
|
||
private var didSetValueOnce = false
|
||
|
||
public var customUpdateIsHighlighted: ((Bool) -> Void)?
|
||
public var enumerateSiblings: (((UIView) -> Void) -> Void)?
|
||
public let separatorInset: CGFloat = 16.0
|
||
|
||
public override init(frame: CGRect) {
|
||
self.placeholderView = ComponentView<Empty>()
|
||
self.textField = TextFieldNodeView(frame: .zero)
|
||
self.labelView = ComponentView<Empty>()
|
||
|
||
super.init(frame: frame)
|
||
|
||
self.addSubview(self.textField)
|
||
}
|
||
|
||
required init?(coder: NSCoder) {
|
||
fatalError("init(coder:) has not been implemented")
|
||
}
|
||
|
||
public func activateInput() {
|
||
self.textField.becomeFirstResponder()
|
||
}
|
||
|
||
public func deactivateInput() {
|
||
self.textField.resignFirstResponder()
|
||
}
|
||
|
||
public func selectAll() {
|
||
self.textField.selectAll(nil)
|
||
}
|
||
|
||
public func animateError() {
|
||
self.textField.layer.addShakeAnimation()
|
||
let hapticFeedback = HapticFeedback()
|
||
hapticFeedback.error()
|
||
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 1.0, execute: {
|
||
let _ = hapticFeedback
|
||
})
|
||
}
|
||
|
||
public func resetValue() {
|
||
guard let component = self.component, let value = component.value else {
|
||
return
|
||
}
|
||
var text = ""
|
||
switch component.currency {
|
||
case .stars:
|
||
text = "\(value)"
|
||
case .ton:
|
||
text = "\(formatTonAmountText(value, dateTimeFormat: PresentationDateTimeFormat(timeFormat: component.dateTimeFormat.timeFormat, dateFormat: component.dateTimeFormat.dateFormat, dateSeparator: "", dateSuffix: "", requiresFullYear: false, decimalSeparator: ".", groupingSeparator: ""), maxDecimalPositions: nil))"
|
||
}
|
||
self.textField.text = text
|
||
self.placeholderView.view?.isHidden = !(self.textField.text ?? "").isEmpty
|
||
}
|
||
|
||
func update(component: AmountFieldComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<EnvironmentType>, transition: ComponentTransition) -> CGSize {
|
||
self.isUpdating = true
|
||
defer {
|
||
self.isUpdating = false
|
||
}
|
||
|
||
self.textField.textColor = component.textColor
|
||
if self.component?.currency != component.currency || ((self.textField.text ?? "").isEmpty && !self.didSetValueOnce) {
|
||
if let value = component.value, value != .zero {
|
||
var text = ""
|
||
switch component.currency {
|
||
case .stars:
|
||
text = "\(value)"
|
||
case .ton:
|
||
text = "\(formatTonAmountText(value, dateTimeFormat: PresentationDateTimeFormat(timeFormat: component.dateTimeFormat.timeFormat, dateFormat: component.dateTimeFormat.dateFormat, dateSeparator: "", dateSuffix: "", requiresFullYear: false, decimalSeparator: ".", groupingSeparator: ""), maxDecimalPositions: nil))"
|
||
}
|
||
self.textField.text = text
|
||
self.placeholderView.view?.isHidden = !text.isEmpty
|
||
} else {
|
||
self.textField.text = ""
|
||
}
|
||
self.didSetValueOnce = true
|
||
}
|
||
self.textField.font = Font.regular(17.0)
|
||
|
||
self.textField.returnKeyType = .done
|
||
self.textField.autocorrectionType = .no
|
||
self.textField.autocapitalizationType = .none
|
||
|
||
if self.component?.currency != component.currency {
|
||
switch component.currency {
|
||
case .stars:
|
||
self.textField.delegate = self
|
||
self.textField.keyboardType = .numberPad
|
||
if self.starsFormatter == nil {
|
||
self.starsFormatter = AmountFieldStarsFormatter(
|
||
textField: self.textField,
|
||
currency: component.currency,
|
||
dateTimeFormat: component.dateTimeFormat,
|
||
minValue: component.minValue ?? 0,
|
||
forceMinValue: component.forceMinValue,
|
||
allowZero: component.allowZero,
|
||
maxValue: component.maxValue ?? Int64.max,
|
||
updated: { [weak self] value in
|
||
guard let self, let component = self.component else {
|
||
return
|
||
}
|
||
if !self.isUpdating {
|
||
component.amountUpdated(value == 0 ? nil : value)
|
||
}
|
||
},
|
||
isEmptyUpdated: { [weak self] isEmpty in
|
||
guard let self else {
|
||
return
|
||
}
|
||
self.placeholderView.view?.isHidden = !isEmpty
|
||
},
|
||
animateError: { [weak self] in
|
||
guard let self else {
|
||
return
|
||
}
|
||
self.animateError()
|
||
},
|
||
focusUpdated: { _ in
|
||
}
|
||
)
|
||
}
|
||
self.tonFormatter = nil
|
||
self.textField.delegate = self.starsFormatter
|
||
case .ton:
|
||
self.textField.keyboardType = .decimalPad
|
||
if self.tonFormatter == nil {
|
||
self.tonFormatter = AmountFieldStarsFormatter(
|
||
textField: self.textField,
|
||
currency: component.currency,
|
||
dateTimeFormat: component.dateTimeFormat,
|
||
minValue: component.minValue ?? 0,
|
||
forceMinValue: component.forceMinValue,
|
||
allowZero: component.allowZero,
|
||
maxValue: component.maxValue ?? Int64.max,
|
||
updated: { [weak self] value in
|
||
guard let self, let component = self.component else {
|
||
return
|
||
}
|
||
if !self.isUpdating {
|
||
component.amountUpdated(value == 0 ? nil : value)
|
||
}
|
||
},
|
||
isEmptyUpdated: { [weak self] isEmpty in
|
||
guard let self else {
|
||
return
|
||
}
|
||
self.placeholderView.view?.isHidden = !isEmpty
|
||
},
|
||
animateError: { [weak self] in
|
||
guard let self else {
|
||
return
|
||
}
|
||
self.animateError()
|
||
},
|
||
focusUpdated: { _ in
|
||
}
|
||
)
|
||
}
|
||
self.starsFormatter = nil
|
||
self.textField.delegate = self.tonFormatter
|
||
}
|
||
self.textField.reloadInputViews()
|
||
}
|
||
|
||
self.component = component
|
||
self.state = state
|
||
|
||
let size = CGSize(width: availableSize.width, height: 52.0)
|
||
|
||
let sideInset: CGFloat = 16.0
|
||
var leftInset: CGFloat = 16.0
|
||
|
||
let iconName: String
|
||
var iconTintColor: UIColor?
|
||
let iconMaxSize: CGSize?
|
||
var iconOffset = CGPoint()
|
||
switch component.currency {
|
||
case .stars:
|
||
iconName = "Premium/Stars/StarLarge"
|
||
iconMaxSize = CGSize(width: 22.0, height: 22.0)
|
||
case .ton:
|
||
iconName = "Ads/TonBig"
|
||
iconTintColor = component.accentColor
|
||
iconMaxSize = CGSize(width: 18.0, height: 18.0)
|
||
iconOffset = CGPoint(x: 3.0, y: 1.0)
|
||
}
|
||
let iconSize = self.icon.update(
|
||
transition: .immediate,
|
||
component: AnyComponent(BundleIconComponent(
|
||
name: iconName,
|
||
tintColor: iconTintColor,
|
||
maxSize: iconMaxSize
|
||
)),
|
||
environment: {},
|
||
containerSize: CGSize(width: 100.0, height: 100.0)
|
||
)
|
||
|
||
if let iconView = self.icon.view {
|
||
if iconView.superview == nil {
|
||
self.addSubview(iconView)
|
||
}
|
||
iconView.frame = CGRect(origin: CGPoint(x: iconOffset.x + 15.0, y: iconOffset.y - 1.0 + floorToScreenPixels((size.height - iconSize.height) / 2.0)), size: iconSize)
|
||
}
|
||
|
||
leftInset += 24.0 + 6.0
|
||
|
||
let placeholderSize = self.placeholderView.update(
|
||
transition: .easeInOut(duration: 0.2),
|
||
component: AnyComponent(
|
||
Text(
|
||
text: component.placeholderText,
|
||
font: Font.regular(17.0),
|
||
color: component.placeholderColor
|
||
)
|
||
),
|
||
environment: {},
|
||
containerSize: availableSize
|
||
)
|
||
|
||
if let placeholderComponentView = self.placeholderView.view {
|
||
if placeholderComponentView.superview == nil {
|
||
self.insertSubview(placeholderComponentView, at: 0)
|
||
}
|
||
|
||
placeholderComponentView.frame = CGRect(origin: CGPoint(x: leftInset, y: -1.0 + floorToScreenPixels((size.height - placeholderSize.height) / 2.0) + 1.0 - UIScreenPixel), size: placeholderSize)
|
||
placeholderComponentView.isHidden = !(self.textField.text ?? "").isEmpty
|
||
}
|
||
|
||
if let labelText = component.labelText {
|
||
let labelSize = self.labelView.update(
|
||
transition: .immediate,
|
||
component: AnyComponent(
|
||
Text(
|
||
text: labelText,
|
||
font: Font.regular(17.0),
|
||
color: component.secondaryColor
|
||
)
|
||
),
|
||
environment: {},
|
||
containerSize: availableSize
|
||
)
|
||
|
||
if let labelView = self.labelView.view {
|
||
if labelView.superview == nil {
|
||
self.insertSubview(labelView, at: 0)
|
||
}
|
||
|
||
labelView.frame = CGRect(origin: CGPoint(x: size.width - sideInset - labelSize.width, y: floorToScreenPixels((size.height - labelSize.height) / 2.0) + 1.0 - UIScreenPixel), size: labelSize)
|
||
}
|
||
} else if let labelView = self.labelView.view, labelView.superview != nil {
|
||
labelView.removeFromSuperview()
|
||
}
|
||
|
||
self.textField.frame = CGRect(x: leftInset + component.textFieldOffset.x, y: 4.0 + component.textFieldOffset.y, width: size.width - 30.0, height: 44.0)
|
||
|
||
return size
|
||
}
|
||
}
|
||
|
||
public func makeView() -> View {
|
||
return View(frame: CGRect())
|
||
}
|
||
|
||
public func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<EnvironmentType>, transition: ComponentTransition) -> CGSize {
|
||
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
|
||
}
|
||
}
|
||
|
||
private final class ResultCellComponent: Component {
|
||
let context: AccountContext
|
||
let theme: PresentationTheme
|
||
let files: [TelegramMediaFile]?
|
||
let value: Int32
|
||
let size: CGSize
|
||
|
||
init(
|
||
context: AccountContext,
|
||
theme: PresentationTheme,
|
||
files: [TelegramMediaFile]?,
|
||
value: Int32,
|
||
size: CGSize
|
||
) {
|
||
self.context = context
|
||
self.theme = theme
|
||
self.files = files
|
||
self.value = value
|
||
self.size = size
|
||
}
|
||
|
||
static func ==(lhs: ResultCellComponent, rhs: ResultCellComponent) -> Bool {
|
||
if lhs.context !== rhs.context {
|
||
return false
|
||
}
|
||
if lhs.theme !== rhs.theme {
|
||
return false
|
||
}
|
||
if lhs.files != rhs.files {
|
||
return false
|
||
}
|
||
if lhs.value != rhs.value {
|
||
return false
|
||
}
|
||
if lhs.size != rhs.size {
|
||
return false
|
||
}
|
||
return true
|
||
}
|
||
|
||
final class View: UIView {
|
||
private var component: ResultCellComponent?
|
||
|
||
private let background = ComponentView<Empty>()
|
||
private let emoji = ComponentView<Empty>()
|
||
private let title = ComponentView<Empty>()
|
||
|
||
override init(frame: CGRect) {
|
||
|
||
super.init(frame: frame)
|
||
}
|
||
|
||
required init?(coder: NSCoder) {
|
||
fatalError("init(coder:) has not been implemented")
|
||
}
|
||
|
||
func update(component: ResultCellComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
|
||
self.component = component
|
||
|
||
let size = component.size
|
||
|
||
let backgroundSize = self.background.update(
|
||
transition: transition,
|
||
component: AnyComponent(FilledRoundedRectangleComponent(color: component.theme.list.itemBlocksBackgroundColor, cornerRadius: .value(22.0), smoothCorners: true)),
|
||
environment: {},
|
||
containerSize: size
|
||
)
|
||
let backgroundFrame = CGRect(origin: .zero, size: backgroundSize)
|
||
if let backgroundView = self.background.view {
|
||
if backgroundView.superview == nil {
|
||
self.addSubview(backgroundView)
|
||
}
|
||
transition.setFrame(view: backgroundView, frame: backgroundFrame)
|
||
}
|
||
|
||
let value = Double(component.value) / 1000.0
|
||
let titleString = String(format: "%0.1f", value).replacingOccurrences(of: ".0", with: "").replacingOccurrences(of: ",0", with: "")
|
||
|
||
var items: [AnyComponentWithIdentity<Empty>] = []
|
||
if let files = component.files {
|
||
for file in files {
|
||
items.append(AnyComponentWithIdentity(id: items.count, component: AnyComponent(
|
||
LottieComponent(
|
||
content: LottieComponent.ResourceContent(
|
||
context: component.context,
|
||
file: file,
|
||
attemptSynchronously: true,
|
||
providesPlaceholder: true
|
||
),
|
||
placeholderColor: component.theme.list.mediaPlaceholderColor,
|
||
startingPosition: .end,
|
||
size: CGSize(width: 50.0, height: 50.0),
|
||
loop: false
|
||
)
|
||
)))
|
||
}
|
||
}
|
||
let emojiSize = self.emoji.update(
|
||
transition: transition,
|
||
component: AnyComponent(
|
||
HStack(items, spacing: -18.0)
|
||
),
|
||
environment: {},
|
||
containerSize: availableSize
|
||
)
|
||
let emojiFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((size.width - emojiSize.width) / 2.0), y: -9.0), size: emojiSize)
|
||
if let emojiView = self.emoji.view {
|
||
if emojiView.superview == nil {
|
||
self.addSubview(emojiView)
|
||
}
|
||
transition.setFrame(view: emojiView, frame: emojiFrame)
|
||
}
|
||
|
||
let titleSize = self.title.update(
|
||
transition: transition,
|
||
component: AnyComponent(
|
||
MultilineTextComponent(
|
||
text: .plain(NSAttributedString(string: "×\(titleString)", font: Font.semibold(11.0), textColor: component.theme.list.itemPrimaryTextColor)),
|
||
horizontalAlignment: .center,
|
||
maximumNumberOfLines: 1
|
||
)
|
||
),
|
||
environment: {},
|
||
containerSize: availableSize
|
||
)
|
||
let titleFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((size.width - titleSize.width) / 2.0), y: size.height - titleSize.height - 10.0), size: titleSize)
|
||
if let titleView = self.title.view {
|
||
if titleView.superview == nil {
|
||
self.addSubview(titleView)
|
||
}
|
||
transition.setFrame(view: titleView, frame: titleFrame)
|
||
}
|
||
|
||
return size
|
||
}
|
||
}
|
||
|
||
func makeView() -> View {
|
||
return View(frame: CGRect())
|
||
}
|
||
|
||
func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
|
||
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
|
||
}
|
||
}
|
||
|
||
private final class AmountPresetsListItemComponent: Component {
|
||
let context: AccountContext
|
||
let theme: PresentationTheme
|
||
let values: [Int64]
|
||
let valueSelected: (Int64) -> Void
|
||
|
||
init(
|
||
context: AccountContext,
|
||
theme: PresentationTheme,
|
||
values: [Int64],
|
||
valueSelected: @escaping (Int64) -> Void
|
||
) {
|
||
self.context = context
|
||
self.theme = theme
|
||
self.values = values
|
||
self.valueSelected = valueSelected
|
||
}
|
||
|
||
static func ==(lhs: AmountPresetsListItemComponent, rhs: AmountPresetsListItemComponent) -> Bool {
|
||
if lhs.context !== rhs.context {
|
||
return false
|
||
}
|
||
if lhs.theme !== rhs.theme {
|
||
return false
|
||
}
|
||
if lhs.values != rhs.values {
|
||
return false
|
||
}
|
||
return true
|
||
}
|
||
|
||
final class View: UIView {
|
||
private var component: AmountPresetsListItemComponent?
|
||
|
||
private var itemViews: [Int64: ComponentView<Empty>] = [:]
|
||
|
||
override init(frame: CGRect) {
|
||
super.init(frame: frame)
|
||
}
|
||
|
||
required init?(coder: NSCoder) {
|
||
fatalError("init(coder:) has not been implemented")
|
||
}
|
||
|
||
func update(component: AmountPresetsListItemComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
|
||
self.component = component
|
||
|
||
|
||
let sideInset: CGFloat = 16.0
|
||
let spacing: CGFloat = 6.0
|
||
let itemSize = CGSize(width: floorToScreenPixels((availableSize.width - sideInset * 2.0 - spacing * 2.0) / 3.0), height: 28.0)
|
||
|
||
var itemOrigin = CGPoint(x: sideInset, y: sideInset)
|
||
for value in component.values {
|
||
let itemView: ComponentView<Empty>
|
||
if let current = self.itemViews[value] {
|
||
itemView = current
|
||
} else {
|
||
itemView = ComponentView()
|
||
self.itemViews[value] = itemView
|
||
}
|
||
let _ = itemView.update(
|
||
transition: .immediate,
|
||
component: AnyComponent(PlainButtonComponent(
|
||
content: AnyComponent(AmountPresetComponent(
|
||
context: component.context,
|
||
theme: component.theme,
|
||
value: value
|
||
)),
|
||
action: {
|
||
component.valueSelected(value)
|
||
},
|
||
animateScale: false
|
||
)),
|
||
environment: {},
|
||
containerSize: itemSize
|
||
)
|
||
var itemFrame = CGRect(origin: itemOrigin, size: itemSize)
|
||
if itemFrame.maxX > availableSize.width {
|
||
itemOrigin = CGPoint(x: sideInset, y: itemOrigin.y + itemSize.height + spacing)
|
||
itemFrame.origin = itemOrigin
|
||
}
|
||
if let itemView = itemView.view {
|
||
if itemView.superview == nil {
|
||
self.addSubview(itemView)
|
||
}
|
||
transition.setFrame(view: itemView, frame: itemFrame)
|
||
}
|
||
itemOrigin.x += itemSize.width + spacing
|
||
}
|
||
|
||
let size = CGSize(width: availableSize.width, height: itemOrigin.y + itemSize.height + sideInset)
|
||
return size
|
||
}
|
||
}
|
||
|
||
func makeView() -> View {
|
||
return View(frame: CGRect())
|
||
}
|
||
|
||
func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
|
||
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
|
||
}
|
||
}
|
||
|
||
private final class AmountPresetComponent: Component {
|
||
let context: AccountContext
|
||
let theme: PresentationTheme
|
||
let value: Int64
|
||
|
||
init(
|
||
context: AccountContext,
|
||
theme: PresentationTheme,
|
||
value: Int64
|
||
) {
|
||
self.context = context
|
||
self.theme = theme
|
||
self.value = value
|
||
}
|
||
|
||
static func ==(lhs: AmountPresetComponent, rhs: AmountPresetComponent) -> Bool {
|
||
if lhs.context !== rhs.context {
|
||
return false
|
||
}
|
||
if lhs.theme !== rhs.theme {
|
||
return false
|
||
}
|
||
if lhs.value != rhs.value {
|
||
return false
|
||
}
|
||
return true
|
||
}
|
||
|
||
final class View: UIView {
|
||
private var component: AmountPresetComponent?
|
||
|
||
private let background = ComponentView<Empty>()
|
||
private let title = ComponentView<Empty>()
|
||
|
||
override init(frame: CGRect) {
|
||
super.init(frame: frame)
|
||
}
|
||
|
||
required init?(coder: NSCoder) {
|
||
fatalError("init(coder:) has not been implemented")
|
||
}
|
||
|
||
func update(component: AmountPresetComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
|
||
self.component = component
|
||
|
||
let size = availableSize
|
||
|
||
let backgroundSize = self.background.update(
|
||
transition: transition,
|
||
component: AnyComponent(FilledRoundedRectangleComponent(color: component.theme.list.itemAccentColor.withMultipliedAlpha(0.1), cornerRadius: .minEdge, smoothCorners: false)),
|
||
environment: {},
|
||
containerSize: size
|
||
)
|
||
let backgroundFrame = CGRect(origin: .zero, size: backgroundSize)
|
||
if let backgroundView = self.background.view {
|
||
if backgroundView.superview == nil {
|
||
self.addSubview(backgroundView)
|
||
}
|
||
transition.setFrame(view: backgroundView, frame: backgroundFrame)
|
||
}
|
||
|
||
let presentationData = component.context.sharedContext.currentPresentationData.with { $0 }
|
||
|
||
let attributedText = NSMutableAttributedString(string: "$ \(formatTonAmountText(component.value, dateTimeFormat: presentationData.dateTimeFormat))", font: Font.semibold(14.0), textColor: component.theme.list.itemAccentColor)
|
||
if let range = attributedText.string.range(of: "$") {
|
||
attributedText.addAttribute(ChatTextInputAttributes.customEmoji, value: ChatTextInputTextCustomEmojiAttribute(interactivelySelectedFromPackId: nil, fileId: 0, file: nil, custom: .ton(tinted: true)), range: NSRange(range, in: attributedText.string))
|
||
attributedText.addAttribute(.baselineOffset, value: 1.0, range: NSRange(range, in: attributedText.string))
|
||
}
|
||
|
||
let titleSize = self.title.update(
|
||
transition: transition,
|
||
component: AnyComponent(
|
||
MultilineTextWithEntitiesComponent(
|
||
context: component.context,
|
||
animationCache: component.context.animationCache,
|
||
animationRenderer: component.context.animationRenderer,
|
||
placeholderColor: .clear,
|
||
text: .plain(attributedText)
|
||
)
|
||
),
|
||
environment: {},
|
||
containerSize: availableSize
|
||
)
|
||
let titleFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((size.width - titleSize.width) / 2.0), y: floorToScreenPixels((size.height - titleSize.height) / 2.0)), size: titleSize)
|
||
if let titleView = self.title.view {
|
||
if titleView.superview == nil {
|
||
self.addSubview(titleView)
|
||
}
|
||
transition.setFrame(view: titleView, frame: titleFrame)
|
||
}
|
||
|
||
return size
|
||
}
|
||
}
|
||
|
||
func makeView() -> View {
|
||
return View(frame: CGRect())
|
||
}
|
||
|
||
func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
|
||
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
|
||
}
|
||
}
|
||
|
||
private struct EmojiGameStakeConfiguration {
|
||
static var defaultValue: EmojiGameStakeConfiguration {
|
||
return EmojiGameStakeConfiguration(
|
||
tonUsdRate: nil,
|
||
minStakeAmount: nil,
|
||
maxStakeAmount: nil,
|
||
suggestedAmounts: [
|
||
100000000,
|
||
1000000000,
|
||
2000000000,
|
||
5000000000,
|
||
10000000000,
|
||
20000000000
|
||
]
|
||
)
|
||
}
|
||
|
||
let tonUsdRate: Double?
|
||
let minStakeAmount: Int64?
|
||
let maxStakeAmount: Int64?
|
||
let suggestedAmounts: [Int64]
|
||
|
||
fileprivate init(
|
||
tonUsdRate: Double?,
|
||
minStakeAmount: Int64?,
|
||
maxStakeAmount: Int64?,
|
||
suggestedAmounts: [Int64]
|
||
) {
|
||
self.tonUsdRate = tonUsdRate
|
||
self.minStakeAmount = minStakeAmount
|
||
self.maxStakeAmount = maxStakeAmount
|
||
self.suggestedAmounts = suggestedAmounts
|
||
}
|
||
|
||
static func with(appConfiguration: AppConfiguration) -> EmojiGameStakeConfiguration {
|
||
if let data = appConfiguration.data {
|
||
var tonUsdRate: Double?
|
||
if let value = data["ton_usd_rate"] as? Double {
|
||
tonUsdRate = value
|
||
}
|
||
|
||
var minStakeAmount: Int64?
|
||
if let value = data["ton_stakedice_stake_amount_min"] as? Double {
|
||
minStakeAmount = Int64(value)
|
||
}
|
||
|
||
var maxStakeAmount: Int64?
|
||
if let value = data["ton_stakedice_stake_amount_max"] as? Double {
|
||
maxStakeAmount = Int64(value)
|
||
}
|
||
|
||
var suggestedAmounts: [Int64] = []
|
||
if let value = data["ton_stakedice_stake_suggested_amounts"] as? [Double] {
|
||
suggestedAmounts = value.map { Int64($0) }
|
||
} else {
|
||
suggestedAmounts = EmojiGameStakeConfiguration.defaultValue.suggestedAmounts
|
||
}
|
||
|
||
return EmojiGameStakeConfiguration(
|
||
tonUsdRate: tonUsdRate,
|
||
minStakeAmount: minStakeAmount,
|
||
maxStakeAmount: maxStakeAmount,
|
||
suggestedAmounts: suggestedAmounts
|
||
)
|
||
} else {
|
||
return .defaultValue
|
||
}
|
||
}
|
||
}
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
public final class ResizableSheetComponentEnvironment: Equatable {
|
||
public let theme: PresentationTheme
|
||
public let statusBarHeight: CGFloat
|
||
public let safeInsets: UIEdgeInsets
|
||
public let metrics: LayoutMetrics
|
||
public let deviceMetrics: DeviceMetrics
|
||
public let isDisplaying: Bool
|
||
public let isCentered: Bool
|
||
public let regularMetricsSize: CGSize?
|
||
public let dismiss: (Bool) -> Void
|
||
|
||
public init(
|
||
theme: PresentationTheme,
|
||
statusBarHeight: CGFloat,
|
||
safeInsets: UIEdgeInsets,
|
||
metrics: LayoutMetrics,
|
||
deviceMetrics: DeviceMetrics,
|
||
isDisplaying: Bool,
|
||
isCentered: Bool,
|
||
regularMetricsSize: CGSize?,
|
||
dismiss: @escaping (Bool) -> Void
|
||
) {
|
||
self.theme = theme
|
||
self.statusBarHeight = statusBarHeight
|
||
self.safeInsets = safeInsets
|
||
self.metrics = metrics
|
||
self.deviceMetrics = deviceMetrics
|
||
self.isDisplaying = isDisplaying
|
||
self.isCentered = isCentered
|
||
self.regularMetricsSize = regularMetricsSize
|
||
self.dismiss = dismiss
|
||
}
|
||
|
||
public static func ==(lhs: ResizableSheetComponentEnvironment, rhs: ResizableSheetComponentEnvironment) -> Bool {
|
||
if lhs.theme != rhs.theme {
|
||
return false
|
||
}
|
||
if lhs.statusBarHeight != rhs.statusBarHeight {
|
||
return false
|
||
}
|
||
if lhs.safeInsets != rhs.safeInsets {
|
||
return false
|
||
}
|
||
if lhs.metrics != rhs.metrics {
|
||
return false
|
||
}
|
||
if lhs.deviceMetrics != rhs.deviceMetrics {
|
||
return false
|
||
}
|
||
if lhs.isDisplaying != rhs.isDisplaying {
|
||
return false
|
||
}
|
||
if lhs.isCentered != rhs.isCentered {
|
||
return false
|
||
}
|
||
if lhs.regularMetricsSize != rhs.regularMetricsSize {
|
||
return false
|
||
}
|
||
return true
|
||
}
|
||
}
|
||
|
||
public final class ResizableSheetComponent<ChildEnvironmentType: Sendable & Equatable>: Component {
|
||
public typealias EnvironmentType = (ChildEnvironmentType, ResizableSheetComponentEnvironment)
|
||
|
||
public class ExternalState {
|
||
public fileprivate(set) var contentHeight: CGFloat
|
||
|
||
public init() {
|
||
self.contentHeight = 0.0
|
||
}
|
||
}
|
||
|
||
public enum BackgroundColor: Equatable {
|
||
case color(UIColor)
|
||
}
|
||
|
||
public let content: AnyComponent<ChildEnvironmentType>
|
||
public let titleItem: AnyComponent<Empty>?
|
||
public let leftItem: AnyComponent<Empty>?
|
||
public let rightItem: AnyComponent<Empty>?
|
||
public let bottomItem: AnyComponent<Empty>?
|
||
public let backgroundColor: BackgroundColor
|
||
public let externalState: ExternalState?
|
||
public let animateOut: ActionSlot<Action<()>>
|
||
|
||
public init(
|
||
content: AnyComponent<ChildEnvironmentType>,
|
||
titleItem: AnyComponent<Empty>? = nil,
|
||
leftItem: AnyComponent<Empty>? = nil,
|
||
rightItem: AnyComponent<Empty>? = nil,
|
||
bottomItem: AnyComponent<Empty>? = nil,
|
||
backgroundColor: BackgroundColor,
|
||
externalState: ExternalState? = nil,
|
||
animateOut: ActionSlot<Action<()>>,
|
||
) {
|
||
self.content = content
|
||
self.titleItem = titleItem
|
||
self.leftItem = leftItem
|
||
self.rightItem = rightItem
|
||
self.bottomItem = bottomItem
|
||
self.backgroundColor = backgroundColor
|
||
self.externalState = externalState
|
||
self.animateOut = animateOut
|
||
}
|
||
|
||
public static func ==(lhs: ResizableSheetComponent, rhs: ResizableSheetComponent) -> Bool {
|
||
if lhs.content != rhs.content {
|
||
return false
|
||
}
|
||
if lhs.titleItem != rhs.titleItem {
|
||
return false
|
||
}
|
||
if lhs.leftItem != rhs.leftItem {
|
||
return false
|
||
}
|
||
if lhs.rightItem != rhs.rightItem {
|
||
return false
|
||
}
|
||
if lhs.bottomItem != rhs.bottomItem {
|
||
return false
|
||
}
|
||
if lhs.backgroundColor != rhs.backgroundColor {
|
||
return false
|
||
}
|
||
if lhs.animateOut != rhs.animateOut {
|
||
return false
|
||
}
|
||
return true
|
||
}
|
||
|
||
private struct ItemLayout: Equatable {
|
||
var containerSize: CGSize
|
||
var containerInset: CGFloat
|
||
var containerCornerRadius: CGFloat
|
||
var bottomInset: CGFloat
|
||
var topInset: CGFloat
|
||
|
||
init(containerSize: CGSize, containerInset: CGFloat, containerCornerRadius: CGFloat, bottomInset: CGFloat, topInset: CGFloat) {
|
||
self.containerSize = containerSize
|
||
self.containerInset = containerInset
|
||
self.containerCornerRadius = containerCornerRadius
|
||
self.bottomInset = bottomInset
|
||
self.topInset = topInset
|
||
}
|
||
}
|
||
|
||
private final class ScrollView: UIScrollView {
|
||
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
|
||
return super.hitTest(point, with: event)
|
||
}
|
||
}
|
||
|
||
public final class View: UIView, UIScrollViewDelegate, ComponentTaggedView {
|
||
public final class Tag {
|
||
public init() {
|
||
}
|
||
}
|
||
|
||
public func matches(tag: Any) -> Bool {
|
||
if let _ = tag as? Tag {
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
private let dimView: UIView
|
||
private let containerView: UIView
|
||
private let backgroundLayer: SimpleLayer
|
||
private let navigationBarContainer: SparseContainerView
|
||
private let scrollView: ScrollView
|
||
private let scrollContentClippingView: SparseContainerView
|
||
private let scrollContentView: UIView
|
||
|
||
private let topEdgeEffectView: EdgeEffectView
|
||
private let contentView: ComponentView<ChildEnvironmentType>
|
||
|
||
private var titleItemView: ComponentView<Empty>?
|
||
private var leftItemView: ComponentView<Empty>?
|
||
private var rightItemView: ComponentView<Empty>?
|
||
private var bottomItemView: ComponentView<Empty>?
|
||
|
||
private let backgroundHandleView: UIImageView
|
||
|
||
private var ignoreScrolling: Bool = false
|
||
|
||
private var component: ResizableSheetComponent?
|
||
private weak var state: EmptyComponentState?
|
||
private var isUpdating: Bool = false
|
||
private var environment: ResizableSheetComponentEnvironment?
|
||
private var itemLayout: ItemLayout?
|
||
|
||
override init(frame: CGRect) {
|
||
self.dimView = UIView()
|
||
self.containerView = UIView()
|
||
|
||
self.containerView.clipsToBounds = true
|
||
self.containerView.layer.cornerRadius = 40.0
|
||
self.containerView.layer.maskedCorners = [.layerMinXMaxYCorner, .layerMaxXMaxYCorner]
|
||
|
||
self.backgroundLayer = SimpleLayer()
|
||
self.backgroundLayer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
|
||
self.backgroundLayer.cornerRadius = 40.0
|
||
|
||
self.backgroundHandleView = UIImageView()
|
||
|
||
self.navigationBarContainer = SparseContainerView()
|
||
|
||
self.scrollView = ScrollView()
|
||
|
||
self.scrollContentClippingView = SparseContainerView()
|
||
self.scrollContentClippingView.clipsToBounds = true
|
||
|
||
self.scrollContentView = UIView()
|
||
|
||
self.topEdgeEffectView = EdgeEffectView()
|
||
self.topEdgeEffectView.clipsToBounds = true
|
||
self.topEdgeEffectView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
|
||
self.topEdgeEffectView.layer.cornerRadius = 40.0
|
||
|
||
self.contentView = ComponentView()
|
||
|
||
super.init(frame: frame)
|
||
|
||
self.addSubview(self.dimView)
|
||
self.addSubview(self.containerView)
|
||
self.containerView.layer.addSublayer(self.backgroundLayer)
|
||
|
||
self.scrollView.delaysContentTouches = true
|
||
self.scrollView.canCancelContentTouches = true
|
||
self.scrollView.clipsToBounds = false
|
||
self.scrollView.contentInsetAdjustmentBehavior = .never
|
||
self.scrollView.automaticallyAdjustsScrollIndicatorInsets = false
|
||
self.scrollView.showsVerticalScrollIndicator = false
|
||
self.scrollView.showsHorizontalScrollIndicator = false
|
||
self.scrollView.alwaysBounceHorizontal = false
|
||
self.scrollView.alwaysBounceVertical = true
|
||
self.scrollView.scrollsToTop = false
|
||
self.scrollView.delegate = self
|
||
self.scrollView.clipsToBounds = true
|
||
|
||
self.containerView.addSubview(self.scrollContentClippingView)
|
||
self.scrollContentClippingView.addSubview(self.scrollView)
|
||
|
||
self.scrollView.addSubview(self.scrollContentView)
|
||
|
||
self.containerView.addSubview(self.navigationBarContainer)
|
||
|
||
self.dimView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.dimTapGesture(_:))))
|
||
}
|
||
|
||
required init?(coder: NSCoder) {
|
||
fatalError("init(coder:) has not been implemented")
|
||
}
|
||
|
||
public func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
||
if !self.ignoreScrolling {
|
||
self.updateScrolling(transition: .immediate)
|
||
}
|
||
}
|
||
|
||
public override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
|
||
if !self.bounds.contains(point) {
|
||
return nil
|
||
}
|
||
if !self.backgroundLayer.frame.contains(point) {
|
||
return self.dimView
|
||
}
|
||
|
||
if let result = self.navigationBarContainer.hitTest(self.convert(point, to: self.navigationBarContainer), with: event) {
|
||
return result
|
||
}
|
||
let result = super.hitTest(point, with: event)
|
||
return result
|
||
}
|
||
|
||
@objc private func dimTapGesture(_ recognizer: UITapGestureRecognizer) {
|
||
if case .ended = recognizer.state {
|
||
self.dismissAnimated()
|
||
}
|
||
}
|
||
|
||
func dismissAnimated() {
|
||
guard let environment = self.environment else {
|
||
return
|
||
}
|
||
self.endEditing(true)
|
||
environment.dismiss(true)
|
||
}
|
||
|
||
private func updateScrolling(transition: ComponentTransition) {
|
||
guard let itemLayout = self.itemLayout else {
|
||
return
|
||
}
|
||
var topOffset = -self.scrollView.bounds.minY + itemLayout.topInset
|
||
topOffset = max(0.0, topOffset)
|
||
transition.setTransform(layer: self.backgroundLayer, transform: CATransform3DMakeTranslation(0.0, topOffset + itemLayout.containerInset, 0.0))
|
||
|
||
transition.setPosition(view: self.navigationBarContainer, position: CGPoint(x: 0.0, y: topOffset + itemLayout.containerInset))
|
||
|
||
var topOffsetFraction = self.scrollView.bounds.minY / 100.0
|
||
topOffsetFraction = max(0.0, min(1.0, topOffsetFraction))
|
||
|
||
let minScale: CGFloat = (itemLayout.containerSize.width - 6.0 * 2.0) / itemLayout.containerSize.width
|
||
let minScaledTranslation: CGFloat = (itemLayout.containerSize.height - itemLayout.containerSize.height * minScale) * 0.5 - 6.0
|
||
let minScaledCornerRadius: CGFloat = itemLayout.containerCornerRadius
|
||
|
||
let scale = minScale * (1.0 - topOffsetFraction) + 1.0 * topOffsetFraction
|
||
let scaledTranslation = minScaledTranslation * (1.0 - topOffsetFraction)
|
||
let scaledCornerRadius = minScaledCornerRadius * (1.0 - topOffsetFraction) + itemLayout.containerCornerRadius * topOffsetFraction
|
||
|
||
var containerTransform = CATransform3DIdentity
|
||
containerTransform = CATransform3DTranslate(containerTransform, 0.0, scaledTranslation, 0.0)
|
||
containerTransform = CATransform3DScale(containerTransform, scale, scale, scale)
|
||
transition.setTransform(view: self.containerView, transform: containerTransform)
|
||
transition.setCornerRadius(layer: self.containerView.layer, cornerRadius: scaledCornerRadius)
|
||
}
|
||
|
||
private var didPlayAppearanceAnimation = false
|
||
func animateIn() {
|
||
self.didPlayAppearanceAnimation = true
|
||
|
||
self.dimView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.3)
|
||
let animateOffset: CGFloat = self.bounds.height - self.backgroundLayer.frame.minY
|
||
self.scrollContentClippingView.layer.animatePosition(from: CGPoint(x: 0.0, y: animateOffset), to: CGPoint(), duration: 0.5, timingFunction: kCAMediaTimingFunctionSpring, additive: true)
|
||
self.backgroundLayer.animatePosition(from: CGPoint(x: 0.0, y: animateOffset), to: CGPoint(), duration: 0.5, timingFunction: kCAMediaTimingFunctionSpring, additive: true)
|
||
self.navigationBarContainer.layer.animatePosition(from: CGPoint(x: 0.0, y: animateOffset), to: CGPoint(), duration: 0.5, timingFunction: kCAMediaTimingFunctionSpring, additive: true)
|
||
}
|
||
|
||
func animateOut(completion: @escaping () -> Void) {
|
||
let animateOffset: CGFloat = self.bounds.height - self.backgroundLayer.frame.minY
|
||
|
||
self.dimView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, removeOnCompletion: false)
|
||
self.scrollContentClippingView.layer.animatePosition(from: CGPoint(), to: CGPoint(x: 0.0, y: animateOffset), duration: 0.3, timingFunction: CAMediaTimingFunctionName.easeInEaseOut.rawValue, removeOnCompletion: false, additive: true, completion: { _ in
|
||
completion()
|
||
})
|
||
self.backgroundLayer.animatePosition(from: CGPoint(), to: CGPoint(x: 0.0, y: animateOffset), duration: 0.3, timingFunction: CAMediaTimingFunctionName.easeInEaseOut.rawValue, removeOnCompletion: false, additive: true)
|
||
self.navigationBarContainer.layer.animatePosition(from: CGPoint(), to: CGPoint(x: 0.0, y: animateOffset), duration: 0.3, timingFunction: CAMediaTimingFunctionName.easeInEaseOut.rawValue, removeOnCompletion: false, additive: true)
|
||
}
|
||
|
||
func update(component: ResizableSheetComponent<ChildEnvironmentType>, availableSize: CGSize, state: EmptyComponentState, environment: Environment<EnvironmentType>, transition: ComponentTransition) -> CGSize {
|
||
self.isUpdating = true
|
||
defer {
|
||
self.isUpdating = false
|
||
}
|
||
|
||
let sheetEnvironment = environment[ResizableSheetComponentEnvironment.self].value
|
||
component.animateOut.connect { [weak self] completion in
|
||
guard let self else {
|
||
return
|
||
}
|
||
self.animateOut {
|
||
completion(Void())
|
||
}
|
||
}
|
||
|
||
let themeUpdated = self.environment?.theme !== sheetEnvironment.theme
|
||
|
||
let resetScrolling = self.scrollView.bounds.width != availableSize.width
|
||
|
||
let fillingSize: CGFloat
|
||
if case .regular = sheetEnvironment.metrics.widthClass {
|
||
fillingSize = min(availableSize.width, 414.0) - sheetEnvironment.safeInsets.left * 2.0
|
||
} else {
|
||
fillingSize = min(availableSize.width, sheetEnvironment.deviceMetrics.screenSize.width) - sheetEnvironment.safeInsets.left * 2.0
|
||
}
|
||
let rawSideInset: CGFloat = floor((availableSize.width - fillingSize) * 0.5)
|
||
|
||
self.component = component
|
||
self.state = state
|
||
self.environment = sheetEnvironment
|
||
|
||
let theme = sheetEnvironment.theme.withModalBlocksBackground()
|
||
if themeUpdated {
|
||
self.dimView.backgroundColor = UIColor(white: 0.0, alpha: 0.5)
|
||
self.backgroundLayer.backgroundColor = theme.list.blocksBackgroundColor.cgColor
|
||
}
|
||
|
||
transition.setFrame(view: self.dimView, frame: CGRect(origin: CGPoint(), size: availableSize))
|
||
|
||
var containerSize: CGSize
|
||
if !"".isEmpty, sheetEnvironment.isCentered {
|
||
let verticalInset: CGFloat = 44.0
|
||
let maxSide = max(availableSize.width, availableSize.height)
|
||
let minSide = min(availableSize.width, availableSize.height)
|
||
containerSize = CGSize(width: min(availableSize.width - 20.0, floor(maxSide / 2.0)), height: min(availableSize.height, minSide) - verticalInset * 2.0)
|
||
if let regularMetricsSize = sheetEnvironment.regularMetricsSize {
|
||
containerSize = regularMetricsSize
|
||
}
|
||
} else {
|
||
containerSize = CGSize(width: fillingSize, height: .greatestFiniteMagnitude)
|
||
}
|
||
|
||
let containerInset: CGFloat = sheetEnvironment.statusBarHeight + 10.0
|
||
let clippingY: CGFloat
|
||
|
||
self.contentView.parentState = state
|
||
let contentViewSize = self.contentView.update(
|
||
transition: transition,
|
||
component: component.content,
|
||
environment: {
|
||
environment[ChildEnvironmentType.self]
|
||
},
|
||
containerSize: containerSize
|
||
)
|
||
component.externalState?.contentHeight = contentViewSize.height
|
||
|
||
if let contentView = self.contentView.view {
|
||
if contentView.superview == nil {
|
||
self.scrollContentView.addSubview(contentView)
|
||
}
|
||
transition.setFrame(view: contentView, frame: CGRect(origin: CGPoint(x: rawSideInset, y: 0.0), size: contentViewSize))
|
||
}
|
||
|
||
let contentHeight = contentViewSize.height
|
||
let initialContentHeight = contentHeight
|
||
|
||
let edgeEffectHeight: CGFloat = 80.0
|
||
let edgeEffectFrame = CGRect(origin: CGPoint(x: rawSideInset, y: 0.0), size: CGSize(width: fillingSize, height: edgeEffectHeight))
|
||
transition.setFrame(view: self.topEdgeEffectView, frame: edgeEffectFrame)
|
||
self.topEdgeEffectView.update(content: theme.actionSheet.opaqueItemBackgroundColor, blur: true, alpha: 1.0, rect: edgeEffectFrame, edge: .top, edgeSize: edgeEffectFrame.height, transition: transition)
|
||
if self.topEdgeEffectView.superview == nil {
|
||
self.navigationBarContainer.insertSubview(self.topEdgeEffectView, at: 0)
|
||
}
|
||
|
||
if let titleItem = component.titleItem {
|
||
let titleItemView: ComponentView<Empty>
|
||
if let current = self.titleItemView {
|
||
titleItemView = current
|
||
} else {
|
||
titleItemView = ComponentView<Empty>()
|
||
self.titleItemView = titleItemView
|
||
}
|
||
|
||
let titleItemSize = titleItemView.update(
|
||
transition: transition,
|
||
component: titleItem,
|
||
environment: {},
|
||
containerSize: CGSize(width: containerSize.width - 66.0 * 2.0, height: 66.0)
|
||
)
|
||
let titleItemFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((containerSize.width - titleItemSize.width)) / 2.0, y: floorToScreenPixels(36.0 - titleItemSize.height * 0.5)), size: titleItemSize)
|
||
if let view = titleItemView.view {
|
||
if view.superview == nil {
|
||
self.navigationBarContainer.addSubview(view)
|
||
}
|
||
transition.setFrame(view: view, frame: titleItemFrame)
|
||
}
|
||
} else if let titleItemView = self.titleItemView {
|
||
self.titleItemView = nil
|
||
titleItemView.view?.removeFromSuperview()
|
||
}
|
||
|
||
|
||
if let leftItem = component.leftItem {
|
||
let leftItemView: ComponentView<Empty>
|
||
if let current = self.leftItemView {
|
||
leftItemView = current
|
||
} else {
|
||
leftItemView = ComponentView<Empty>()
|
||
self.leftItemView = leftItemView
|
||
}
|
||
|
||
let leftItemSize = leftItemView.update(
|
||
transition: transition,
|
||
component: leftItem,
|
||
environment: {},
|
||
containerSize: CGSize(width: 66.0, height: 66.0)
|
||
)
|
||
let leftItemFrame = CGRect(origin: CGPoint(x: 16.0, y: 16.0), size: leftItemSize)
|
||
if let view = leftItemView.view {
|
||
if view.superview == nil {
|
||
self.navigationBarContainer.addSubview(view)
|
||
}
|
||
transition.setFrame(view: view, frame: leftItemFrame)
|
||
}
|
||
} else if let leftItemView = self.leftItemView {
|
||
self.leftItemView = nil
|
||
leftItemView.view?.removeFromSuperview()
|
||
}
|
||
|
||
if let rightItem = component.rightItem {
|
||
let rightItemView: ComponentView<Empty>
|
||
if let current = self.rightItemView {
|
||
rightItemView = current
|
||
} else {
|
||
rightItemView = ComponentView<Empty>()
|
||
self.rightItemView = rightItemView
|
||
}
|
||
|
||
let rightItemSize = rightItemView.update(
|
||
transition: transition,
|
||
component: rightItem,
|
||
environment: {},
|
||
containerSize: CGSize(width: 66.0, height: 66.0)
|
||
)
|
||
let rightItemFrame = CGRect(origin: CGPoint(x: containerSize.width - 16.0 - rightItemSize.width, y: 16.0), size: rightItemSize)
|
||
if let view = rightItemView.view {
|
||
if view.superview == nil {
|
||
self.navigationBarContainer.addSubview(view)
|
||
}
|
||
transition.setFrame(view: view, frame: rightItemFrame)
|
||
}
|
||
} else if let rightItemView = self.rightItemView {
|
||
self.rightItemView = nil
|
||
rightItemView.view?.removeFromSuperview()
|
||
}
|
||
|
||
|
||
clippingY = availableSize.height
|
||
|
||
let topInset: CGFloat = max(0.0, availableSize.height - containerInset - initialContentHeight)
|
||
|
||
let scrollContentHeight = max(topInset + contentHeight + containerInset, availableSize.height - containerInset)
|
||
|
||
self.scrollContentClippingView.layer.cornerRadius = 38.0
|
||
|
||
self.itemLayout = ItemLayout(containerSize: availableSize, containerInset: containerInset, containerCornerRadius: sheetEnvironment.deviceMetrics.screenCornerRadius, bottomInset: sheetEnvironment.safeInsets.bottom, topInset: topInset)
|
||
|
||
transition.setFrame(view: self.scrollContentView, frame: CGRect(origin: CGPoint(x: 0.0, y: topInset + containerInset), size: CGSize(width: availableSize.width, height: contentHeight)))
|
||
|
||
transition.setPosition(layer: self.backgroundLayer, position: CGPoint(x: availableSize.width / 2.0, y: availableSize.height / 2.0))
|
||
transition.setBounds(layer: self.backgroundLayer, bounds: CGRect(origin: CGPoint(), size: CGSize(width: fillingSize, height: availableSize.height)))
|
||
|
||
let scrollClippingFrame = CGRect(origin: CGPoint(x: 0.0, y: containerInset), size: CGSize(width: availableSize.width, height: clippingY - containerInset))
|
||
transition.setPosition(view: self.scrollContentClippingView, position: scrollClippingFrame.center)
|
||
transition.setBounds(view: self.scrollContentClippingView, bounds: CGRect(origin: CGPoint(x: scrollClippingFrame.minX, y: scrollClippingFrame.minY), size: scrollClippingFrame.size))
|
||
|
||
self.ignoreScrolling = true
|
||
transition.setFrame(view: self.scrollView, frame: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: availableSize.width, height: availableSize.height)))
|
||
let contentSize = CGSize(width: availableSize.width, height: scrollContentHeight)
|
||
if contentSize != self.scrollView.contentSize {
|
||
self.scrollView.contentSize = contentSize
|
||
}
|
||
if resetScrolling {
|
||
self.scrollView.bounds = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: availableSize)
|
||
}
|
||
self.ignoreScrolling = false
|
||
self.updateScrolling(transition: transition)
|
||
|
||
transition.setPosition(view: self.containerView, position: CGRect(origin: CGPoint(), size: availableSize).center)
|
||
transition.setBounds(view: self.containerView, bounds: CGRect(origin: CGPoint(), size: availableSize))
|
||
|
||
if sheetEnvironment.isDisplaying && !self.didPlayAppearanceAnimation {
|
||
self.animateIn()
|
||
}
|
||
|
||
return availableSize
|
||
}
|
||
}
|
||
|
||
public func makeView() -> View {
|
||
return View(frame: CGRect())
|
||
}
|
||
|
||
public func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<EnvironmentType>, transition: ComponentTransition) -> CGSize {
|
||
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
|
||
}
|
||
}
|