import Foundation import UIKit import Display import AsyncDisplayKit import ComponentFlow import PlainButtonComponent import MultilineTextWithEntitiesComponent import TextFormat import AccountContext import TelegramPresentationData public final class TabSelectorComponent: Component { public final class ItemEnvironment: Equatable { public let selectionFraction: CGFloat init(selectionFraction: CGFloat) { self.selectionFraction = selectionFraction } public static func ==(lhs: ItemEnvironment, rhs: ItemEnvironment) -> Bool { if lhs.selectionFraction != rhs.selectionFraction { return false } return true } } public struct Colors: Equatable { public var foreground: UIColor public var selection: UIColor public var simple: Bool public init( foreground: UIColor, selection: UIColor, simple: Bool = false ) { self.foreground = foreground self.selection = selection self.simple = simple } } public struct CustomLayout: Equatable { public var font: UIFont public var spacing: CGFloat public var innerSpacing: CGFloat? public var lineSelection: Bool public var verticalInset: CGFloat public var allowScroll: Bool public init(font: UIFont, spacing: CGFloat, innerSpacing: CGFloat? = nil, lineSelection: Bool = false, verticalInset: CGFloat = 0.0, allowScroll: Bool = true) { self.font = font self.spacing = spacing self.innerSpacing = innerSpacing self.lineSelection = lineSelection self.verticalInset = verticalInset self.allowScroll = allowScroll } } public final class Item: Equatable { public enum Content: Equatable { case text(String) case component(AnyComponent) } public let id: AnyHashable public let content: Content public let isReorderable: Bool public let contextAction: ((ASDisplayNode, ContextGesture) -> Void)? public init( id: AnyHashable, content: Content, isReorderable: Bool = false, contextAction: ((ASDisplayNode, ContextGesture) -> Void)? = nil ) { self.id = id self.content = content self.isReorderable = isReorderable self.contextAction = contextAction } convenience public init( id: AnyHashable, title: String, isReorderable: Bool = false, contextAction: ((ASDisplayNode, ContextGesture) -> Void)? = nil ) { self.init(id: id, content: .text(title), isReorderable: isReorderable, contextAction: contextAction) } public static func ==(lhs: Item, rhs: Item) -> Bool { if lhs.id != rhs.id { return false } if lhs.content != rhs.content { return false } if lhs.isReorderable != rhs.isReorderable { return false } if (lhs.contextAction == nil) != (rhs.contextAction == nil) { return false } return true } } public let context: AccountContext? public let colors: Colors public let theme: PresentationTheme public let customLayout: CustomLayout? public let items: [Item] public let selectedId: AnyHashable? public let reorderItem: ((AnyHashable, AnyHashable) -> Void)? public let setSelectedId: (AnyHashable) -> Void public let transitionFraction: CGFloat? public init( context: AccountContext? = nil, colors: Colors, theme: PresentationTheme, customLayout: CustomLayout? = nil, items: [Item], selectedId: AnyHashable?, reorderItem: ((AnyHashable, AnyHashable) -> Void)? = nil, setSelectedId: @escaping (AnyHashable) -> Void, transitionFraction: CGFloat? = nil ) { self.context = context self.colors = colors self.theme = theme self.customLayout = customLayout self.items = items self.selectedId = selectedId self.reorderItem = reorderItem self.setSelectedId = setSelectedId self.transitionFraction = transitionFraction } public static func ==(lhs: TabSelectorComponent, rhs: TabSelectorComponent) -> Bool { if lhs.context !== rhs.context { return false } if lhs.colors != rhs.colors { return false } if lhs.theme !== rhs.theme { return false } if lhs.customLayout != rhs.customLayout { return false } if lhs.items != rhs.items { return false } if lhs.selectedId != rhs.selectedId { return false } if (lhs.reorderItem == nil) != (rhs.reorderItem == nil) { return false } if lhs.transitionFraction != rhs.transitionFraction { return false } return true } final class VisibleItem: UIView { let action: () -> Void let contextAction: (ASDisplayNode, ContextGesture) -> Void let extractedContainerNode: ContextExtractedContentContainingNode let containerNode: ContextControllerSourceNode let containerButton: UIView var extractedBackgroundView: UIImageView? let title = ComponentView() var item: Item? var tapGesture: UITapGestureRecognizer? var theme: PresentationTheme? var size: CGSize? var isReordering: Bool = false init(action: @escaping () -> Void, contextAction: @escaping (ASDisplayNode, ContextGesture) -> Void) { self.action = action self.contextAction = contextAction self.extractedContainerNode = ContextExtractedContentContainingNode() self.containerNode = ContextControllerSourceNode() self.containerButton = UIView() super.init(frame: CGRect()) self.extractedContainerNode.contentNode.view.addSubview(self.containerButton) self.containerNode.addSubnode(self.extractedContainerNode) self.containerNode.targetNodeForActivationProgress = self.extractedContainerNode.contentNode self.addSubview(self.containerNode.view) //self.containerButton.addSubview(self.iconContainer) let tapGesture = UITapGestureRecognizer(target: self, action: #selector(self.onTapGesture(_:))) self.tapGesture = tapGesture self.containerButton.addGestureRecognizer(tapGesture) tapGesture.isEnabled = false self.containerNode.activated = { [weak self] gesture, _ in guard let self else { return } self.contextAction(self.extractedContainerNode, gesture) } self.extractedContainerNode.willUpdateIsExtractedToContextPreview = { [weak self] isExtracted, transition in guard let self, let theme = self.theme, let size = self.size else { return } if isExtracted { let extractedBackgroundView: UIImageView if let current = self.extractedBackgroundView { extractedBackgroundView = current } else { extractedBackgroundView = UIImageView(image: generateStretchableFilledCircleImage(diameter: size.height, color: theme.contextMenu.backgroundColor)) self.extractedBackgroundView = extractedBackgroundView self.extractedContainerNode.contentNode.view.insertSubview(extractedBackgroundView, at: 0) extractedBackgroundView.frame = self.extractedContainerNode.contentNode.bounds.insetBy(dx: 0.0, dy: 0.0) extractedBackgroundView.alpha = 0.0 } transition.updateAlpha(layer: extractedBackgroundView.layer, alpha: 1.0) } else if let extractedBackgroundView = self.extractedBackgroundView { self.extractedBackgroundView = nil let alphaTransition: ContainedViewLayoutTransition if transition.isAnimated { alphaTransition = .animated(duration: 0.18, curve: .easeInOut) } else { alphaTransition = .immediate } alphaTransition.updateAlpha(layer: extractedBackgroundView.layer, alpha: 0.0, completion: { [weak extractedBackgroundView] _ in extractedBackgroundView?.removeFromSuperview() }) } } self.containerNode.isGestureEnabled = false } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } @objc private func onTapGesture(_ recognizer: UITapGestureRecognizer) { if case .ended = recognizer.state { self.action() } } private func updateIsShaking(animated: Bool) { if self.isReordering { if self.containerButton.layer.animation(forKey: "shaking_position") == nil { let degreesToRadians: (_ x: CGFloat) -> CGFloat = { x in return .pi * x / 180.0 } let duration: Double = 0.4 let displacement: CGFloat = 1.0 let degreesRotation: CGFloat = 2.0 let negativeDisplacement = -1.0 * displacement let position = CAKeyframeAnimation.init(keyPath: "position") position.beginTime = 0.8 position.duration = duration position.values = [ NSValue(cgPoint: CGPoint(x: negativeDisplacement, y: negativeDisplacement)), NSValue(cgPoint: CGPoint(x: 0, y: 0)), NSValue(cgPoint: CGPoint(x: negativeDisplacement, y: 0)), NSValue(cgPoint: CGPoint(x: 0, y: negativeDisplacement)), NSValue(cgPoint: CGPoint(x: negativeDisplacement, y: negativeDisplacement)) ] position.calculationMode = .linear position.isRemovedOnCompletion = false position.repeatCount = Float.greatestFiniteMagnitude position.beginTime = CFTimeInterval(Float(arc4random()).truncatingRemainder(dividingBy: Float(25)) / Float(100)) position.isAdditive = true let transform = CAKeyframeAnimation.init(keyPath: "transform") transform.beginTime = 2.6 transform.duration = 0.3 transform.valueFunction = CAValueFunction(name: CAValueFunctionName.rotateZ) transform.values = [ degreesToRadians(-1.0 * degreesRotation), degreesToRadians(degreesRotation), degreesToRadians(-1.0 * degreesRotation) ] transform.calculationMode = .linear transform.isRemovedOnCompletion = false transform.repeatCount = Float.greatestFiniteMagnitude transform.isAdditive = true transform.beginTime = CFTimeInterval(Float(arc4random()).truncatingRemainder(dividingBy: Float(25)) / Float(100)) self.containerButton.layer.add(position, forKey: "shaking_position") self.containerButton.layer.add(transform, forKey: "shaking_rotation") } } else if self.containerButton.layer.animation(forKey: "shaking_position") != nil { if let presentationLayer = self.containerButton.layer.presentation() { let transition: ComponentTransition = .easeInOut(duration: 0.1) if presentationLayer.position != self.containerButton.layer.position { transition.animatePosition(layer: self.containerButton.layer, from: CGPoint(x: presentationLayer.position.x - self.containerButton.layer.position.x, y: presentationLayer.position.y - self.containerButton.layer.position.y), to: CGPoint(), additive: true) } if !CATransform3DIsIdentity(presentationLayer.transform) { transition.setTransform(layer: self.containerButton.layer, transform: CATransform3DIdentity) } } self.containerButton.layer.removeAnimation(forKey: "shaking_position") self.containerButton.layer.removeAnimation(forKey: "shaking_rotation") } } func update(theme: PresentationTheme, size: CGSize, item: Item, isReordering: Bool, transition: ComponentTransition) { self.theme = theme self.size = size self.isReordering = isReordering self.item = item self.containerNode.isGestureEnabled = item.contextAction != nil && !isReordering self.tapGesture?.isEnabled = !isReordering transition.setFrame(view: self.containerButton, frame: CGRect(origin: CGPoint(), size: size)) self.extractedContainerNode.frame = CGRect(origin: CGPoint(), size: size) self.extractedContainerNode.contentNode.frame = CGRect(origin: CGPoint(), size: size) self.extractedContainerNode.contentRect = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: size.width, height: size.height)) self.containerNode.frame = CGRect(origin: CGPoint(), size: size) self.updateIsShaking(animated: !transition.animation.isImmediate) } } public final class View: UIScrollView { private var component: TabSelectorComponent? private weak var state: EmptyComponentState? private let selectionView: UIImageView private var visibleItems: [AnyHashable: VisibleItem] = [:] private var didInitiallyScroll = false private var reorderRecognizer: ReorderGestureRecognizer? private weak var reorderingItem: VisibleItem? private var reorderingItemPosition: (initial: CGFloat, offset: CGFloat) = (0.0, 0.0) override init(frame: CGRect) { self.selectionView = UIImageView() super.init(frame: frame) self.showsVerticalScrollIndicator = false self.showsHorizontalScrollIndicator = false self.scrollsToTop = false self.delaysContentTouches = false self.canCancelContentTouches = true self.contentInsetAdjustmentBehavior = .never self.alwaysBounceVertical = false self.clipsToBounds = false self.addSubview(self.selectionView) let reorderRecognizer = ReorderGestureRecognizer( shouldBegin: { [weak self] point in guard let self, let component = self.component, component.reorderItem != nil else { return (allowed: false, requiresLongPress: false, item: nil) } var item: VisibleItem? for (_, visibleItem) in self.visibleItems { if visibleItem.bounds.contains(self.convert(point, to: visibleItem)) { item = visibleItem break } } if let item, let itemValue = item.item, itemValue.isReorderable { return (allowed: true, requiresLongPress: false, item: item) } else { return (allowed: false, requiresLongPress: false, item: nil) } }, willBegin: { point in }, began: { [weak self] item in guard let self else { return } self.setReorderingItem(item: item) }, ended: { [weak self] in guard let self else { return } self.setReorderingItem(item: nil) }, moved: { [weak self] distance in guard let self else { return } self.moveReorderingItem(distance: distance.x) }, isActiveUpdated: { _ in } ) self.reorderRecognizer = reorderRecognizer self.addGestureRecognizer(reorderRecognizer) reorderRecognizer.isEnabled = false } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } deinit { } override public func touchesShouldCancel(in view: UIView) -> Bool { return true } private func setReorderingItem(item: VisibleItem?) { self.reorderingItem = item if let item { self.reorderingItemPosition.initial = item.frame.minX self.reorderingItemPosition.offset = 0.0 } else { self.reorderingItemPosition = (0.0, 0.0) } self.state?.updated(transition: .easeInOut(duration: 0.2)) } private func moveReorderingItem(distance: CGFloat) { guard let reorderingItem = self.reorderingItem else { return } let previousPosition = self.reorderingItemPosition.initial + self.reorderingItemPosition.offset + reorderingItem.bounds.width * 0.5 self.reorderingItemPosition.offset = distance let updatedPosition = self.reorderingItemPosition.initial + self.reorderingItemPosition.offset + reorderingItem.bounds.width * 0.5 self.state?.updated(transition: .immediate) if let component = self.component, let reorderItem = component.reorderItem { var currentId: AnyHashable? var reorderToId: AnyHashable? for (id, item) in self.visibleItems { if item === reorderingItem { currentId = id continue } guard let targetItem = item.item else { continue } if !targetItem.isReorderable { continue } if reorderToId != nil { continue } let itemCenter = item.center.x if previousPosition < itemCenter && updatedPosition > itemCenter { reorderToId = id } else if previousPosition > itemCenter && updatedPosition < itemCenter { reorderToId = id } } if let currentId, let reorderToId { reorderItem(currentId, reorderToId) } } } func update(component: TabSelectorComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { let selectionColorUpdated = component.colors.selection != self.component?.colors.selection self.component = component self.state = state self.reorderRecognizer?.isEnabled = component.reorderItem != nil let baseHeight: CGFloat = 28.0 var verticalInset: CGFloat = 0.0 if let customLayout = component.customLayout { verticalInset = customLayout.verticalInset * 2.0 } var innerInset: CGFloat = component.customLayout?.innerSpacing ?? 12.0 var spacing: CGFloat = component.customLayout?.spacing ?? 2.0 let itemFont: UIFont var isLineSelection = false let allowScroll: Bool if let customLayout = component.customLayout { itemFont = customLayout.font isLineSelection = customLayout.lineSelection allowScroll = customLayout.allowScroll || component.items.count > 3 } else { itemFont = Font.semibold(14.0) allowScroll = true } if selectionColorUpdated { if isLineSelection { self.selectionView.image = generateImage(CGSize(width: 5.0, height: 3.0), rotatedContext: { size, context in context.clear(CGRect(origin: CGPoint(), size: size)) context.setFillColor(component.colors.selection.cgColor) context.fillEllipse(in: CGRect(origin: CGPoint(), size: CGSize(width: 4.0, height: 4.0))) context.fillEllipse(in: CGRect(origin: CGPoint(x: size.width - 4.0, y: 0.0), size: CGSize(width: 4.0, height: 4.0))) context.fill(CGRect(x: 2.0, y: 0.0, width: size.width - 4.0, height: 4.0)) context.fill(CGRect(x: 0.0, y: 2.0, width: size.width, height: 2.0)) })?.resizableImage(withCapInsets: UIEdgeInsets(top: 3.0, left: 3.0, bottom: 0.0, right: 3.0), resizingMode: .stretch) } else { self.selectionView.image = generateStretchableFilledCircleImage(diameter: baseHeight, color: component.colors.selection) } } var innerContentWidth: CGFloat = 0.0 let selectedIndex = component.items.firstIndex(where: { $0.id == component.selectedId }) var validIds: [AnyHashable] = [] var index = 0 var itemViews: [AnyHashable: (VisibleItem, CGSize, ComponentTransition)] = [:] for item in component.items { var itemTransition = transition let itemView: VisibleItem if let current = self.visibleItems[item.id] { itemView = current } else { let itemId = item.id itemView = VisibleItem(action: { [weak self] in guard let self, let component = self.component else { return } guard let item = component.items.first(where: { $0.id == itemId }) else { return } component.setSelectedId(item.id) }, contextAction: { [weak self] sourceNode, gesture in guard let self, let component = self.component else { return } guard let item = component.items.first(where: { $0.id == itemId }) else { return } item.contextAction?(sourceNode, gesture) }) self.visibleItems[item.id] = itemView itemTransition = itemTransition.withAnimation(.none) } let itemId = item.id validIds.append(itemId) var selectionFraction: CGFloat = 0.0 if let transitionFraction = component.transitionFraction, let selectedIndex { if item.id == component.selectedId { selectionFraction = 1.0 - abs(transitionFraction) } else { if index == selectedIndex - 1 && transitionFraction < 0.0 { selectionFraction = abs(transitionFraction) } else if index == selectedIndex + 1 && transitionFraction > 0.0 { selectionFraction = abs(transitionFraction) } } } else { selectionFraction = item.id == component.selectedId ? 1.0 : 0.0 } var useSelectionFraction = isLineSelection if case .component = item.content { useSelectionFraction = true } let itemSize = itemView.title.update( transition: .immediate, component: AnyComponent(ItemComponent( context: component.context, content: item.content, font: itemFont, color: component.colors.foreground, selectedColor: component.colors.selection, selectionFraction: useSelectionFraction ? selectionFraction : 0.0 )), environment: {}, containerSize: CGSize(width: 200.0, height: 100.0) ) innerContentWidth += itemSize.width itemViews[item.id] = (itemView, itemSize, itemTransition) index += 1 } let estimatedContentWidth = 2.0 * spacing + innerContentWidth + (CGFloat(component.items.count - 1) * (spacing + innerInset)) if estimatedContentWidth > availableSize.width && !allowScroll { spacing = (availableSize.width - innerContentWidth) / CGFloat(component.items.count + 1) innerInset = 0.0 } var contentWidth: CGFloat = spacing var previousBackgroundRect: CGRect? var selectedBackgroundRect: CGRect? var nextBackgroundRect: CGRect? var selectedItemIsReordering = false for item in component.items { guard let (itemView, itemSize, itemTransition) = itemViews[item.id] else { continue } if contentWidth > spacing { contentWidth += spacing } let baseItemTitleFrame = CGRect(origin: CGPoint(x: contentWidth + innerInset, y: verticalInset + floor((baseHeight - itemSize.height) * 0.5)), size: itemSize) var itemBackgroundRect = CGRect(origin: CGPoint(x: contentWidth, y: verticalInset), size: CGSize(width: innerInset + itemSize.width + innerInset, height: baseHeight)) let itemTitleFrame = CGRect(origin: CGPoint(x: baseItemTitleFrame.minX - itemBackgroundRect.minX, y: baseItemTitleFrame.minY - itemBackgroundRect.minY), size: baseItemTitleFrame.size) contentWidth = itemBackgroundRect.maxX if self.reorderingItem === itemView { itemBackgroundRect.origin.x = self.reorderingItemPosition.initial + self.reorderingItemPosition.offset if item.id == component.selectedId { selectedItemIsReordering = true } } if item.id == component.selectedId { selectedBackgroundRect = itemBackgroundRect } if selectedBackgroundRect == nil { previousBackgroundRect = itemBackgroundRect } else if nextBackgroundRect == nil, itemBackgroundRect != selectedBackgroundRect { nextBackgroundRect = itemBackgroundRect } if itemView.superview == nil { self.addSubview(itemView) } if let itemTitleView = itemView.title.view { if itemTitleView.superview == nil { itemTitleView.layer.anchorPoint = CGPoint() itemTitleView.isUserInteractionEnabled = false itemView.containerButton.addSubview(itemTitleView) } itemTransition.setPosition(view: itemView, position: itemBackgroundRect.center) itemTransition.setBounds(view: itemView, bounds: CGRect(origin: CGPoint(), size: itemBackgroundRect.size)) if self.reorderingItem === itemView { itemTransition.setTransform(view: itemView, transform: CATransform3DMakeScale(1.1, 1.1, 1.0)) } else { itemTransition.setTransform(view: itemView, transform: CATransform3DIdentity) } itemView.update(theme: component.theme, size: itemBackgroundRect.size, item: item, isReordering: item.isReorderable && component.reorderItem != nil, transition: itemTransition) itemTransition.setPosition(view: itemTitleView, position: CGPoint(x: itemTitleFrame.minX, y: itemTitleFrame.minY)) itemTransition.setBounds(view: itemTitleView, bounds: CGRect(origin: CGPoint(), size: itemTitleFrame.size)) var itemAlpha: CGFloat = item.id == component.selectedId || isLineSelection || component.colors.simple ? 1.0 : 0.4 if component.reorderItem != nil && !item.isReorderable { itemAlpha *= 0.5 itemView.isUserInteractionEnabled = false } else { itemView.isUserInteractionEnabled = true } itemTransition.setAlpha(view: itemTitleView, alpha: itemAlpha) } } contentWidth += spacing var removeIds: [AnyHashable] = [] for (id, itemView) in self.visibleItems { if !validIds.contains(id) { removeIds.append(id) itemView.removeFromSuperview() } } for id in removeIds { self.visibleItems.removeValue(forKey: id) } if let selectedBackgroundRect { self.selectionView.alpha = 1.0 if isLineSelection { var effectiveBackgroundRect = selectedBackgroundRect if let transitionFraction = component.transitionFraction { if transitionFraction < 0.0 { if let previousBackgroundRect { effectiveBackgroundRect = effectiveBackgroundRect.interpolate(with: previousBackgroundRect, fraction: abs(transitionFraction)) } } else if transitionFraction > 0.0 { if let nextBackgroundRect { effectiveBackgroundRect = effectiveBackgroundRect.interpolate(with: nextBackgroundRect, fraction: abs(transitionFraction)) } } } var mappedSelectionFrame = effectiveBackgroundRect.insetBy(dx: innerInset, dy: 0.0) mappedSelectionFrame.origin.y = mappedSelectionFrame.maxY + 6.0 mappedSelectionFrame.size.height = 3.0 transition.setPosition(view: self.selectionView, position: mappedSelectionFrame.center) transition.setBounds(view: self.selectionView, bounds: CGRect(origin: CGPoint(), size: mappedSelectionFrame.size)) transition.setTransform(view: self.selectionView, transform: CATransform3DIdentity) } else { transition.setPosition(view: self.selectionView, position: selectedBackgroundRect.center) transition.setBounds(view: self.selectionView, bounds: CGRect(origin: CGPoint(), size: selectedBackgroundRect.size)) if selectedItemIsReordering { transition.setTransform(view: self.selectionView, transform: CATransform3DMakeScale(1.1, 1.1, 1.0)) } else { transition.setTransform(view: self.selectionView, transform: CATransform3DIdentity) } } } else { self.selectionView.alpha = 0.0 } let contentSize = CGSize(width: contentWidth, height: baseHeight + verticalInset * 2.0) if self.contentSize != contentSize { self.contentSize = contentSize } self.disablesInteractiveTransitionGestureRecognizer = contentWidth > availableSize.width if let selectedBackgroundRect, self.bounds.width > 0.0 && !self.didInitiallyScroll { self.scrollRectToVisible(selectedBackgroundRect.insetBy(dx: -spacing, dy: 0.0), animated: false) self.didInitiallyScroll = true } return CGSize(width: min(contentWidth, availableSize.width), height: baseHeight + verticalInset * 2.0) } } public func makeView() -> View { return View(frame: CGRect()) } public func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) } } extension CGRect { func interpolate(with other: CGRect, fraction: CGFloat) -> CGRect { return CGRect( x: self.origin.x * (1.0 - fraction) + (other.origin.x) * fraction, y: self.origin.y * (1.0 - fraction) + (other.origin.y) * fraction, width: self.size.width * (1.0 - fraction) + (other.size.width) * fraction, height: self.size.height * (1.0 - fraction) + (other.size.height) * fraction ) } } private final class ItemComponent: CombinedComponent { let context: AccountContext? let content: TabSelectorComponent.Item.Content let font: UIFont let color: UIColor let selectedColor: UIColor let selectionFraction: CGFloat init( context: AccountContext?, content: TabSelectorComponent.Item.Content, font: UIFont, color: UIColor, selectedColor: UIColor, selectionFraction: CGFloat ) { self.context = context self.content = content self.font = font self.color = color self.selectedColor = selectedColor self.selectionFraction = selectionFraction } static func ==(lhs: ItemComponent, rhs: ItemComponent) -> Bool { if lhs.context !== rhs.context { return false } if lhs.content != rhs.content { return false } if lhs.font != rhs.font { return false } if lhs.color != rhs.color { return false } if lhs.selectedColor != rhs.selectedColor { return false } if lhs.selectionFraction != rhs.selectionFraction { return false } return true } static var body: Body { let title = Child(MultilineTextWithEntitiesComponent.self) let selectedTitle = Child(MultilineTextWithEntitiesComponent.self) let contentComponent = Child(environment: TabSelectorComponent.ItemEnvironment.self) return { context in let component = context.component switch component.content { case let .text(text): let attributedTitle = NSMutableAttributedString(string: text, font: component.font, textColor: component.color) var range = (attributedTitle.string as NSString).range(of: "⭐️") if range.location != NSNotFound { attributedTitle.addAttribute(ChatTextInputAttributes.customEmoji, value: ChatTextInputTextCustomEmojiAttribute(interactivelySelectedFromPackId: nil, fileId: 0, file: nil, custom: .stars(tinted: false)), range: range) } let title = title.update( component: MultilineTextWithEntitiesComponent( context: component.context, animationCache: component.context?.animationCache, animationRenderer: component.context?.animationRenderer, placeholderColor: .white, text: .plain(attributedTitle) ), availableSize: context.availableSize, transition: .immediate ) context.add(title .position(CGPoint(x: title.size.width / 2.0, y: title.size.height / 2.0)) .opacity(1.0 - component.selectionFraction) ) let selectedAttributedTitle = NSMutableAttributedString(string: text, font: component.font, textColor: component.selectedColor) range = (selectedAttributedTitle.string as NSString).range(of: "⭐️") if range.location != NSNotFound { selectedAttributedTitle.addAttribute(ChatTextInputAttributes.customEmoji, value: ChatTextInputTextCustomEmojiAttribute(interactivelySelectedFromPackId: nil, fileId: 0, file: nil, custom: .stars(tinted: false)), range: range) } let selectedTitle = selectedTitle.update( component: MultilineTextWithEntitiesComponent( context: nil, animationCache: nil, animationRenderer: nil, placeholderColor: .white, text: .plain(selectedAttributedTitle) ), availableSize: context.availableSize, transition: .immediate ) context.add(selectedTitle .position(CGPoint(x: selectedTitle.size.width / 2.0, y: selectedTitle.size.height / 2.0)) .opacity(component.selectionFraction) ) return title.size case let .component(contentComponentValue): let content = contentComponent.update( contentComponentValue, environment: { TabSelectorComponent.ItemEnvironment(selectionFraction: component.selectionFraction) }, availableSize: context.availableSize, transition: .immediate ) context.add(content .position(CGPoint(x: content.size.width / 2.0, y: content.size.height / 2.0)) ) return content.size } } } } private final class ReorderGestureRecognizer: UIGestureRecognizer { private let shouldBegin: (CGPoint) -> (allowed: Bool, requiresLongPress: Bool, item: TabSelectorComponent.VisibleItem?) private let willBegin: (CGPoint) -> Void private let began: (TabSelectorComponent.VisibleItem) -> Void private let ended: () -> Void private let moved: (CGPoint) -> Void private let isActiveUpdated: (Bool) -> Void private var initialLocation: CGPoint? private var longTapTimer: Foundation.Timer? private var longPressTimer: Foundation.Timer? private var itemView: TabSelectorComponent.VisibleItem? public init(shouldBegin: @escaping (CGPoint) -> (allowed: Bool, requiresLongPress: Bool, item: TabSelectorComponent.VisibleItem?), willBegin: @escaping (CGPoint) -> Void, began: @escaping (TabSelectorComponent.VisibleItem) -> Void, ended: @escaping () -> Void, moved: @escaping (CGPoint) -> Void, isActiveUpdated: @escaping (Bool) -> Void) { self.shouldBegin = shouldBegin self.willBegin = willBegin self.began = began self.ended = ended self.moved = moved self.isActiveUpdated = isActiveUpdated super.init(target: nil, action: nil) } deinit { self.longTapTimer?.invalidate() self.longPressTimer?.invalidate() } private func startLongTapTimer() { self.longTapTimer?.invalidate() let longTapTimer = Foundation.Timer.scheduledTimer(withTimeInterval: 0.25, repeats: false, block: { [weak self] _ in self?.longTapTimerFired() }) self.longTapTimer = longTapTimer } private func stopLongTapTimer() { self.itemView = nil self.longTapTimer?.invalidate() self.longTapTimer = nil } private func startLongPressTimer() { self.longPressTimer?.invalidate() let longPressTimer = Foundation.Timer.scheduledTimer(withTimeInterval: 0.6, repeats: false, block: { [weak self] _ in self?.longPressTimerFired() }) self.longPressTimer = longPressTimer } private func stopLongPressTimer() { self.itemView = nil self.longPressTimer?.invalidate() self.longPressTimer = nil } override public func reset() { super.reset() self.itemView = nil self.stopLongTapTimer() self.stopLongPressTimer() self.initialLocation = nil self.isActiveUpdated(false) } private func longTapTimerFired() { guard let location = self.initialLocation else { return } self.longTapTimer?.invalidate() self.longTapTimer = nil self.willBegin(location) } private func longPressTimerFired() { guard let _ = self.initialLocation else { return } self.isActiveUpdated(true) self.state = .began self.longPressTimer?.invalidate() self.longPressTimer = nil self.longTapTimer?.invalidate() self.longTapTimer = nil if let itemView = self.itemView { self.began(itemView) } self.isActiveUpdated(true) } override public func touchesBegan(_ touches: Set, with event: UIEvent) { super.touchesBegan(touches, with: event) if self.numberOfTouches > 1 { self.isActiveUpdated(false) self.state = .failed self.ended() return } if self.state == .possible { if let location = touches.first?.location(in: self.view) { let (allowed, requiresLongPress, itemView) = self.shouldBegin(location) if allowed { self.isActiveUpdated(true) self.itemView = itemView self.initialLocation = location if requiresLongPress { self.startLongTapTimer() self.startLongPressTimer() } else { self.state = .began if let itemView = self.itemView { self.began(itemView) } } } else { self.isActiveUpdated(false) self.state = .failed } } else { self.isActiveUpdated(false) self.state = .failed } } } override public func touchesEnded(_ touches: Set, with event: UIEvent) { super.touchesEnded(touches, with: event) self.initialLocation = nil self.stopLongTapTimer() if self.longPressTimer != nil { self.stopLongPressTimer() self.isActiveUpdated(false) self.state = .failed } if self.state == .began || self.state == .changed { self.isActiveUpdated(false) self.ended() self.state = .failed } } override public func touchesCancelled(_ touches: Set, with event: UIEvent) { super.touchesCancelled(touches, with: event) self.initialLocation = nil self.stopLongTapTimer() if self.longPressTimer != nil { self.isActiveUpdated(false) self.stopLongPressTimer() self.state = .failed } if self.state == .began || self.state == .changed { self.isActiveUpdated(false) self.ended() self.state = .failed } } override public func touchesMoved(_ touches: Set, with event: UIEvent) { super.touchesMoved(touches, with: event) if (self.state == .began || self.state == .changed), let initialLocation = self.initialLocation, let location = touches.first?.location(in: self.view) { self.state = .changed let offset = CGPoint(x: location.x - initialLocation.x, y: 0.0) self.moved(offset) } else if let touch = touches.first, let initialTapLocation = self.initialLocation, self.longPressTimer != nil { let touchLocation = touch.location(in: self.view) let dX = touchLocation.x - initialTapLocation.x if dX > 3.0 { self.stopLongTapTimer() self.stopLongPressTimer() self.initialLocation = nil self.isActiveUpdated(false) self.state = .failed } } } }