import Foundation import UIKit import Display import TelegramPresentationData import ComponentFlow import ComponentDisplayAdapters import SwitchComponent import EntityKeyboard import AccountContext import HierarchyTrackingLayer import TelegramCore private final class CaretIndicatorView: UIImageView { private let hierarchyTrackingLayer: HierarchyTrackingLayer override init(frame: CGRect) { self.hierarchyTrackingLayer = HierarchyTrackingLayer() super.init(frame: frame) self.layer.addSublayer(self.hierarchyTrackingLayer) self.hierarchyTrackingLayer.didEnterHierarchy = { [weak self] in self?.restartAnimations(delayStart: false) } } required init?(coder: NSCoder) { preconditionFailure() } func restartAnimations(delayStart: Bool) { self.layer.removeAnimation(forKey: "caret") let animation = CAKeyframeAnimation(keyPath: "opacity") animation.values = [1.0 as NSNumber, 0.0 as NSNumber, 1.0 as NSNumber, 1.0 as NSNumber] let firstDuration = 0.3 let secondDuration = 0.25 let restDuration = 0.35 let duration = firstDuration + secondDuration + restDuration let keyTimes: [NSNumber] = [0.0 as NSNumber, (firstDuration / duration) as NSNumber, ((firstDuration + secondDuration) / duration) as NSNumber, ((firstDuration + secondDuration + restDuration) / duration) as NSNumber] animation.keyTimes = keyTimes animation.duration = duration animation.repeatCount = Float.greatestFiniteMagnitude animation.fillMode = .both if delayStart { animation.beginTime = self.layer.convertTime(CACurrentMediaTime(), from: nil) + 0.8 * UIView.animationDurationFactor() } self.layer.add(animation, forKey: "caret") } } final class EmojiListInputComponent: Component { let context: AccountContext let theme: PresentationTheme let placeholder: String let reactionItems: [EmojiComponentReactionItem] let isInputActive: Bool let caretPosition: Int let activateInput: () -> Void let setCaretPosition: (Int) -> Void init( context: AccountContext, theme: PresentationTheme, placeholder: String, reactionItems: [EmojiComponentReactionItem], isInputActive: Bool, caretPosition: Int, activateInput: @escaping () -> Void, setCaretPosition: @escaping (Int) -> Void ) { self.context = context self.theme = theme self.placeholder = placeholder self.reactionItems = reactionItems self.isInputActive = isInputActive self.caretPosition = caretPosition self.activateInput = activateInput self.setCaretPosition = setCaretPosition } static func ==(lhs: EmojiListInputComponent, rhs: EmojiListInputComponent) -> Bool { if lhs.context !== rhs.context { return false } if lhs.theme !== rhs.theme { return false } if lhs.placeholder != rhs.placeholder { return false } if lhs.reactionItems != rhs.reactionItems { return false } if lhs.isInputActive != rhs.isInputActive { return false } if lhs.caretPosition != rhs.caretPosition { return false } return true } final class View: UIView { private var component: EmojiListInputComponent? private weak var state: EmptyComponentState? private var itemLayers: [Int64: EmojiKeyboardItemLayer] = [:] private let trailingPlaceholder = ComponentView() private let caretIndicator: CaretIndicatorView override init(frame: CGRect) { self.caretIndicator = CaretIndicatorView(frame: CGRect()) self.caretIndicator.image = generateImage(CGSize(width: 2.0, height: 4.0), rotatedContext: { size, context in context.clear(CGRect(origin: CGPoint(), size: size)) context.setFillColor(UIColor.white.cgColor) context.addPath(UIBezierPath(roundedRect: CGRect(origin: CGPoint(), size: size), cornerRadius: size.width * 0.5).cgPath) context.fillPath() })?.stretchableImage(withLeftCapWidth: 1, topCapHeight: 2).withRenderingMode(.alwaysTemplate) super.init(frame: frame) self.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.tapGesture(_:)))) } required init(coder: NSCoder) { preconditionFailure() } @objc private func tapGesture(_ recognizer: UITapGestureRecognizer) { guard let component = self.component else { return } if case .ended = recognizer.state { let point = recognizer.location(in: self) var tapOnItem = false for (itemId, itemLayer) in self.itemLayers { if itemLayer.frame.insetBy(dx: -6.0, dy: -6.0).contains(point) { if let itemIndex = component.reactionItems.firstIndex(where: { $0.file.fileId.id == itemId }) { var caretPosition = point.x >= itemLayer.frame.midX ? (itemIndex + 1) : itemIndex caretPosition = max(0, min(component.reactionItems.count, caretPosition)) component.setCaretPosition(caretPosition) component.activateInput() } tapOnItem = true break } } if !tapOnItem { component.setCaretPosition(component.reactionItems.count) component.activateInput() } } } func caretRect() -> CGRect? { if !self.caretIndicator.isHidden { return self.caretIndicator.frame } else { return nil } } func update(component: EmojiListInputComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { let verticalInset: CGFloat = 12.0 let placeholderSpacing: CGFloat = 6.0 let minItemSize: CGFloat = 24.0 let itemSpacingFactor: CGFloat = 0.15 let minSideInset: CGFloat = 12.0 self.backgroundColor = component.theme.list.itemBlocksBackgroundColor self.layer.cornerRadius = 12.0 let maxItemsWidth = availableSize.width - minSideInset * 2.0 let itemsPerRow = Int(floor((maxItemsWidth + minItemSize * itemSpacingFactor) / (minItemSize + minItemSize * itemSpacingFactor))) let itemSizePlusSpacing = maxItemsWidth / CGFloat(itemsPerRow) let itemSize = floor(itemSizePlusSpacing * (1.0 - itemSpacingFactor)) let itemSpacing = floor(itemSizePlusSpacing * itemSpacingFactor) let sideInset = floor((availableSize.width - (itemSize * CGFloat(itemsPerRow) + itemSpacing * CGFloat(itemsPerRow - 1))) * 0.5) var rowCount = (component.reactionItems.count + (itemsPerRow - 1)) / itemsPerRow rowCount = max(1, rowCount) if let previousComponent = self.component, (previousComponent.reactionItems.count != component.reactionItems.count || previousComponent.isInputActive != component.isInputActive) { self.caretIndicator.restartAnimations(delayStart: true) } self.component = component self.state = state let trailingPlaceholderSize = self.trailingPlaceholder.update( transition: .immediate, component: AnyComponent(Text(text: component.placeholder, font: Font.regular(17.0), color: component.theme.list.itemPlaceholderTextColor)), environment: {}, containerSize: CGSize(width: availableSize.width - sideInset * 2.0, height: 100.0) ) var lastRowItemCount = component.reactionItems.count % itemsPerRow if lastRowItemCount == 0 && !component.reactionItems.isEmpty { lastRowItemCount = itemsPerRow } let trailingLineWidth = sideInset + CGFloat(lastRowItemCount) * (itemSize + itemSpacing) + placeholderSpacing var contentHeight: CGFloat = verticalInset * 2.0 + CGFloat(rowCount) * itemSize + CGFloat(max(0, rowCount - 1)) * itemSpacing let trailingPlaceholderFrame: CGRect if availableSize.width - sideInset - trailingLineWidth < trailingPlaceholderSize.width { contentHeight += itemSize + itemSpacing trailingPlaceholderFrame = CGRect(origin: CGPoint(x: sideInset, y: verticalInset + CGFloat(rowCount) * (itemSize + itemSpacing) + floor((itemSize - trailingPlaceholderSize.height) * 0.5)), size: trailingPlaceholderSize) } else { trailingPlaceholderFrame = CGRect(origin: CGPoint(x: trailingLineWidth, y: verticalInset + CGFloat(rowCount - 1) * (itemSize + itemSpacing) + floor((itemSize - trailingPlaceholderSize.height) * 0.5)), size: trailingPlaceholderSize) } if let trailingPlaceholderView = self.trailingPlaceholder.view { if trailingPlaceholderView.superview == nil { trailingPlaceholderView.layer.anchorPoint = CGPoint() self.addSubview(trailingPlaceholderView) self.addSubview(self.caretIndicator) } transition.setPosition(view: trailingPlaceholderView, position: trailingPlaceholderFrame.origin) trailingPlaceholderView.bounds = CGRect(origin: CGPoint(), size: trailingPlaceholderFrame.size) } self.caretIndicator.tintColor = component.theme.list.itemAccentColor self.caretIndicator.isHidden = !component.isInputActive var caretFrame = CGRect(origin: CGPoint(x: trailingPlaceholderFrame.minX, y: trailingPlaceholderFrame.minY + floorToScreenPixels((trailingPlaceholderFrame.height - 22.0) * 0.5)), size: CGSize(width: 2.0, height: 22.0)) var validIds: [Int64] = [] for i in 0 ..< component.reactionItems.count { let item = component.reactionItems[i] let itemKey = item.file.fileId.id validIds.append(itemKey) let itemFrame = CGRect(origin: CGPoint(x: sideInset + CGFloat(i % itemsPerRow) * (itemSize + itemSpacing), y: verticalInset + CGFloat(i / itemsPerRow) * (itemSize + itemSpacing)), size: CGSize(width: itemSize, height: itemSize)) var itemTransition = transition var animateIn = false let itemLayer: EmojiKeyboardItemLayer if let current = self.itemLayers[itemKey] { itemLayer = current } else { itemTransition = .immediate animateIn = true let animationData = EntityKeyboardAnimationData( file: item.file ) itemLayer = EmojiKeyboardItemLayer( item: EmojiPagerContentComponent.Item( animationData: animationData, content: .animation(animationData), itemFile: item.file, subgroupId: nil, icon: .none, tintMode: item.file.isCustomTemplateEmoji ? .primary : .none ), context: component.context, attemptSynchronousLoad: false, content: EmojiPagerContentComponent.ItemContent.animation(animationData), cache: component.context.animationCache, renderer: component.context.animationRenderer, placeholderColor: component.theme.list.mediaPlaceholderColor, blurredBadgeColor: .clear, accentIconColor: component.theme.list.itemPrimaryTextColor, pointSize: CGSize(width: 32.0, height: 32.0), onUpdateDisplayPlaceholder: { _, _ in } ) self.itemLayers[itemKey] = itemLayer self.layer.addSublayer(itemLayer) } itemLayer.isVisibleForAnimations = true switch itemLayer.item.tintMode { case .none: itemLayer.layerTintColor = nil case .accent, .primary, .custom: itemLayer.layerTintColor = component.theme.list.itemPrimaryTextColor.cgColor } itemTransition.setFrame(layer: itemLayer, frame: itemFrame) if component.caretPosition == i { caretFrame = CGRect(origin: CGPoint(x: itemFrame.minX - 2.0, y: itemFrame.minY + floorToScreenPixels((itemFrame.height - 22.0) * 0.5)), size: CGSize(width: 2.0, height: 22.0)) } else if i == component.reactionItems.count - 1 && component.caretPosition == i + 1 { caretFrame = CGRect(origin: CGPoint(x: itemFrame.maxX + itemSpacing, y: itemFrame.minY + floorToScreenPixels((itemFrame.height - 22.0) * 0.5)), size: CGSize(width: 2.0, height: 22.0)) } if animateIn, !transition.animation.isImmediate { itemLayer.animateScale(from: 0.001, to: 1.0, duration: 0.2) itemLayer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) } } var removedIds: [Int64] = [] for (key, itemLayer) in self.itemLayers { if !validIds.contains(key) { removedIds.append(key) if !transition.animation.isImmediate { itemLayer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak itemLayer] _ in itemLayer?.removeFromSuperlayer() }) itemLayer.animateScale(from: 1.0, to: 0.001, duration: 0.2, removeOnCompletion: false) } else { itemLayer.removeFromSuperlayer() } } } for key in removedIds { self.itemLayers.removeValue(forKey: key) } if !transition.animation.isImmediate && abs(caretFrame.midY - self.caretIndicator.center.y) > 2.0 { if let caretSnapshot = self.caretIndicator.snapshotView(afterScreenUpdates: false) { caretSnapshot.frame = self.caretIndicator.frame self.insertSubview(caretSnapshot, aboveSubview: self.caretIndicator) caretSnapshot.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.15, removeOnCompletion: false, completion: { [weak caretSnapshot] _ in caretSnapshot?.removeFromSuperview() }) caretSnapshot.layer.animateScale(from: 1.0, to: 0.001, duration: 0.15, removeOnCompletion: false) } self.caretIndicator.frame = caretFrame self.caretIndicator.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.15) self.caretIndicator.layer.animateScale(from: 0.001, to: 1.0, duration: 0.15) } else { transition.setFrame(view: self.caretIndicator, frame: caretFrame) } return CGSize(width: availableSize.width, height: contentHeight) } } func makeView() -> View { return View(frame: CGRect()) } 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) } }