Swiftgram/submodules/TelegramUI/Sources/ChatTranslationPanelNode.swift
Ilya Laktyushin 9e0600edfa Various fixes
2025-04-25 18:13:30 +04:00

920 lines
42 KiB
Swift

import Foundation
import UIKit
import Display
import AsyncDisplayKit
import SwiftSignalKit
import Postbox
import TelegramCore
import TelegramPresentationData
import LocalizedPeerData
import TelegramStringFormatting
import TextFormat
import Markdown
import ChatPresentationInterfaceState
import AccountContext
import MoreButtonNode
import ContextUI
import TranslateUI
import TelegramUIPreferences
import TelegramNotices
import PremiumUI
final class ChatTranslationPanelNode: ASDisplayNode {
private let context: AccountContext
private let separatorNode: ASDisplayNode
private let button: HighlightableButtonNode
private let buttonIconNode: ASImageNode
private let buttonTextNode: ImmediateTextNode
private let moreButton: MoreButtonNode
private let closeButton: HighlightableButtonNode
private var theme: PresentationTheme?
private var chatInterfaceState: ChatPresentationInterfaceState?
var interfaceInteraction: ChatPanelInterfaceInteraction?
init(context: AccountContext) {
self.context = context
self.separatorNode = ASDisplayNode()
self.separatorNode.isLayerBacked = true
self.button = HighlightableButtonNode()
self.buttonIconNode = ASImageNode()
self.buttonIconNode.displaysAsynchronously = false
self.buttonTextNode = ImmediateTextNode()
self.buttonTextNode.displaysAsynchronously = false
self.moreButton = MoreButtonNode(theme: context.sharedContext.currentPresentationData.with { $0 }.theme)
self.moreButton.iconNode.enqueueState(.more, animated: false)
self.moreButton.hitTestSlop = UIEdgeInsets(top: -8.0, left: -8.0, bottom: -8.0, right: -8.0)
self.closeButton = HighlightableButtonNode()
self.closeButton.hitTestSlop = UIEdgeInsets(top: -8.0, left: -8.0, bottom: -8.0, right: -8.0)
self.closeButton.displaysAsynchronously = false
super.init()
self.clipsToBounds = true
self.addSubnode(self.separatorNode)
self.addSubnode(self.button)
self.addSubnode(self.moreButton)
self.button.addSubnode(self.buttonIconNode)
self.button.addSubnode(self.buttonTextNode)
self.button.addTarget(self, action: #selector(self.buttonPressed), forControlEvents: [.touchUpInside])
self.moreButton.action = { [weak self] _, gesture in
if let strongSelf = self {
strongSelf.morePressed(node: strongSelf.moreButton.contextSourceNode, gesture: gesture)
}
}
self.closeButton.addTarget(self, action: #selector(self.closePressed), forControlEvents: [.touchUpInside])
self.addSubnode(self.closeButton)
}
func animateOut() {
self.layer.animateBounds(from: self.bounds, to: self.bounds.offsetBy(dx: 0.0, dy: self.bounds.size.height), duration: 0.4, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false)
}
func updateLayout(width: CGFloat, leftInset: CGFloat, rightInset: CGFloat, transition: ContainedViewLayoutTransition, interfaceState: ChatPresentationInterfaceState) -> CGFloat {
let previousIsEnabled = self.chatInterfaceState?.translationState?.isEnabled
self.chatInterfaceState = interfaceState
var themeUpdated = false
if interfaceState.theme !== self.theme {
themeUpdated = true
self.theme = interfaceState.theme
}
var isEnabledUpdated = false
if previousIsEnabled != interfaceState.translationState?.isEnabled {
isEnabledUpdated = true
}
if themeUpdated {
self.buttonIconNode.image = generateTintedImage(image: UIImage(bundleImageName: "Chat/Title Panels/Translate"), color: interfaceState.theme.chat.inputPanel.panelControlAccentColor)
self.moreButton.theme = interfaceState.theme
self.separatorNode.backgroundColor = interfaceState.theme.rootController.navigationBar.separatorColor
self.closeButton.setImage(PresentationResourcesChat.chatInputPanelEncircledCloseIconImage(interfaceState.theme), for: [])
}
if themeUpdated || isEnabledUpdated {
if previousIsEnabled != nil && isEnabledUpdated {
var offset: CGFloat = 30.0
if interfaceState.translationState?.isEnabled == false {
offset *= -1
}
if let snapshotView = self.button.view.snapshotContentTree() {
snapshotView.frame = self.button.frame
self.button.supernode?.view.addSubview(snapshotView)
snapshotView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak snapshotView] _ in
snapshotView?.removeFromSuperview()
})
snapshotView.layer.animatePosition(from: CGPoint(), to: CGPoint(x: 0.0, y: offset), duration: 0.2, removeOnCompletion: false, additive: true)
self.button.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2)
self.button.layer.animatePosition(from: CGPoint(x: 0.0, y: -offset), to: CGPoint(), duration: 0.2, additive: true)
}
}
var languageCode = interfaceState.strings.baseLanguageCode
let rawSuffix = "-raw"
if languageCode.hasSuffix(rawSuffix) {
languageCode = String(languageCode.dropLast(rawSuffix.count))
}
let toLang = interfaceState.translationState?.toLang ?? languageCode
let key = "Translation.Language.\(toLang)"
let translateTitle: String
if let string = interfaceState.strings.primaryComponent.dict[key] {
translateTitle = interfaceState.strings.Conversation_Translation_TranslateTo(string).string
} else {
let languageLocale = Locale(identifier: languageCode)
let toLanguage = languageLocale.localizedString(forLanguageCode: toLang) ?? ""
translateTitle = interfaceState.strings.Conversation_Translation_TranslateToOther(toLanguage).string
}
let buttonText = interfaceState.translationState?.isEnabled == true ? interfaceState.strings.Conversation_Translation_ShowOriginal : translateTitle
self.buttonTextNode.attributedText = NSAttributedString(string: buttonText, font: Font.regular(17.0), textColor: interfaceState.theme.rootController.navigationBar.accentTextColor)
}
let panelHeight: CGFloat = 40.0
let contentRightInset: CGFloat = 14.0 + rightInset
let moreButtonSize = self.moreButton.measure(CGSize(width: 100.0, height: panelHeight))
self.moreButton.frame = CGRect(origin: CGPoint(x: width - contentRightInset - moreButtonSize.width, y: floorToScreenPixels((panelHeight - moreButtonSize.height) / 2.0)), size: moreButtonSize)
let closeButtonSize = self.closeButton.measure(CGSize(width: 100.0, height: 100.0))
self.closeButton.frame = CGRect(origin: CGPoint(x: width - contentRightInset - closeButtonSize.width, y: floorToScreenPixels((panelHeight - closeButtonSize.height) / 2.0)), size: closeButtonSize)
if interfaceState.isPremium {
self.moreButton.isHidden = false
self.closeButton.isHidden = true
} else {
self.moreButton.isHidden = true
self.closeButton.isHidden = false
}
let buttonPadding: CGFloat = 10.0
let buttonSpacing: CGFloat = 10.0
let buttonTextSize = self.buttonTextNode.updateLayout(CGSize(width: width - contentRightInset - moreButtonSize.width, height: panelHeight))
if let icon = self.buttonIconNode.image {
let buttonSize = CGSize(width: buttonTextSize.width + icon.size.width + buttonSpacing + buttonPadding * 2.0, height: panelHeight)
self.button.frame = CGRect(origin: CGPoint(x: floorToScreenPixels((width - buttonSize.width) / 2.0), y: 0.0), size: buttonSize)
self.buttonIconNode.frame = CGRect(origin: CGPoint(x: buttonPadding, y: floorToScreenPixels((buttonSize.height - icon.size.height) / 2.0)), size: icon.size)
self.buttonTextNode.frame = CGRect(origin: CGPoint(x: buttonPadding + icon.size.width + buttonSpacing, y: floorToScreenPixels((buttonSize.height - buttonTextSize.height) / 2.0)), size: buttonTextSize)
}
transition.updateFrame(node: self.separatorNode, frame: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: width, height: UIScreenPixel)))
return panelHeight
}
@objc private func closePressed() {
let _ = ApplicationSpecificNotice.incrementTranslationSuggestion(accountManager: self.context.sharedContext.accountManager, count: -100, timestamp: Int32(Date().timeIntervalSince1970) + 60 * 60 * 24 * 7).startStandalone()
}
@objc private func buttonPressed() {
guard let translationState = self.chatInterfaceState?.translationState else {
return
}
let isPremium = self.chatInterfaceState?.isPremium ?? false
var translationAvailable = isPremium
if let channel = self.chatInterfaceState?.renderedPeer?.chatMainPeer as? TelegramChannel, channel.flags.contains(.autoTranslateEnabled) {
translationAvailable = true
}
if translationAvailable {
self.interfaceInteraction?.toggleTranslation(translationState.isEnabled ? .original : .translated)
} else if !translationState.isEnabled {
if !isPremium {
let context = self.context
var replaceImpl: ((ViewController) -> Void)?
let controller = PremiumDemoScreen(context: context, subject: .translation, action: {
let controller = PremiumIntroScreen(context: context, source: .translation)
replaceImpl?(controller)
})
replaceImpl = { [weak controller] c in
controller?.replace(with: c)
}
self.interfaceInteraction?.chatController()?.push(controller)
}
}
}
@objc private func morePressed(node: ContextReferenceContentNode, gesture: ContextGesture?) {
guard let translationState = self.chatInterfaceState?.translationState else {
return
}
let context = self.context
let presentationData = context.sharedContext.currentPresentationData.with { $0 }
var languageCode = presentationData.strings.baseLanguageCode
let rawSuffix = "-raw"
if languageCode.hasSuffix(rawSuffix) {
languageCode = String(languageCode.dropLast(rawSuffix.count))
}
let doNotTranslateTitle: String
let fromLang = translationState.fromLang
let key = "Translation.Language.\(fromLang)"
if let string = presentationData.strings.primaryComponent.dict[key] {
doNotTranslateTitle = presentationData.strings.Conversation_Translation_DoNotTranslate(string).string
} else {
let languageLocale = Locale(identifier: languageCode)
let fromLanguage = languageLocale.localizedString(forLanguageCode: fromLang) ?? ""
doNotTranslateTitle = presentationData.strings.Conversation_Translation_DoNotTranslateOther(fromLanguage).string
}
let items: Signal<ContextController.Items, NoError> = context.sharedContext.accountManager.sharedData(keys: [ApplicationSpecificSharedDataKeys.translationSettings])
|> take(1)
|> map { sharedData -> ContextController.Items in
let settings: TranslationSettings
if let current = sharedData.entries[ApplicationSpecificSharedDataKeys.translationSettings]?.get(TranslationSettings.self) {
settings = current
} else {
settings = TranslationSettings.defaultSettings
}
var items: [ContextMenuItem] = []
items.append(.action(ContextMenuActionItem(text: presentationData.strings.Conversation_Translation_ChooseLanguage, icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Translate"), color: theme.contextMenu.primaryColor)
}, action: { c, _ in
var addedLanguages = Set<String>()
var topLanguages: [String] = []
let langCode = normalizeTranslationLanguage(languageCode)
var selectedLanguages: Set<String>
if let ignoredLanguages = settings.ignoredLanguages {
selectedLanguages = Set(ignoredLanguages)
} else {
selectedLanguages = Set([langCode])
for language in systemLanguageCodes() {
selectedLanguages.insert(language)
}
}
for code in supportedTranslationLanguages {
if selectedLanguages.contains(code) {
topLanguages.append(code)
}
}
topLanguages.append("")
var languages: [(String, String)] = []
let languageLocale = Locale(identifier: langCode)
for code in topLanguages {
if !addedLanguages.contains(code) {
let displayTitle = languageLocale.localizedString(forLanguageCode: code) ?? ""
let value = (code, displayTitle)
if code == languageCode {
languages.insert(value, at: 0)
} else {
languages.append(value)
}
addedLanguages.insert(code)
}
}
for code in supportedTranslationLanguages {
if !addedLanguages.contains(code) {
let displayTitle = languageLocale.localizedString(forLanguageCode: code) ?? ""
let value = (code, displayTitle)
if code == languageCode {
languages.insert(value, at: 0)
} else {
languages.append(value)
}
addedLanguages.insert(code)
}
}
c?.pushItems(items: .single(ContextController.Items(
content: .custom(
TranslationLanguagesContextMenuContent(
context: self.context,
languages: languages, back: { [weak c] in
c?.popItems()
}, selectLanguage: { [weak self, weak c] language in
c?.dismiss(completion: {
guard let strongSelf = self else {
return
}
strongSelf.interfaceInteraction?.changeTranslationLanguage(language)
})
}
)
)
)))
})))
items.append(.separator)
items.append(.action(ContextMenuActionItem(text: doNotTranslateTitle, icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Restrict"), color: theme.contextMenu.primaryColor)
}, action: { [weak self] c, _ in
c?.dismiss(completion: nil)
self?.interfaceInteraction?.addDoNotTranslateLanguage(translationState.fromLang)
})))
items.append(.action(ContextMenuActionItem(text: presentationData.strings.Conversation_Translation_Hide, icon: { theme in
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Clear"), color: theme.contextMenu.primaryColor)
}, action: { [weak self] c, _ in
c?.dismiss(completion: nil)
self?.interfaceInteraction?.hideTranslationPanel()
})))
return ContextController.Items(content: .list(items))
}
if let controller = self.interfaceInteraction?.chatController() {
let contextController = ContextController(presentationData: presentationData, source: .reference(TranslationContextReferenceContentSource(controller: controller, sourceNode: node)), items: items, gesture: gesture)
self.interfaceInteraction?.presentGlobalOverlayController(contextController, nil)
}
}
}
private final class TranslationContextReferenceContentSource: ContextReferenceContentSource {
private let controller: ViewController
private let sourceNode: ContextReferenceContentNode
var keepInPlace: Bool {
return true
}
init(controller: ViewController, sourceNode: ContextReferenceContentNode) {
self.controller = controller
self.sourceNode = sourceNode
}
func transitionInfo() -> ContextControllerReferenceViewInfo? {
return ContextControllerReferenceViewInfo(referenceView: self.sourceNode.view, contentAreaInScreenSpace: UIScreen.main.bounds)
}
}
private let separatorHeight: CGFloat = 7.0
private final class TranslationLanguagesContextMenuContent: ContextControllerItemsContent {
private final class BackButtonNode: HighlightTrackingButtonNode {
let highlightBackgroundNode: ASDisplayNode
let titleLabelNode: ImmediateTextNode
let separatorNode: ASDisplayNode
let iconNode: ASImageNode
var action: (() -> Void)?
private var theme: PresentationTheme?
init() {
self.highlightBackgroundNode = ASDisplayNode()
self.highlightBackgroundNode.isAccessibilityElement = false
self.highlightBackgroundNode.alpha = 0.0
self.titleLabelNode = ImmediateTextNode()
self.titleLabelNode.isAccessibilityElement = false
self.titleLabelNode.maximumNumberOfLines = 1
self.titleLabelNode.isUserInteractionEnabled = false
self.iconNode = ASImageNode()
self.iconNode.isAccessibilityElement = false
self.separatorNode = ASDisplayNode()
self.separatorNode.isAccessibilityElement = false
super.init()
self.addSubnode(self.separatorNode)
self.addSubnode(self.highlightBackgroundNode)
self.addSubnode(self.titleLabelNode)
self.addSubnode(self.iconNode)
self.isAccessibilityElement = true
self.highligthedChanged = { [weak self] highlighted in
guard let strongSelf = self else {
return
}
if highlighted {
strongSelf.highlightBackgroundNode.alpha = 1.0
} else {
let previousAlpha = strongSelf.highlightBackgroundNode.alpha
strongSelf.highlightBackgroundNode.alpha = 0.0
strongSelf.highlightBackgroundNode.layer.animateAlpha(from: previousAlpha, to: 0.0, duration: 0.2)
}
}
self.addTarget(self, action: #selector(self.pressed), forControlEvents: .touchUpInside)
}
@objc private func pressed() {
self.action?()
}
func update(size: CGSize, presentationData: PresentationData, isLast: Bool) {
let standardIconWidth: CGFloat = 32.0
let sideInset: CGFloat = 16.0
let iconSideInset: CGFloat = 12.0
if self.theme !== presentationData.theme {
self.theme = presentationData.theme
self.iconNode.image = generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Back"), color: presentationData.theme.contextMenu.primaryColor)
self.accessibilityLabel = presentationData.strings.Common_Back
}
self.highlightBackgroundNode.backgroundColor = presentationData.theme.contextMenu.itemHighlightedBackgroundColor
self.separatorNode.backgroundColor = presentationData.theme.contextMenu.itemSeparatorColor
self.highlightBackgroundNode.frame = CGRect(origin: CGPoint(), size: size)
self.titleLabelNode.attributedText = NSAttributedString(string: presentationData.strings.Common_Back, font: Font.regular(17.0), textColor: presentationData.theme.contextMenu.primaryColor)
let titleSize = self.titleLabelNode.updateLayout(CGSize(width: size.width - sideInset - standardIconWidth, height: 100.0))
self.titleLabelNode.frame = CGRect(origin: CGPoint(x: sideInset + 36.0, y: floor((size.height - titleSize.height) / 2.0)), size: titleSize)
if let iconImage = self.iconNode.image {
let iconFrame = CGRect(origin: CGPoint(x: iconSideInset, y: floor((size.height - iconImage.size.height) / 2.0)), size: iconImage.size)
self.iconNode.frame = iconFrame
}
self.separatorNode.frame = CGRect(origin: CGPoint(x: 0.0, y: size.height - UIScreenPixel), size: CGSize(width: size.width, height: UIScreenPixel))
self.separatorNode.isHidden = isLast
}
}
private final class LanguagesListNode: ASDisplayNode, ASScrollViewDelegate {
private final class ItemNode: HighlightTrackingButtonNode {
let context: AccountContext
let highlightBackgroundNode: ASDisplayNode
let titleLabelNode: ImmediateTextNode
let separatorNode: ASDisplayNode
let action: () -> Void
private var language: String?
init(context: AccountContext, action: @escaping () -> Void) {
self.action = action
self.context = context
self.highlightBackgroundNode = ASDisplayNode()
self.highlightBackgroundNode.isAccessibilityElement = false
self.highlightBackgroundNode.alpha = 0.0
self.titleLabelNode = ImmediateTextNode()
self.titleLabelNode.isAccessibilityElement = false
self.titleLabelNode.maximumNumberOfLines = 1
self.titleLabelNode.isUserInteractionEnabled = false
self.separatorNode = ASDisplayNode()
self.separatorNode.isAccessibilityElement = false
super.init()
self.isAccessibilityElement = true
self.addSubnode(self.separatorNode)
self.addSubnode(self.highlightBackgroundNode)
self.addSubnode(self.titleLabelNode)
self.highligthedChanged = { [weak self] highlighted in
guard let strongSelf = self, let language = strongSelf.language, !language.isEmpty else {
return
}
if highlighted {
strongSelf.highlightBackgroundNode.alpha = 1.0
} else {
let previousAlpha = strongSelf.highlightBackgroundNode.alpha
strongSelf.highlightBackgroundNode.alpha = 0.0
strongSelf.highlightBackgroundNode.layer.animateAlpha(from: previousAlpha, to: 0.0, duration: 0.2)
}
}
self.addTarget(self, action: #selector(self.pressed), forControlEvents: .touchUpInside)
}
@objc private func pressed() {
guard let language = self.language, !language.isEmpty else {
return
}
self.action()
}
private var displayTitle: String?
func update(size: CGSize, presentationData: PresentationData, language: String, displayTitle: String, isLast: Bool, syncronousLoad: Bool) {
let sideInset: CGFloat = 16.0
if self.language != language {
self.language = language
self.displayTitle = displayTitle
self.accessibilityLabel = "\(displayTitle)"
}
self.highlightBackgroundNode.backgroundColor = presentationData.theme.contextMenu.itemHighlightedBackgroundColor
self.highlightBackgroundNode.frame = CGRect(origin: CGPoint(), size: size)
self.titleLabelNode.attributedText = NSAttributedString(string: self.displayTitle ?? "", font: Font.regular(17.0), textColor: presentationData.theme.contextMenu.primaryColor)
let maxTextWidth: CGFloat = size.width - sideInset
let titleSize = self.titleLabelNode.updateLayout(CGSize(width: maxTextWidth, height: 100.0))
let titleFrame = CGRect(origin: CGPoint(x: sideInset, y: floor((size.height - titleSize.height) / 2.0)), size: titleSize)
self.titleLabelNode.frame = titleFrame
if language == "" {
self.separatorNode.backgroundColor = presentationData.theme.contextMenu.sectionSeparatorColor
self.separatorNode.frame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: size.width, height: separatorHeight))
self.separatorNode.isHidden = false
} else {
self.separatorNode.backgroundColor = presentationData.theme.contextMenu.itemSeparatorColor
self.separatorNode.frame = CGRect(origin: CGPoint(x: 0.0, y: size.height), size: CGSize(width: size.width, height: UIScreenPixel))
self.separatorNode.isHidden = isLast
}
}
}
private let context: AccountContext
private let languages: [(String, String)]
private let requestUpdate: (LanguagesListNode, ContainedViewLayoutTransition) -> Void
private let requestUpdateApparentHeight: (LanguagesListNode, ContainedViewLayoutTransition) -> Void
private let selectLanguage: (String) -> Void
private let scrollNode: ASScrollNode
private var ignoreScrolling: Bool = false
private var animateIn: Bool = false
private var bottomScrollInset: CGFloat = 0.0
private var presentationData: PresentationData?
private var currentSize: CGSize?
private var apparentHeight: CGFloat = 0.0
private var itemNodes: [Int: ItemNode] = [:]
init(
context: AccountContext,
languages: [(String, String)],
requestUpdate: @escaping (LanguagesListNode, ContainedViewLayoutTransition) -> Void,
requestUpdateApparentHeight: @escaping (LanguagesListNode, ContainedViewLayoutTransition) -> Void,
selectLanguage: @escaping (String) -> Void
) {
self.context = context
self.languages = languages
self.requestUpdate = requestUpdate
self.requestUpdateApparentHeight = requestUpdateApparentHeight
self.selectLanguage = selectLanguage
self.scrollNode = ASScrollNode()
self.scrollNode.canCancelAllTouchesInViews = true
self.scrollNode.view.delaysContentTouches = false
self.scrollNode.view.showsVerticalScrollIndicator = false
if #available(iOS 11.0, *) {
self.scrollNode.view.contentInsetAdjustmentBehavior = .never
}
self.scrollNode.clipsToBounds = false
super.init()
self.addSubnode(self.scrollNode)
self.scrollNode.view.delegate = self.wrappedScrollViewDelegate
self.clipsToBounds = true
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
if self.ignoreScrolling {
return
}
self.updateVisibleItems(animated: false, syncronousLoad: false)
if let size = self.currentSize {
var apparentHeight = -self.scrollNode.view.contentOffset.y + self.scrollNode.view.contentSize.height
apparentHeight = max(apparentHeight, 44.0)
apparentHeight = min(apparentHeight, size.height)
if self.apparentHeight != apparentHeight {
self.apparentHeight = apparentHeight
self.requestUpdateApparentHeight(self, .immediate)
}
}
}
private func updateVisibleItems(animated: Bool, syncronousLoad: Bool) {
guard let size = self.currentSize else {
return
}
guard let presentationData = self.presentationData else {
return
}
let itemHeight: CGFloat = 44.0
let visibleBounds = self.scrollNode.bounds.insetBy(dx: 0.0, dy: -180.0)
var validIds = Set<Int>()
let minVisibleIndex = max(0, Int(floor(visibleBounds.minY / itemHeight)))
let maxVisibleIndex = Int(ceil(visibleBounds.maxY / itemHeight))
var separatorIndex = 0
for i in 0 ..< self.languages.count {
if self.languages[i].0.isEmpty {
separatorIndex = i
break
}
}
if minVisibleIndex <= maxVisibleIndex {
for index in minVisibleIndex ... maxVisibleIndex {
if index < self.languages.count {
let height = self.languages[index].0.isEmpty ? separatorHeight : itemHeight
var itemFrame = CGRect(origin: CGPoint(x: 0.0, y: CGFloat(index) * itemHeight), size: CGSize(width: size.width, height: height))
if index > separatorIndex {
itemFrame.origin.y += separatorHeight - itemHeight
}
let (languageCode, displayTitle) = self.languages[index]
validIds.insert(index)
let itemNode: ItemNode
if let current = self.itemNodes[index] {
itemNode = current
} else {
let selectLanguage = self.selectLanguage
itemNode = ItemNode(context: self.context, action: {
selectLanguage(languageCode)
})
self.itemNodes[index] = itemNode
self.scrollNode.addSubnode(itemNode)
}
itemNode.update(size: itemFrame.size, presentationData: presentationData, language: languageCode, displayTitle: displayTitle, isLast: index == self.languages.count - 1 || index == separatorIndex - 1, syncronousLoad: syncronousLoad)
itemNode.frame = itemFrame
}
}
}
var removeIds: [Int] = []
for (id, itemNode) in self.itemNodes {
if !validIds.contains(id) {
removeIds.append(id)
itemNode.removeFromSupernode()
}
}
for id in removeIds {
self.itemNodes.removeValue(forKey: id)
}
}
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
var extendedScrollNodeFrame = self.scrollNode.frame
extendedScrollNodeFrame.size.height += self.bottomScrollInset
if extendedScrollNodeFrame.contains(point) {
return self.scrollNode.view.hitTest(self.view.convert(point, to: self.scrollNode.view), with: event)
}
return super.hitTest(point, with: event)
}
func update(presentationData: PresentationData, constrainedSize: CGSize, bottomInset: CGFloat, transition: ContainedViewLayoutTransition) -> (height: CGFloat, apparentHeight: CGFloat) {
let itemHeight: CGFloat = 44.0
self.presentationData = presentationData
var separatorIndex = 0
for i in 0 ..< self.languages.count {
if self.languages[i].0.isEmpty {
separatorIndex = i
break
}
}
var contentHeight: CGFloat
if separatorIndex != 0 {
contentHeight = CGFloat(self.languages.count - 1) * itemHeight + separatorHeight
} else {
contentHeight = CGFloat(self.languages.count) * itemHeight
}
let size = CGSize(width: constrainedSize.width, height: contentHeight)
let containerSize = CGSize(width: size.width, height: min(constrainedSize.height, size.height))
self.currentSize = containerSize
self.ignoreScrolling = true
if self.scrollNode.frame != CGRect(origin: CGPoint(), size: containerSize) {
self.scrollNode.frame = CGRect(origin: CGPoint(), size: containerSize)
}
if self.scrollNode.view.contentInset.bottom != bottomInset {
self.scrollNode.view.contentInset.bottom = bottomInset
}
self.bottomScrollInset = bottomInset
let scrollContentSize = CGSize(width: size.width, height: size.height)
if self.scrollNode.view.contentSize != scrollContentSize {
self.scrollNode.view.contentSize = scrollContentSize
}
self.ignoreScrolling = false
self.updateVisibleItems(animated: transition.isAnimated, syncronousLoad: !transition.isAnimated)
self.animateIn = false
var apparentHeight = -self.scrollNode.view.contentOffset.y + self.scrollNode.view.contentSize.height
apparentHeight = max(apparentHeight, 44.0)
apparentHeight = min(apparentHeight, containerSize.height)
self.apparentHeight = apparentHeight
return (containerSize.height, apparentHeight)
}
}
final class ItemsNode: ASDisplayNode, ContextControllerItemsNode {
private let context: AccountContext
private let languages: [(String, String)]
private let requestUpdate: (ContainedViewLayoutTransition) -> Void
private let requestUpdateApparentHeight: (ContainedViewLayoutTransition) -> Void
private var presentationData: PresentationData
private var backButtonNode: BackButtonNode?
private var separatorNode: ASDisplayNode?
private let currentTabIndex: Int = 0
private var visibleTabNodes: [Int: LanguagesListNode] = [:]
private let selectLanguage: (String) -> Void
private(set) var apparentHeight: CGFloat = 0.0
init(
context: AccountContext,
languages: [(String, String)],
requestUpdate: @escaping (ContainedViewLayoutTransition) -> Void,
requestUpdateApparentHeight: @escaping (ContainedViewLayoutTransition) -> Void,
back: (() -> Void)?,
selectLanguage: @escaping (String) -> Void
) {
self.context = context
self.languages = languages
self.selectLanguage = selectLanguage
self.presentationData = context.sharedContext.currentPresentationData.with({ $0 })
self.requestUpdate = requestUpdate
self.requestUpdateApparentHeight = requestUpdateApparentHeight
if let back = back {
self.backButtonNode = BackButtonNode()
self.backButtonNode?.action = {
back()
}
}
super.init()
if self.backButtonNode != nil {
self.separatorNode = ASDisplayNode()
}
if let backButtonNode = self.backButtonNode {
self.addSubnode(backButtonNode)
}
if let separatorNode = self.separatorNode {
self.addSubnode(separatorNode)
}
}
func update(presentationData: PresentationData, constrainedWidth: CGFloat, maxHeight: CGFloat, bottomInset: CGFloat, transition: ContainedViewLayoutTransition) -> (cleanSize: CGSize, apparentHeight: CGFloat) {
let constrainedSize = CGSize(width: min(220.0, constrainedWidth), height: min(604.0, maxHeight))
var topContentHeight: CGFloat = 0.0
if let backButtonNode = self.backButtonNode {
let backButtonFrame = CGRect(origin: CGPoint(x: 0.0, y: topContentHeight), size: CGSize(width: constrainedSize.width, height: 44.0))
backButtonNode.update(size: backButtonFrame.size, presentationData: self.presentationData, isLast: true)
transition.updateFrame(node: backButtonNode, frame: backButtonFrame)
topContentHeight += backButtonFrame.height
}
if let separatorNode = self.separatorNode {
let separatorFrame = CGRect(origin: CGPoint(x: 0.0, y: topContentHeight), size: CGSize(width: constrainedSize.width, height: separatorHeight))
separatorNode.backgroundColor = self.presentationData.theme.contextMenu.sectionSeparatorColor
transition.updateFrame(node: separatorNode, frame: separatorFrame)
topContentHeight += separatorFrame.height
}
var tabLayouts: [Int: (height: CGFloat, apparentHeight: CGFloat)] = [:]
var visibleIndices: [Int] = []
visibleIndices.append(self.currentTabIndex)
let previousVisibleTabFrames: [(Int, CGRect)] = self.visibleTabNodes.map { key, value -> (Int, CGRect) in
return (key, value.frame)
}
for index in visibleIndices {
var tabTransition = transition
let tabNode: LanguagesListNode
var initialReferenceFrame: CGRect?
if let current = self.visibleTabNodes[index] {
tabNode = current
} else {
for (previousIndex, previousFrame) in previousVisibleTabFrames {
if index > previousIndex {
initialReferenceFrame = previousFrame.offsetBy(dx: constrainedSize.width, dy: 0.0)
} else {
initialReferenceFrame = previousFrame.offsetBy(dx: -constrainedSize.width, dy: 0.0)
}
break
}
tabNode = LanguagesListNode(
context: self.context,
languages: self.languages,
requestUpdate: { [weak self] tab, transition in
guard let strongSelf = self else {
return
}
if strongSelf.visibleTabNodes.contains(where: { $0.value === tab }) {
strongSelf.requestUpdate(transition)
}
},
requestUpdateApparentHeight: { [weak self] tab, transition in
guard let strongSelf = self else {
return
}
if strongSelf.visibleTabNodes.contains(where: { $0.value === tab }) {
strongSelf.requestUpdateApparentHeight(transition)
}
},
selectLanguage: self.selectLanguage
)
self.addSubnode(tabNode)
self.visibleTabNodes[index] = tabNode
tabTransition = .immediate
}
let tabLayout = tabNode.update(presentationData: presentationData, constrainedSize: CGSize(width: constrainedSize.width, height: constrainedSize.height - topContentHeight), bottomInset: bottomInset, transition: tabTransition)
tabLayouts[index] = tabLayout
let currentFractionalTabIndex = CGFloat(self.currentTabIndex)
let xOffset: CGFloat = (CGFloat(index) - currentFractionalTabIndex) * constrainedSize.width
let tabFrame = CGRect(origin: CGPoint(x: xOffset, y: topContentHeight), size: CGSize(width: constrainedSize.width, height: tabLayout.height))
tabTransition.updateFrame(node: tabNode, frame: tabFrame)
if let initialReferenceFrame = initialReferenceFrame {
transition.animatePositionAdditive(node: tabNode, offset: CGPoint(x: initialReferenceFrame.minX - tabFrame.minX, y: 0.0))
}
}
var contentSize = CGSize(width: constrainedSize.width, height: topContentHeight)
var apparentHeight = topContentHeight
if let tabLayout = tabLayouts[self.currentTabIndex] {
contentSize.height += tabLayout.height
apparentHeight += tabLayout.apparentHeight
}
return (contentSize, apparentHeight)
}
}
let context: AccountContext
let languages: [(String, String)]
let back: (() -> Void)?
let selectLanguage: (String) -> Void
public init(
context: AccountContext,
languages: [(String, String)],
back: (() -> Void)?,
selectLanguage: @escaping (String) -> Void
) {
self.context = context
self.languages = languages
self.back = back
self.selectLanguage = selectLanguage
}
func node(
requestUpdate: @escaping (ContainedViewLayoutTransition) -> Void,
requestUpdateApparentHeight: @escaping (ContainedViewLayoutTransition) -> Void
) -> ContextControllerItemsNode {
return ItemsNode(
context: self.context,
languages: self.languages,
requestUpdate: requestUpdate,
requestUpdateApparentHeight: requestUpdateApparentHeight,
back: self.back,
selectLanguage: self.selectLanguage
)
}
}