import Foundation import SwiftSignalKit import UIKit import Display import ComponentFlow import PagerComponent import TelegramPresentationData import TelegramCore import Postbox import AnimationCache import MultiAnimationRenderer import AccountContext import MultilineTextComponent import LottieAnimationComponent final class EntityKeyboardAnimationTopPanelComponent: Component { typealias EnvironmentType = EntityKeyboardTopPanelItemEnvironment let context: AccountContext let file: TelegramMediaFile let animationCache: AnimationCache let animationRenderer: MultiAnimationRenderer let theme: PresentationTheme let title: String let pressed: () -> Void init( context: AccountContext, file: TelegramMediaFile, animationCache: AnimationCache, animationRenderer: MultiAnimationRenderer, theme: PresentationTheme, title: String, pressed: @escaping () -> Void ) { self.context = context self.file = file self.animationCache = animationCache self.animationRenderer = animationRenderer self.theme = theme self.title = title self.pressed = pressed } static func ==(lhs: EntityKeyboardAnimationTopPanelComponent, rhs: EntityKeyboardAnimationTopPanelComponent) -> Bool { if lhs.context !== rhs.context { return false } if lhs.file.fileId != rhs.file.fileId { return false } if lhs.animationCache !== rhs.animationCache { return false } if lhs.animationRenderer !== rhs.animationRenderer { return false } if lhs.theme !== rhs.theme { return false } if lhs.title != rhs.title { return false } return true } final class View: UIView { var itemLayer: EmojiPagerContentComponent.View.ItemLayer? var placeholderView: EmojiPagerContentComponent.View.ItemPlaceholderView? var component: EntityKeyboardAnimationTopPanelComponent? var titleView: ComponentView? override init(frame: CGRect) { super.init(frame: frame) self.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.tapGesture(_:)))) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } @objc private func tapGesture(_ recognizer: UITapGestureRecognizer) { if case .ended = recognizer.state { self.component?.pressed() } } func update(component: EntityKeyboardAnimationTopPanelComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { self.component = component let itemEnvironment = environment[EntityKeyboardTopPanelItemEnvironment.self].value if self.itemLayer == nil { let itemLayer = EmojiPagerContentComponent.View.ItemLayer( item: EmojiPagerContentComponent.Item( file: component.file, staticEmoji: nil, subgroupId: nil ), context: component.context, groupId: "topPanel", attemptSynchronousLoad: false, file: component.file, staticEmoji: nil, cache: component.animationCache, renderer: component.animationRenderer, placeholderColor: .lightGray, blurredBadgeColor: .clear, displayPremiumBadgeIfAvailable: false, pointSize: CGSize(width: 44.0, height: 44.0), onUpdateDisplayPlaceholder: { [weak self] displayPlaceholder in guard let strongSelf = self else { return } strongSelf.updateDisplayPlaceholder(displayPlaceholder: displayPlaceholder) } ) self.itemLayer = itemLayer self.layer.addSublayer(itemLayer) if itemLayer.displayPlaceholder { self.updateDisplayPlaceholder(displayPlaceholder: true) } } let iconSize: CGSize = itemEnvironment.isExpanded ? CGSize(width: 44.0, height: 44.0) : CGSize(width: 28.0, height: 28.0) let iconFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - iconSize.width) / 2.0), y: 0.0), size: iconSize) if let itemLayer = self.itemLayer { transition.setPosition(layer: itemLayer, position: CGPoint(x: iconFrame.midX, y: iconFrame.midY)) transition.setBounds(layer: itemLayer, bounds: CGRect(origin: CGPoint(), size: iconFrame.size)) itemLayer.isVisibleForAnimations = true } if itemEnvironment.isExpanded { let titleView: ComponentView if let current = self.titleView { titleView = current } else { titleView = ComponentView() self.titleView = titleView } let titleSize = titleView.update( transition: .immediate, component: AnyComponent(MultilineTextComponent( text: .plain(NSAttributedString(string: component.title, font: Font.regular(10.0), textColor: component.theme.chat.inputPanel.primaryTextColor)) )), environment: {}, containerSize: CGSize(width: 62.0, height: 100.0) ) if let view = titleView.view { if view.superview == nil { view.alpha = 0.0 self.addSubview(view) } view.frame = CGRect(origin: CGPoint(x: floor((availableSize.width - titleSize.width) / 2.0), y: availableSize.height - titleSize.height), size: titleSize) transition.setAlpha(view: view, alpha: 1.0) } } else if let titleView = self.titleView { self.titleView = nil if let view = titleView.view { transition.setAlpha(view: view, alpha: 0.0, completion: { [weak view] _ in view?.removeFromSuperview() }) } } return availableSize } private func updateDisplayPlaceholder(displayPlaceholder: Bool) { if displayPlaceholder { if self.placeholderView == nil, let component = self.component { let placeholderView = EmojiPagerContentComponent.View.ItemPlaceholderView( context: component.context, file: component.file, shimmerView: nil, color: component.theme.chat.inputPanel.primaryTextColor.withMultipliedAlpha(0.08), size: CGSize(width: 28.0, height: 28.0) ) self.placeholderView = placeholderView self.insertSubview(placeholderView, at: 0) placeholderView.frame = CGRect(origin: CGPoint(), size: CGSize(width: 28.0, height: 28.0)) placeholderView.update(size: CGSize(width: 28.0, height: 28.0)) } } else { if let placeholderView = self.placeholderView { self.placeholderView = nil placeholderView.removeFromSuperview() } } } } func makeView() -> View { return View(frame: CGRect()) } func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) } } final class EntityKeyboardIconTopPanelComponent: Component { typealias EnvironmentType = EntityKeyboardTopPanelItemEnvironment let imageName: String let theme: PresentationTheme let title: String let pressed: () -> Void init( imageName: String, theme: PresentationTheme, title: String, pressed: @escaping () -> Void ) { self.imageName = imageName self.theme = theme self.title = title self.pressed = pressed } static func ==(lhs: EntityKeyboardIconTopPanelComponent, rhs: EntityKeyboardIconTopPanelComponent) -> Bool { if lhs.imageName != rhs.imageName { return false } if lhs.theme !== rhs.theme { return false } if lhs.title != rhs.title { return false } return true } final class View: UIView { let iconView: UIImageView var component: EntityKeyboardIconTopPanelComponent? var titleView: ComponentView? override init(frame: CGRect) { self.iconView = UIImageView() super.init(frame: frame) self.addSubview(self.iconView) self.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.tapGesture(_:)))) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } @objc private func tapGesture(_ recognizer: UITapGestureRecognizer) { if case .ended = recognizer.state { self.component?.pressed() } } func update(component: EntityKeyboardIconTopPanelComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { let itemEnvironment = environment[EntityKeyboardTopPanelItemEnvironment.self].value if self.component?.imageName != component.imageName { self.iconView.image = generateTintedImage(image: UIImage(bundleImageName: component.imageName), color: component.theme.chat.inputMediaPanel.panelIconColor) } self.component = component let nativeIconSize: CGSize = itemEnvironment.isExpanded ? CGSize(width: 44.0, height: 44.0) : CGSize(width: 28.0, height: 28.0) let boundingIconSize: CGSize = itemEnvironment.isExpanded ? CGSize(width: 38.0, height: 38.0) : CGSize(width: 24.0, height: 24.0) let iconSize = (self.iconView.image?.size ?? nativeIconSize).aspectFitted(boundingIconSize) let iconFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - iconSize.width) / 2.0), y: floor((nativeIconSize.height - iconSize.height) / 2.0)), size: iconSize) transition.setFrame(view: self.iconView, frame: iconFrame) if itemEnvironment.isExpanded { let titleView: ComponentView if let current = self.titleView { titleView = current } else { titleView = ComponentView() self.titleView = titleView } let titleSize = titleView.update( transition: .immediate, component: AnyComponent(MultilineTextComponent( text: .plain(NSAttributedString(string: component.title, font: Font.regular(10.0), textColor: component.theme.chat.inputPanel.primaryTextColor)) )), environment: {}, containerSize: CGSize(width: 62.0, height: 100.0) ) if let view = titleView.view { if view.superview == nil { view.alpha = 0.0 self.addSubview(view) } view.frame = CGRect(origin: CGPoint(x: floor((availableSize.width - titleSize.width) / 2.0), y: availableSize.height - titleSize.height), size: titleSize) transition.setAlpha(view: view, alpha: 1.0) } } else if let titleView = self.titleView { self.titleView = nil if let view = titleView.view { transition.setAlpha(view: view, alpha: 0.0, completion: { [weak view] _ in view?.removeFromSuperview() }) } } return availableSize } } func makeView() -> View { return View(frame: CGRect()) } func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) } } final class EntityKeyboardStaticStickersPanelComponent: Component { typealias EnvironmentType = EntityKeyboardTopPanelItemEnvironment let theme: PresentationTheme let pressed: (EmojiPagerContentComponent.StaticEmojiSegment) -> Void init( theme: PresentationTheme, pressed: @escaping (EmojiPagerContentComponent.StaticEmojiSegment) -> Void ) { self.theme = theme self.pressed = pressed } static func ==(lhs: EntityKeyboardStaticStickersPanelComponent, rhs: EntityKeyboardStaticStickersPanelComponent) -> Bool { if lhs.theme !== rhs.theme { return false } return true } final class View: UIView, UIScrollViewDelegate { private let scrollView: UIScrollView private var visibleItemViews: [EmojiPagerContentComponent.StaticEmojiSegment: ComponentView] = [:] private var component: EntityKeyboardStaticStickersPanelComponent? private var ignoreScrolling: Bool = false override init(frame: CGRect) { self.scrollView = UIScrollView() super.init(frame: frame) self.scrollView.layer.anchorPoint = CGPoint() self.scrollView.delaysContentTouches = false self.scrollView.clipsToBounds = false if #available(iOSApplicationExtension 11.0, iOS 11.0, *) { self.scrollView.contentInsetAdjustmentBehavior = .never } if #available(iOS 13.0, *) { self.scrollView.automaticallyAdjustsScrollIndicatorInsets = false } self.scrollView.showsVerticalScrollIndicator = false self.scrollView.showsHorizontalScrollIndicator = false self.scrollView.alwaysBounceHorizontal = false self.scrollView.delegate = self self.addSubview(self.scrollView) self.clipsToBounds = true self.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.tapGesture(_:)))) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } @objc private func tapGesture(_ recognizer: UITapGestureRecognizer) { if case .ended = recognizer.state { let scrollViewLocation = recognizer.location(in: self.scrollView) for (id, itemView) in self.visibleItemViews { if let view = itemView.view, view.frame.insetBy(dx: -4.0, dy: -4.0).contains(scrollViewLocation) { self.component?.pressed(id) break } } } } public func scrollViewDidScroll(_ scrollView: UIScrollView) { if self.ignoreScrolling { return } self.updateVisibleItems(transition: .immediate, animateAppearingItems: true) } private func updateVisibleItems(transition: Transition, animateAppearingItems: Bool) { guard let component = self.component else { return } let _ = component var validItemIds = Set() let visibleBounds = self.scrollView.bounds let componentHeight: CGFloat = 32.0 let items = EmojiPagerContentComponent.StaticEmojiSegment.allCases let itemSize: CGFloat = 28.0 let itemSpacing: CGFloat = 4.0 let sideInset: CGFloat = 2.0 for i in 0 ..< items.count { let itemFrame = CGRect(origin: CGPoint(x: sideInset + CGFloat(i) * (itemSize + itemSpacing), y: floor(componentHeight - itemSize) / 2.0), size: CGSize(width: itemSize, height: itemSize)) if visibleBounds.intersects(itemFrame) { let item = items[i] validItemIds.insert(item) let itemView: ComponentView if let current = self.visibleItemViews[item] { itemView = current } else { itemView = ComponentView() self.visibleItemViews[item] = itemView } let animationName: String switch item { case .people: animationName = "emojicat_smiles" case .animalsAndNature: animationName = "emojicat_animals" case .foodAndDrink: animationName = "emojicat_food" case .activityAndSport: animationName = "emojicat_activity" case .travelAndPlaces: animationName = "emojicat_places" case .objects: animationName = "emojicat_objects" case .symbols: animationName = "emojicat_symbols" case .flags: animationName = "emojicat_flags" } let _ = itemView.update( transition: .immediate, component: AnyComponent(LottieAnimationComponent( animation: LottieAnimationComponent.AnimationItem( name: animationName, colors: ["__allcolors__": component.theme.chat.inputMediaPanel.panelIconColor], mode: animateAppearingItems ? .animating(loop: false) : .still(position: .end) ), size: CGSize(width: itemSize, height: itemSize) )), environment: {}, containerSize: CGSize(width: itemSize, height: itemSize) ) if let view = itemView.view { if view.superview == nil { self.scrollView.addSubview(view) } view.frame = itemFrame } } } var removedItemIds: [EmojiPagerContentComponent.StaticEmojiSegment] = [] for (id, itemView) in self.visibleItemViews { if !validItemIds.contains(id) { removedItemIds.append(id) itemView.view?.removeFromSuperview() } } for id in removedItemIds { self.visibleItemViews.removeValue(forKey: id) } } func update(component: EntityKeyboardStaticStickersPanelComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { self.layer.cornerRadius = availableSize.height / 2.0 let itemEnvironment = environment[EntityKeyboardTopPanelItemEnvironment.self].value self.component = component let itemSize: CGFloat = 28.0 let itemSpacing: CGFloat = 4.0 let sideInset: CGFloat = 2.0 let itemCount = EmojiPagerContentComponent.StaticEmojiSegment.allCases.count self.ignoreScrolling = true self.scrollView.frame = CGRect(origin: CGPoint(), size: CGSize(width: max(availableSize.width, 150.0), height: availableSize.height)) self.scrollView.contentSize = CGSize(width: sideInset * 2.0 + itemSize * CGFloat(itemCount) + itemSpacing * CGFloat(itemCount - 1), height: availableSize.height) self.ignoreScrolling = false self.updateVisibleItems(transition: .immediate, animateAppearingItems: false) if !itemEnvironment.isHighlighted && self.scrollView.contentOffset.x != 0.0 { self.scrollView.setContentOffset(CGPoint(), animated: true) } return availableSize } } func makeView() -> View { return View(frame: CGRect()) } func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) } } final class EntityKeyboardTopPanelItemEnvironment: Equatable { let isExpanded: Bool let isHighlighted: Bool init(isExpanded: Bool, isHighlighted: Bool) { self.isExpanded = isExpanded self.isHighlighted = isHighlighted } static func ==(lhs: EntityKeyboardTopPanelItemEnvironment, rhs: EntityKeyboardTopPanelItemEnvironment) -> Bool { if lhs.isExpanded != rhs.isExpanded { return false } if lhs.isHighlighted != rhs.isHighlighted { return false } return true } } private final class ReorderGestureRecognizer: UIGestureRecognizer { private let shouldBegin: (CGPoint) -> (allowed: Bool, requiresLongPress: Bool, itemView: ComponentHostView?) private let willBegin: (CGPoint) -> Void private let began: (ComponentHostView) -> Void private let ended: () -> Void private let moved: (CGFloat) -> Void private let isActiveUpdated: (Bool) -> Void private var initialLocation: CGPoint? private var longTapTimer: SwiftSignalKit.Timer? private var longPressTimer: SwiftSignalKit.Timer? private var itemView: ComponentHostView? public init(shouldBegin: @escaping (CGPoint) -> (allowed: Bool, requiresLongPress: Bool, itemView: ComponentHostView?), willBegin: @escaping (CGPoint) -> Void, began: @escaping (ComponentHostView) -> Void, ended: @escaping () -> Void, moved: @escaping (CGFloat) -> 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 = SwiftSignalKit.Timer(timeout: 0.25, repeat: false, completion: { [weak self] in self?.longTapTimerFired() }, queue: Queue.mainQueue()) self.longTapTimer = longTapTimer longTapTimer.start() } private func stopLongTapTimer() { self.itemView = nil self.longTapTimer?.invalidate() self.longTapTimer = nil } private func startLongPressTimer() { self.longPressTimer?.invalidate() let longPressTimer = SwiftSignalKit.Timer(timeout: 0.6, repeat: false, completion: { [weak self] in self?.longPressTimerFired() }, queue: Queue.mainQueue()) self.longPressTimer = longPressTimer longPressTimer.start() } 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 } 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 = location.x - initialLocation.x 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 let dY = touchLocation.y - initialTapLocation.y if dX * dX + dY * dY > 3.0 * 3.0 { self.stopLongTapTimer() self.stopLongPressTimer() self.initialLocation = nil self.isActiveUpdated(false) self.state = .failed } } } } final class EntityKeyboardTopPanelComponent: Component { typealias EnvironmentType = EntityKeyboardTopContainerPanelEnvironment final class Item: Equatable { let id: AnyHashable let isReorderable: Bool let content: AnyComponent init(id: AnyHashable, isReorderable: Bool, content: AnyComponent) { self.id = id self.isReorderable = isReorderable self.content = content } static func ==(lhs: Item, rhs: Item) -> Bool { if lhs.id != rhs.id { return false } if lhs.isReorderable != rhs.isReorderable { return false } if lhs.content != rhs.content { return false } return true } } let theme: PresentationTheme let items: [Item] let defaultActiveItemId: AnyHashable? let activeContentItemIdUpdated: ActionSlot<(AnyHashable, Transition)> let reorderItems: ([Item]) -> Void init( theme: PresentationTheme, items: [Item], defaultActiveItemId: AnyHashable? = nil, activeContentItemIdUpdated: ActionSlot<(AnyHashable, Transition)>, reorderItems: @escaping ([Item]) -> Void ) { self.theme = theme self.items = items self.defaultActiveItemId = defaultActiveItemId self.activeContentItemIdUpdated = activeContentItemIdUpdated self.reorderItems = reorderItems } static func ==(lhs: EntityKeyboardTopPanelComponent, rhs: EntityKeyboardTopPanelComponent) -> Bool { if lhs.theme !== rhs.theme { return false } if lhs.items != rhs.items { return false } if lhs.defaultActiveItemId != rhs.defaultActiveItemId { return false } if lhs.activeContentItemIdUpdated !== rhs.activeContentItemIdUpdated { return false } return true } final class View: UIView, UIScrollViewDelegate { private struct ItemLayout { struct ItemDescription { var isStatic: Bool var isStaticExpanded: Bool } struct Item { var frame: CGRect var innerFrame: CGRect } let sideInset: CGFloat = 7.0 let itemSize: CGSize let staticItemSize: CGSize let staticExpandedItemSize: CGSize let innerItemSize: CGSize let itemSpacing: CGFloat = 15.0 let contentSize: CGSize let isExpanded: Bool let items: [Item] init(isExpanded: Bool, height: CGFloat, items: [ItemDescription]) { self.isExpanded = isExpanded self.itemSize = self.isExpanded ? CGSize(width: 54.0, height: 68.0) : CGSize(width: 32.0, height: 32.0) self.staticItemSize = self.itemSize self.staticExpandedItemSize = self.isExpanded ? CGSize(width: 150.0, height: 68.0) : CGSize(width: 150.0, height: 32.0) self.innerItemSize = self.isExpanded ? CGSize(width: 50.0, height: 62.0) : CGSize(width: 28.0, height: 28.0) var contentSize = CGSize(width: sideInset, height: height) var resultItems: [Item] = [] var isFirst = true let itemY = floor((contentSize.height - self.itemSize.height) / 2.0) for item in items { if isFirst { isFirst = false } else { contentSize.width += itemSpacing } let currentItemSize: CGSize if item.isStaticExpanded { currentItemSize = self.staticExpandedItemSize } else if item.isStatic { currentItemSize = self.staticItemSize } else { currentItemSize = self.itemSize } let frame = CGRect(origin: CGPoint(x: contentSize.width, y: itemY), size: currentItemSize) var innerFrame = frame if item.isStaticExpanded { } else if item.isStatic { } else { innerFrame.origin.x += floor((self.itemSize.width - self.innerItemSize.width)) / 2.0 innerFrame.origin.y += floor((self.itemSize.height - self.innerItemSize.height)) / 2.0 innerFrame.size = self.innerItemSize } resultItems.append(Item( frame: frame, innerFrame: innerFrame )) contentSize.width += frame.width } contentSize.width += sideInset self.contentSize = contentSize self.items = resultItems } func containerFrame(at index: Int) -> CGRect { return self.items[index].frame } func contentFrame(containerFrame: CGRect) -> CGRect { var frame = containerFrame frame.origin.x += floor((self.itemSize.width - self.innerItemSize.width)) / 2.0 frame.origin.y += floor((self.itemSize.height - self.innerItemSize.height)) / 2.0 frame.size = self.innerItemSize return frame } func contentFrame(at index: Int) -> CGRect { return self.items[index].innerFrame } func visibleItemRange(for rect: CGRect) -> (minIndex: Int, maxIndex: Int) { for i in 0 ..< self.items.count { if self.items[i].frame.intersects(rect) { for j in i ..< self.items.count { if !self.items[j].frame.intersects(rect) { return (i, j - 1) } } return (i, self.items.count - 1) } } return (0, -1) } } private let scrollView: UIScrollView private var itemViews: [AnyHashable: ComponentHostView] = [:] private var highlightedIconBackgroundView: UIView private var temporaryReorderingOrderIndex: (id: AnyHashable, index: Int)? private weak var currentReorderingItemView: ComponentHostView? private var currentReorderingItemId: AnyHashable? private var currentReorderingItemContainerView: UIView? private var initialReorderingItemFrame: CGRect? private var itemLayout: ItemLayout? private var items: [Item] = [] private var ignoreScrolling: Bool = false private var isDragging: Bool = false private var isReordering: Bool = false private var isDraggingOrReordering: Bool = false private var draggingStoppedTimer: SwiftSignalKit.Timer? private var isExpanded: Bool = false private var visibilityFraction: CGFloat = 1.0 private var activeContentItemId: AnyHashable? private var component: EntityKeyboardTopPanelComponent? weak var state: EmptyComponentState? private var environment: EntityKeyboardTopContainerPanelEnvironment? override init(frame: CGRect) { self.scrollView = UIScrollView() self.highlightedIconBackgroundView = UIView() self.highlightedIconBackgroundView.isUserInteractionEnabled = false self.highlightedIconBackgroundView.clipsToBounds = true self.highlightedIconBackgroundView.isHidden = true super.init(frame: frame) self.scrollView.layer.anchorPoint = CGPoint() self.scrollView.delaysContentTouches = false self.scrollView.clipsToBounds = false if #available(iOSApplicationExtension 11.0, iOS 11.0, *) { self.scrollView.contentInsetAdjustmentBehavior = .never } if #available(iOS 13.0, *) { self.scrollView.automaticallyAdjustsScrollIndicatorInsets = false } self.scrollView.showsVerticalScrollIndicator = false self.scrollView.showsHorizontalScrollIndicator = false self.scrollView.alwaysBounceHorizontal = true self.scrollView.delegate = self self.addSubview(self.scrollView) self.scrollView.addSubview(self.highlightedIconBackgroundView) self.clipsToBounds = true self.disablesInteractiveTransitionGestureRecognizerNow = { [weak self] in guard let strongSelf = self else { return false } return strongSelf.scrollView.contentOffset.x > 0.0 } self.addGestureRecognizer(ReorderGestureRecognizer( shouldBegin: { [weak self] point in guard let strongSelf = self else { return (false, false, nil) } if !strongSelf.isExpanded { return (false, false, nil) } let scrollViewLocation = strongSelf.convert(point, to: strongSelf.scrollView) for (id, itemView) in strongSelf.itemViews { if itemView.frame.contains(scrollViewLocation) { for item in strongSelf.items { if item.id == id, item.isReorderable { return (true, true, itemView) } } break } } return (false, false, nil) }, willBegin: { _ in }, began: { [weak self] itemView in guard let strongSelf = self else { return } strongSelf.beginReordering(itemView: itemView) }, ended: { [weak self] in guard let strongSelf = self else { return } strongSelf.endReordering() }, moved: { [weak self] value in guard let strongSelf = self else { return } strongSelf.updateReordering(offset: value) }, isActiveUpdated: { [weak self] isActive in guard let strongSelf = self else { return } strongSelf.updateIsReordering(isActive) } )) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } public func scrollViewDidScroll(_ scrollView: UIScrollView) { if self.ignoreScrolling { return } self.updateVisibleItems(attemptSynchronousLoads: false, transition: .immediate) } public func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { self.updateIsDragging(true) } public func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { if !decelerate { self.updateIsDragging(false) } } public func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { self.updateIsDragging(false) } private func updateIsDragging(_ isDragging: Bool) { self.isDragging = isDragging self.updateIsDraggingOrReordering() } private func updateIsReordering(_ isReordering: Bool) { self.isReordering = isReordering self.updateIsDraggingOrReordering() } private func updateIsDraggingOrReordering() { let isDraggingOrReordering = self.isDragging || self.isReordering if !isDraggingOrReordering { if !self.isDraggingOrReordering { return } if self.draggingStoppedTimer == nil { self.draggingStoppedTimer = SwiftSignalKit.Timer(timeout: 0.8, repeat: false, completion: { [weak self] in guard let strongSelf = self else { return } strongSelf.draggingStoppedTimer = nil strongSelf.isDraggingOrReordering = false guard let environment = strongSelf.environment else { return } environment.isExpandedUpdated(false, Transition(animation: .curve(duration: 0.3, curve: .spring))) }, queue: .mainQueue()) self.draggingStoppedTimer?.start() } } else { self.draggingStoppedTimer?.invalidate() self.draggingStoppedTimer = nil if !self.isDraggingOrReordering { self.isDraggingOrReordering = true guard let environment = self.environment else { return } environment.isExpandedUpdated(true, Transition(animation: .curve(duration: 0.3, curve: .spring))) } } } private func beginReordering(itemView: ComponentHostView) { if let currentReorderingItemView = self.currentReorderingItemView { if let componentView = currentReorderingItemView.componentView { currentReorderingItemView.addSubview(componentView) } self.currentReorderingItemView = nil self.currentReorderingItemId = nil } guard let id = self.itemViews.first(where: { $0.value === itemView })?.key else { return } self.currentReorderingItemId = id self.currentReorderingItemView = itemView let reorderingItemContainerView: UIView if let current = self.currentReorderingItemContainerView { reorderingItemContainerView = current } else { reorderingItemContainerView = UIView() self.addSubview(reorderingItemContainerView) self.currentReorderingItemContainerView = reorderingItemContainerView } reorderingItemContainerView.alpha = 0.5 reorderingItemContainerView.layer.animateAlpha(from: 1.0, to: 0.5, duration: 0.2) reorderingItemContainerView.frame = itemView.convert(itemView.bounds, to: self) self.initialReorderingItemFrame = reorderingItemContainerView.frame if let componentView = itemView.componentView { reorderingItemContainerView.addSubview(componentView) } } private func endReordering() { if let currentReorderingItemView = self.currentReorderingItemView { self.currentReorderingItemView = nil if let componentView = currentReorderingItemView.componentView { let localFrame = componentView.convert(componentView.bounds, to: self.scrollView) currentReorderingItemView.superview?.bringSubviewToFront(currentReorderingItemView) currentReorderingItemView.addSubview(componentView) let deltaPosition = CGPoint(x: localFrame.minX - currentReorderingItemView.frame.minX, y: localFrame.minY - currentReorderingItemView.frame.minY) currentReorderingItemView.layer.animatePosition(from: deltaPosition, to: CGPoint(), duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring, additive: true) } } if let reorderingItemContainerView = self.currentReorderingItemContainerView { self.currentReorderingItemContainerView = nil reorderingItemContainerView.removeFromSuperview() } self.currentReorderingItemId = nil self.temporaryReorderingOrderIndex = nil self.component?.reorderItems(self.items) //self.state?.updated(transition: Transition(animation: .curve(duration: 0.3, curve: .spring))) } private func updateReordering(offset: CGFloat) { guard let itemLayout = self.itemLayout, let currentReorderingItemId = self.currentReorderingItemId, let reorderingItemContainerView = self.currentReorderingItemContainerView, let initialReorderingItemFrame = self.initialReorderingItemFrame else { return } reorderingItemContainerView.frame = initialReorderingItemFrame.offsetBy(dx: offset, dy: 0.0) for i in 0 ..< self.items.count { if !self.items[i].isReorderable { continue } let containerFrame = itemLayout.containerFrame(at: i) if containerFrame.intersects(reorderingItemContainerView.frame) { let temporaryReorderingOrderIndex: (id: AnyHashable, index: Int) = (currentReorderingItemId, i) if self.temporaryReorderingOrderIndex?.id != temporaryReorderingOrderIndex.id || self.temporaryReorderingOrderIndex?.index != temporaryReorderingOrderIndex.index { self.temporaryReorderingOrderIndex = temporaryReorderingOrderIndex self.state?.updated(transition: Transition(animation: .curve(duration: 0.3, curve: .spring))) } break } } } private func updateVisibleItems(attemptSynchronousLoads: Bool, transition: Transition) { guard let itemLayout = self.itemLayout else { return } var visibleBounds = self.scrollView.bounds visibleBounds.size.width += 200.0 var validIds = Set() let visibleItemRange = itemLayout.visibleItemRange(for: visibleBounds) if !self.items.isEmpty && visibleItemRange.maxIndex >= visibleItemRange.minIndex { for index in visibleItemRange.minIndex ... visibleItemRange.maxIndex { let item = self.items[index] validIds.insert(item.id) var itemTransition = transition let itemView: ComponentHostView if let current = self.itemViews[item.id] { itemView = current } else { itemTransition = .immediate itemView = ComponentHostView() self.scrollView.addSubview(itemView) self.itemViews[item.id] = itemView } let itemOuterFrame = itemLayout.contentFrame(at: index) let itemSize = itemView.update( transition: itemTransition, component: item.content, environment: { EntityKeyboardTopPanelItemEnvironment(isExpanded: itemLayout.isExpanded, isHighlighted: self.activeContentItemId == item.id) }, containerSize: itemOuterFrame.size ) let itemFrame = CGRect(origin: CGPoint(x: itemOuterFrame.minX + floor((itemOuterFrame.width - itemSize.width) / 2.0), y: itemOuterFrame.minY + floor((itemOuterFrame.height - itemSize.height) / 2.0)), size: itemSize) /*if index == visibleItemRange.minIndex, !itemTransition.animation.isImmediate { print("\(index): \(itemView.frame) -> \(itemFrame)") }*/ itemTransition.setFrame(view: itemView, frame: itemFrame) } } var removedIds: [AnyHashable] = [] for (id, itemView) in self.itemViews { if !validIds.contains(id) { removedIds.append(id) itemView.removeFromSuperview() } } for id in removedIds { self.itemViews.removeValue(forKey: id) } } func update(component: EntityKeyboardTopPanelComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { if self.component?.theme !== component.theme { self.highlightedIconBackgroundView.backgroundColor = component.theme.chat.inputMediaPanel.panelHighlightedIconBackgroundColor } self.component = component self.state = state if let defaultActiveItemId = component.defaultActiveItemId { self.activeContentItemId = defaultActiveItemId } let panelEnvironment = environment[EntityKeyboardTopContainerPanelEnvironment.self].value self.environment = panelEnvironment let isExpanded = availableSize.height > 41.0 let wasExpanded = self.isExpanded self.isExpanded = isExpanded if !isExpanded { if self.isDragging { self.isDragging = false } if self.isReordering { self.isReordering = false } if self.isDraggingOrReordering { self.isDraggingOrReordering = false } if let draggingStoppedTimer = self.draggingStoppedTimer { self.draggingStoppedTimer = nil draggingStoppedTimer.invalidate() } } let intrinsicHeight: CGFloat = availableSize.height let height = intrinsicHeight var items = component.items if let (id, index) = self.temporaryReorderingOrderIndex { for i in 0 ..< items.count { if items[i].id == id { let item = items.remove(at: i) items.insert(item, at: min(index, items.count)) break } } } self.items = items if self.activeContentItemId == nil { self.activeContentItemId = items.first?.id } let previousItemLayout = self.itemLayout let itemLayout = ItemLayout(isExpanded: isExpanded, height: availableSize.height, items: self.items.map { item -> ItemLayout.ItemDescription in let isStatic = item.id == AnyHashable("static") return ItemLayout.ItemDescription( isStatic: isStatic, isStaticExpanded: isStatic && self.activeContentItemId == item.id ) }) self.itemLayout = itemLayout self.ignoreScrolling = true var updatedBounds: CGRect? if wasExpanded != isExpanded, let previousItemLayout = previousItemLayout { var visibleBounds = self.scrollView.bounds visibleBounds.size.width += 200.0 let previousVisibleRange = previousItemLayout.visibleItemRange(for: visibleBounds) if previousVisibleRange.minIndex <= previousVisibleRange.maxIndex { let previousItemFrame = previousItemLayout.containerFrame(at: previousVisibleRange.minIndex) let updatedItemFrame = itemLayout.containerFrame(at: previousVisibleRange.minIndex) let previousDistanceToItem = (previousItemFrame.minX - self.scrollView.bounds.minX)// / previousItemFrame.width let newBounds = CGRect(origin: CGPoint(x: updatedItemFrame.minX - previousDistanceToItem/* * updatedItemFrame.width)*/, y: 0.0), size: availableSize) updatedBounds = newBounds var updatedVisibleBounds = newBounds updatedVisibleBounds.size.width += 200.0 let updatedVisibleRange = itemLayout.visibleItemRange(for: updatedVisibleBounds) let baseFrame = CGRect(origin: CGPoint(x: updatedItemFrame.minX, y: previousItemFrame.minY), size: previousItemFrame.size) for index in updatedVisibleRange.minIndex ..< updatedVisibleRange.maxIndex { let indexDifference = index - previousVisibleRange.minIndex if let itemView = self.itemViews[self.items[index].id] { let itemContainerOriginX = baseFrame.minX + CGFloat(indexDifference) * (previousItemLayout.itemSize.width + previousItemLayout.itemSpacing) let itemContainerFrame = CGRect(origin: CGPoint(x: itemContainerOriginX, y: baseFrame.minY), size: baseFrame.size) let itemOuterFrame = previousItemLayout.contentFrame(containerFrame: itemContainerFrame) let itemSize = itemView.bounds.size itemView.frame = CGRect(origin: CGPoint(x: itemOuterFrame.minX + floor((itemOuterFrame.width - itemSize.width) / 2.0), y: itemOuterFrame.minY + floor((itemOuterFrame.height - itemSize.height) / 2.0)), size: itemSize) } } } } if self.scrollView.contentSize != itemLayout.contentSize { self.scrollView.contentSize = itemLayout.contentSize } if let updatedBounds = updatedBounds { self.scrollView.bounds = updatedBounds } else { self.scrollView.bounds = CGRect(origin: self.scrollView.bounds.origin, size: availableSize) } self.ignoreScrolling = false self.updateVisibleItems(attemptSynchronousLoads: !(self.scrollView.isDragging || self.scrollView.isDecelerating), transition: transition) if let activeContentItemId = self.activeContentItemId { if let index = self.items.firstIndex(where: { $0.id == activeContentItemId }) { let itemFrame = itemLayout.containerFrame(at: index) var highlightTransition = transition if self.highlightedIconBackgroundView.isHidden { self.highlightedIconBackgroundView.isHidden = false highlightTransition = .immediate } highlightTransition.setCornerRadius(layer: self.highlightedIconBackgroundView.layer, cornerRadius: activeContentItemId.base is String ? min(itemFrame.width / 2.0, itemFrame.height / 2.0) : 10.0) highlightTransition.setPosition(view: self.highlightedIconBackgroundView, position: CGPoint(x: itemFrame.midX, y: itemFrame.midY)) highlightTransition.setBounds(view: self.highlightedIconBackgroundView, bounds: CGRect(origin: CGPoint(), size: itemFrame.size)) } else { self.highlightedIconBackgroundView.isHidden = true } } else { self.highlightedIconBackgroundView.isHidden = true } transition.setAlpha(view: self.highlightedIconBackgroundView, alpha: isExpanded ? 0.0 : 1.0) panelEnvironment.visibilityFractionUpdated.connect { [weak self] (fraction, transition) in guard let strongSelf = self else { return } strongSelf.visibilityFractionUpdated(value: fraction, transition: transition) } component.activeContentItemIdUpdated.connect { [weak self] (itemId, transition) in guard let strongSelf = self else { return } strongSelf.activeContentItemIdUpdated(itemId: itemId, transition: transition) } return CGSize(width: availableSize.width, height: height) } private func visibilityFractionUpdated(value: CGFloat, transition: Transition) { if self.visibilityFraction == value { return } self.visibilityFraction = value let scale = max(0.01, self.visibilityFraction) transition.setScale(view: self.highlightedIconBackgroundView, scale: scale) transition.setAlpha(view: self.highlightedIconBackgroundView, alpha: self.visibilityFraction) for (_, itemView) in self.itemViews { transition.setSublayerTransform(view: itemView, transform: CATransform3DMakeScale(scale, scale, 1.0)) transition.setAlpha(view: itemView, alpha: self.visibilityFraction) } } private func activeContentItemIdUpdated(itemId: AnyHashable, transition: Transition) { guard let component = self.component, let itemLayout = self.itemLayout else { return } if self.activeContentItemId == itemId { return } self.activeContentItemId = itemId let _ = component let _ = itemLayout self.state?.updated(transition: Transition(animation: .curve(duration: 0.4, curve: .spring))) /*var found = false for i in 0 ..< self.items.count { if self.items[i].id == itemId { found = true self.highlightedIconBackgroundView.isHidden = false let itemFrame = itemLayout.containerFrame(at: i) var highlightTransition = transition if highlightTransition.animation.isImmediate { highlightTransition = highlightTransition.withAnimation(.curve(duration: 0.3, curve: .spring)) } highlightTransition.setPosition(view: self.highlightedIconBackgroundView, position: CGPoint(x: itemFrame.midX, y: itemFrame.midY)) highlightTransition.setBounds(view: self.highlightedIconBackgroundView, bounds: CGRect(origin: CGPoint(), size: itemFrame.size)) self.scrollView.scrollRectToVisible(itemFrame.insetBy(dx: -6.0, dy: 0.0), animated: true) break } } if !found { self.highlightedIconBackgroundView.isHidden = true }*/ } } func makeView() -> View { return View(frame: CGRect()) } func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) } }