Files
Swiftgram/submodules/TelegramUI/Components/TextProcessingScreen/Sources/TextProcessingLanguageSelectionComponent.swift
2026-03-27 20:16:52 +08:00

1199 lines
54 KiB
Swift

import Foundation
import UIKit
import Display
import TelegramPresentationData
import ComponentFlow
import GlassBackgroundComponent
import MultilineTextComponent
import BundleIconComponent
import TelegramCore
import TranslateUI
import EmojiStatusComponent
import AccountContext
final class TextProcessingLanguageSelectionComponent: Component {
public struct Language: Equatable {
public let id: String
public let languageCode: String
public let name: String
public init(id: String, languageCode: String, name: String) {
self.id = id
self.languageCode = languageCode
self.name = name
}
}
let context: AccountContext
let theme: PresentationTheme
let strings: PresentationStrings
let sourceView: UIView
let topLanguages: [Language]
let selectedLanguageCode: String
let ignoredTranslationLanguages: [String]
let currentStyle: TelegramComposeAIMessageMode.StyleId
let displayStyles: [TextProcessingScreen.Style]?
let completion: (String, TelegramComposeAIMessageMode.StyleId) -> Void
let dismissed: () -> Void
let inputHeight: CGFloat
init(
context: AccountContext,
theme: PresentationTheme,
strings: PresentationStrings,
sourceView: UIView,
topLanguages: [Language],
selectedLanguageCode: String,
ignoredTranslationLanguages: [String],
currentStyle: TelegramComposeAIMessageMode.StyleId,
displayStyles: [TextProcessingScreen.Style]?,
completion: @escaping (String, TelegramComposeAIMessageMode.StyleId) -> Void,
dismissed: @escaping () -> Void,
inputHeight: CGFloat
) {
self.context = context
self.theme = theme
self.strings = strings
self.sourceView = sourceView
self.topLanguages = topLanguages
self.selectedLanguageCode = selectedLanguageCode
self.ignoredTranslationLanguages = ignoredTranslationLanguages
self.currentStyle = currentStyle
self.displayStyles = displayStyles
self.completion = completion
self.dismissed = dismissed
self.inputHeight = inputHeight
}
static func ==(lhs: TextProcessingLanguageSelectionComponent, rhs: TextProcessingLanguageSelectionComponent) -> Bool {
if lhs.theme !== rhs.theme {
return false
}
if lhs.strings !== rhs.strings {
return false
}
if lhs.topLanguages != rhs.topLanguages {
return false
}
if lhs.selectedLanguageCode != rhs.selectedLanguageCode {
return false
}
if lhs.ignoredTranslationLanguages != rhs.ignoredTranslationLanguages {
return false
}
if lhs.currentStyle != rhs.currentStyle {
return false
}
if lhs.displayStyles != rhs.displayStyles {
return false
}
if lhs.inputHeight != rhs.inputHeight {
return false
}
return true
}
private final class ScrollView: UIScrollView {
override func touchesShouldCancel(in view: UIView) -> Bool {
return true
}
}
private struct ItemLayout: Equatable {
let size: CGSize
let itemHeight: CGFloat
let itemCount: Int
let topSeparatedItemCount: Int
let topSeparatorHeight: CGFloat
let searchSeparatorHeight: CGFloat
let verticalInset: CGFloat
let searchItemHeight: CGFloat
let contentHeight: CGFloat
init(size: CGSize, itemHeight: CGFloat, itemCount: Int, topSeparatedItemCount: Int, verticalInset: CGFloat, searchItemHeight: CGFloat) {
self.size = size
self.itemHeight = itemHeight
self.itemCount = itemCount
self.topSeparatedItemCount = topSeparatedItemCount
self.topSeparatorHeight = 20.0
self.searchSeparatorHeight = 20.0
self.verticalInset = verticalInset
self.searchItemHeight = searchItemHeight
var contentHeight = verticalInset * 2.0 + searchItemHeight + self.searchSeparatorHeight + CGFloat(itemCount) * itemHeight
if topSeparatedItemCount != 0 {
contentHeight += self.topSeparatorHeight
}
self.contentHeight = contentHeight
}
func indexRange(minY: CGFloat, maxY: CGFloat) -> Range<Int>? {
let itemsOriginY = self.verticalInset + self.searchItemHeight + self.searchSeparatorHeight
var firstIndex = Int(floor((minY - itemsOriginY - self.topSeparatorHeight) / self.itemHeight))
firstIndex = max(0, firstIndex)
var lastIndex = Int(ceil((maxY - itemsOriginY + self.topSeparatorHeight) / self.itemHeight))
lastIndex = min(self.itemCount - 1, lastIndex)
if firstIndex <= lastIndex {
return firstIndex ..< (lastIndex + 1)
} else {
return nil
}
}
func frame(forItemAt index: Int) -> CGRect {
let itemsOriginY = self.verticalInset + self.searchItemHeight + self.searchSeparatorHeight
var rect = CGRect(origin: CGPoint(x: 0.0, y: itemsOriginY + CGFloat(index) * self.itemHeight), size: CGSize(width: self.size.width, height: self.itemHeight))
if index >= self.topSeparatedItemCount && self.topSeparatedItemCount != 0 {
rect.origin.y += self.topSeparatorHeight
}
return rect
}
}
final class View: UIView, UIScrollViewDelegate {
private let dimView: UIView
private let backgroundContainer: GlassBackgroundContainerView
private let mainBackground: GlassBackgroundView
private let mainScrollView: ScrollView
private var mainItemViews: [String: ComponentView<Empty>] = [:]
private let mainMeasureItem = ComponentView<Empty>()
private let mainSearchSeparator: SimpleLayer
private let mainTopSeparator: SimpleLayer
private let stylesBackground: GlassBackgroundView
private let stylesScrollView: ScrollView
private let stylesSelectionView: UIImageView
private var stylesItemViews: [TelegramComposeAIMessageMode.StyleId: ComponentView<Empty>] = [:]
private var mainItems: [Language] = []
private var mainTopItemCount: Int = 0
private var mainItemLayout: ItemLayout?
private var component: TextProcessingLanguageSelectionComponent?
private weak var state: EmptyComponentState?
private var isUpdating: Bool = false
private var ignoreScrolling: Bool = false
private var updatedLanguage: String?
private var updatedStyle: TelegramComposeAIMessageMode.StyleId?
private var searchQuery: String = "" {
didSet {
if self.searchQuery != oldValue {
self.cachedFilteredItems = nil
}
}
}
private var searchItemView = ComponentView<Empty>()
private let searchExternalState = SearchItemComponent.ExternalState()
private var cachedFilteredItems: [Language]?
private var filteredMainItems: [Language] {
if let cached = self.cachedFilteredItems {
return cached
}
let result: [Language]
if self.searchQuery.isEmpty {
result = self.mainItems
} else {
let query = self.searchQuery.lowercased()
result = self.mainItems.filter { item in
if item.id.hasPrefix("top-") {
return false
}
return item.name.lowercased().contains(query)
}
}
self.cachedFilteredItems = result
return result
}
override init(frame: CGRect) {
self.dimView = UIView()
self.backgroundContainer = GlassBackgroundContainerView()
self.mainBackground = GlassBackgroundView()
self.backgroundContainer.contentView.addSubview(self.mainBackground)
self.stylesBackground = GlassBackgroundView()
self.mainScrollView = ScrollView()
self.stylesScrollView = ScrollView()
self.mainSearchSeparator = SimpleLayer()
self.mainScrollView.layer.addSublayer(self.mainSearchSeparator)
self.mainTopSeparator = SimpleLayer()
self.mainScrollView.layer.addSublayer(self.mainTopSeparator)
self.stylesSelectionView = UIImageView()
self.stylesScrollView.addSubview(self.stylesSelectionView)
super.init(frame: frame)
self.addSubview(self.dimView)
self.dimView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.onDimTapGesture(_:))))
self.addSubview(self.backgroundContainer)
self.mainScrollView.delaysContentTouches = false
self.mainScrollView.canCancelContentTouches = true
self.mainScrollView.contentInsetAdjustmentBehavior = .never
self.mainScrollView.automaticallyAdjustsScrollIndicatorInsets = false
self.mainScrollView.showsVerticalScrollIndicator = false
self.mainScrollView.showsHorizontalScrollIndicator = false
self.mainScrollView.alwaysBounceHorizontal = false
self.mainScrollView.alwaysBounceVertical = true
self.mainScrollView.scrollsToTop = false
self.mainScrollView.delegate = self
self.mainScrollView.clipsToBounds = true
self.mainBackground.contentView.addSubview(self.mainScrollView)
self.stylesScrollView.delaysContentTouches = false
self.stylesScrollView.canCancelContentTouches = true
self.stylesScrollView.contentInsetAdjustmentBehavior = .never
self.stylesScrollView.automaticallyAdjustsScrollIndicatorInsets = false
self.stylesScrollView.showsVerticalScrollIndicator = false
self.stylesScrollView.showsHorizontalScrollIndicator = false
self.stylesScrollView.alwaysBounceHorizontal = false
self.stylesScrollView.alwaysBounceVertical = false
self.stylesScrollView.scrollsToTop = false
self.stylesScrollView.delegate = self
self.stylesScrollView.clipsToBounds = true
self.stylesBackground.contentView.addSubview(self.stylesScrollView)
}
required init?(coder: NSCoder) {
preconditionFailure()
}
@objc private func onDimTapGesture(_ recognizer: UITapGestureRecognizer) {
guard let component = self.component else {
return
}
if case .ended = recognizer.state {
if component.displayStyles != nil, let updatedStyle = self.updatedStyle {
component.completion(component.selectedLanguageCode, updatedStyle)
}
self.animateOut()
}
}
private func animateIn() {
self.backgroundContainer.layer.animateSpring(from: 0.001, to: 1.0, keyPath: "transform.scale", duration: 0.5)
self.backgroundContainer.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2)
}
private func animateOut() {
self.endEditing(true)
self.backgroundContainer.layer.animateScale(from: 1.0, to: 0.001, duration: 0.2, removeOnCompletion: false)
self.backgroundContainer.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak self] _ in
guard let self, let component = self.component else {
return
}
component.dismissed()
})
}
private func completeIfPossible() {
guard let component = self.component else {
return
}
if self.updatedLanguage != nil || self.updatedStyle != nil {
component.completion(self.updatedLanguage ?? component.selectedLanguageCode, self.updatedStyle ?? component.currentStyle)
}
self.animateOut()
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
if !self.ignoreScrolling {
self.updateScrolling(scrollView: scrollView, transition: .immediate)
}
}
private func updateScrolling(scrollView: UIScrollView, transition: ComponentTransition) {
guard let component = self.component else {
return
}
if scrollView == self.mainScrollView {
guard let itemLayout = self.mainItemLayout else {
return
}
let isSearchFocused = self.searchExternalState.isEditing
let fixedSearchHeight = itemLayout.searchItemHeight + itemLayout.searchSeparatorHeight
let scrollOffset: CGFloat = isSearchFocused ? fixedSearchHeight : 0.0
let visibleBounds = scrollView.bounds.offsetBy(dx: 0.0, dy: scrollOffset)
var validIds: [String] = []
let displayItems = self.filteredMainItems
if let indexRange = itemLayout.indexRange(minY: visibleBounds.minY, maxY: visibleBounds.maxY) {
for index in indexRange.lowerBound ..< indexRange.upperBound {
if index >= displayItems.count {
break
}
let item = displayItems[index]
validIds.append(item.id)
let itemView: ComponentView<Empty>
var itemTransition = transition
if let current = self.mainItemViews[item.id] {
itemView = current
} else {
itemTransition = itemTransition.withAnimation(.none)
itemView = ComponentView()
self.mainItemViews[item.id] = itemView
}
var itemFrame = itemLayout.frame(forItemAt: index)
itemFrame.origin.y -= scrollOffset
let _ = itemView.update(
transition: itemTransition,
component: AnyComponent(LanguageItemComponent(
theme: component.theme,
title: item.name,
isSelected: item.languageCode == component.selectedLanguageCode,
action: { [weak self] in
guard let self else {
return
}
self.updatedLanguage = item.languageCode
self.completeIfPossible()
}
)),
environment: {},
containerSize: itemFrame.size
)
if let itemComponentView = itemView.view {
if itemComponentView.superview == nil {
self.mainScrollView.addSubview(itemComponentView)
}
itemTransition.setFrame(view: itemComponentView, frame: itemFrame)
}
}
}
var removedIds: [String] = []
for (id, itemView) in self.mainItemViews {
if !validIds.contains(id) {
removedIds.append(id)
itemView.view?.removeFromSuperview()
}
}
for id in removedIds {
self.mainItemViews.removeValue(forKey: id)
}
}
}
func update(component: TextProcessingLanguageSelectionComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
self.isUpdating = true
defer {
self.isUpdating = false
}
let containerSideInset: CGFloat = 16.0
var shouldAnimateIn = false
if self.component == nil {
shouldAnimateIn = true
}
self.component = component
self.state = state
if self.mainItems.isEmpty {
self.mainItems = supportedTranslationLanguages.compactMap { item in
if component.ignoredTranslationLanguages.contains(item) {
return nil
}
return Language(id: item, languageCode: item, name: localizedLanguageName(strings: component.strings, language: item))
}
var topIds: [String] = []
if !topIds.contains(component.selectedLanguageCode), let item = self.mainItems.first(where: { $0.languageCode == component.selectedLanguageCode }) {
self.mainItems.insert(TextProcessingLanguageSelectionComponent.Language(
id: "top-" + item.id,
languageCode: item.languageCode,
name: item.name
), at: 0)
topIds.append(item.languageCode)
}
if !topIds.contains("en"), let item = self.mainItems.first(where: { $0.languageCode == "en" }) {
self.mainItems.insert(TextProcessingLanguageSelectionComponent.Language(
id: "top-" + item.id,
languageCode: item.languageCode,
name: item.name
), at: 0)
topIds.append(item.languageCode)
}
var languageCode = component.strings.baseLanguageCode
let rawSuffix = "-raw"
if languageCode.hasSuffix(rawSuffix) {
languageCode = String(languageCode.dropLast(rawSuffix.count))
}
if !topIds.contains(languageCode), let item = self.mainItems.first(where: { $0.languageCode == languageCode }) {
self.mainItems.insert(TextProcessingLanguageSelectionComponent.Language(
id: "top-" + item.id,
languageCode: item.languageCode,
name: item.name
), at: 0)
topIds.append(item.languageCode)
}
self.mainTopItemCount = topIds.count
}
let mainWidth: CGFloat = 220.0
let mainContainerInset: CGFloat = 11.0
let mainItemSize = self.mainMeasureItem.update(
transition: .immediate,
component: AnyComponent(LanguageItemComponent(
theme: component.theme,
title: "A",
isSelected: false,
action: {
}
)),
environment: {},
containerSize: CGSize(width: mainWidth, height: 1000.0)
)
let searchItemHeight: CGFloat = mainItemSize.height
let filteredItems = self.filteredMainItems
let effectiveTopItemCount = self.searchQuery.isEmpty ? self.mainTopItemCount : 0
let searchSeparatorHeight: CGFloat = 20.0
let totalTopItemCount = self.mainTopItemCount
let mainContentHeight = mainContainerInset * 2.0 + searchItemHeight + searchSeparatorHeight + CGFloat(self.mainItems.count) * mainItemSize.height + (totalTopItemCount != 0 ? 20.0 : 0.0)
let maxAvailableHeight = max(mainItemSize.height * 2.0, availableSize.height - component.inputHeight - containerSideInset * 2.0)
var mainSize = CGSize(width: mainWidth, height: min(min(370.0, maxAvailableHeight), mainContentHeight))
var stylesSize: CGSize?
var selectedStyleItemFrame: CGRect?
let stylesSpacing: CGFloat = 8.0
if let displayStyles = component.displayStyles {
var styleData: [(id: TelegramComposeAIMessageMode.StyleId, icon: String, iconFileId: Int64?, iconFile: TelegramMediaFile?, title: String)] = []
styleData.append((.neutral, "🏳️", nil, nil, localizedStyleName(strings: component.strings, styleId: .neutral)))
for item in displayStyles {
styleData.append((item.id, item.emoji, item.emojiFileId, item.emojiFile, localizedStyleName(strings: component.strings, styleId: item.id)))
}
let stylesItemSize = CGSize(width: 82.0, height: 60.0)
var selectedItemFrame: CGRect?
stylesSize = CGSize(width: stylesItemSize.width, height: CGFloat(styleData.count) * stylesItemSize.height)
for index in 0 ..< styleData.count {
let item = styleData[index]
let itemView: ComponentView<Empty>
var itemViewTransition = transition
if let current = self.stylesItemViews[item.id] {
itemView = current
} else {
itemViewTransition = itemViewTransition.withAnimation(.none)
itemView = ComponentView()
self.stylesItemViews[item.id] = itemView
}
let _ = itemView.update(
transition: itemViewTransition,
component: AnyComponent(StyleItemComponent(
context: component.context,
theme: component.theme,
icon: item.icon,
iconFileId: item.iconFileId,
iconFile: item.iconFile,
title: item.title,
action: { [weak self] in
guard let self else {
return
}
self.updatedStyle = item.id
self.completeIfPossible()
}
)),
environment: {},
containerSize: stylesItemSize
)
let itemFrame = CGRect(origin: CGPoint(x: 0.0, y: CGFloat(index) * stylesItemSize.height), size: stylesItemSize)
if let itemComponentView = itemView.view {
if itemComponentView.superview == nil {
self.stylesScrollView.addSubview(itemComponentView)
}
itemViewTransition.setFrame(view: itemComponentView, frame: itemFrame)
}
if item.id == self.updatedStyle ?? component.currentStyle {
selectedItemFrame = itemFrame
}
}
if self.stylesSelectionView.image == nil {
self.stylesSelectionView.image = generateStretchableFilledCircleImage(diameter: (30.0 - 4.0) * 2.0, color: .white)?.withRenderingMode(.alwaysTemplate)
}
self.stylesSelectionView.tintColor = component.theme.list.itemHighlightedBackgroundColor.withMultipliedAlpha(0.6)
if let selectedItemFrame {
var selectedBackgroundTransition = transition
if self.stylesSelectionView.isHidden {
self.stylesSelectionView.isHidden = false
selectedBackgroundTransition = selectedBackgroundTransition.withAnimation(.none)
}
selectedBackgroundTransition.setFrame(view: self.stylesSelectionView, frame: selectedItemFrame.insetBy(dx: 4.0, dy: 4.0))
transition.setAlpha(view: self.stylesSelectionView, alpha: 1.0)
} else {
if !self.stylesSelectionView.isHidden {
transition.setAlpha(view: self.stylesSelectionView, alpha: 0.0, completion: { [weak self] flag in
guard let self, flag else {
return
}
self.stylesSelectionView.isHidden = true
})
}
}
selectedStyleItemFrame = selectedItemFrame
}
let stylesContentSize = stylesSize
if var stylesSizeValue = stylesSize {
let stylesItemHeight: CGFloat = 60.0
let maxHeight = min(370.0, maxAvailableHeight)
if stylesSizeValue.height > maxHeight {
let n = floor(maxHeight / stylesItemHeight)
let visibleHeight = (n - 0.5) * stylesItemHeight
stylesSizeValue.height = max(stylesItemHeight, visibleHeight)
} else {
stylesSizeValue.height = min(stylesSizeValue.height, maxHeight)
}
stylesSize = stylesSizeValue
mainSize.height = min(mainSize.height, stylesSizeValue.height)
}
let mainItemLayout = ItemLayout(size: mainSize, itemHeight: mainItemSize.height, itemCount: filteredItems.count, topSeparatedItemCount: effectiveTopItemCount, verticalInset: mainContainerInset, searchItemHeight: searchItemHeight)
self.mainItemLayout = mainItemLayout
let _ = self.searchItemView.update(
transition: transition,
component: AnyComponent(SearchItemComponent(
theme: component.theme,
placeholder: component.strings.Common_Search,
externalState: self.searchExternalState,
valueChanged: { [weak self] query in
guard let self else {
return
}
self.searchQuery = query
if !self.isUpdating {
self.state?.updated(transition: .immediate)
}
}
)),
environment: {},
containerSize: CGSize(width: mainSize.width, height: searchItemHeight)
)
let isSearchFocused = self.searchExternalState.isEditing
if let searchView = self.searchItemView.view {
if isSearchFocused {
if searchView.superview !== self.mainBackground.contentView {
self.mainBackground.contentView.addSubview(searchView)
}
transition.setFrame(view: searchView, frame: CGRect(origin: CGPoint(x: 0.0, y: mainContainerInset), size: CGSize(width: mainSize.width, height: searchItemHeight)))
} else {
if searchView.superview !== self.mainScrollView {
self.mainScrollView.addSubview(searchView)
}
transition.setFrame(view: searchView, frame: CGRect(origin: CGPoint(x: 0.0, y: mainContainerInset), size: CGSize(width: mainSize.width, height: searchItemHeight)))
}
}
self.mainSearchSeparator.backgroundColor = component.theme.contextMenu.itemSeparatorColor.cgColor
if isSearchFocused {
if self.mainSearchSeparator.superlayer !== self.mainBackground.contentView.layer {
self.mainBackground.contentView.layer.addSublayer(self.mainSearchSeparator)
}
var searchSeparatorFrame = CGRect(origin: CGPoint(x: 18.0, y: mainContainerInset + searchItemHeight), size: CGSize(width: mainItemLayout.size.width - 18.0 - 18.0, height: UIScreenPixel))
searchSeparatorFrame.origin.y += floorToScreenPixels((mainItemLayout.searchSeparatorHeight - searchSeparatorFrame.height) * 0.5)
transition.setFrame(layer: self.mainSearchSeparator, frame: searchSeparatorFrame)
} else {
if self.mainSearchSeparator.superlayer !== self.mainScrollView.layer {
self.mainScrollView.layer.addSublayer(self.mainSearchSeparator)
}
var searchSeparatorFrame = CGRect(origin: CGPoint(x: 18.0, y: mainItemLayout.verticalInset + mainItemLayout.searchItemHeight), size: CGSize(width: mainItemLayout.size.width - 18.0 - 18.0, height: UIScreenPixel))
searchSeparatorFrame.origin.y += floorToScreenPixels((mainItemLayout.searchSeparatorHeight - searchSeparatorFrame.height) * 0.5)
transition.setFrame(layer: self.mainSearchSeparator, frame: searchSeparatorFrame)
}
let fixedSearchHeight = searchItemHeight + mainItemLayout.searchSeparatorHeight
let topSeparatorScrollOffset: CGFloat = isSearchFocused ? fixedSearchHeight : 0.0
if mainItemLayout.topSeparatedItemCount != 0 {
self.mainTopSeparator.backgroundColor = component.theme.contextMenu.itemSeparatorColor.cgColor
self.mainTopSeparator.isHidden = false
var topSeparatorFrame = CGRect(origin: CGPoint(x: 18.0, y: mainItemLayout.verticalInset + mainItemLayout.searchItemHeight + mainItemLayout.searchSeparatorHeight + CGFloat(mainItemLayout.topSeparatedItemCount) * mainItemLayout.itemHeight - topSeparatorScrollOffset), size: CGSize(width: mainItemLayout.size.width - 18.0 - 18.0, height: UIScreenPixel))
topSeparatorFrame.origin.y += floorToScreenPixels((mainItemLayout.topSeparatorHeight - topSeparatorFrame.height) * 0.5)
transition.setFrame(layer: self.mainTopSeparator, frame: topSeparatorFrame)
} else {
self.mainTopSeparator.isHidden = true
}
self.ignoreScrolling = true
if isSearchFocused {
let scrollViewOriginY = fixedSearchHeight
let scrollViewHeight = mainItemLayout.size.height - fixedSearchHeight
self.mainScrollView.frame = CGRect(origin: CGPoint(x: 0.0, y: scrollViewOriginY), size: CGSize(width: mainItemLayout.size.width, height: max(0.0, scrollViewHeight)))
self.mainScrollView.contentSize = CGSize(width: mainItemLayout.size.width, height: mainItemLayout.contentHeight - fixedSearchHeight)
self.mainScrollView.contentOffset = .zero
} else {
self.mainScrollView.frame = CGRect(origin: CGPoint(), size: mainItemLayout.size)
self.mainScrollView.contentSize = CGSize(width: mainItemLayout.size.width, height: mainItemLayout.contentHeight)
}
self.ignoreScrolling = false
self.updateScrolling(scrollView: self.mainScrollView, transition: transition)
let sourceLocation = component.sourceView.convert(component.sourceView.bounds.center, to: self)
let effectiveBottomBound = availableSize.height - component.inputHeight
var mainFrame = CGRect(origin: CGPoint(x: floor(sourceLocation.x - mainItemLayout.size.width * 0.5), y: floor(sourceLocation.y - mainItemLayout.size.height * 0.5)), size: mainItemLayout.size)
if mainFrame.origin.x + mainFrame.size.width > availableSize.width - containerSideInset {
mainFrame.origin.x = availableSize.width - containerSideInset - mainFrame.size.width
}
if mainFrame.origin.y + mainFrame.size.height > effectiveBottomBound - containerSideInset {
mainFrame.origin.y = effectiveBottomBound - containerSideInset - mainFrame.size.height
}
if mainFrame.origin.x < containerSideInset {
mainFrame.origin.x = containerSideInset
}
if mainFrame.origin.y < containerSideInset {
mainFrame.origin.y = containerSideInset
}
let containerInset: CGFloat = 100.0
var unionRect = mainFrame
var stylesFrame: CGRect?
if let stylesSize {
let frame = CGRect(origin: CGPoint(x: mainFrame.maxX + stylesSpacing, y: mainFrame.minY), size: stylesSize)
stylesFrame = frame
unionRect = unionRect.union(frame)
}
let containerFrame = unionRect.insetBy(dx: -containerInset, dy: -containerInset)
let anchorX = (sourceLocation.x - containerFrame.minX) / containerFrame.width
let anchorY = (sourceLocation.y - containerFrame.minY) / containerFrame.height
self.backgroundContainer.layer.anchorPoint = CGPoint(x: anchorX, y: anchorY)
self.backgroundContainer.layer.position = CGPoint(x: containerFrame.minX + anchorX * containerFrame.width, y: containerFrame.minY + anchorY * containerFrame.height)
self.backgroundContainer.bounds = CGRect(origin: .zero, size: containerFrame.size)
self.backgroundContainer.update(size: containerFrame.size, isDark: component.theme.overallDarkAppearance, transition: transition)
let mainLocalFrame = CGRect(origin: CGPoint(x: mainFrame.minX - containerFrame.minX, y: mainFrame.minY - containerFrame.minY), size: mainFrame.size)
transition.setFrame(view: self.mainBackground, frame: mainLocalFrame)
self.mainBackground.update(size: mainFrame.size, cornerRadius: 30.0, isDark: component.theme.overallDarkAppearance, tintColor: .init(kind: .panel), isInteractive: true, transition: transition)
if let stylesFrame {
let stylesLocalFrame = CGRect(origin: CGPoint(x: stylesFrame.minX - containerFrame.minX, y: stylesFrame.minY - containerFrame.minY), size: stylesFrame.size)
if self.stylesBackground.superview == nil {
self.backgroundContainer.contentView.addSubview(self.stylesBackground)
}
transition.setFrame(view: self.stylesBackground, frame: stylesLocalFrame)
self.stylesBackground.update(size: stylesFrame.size, cornerRadius: 30.0, isDark: component.theme.overallDarkAppearance, tintColor: .init(kind: .panel), isInteractive: true, transition: transition)
transition.setFrame(view: self.stylesScrollView, frame: CGRect(origin: CGPoint(), size: stylesFrame.size))
self.stylesScrollView.contentSize = stylesContentSize ?? stylesFrame.size
if shouldAnimateIn, let selectedStyleItemFrame {
let visibleHeight = stylesFrame.size.height
let maxOffsetY = max(0.0, (stylesContentSize ?? stylesFrame.size).height - visibleHeight)
let targetOffsetY = min(max(0.0, selectedStyleItemFrame.midY - visibleHeight * 0.5), maxOffsetY)
self.stylesScrollView.contentOffset = CGPoint(x: 0.0, y: targetOffsetY)
}
}
transition.setFrame(view: self.dimView, frame: CGRect(origin: CGPoint(), size: availableSize))
if shouldAnimateIn {
self.animateIn()
}
return availableSize
}
}
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 LanguageItemComponent: Component {
let theme: PresentationTheme
let title: String
let isSelected: Bool
let action: () -> Void
init(
theme: PresentationTheme,
title: String,
isSelected: Bool,
action: @escaping () -> Void
) {
self.theme = theme
self.title = title
self.isSelected = isSelected
self.action = action
}
static func ==(lhs: LanguageItemComponent, rhs: LanguageItemComponent) -> Bool {
if lhs.theme !== rhs.theme {
return false
}
if lhs.title != rhs.title {
return false
}
if lhs.isSelected != rhs.isSelected {
return false
}
return true
}
final class View: UIView {
private var imageIcon: ComponentView<Empty>?
private let title = ComponentView<Empty>()
private var component: LanguageItemComponent?
private weak var state: EmptyComponentState?
override init(frame: CGRect) {
super.init(frame: frame)
self.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.onTapGesture(_:))))
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
@objc private func onTapGesture(_ recognizer: UITapGestureRecognizer) {
guard let component = self.component else {
return
}
if case .ended = recognizer.state {
component.action()
}
}
func update(component: LanguageItemComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
self.component = component
self.state = state
let size = CGSize(width: availableSize.width, height: 42.0)
let leftTitleInset: CGFloat = 60.0
let rightTitleInset: CGFloat = 8.0
let titleSize = self.title.update(
transition: .immediate,
component: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(string: component.title, font: Font.regular(17.0), textColor: component.theme.contextMenu.primaryColor))
)),
environment: {},
containerSize: CGSize(width: availableSize.width - leftTitleInset - rightTitleInset, height: 100.0)
)
let titleFrame = CGRect(origin: CGPoint(x: leftTitleInset, y: floorToScreenPixels((size.height - titleSize.height) * 0.5)), size: titleSize)
if let titleView = self.title.view {
if titleView.superview == nil {
titleView.isUserInteractionEnabled = false
self.addSubview(titleView)
}
titleView.frame = titleFrame
}
if component.isSelected {
let imageIcon: ComponentView<Empty>
var imageIconTransition = transition
if let current = self.imageIcon {
imageIcon = current
} else {
imageIconTransition = imageIconTransition.withAnimation(.none)
imageIcon = ComponentView()
self.imageIcon = imageIcon
}
let imageIconSize = imageIcon.update(
transition: imageIconTransition,
component: AnyComponent(BundleIconComponent(
name: "Chat/Context Menu/Check",
tintColor: component.theme.contextMenu.primaryColor
)),
environment: {},
containerSize: CGSize(width: 100.0, height: 100.0)
)
let imageIconFrame = CGRect(origin: CGPoint(x: 23.0, y: floorToScreenPixels((size.height - imageIconSize.height) * 0.5)), size: imageIconSize)
if let imageIconView = imageIcon.view {
if imageIconView.superview == nil {
imageIconView.isUserInteractionEnabled = false
self.addSubview(imageIconView)
}
imageIconTransition.setFrame(view: imageIconView, frame: imageIconFrame)
}
} else {
if let imageIcon = self.imageIcon {
self.imageIcon = nil
imageIcon.view?.removeFromSuperview()
}
}
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 StyleItemComponent: Component {
let context: AccountContext
let theme: PresentationTheme
let icon: String
let iconFileId: Int64?
let iconFile: TelegramMediaFile?
let title: String
let action: () -> Void
init(
context: AccountContext,
theme: PresentationTheme,
icon: String,
iconFileId: Int64?,
iconFile: TelegramMediaFile?,
title: String,
action: @escaping () -> Void
) {
self.context = context
self.theme = theme
self.icon = icon
self.iconFileId = iconFileId
self.iconFile = iconFile
self.title = title
self.action = action
}
static func ==(lhs: StyleItemComponent, rhs: StyleItemComponent) -> Bool {
if lhs.theme !== rhs.theme {
return false
}
if lhs.icon != rhs.icon {
return false
}
if lhs.iconFileId != rhs.iconFileId {
return false
}
if lhs.iconFile?.fileId != rhs.iconFile?.fileId {
return false
}
if lhs.title != rhs.title {
return false
}
return true
}
final class View: UIView {
private var imageIcon: ComponentView<Empty>?
private let title = ComponentView<Empty>()
private var component: StyleItemComponent?
private weak var state: EmptyComponentState?
override init(frame: CGRect) {
super.init(frame: frame)
self.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.onTapGesture(_:))))
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
@objc private func onTapGesture(_ recognizer: UITapGestureRecognizer) {
guard let component = self.component else {
return
}
if case .ended = recognizer.state {
component.action()
}
}
func update(component: StyleItemComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
let previousComponent = self.component
self.component = component
self.state = state
let iconTintColor = component.theme.list.itemPrimaryTextColor
if previousComponent?.iconFileId != component.iconFileId {
if let imageIcon = self.imageIcon {
self.imageIcon = nil
imageIcon.view?.removeFromSuperview()
}
}
let imageIcon: ComponentView<Empty>
var iconTransition = transition
if let current = self.imageIcon {
imageIcon = current
} else {
iconTransition = iconTransition.withAnimation(.none)
imageIcon = ComponentView()
self.imageIcon = imageIcon
}
let iconComponent: AnyComponent<Empty>
if let iconFileId = component.iconFileId {
let iconSize = CGSize(width: 34.0, height: 34.0)
let content: EmojiStatusComponent.AnimationContent
if let file = component.iconFile {
content = .file(file: file)
} else {
content = .customEmoji(fileId: iconFileId)
}
iconComponent = AnyComponent(EmojiStatusComponent(
context: component.context,
animationCache: component.context.animationCache,
animationRenderer: component.context.animationRenderer,
content: .animation(
content: content,
size: iconSize,
placeholderColor: component.theme.list.mediaPlaceholderColor,
themeColor: component.theme.list.itemAccentColor,
loopMode: .count(0)
),
size: iconSize,
isVisibleForAnimations: true,
action: nil
))
} else {
iconComponent = AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(string: component.icon, font: Font.regular(25.0), textColor: .black))
))
}
let iconSize = imageIcon.update(
transition: .immediate,
component: iconComponent,
environment: {},
containerSize: CGSize(width: 100.0, height: 100.0)
)
let iconFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - iconSize.width) * 0.5), y: 8.0), size: iconSize)
if let imageIconView = imageIcon.view {
if imageIconView.superview == nil {
self.addSubview(imageIconView)
}
iconTransition.setFrame(view: imageIconView, frame: iconFrame)
}
let titleSize = self.title.update(
transition: .immediate,
component: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(string: component.title, font: Font.semibold(10.0), textColor: iconTintColor))
)),
environment: {},
containerSize: CGSize(width: availableSize.width, height: 100.0)
)
let titleFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - titleSize.width) * 0.5), y: availableSize.height - 9.0 - titleSize.height), size: titleSize)
if let titleView = self.title.view {
if titleView.superview == nil {
self.addSubview(titleView)
}
titleView.frame = titleFrame
}
return availableSize
}
}
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 SearchItemComponent: Component {
final class ExternalState {
var isEditing: Bool = false
}
let theme: PresentationTheme
let placeholder: String
let externalState: ExternalState
let valueChanged: (String) -> Void
init(
theme: PresentationTheme,
placeholder: String,
externalState: ExternalState,
valueChanged: @escaping (String) -> Void
) {
self.theme = theme
self.placeholder = placeholder
self.externalState = externalState
self.valueChanged = valueChanged
}
static func ==(lhs: SearchItemComponent, rhs: SearchItemComponent) -> Bool {
if lhs.theme !== rhs.theme {
return false
}
if lhs.placeholder != rhs.placeholder {
return false
}
return true
}
final class View: UIView, UITextFieldDelegate {
private let icon = ComponentView<Empty>()
private let textField: UITextField
private let clearButton = ComponentView<Empty>()
private var component: SearchItemComponent?
override init(frame: CGRect) {
self.textField = UITextField()
super.init(frame: frame)
self.textField.autocorrectionType = .no
self.textField.autocapitalizationType = .none
self.textField.returnKeyType = .search
self.textField.delegate = self
self.textField.addTarget(self, action: #selector(self.textFieldChanged(_:)), for: .editingChanged)
self.addSubview(self.textField)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
@objc private func textFieldChanged(_ textField: UITextField) {
self.component?.valueChanged(textField.text ?? "")
self.updateClearButton()
}
func textFieldDidBeginEditing(_ textField: UITextField) {
self.component?.externalState.isEditing = true
self.component?.valueChanged(textField.text ?? "")
}
func textFieldDidEndEditing(_ textField: UITextField) {
self.component?.externalState.isEditing = false
self.component?.valueChanged(textField.text ?? "")
}
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
textField.resignFirstResponder()
return true
}
private func updateClearButton() {
if let clearView = self.clearButton.view {
clearView.isHidden = (self.textField.text ?? "").isEmpty
}
}
func update(component: SearchItemComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
self.component = component
let size = CGSize(width: availableSize.width, height: 42.0)
// Search icon
let iconSize = self.icon.update(
transition: .immediate,
component: AnyComponent(BundleIconComponent(
name: "Chat/Context Menu/Search",
tintColor: component.theme.contextMenu.primaryColor
)),
environment: {},
containerSize: CGSize(width: 100.0, height: 100.0)
)
let iconFrame = CGRect(origin: CGPoint(x: 23.0, y: floorToScreenPixels((size.height - iconSize.height) * 0.5)), size: iconSize)
if let iconView = self.icon.view {
if iconView.superview == nil {
iconView.isUserInteractionEnabled = false
self.addSubview(iconView)
}
iconView.frame = iconFrame
}
// Text field
let inputInset: CGFloat = 60.0
let inputRightInset: CGFloat = 36.0
self.textField.font = Font.regular(17.0)
self.textField.textColor = component.theme.contextMenu.primaryColor
self.textField.attributedPlaceholder = NSAttributedString(
string: component.placeholder,
attributes: [
.font: Font.regular(17.0),
.foregroundColor: component.theme.contextMenu.secondaryColor
]
)
self.textField.tintColor = component.theme.list.itemAccentColor
self.textField.keyboardAppearance = component.theme.overallDarkAppearance ? .dark : .light
self.textField.frame = CGRect(
x: inputInset,
y: 0.0,
width: size.width - inputInset - inputRightInset,
height: size.height
)
// Clear button
let clearSize = self.clearButton.update(
transition: .immediate,
component: AnyComponent(
Button(
content: AnyComponent(
BundleIconComponent(
name: "Components/Search Bar/Clear",
tintColor: component.theme.contextMenu.secondaryColor,
maxSize: CGSize(width: 24.0, height: 24.0)
)
),
action: { [weak self] in
guard let self else { return }
self.textField.text = ""
self.component?.valueChanged("")
self.updateClearButton()
}
)
),
environment: {},
containerSize: CGSize(width: 30.0, height: 30.0)
)
let clearFrame = CGRect(
origin: CGPoint(
x: size.width - clearSize.width - 10.0,
y: floorToScreenPixels((size.height - clearSize.height) * 0.5)
),
size: clearSize
)
if let clearView = self.clearButton.view {
if clearView.superview == nil {
self.addSubview(clearView)
}
clearView.frame = clearFrame
clearView.isHidden = (self.textField.text ?? "").isEmpty
}
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)
}
}