import Foundation import UIKit import Display import ComponentFlow import PagerComponent import TelegramPresentationData import TelegramCore import Postbox import AnimationCache import MultiAnimationRenderer import AccountContext import AsyncDisplayKit import ComponentDisplayAdapters import LottieAnimationComponent import EmojiStatusComponent import LottieComponent import LottieComponentEmojiContent private final class RoundMaskView: UIImageView { private var currentDiameter: CGFloat? func update(diameter: CGFloat) { if self.currentDiameter != diameter { self.currentDiameter = diameter let shadowWidth: CGFloat = 6.0 self.image = generateImage(CGSize(width: shadowWidth * 2.0 + diameter, height: diameter), rotatedContext: { size, context in context.clear(CGRect(origin: CGPoint(), size: size)) let shadowColor = UIColor.black let stepCount = 10 var colors: [CGColor] = [] var locations: [CGFloat] = [] for i in 0 ... stepCount { let t = CGFloat(i) / CGFloat(stepCount) colors.append(shadowColor.withAlphaComponent(t * t).cgColor) locations.append(t) } let gradient = CGGradient(colorsSpace: deviceColorSpace, colors: colors as CFArray, locations: &locations)! let center = CGPoint(x: size.width / 2.0, y: size.height / 2.0) let gradientWidth = shadowWidth context.drawRadialGradient(gradient, startCenter: center, startRadius: size.width / 2.0, endCenter: center, endRadius: size.width / 2.0 - gradientWidth, options: []) context.setFillColor(shadowColor.cgColor) context.fillEllipse(in: CGRect(origin: CGPoint(x: shadowWidth, y: 0.0), size: CGSize(width: size.height, height: size.height)).insetBy(dx: -0.5, dy: -0.5)) })?.stretchableImage(withLeftCapWidth: Int(shadowWidth * 0.5 + diameter * 0.5), topCapHeight: Int(diameter * 0.5)) } } } final class EmojiSearchSearchBarComponent: Component { enum TextInputState: Equatable { case inactive case active(hasText: Bool) } let context: AccountContext let theme: PresentationTheme let strings: PresentationStrings let useOpaqueTheme: Bool let textInputState: TextInputState let categories: EmojiSearchCategories? let searchTermUpdated: (String?) -> Void let activateTextInput: () -> Void init( context: AccountContext, theme: PresentationTheme, strings: PresentationStrings, useOpaqueTheme: Bool, textInputState: TextInputState, categories: EmojiSearchCategories?, searchTermUpdated: @escaping (String?) -> Void, activateTextInput: @escaping () -> Void ) { self.context = context self.theme = theme self.strings = strings self.useOpaqueTheme = useOpaqueTheme self.textInputState = textInputState self.categories = categories self.searchTermUpdated = searchTermUpdated self.activateTextInput = activateTextInput } static func ==(lhs: EmojiSearchSearchBarComponent, rhs: EmojiSearchSearchBarComponent) -> Bool { if lhs.theme !== rhs.theme { return false } if lhs.strings !== rhs.strings { return false } if lhs.useOpaqueTheme != rhs.useOpaqueTheme { return false } if lhs.textInputState != rhs.textInputState { return false } if lhs.categories != rhs.categories { return false } return true } private struct ItemLayout { let containerSize: CGSize let itemCount: Int let itemSize: CGSize let itemSpacing: CGFloat let contentSize: CGSize let leftInset: CGFloat let rightInset: CGFloat let itemStartX: CGFloat let textSpacing: CGFloat let textFrame: CGRect init(containerSize: CGSize, textSize: CGSize, itemCount: Int) { self.containerSize = containerSize self.itemCount = itemCount self.itemSpacing = 11.0 self.leftInset = 6.0 self.rightInset = 8.0 self.itemSize = CGSize(width: 24.0, height: 24.0) self.textSpacing = 11.0 self.textFrame = CGRect(origin: CGPoint(x: self.leftInset, y: floor((containerSize.height - textSize.height) * 0.5)), size: textSize) self.itemStartX = self.textFrame.maxX + self.textSpacing self.contentSize = CGSize(width: self.itemStartX + self.itemSize.width * CGFloat(self.itemCount) + self.itemSpacing * CGFloat(max(0, self.itemCount - 1)) + self.rightInset, height: containerSize.height) } func visibleItems(for rect: CGRect) -> Range? { let baseItemX: CGFloat = self.itemStartX let offsetRect = rect.offsetBy(dx: -baseItemX, dy: 0.0) var minVisibleIndex = Int(floor((offsetRect.minX - self.itemSpacing) / (self.itemSize.width + self.itemSpacing))) minVisibleIndex = max(0, minVisibleIndex) var maxVisibleIndex = Int(ceil((offsetRect.maxX - self.itemSpacing) / (self.itemSize.height + self.itemSpacing))) maxVisibleIndex = min(maxVisibleIndex, self.itemCount - 1) if minVisibleIndex <= maxVisibleIndex { return minVisibleIndex ..< (maxVisibleIndex + 1) } else { return nil } } func frame(at index: Int) -> CGRect { return CGRect(origin: CGPoint(x: self.itemStartX + CGFloat(index) * (self.itemSize.width + self.itemSpacing), y: floor((self.containerSize.height - self.itemSize.height) * 0.5)), size: self.itemSize) } } private final class ContentScrollView: UIScrollView, PagerExpandableScrollView { override static var layerClass: AnyClass { return EmojiPagerContentComponent.View.ContentScrollLayer.self } private let mirrorView: UIView init(mirrorView: UIView) { self.mirrorView = mirrorView super.init(frame: CGRect()) (self.layer as? EmojiPagerContentComponent.View.ContentScrollLayer)?.mirrorLayer = mirrorView.layer self.canCancelContentTouches = true } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func touchesShouldCancel(in view: UIView) -> Bool { return true } } private final class ItemView { let view = ComponentView() let tintView = UIImageView() init() { } } final class View: UIView, UIScrollViewDelegate { let tintContainerView: UIView private let scrollView: ContentScrollView private let tintScrollView: UIView private let textView = ComponentView() private let textContainerView: UIView private let tintTextView = ComponentView() private let tintTextContainerView: UIView private var visibleItemViews: [AnyHashable: ItemView] = [:] private let selectedItemBackground: SimpleLayer private let selectedItemTintBackground: SimpleLayer private var component: EmojiSearchSearchBarComponent? private weak var state: EmptyComponentState? private var itemLayout: ItemLayout? private var ignoreScrolling: Bool = false private let roundMaskView: RoundMaskView private let tintRoundMaskView: RoundMaskView private var selectedItem: AnyHashable? override init(frame: CGRect) { self.tintContainerView = UIView() self.tintScrollView = UIView() self.tintScrollView.clipsToBounds = true self.scrollView = ContentScrollView(mirrorView: self.tintScrollView) self.textContainerView = UIView() self.textContainerView.isUserInteractionEnabled = false self.tintTextContainerView = UIView() self.tintTextContainerView.isUserInteractionEnabled = false self.roundMaskView = RoundMaskView() self.tintRoundMaskView = RoundMaskView() self.selectedItemBackground = SimpleLayer() self.selectedItemTintBackground = SimpleLayer() super.init(frame: frame) self.scrollView.delaysContentTouches = 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 = true self.scrollView.showsHorizontalScrollIndicator = false self.scrollView.delegate = self self.scrollView.clipsToBounds = true self.scrollView.scrollsToTop = false self.addSubview(self.scrollView) self.addSubview(self.textContainerView) self.tintContainerView.addSubview(self.tintScrollView) self.tintContainerView.addSubview(self.tintTextContainerView) self.mask = self.roundMaskView self.tintContainerView.mask = self.tintRoundMaskView self.scrollView.layer.addSublayer(self.selectedItemBackground) self.tintScrollView.layer.addSublayer(self.selectedItemTintBackground) 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 { guard let component = self.component, let itemLayout = self.itemLayout else { return } let location = recognizer.location(in: self.scrollView) if (component.categories?.groups ?? []).isEmpty || location.x <= itemLayout.itemStartX - itemLayout.textSpacing { component.activateTextInput() } else { for (id, itemView) in self.visibleItemViews { if let itemComponentView = itemView.view.view, itemComponentView.frame.contains(location), let itemId = id.base as? Int64 { if self.selectedItem == AnyHashable(id) { self.selectedItem = nil } else { self.selectedItem = AnyHashable(id) } self.state?.updated(transition: .immediate) if let _ = self.selectedItem, let categories = component.categories, let group = categories.groups.first(where: { $0.id == itemId }) { component.searchTermUpdated(group.identifiers.joined(separator: "")) } else { component.searchTermUpdated(nil) self.scrollView.setContentOffset(CGPoint(), animated: true) } break } } } } } func clearSelection(dispatchEvent: Bool) { if self.selectedItem != nil { self.selectedItem = nil self.state?.updated(transition: .immediate) self.scrollView.setContentOffset(CGPoint(), animated: true) if dispatchEvent { self.component?.searchTermUpdated(nil) } } } func scrollViewDidScroll(_ scrollView: UIScrollView) { if !self.ignoreScrolling { self.updateScrolling(transition: .immediate, fromScrolling: true) } } private func updateScrolling(transition: Transition, fromScrolling: Bool) { guard let component = self.component, let itemLayout = self.itemLayout else { return } let itemAlpha: CGFloat switch component.textInputState { case .active: itemAlpha = 0.0 case .inactive: itemAlpha = 1.0 } var validItemIds = Set() let visibleBounds = self.scrollView.bounds var animateAppearingItems = false if fromScrolling { animateAppearingItems = true } let items = component.categories?.groups ?? [] for i in 0 ..< items.count { let itemFrame = itemLayout.frame(at: i) if visibleBounds.intersects(itemFrame) { let item = items[i] validItemIds.insert(AnyHashable(item.id)) var animateItem = false var itemTransition = transition let itemView: ItemView if let current = self.visibleItemViews[AnyHashable(item.id)] { itemView = current } else { animateItem = animateAppearingItems itemTransition = .immediate itemView = ItemView() self.visibleItemViews[AnyHashable(item.id)] = itemView } let color: UIColor if component.useOpaqueTheme { color = self.selectedItem == AnyHashable(item.id) ? component.theme.chat.inputMediaPanel.panelContentOpaqueSearchOverlaySelectedColor : component.theme.chat.inputMediaPanel.panelContentOpaqueSearchOverlayColor } else { color = self.selectedItem == AnyHashable(item.id) ? component.theme.chat.inputMediaPanel.panelContentVibrantSearchOverlaySelectedColor : component.theme.chat.inputMediaPanel.panelContentVibrantSearchOverlayColor } let _ = itemView.view.update( transition: .immediate, component: AnyComponent(LottieComponent( content: LottieComponent.EmojiContent( context: component.context, fileId: item.id ), color: color )), environment: {}, containerSize: itemLayout.itemSize ) itemView.tintView.tintColor = .white if let view = itemView.view.view as? LottieComponent.View { if view.superview == nil { self.scrollView.addSubview(view) view.output = itemView.tintView self.tintScrollView.addSubview(itemView.tintView) } itemTransition.setPosition(view: view, position: CGPoint(x: itemFrame.midX, y: itemFrame.midY)) itemTransition.setBounds(view: view, bounds: CGRect(origin: CGPoint(), size: CGSize(width: itemLayout.itemSize.width, height: itemLayout.itemSize.height))) let scaleFactor = itemFrame.width / itemLayout.itemSize.width itemTransition.setSublayerTransform(view: view, transform: CATransform3DMakeScale(scaleFactor, scaleFactor, 1.0)) itemTransition.setPosition(view: itemView.tintView, position: CGPoint(x: itemFrame.midX, y: itemFrame.midY)) itemTransition.setBounds(view: itemView.tintView, bounds: CGRect(origin: CGPoint(), size: CGSize(width: itemLayout.itemSize.width, height: itemLayout.itemSize.height))) itemTransition.setSublayerTransform(view: itemView.tintView, transform: CATransform3DMakeScale(scaleFactor, scaleFactor, 1.0)) itemTransition.setAlpha(view: view, alpha: itemAlpha) itemTransition.setAlpha(view: itemView.tintView, alpha: itemAlpha) let isHidden = !visibleBounds.intersects(itemFrame) if isHidden != view.isHidden { view.isHidden = isHidden itemView.tintView.isHidden = true if !isHidden { view.playOnce() } } else if animateItem { if fromScrolling { view.playOnce(delay: 0.08) } } } } } var removedItemIds: [AnyHashable] = [] for (id, itemView) in self.visibleItemViews { if !validItemIds.contains(id) { removedItemIds.append(id) itemView.view.view?.removeFromSuperview() itemView.tintView.removeFromSuperview() } } for id in removedItemIds { self.visibleItemViews.removeValue(forKey: id) } if let selectedItem = self.selectedItem, let index = items.firstIndex(where: { AnyHashable($0.id) == selectedItem }) { self.selectedItemBackground.isHidden = false self.selectedItemTintBackground.isHidden = false let selectedItemCenter = itemLayout.frame(at: index).center let selectionSize = CGSize(width: 28.0, height: 28.0) self.selectedItemBackground.backgroundColor = component.useOpaqueTheme ? component.theme.chat.inputMediaPanel.panelContentOpaqueSearchOverlayHighlightColor.cgColor : component.theme.chat.inputMediaPanel.panelContentVibrantSearchOverlayHighlightColor.cgColor self.selectedItemTintBackground.backgroundColor = UIColor(white: 1.0, alpha: 0.15).cgColor self.selectedItemBackground.cornerRadius = selectionSize.height * 0.5 self.selectedItemTintBackground.cornerRadius = selectionSize.height * 0.5 let selectionFrame = CGRect(origin: CGPoint(x: floor(selectedItemCenter.x - selectionSize.width * 0.5), y: floor(selectedItemCenter.y - selectionSize.height * 0.5)), size: selectionSize) self.selectedItemBackground.frame = selectionFrame self.selectedItemTintBackground.frame = selectionFrame } else { self.selectedItemBackground.isHidden = true self.selectedItemTintBackground.isHidden = true } let scrollBounds = self.scrollView.bounds let textOffset = max(0.0, scrollBounds.minX - (itemLayout.itemStartX - itemLayout.textFrame.maxX - itemLayout.textSpacing)) transition.setPosition(view: self.textContainerView, position: self.scrollView.center) transition.setBounds(view: self.textContainerView, bounds: CGRect(origin: CGPoint(x: textOffset, y: 0.0), size: scrollBounds.size)) transition.setPosition(view: self.tintTextContainerView, position: self.scrollView.center) transition.setBounds(view: self.tintTextContainerView, bounds: CGRect(origin: CGPoint(x: textOffset, y: 0.0), size: scrollBounds.size)) } func update(component: EmojiSearchSearchBarComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { self.component = component self.state = state let textSize = self.textView.update( transition: .immediate, component: AnyComponent(Text( text: component.strings.Common_Search, font: Font.regular(17.0), color: component.useOpaqueTheme ? component.theme.chat.inputMediaPanel.panelContentOpaqueSearchOverlayColor : component.theme.chat.inputMediaPanel.panelContentVibrantSearchOverlayColor )), environment: {}, containerSize: CGSize(width: availableSize.width - 32.0, height: 100.0) ) let _ = self.tintTextView.update( transition: .immediate, component: AnyComponent(Text( text: component.strings.Common_Search, font: Font.regular(17.0), color: .white )), environment: {}, containerSize: CGSize(width: availableSize.width - 32.0, height: 100.0) ) let itemLayout = ItemLayout(containerSize: availableSize, textSize: textSize, itemCount: component.categories?.groups.count ?? 0) self.itemLayout = itemLayout if let textComponentView = self.textView.view { if textComponentView.superview == nil { self.textContainerView.addSubview(textComponentView) } transition.setFrame(view: textComponentView, frame: itemLayout.textFrame) } if let tintTextComponentView = self.tintTextView.view { if tintTextComponentView.superview == nil { self.tintTextContainerView.addSubview(tintTextComponentView) } transition.setFrame(view: tintTextComponentView, frame: itemLayout.textFrame) } self.ignoreScrolling = true if self.scrollView.bounds.size != availableSize { transition.setFrame(view: self.scrollView, frame: CGRect(origin: CGPoint(), size: availableSize)) } if case .active = component.textInputState { transition.setBounds(view: self.scrollView, bounds: CGRect(origin: CGPoint(), size: availableSize)) } if self.scrollView.contentSize != itemLayout.contentSize { self.scrollView.contentSize = itemLayout.contentSize } self.ignoreScrolling = false let maskFrame = CGRect(origin: CGPoint(), size: availableSize) transition.setFrame(view: self.roundMaskView, frame: maskFrame) self.roundMaskView.update(diameter: maskFrame.height) transition.setFrame(view: self.tintRoundMaskView, frame: maskFrame) self.tintRoundMaskView.update(diameter: maskFrame.height) self.updateScrolling(transition: transition, fromScrolling: false) switch component.textInputState { case let .active(hasText): self.isUserInteractionEnabled = false self.textView.view?.isHidden = hasText self.tintTextView.view?.isHidden = hasText /*if self.scrollView.contentOffset.x != 0.0 { self.scrollView.setContentOffset(CGPoint(), animated: true) }*/ case .inactive: self.isUserInteractionEnabled = true self.textView.view?.isHidden = false self.tintTextView.view?.isHidden = false } 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) } }