import Foundation import AsyncDisplayKit import Display import AnimatedStickerNode import TelegramCore import Postbox import TelegramPresentationData import AccountContext import TelegramAnimatedStickerNode import ReactionButtonListComponent import SwiftSignalKit import Lottie import AppBundle import AvatarNode import ComponentFlow import PagerComponent import EmojiStatusSelectionComponent import EntityKeyboard import ComponentDisplayAdapters import AnimationCache import MultiAnimationRenderer import EmojiTextAttachmentView import TextFormat import GZip import BalancedTextComponent import Markdown public final class ReactionItem { public struct Reaction: Equatable { public var rawValue: MessageReaction.Reaction public init(rawValue: MessageReaction.Reaction) { self.rawValue = rawValue } } public let reaction: ReactionItem.Reaction public let appearAnimation: TelegramMediaFile public let stillAnimation: TelegramMediaFile public let listAnimation: TelegramMediaFile public let largeListAnimation: TelegramMediaFile public let applicationAnimation: TelegramMediaFile? public let largeApplicationAnimation: TelegramMediaFile? public let isCustom: Bool public init( reaction: ReactionItem.Reaction, appearAnimation: TelegramMediaFile, stillAnimation: TelegramMediaFile, listAnimation: TelegramMediaFile, largeListAnimation: TelegramMediaFile, applicationAnimation: TelegramMediaFile?, largeApplicationAnimation: TelegramMediaFile?, isCustom: Bool ) { self.reaction = reaction self.appearAnimation = appearAnimation self.stillAnimation = stillAnimation self.listAnimation = listAnimation self.largeListAnimation = largeListAnimation self.applicationAnimation = applicationAnimation self.largeApplicationAnimation = largeApplicationAnimation self.isCustom = isCustom } var updateMessageReaction: UpdateMessageReaction { switch self.reaction.rawValue { case let .builtin(value): return .builtin(value) case let .custom(fileId): return .custom(fileId: fileId, file: self.listAnimation) } } } public enum ReactionContextItem: Equatable { public static func == (lhs: ReactionContextItem, rhs: ReactionContextItem) -> Bool { switch lhs { case let .staticEmoji(emoji): if case .staticEmoji(emoji) = rhs { return true } else { return false } case let .reaction(lhsReaction, lhsIcon): if case let .reaction(rhsReaction, rhsIcon) = rhs { return lhsReaction.reaction == rhsReaction.reaction && lhsIcon == rhsIcon } else { return false } case .premium: if case .premium = rhs { return true } else { return false } } } case staticEmoji(String) case reaction(item: ReactionItem, icon: EmojiPagerContentComponent.Item.Icon) case premium public var reaction: ReactionItem.Reaction? { if case let .reaction(item, _) = self { return item.reaction } else { return nil } } public var emoji: String? { if case let .staticEmoji(emoji) = self { return emoji } else { return nil } } } private let largeCircleSize: CGFloat = 16.0 private let smallCircleSize: CGFloat = 8.0 private final class ExpandItemView: UIView { private let arrowView: UIImageView let tintView: UIView override init(frame: CGRect) { self.tintView = UIView() self.tintView.backgroundColor = .white self.arrowView = UIImageView() self.arrowView.image = generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/ReactionExpandArrow"), color: .white) super.init(frame: frame) self.addSubview(self.arrowView) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func updateTheme(theme: PresentationTheme) { self.backgroundColor = theme.chat.inputMediaPanel.panelContentControlVibrantOverlayColor.mixedWith(theme.contextMenu.backgroundColor.withMultipliedAlpha(0.4), alpha: 0.5) } func update(size: CGSize, transition: ContainedViewLayoutTransition) { transition.updateCornerRadius(layer: self.layer, cornerRadius: size.width / 2.0) transition.updateCornerRadius(layer: self.tintView.layer, cornerRadius: size.width / 2.0) if let image = self.arrowView.image { transition.updateFrame(view: self.arrowView, frame: CGRect(origin: CGPoint(x: floorToScreenPixels((size.width - image.size.width) / 2.0), y: floorToScreenPixels(size.height - size.width + (size.width - image.size.height) / 2.0 + 1.0)), size: image.size)) } } } private final class TitleLabelView: UIView { let contentView = ComponentView() let tintContentView = ComponentView() var action: (() -> Void)? override init(frame: CGRect) { super.init(frame: frame) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func update(width: CGFloat, text: String, theme: PresentationTheme, transition: ContainedViewLayoutTransition) -> CGFloat { let foregroundColor: UIColor if theme.overallDarkAppearance { foregroundColor = UIColor(white: 1.0, alpha: 0.5) } else { foregroundColor = UIColor(white: 0.5, alpha: 0.9) } let body = MarkdownAttributeSet(font: Font.regular(13.0), textColor: foregroundColor) let bold = MarkdownAttributeSet(font: Font.semibold(13.0), textColor: foregroundColor) let link = MarkdownAttributeSet(font: Font.regular(13.0), textColor: theme.list.itemAccentColor, additionalAttributes: ["URL": true as NSNumber]) let attributes = MarkdownAttributes(body: body, bold: bold, link: link, linkAttribute: { _ in return nil }) let tintBody = MarkdownAttributeSet(font: Font.regular(13.0), textColor: .white) let tintBold = MarkdownAttributeSet(font: Font.semibold(13.0), textColor: .white) let tintLink = MarkdownAttributeSet(font: Font.regular(13.0), textColor: .white, additionalAttributes: [TelegramTextAttributes.URL: true as NSNumber]) let tintAttributes = MarkdownAttributes(body: tintBody, bold: tintBold, link: tintLink, linkAttribute: { _ in return (TelegramTextAttributes.URL, "") }) let contentSize = self.contentView.update( transition: .immediate, component: AnyComponent(BalancedTextComponent( text: .markdown(text: text, attributes: attributes), balanced: true, horizontalAlignment: .center, maximumNumberOfLines: 0, highlightColor: theme.list.itemAccentColor.withMultipliedAlpha(0.1), highlightAction: { attributes in if let _ = attributes[NSAttributedString.Key(rawValue: "URL")] { return NSAttributedString.Key(rawValue: "URL") } else { return nil } }, tapAction: { [weak self] attributes, _ in guard let self else { return } if let _ = attributes[NSAttributedString.Key(rawValue: "URL")] { self.action?() } } )), environment: {}, containerSize: CGSize(width: width - 8.0 * 2.0, height: 10000.0) ) let _ = self.tintContentView.update( transition: .immediate, component: AnyComponent(BalancedTextComponent( text: .markdown(text: text, attributes: tintAttributes), balanced: true, horizontalAlignment: .center, maximumNumberOfLines: 0 )), environment: {}, containerSize: CGSize(width: width - 8.0 * 2.0, height: 10000.0) ) if let contentView = self.contentView.view { if contentView.superview == nil { contentView.layer.rasterizationScale = UIScreenScale self.addSubview(contentView) } transition.updateFrame(view: contentView, frame: CGRect(origin: CGPoint(x: floorToScreenPixels((width - contentSize.width) / 2.0), y: 6.0), size: contentSize)) } return 6.0 + contentSize.height } } public final class ReactionContextNode: ASDisplayNode, ASScrollViewDelegate { private struct ItemLayout { var itemSize: CGFloat var visibleItemCount: Int init( itemSize: CGFloat, visibleItemCount: Int ) { self.itemSize = itemSize self.visibleItemCount = visibleItemCount } } private final class ContentScrollView: UIScrollView { override static var layerClass: AnyClass { return EmojiPagerContentComponent.View.ContentScrollLayer.self } init(mirrorView: UIView) { super.init(frame: CGRect()) (self.layer as? EmojiPagerContentComponent.View.ContentScrollLayer)?.mirrorLayer = mirrorView.layer } required init(coder: NSCoder) { preconditionFailure() } } private final class ContentScrollNode: ASDisplayNode { override var view: ContentScrollView { return super.view as! ContentScrollView } init(mirrorView: UIView) { super.init() self.setViewBlock({ return ContentScrollView(mirrorView: mirrorView) }) } } private struct EmojiSearchResult { var groups: [EmojiPagerContentComponent.ItemGroup] var id: AnyHashable var version: Int var isPreset: Bool } private struct EmojiSearchState { var result: EmojiSearchResult? var isSearching: Bool init(result: EmojiSearchResult?, isSearching: Bool) { self.result = result self.isSearching = isSearching } } private let context: AccountContext private let presentationData: PresentationData private let animationCache: AnimationCache private let animationRenderer: MultiAnimationRenderer public var items: [ReactionContextItem] { didSet { var updated = false if oldValue != self.items { for (_, itemNode) in self.visibleItemNodes { itemNode.selectionView?.removeFromSuperview() itemNode.selectionTintView?.removeFromSuperview() itemNode.removeFromSupernode() } self.visibleItemNodes.removeAll() updated = true } self.skipApperanceAnimation = true self.updateScrolling(transition: updated ? .immediate : .animated(duration: 0.25, curve: .easeInOut)) self.skipApperanceAnimation = false } } private var skipApperanceAnimation = false public var selectedItems: Set private let getEmojiContent: ((AnimationCache, MultiAnimationRenderer) -> Signal)? private let isExpandedUpdated: (ContainedViewLayoutTransition) -> Void private let requestLayout: (ContainedViewLayoutTransition) -> Void private let requestUpdateOverlayWantsToBeBelowKeyboard: (ContainedViewLayoutTransition) -> Void private let backgroundNode: ReactionContextBackgroundNode private let contentTintContainer: ASDisplayNode private let contentContainer: ASDisplayNode private let contentContainerMask: UIImageView private let leftBackgroundMaskNode: ASDisplayNode private let rightBackgroundMaskNode: ASDisplayNode private let backgroundMaskNode: ASDisplayNode private let mirrorContentScrollView: UIView private let scrollNode: ContentScrollNode private let previewingItemContainer: ASDisplayNode private var visibleItemNodes: [Int: ReactionItemNode] = [:] private var disappearingVisibleItemNodes: [Int: ReactionItemNode] = [:] private var visibleItemMaskNodes: [Int: ASDisplayNode] = [:] private let expandItemView: ExpandItemView? private let title: String? private let reactionsLocked: Bool private var titleLabelView: TitleLabelView? private var titleLabelHeight: CGFloat? private var reactionSelectionComponentHost: ComponentView? private var longPressRecognizer: UILongPressGestureRecognizer? private var longPressTimer: SwiftSignalKit.Timer? private var highlightedReaction: ReactionItem.Reaction? private var highlightedByHover = false private var didTriggerExpandedReaction: Bool = false private var continuousHaptic: Any? private var validLayout: (CGSize, UIEdgeInsets, CGRect, Bool, Bool)? private var isLeftAligned: Bool = true private var itemLayout: ItemLayout? public var centerAligned: Bool { return self.validLayout?.4 ?? false } private var customReactionSource: (view: UIView, rect: CGRect, layer: CALayer, item: ReactionItem)? public var emojiSelected: ((String) -> Void)? public var reactionSelected: ((UpdateMessageReaction, Bool) -> Void)? public var premiumReactionsSelected: ((TelegramMediaFile?) -> Void)? private var hapticFeedback: HapticFeedback? private var standaloneReactionAnimation: StandaloneReactionAnimation? private weak var animationTargetView: UIView? private var animationHideNode: Bool = false public var displayTail: Bool = true public var forceTailToRight: Bool = false public var forceDark: Bool = false public var hideBackground: Bool = false public var isMessageEffects: Bool = false private var didAnimateIn: Bool = false public private(set) var isAnimatingOut: Bool = false public private(set) var isAnimatingOutToReaction: Bool = false private var contentTopInset: CGFloat = 0.0 public var contentHeight: CGFloat { return self.contentTopInset + self.currentContentHeight } private var currentContentHeight: CGFloat = 46.0 public private(set) var isExpanded: Bool = false public private(set) var canBeExpanded: Bool = false private var isCollapsing: Bool = false private var animateFromExtensionDistance: CGFloat = 0.0 private var extensionDistance: CGFloat = 0.0 public private(set) var visibleExtensionDistance: CGFloat = 0.0 private var emojiContentHeight: CGFloat = 300.0 private var didInitializeEmojiContentHeight: Bool = false private var emojiContentLayout: EmojiPagerContentComponent.CustomLayout? private var emojiContent: EmojiPagerContentComponent? private var scheduledEmojiContentAnimationHint: EmojiPagerContentComponent.ContentAnimation? private var emojiContentDisposable: Disposable? private let emojiSearchDisposable = MetaDisposable() private let emojiSearchState = Promise(EmojiSearchState(result: nil, isSearching: false)) private var emojiSearchStateValue = EmojiSearchState(result: nil, isSearching: false) { didSet { self.emojiSearchState.set(.single(self.emojiSearchStateValue)) } } private var emptyResultEmojis: [TelegramMediaFile] = [] private var stableEmptyResultEmoji: TelegramMediaFile? private let stableEmptyResultEmojiDisposable = MetaDisposable() private var horizontalExpandRecognizer: UIPanGestureRecognizer? private var horizontalExpandStartLocation: CGPoint? private var horizontalExpandDistance: CGFloat = 0.0 private var animateInInfo: (centerX: CGFloat, width: CGFloat)? private var availableReactions: AvailableReactions? private var availableReactionsDisposable: Disposable? public let alwaysAllowPremiumReactions: Bool private var hideExpandedTopPanel: Bool = false private var hasPremium: Bool? private var hasPremiumDisposable: Disposable? private var allPresetReactionsAreAvailable: Bool private var genericReactionEffectDisposable: Disposable? private var genericReactionEffect: String? public var isReactionSearchActive: Bool = false public var reduceMotion: Bool = false public var isEmojiOnly: Bool = false public static func randomGenericReactionEffect(context: AccountContext) -> Signal { return context.engine.stickers.loadedStickerPack(reference: .emojiGenericAnimations, forceActualized: false) |> map { result -> [TelegramMediaFile]? in switch result { case let .result(_, items, _): return items.map(\.file) default: return nil } } |> filter { $0 != nil } |> take(1) |> mapToSignal { items -> Signal in guard let items = items else { return .single(nil) } guard let file = items.randomElement() else { return .single(nil) } return Signal { subscriber in let fetchDisposable = freeMediaFileInteractiveFetched(account: context.account, userLocation: .other, fileReference: .standalone(media: file)).start() let dataDisposable = (context.account.postbox.mediaBox.resourceData(file.resource) |> filter(\.complete) |> take(1)).start(next: { data in subscriber.putNext(data.path) subscriber.putCompletion() }) return ActionDisposable { fetchDisposable.dispose() dataDisposable.dispose() } } } } public init(context: AccountContext, animationCache: AnimationCache, presentationData: PresentationData, items: [ReactionContextItem], selectedItems: Set, title: String? = nil, reactionsLocked: Bool, alwaysAllowPremiumReactions: Bool, allPresetReactionsAreAvailable: Bool, getEmojiContent: ((AnimationCache, MultiAnimationRenderer) -> Signal)?, isExpandedUpdated: @escaping (ContainedViewLayoutTransition) -> Void, requestLayout: @escaping (ContainedViewLayoutTransition) -> Void, requestUpdateOverlayWantsToBeBelowKeyboard: @escaping (ContainedViewLayoutTransition) -> Void) { self.context = context self.presentationData = presentationData self.items = items self.selectedItems = selectedItems self.getEmojiContent = getEmojiContent self.isExpandedUpdated = isExpandedUpdated self.requestLayout = requestLayout self.requestUpdateOverlayWantsToBeBelowKeyboard = requestUpdateOverlayWantsToBeBelowKeyboard self.reactionsLocked = reactionsLocked self.animationCache = animationCache self.animationRenderer = MultiAnimationRendererImpl() self.backgroundMaskNode = ASDisplayNode() self.backgroundNode = ReactionContextBackgroundNode(largeCircleSize: largeCircleSize, smallCircleSize: smallCircleSize, maskNode: self.backgroundMaskNode) self.leftBackgroundMaskNode = ASDisplayNode() self.leftBackgroundMaskNode.backgroundColor = .black self.rightBackgroundMaskNode = ASDisplayNode() self.rightBackgroundMaskNode.backgroundColor = .black self.backgroundMaskNode.addSubnode(self.leftBackgroundMaskNode) self.backgroundMaskNode.addSubnode(self.rightBackgroundMaskNode) self.mirrorContentScrollView = UIView() self.mirrorContentScrollView.isUserInteractionEnabled = false self.scrollNode = ContentScrollNode(mirrorView: self.mirrorContentScrollView) self.scrollNode.view.disablesInteractiveTransitionGestureRecognizer = true self.scrollNode.view.showsVerticalScrollIndicator = false self.scrollNode.view.showsHorizontalScrollIndicator = false self.scrollNode.view.scrollsToTop = false self.scrollNode.view.delaysContentTouches = false self.scrollNode.view.canCancelContentTouches = true self.scrollNode.clipsToBounds = false if #available(iOS 11.0, *) { self.scrollNode.view.contentInsetAdjustmentBehavior = .never } self.previewingItemContainer = ASDisplayNode() self.previewingItemContainer.isUserInteractionEnabled = false self.contentContainer = ASDisplayNode() self.contentContainer.clipsToBounds = true self.contentContainer.addSubnode(self.scrollNode) self.contentTintContainer = ASDisplayNode() self.contentTintContainer.clipsToBounds = true self.contentTintContainer.isUserInteractionEnabled = false self.contentTintContainer.view.addSubview(self.mirrorContentScrollView) self.contentContainerMask = UIImageView() self.contentContainerMask.image = generateImage(CGSize(width: 46.0, height: 46.0), rotatedContext: { size, context in context.clear(CGRect(origin: CGPoint(), size: size)) context.translateBy(x: size.width / 2.0, y: size.height / 2.0) context.scaleBy(x: 1.0, y: 1.1) context.translateBy(x: -size.width / 2.0, y: -size.height / 2.0) 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).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 = 6.0 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(), size: size).insetBy(dx: gradientWidth - 1.0, dy: gradientWidth - 1.0)) })?.stretchableImage(withLeftCapWidth: Int(46.0 / 2.0), topCapHeight: Int(46.0 / 2.0)) if self.getEmojiContent == nil || self.reactionsLocked { self.contentContainer.view.mask = self.contentContainerMask } if getEmojiContent != nil && !self.reactionsLocked { let expandItemView = ExpandItemView() self.expandItemView = expandItemView self.contentContainer.view.addSubview(expandItemView) self.contentTintContainer.view.addSubview(expandItemView.tintView) } else { self.expandItemView = nil } self.title = title if self.title != nil { let titleLabelView = TitleLabelView(frame: CGRect()) self.titleLabelView = titleLabelView self.contentContainer.view.addSubview(titleLabelView) } self.alwaysAllowPremiumReactions = alwaysAllowPremiumReactions self.allPresetReactionsAreAvailable = allPresetReactionsAreAvailable super.init() self.addSubnode(self.backgroundNode) self.scrollNode.view.delegate = self.wrappedScrollViewDelegate self.addSubnode(self.contentContainer) self.addSubnode(self.previewingItemContainer) if let titleLabelView = self.titleLabelView { titleLabelView.action = { [weak self] in guard let self else { return } self.premiumReactionsSelected?(nil) } } self.availableReactionsDisposable = (context.engine.stickers.availableReactions() |> take(1) |> deliverOnMainQueue).start(next: { [weak self] availableReactions in guard let strongSelf = self else { return } strongSelf.availableReactions = availableReactions }) if alwaysAllowPremiumReactions { self.hasPremium = true } else { self.hasPremiumDisposable = (context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: context.account.peerId)) |> deliverOnMainQueue).start(next: { [weak self] peer in guard let strongSelf = self else { return } strongSelf.hasPremium = peer?.isPremium ?? false }) } if let getEmojiContent = getEmojiContent, !self.reactionsLocked { let viewKey = PostboxViewKey.orderedItemList(id: Namespaces.OrderedItemList.CloudFeaturedEmojiPacks) self.stableEmptyResultEmojiDisposable.set((self.context.account.postbox.combinedView(keys: [viewKey]) |> take(1) |> deliverOnMainQueue).start(next: { [weak self] views in guard let strongSelf = self, let view = views.views[viewKey] as? OrderedItemListView else { return } var filteredFiles: [TelegramMediaFile] = [] let filterList: [String] = ["😖", "😫", "🫠", "😨", "❓"] for featuredEmojiPack in view.items.lazy.map({ $0.contents.get(FeaturedStickerPackItem.self)! }) { for item in featuredEmojiPack.topItems { for attribute in item.file.attributes { switch attribute { case let .CustomEmoji(_, _, alt, _): if filterList.contains(alt) { filteredFiles.append(item.file) } default: break } } } } strongSelf.emptyResultEmojis = filteredFiles })) self.emojiContentDisposable = combineLatest(queue: .mainQueue(), getEmojiContent(self.animationCache, self.animationRenderer), self.emojiSearchState.get() ).start(next: { [weak self] emojiContent, emojiSearchState in guard let strongSelf = self else { return } strongSelf.hideExpandedTopPanel = emojiContent.panelItemGroups.isEmpty var emojiContent = emojiContent if let emojiSearchResult = emojiSearchState.result { var emptySearchResults: EmojiPagerContentComponent.EmptySearchResults? if !emojiSearchResult.groups.contains(where: { !$0.items.isEmpty || $0.fillWithLoadingPlaceholders }) { if strongSelf.stableEmptyResultEmoji == nil { strongSelf.stableEmptyResultEmoji = strongSelf.emptyResultEmojis.randomElement() } emptySearchResults = EmojiPagerContentComponent.EmptySearchResults( text: strongSelf.presentationData.strings.EmojiSearch_SearchReactionsEmptyResult, iconFile: strongSelf.stableEmptyResultEmoji ) } else { strongSelf.stableEmptyResultEmoji = nil } emojiContent = emojiContent.withUpdatedItemGroups(panelItemGroups: emojiContent.panelItemGroups, contentItemGroups: emojiSearchResult.groups, itemContentUniqueId: EmojiPagerContentComponent.ContentId(id: emojiSearchResult.id, version: emojiSearchResult.version), emptySearchResults: emptySearchResults, searchState: emojiSearchState.isSearching ? .searching : .active) } else { strongSelf.stableEmptyResultEmoji = nil } strongSelf.emojiContent = emojiContent if !strongSelf.canBeExpanded { strongSelf.canBeExpanded = true if !strongSelf.isEmojiOnly { let horizontalExpandRecognizer = UIPanGestureRecognizer(target: strongSelf, action: #selector(strongSelf.horizontalExpandGesture(_:))) strongSelf.view.addGestureRecognizer(horizontalExpandRecognizer) strongSelf.horizontalExpandRecognizer = horizontalExpandRecognizer } } strongSelf.updateEmojiContent(emojiContent) if let reactionSelectionComponentHost = strongSelf.reactionSelectionComponentHost, let componentView = reactionSelectionComponentHost.view { var emojiTransition: Transition = .immediate if let scheduledEmojiContentAnimationHint = strongSelf.scheduledEmojiContentAnimationHint { strongSelf.scheduledEmojiContentAnimationHint = nil let contentAnimation = scheduledEmojiContentAnimationHint emojiTransition = Transition(animation: .curve(duration: 0.4, curve: .spring)).withUserData(contentAnimation) } var hideTopPanel = false if strongSelf.isReactionSearchActive { hideTopPanel = true } else if strongSelf.alwaysAllowPremiumReactions || strongSelf.hideExpandedTopPanel { hideTopPanel = true } let _ = reactionSelectionComponentHost.update( transition: emojiTransition, component: AnyComponent(EmojiStatusSelectionComponent( theme: strongSelf.presentationData.theme, strings: strongSelf.presentationData.strings, deviceMetrics: DeviceMetrics.iPhone13, emojiContent: emojiContent, color: nil, backgroundColor: .clear, separatorColor: strongSelf.presentationData.theme.list.itemPlainSeparatorColor.withMultipliedAlpha(0.5), hideTopPanel: hideTopPanel, disableTopPanel: strongSelf.alwaysAllowPremiumReactions || strongSelf.hideExpandedTopPanel, hideTopPanelUpdated: { hideTopPanel, transition in guard let strongSelf = self else { return } strongSelf.isReactionSearchActive = hideTopPanel strongSelf.requestLayout(transition.containedViewLayoutTransition) } )), environment: {}, containerSize: CGSize(width: componentView.bounds.width, height: strongSelf.emojiContentHeight) ) } }) } self.genericReactionEffectDisposable = (ReactionContextNode.randomGenericReactionEffect(context: context) |> deliverOnMainQueue).start(next: { [weak self] path in self?.genericReactionEffect = path }) } deinit { self.emojiContentDisposable?.dispose() self.availableReactionsDisposable?.dispose() self.hasPremiumDisposable?.dispose() self.genericReactionEffectDisposable?.dispose() self.emojiSearchDisposable.dispose() self.stableEmptyResultEmojiDisposable.dispose() } override public func didLoad() { super.didLoad() self.view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.tapGesture(_:)))) let longPressRecognizer = UILongPressGestureRecognizer(target: self, action: #selector(self.longPressGesture(_:))) longPressRecognizer.minimumPressDuration = 0.2 self.longPressRecognizer = longPressRecognizer self.view.addGestureRecognizer(longPressRecognizer) if self.allPresetReactionsAreAvailable { longPressRecognizer.isEnabled = false } } @objc private func horizontalExpandGesture(_ recognizer: UIPanGestureRecognizer) { switch recognizer.state { case .began: self.horizontalExpandStartLocation = recognizer.location(in: self.view) case .changed: if let horizontalExpandStartLocation = self.horizontalExpandStartLocation { let currentLocation = recognizer.location(in: self.view) let distance = -min(0.0, currentLocation.x - horizontalExpandStartLocation.x) self.horizontalExpandDistance = distance let maxCompressionDistance: CGFloat = 100.0 var compressionFactor: CGFloat = max(0.0, min(1.0, self.horizontalExpandDistance / maxCompressionDistance)) compressionFactor = compressionFactor * compressionFactor if compressionFactor >= 0.95 { self.horizontalExpandStartLocation = nil self.expand() } else { self.extensionDistance = 20.0 * compressionFactor self.visibleExtensionDistance = self.extensionDistance self.requestLayout(.immediate) } } case .cancelled, .ended: if let _ = self.horizontalExpandStartLocation, self.horizontalExpandDistance != 0.0 { if self.horizontalExpandDistance >= 90.0 { self.expand() } else { self.horizontalExpandDistance = 0.0 self.extensionDistance = 0.0 self.visibleExtensionDistance = 0.0 self.requestLayout(.animated(duration: 0.4, curve: .spring)) } } default: break } } public func updateLayout(size: CGSize, insets: UIEdgeInsets, anchorRect: CGRect, centerAligned: Bool = false, isCoveredByInput: Bool, isAnimatingOut: Bool, forceUpdate: Bool = false, transition: ContainedViewLayoutTransition) { self.updateLayout(size: size, insets: insets, anchorRect: anchorRect, centerAligned: centerAligned, isCoveredByInput: isCoveredByInput, isAnimatingOut: isAnimatingOut, forceUpdate: forceUpdate, transition: transition, animateInFromAnchorRect: nil, animateOutToAnchorRect: nil) } public func updateIsIntersectingContent(isIntersectingContent: Bool, transition: ContainedViewLayoutTransition) { self.backgroundNode.updateIsIntersectingContent(isIntersectingContent: isIntersectingContent, transition: transition) } public func updateExtension(distance: CGFloat) { if self.extensionDistance != distance { self.extensionDistance = distance if let (size, insets, anchorRect, isCoveredByInput, centerAligned) = self.validLayout { self.updateLayout(size: size, insets: insets, anchorRect: anchorRect, centerAligned: centerAligned, isCoveredByInput: isCoveredByInput, isAnimatingOut: false, transition: .immediate, animateInFromAnchorRect: nil, animateOutToAnchorRect: nil) } } } public func wantsDisplayBelowKeyboard() -> Bool { if let emojiView = self.reactionSelectionComponentHost?.findTaggedView(tag: EmojiPagerContentComponent.Tag(id: AnyHashable("emoji"))) as? EmojiPagerContentComponent.View { return emojiView.wantsDisplayBelowKeyboard() } else if let stickersView = self.reactionSelectionComponentHost?.findTaggedView(tag: EmojiPagerContentComponent.Tag(id: AnyHashable("stickers"))) as? EmojiPagerContentComponent.View { return stickersView.wantsDisplayBelowKeyboard() } else { return false } } private func calculateBackgroundFrame(containerSize: CGSize, insets: UIEdgeInsets, anchorRect: CGRect, contentSize: CGSize, centerAligned: Bool) -> (backgroundFrame: CGRect, visualBackgroundFrame: CGRect, isLeftAligned: Bool, cloudSourcePoint: CGFloat) { var contentSize = contentSize contentSize.width = max(46.0, contentSize.width) contentSize.height = self.contentTopInset + self.currentContentHeight let sideInset: CGFloat if self.forceTailToRight { sideInset = insets.left } else { sideInset = 11.0 + insets.left } let backgroundOffset: CGPoint = CGPoint(x: 22.0, y: -7.0) var rect: CGRect let isLeftAligned: Bool if self.forceTailToRight { rect = CGRect(origin: CGPoint(x: anchorRect.minX - backgroundOffset.x - 4.0, y: anchorRect.minY - contentSize.height + backgroundOffset.y), size: contentSize) isLeftAligned = false } else if anchorRect.minX < containerSize.width - anchorRect.maxX { rect = CGRect(origin: CGPoint(x: anchorRect.maxX - contentSize.width + backgroundOffset.x, y: anchorRect.minY - contentSize.height + backgroundOffset.y), size: contentSize) isLeftAligned = true } else { rect = CGRect(origin: CGPoint(x: anchorRect.minX - backgroundOffset.x - 4.0, y: anchorRect.minY - contentSize.height + backgroundOffset.y), size: contentSize) isLeftAligned = false } rect.origin.x = max(sideInset, rect.origin.x) rect.origin.y = max(insets.top + sideInset, rect.origin.y) rect.origin.x = min(containerSize.width - contentSize.width - sideInset, rect.origin.x) let rightEdge = containerSize.width - sideInset if rect.maxX > rightEdge { rect.origin.x = containerSize.width - sideInset - rect.width } if rect.minX < sideInset { rect.origin.x = sideInset } if centerAligned { rect.origin.x = floor((containerSize.width - rect.width) / 2.0) } let cloudSourcePoint: CGFloat if self.forceTailToRight { cloudSourcePoint = min(rect.maxX - 46.0 / 2.0, anchorRect.maxX - 4.0) } else if isLeftAligned { cloudSourcePoint = min(rect.maxX - 46.0 / 2.0, anchorRect.maxX - 4.0) } else { cloudSourcePoint = max(rect.minX + 46.0 / 2.0, anchorRect.minX) } var visualRect = rect visualRect.size.height += self.extensionDistance return (rect, visualRect, isLeftAligned, cloudSourcePoint) } public func scrollViewDidScroll(_ scrollView: UIScrollView) { self.updateScrolling(transition: .immediate) } private func updateScrolling(transition: ContainedViewLayoutTransition) { guard let itemLayout = self.itemLayout else { return } let sideInset: CGFloat = 6.0 let itemSpacing: CGFloat = 8.0 let itemSize: CGFloat = itemLayout.itemSize let containerHeight: CGFloat = 46.0 var contentHeight: CGFloat = containerHeight if self.highlightedReaction != nil { contentHeight = floor(contentHeight * 0.9) } let totalVisibleCount: CGFloat = CGFloat(min(7, self.items.count)) let totalVisibleWidth: CGFloat = totalVisibleCount * itemSize + (totalVisibleCount - 1.0) * itemSpacing let selectedItemSize = floor(itemSize * 1.5) let remainingVisibleWidth = totalVisibleWidth - selectedItemSize let remainingVisibleCount = totalVisibleCount - 1.0 let remainingItemSize = floor((remainingVisibleWidth - (remainingVisibleCount - 1.0) * itemSpacing) / remainingVisibleCount) var visibleBounds = self.scrollNode.view.bounds self.previewingItemContainer.bounds = visibleBounds if self.highlightedReaction != nil { visibleBounds = visibleBounds.insetBy(dx: remainingItemSize - selectedItemSize, dy: 0.0) } let appearBounds = visibleBounds.insetBy(dx: 16.0, dy: 0.0) let highlightedReactionIndex: Int? if let highlightedReaction = self.highlightedReaction { highlightedReactionIndex = self.items.firstIndex(where: { $0.reaction == highlightedReaction }) } else { highlightedReactionIndex = nil } var currentMaskFrame: CGRect? var maskTransition: ContainedViewLayoutTransition? let maxCompressionDistance: CGFloat = 100.0 let compressionFactor: CGFloat = max(0.0, min(1.0, self.horizontalExpandDistance / maxCompressionDistance)) let minItemSpacing: CGFloat = 2.0 let effectiveItemSpacing: CGFloat = minItemSpacing + (1.0 - compressionFactor) * (itemSpacing - minItemSpacing) var topVisibleItems: Int if !self.reactionsLocked && self.getEmojiContent != nil { topVisibleItems = min(self.items.count, itemLayout.visibleItemCount) } else { topVisibleItems = self.items.count } var loopIdle = false for i in 0 ..< min(self.items.count, itemLayout.visibleItemCount) { if let reaction = self.items[i].reaction { switch reaction.rawValue { case .builtin: break case .custom: loopIdle = true } } } if !self.context.sharedContext.energyUsageSettings.loopEmoji { loopIdle = false } var validIndices = Set() var nextX: CGFloat = sideInset for i in 0 ..< self.items.count { var currentItemSize = itemSize if let highlightedReactionIndex = highlightedReactionIndex { if highlightedReactionIndex == i { currentItemSize = selectedItemSize } else { currentItemSize = remainingItemSize } } var baseItemFrame = CGRect(origin: CGPoint(x: nextX, y: containerHeight - contentHeight + floor((contentHeight - currentItemSize) / 2.0)), size: CGSize(width: currentItemSize, height: currentItemSize)) if highlightedReactionIndex == i { let updatedSize = floor(itemSize * 2.0) baseItemFrame = baseItemFrame.insetBy(dx: (baseItemFrame.width - updatedSize) / 2.0, dy: (baseItemFrame.height - updatedSize) / 2.0) baseItemFrame.origin.y = containerHeight - contentHeight + floor((contentHeight - itemSize) / 2.0) + itemSize + 4.0 - updatedSize } nextX += currentItemSize + effectiveItemSpacing if i >= topVisibleItems { if let itemNode = self.visibleItemNodes[i] { self.visibleItemNodes.removeValue(forKey: i) if self.disappearingVisibleItemNodes[i] == nil { self.disappearingVisibleItemNodes[i] = itemNode itemNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.1, removeOnCompletion: false, completion: { [weak self, weak itemNode] _ in guard let strongSelf = self, let itemNode = itemNode else { return } itemNode.removeFromSupernode() if strongSelf.disappearingVisibleItemNodes[i] === itemNode { strongSelf.disappearingVisibleItemNodes.removeValue(forKey: i) } }) itemNode.layer.animateScale(from: 1.0, to: 0.001, duration: 0.1, removeOnCompletion: false) } } } if i >= topVisibleItems { if let itemNode = self.disappearingVisibleItemNodes[i] { transition.updatePosition(node: itemNode, position: baseItemFrame.center, beginWithCurrentState: true) } break } if appearBounds.intersects(baseItemFrame) || (self.visibleItemNodes[i] != nil && visibleBounds.intersects(baseItemFrame)) { validIndices.insert(i) var itemFrame = baseItemFrame var selectionItemFrame = itemFrame let normalItemScale: CGFloat = 1.0 var isPreviewing = false if let highlightedReaction = self.highlightedReaction, highlightedReaction == self.items[i].reaction { isPreviewing = true } var isSelected = false if let reaction = self.items[i].reaction, self.selectedItems.contains(reaction.rawValue) { isSelected = true } else if let emoji = self.items[i].emoji, self.selectedItems.contains(emoji) { isSelected = true } if isSelected && !isPreviewing { itemFrame = itemFrame.insetBy(dx: (itemFrame.width - 0.8 * itemFrame.width) * 0.5, dy: (itemFrame.height - 0.8 * itemFrame.height) * 0.5) } var animateIn = false let maskNode: ASDisplayNode? let itemNode: ReactionItemNode var itemTransition = transition if let current = self.visibleItemNodes[i] { itemNode = current maskNode = self.visibleItemMaskNodes[i] } else { animateIn = self.didAnimateIn itemTransition = .immediate switch self.items[i] { case let .reaction(item, icon): var isLocked = self.reactionsLocked switch icon { case .locked: isLocked = true default: break } itemNode = ReactionNode(context: self.context, theme: self.presentationData.theme, item: item, icon: icon, animationCache: self.animationCache, animationRenderer: self.animationRenderer, loopIdle: loopIdle, isLocked: isLocked) maskNode = nil case let .staticEmoji(emoji): itemNode = EmojiItemNode(theme: self.presentationData.theme, emoji: emoji) maskNode = nil case .premium: itemNode = PremiumReactionsNode(theme: self.presentationData.theme) maskNode = itemNode.maskNode } self.visibleItemNodes[i] = itemNode self.scrollNode.addSubnode(itemNode) if let maskNode = maskNode { self.visibleItemMaskNodes[i] = maskNode self.backgroundMaskNode.addSubnode(maskNode) } } maskTransition = itemTransition if let selectionView = itemNode.selectionView, let selectionTintView = itemNode.selectionTintView { if isSelected { if selectionView.superview == nil { self.mirrorContentScrollView.addSubview(selectionTintView) self.scrollNode.view.addSubview(selectionView) selectionView.alpha = 1.0 selectionTintView.alpha = 1.0 if transition.isAnimated { selectionView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) selectionTintView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) transition.animateTransformScale(view: selectionView, from: 0.1) transition.animateTransformScale(view: selectionTintView, from: 0.1) } } } else { if selectionView.superview != nil { selectionView.alpha = 0.0 selectionTintView.alpha = 0.0 if transition.isAnimated { selectionView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, completion: { _ in selectionTintView.removeFromSuperview() selectionView.removeFromSuperview() }) selectionTintView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2) } else { selectionTintView.removeFromSuperview() selectionView.removeFromSuperview() } } } } if let maskNode = maskNode { let maskFrame = CGRect(origin: CGPoint(x: -self.scrollNode.view.contentOffset.x + itemFrame.minX, y: 0.0), size: CGSize(width: itemFrame.width, height: itemFrame.height + 12.0)) itemTransition.updateFrame(node: maskNode, frame: maskFrame) currentMaskFrame = maskFrame } if let reaction = self.items[i].reaction, case .custom = reaction.rawValue, self.selectedItems.contains(reaction.rawValue) { itemNode.layer.masksToBounds = true itemNode.layer.cornerRadius = 12.0 } else { itemNode.layer.masksToBounds = false itemNode.layer.cornerRadius = 0.0 } if !itemNode.isExtracted { if isPreviewing { if itemNode.supernode !== self.previewingItemContainer { self.previewingItemContainer.addSubnode(itemNode) } } if !self.reactionsLocked && self.getEmojiContent != nil && i == itemLayout.visibleItemCount - 1 { itemFrame.origin.x -= (1.0 - compressionFactor) * selectionItemFrame.width * 0.5 selectionItemFrame.origin.x -= (1.0 - compressionFactor) * selectionItemFrame.width * 0.5 itemNode.isUserInteractionEnabled = false } else { itemNode.isUserInteractionEnabled = true } itemTransition.updateFrameAsPositionAndBounds(node: itemNode, frame: itemFrame, beginWithCurrentState: true, completion: { [weak self, weak itemNode] completed in guard let strongSelf = self, let itemNode = itemNode else { return } if !completed { return } if !isPreviewing { if itemNode.supernode !== strongSelf.scrollNode { strongSelf.scrollNode.addSubnode(itemNode) } } }) itemNode.updateLayout(size: itemFrame.size, isExpanded: false, largeExpanded: false, isPreviewing: isPreviewing, transition: itemTransition) if let selectionView = itemNode.selectionView, let selectionTintView = itemNode.selectionTintView { let cornerRadius = min(selectionItemFrame.width, selectionItemFrame.height) / 2.0 itemTransition.updatePosition(layer: selectionTintView.layer, position: selectionItemFrame.center) itemTransition.updateBounds(layer: selectionTintView.layer, bounds: CGRect(origin: CGPoint(), size: selectionItemFrame.size)) itemTransition.updateCornerRadius(layer: selectionTintView.layer, cornerRadius: cornerRadius) itemTransition.updatePosition(layer: selectionView.layer, position: selectionItemFrame.center) itemTransition.updateBounds(layer: selectionView.layer, bounds: CGRect(origin: CGPoint(), size: selectionItemFrame.size)) itemTransition.updateCornerRadius(layer: selectionView.layer, cornerRadius: cornerRadius) } if animateIn && !self.skipApperanceAnimation { itemNode.appear(animated: !self.context.sharedContext.currentPresentationData.with({ $0 }).reduceMotion && !self.reduceMotion) } if !self.reactionsLocked, self.getEmojiContent != nil, i == itemLayout.visibleItemCount - 1, let itemNode = itemNode as? ReactionNode { let itemScale: CGFloat = 0.001 * (1.0 - compressionFactor) + normalItemScale * compressionFactor let alphaFraction = min(compressionFactor, 0.2) / 0.2 transition.updateSublayerTransformScale(node: itemNode, scale: itemScale) transition.updateAlpha(node: itemNode, alpha: alphaFraction) if let selectionView = itemNode.selectionView, let selectionTintView = itemNode.selectionTintView { transition.updateTransformScale(layer: selectionView.layer, scale: CGPoint(x: itemScale, y: itemScale)) transition.updateAlpha(layer: selectionView.layer, alpha: alphaFraction) transition.updateTransformScale(layer: selectionTintView.layer, scale: CGPoint(x: itemScale, y: itemScale)) transition.updateAlpha(layer: selectionTintView.layer, alpha: alphaFraction) } } else { transition.updateSublayerTransformScale(node: itemNode, scale: normalItemScale) if let selectionView = itemNode.selectionView, let selectionTintView = itemNode.selectionTintView { transition.updateSublayerTransformScale(layer: selectionView.layer, scale: CGPoint(x: normalItemScale, y: normalItemScale)) transition.updateSublayerTransformScale(layer: selectionTintView.layer, scale: CGPoint(x: normalItemScale, y: normalItemScale)) } } } } } if let expandItemView = self.expandItemView { let expandItemSize: CGFloat let expandTintOffset: CGFloat if self.highlightedReaction != nil { expandItemSize = floor(30.0 * 0.9) expandTintOffset = contentHeight - containerHeight } else { expandItemSize = 30.0 expandTintOffset = 0.0 } var baseNextFrame = CGRect(origin: CGPoint(x: self.scrollNode.view.bounds.width - expandItemSize - 9.0, y: self.contentTopInset + containerHeight - contentHeight + floor((contentHeight - expandItemSize) / 2.0)), size: CGSize(width: expandItemSize, height: expandItemSize + self.extensionDistance)) if self.isExpanded { if self.alwaysAllowPremiumReactions || self.hideExpandedTopPanel { } else { baseNextFrame.origin.y += 46.0 + 54.0 - 4.0 } } transition.updateFrame(view: expandItemView, frame: baseNextFrame) transition.updateFrame(view: expandItemView.tintView, frame: baseNextFrame.offsetBy(dx: 0.0, dy: expandTintOffset)) expandItemView.update(size: baseNextFrame.size, transition: transition) } if let currentMaskFrame = currentMaskFrame { let transition = maskTransition ?? transition transition.updateFrame(node: self.leftBackgroundMaskNode, frame: CGRect(x: -1000.0 + currentMaskFrame.minX, y: 0.0, width: 1000.0, height: self.contentTopInset + self.currentContentHeight + self.extensionDistance)) transition.updateFrame(node: self.rightBackgroundMaskNode, frame: CGRect(x: currentMaskFrame.maxX, y: 0.0, width: 1000.0, height: self.currentContentHeight + self.extensionDistance)) } else { transition.updateFrame(node: self.leftBackgroundMaskNode, frame: CGRect(x: 0.0, y: 0.0, width: 1000.0, height: self.contentTopInset + self.currentContentHeight + self.extensionDistance)) self.rightBackgroundMaskNode.frame = CGRect(origin: .zero, size: .zero) } var removedIndices: [Int] = [] for (index, itemNode) in self.visibleItemNodes { if !validIndices.contains(index) { removedIndices.append(index) itemNode.removeFromSupernode() } } for (index, maskNode) in self.visibleItemMaskNodes { if !validIndices.contains(index) { maskNode.removeFromSupernode() } } for index in removedIndices { self.visibleItemNodes.removeValue(forKey: index) self.visibleItemMaskNodes.removeValue(forKey: index) } } private func updateLayout(size: CGSize, insets: UIEdgeInsets, anchorRect: CGRect, centerAligned: Bool, isCoveredByInput: Bool, isAnimatingOut: Bool, forceUpdate: Bool = false, transition: ContainedViewLayoutTransition, animateInFromAnchorRect: CGRect?, animateOutToAnchorRect: CGRect?, animateReactionHighlight: Bool = false) { if let expandItemView = self.expandItemView { expandItemView.updateTheme(theme: self.presentationData.theme) } self.validLayout = (size, insets, anchorRect, isCoveredByInput, centerAligned) let externalSideInset: CGFloat = 4.0 let sideInset: CGFloat = 6.0 let itemSpacing: CGFloat = 8.0 var itemSize: CGFloat = 36.0 let verticalInset: CGFloat = 13.0 let rowHeight: CGFloat = 30.0 var itemCount: Int var visibleContentWidth: CGFloat var completeContentWidth: CGFloat if !self.reactionsLocked && self.getEmojiContent != nil { let totalItemSlotCount = self.items.count + 1 var maxRowItemCount = Int(floor((size.width - sideInset * 2.0 - externalSideInset * 2.0 - itemSpacing) / (itemSize + itemSpacing))) if maxRowItemCount < 8 { itemSize = floor((size.width - sideInset * 2.0 - externalSideInset * 2.0 - itemSpacing - 8 * itemSpacing) / 8.0) maxRowItemCount = Int(floor((size.width - sideInset * 2.0 - externalSideInset * 2.0 - itemSpacing) / (itemSize + itemSpacing))) } maxRowItemCount = min(maxRowItemCount, 8) itemCount = min(totalItemSlotCount, maxRowItemCount) if self.isExpanded { itemCount = maxRowItemCount } let minVisibleItemCount: CGFloat = CGFloat(itemCount) completeContentWidth = CGFloat(itemCount) * itemSize + (CGFloat(itemCount) - 1.0) * itemSpacing + sideInset * 2.0 visibleContentWidth = floor(minVisibleItemCount * itemSize + (minVisibleItemCount - 1.0) * itemSpacing + sideInset * 2.0) if visibleContentWidth > size.width - sideInset * 2.0 { visibleContentWidth = size.width - sideInset * 2.0 } } else { itemCount = self.items.count completeContentWidth = floor(CGFloat(itemCount) * itemSize + (CGFloat(itemCount) - 1.0) * itemSpacing + sideInset * 2.0) let minVisibleItemCount = min(CGFloat(self.items.count), 6.5) visibleContentWidth = floor(minVisibleItemCount * itemSize + (minVisibleItemCount - 1.0) * itemSpacing + sideInset * 2.0) if visibleContentWidth > size.width - sideInset * 2.0 { visibleContentWidth = size.width - sideInset * 2.0 } } if let title = self.title, let titleLabelView = self.titleLabelView { let titleLabelHeight = titleLabelView.update(width: visibleContentWidth, text: title, theme: self.presentationData.theme, transition: transition) self.titleLabelHeight = titleLabelHeight let baseTitleFrame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: visibleContentWidth, height: titleLabelHeight)) transition.updateFrame(view: titleLabelView, frame: baseTitleFrame) transition.updateAlpha(layer: titleLabelView.layer, alpha: self.isExpanded ? 0.0 : 1.0) if let titleView = titleLabelView.contentView.view, let tintContentView = titleLabelView.tintContentView.view { if tintContentView.superview == nil { tintContentView.layer.rasterizationScale = UIScreenScale self.contentTintContainer.view.addSubview(tintContentView) } transition.updateFrame(view: tintContentView, frame: titleView.frame.offsetBy(dx: baseTitleFrame.minX, dy: baseTitleFrame.minY)) transition.updateAlpha(layer: tintContentView.layer, alpha: self.isExpanded ? 0.0 : 1.0) } if !self.isExpanded { self.contentTopInset = titleLabelHeight } } let contentHeight = verticalInset * 2.0 + rowHeight var backgroundInsets = insets backgroundInsets.left += sideInset backgroundInsets.right += sideInset let (actualBackgroundFrame, visualBackgroundFrame, isLeftAligned, cloudSourcePoint) = self.calculateBackgroundFrame(containerSize: CGSize(width: size.width, height: size.height), insets: backgroundInsets, anchorRect: anchorRect, contentSize: CGSize(width: visibleContentWidth, height: contentHeight), centerAligned: centerAligned) self.isLeftAligned = isLeftAligned self.itemLayout = ItemLayout( itemSize: itemSize, visibleItemCount: itemCount ) var scrollFrame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: actualBackgroundFrame.size) if self.isExpanded { if self.alwaysAllowPremiumReactions || self.hideExpandedTopPanel { scrollFrame.origin.y += 0.0 } else { scrollFrame.origin.y += 46.0 + 54.0 - 4.0 } } else { scrollFrame.origin.y += self.contentTopInset } scrollFrame.origin.y += floorToScreenPixels(self.extensionDistance / 2.0) transition.updatePosition(node: self.contentContainer, position: visualBackgroundFrame.center, beginWithCurrentState: true) if !self.contentContainer.bounds.equalTo(CGRect(origin: CGPoint(), size: visualBackgroundFrame.size)) { transition.updateBounds(node: self.contentContainer, bounds: CGRect(origin: CGPoint(), size: visualBackgroundFrame.size), beginWithCurrentState: true) } transition.updateFrame(node: self.contentTintContainer, frame: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: visualBackgroundFrame.size), beginWithCurrentState: true) transition.updateFrame(view: self.contentContainerMask, frame: CGRect(origin: CGPoint(), size: visualBackgroundFrame.size), beginWithCurrentState: true) transition.updateFrame(node: self.scrollNode, frame: scrollFrame, beginWithCurrentState: true) transition.updateFrame(node: self.previewingItemContainer, frame: visualBackgroundFrame, beginWithCurrentState: true) self.scrollNode.view.contentSize = CGSize(width: completeContentWidth, height: scrollFrame.size.height) self.updateScrolling(transition: transition) self.emojiContentLayout = EmojiPagerContentComponent.CustomLayout( topPanelAlwaysHidden: self.alwaysAllowPremiumReactions || self.hideExpandedTopPanel, itemsPerRow: itemCount, itemSize: itemSize, sideInset: sideInset, itemSpacing: itemSpacing ) if (self.isExpanded || (self.reactionSelectionComponentHost != nil && !self.isCollapsing)), let _ = self.getEmojiContent, !self.reactionsLocked { let reactionSelectionComponentHost: ComponentView var componentTransition = Transition(transition) if let current = self.reactionSelectionComponentHost { reactionSelectionComponentHost = current } else { componentTransition = .immediate reactionSelectionComponentHost = ComponentView() self.reactionSelectionComponentHost = reactionSelectionComponentHost } if let emojiContent = self.emojiContent { self.updateEmojiContent(emojiContent) if let scheduledEmojiContentAnimationHint = self.scheduledEmojiContentAnimationHint { self.scheduledEmojiContentAnimationHint = nil let contentAnimation = scheduledEmojiContentAnimationHint componentTransition = Transition(animation: .curve(duration: 0.4, curve: .spring)).withUserData(contentAnimation) } var hideTopPanel = false if self.isReactionSearchActive { hideTopPanel = true } else if self.alwaysAllowPremiumReactions || self.hideExpandedTopPanel { hideTopPanel = true } if forceUpdate { componentTransition = componentTransition.withUserData(PagerComponentForceUpdate()) } let _ = reactionSelectionComponentHost.update( transition: componentTransition, component: AnyComponent(EmojiStatusSelectionComponent( theme: self.presentationData.theme, strings: self.presentationData.strings, deviceMetrics: DeviceMetrics.iPhone13, emojiContent: emojiContent, color: nil, backgroundColor: .clear, separatorColor: self.presentationData.theme.list.itemPlainSeparatorColor.withMultipliedAlpha(0.5), hideTopPanel: hideTopPanel, disableTopPanel: self.alwaysAllowPremiumReactions, hideTopPanelUpdated: { [weak self] hideTopPanel, transition in guard let strongSelf = self else { return } strongSelf.isReactionSearchActive = hideTopPanel strongSelf.requestLayout(transition.containedViewLayoutTransition) } )), environment: {}, forceUpdate: forceUpdate, containerSize: CGSize(width: actualBackgroundFrame.width, height: self.emojiContentHeight) ) if let componentView = reactionSelectionComponentHost.view { var animateIn = false if componentView.superview == nil { componentView.layer.cornerRadius = 26.0 componentView.clipsToBounds = true self.contentContainer.view.insertSubview(componentView, belowSubview: self.scrollNode.view) self.contentContainer.view.mask = nil for (_, itemNode) in self.visibleItemNodes { itemNode.isHidden = true if let selectionView = itemNode.selectionView, let selectionTintView = itemNode.selectionTintView { selectionView.isHidden = true selectionTintView.isHidden = true } } if let emojiView = reactionSelectionComponentHost.findTaggedView(tag: EmojiPagerContentComponent.Tag(id: AnyHashable("emoji"))) as? EmojiPagerContentComponent.View { var initialPositionAndFrame: [MediaId: (frame: CGRect, cornerRadius: CGFloat, frameIndex: Int, placeholder: UIImage)] = [:] for (_, itemNode) in self.visibleItemNodes { guard let itemNode = itemNode as? ReactionNode else { continue } guard let placeholder = itemNode.currentFrameImage else { continue } if itemNode.alpha.isZero { continue } initialPositionAndFrame[itemNode.item.stillAnimation.fileId] = ( frame: itemNode.frame, cornerRadius: itemNode.layer.cornerRadius, frameIndex: itemNode.currentFrameIndex, placeholder: placeholder ) } emojiView.animateInReactionSelection(sourceItems: initialPositionAndFrame) if let mirrorContentClippingView = emojiView.mirrorContentClippingView { mirrorContentClippingView.clipsToBounds = false var animationOffsetY: CGFloat = 0.0 if self.isEmojiOnly { animationOffsetY += 54.0 } else if self.alwaysAllowPremiumReactions { animationOffsetY += -4.0 } else { animationOffsetY += 46.0 + 54.0 - 4.0 } Transition(transition).animateBoundsOrigin(view: mirrorContentClippingView, from: CGPoint(x: 0.0, y: animationOffsetY), to: CGPoint(), additive: true, completion: { [weak mirrorContentClippingView] _ in mirrorContentClippingView?.clipsToBounds = true }) } } if let topPanelView = reactionSelectionComponentHost.findTaggedView(tag: EntityKeyboardTopPanelComponent.Tag(id: AnyHashable("emoji"))) as? EntityKeyboardTopPanelComponent.View { topPanelView.animateIn() } if let expandItemView = self.expandItemView { expandItemView.alpha = 0.0 expandItemView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, completion: { [weak self] _ in guard let strongSelf = self else { return } strongSelf.scrollNode.isHidden = true strongSelf.mirrorContentScrollView.isHidden = true }) expandItemView.layer.animateScale(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false) expandItemView.tintView.alpha = 0.0 expandItemView.tintView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2) expandItemView.tintView.layer.animateScale(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false) } animateIn = true } let componentFrame = CGRect(origin: CGPoint(), size: actualBackgroundFrame.size) componentTransition.setFrame(view: componentView, frame: CGRect(origin: componentFrame.origin, size: CGSize(width: componentFrame.width, height: componentFrame.height))) if animateIn { var animationOffsetY: CGFloat = 0.0 if self.isEmojiOnly { animationOffsetY += 54.0 } else if self.alwaysAllowPremiumReactions { animationOffsetY += 4.0 } else if self.isMessageEffects { animationOffsetY += 54.0 transition.animatePositionAdditive(layer: self.backgroundNode.vibrantExpandedContentContainer.layer, offset: CGPoint(x: 0.0, y: -animationOffsetY + floorToScreenPixels(self.animateFromExtensionDistance / 2.0))) } else { animationOffsetY += 46.0 + 54.0 - 4.0 } transition.animatePositionAdditive(layer: componentView.layer, offset: CGPoint(x: 0.0, y: -animationOffsetY + floorToScreenPixels(self.animateFromExtensionDistance / 2.0))) } } } } else if !self.isExpanded, let reactionSelectionComponentHost = self.reactionSelectionComponentHost { self.reactionSelectionComponentHost = nil if let componentView = reactionSelectionComponentHost.view { self.scrollNode.isHidden = false self.mirrorContentScrollView.isHidden = false if let expandItemView = self.expandItemView { expandItemView.alpha = 1.0 expandItemView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) expandItemView.layer.animateScale(from: 0.0, to: 1.0, duration: 0.2, removeOnCompletion: false) expandItemView.tintView.alpha = 1.0 expandItemView.tintView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) expandItemView.tintView.layer.animateScale(from: 0.0, to: 1.0, duration: 0.2, removeOnCompletion: false) } for (_, itemNode) in self.visibleItemNodes { itemNode.isHidden = false itemNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) transition.animatePositionAdditive(layer: itemNode.layer, offset: CGPoint(x: 0.0, y: 54.0)) if let selectionView = itemNode.selectionView, let selectionTintView = itemNode.selectionTintView { transition.animatePositionAdditive(layer: selectionView.layer, offset: CGPoint(x: 0.0, y: 54.0)) transition.animatePositionAdditive(layer: selectionTintView.layer, offset: CGPoint(x: 0.0, y: 54.0)) selectionView.isHidden = false selectionTintView.isHidden = false } } transition.updatePosition(layer: componentView.layer, position: componentView.layer.position.offsetBy(dx: 0.0, dy: -30.0)) transition.updateAlpha(layer: componentView.layer, alpha: 0.0, completion: { _ in componentView.removeFromSuperview() }) if let emojiView = reactionSelectionComponentHost.findTaggedView(tag: EmojiPagerContentComponent.Tag(id: AnyHashable("emoji"))) as? EmojiPagerContentComponent.View, let mirrorContentClippingView = emojiView.mirrorContentClippingView { transition.updatePosition(layer: mirrorContentClippingView.layer, position: mirrorContentClippingView.layer.position.offsetBy(dx: 0.0, dy: -30.0)) transition.updateAlpha(layer: mirrorContentClippingView.layer, alpha: 0.0, completion: { _ in mirrorContentClippingView.removeFromSuperview() }) } } if self.isCollapsing { Queue.mainQueue().justDispatch { self.isCollapsing = false } } } transition.updateFrame(node: self.backgroundNode, frame: visualBackgroundFrame, beginWithCurrentState: true) self.backgroundNode.update( theme: self.presentationData.theme, forceDark: self.forceDark, size: visualBackgroundFrame.size, cloudSourcePoint: cloudSourcePoint - visualBackgroundFrame.minX, isLeftAligned: isLeftAligned, isMinimized: self.highlightedReaction != nil && !self.highlightedByHover, isCoveredByInput: isCoveredByInput, displayTail: self.displayTail, forceTailToRight: self.forceTailToRight, transition: transition ) if let vibrancyEffectView = self.backgroundNode.vibrancyEffectView { if self.contentTintContainer.view.superview !== vibrancyEffectView.contentView { vibrancyEffectView.contentView.addSubview(self.contentTintContainer.view) } } if let animateInFromAnchorRect = animateInFromAnchorRect, !self.reduceMotion { let springDuration: Double = 0.5 let springDamping: CGFloat = 104.0 let springScaleDelay: Double = 0.1 let springDelay: Double = springScaleDelay + 0.01 let sourceBackgroundFrame = self.calculateBackgroundFrame(containerSize: size, insets: backgroundInsets, anchorRect: animateInFromAnchorRect, contentSize: CGSize(width: visualBackgroundFrame.height, height: contentHeight), centerAligned: false).0 self.backgroundNode.animateInFromAnchorRect(size: visualBackgroundFrame.size, sourceBackgroundFrame: sourceBackgroundFrame.offsetBy(dx: -visualBackgroundFrame.minX, dy: -visualBackgroundFrame.minY)) let xOffset = sourceBackgroundFrame.midX - visualBackgroundFrame.midX self.animateInInfo = (sourceBackgroundFrame.minX - visualBackgroundFrame.minX, visualBackgroundFrame.width) self.contentContainer.layer.animateSpring(from: NSValue(cgPoint: CGPoint(x: xOffset, y: 0.0)), to: NSValue(cgPoint: CGPoint()), keyPath: "position", duration: springDuration, delay: springDelay, initialVelocity: 0.0, damping: springDamping, additive: true) self.contentContainer.layer.animateSpring(from: NSValue(cgRect: CGRect(origin: CGPoint(x: (sourceBackgroundFrame.minX - visualBackgroundFrame.minX), y: 0.0), size: sourceBackgroundFrame.size)), to: NSValue(cgRect: CGRect(origin: CGPoint(), size: visualBackgroundFrame.size)), keyPath: "bounds", duration: springDuration, delay: springDelay, initialVelocity: 0.0, damping: springDamping) self.contentTintContainer.layer.animateSpring(from: NSValue(cgPoint: CGPoint(x: xOffset, y: 0.0)), to: NSValue(cgPoint: CGPoint()), keyPath: "position", duration: springDuration, delay: springDelay, initialVelocity: 0.0, damping: springDamping, additive: true) self.contentTintContainer.layer.animateSpring(from: NSValue(cgRect: CGRect(origin: CGPoint(x: (sourceBackgroundFrame.minX - visualBackgroundFrame.minX), y: 0.0), size: sourceBackgroundFrame.size)), to: NSValue(cgRect: CGRect(origin: CGPoint(), size: visualBackgroundFrame.size)), keyPath: "bounds", duration: springDuration, delay: springDelay, initialVelocity: 0.0, damping: springDamping) } else if let animateOutToAnchorRect = animateOutToAnchorRect { let targetBackgroundFrame = self.calculateBackgroundFrame(containerSize: size, insets: backgroundInsets, anchorRect: animateOutToAnchorRect, contentSize: CGSize(width: visibleContentWidth, height: contentHeight), centerAligned: false).0 let offset = CGPoint(x: -(targetBackgroundFrame.minX - visualBackgroundFrame.minX), y: -(targetBackgroundFrame.minY - visualBackgroundFrame.minY)) self.position = CGPoint(x: self.position.x - offset.x, y: self.position.y - offset.y) self.layer.animatePosition(from: offset, to: CGPoint(), duration: 0.2, removeOnCompletion: true, additive: true) } } private func updateEmojiContent(_ emojiContent: EmojiPagerContentComponent) { guard let emojiContentLayout = self.emojiContentLayout else { return } if !self.didInitializeEmojiContentHeight { self.didInitializeEmojiContentHeight = true if emojiContent.contentItemGroups.count == 1 && emojiContent.contentItemGroups[0].title == nil { let itemCount = emojiContent.contentItemGroups[0].items.count let numRows = (itemCount + (emojiContentLayout.itemsPerRow - 1)) / emojiContentLayout.itemsPerRow let proposedHeight: CGFloat = CGFloat(numRows) * emojiContentLayout.itemSize + CGFloat(numRows - 1) * emojiContentLayout.itemSpacing + emojiContentLayout.itemSpacing * 2.0 + 5.0 self.emojiContentHeight = min(300.0, proposedHeight) } } emojiContent.inputInteractionHolder.inputInteraction = EmojiPagerContentComponent.InputInteraction( performItemAction: { [weak self] groupId, item, sourceView, sourceRect, sourceLayer, isLongPress in guard let strongSelf = self, let availableReactions = strongSelf.availableReactions else { return } if case let .staticEmoji(emoji) = item.content { strongSelf.emojiSelected?(emoji) return } guard let itemFile = item.itemFile else { return } strongSelf.didTriggerExpandedReaction = isLongPress var found = false for reaction in availableReactions.reactions { guard let centerAnimation = reaction.centerAnimation, let aroundAnimation = reaction.aroundAnimation else { continue } if reaction.selectAnimation.fileId == itemFile.fileId { found = true let updateReaction: UpdateMessageReaction switch reaction.value { case let .builtin(value): updateReaction = .builtin(value) case let .custom(fileId): updateReaction = .custom(fileId: fileId, file: nil) } let reactionItem = ReactionItem( reaction: ReactionItem.Reaction(rawValue: reaction.value), appearAnimation: reaction.appearAnimation, stillAnimation: reaction.selectAnimation, listAnimation: centerAnimation, largeListAnimation: reaction.activateAnimation, applicationAnimation: aroundAnimation, largeApplicationAnimation: reaction.effectAnimation, isCustom: false ) if case .locked = item.icon { strongSelf.premiumReactionsSelected?(reactionItem.stillAnimation) } else if strongSelf.reactionsLocked { strongSelf.premiumReactionsSelected?(reactionItem.stillAnimation) } else { strongSelf.customReactionSource = (sourceView, sourceRect, sourceLayer, reactionItem) strongSelf.reactionSelected?(updateReaction, isLongPress) } break } } if !found { let reactionItem = ReactionItem( reaction: ReactionItem.Reaction(rawValue: .custom(itemFile.fileId.id)), appearAnimation: itemFile, stillAnimation: itemFile, listAnimation: itemFile, largeListAnimation: itemFile, applicationAnimation: nil, largeApplicationAnimation: nil, isCustom: true ) strongSelf.customReactionSource = (sourceView, sourceRect, sourceLayer, reactionItem) if case .locked = item.icon { strongSelf.premiumReactionsSelected?(reactionItem.stillAnimation) } else if strongSelf.reactionsLocked { strongSelf.premiumReactionsSelected?(reactionItem.stillAnimation) } else { strongSelf.reactionSelected?(reactionItem.updateMessageReaction, isLongPress) } } }, deleteBackwards: { }, openStickerSettings: { }, openFeatured: { }, openSearch: { }, addGroupAction: { [weak self] groupId, isPremiumLocked, _ in guard let strongSelf = self, let collectionId = groupId.base as? ItemCollectionId else { return } if isPremiumLocked { strongSelf.premiumReactionsSelected?(nil) return } let viewKey = PostboxViewKey.orderedItemList(id: Namespaces.OrderedItemList.CloudFeaturedEmojiPacks) let _ = (strongSelf.context.account.postbox.combinedView(keys: [viewKey]) |> take(1) |> deliverOnMainQueue).start(next: { views in guard let strongSelf = self, let view = views.views[viewKey] as? OrderedItemListView else { return } for featuredEmojiPack in view.items.lazy.map({ $0.contents.get(FeaturedStickerPackItem.self)! }) { if featuredEmojiPack.info.id == collectionId { if let strongSelf = self { strongSelf.scheduledEmojiContentAnimationHint = EmojiPagerContentComponent.ContentAnimation(type: .groupInstalled(id: collectionId, scrollToGroup: true)) } let _ = strongSelf.context.engine.stickers.addStickerPackInteractively(info: featuredEmojiPack.info, items: featuredEmojiPack.topItems).start() break } } }) }, clearGroup: { [weak self] groupId in guard let strongSelf = self else { return } if groupId == AnyHashable("popular") { let presentationData = strongSelf.presentationData let actionSheet = ActionSheetController(theme: ActionSheetControllerTheme(presentationTheme: presentationData.theme, fontSize: presentationData.listsFontSize)) var items: [ActionSheetItem] = [] let context = strongSelf.context items.append(ActionSheetTextItem(title: presentationData.strings.Chat_ClearReactionsAlertText, parseMarkdown: true)) items.append(ActionSheetButtonItem(title: presentationData.strings.Chat_ClearReactionsAlertAction, color: .destructive, action: { [weak actionSheet] in actionSheet?.dismissAnimated() guard let strongSelf = self else { return } strongSelf.scheduledEmojiContentAnimationHint = EmojiPagerContentComponent.ContentAnimation(type: .groupRemoved(id: "popular")) let _ = strongSelf.context.engine.stickers.clearRecentlyUsedReactions().start() })) actionSheet.setItemGroups([ActionSheetItemGroup(items: items), ActionSheetItemGroup(items: [ ActionSheetButtonItem(title: presentationData.strings.Common_Cancel, color: .accent, font: .bold, action: { [weak actionSheet] in actionSheet?.dismissAnimated() }) ])]) context.sharedContext.mainWindow?.presentInGlobalOverlay(actionSheet) } }, editAction: { _ in }, pushController: { _ in }, presentController: { _ in }, presentGlobalOverlayController: { _ in }, navigationController: { return nil }, requestUpdate: { [weak self] transition in guard let strongSelf = self else { return } strongSelf.requestUpdateOverlayWantsToBeBelowKeyboard(transition.containedViewLayoutTransition) }, updateSearchQuery: { [weak self] query in guard let self else { return } switch query { case .none: self.emojiSearchDisposable.set(nil) self.emojiSearchState.set(.single(EmojiSearchState(result: nil, isSearching: false))) case let .text(rawQuery, languageCode): let query = rawQuery.trimmingCharacters(in: .whitespacesAndNewlines) if query.isEmpty { self.emojiSearchDisposable.set(nil) self.emojiSearchState.set(.single(EmojiSearchState(result: nil, isSearching: false))) } else { let context = self.context let isEmojiOnly = self.isEmojiOnly var signal = context.engine.stickers.searchEmojiKeywords(inputLanguageCode: languageCode, query: query, completeMatch: false) if !languageCode.lowercased().hasPrefix("en") { signal = signal |> mapToSignal { keywords in return .single(keywords) |> then( context.engine.stickers.searchEmojiKeywords(inputLanguageCode: "en-US", query: query, completeMatch: query.count < 3) |> map { englishKeywords in return keywords + englishKeywords } ) } } let hasPremium = context.engine.data.subscribe(TelegramEngine.EngineData.Item.Peer.Peer(id: context.account.peerId)) |> map { peer -> Bool in guard case let .user(user) = peer else { return false } return user.isPremium } |> distinctUntilChanged let resultSignal: Signal<[EmojiPagerContentComponent.ItemGroup], NoError> if self.isMessageEffects { resultSignal = signal |> mapToSignal { keywords -> Signal<[EmojiPagerContentComponent.ItemGroup], NoError> in var allEmoticons: [String: String] = [:] for keyword in keywords { for emoticon in keyword.emoticons { allEmoticons[emoticon] = keyword.keyword } } return context.availableMessageEffects |> take(1) |> mapToSignal { availableMessageEffects -> Signal<[EmojiPagerContentComponent.ItemGroup], NoError> in guard let availableMessageEffects else { return .single([]) } var filteredEffects: [AvailableMessageEffects.MessageEffect] = [] for messageEffect in availableMessageEffects.messageEffects { if allEmoticons[messageEffect.emoticon] != nil { filteredEffects.append(messageEffect) } } var reactionEffects: [AvailableMessageEffects.MessageEffect] = [] var stickerEffects: [AvailableMessageEffects.MessageEffect] = [] for messageEffect in filteredEffects { if messageEffect.effectAnimation != nil { reactionEffects.append(messageEffect) } else { stickerEffects.append(messageEffect) } } struct ItemGroup { var supergroupId: AnyHashable var id: AnyHashable var title: String? var subtitle: String? var actionButtonTitle: String? var isPremiumLocked: Bool var isFeatured: Bool var displayPremiumBadges: Bool var hasEdit: Bool var headerItem: EntityKeyboardAnimationData? var items: [EmojiPagerContentComponent.Item] } var resultGroups: [ItemGroup] = [] var resultGroupIndexById: [AnyHashable: Int] = [:] for i in 0 ..< 2 { let groupId = i == 0 ? "reactions" : "stickers" for item in i == 0 ? reactionEffects : stickerEffects { let itemFile: TelegramMediaFile = item.effectSticker var tintMode: EmojiPagerContentComponent.Item.TintMode = .none if itemFile.isCustomTemplateEmoji { tintMode = .primary } let animationData = EntityKeyboardAnimationData(file: itemFile, partialReference: .none) let resultItem = EmojiPagerContentComponent.Item( animationData: animationData, content: .animation(animationData), itemFile: itemFile, subgroupId: nil, icon: .none, tintMode: tintMode ) if let groupIndex = resultGroupIndexById[groupId] { resultGroups[groupIndex].items.append(resultItem) } else { resultGroupIndexById[groupId] = resultGroups.count //TODO:localize resultGroups.append(ItemGroup(supergroupId: groupId, id: groupId, title: i == 0 ? nil : "Message Effects", subtitle: nil, actionButtonTitle: nil, isPremiumLocked: false, isFeatured: false, displayPremiumBadges: false, hasEdit: false, headerItem: nil, items: [resultItem])) } } } let allItemGroups = resultGroups.map { group -> EmojiPagerContentComponent.ItemGroup in let hasClear = false let isEmbedded = false return EmojiPagerContentComponent.ItemGroup( supergroupId: group.supergroupId, groupId: group.id, title: group.title, subtitle: group.subtitle, badge: nil, actionButtonTitle: group.actionButtonTitle, isFeatured: group.isFeatured, isPremiumLocked: group.isPremiumLocked, isEmbedded: isEmbedded, hasClear: hasClear, hasEdit: group.hasEdit, collapsedLineCount: nil, displayPremiumBadges: group.displayPremiumBadges, headerItem: group.headerItem, fillWithLoadingPlaceholders: false, items: group.items ) } return .single(allItemGroups) } } } else { let remotePacksSignal: Signal<(sets: FoundStickerSets, isFinalResult: Bool), NoError> = .single((FoundStickerSets(), false)) |> then( context.engine.stickers.searchEmojiSetsRemotely(query: query) |> map { ($0, true) } ) resultSignal = signal |> mapToSignal { keywords -> Signal<[EmojiPagerContentComponent.ItemGroup], NoError> in var allEmoticons: [String: String] = [:] for keyword in keywords { for emoticon in keyword.emoticons { allEmoticons[emoticon] = keyword.keyword } } if isEmojiOnly { var items: [EmojiPagerContentComponent.Item] = [] for (_, list) in EmojiPagerContentComponent.staticEmojiMapping { for emojiString in list { if allEmoticons[emojiString] != nil { let item = EmojiPagerContentComponent.Item( animationData: nil, content: .staticEmoji(emojiString), itemFile: nil, subgroupId: nil, icon: .none, tintMode: .none ) items.append(item) } } } var resultGroups: [EmojiPagerContentComponent.ItemGroup] = [] resultGroups.append(EmojiPagerContentComponent.ItemGroup( supergroupId: "search", groupId: "search", title: nil, subtitle: nil, badge: nil, actionButtonTitle: nil, isFeatured: false, isPremiumLocked: false, isEmbedded: false, hasClear: false, hasEdit: false, collapsedLineCount: nil, displayPremiumBadges: false, headerItem: nil, fillWithLoadingPlaceholders: false, items: items )) return .single(resultGroups) } else { return combineLatest( context.account.postbox.itemCollectionsView(orderedItemListCollectionIds: [], namespaces: [Namespaces.ItemCollection.CloudEmojiPacks], aroundIndex: nil, count: 10000000) |> take(1), context.engine.stickers.availableReactions() |> take(1), hasPremium |> take(1), remotePacksSignal ) |> map { view, availableReactions, hasPremium, foundPacks -> [EmojiPagerContentComponent.ItemGroup] in var result: [(String, TelegramMediaFile?, String)] = [] var allEmoticons: [String: String] = [:] for keyword in keywords { for emoticon in keyword.emoticons { allEmoticons[emoticon] = keyword.keyword } } for entry in view.entries { guard let item = entry.item as? StickerPackItem else { continue } for attribute in item.file.attributes { switch attribute { case let .CustomEmoji(_, _, alt, _): if !item.file.isPremiumEmoji || hasPremium { if !alt.isEmpty, let keyword = allEmoticons[alt] { result.append((alt, item.file, keyword)) } else if alt == query { result.append((alt, item.file, alt)) } } default: break } } } var items: [EmojiPagerContentComponent.Item] = [] var existingIds = Set() for item in result { if let itemFile = item.1 { if existingIds.contains(itemFile.fileId) { continue } existingIds.insert(itemFile.fileId) let animationData = EntityKeyboardAnimationData(file: itemFile) let item = EmojiPagerContentComponent.Item( animationData: animationData, content: .animation(animationData), itemFile: itemFile, subgroupId: nil, icon: .none, tintMode: animationData.isTemplate ? .primary : .none ) items.append(item) } } var resultGroups: [EmojiPagerContentComponent.ItemGroup] = [] resultGroups.append(EmojiPagerContentComponent.ItemGroup( supergroupId: "search", groupId: "search", title: nil, subtitle: nil, badge: nil, actionButtonTitle: nil, isFeatured: false, isPremiumLocked: false, isEmbedded: false, hasClear: false, hasEdit: false, collapsedLineCount: nil, displayPremiumBadges: false, headerItem: nil, fillWithLoadingPlaceholders: false, items: items )) for (collectionId, info, _, _) in foundPacks.sets.infos { if let info = info as? StickerPackCollectionInfo { var topItems: [StickerPackItem] = [] for e in foundPacks.sets.entries { if let item = e.item as? StickerPackItem { if e.index.collectionId == collectionId { topItems.append(item) } } } var groupItems: [EmojiPagerContentComponent.Item] = [] for item in topItems { var tintMode: EmojiPagerContentComponent.Item.TintMode = .none if item.file.isCustomTemplateEmoji { tintMode = .primary } let animationData = EntityKeyboardAnimationData(file: item.file) let resultItem = EmojiPagerContentComponent.Item( animationData: animationData, content: .animation(animationData), itemFile: item.file, subgroupId: nil, icon: .none, tintMode: tintMode ) groupItems.append(resultItem) } resultGroups.append(EmojiPagerContentComponent.ItemGroup( supergroupId: AnyHashable(info.id), groupId: AnyHashable(info.id), title: info.title, subtitle: nil, badge: nil, actionButtonTitle: nil, isFeatured: false, isPremiumLocked: false, isEmbedded: false, hasClear: false, hasEdit: false, collapsedLineCount: 3, displayPremiumBadges: false, headerItem: nil, fillWithLoadingPlaceholders: false, items: groupItems )) } } return resultGroups } } } } var version = 0 self.emojiSearchStateValue.isSearching = true self.emojiSearchDisposable.set((resultSignal |> delay(0.15, queue: .mainQueue()) |> deliverOnMainQueue).start(next: { [weak self] result in guard let self else { return } self.emojiSearchStateValue = EmojiSearchState(result: EmojiSearchResult(groups: result, id: AnyHashable(query), version: version, isPreset: false), isSearching: false) version += 1 })) } case let .category(value): let context = self.context let resultSignal: Signal<(items: [EmojiPagerContentComponent.ItemGroup], isFinalResult: Bool), NoError> if self.isMessageEffects { let keywords: Signal<[String], NoError> = .single(value.identifiers) resultSignal = keywords |> mapToSignal { keywords -> Signal<(items: [EmojiPagerContentComponent.ItemGroup], isFinalResult: Bool), NoError> in var allEmoticons: [String: String] = [:] for keyword in keywords { allEmoticons[keyword] = keyword } return context.availableMessageEffects |> take(1) |> mapToSignal { availableMessageEffects -> Signal<(items: [EmojiPagerContentComponent.ItemGroup], isFinalResult: Bool), NoError> in guard let availableMessageEffects else { return .single(([], true)) } var filteredEffects: [AvailableMessageEffects.MessageEffect] = [] for messageEffect in availableMessageEffects.messageEffects { if allEmoticons[messageEffect.emoticon] != nil { filteredEffects.append(messageEffect) } } var reactionEffects: [AvailableMessageEffects.MessageEffect] = [] var stickerEffects: [AvailableMessageEffects.MessageEffect] = [] for messageEffect in filteredEffects { if messageEffect.effectAnimation != nil { reactionEffects.append(messageEffect) } else { stickerEffects.append(messageEffect) } } struct ItemGroup { var supergroupId: AnyHashable var id: AnyHashable var title: String? var subtitle: String? var actionButtonTitle: String? var isPremiumLocked: Bool var isFeatured: Bool var displayPremiumBadges: Bool var hasEdit: Bool var headerItem: EntityKeyboardAnimationData? var items: [EmojiPagerContentComponent.Item] } var resultGroups: [ItemGroup] = [] var resultGroupIndexById: [AnyHashable: Int] = [:] for i in 0 ..< 2 { let groupId = i == 0 ? "reactions" : "stickers" for item in i == 0 ? reactionEffects : stickerEffects { let itemFile: TelegramMediaFile = item.effectSticker var tintMode: EmojiPagerContentComponent.Item.TintMode = .none if itemFile.isCustomTemplateEmoji { tintMode = .primary } let animationData = EntityKeyboardAnimationData(file: itemFile, partialReference: .none) let resultItem = EmojiPagerContentComponent.Item( animationData: animationData, content: .animation(animationData), itemFile: itemFile, subgroupId: nil, icon: .none, tintMode: tintMode ) if let groupIndex = resultGroupIndexById[groupId] { resultGroups[groupIndex].items.append(resultItem) } else { resultGroupIndexById[groupId] = resultGroups.count //TODO:localize resultGroups.append(ItemGroup(supergroupId: groupId, id: groupId, title: i == 0 ? nil : "Message Effects", subtitle: nil, actionButtonTitle: nil, isPremiumLocked: false, isFeatured: false, displayPremiumBadges: false, hasEdit: false, headerItem: nil, items: [resultItem])) } } } let allItemGroups = resultGroups.map { group -> EmojiPagerContentComponent.ItemGroup in let hasClear = false let isEmbedded = false return EmojiPagerContentComponent.ItemGroup( supergroupId: group.supergroupId, groupId: group.id, title: group.title, subtitle: group.subtitle, badge: nil, actionButtonTitle: group.actionButtonTitle, isFeatured: group.isFeatured, isPremiumLocked: group.isPremiumLocked, isEmbedded: isEmbedded, hasClear: hasClear, hasEdit: group.hasEdit, collapsedLineCount: nil, displayPremiumBadges: group.displayPremiumBadges, headerItem: group.headerItem, fillWithLoadingPlaceholders: false, items: group.items ) } return .single((allItemGroups, true)) } } } else { resultSignal = self.context.engine.stickers.searchEmoji(category: value) |> mapToSignal { files, isFinalResult -> Signal<(items: [EmojiPagerContentComponent.ItemGroup], isFinalResult: Bool), NoError> in var items: [EmojiPagerContentComponent.Item] = [] var existingIds = Set() for itemFile in files { if existingIds.contains(itemFile.fileId) { continue } existingIds.insert(itemFile.fileId) let animationData = EntityKeyboardAnimationData(file: itemFile) let item = EmojiPagerContentComponent.Item( animationData: animationData, content: .animation(animationData), itemFile: itemFile, subgroupId: nil, icon: .none, tintMode: animationData.isTemplate ? .primary : .none ) items.append(item) } return .single(([EmojiPagerContentComponent.ItemGroup( supergroupId: "search", groupId: "search", title: nil, subtitle: nil, badge: nil, actionButtonTitle: nil, isFeatured: false, isPremiumLocked: false, isEmbedded: false, hasClear: false, hasEdit: false, collapsedLineCount: nil, displayPremiumBadges: false, headerItem: nil, fillWithLoadingPlaceholders: false, items: items )], isFinalResult)) } } var version = 0 self.emojiSearchDisposable.set((resultSignal |> deliverOnMainQueue).start(next: { [weak self] result in guard let self else { return } guard let group = result.items.first else { return } if group.items.isEmpty && !result.isFinalResult { //self.emojiSearchStateValue.isSearching = true self.emojiSearchStateValue = EmojiSearchState(result: EmojiSearchResult(groups: [ EmojiPagerContentComponent.ItemGroup( supergroupId: "search", groupId: "search", title: nil, subtitle: nil, badge: nil, actionButtonTitle: nil, isFeatured: false, isPremiumLocked: false, isEmbedded: false, hasClear: false, hasEdit: false, collapsedLineCount: nil, displayPremiumBadges: false, headerItem: nil, fillWithLoadingPlaceholders: true, items: [] ) ], id: AnyHashable(value.id), version: version, isPreset: true), isSearching: false) return } self.emojiSearchStateValue = EmojiSearchState(result: EmojiSearchResult(groups: result.items, id: AnyHashable(value.id), version: version, isPreset: true), isSearching: false) version += 1 })) } }, updateScrollingToItemGroup: { }, onScroll: {}, chatPeerId: nil, peekBehavior: nil, customLayout: emojiContentLayout, externalBackground: self.backgroundNode.vibrancyEffectView == nil ? nil : EmojiPagerContentComponent.ExternalBackground( effectContainerView: self.backgroundNode.vibrantExpandedContentContainer ), externalExpansionView: self.view, customContentView: nil, useOpaqueTheme: false, hideBackground: self.hideBackground, stateContext: nil, addImage: nil ) } public func animateIn(from sourceAnchorRect: CGRect) { self.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.15) if let (size, insets, anchorRect, isCoveredByInput, centerAligned) = self.validLayout { self.updateLayout(size: size, insets: insets, anchorRect: anchorRect, centerAligned: centerAligned, isCoveredByInput: isCoveredByInput, isAnimatingOut: false, transition: .immediate, animateInFromAnchorRect: sourceAnchorRect, animateOutToAnchorRect: nil) } let mainCircleDelay: Double = 0.01 if !self.presentationData.reduceMotion && !self.reduceMotion { self.backgroundNode.animateIn() } self.didAnimateIn = true if !self.presentationData.reduceMotion && !self.reduceMotion { for i in 0 ..< self.items.count { guard let itemNode = self.visibleItemNodes[i] else { continue } if let itemLayout = self.itemLayout, !self.reactionsLocked, self.getEmojiContent != nil, i == itemLayout.visibleItemCount - 1 { itemNode.appear(animated: false) continue } let itemDelay: Double if let animateInInfo = self.animateInInfo { let distance = abs(itemNode.frame.center.x - animateInInfo.centerX) let distanceNorm = distance / animateInInfo.width let adjustedDistanceNorm = distanceNorm//listViewAnimationCurveSystem(distanceNorm) itemDelay = mainCircleDelay + adjustedDistanceNorm * 0.3 } else { itemDelay = mainCircleDelay + Double(i) * 0.06 } itemNode.willAppear(animated: true) DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + itemDelay * UIView.animationDurationFactor(), execute: { [weak itemNode] in guard let itemNode = itemNode else { return } itemNode.appear(animated: true) }) } if let expandItemView = self.expandItemView { let itemDelay: Double if let animateInInfo = self.animateInInfo { let distance = abs(expandItemView.frame.center.x - animateInInfo.centerX) let distanceNorm = distance / animateInInfo.width let adjustedDistanceNorm = distanceNorm//listViewAnimationCurveSystem(distanceNorm) itemDelay = mainCircleDelay + adjustedDistanceNorm * 0.3 } else { itemDelay = mainCircleDelay + Double(8) * 0.06 } expandItemView.layer.animateSpring(from: 0.01 as NSNumber, to: 1.0 as NSNumber, keyPath: "transform.scale", duration: 0.4, delay: itemDelay) expandItemView.tintView.layer.animateSpring(from: 0.01 as NSNumber, to: 1.0 as NSNumber, keyPath: "transform.scale", duration: 0.4, delay: itemDelay) } } else { for i in 0 ..< self.items.count { guard let itemNode = self.visibleItemNodes[i] else { continue } itemNode.appear(animated: false) } } } public func animateOut(to targetAnchorRect: CGRect?, animatingOutToReaction: Bool) { self.isAnimatingOut = true self.backgroundNode.animateOut() for (_, itemNode) in self.visibleItemNodes { if itemNode.isExtracted { continue } itemNode.layer.animateAlpha(from: itemNode.alpha, to: 0.0, duration: 0.2, removeOnCompletion: false) if let selectionView = itemNode.selectionView { selectionView.layer.animateAlpha(from: selectionView.alpha, to: 0.0, duration: 0.2, removeOnCompletion: false) } if let selectionTintView = itemNode.selectionTintView { selectionTintView.layer.animateAlpha(from: selectionTintView.alpha, to: 0.0, duration: 0.2, removeOnCompletion: false) } } if let titleLabelView = self.titleLabelView { titleLabelView.layer.animateAlpha(from: titleLabelView.alpha, to: 0.0, duration: 0.2, removeOnCompletion: false) } if let reactionComponentView = self.reactionSelectionComponentHost?.view { reactionComponentView.alpha = 0.0 reactionComponentView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2) } if let expandItemView = self.expandItemView { expandItemView.alpha = 0.0 expandItemView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2) expandItemView.tintView.alpha = 0.0 expandItemView.tintView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2) } if let targetAnchorRect = targetAnchorRect, let (size, insets, anchorRect, isCoveredByInput, centerAligned) = self.validLayout { self.updateLayout(size: size, insets: insets, anchorRect: anchorRect, centerAligned: centerAligned, isCoveredByInput: isCoveredByInput, isAnimatingOut: false, transition: .immediate, animateInFromAnchorRect: nil, animateOutToAnchorRect: targetAnchorRect) } } private func animateFromItemNodeToReaction(itemNode: ReactionNode, targetView: UIView, hideNode: Bool, completion: @escaping () -> Void) { guard let targetSnapshotView = targetView.snapshotContentTree(unhide: true) else { completion() return } //targetSnapshotView.layer.sublayers![0].backgroundColor = UIColor.green.cgColor let sourceFrame = itemNode.view.convert(itemNode.bounds, to: self.view) var selfTargetBounds = targetView.bounds if let targetView = targetView as? ReactionIconView, let iconFrame = targetView.iconFrame, !"".isEmpty { selfTargetBounds = iconFrame } /*if case .builtin = itemNode.item.reaction.rawValue { selfTargetBounds = selfTargetBounds.insetBy(dx: -selfTargetBounds.width * 0.5, dy: -selfTargetBounds.height * 0.5) }*/ let targetFrame = self.view.convert(targetView.convert(selfTargetBounds, to: nil), from: nil) targetSnapshotView.frame = targetFrame //targetSnapshotView.backgroundColor = .blue self.view.insertSubview(targetSnapshotView, belowSubview: itemNode.view) var completedTarget = false var targetScaleCompleted = false let intermediateCompletion: () -> Void = { if completedTarget && targetScaleCompleted { completion() } } let targetPosition = targetFrame.center let duration: Double = 0.16 itemNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: duration * 0.9, removeOnCompletion: false) //itemNode.layer.isHidden = true /*targetView.alpha = 1.0 targetView.isHidden = false if let targetView = targetView as? ReactionIconView { targetView.updateIsAnimationHidden(isAnimationHidden: false, transition: .immediate) }*/ itemNode.layer.animatePosition(from: itemNode.layer.position, to: targetPosition, duration: duration, removeOnCompletion: false) targetSnapshotView.alpha = 1.0 targetSnapshotView.layer.animateAlpha(from: 0.0, to: 1.0, duration: duration * 0.8) targetSnapshotView.layer.animatePosition(from: sourceFrame.center, to: targetPosition, duration: duration, removeOnCompletion: false) targetSnapshotView.layer.animateScale(from: itemNode.bounds.width / targetSnapshotView.bounds.width, to: 1.0, duration: duration, removeOnCompletion: false, completion: { [weak targetSnapshotView] _ in completedTarget = true intermediateCompletion() targetSnapshotView?.isHidden = true if hideNode { targetView.alpha = 1.0 targetView.isHidden = false if let targetView = targetView as? ReactionIconView { targetView.updateIsAnimationHidden(isAnimationHidden: false, transition: .immediate) } targetSnapshotView?.isHidden = true targetScaleCompleted = true intermediateCompletion() } else { targetScaleCompleted = true intermediateCompletion() } }) itemNode.layer.animateScale(from: 1.0, to: (targetSnapshotView.bounds.width * 1.0) / itemNode.bounds.width, duration: duration, removeOnCompletion: false) } public func willAnimateOutToReaction(value: MessageReaction.Reaction) { for (_, itemNode) in self.visibleItemNodes { if let itemNode = itemNode as? ReactionNode, itemNode.item.reaction.rawValue == value { itemNode.isExtracted = true } } } public func animateOutToReaction(value: MessageReaction.Reaction, targetView: UIView, hideNode: Bool, forceSwitchToInlineImmediately: Bool = false, animateTargetContainer: UIView?, addStandaloneReactionAnimation: ((StandaloneReactionAnimation) -> Void)?, completion: @escaping () -> Void) { self.isAnimatingOutToReaction = true var foundItemNode: ReactionNode? for (_, itemNode) in self.visibleItemNodes { if let itemNode = itemNode as? ReactionNode, itemNode.item.reaction.rawValue == value { foundItemNode = itemNode break } } if let customReactionSource = self.customReactionSource { let itemNode = ReactionNode(context: self.context, theme: self.presentationData.theme, item: customReactionSource.item, icon: .none, animationCache: self.animationCache, animationRenderer: self.animationRenderer, loopIdle: false, isLocked: false, useDirectRendering: false) if let contents = customReactionSource.layer.contents { itemNode.setCustomContents(contents: contents) } self.scrollNode.addSubnode(itemNode) itemNode.frame = customReactionSource.view.convert(customReactionSource.rect, to: self.scrollNode.view) itemNode.updateLayout(size: itemNode.frame.size, isExpanded: false, largeExpanded: false, isPreviewing: false, transition: .immediate) customReactionSource.layer.isHidden = true foundItemNode = itemNode } guard let itemNode = foundItemNode else { completion() return } let switchToInlineImmediately: Bool if itemNode.item.listAnimation.isVideoEmoji || itemNode.item.listAnimation.isVideoSticker || itemNode.item.listAnimation.isAnimatedSticker || itemNode.item.listAnimation.isStaticEmoji { switch itemNode.item.reaction.rawValue { case .builtin: switchToInlineImmediately = forceSwitchToInlineImmediately case .custom: switchToInlineImmediately = !self.didTriggerExpandedReaction } } else { switchToInlineImmediately = !self.didTriggerExpandedReaction } self.animationTargetView = targetView self.animationHideNode = hideNode if hideNode { if let animateTargetContainer = animateTargetContainer { animateTargetContainer.isHidden = true targetView.isHidden = true } else { targetView.alpha = 0.0 targetView.layer.animateAlpha(from: targetView.alpha, to: 0.0, duration: 0.2) } } itemNode.isExtracted = true let selfSourceRect = itemNode.view.convert(itemNode.view.bounds, to: self.view) var selfTargetBounds = targetView.bounds if case .builtin = itemNode.item.reaction.rawValue { selfTargetBounds = selfTargetBounds.insetBy(dx: -selfTargetBounds.width * 0.5, dy: -selfTargetBounds.height * 0.5) } let selfTargetRect = self.view.convert(selfTargetBounds, from: targetView) var expandedSize: CGSize = selfTargetRect.size if self.didTriggerExpandedReaction { if itemNode.item.listAnimation.isVideoEmoji || itemNode.item.listAnimation.isVideoSticker || itemNode.item.listAnimation.isStaticEmoji { expandedSize = CGSize(width: 80.0, height: 80.0) } else { expandedSize = CGSize(width: 120.0, height: 120.0) } } let expandedFrame = CGRect(origin: CGPoint(x: selfTargetRect.midX - expandedSize.width / 2.0, y: selfTargetRect.midY - expandedSize.height / 2.0), size: expandedSize) var effectFrame: CGRect let incomingMessage: Bool = expandedFrame.midX < self.bounds.width / 2.0 if self.didTriggerExpandedReaction { let expandFactor: CGFloat = 0.5 effectFrame = expandedFrame.insetBy(dx: -expandedFrame.width * expandFactor, dy: -expandedFrame.height * expandFactor).offsetBy(dx: incomingMessage ? (expandedFrame.width - 50.0) : (-expandedFrame.width + 50.0), dy: 0.0) } else { effectFrame = expandedFrame.insetBy(dx: -expandedSize.width, dy: -expandedSize.height) if itemNode.item.isCustom { effectFrame = effectFrame.insetBy(dx: -expandedSize.width, dy: -expandedSize.height) } } let transition: ContainedViewLayoutTransition = .animated(duration: 0.2, curve: .linear) self.addSubnode(itemNode) itemNode.position = expandedFrame.center transition.updateBounds(node: itemNode, bounds: CGRect(origin: CGPoint(), size: expandedFrame.size)) itemNode.updateLayout(size: expandedFrame.size, isExpanded: true, largeExpanded: self.didTriggerExpandedReaction, isPreviewing: false, transition: transition) let additionalAnimationNode: DefaultAnimatedStickerNodeImpl? var genericAnimationView: AnimationView? var additionalAnimation: TelegramMediaFile? if self.didTriggerExpandedReaction { additionalAnimation = itemNode.item.largeApplicationAnimation } else { additionalAnimation = itemNode.item.applicationAnimation if additionalAnimation == nil && itemNode.item.isCustom { outer: for attribute in itemNode.item.stillAnimation.attributes { if case let .CustomEmoji(_, _, alt, _) = attribute { if let availableReactions = self.availableReactions { for availableReaction in availableReactions.reactions { if availableReaction.value == .builtin(alt) { additionalAnimation = availableReaction.aroundAnimation break outer } } } break } } } } if let additionalAnimation = additionalAnimation { let additionalAnimationNodeValue = DefaultAnimatedStickerNodeImpl() additionalAnimationNode = additionalAnimationNodeValue if self.didTriggerExpandedReaction { if incomingMessage { additionalAnimationNodeValue.transform = CATransform3DMakeScale(-1.0, 1.0, 1.0) } } additionalAnimationNodeValue.setup(source: AnimatedStickerResourceSource(account: itemNode.context.account, resource: additionalAnimation.resource), width: Int(effectFrame.width * 2.0), height: Int(effectFrame.height * 2.0), playbackMode: .once, mode: .direct(cachePathPrefix: self.context.account.postbox.mediaBox.shortLivedResourceCachePathPrefix(additionalAnimation.resource.id))) additionalAnimationNodeValue.frame = effectFrame additionalAnimationNodeValue.updateLayout(size: effectFrame.size) self.addSubnode(additionalAnimationNodeValue) } else if itemNode.item.isCustom { additionalAnimationNode = nil var effectData: Data? if self.didTriggerExpandedReaction { if let url = getAppBundle().url(forResource: "generic_reaction_effect", withExtension: "json") { effectData = try? Data(contentsOf: url) } } else if let genericReactionEffect = self.genericReactionEffect, let data = try? Data(contentsOf: URL(fileURLWithPath: genericReactionEffect)) { effectData = TGGUnzipData(data, 5 * 1024 * 1024) ?? data } else { if let url = getAppBundle().url(forResource: "generic_reaction_small_effect", withExtension: "json") { effectData = try? Data(contentsOf: url) } } if let effectData = effectData, let composition = try? Animation.from(data: effectData) { let view = AnimationView(animation: composition, configuration: LottieConfiguration(renderingEngine: .mainThread, decodingStrategy: .codable)) view.animationSpeed = 1.0 view.backgroundColor = nil view.isOpaque = false if incomingMessage { view.layer.transform = CATransform3DMakeScale(-1.0, 1.0, 1.0) } genericAnimationView = view let animationCache = itemNode.context.animationCache let animationRenderer = itemNode.context.animationRenderer for i in 1 ... 32 { let allLayers = view.allLayers(forKeypath: AnimationKeypath(keypath: "placeholder_\(i)")) for animationLayer in allLayers { let baseItemLayer = InlineStickerItemLayer( context: itemNode.context, userLocation: .other, attemptSynchronousLoad: false, emoji: ChatTextInputTextCustomEmojiAttribute(interactivelySelectedFromPackId: nil, fileId: itemNode.item.listAnimation.fileId.id, file: itemNode.item.listAnimation), file: itemNode.item.listAnimation, cache: animationCache, renderer: animationRenderer, placeholderColor: UIColor(white: 0.0, alpha: 0.0), pointSize: CGSize(width: self.didTriggerExpandedReaction ? 64.0 : 32.0, height: self.didTriggerExpandedReaction ? 64.0 : 32.0) ) if let sublayers = animationLayer.sublayers { for sublayer in sublayers { sublayer.isHidden = true } } baseItemLayer.isVisibleForAnimations = true baseItemLayer.frame = CGRect(origin: CGPoint(x: -0.0, y: -0.0), size: CGSize(width: 500.0, height: 500.0)) animationLayer.addSublayer(baseItemLayer) } } if self.didTriggerExpandedReaction { view.frame = effectFrame.insetBy(dx: -10.0, dy: -10.0).offsetBy(dx: incomingMessage ? 22.0 : -22.0, dy: 0.0) } else { view.frame = effectFrame.insetBy(dx: -20.0, dy: -20.0) } self.view.addSubview(view) } } else { additionalAnimationNode = nil } var mainAnimationCompleted = false var additionalAnimationCompleted = false let intermediateCompletion: () -> Void = { if mainAnimationCompleted && additionalAnimationCompleted { completion() } } if let additionalAnimationNode = additionalAnimationNode { additionalAnimationNode.completed = { _ in additionalAnimationCompleted = true intermediateCompletion() } } else if let genericAnimationView = genericAnimationView { genericAnimationView.play(completion: { _ in additionalAnimationCompleted = true intermediateCompletion() }) } else { additionalAnimationCompleted = true } transition.animatePositionWithKeyframes(node: itemNode, keyframes: generateParabollicMotionKeyframes(from: selfSourceRect.center, to: expandedFrame.center, elevation: 30.0), completion: { [weak self, weak itemNode, weak targetView, weak animateTargetContainer] _ in let afterCompletion: () -> Void = { guard let strongSelf = self else { return } if strongSelf.didTriggerExpandedReaction { return } guard let itemNode = itemNode else { return } if let animateTargetContainer = animateTargetContainer { animateTargetContainer.isHidden = false } if let targetView = targetView { targetView.isHidden = false targetView.alpha = 1.0 targetView.layer.removeAnimation(forKey: "opacity") } if strongSelf.hapticFeedback == nil { strongSelf.hapticFeedback = HapticFeedback() } strongSelf.hapticFeedback?.tap() if let targetView = targetView as? ReactionIconView { if switchToInlineImmediately { targetView.updateIsAnimationHidden(isAnimationHidden: false, transition: .immediate) itemNode.isHidden = true } else { targetView.updateIsAnimationHidden(isAnimationHidden: true, transition: .immediate) targetView.addSubnode(itemNode) itemNode.frame = selfTargetBounds } } else if let targetView = targetView as? UIImageView { itemNode.isHidden = true targetView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.12) targetView.layer.animateScale(from: 0.2, to: 1.0, duration: 0.12) } if switchToInlineImmediately { mainAnimationCompleted = true intermediateCompletion() } } if switchToInlineImmediately { afterCompletion() } else { DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 0.1, execute: afterCompletion) } }) DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 0.15 * UIView.animationDurationFactor(), execute: { additionalAnimationNode?.visibility = true if let animateTargetContainer = animateTargetContainer { animateTargetContainer.isHidden = false animateTargetContainer.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) animateTargetContainer.layer.animateScale(from: 0.01, to: 1.0, duration: 0.2) } }) if !switchToInlineImmediately { DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + min(5.0, 2.0 * UIView.animationDurationFactor()), execute: { if self.didTriggerExpandedReaction { self.animateFromItemNodeToReaction(itemNode: itemNode, targetView: targetView, hideNode: hideNode, completion: { [weak self] in if let strongSelf = self, strongSelf.didTriggerExpandedReaction, let addStandaloneReactionAnimation = addStandaloneReactionAnimation { let standaloneReactionAnimation = StandaloneReactionAnimation(genericReactionEffect: strongSelf.genericReactionEffect) addStandaloneReactionAnimation(standaloneReactionAnimation) standaloneReactionAnimation.animateReactionSelection( context: strongSelf.context, theme: strongSelf.presentationData.theme, animationCache: strongSelf.animationCache, reaction: itemNode.item, avatarPeers: [], playHaptic: false, isLarge: false, targetView: targetView, addStandaloneReactionAnimation: nil, completion: { [weak standaloneReactionAnimation] in if let _ = standaloneReactionAnimation?.supernode { standaloneReactionAnimation?.removeFromSupernode() } else { standaloneReactionAnimation?.view.removeFromSuperview() } } ) } mainAnimationCompleted = true intermediateCompletion() }) } else { if hideNode { targetView.alpha = 1.0 targetView.isHidden = false if let targetView = targetView as? ReactionIconView { targetView.updateIsAnimationHidden(isAnimationHidden: false, transition: .immediate) if let _ = itemNode.supernode { itemNode.removeFromSupernode() } else { itemNode.view.removeFromSuperview() } } } mainAnimationCompleted = true intermediateCompletion() } }) } } override public func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { if !self.isExpanded, let titleLabelView = self.titleLabelView { if let result = titleLabelView.hitTest(self.view.convert(point, to: titleLabelView), with: event) { return result } } let contentPoint = self.contentContainer.view.convert(point, from: self.view) if self.contentContainer.bounds.contains(contentPoint) { return self.contentContainer.hitTest(contentPoint, with: event) } return nil } private let longPressDuration: Double = 0.5 @objc private func longPressGesture(_ recognizer: UILongPressGestureRecognizer) { switch recognizer.state { case .began: let point = recognizer.location(in: self.view) if let itemNode = self.reactionItemNode(at: point) as? ReactionNode { if self.selectedItems.contains(itemNode.item.reaction.rawValue) { recognizer.state = .cancelled return } if !itemNode.isAnimationLoaded { recognizer.state = .cancelled return } self.highlightedReaction = itemNode.item.reaction if #available(iOS 13.0, *) { self.continuousHaptic = try? ContinuousHaptic(duration: longPressDuration) } if self.hapticFeedback == nil { self.hapticFeedback = HapticFeedback() } if let (size, insets, anchorRect, isCoveredByInput, centerAligned) = self.validLayout { self.updateLayout(size: size, insets: insets, anchorRect: anchorRect, centerAligned: centerAligned, isCoveredByInput: isCoveredByInput, isAnimatingOut: false, transition: .animated(duration: longPressDuration, curve: .linear), animateInFromAnchorRect: nil, animateOutToAnchorRect: nil, animateReactionHighlight: true) } self.longPressTimer?.invalidate() self.longPressTimer = SwiftSignalKit.Timer(timeout: longPressDuration, repeat: false, completion: { [weak self] in guard let strongSelf = self else { return } strongSelf.longPressRecognizer?.state = .ended }, queue: .mainQueue()) self.longPressTimer?.start() } case .changed: let point = recognizer.location(in: self.view) var shouldCancel = false if let itemNode = self.reactionItemNode(at: point) as? ReactionNode { if self.highlightedReaction != itemNode.item.reaction { shouldCancel = true } } else { shouldCancel = true } if shouldCancel { self.longPressRecognizer?.state = .cancelled } case .cancelled: self.longPressTimer?.invalidate() self.continuousHaptic = nil self.highlightedReaction = nil if let (size, insets, anchorRect, isCoveredByInput, centerAligned) = self.validLayout { self.updateLayout(size: size, insets: insets, anchorRect: anchorRect, centerAligned: centerAligned, isCoveredByInput: isCoveredByInput, isAnimatingOut: false, transition: .animated(duration: 0.3, curve: .spring), animateInFromAnchorRect: nil, animateOutToAnchorRect: nil, animateReactionHighlight: true) } case .ended: self.longPressTimer?.invalidate() self.continuousHaptic = nil self.didTriggerExpandedReaction = true self.highlightGestureFinished(performAction: true, isLarge: true) default: break } } @objc private func tapGesture(_ recognizer: UITapGestureRecognizer) { switch recognizer.state { case .ended: let point = recognizer.location(in: self.view) if self.isExpanded || self.isCollapsing { return } if let expandItemView = self.expandItemView, expandItemView.bounds.contains(self.view.convert(point, to: self.expandItemView)) { self.animateFromExtensionDistance = self.contentTopInset * 2.0 + self.extensionDistance self.contentTopInset = 0.0 self.currentContentHeight = self.emojiContentHeight self.isExpanded = true self.longPressRecognizer?.isEnabled = false self.isExpandedUpdated(.animated(duration: 0.4, curve: .spring)) } else if let reaction = self.reaction(at: point) { switch reaction { case let .reaction(reactionItem, icon): if case .custom = reactionItem.updateMessageReaction, let hasPremium = self.hasPremium, !hasPremium, !self.allPresetReactionsAreAvailable { self.premiumReactionsSelected?(reactionItem.stillAnimation) } else if self.reactionsLocked { self.premiumReactionsSelected?(reactionItem.stillAnimation) } else if case .locked = icon { self.premiumReactionsSelected?(reactionItem.stillAnimation) } else { self.reactionSelected?(reactionItem.updateMessageReaction, false) } case let .staticEmoji(emoji): self.emojiSelected?(emoji) case .premium: self.premiumReactionsSelected?(nil) } } default: break } } public func hasSpaceInTheBottom(insets: UIEdgeInsets, height: CGFloat) -> Bool { if self.backgroundNode.frame.maxY < self.bounds.height - insets.bottom - height { return true } else { return false } } public func expand() { if self.hapticFeedback == nil { self.hapticFeedback = HapticFeedback() } self.hapticFeedback?.tap() self.longPressRecognizer?.isEnabled = false self.animateFromExtensionDistance = self.contentTopInset * 2.0 + self.extensionDistance self.extensionDistance = 0.0 self.visibleExtensionDistance = 0.0 self.contentTopInset = 0.0 self.currentContentHeight = self.emojiContentHeight self.isExpanded = true self.isExpandedUpdated(.animated(duration: 0.4, curve: .spring)) } public func collapse() { if self.hapticFeedback == nil { self.hapticFeedback = HapticFeedback() } self.hapticFeedback?.tap() self.view.endEditing(true) self.longPressRecognizer?.isEnabled = false guard self.isExpanded else { return } self.animateFromExtensionDistance = 0.0 self.extensionDistance = 0.0 self.visibleExtensionDistance = 0.0 self.contentTopInset = self.titleLabelHeight ?? 0.0 self.currentContentHeight = 46.0 self.isExpanded = false self.isCollapsing = true self.isExpandedUpdated(.animated(duration: 0.4, curve: .spring)) self.emojiSearchDisposable.set(nil) self.emojiSearchState.set(.single(EmojiSearchState(result: nil, isSearching: false))) } public func highlightGestureMoved(location: CGPoint, hover: Bool) { if self.allPresetReactionsAreAvailable { return } let highlightedReaction = self.previewReaction(at: location)?.reaction if self.highlightedReaction != highlightedReaction { self.highlightedReaction = highlightedReaction self.highlightedByHover = hover && highlightedReaction != nil if !hover { if self.hapticFeedback == nil { self.hapticFeedback = HapticFeedback() } self.hapticFeedback?.tap() } if let (size, insets, anchorRect, isCoveredByInput, centerAligned) = self.validLayout { self.updateLayout(size: size, insets: insets, anchorRect: anchorRect, centerAligned: centerAligned, isCoveredByInput: isCoveredByInput, isAnimatingOut: false, transition: .animated(duration: 0.18, curve: .easeInOut), animateInFromAnchorRect: nil, animateOutToAnchorRect: nil, animateReactionHighlight: true) } } } public func highlightGestureFinished(performAction: Bool) { if self.allPresetReactionsAreAvailable { return } self.highlightGestureFinished(performAction: performAction, isLarge: false) } private func highlightGestureFinished(performAction: Bool, isLarge: Bool) { if self.allPresetReactionsAreAvailable { return } if let highlightedReaction = self.highlightedReaction { self.highlightedReaction = nil if performAction { self.performReactionSelection(reaction: highlightedReaction, isLarge: isLarge) } else { if let (size, insets, anchorRect, isCoveredByInput, centerAligned) = self.validLayout { self.updateLayout(size: size, insets: insets, anchorRect: anchorRect, centerAligned: centerAligned, isCoveredByInput: isCoveredByInput, isAnimatingOut: false, transition: .animated(duration: 0.18, curve: .easeInOut), animateInFromAnchorRect: nil, animateOutToAnchorRect: nil, animateReactionHighlight: true) } } } } private func previewReaction(at point: CGPoint) -> ReactionItem? { let scrollPoint = self.view.convert(point, to: self.scrollNode.view) if !self.scrollNode.bounds.contains(scrollPoint) { return nil } let itemSize: CGFloat = 40.0 var closestItem: (index: Int, distance: CGFloat)? for (index, itemNode) in self.visibleItemNodes { let intersectionItemFrame = CGRect(origin: CGPoint(x: itemNode.position.x - itemSize / 2.0, y: itemNode.position.y - 1.0), size: CGSize(width: itemSize, height: 2.0)) if !self.scrollNode.bounds.contains(intersectionItemFrame) { continue } let distance = abs(scrollPoint.x - intersectionItemFrame.midX) if let (_, currentDistance) = closestItem { if currentDistance > distance { closestItem = (index, distance) } } else { closestItem = (index, distance) } } if let closestItem = closestItem, let closestItemNode = self.visibleItemNodes[closestItem.index] as? ReactionNode { if let expandItemView = self.expandItemView, expandItemView.frame.insetBy(dx: -20.0, dy: -20.0).contains(scrollPoint) { return nil } return closestItemNode.item } return nil } private func reactionItemNode(at point: CGPoint) -> ReactionItemNode? { for i in 0 ..< 2 { let touchInset: CGFloat = i == 0 ? 0.0 : 8.0 for (_, itemNode) in self.visibleItemNodes { if itemNode.supernode === self.scrollNode && !self.scrollNode.bounds.intersects(itemNode.frame) { continue } if !itemNode.isUserInteractionEnabled { continue } let itemPoint = self.view.convert(point, to: itemNode.view) if itemNode.bounds.insetBy(dx: -touchInset, dy: -touchInset).contains(itemPoint) { return itemNode } } } return nil } public func reaction(at point: CGPoint) -> ReactionContextItem? { let itemNode = self.reactionItemNode(at: point) if let itemNode = itemNode as? ReactionNode { if !itemNode.isAnimationLoaded { return nil } return .reaction(item: itemNode.item, icon: itemNode.icon) } else if let itemNode = itemNode as? EmojiItemNode { return .staticEmoji(itemNode.emoji) } else if let _ = itemNode as? PremiumReactionsNode { return .premium } return nil } public func performReactionSelection(reaction: ReactionItem.Reaction, isLarge: Bool) { for (_, itemNode) in self.visibleItemNodes { if let itemNode = itemNode as? ReactionNode, itemNode.item.reaction == reaction { if case .custom = itemNode.item.updateMessageReaction, let hasPremium = self.hasPremium, !hasPremium { self.premiumReactionsSelected?(itemNode.item.stillAnimation) } else if self.reactionsLocked { self.premiumReactionsSelected?(itemNode.item.stillAnimation) } else { self.reactionSelected?(itemNode.item.updateMessageReaction, isLarge) } break } } } public func cancelReactionAnimation() { self.standaloneReactionAnimation?.cancel() if let animationTargetView = self.animationTargetView, self.animationHideNode { animationTargetView.alpha = 1.0 animationTargetView.isHidden = false } } public func setHighlightedReaction(_ value: ReactionItem.Reaction?) { self.highlightedReaction = value if let (size, insets, anchorRect, isCoveredByInput, centerAligned) = self.validLayout { self.updateLayout(size: size, insets: insets, anchorRect: anchorRect, centerAligned: centerAligned, isCoveredByInput: isCoveredByInput, isAnimatingOut: false, transition: .animated(duration: 0.18, curve: .easeInOut), animateInFromAnchorRect: nil, animateOutToAnchorRect: nil, animateReactionHighlight: true) } } } public final class StandaloneReactionAnimation: ASDisplayNode { private let genericReactionEffect: String? private let useDirectRendering: Bool private var itemNode: ReactionNode? = nil private var itemNodeIsEmbedded: Bool = false private let hapticFeedback = HapticFeedback() private var isCancelled: Bool = false private weak var targetView: UIView? public init(genericReactionEffect: String?, useDirectRendering: Bool = false) { self.genericReactionEffect = genericReactionEffect self.useDirectRendering = useDirectRendering super.init() self.isUserInteractionEnabled = false } public func animateReactionSelection(context: AccountContext, theme: PresentationTheme, animationCache: AnimationCache, reaction: ReactionItem, customEffectResource: MediaResource? = nil, avatarPeers: [EnginePeer], playHaptic: Bool, isLarge: Bool, playCenterReaction: Bool = true, forceSmallEffectAnimation: Bool = false, hideCenterAnimation: Bool = false, targetView: UIView, addStandaloneReactionAnimation: ((StandaloneReactionAnimation) -> Void)?, completion: @escaping () -> Void) { self.animateReactionSelection(context: context, theme: theme, animationCache: animationCache, reaction: reaction, customEffectResource: customEffectResource, avatarPeers: avatarPeers, playHaptic: playHaptic, isLarge: isLarge, playCenterReaction: playCenterReaction, forceSmallEffectAnimation: forceSmallEffectAnimation, hideCenterAnimation: hideCenterAnimation, targetView: targetView, addStandaloneReactionAnimation: addStandaloneReactionAnimation, currentItemNode: nil, completion: completion) } public var currentDismissAnimation: (() -> Void)? public func animateReactionSelection(context: AccountContext, theme: PresentationTheme, animationCache: AnimationCache, reaction: ReactionItem, customEffectResource: MediaResource? = nil, avatarPeers: [EnginePeer], playHaptic: Bool, isLarge: Bool, playCenterReaction: Bool = true, forceSmallEffectAnimation: Bool = false, hideCenterAnimation: Bool = false, targetView: UIView, addStandaloneReactionAnimation: ((StandaloneReactionAnimation) -> Void)?, currentItemNode: ReactionNode?, completion: @escaping () -> Void) { guard let sourceSnapshotView = targetView.snapshotContentTree() else { completion() return } if playHaptic { self.hapticFeedback.tap() } self.targetView = targetView let itemNode: ReactionNode? if playCenterReaction { if let currentItemNode = currentItemNode { itemNode = currentItemNode } else { let animationRenderer = MultiAnimationRendererImpl() itemNode = ReactionNode(context: context, theme: theme, item: reaction, icon: .none, animationCache: animationCache, animationRenderer: animationRenderer, loopIdle: false, isLocked: false) } self.itemNode = itemNode } else { itemNode = nil } let switchToInlineImmediately: Bool if let itemNode { if itemNode.item.listAnimation.isVideoEmoji || itemNode.item.listAnimation.isVideoSticker || itemNode.item.listAnimation.isAnimatedSticker || itemNode.item.listAnimation.isStaticEmoji { switch itemNode.item.reaction.rawValue { case .builtin: switchToInlineImmediately = false case .custom: switchToInlineImmediately = true } } else { switchToInlineImmediately = false } } else { switchToInlineImmediately = false } if let itemNode, !forceSmallEffectAnimation, !switchToInlineImmediately, !hideCenterAnimation { if let targetView = targetView as? ReactionIconView, !isLarge { self.itemNodeIsEmbedded = true targetView.addSubnode(itemNode) } else { self.addSubnode(itemNode) } } if let itemNode { itemNode.expandedAnimationDidBegin = { [weak self, weak targetView] in guard let strongSelf = self, let targetView = targetView else { return } if let targetView = targetView as? ReactionIconView, !isLarge { strongSelf.itemNodeIsEmbedded = true targetView.updateIsAnimationHidden(isAnimationHidden: true, transition: .immediate) } else { targetView.isHidden = true } } itemNode.isExtracted = true } var selfTargetBounds = targetView.bounds if let targetView = targetView as? ReactionIconView, let iconFrame = targetView.iconFrame { selfTargetBounds = iconFrame } let selfTargetRect = self.view.convert(selfTargetBounds, from: targetView) var expandedSize: CGSize = selfTargetRect.size if isLarge { expandedSize = CGSize(width: 120.0, height: 120.0) } let expandedFrame = CGRect(origin: CGPoint(x: selfTargetRect.midX - expandedSize.width / 2.0, y: selfTargetRect.midY - expandedSize.height / 2.0), size: expandedSize) let effectFrame: CGRect let incomingMessage: Bool = expandedFrame.midX < self.bounds.width / 2.0 if isLarge && !forceSmallEffectAnimation { effectFrame = expandedFrame.insetBy(dx: -expandedFrame.width * 0.5, dy: -expandedFrame.height * 0.5).offsetBy(dx: incomingMessage ? (expandedFrame.width - 50.0) : (-expandedFrame.width + 50.0), dy: 0.0) } else { effectFrame = expandedFrame.insetBy(dx: -expandedSize.width, dy: -expandedSize.height) } if !self.itemNodeIsEmbedded { sourceSnapshotView.frame = selfTargetRect self.view.addSubview(sourceSnapshotView) sourceSnapshotView.alpha = 0.0 sourceSnapshotView.layer.animateSpring(from: 1.0 as NSNumber, to: (expandedFrame.width / selfTargetRect.width) as NSNumber, keyPath: "transform.scale", duration: 0.7) sourceSnapshotView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.01, completion: { [weak sourceSnapshotView] _ in sourceSnapshotView?.removeFromSuperview() }) } if let itemNode { if self.itemNodeIsEmbedded { itemNode.frame = selfTargetBounds } else { itemNode.frame = expandedFrame itemNode.layer.animateSpring(from: (selfTargetRect.width / expandedFrame.width) as NSNumber, to: 1.0 as NSNumber, keyPath: "transform.scale", duration: 0.7) } itemNode.updateLayout(size: expandedFrame.size, isExpanded: true, largeExpanded: isLarge, isPreviewing: false, transition: .immediate) } var additionalAnimationResource: MediaResource? if isLarge && !forceSmallEffectAnimation { additionalAnimationResource = reaction.largeApplicationAnimation?.resource } else { additionalAnimationResource = reaction.applicationAnimation?.resource } if additionalAnimationResource == nil, let customEffectResource { additionalAnimationResource = customEffectResource } let additionalAnimationNode: AnimatedStickerNode? var genericAnimationView: AnimationView? if let additionalAnimationResource { let additionalAnimationNodeValue: AnimatedStickerNode if self.useDirectRendering { additionalAnimationNodeValue = DirectAnimatedStickerNode() } else { additionalAnimationNodeValue = DefaultAnimatedStickerNodeImpl() } additionalAnimationNode = additionalAnimationNodeValue if isLarge && !forceSmallEffectAnimation { if incomingMessage { additionalAnimationNodeValue.transform = CATransform3DMakeScale(-1.0, 1.0, 1.0) } } let additionalCachePathPrefix: String? = nil additionalAnimationNodeValue.setup(source: AnimatedStickerResourceSource(account: context.account, resource: additionalAnimationResource), width: Int(effectFrame.width * 1.33), height: Int(effectFrame.height * 1.33), playbackMode: .once, mode: .direct(cachePathPrefix: additionalCachePathPrefix)) additionalAnimationNodeValue.frame = effectFrame additionalAnimationNodeValue.updateLayout(size: effectFrame.size) self.addSubnode(additionalAnimationNodeValue) } else if reaction.isCustom { var effectURL: URL? if let genericReactionEffect = self.genericReactionEffect { effectURL = URL(fileURLWithPath: genericReactionEffect) } else { if let url = getAppBundle().url(forResource: "generic_reaction_small_effect", withExtension: "json") { effectURL = url } } if let effectURL { let additionalAnimationNodeValue: AnimatedStickerNode if self.useDirectRendering { additionalAnimationNodeValue = DirectAnimatedStickerNode() } else { additionalAnimationNodeValue = DefaultAnimatedStickerNodeImpl() } additionalAnimationNode = additionalAnimationNodeValue if isLarge && !forceSmallEffectAnimation { if incomingMessage { additionalAnimationNodeValue.transform = CATransform3DMakeScale(-1.0, 1.0, 1.0) } } additionalAnimationNodeValue.setup(source: AnimatedStickerNodeLocalFileSource(name: effectURL.path), width: Int(effectFrame.width * 1.33), height: Int(effectFrame.height * 1.33), playbackMode: .once, mode: .direct(cachePathPrefix: nil)) additionalAnimationNodeValue.frame = effectFrame additionalAnimationNodeValue.updateLayout(size: effectFrame.size) self.addSubnode(additionalAnimationNodeValue) } else { additionalAnimationNode = nil } var patternEffectData: Data? if let url = getAppBundle().url(forResource: "generic_reaction_inline_pattern", withExtension: "json") { patternEffectData = try? Data(contentsOf: url) } if let patternEffectData, let composition = try? Animation.from(data: patternEffectData) { let view = AnimationView(animation: composition, configuration: LottieConfiguration(renderingEngine: .mainThread, decodingStrategy: .codable)) view.animationSpeed = 1.0 view.backgroundColor = nil view.isOpaque = false if incomingMessage { view.layer.transform = CATransform3DMakeScale(-1.0, 1.0, 1.0) } genericAnimationView = view let animationCache = context.animationCache let animationRenderer = context.animationRenderer for i in 1 ... 7 { let allLayers = view.allLayers(forKeypath: AnimationKeypath(keypath: "placeholder_\(i)")) for animationLayer in allLayers { let baseItemLayer = InlineStickerItemLayer( context: context, userLocation: .other, attemptSynchronousLoad: false, emoji: ChatTextInputTextCustomEmojiAttribute(interactivelySelectedFromPackId: nil, fileId: reaction.listAnimation.fileId.id, file: reaction.listAnimation), file: reaction.listAnimation, cache: animationCache, renderer: animationRenderer, placeholderColor: UIColor(white: 0.0, alpha: 0.0), pointSize: CGSize(width: 32.0, height: 32.0) ) if let sublayers = animationLayer.sublayers { for sublayer in sublayers { sublayer.isHidden = true } } baseItemLayer.isVisibleForAnimations = true baseItemLayer.frame = CGRect(origin: CGPoint(x: -0.0, y: -0.0), size: CGSize(width: 500.0, height: 500.0)) animationLayer.addSublayer(baseItemLayer) } } view.frame = effectFrame.insetBy(dx: -20.0, dy: -20.0)//.offsetBy(dx: incomingMessage ? 22.0 : -22.0, dy: 0.0) self.view.addSubview(view) } } else { additionalAnimationNode = nil } if let additionalAnimationNode = additionalAnimationNode, !isLarge, !avatarPeers.isEmpty, let url = getAppBundle().url(forResource: "effectavatar", withExtension: "json"), let composition = Animation.filepath(url.path) { let view = AnimationView(animation: composition, configuration: LottieConfiguration(renderingEngine: .mainThread, decodingStrategy: .codable)) view.animationSpeed = 1.0 view.backgroundColor = nil view.isOpaque = false var avatarIndex = 0 let keypathIndices: [Int] = Array((1 ... 3).map({ $0 }).shuffled()) for i in keypathIndices { var peer: EnginePeer? if avatarIndex < avatarPeers.count { peer = avatarPeers[avatarIndex] } avatarIndex += 1 if let peer = peer { let avatarNode = AvatarNode(font: avatarPlaceholderFont(size: 16.0)) let avatarContainer = UIView(frame: CGRect(origin: CGPoint(x: -100.0, y: -100.0), size: CGSize(width: 200.0, height: 200.0))) avatarNode.frame = CGRect(origin: CGPoint(x: floor((200.0 - 40.0) / 2.0), y: floor((200.0 - 40.0) / 2.0)), size: CGSize(width: 40.0, height: 40.0)) avatarNode.setPeer(context: context, theme: context.sharedContext.currentPresentationData.with({ $0 }).theme, peer: peer) avatarNode.transform = CATransform3DMakeScale(200.0 / 40.0, 200.0 / 40.0, 1.0) avatarContainer.addSubnode(avatarNode) let animationSubview = AnimationSubview() animationSubview.addSubview(avatarContainer) view.addSubview(animationSubview, forLayerAt: AnimationKeypath(keypath: "Avatar \(i).Ellipse 1")) } view.setValueProvider(ColorValueProvider(UIColor.clear.lottieColorValue), keypath: AnimationKeypath(keypath: "Avatar \(i).Ellipse 1.Fill 1.Color")) /*let colorCallback = LOTColorValueCallback(color: UIColor.clear.cgColor) self.colorCallbacks.append(colorCallback) view.setValueDelegate(colorCallback, for: LOTKeypath(string: "Avatar \(i).Ellipse 1.Fill 1.Color"))*/ } view.frame = additionalAnimationNode.bounds additionalAnimationNode.view.addSubview(view) view.play() } var mainAnimationCompleted = false var additionalAnimationCompleted = false let intermediateCompletion: () -> Void = { if mainAnimationCompleted && additionalAnimationCompleted { completion() } } var didBeginDismissAnimation = false let beginDismissAnimation: () -> Void = { [weak self, weak additionalAnimationNode] in if !didBeginDismissAnimation { didBeginDismissAnimation = true guard let strongSelf = self else { mainAnimationCompleted = true intermediateCompletion() return } if forceSmallEffectAnimation { if let additionalAnimationNode = additionalAnimationNode { additionalAnimationNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak additionalAnimationNode] _ in additionalAnimationNode?.removeFromSupernode() }) } mainAnimationCompleted = true intermediateCompletion() } else { if isLarge, let itemNode { let genericReactionEffect = strongSelf.genericReactionEffect strongSelf.animateFromItemNodeToReaction(itemNode: itemNode, targetView: targetView, hideNode: true, completion: { if let addStandaloneReactionAnimation = addStandaloneReactionAnimation { let standaloneReactionAnimation = StandaloneReactionAnimation(genericReactionEffect: genericReactionEffect) addStandaloneReactionAnimation(standaloneReactionAnimation) standaloneReactionAnimation.animateReactionSelection( context: context, theme: context.sharedContext.currentPresentationData.with({ $0 }).theme, animationCache: animationCache, reaction: reaction, avatarPeers: avatarPeers, playHaptic: false, isLarge: false, targetView: targetView, addStandaloneReactionAnimation: nil, completion: { [weak standaloneReactionAnimation] in standaloneReactionAnimation?.removeFromSupernode() } ) } mainAnimationCompleted = true intermediateCompletion() }) } else { if let targetView = strongSelf.targetView { if let targetView = targetView as? ReactionIconView, !isLarge { targetView.updateIsAnimationHidden(isAnimationHidden: false, transition: .immediate) } else { targetView.alpha = 1.0 targetView.isHidden = false } } if strongSelf.itemNodeIsEmbedded { strongSelf.itemNode?.removeFromSupernode() } mainAnimationCompleted = true intermediateCompletion() } } } } self.currentDismissAnimation = beginDismissAnimation let maybeBeginDismissAnimation: () -> Void = { if mainAnimationCompleted && additionalAnimationCompleted { beginDismissAnimation() } } if forceSmallEffectAnimation { mainAnimationCompleted = true maybeBeginDismissAnimation() } if let additionalAnimationNode = additionalAnimationNode { additionalAnimationNode.completed = { [weak additionalAnimationNode] _ in additionalAnimationNode?.alpha = 0.0 additionalAnimationNode?.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2) additionalAnimationCompleted = true intermediateCompletion() if forceSmallEffectAnimation { maybeBeginDismissAnimation() } else { beginDismissAnimation() } } if let genericAnimationView = genericAnimationView { genericAnimationView.play(completion: { [weak genericAnimationView] _ in genericAnimationView?.alpha = 0.0 genericAnimationView?.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2) }) } additionalAnimationNode.visibility = true } else if let genericAnimationView = genericAnimationView { genericAnimationView.play(completion: { _ in additionalAnimationNode?.alpha = 0.0 additionalAnimationNode?.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2) additionalAnimationCompleted = true intermediateCompletion() if forceSmallEffectAnimation { maybeBeginDismissAnimation() } else { beginDismissAnimation() } }) } else { additionalAnimationCompleted = true } if !forceSmallEffectAnimation { DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 2.0, execute: { beginDismissAnimation() }) } } private func animateFromItemNodeToReaction(itemNode: ReactionNode, targetView: UIView, hideNode: Bool, completion: @escaping () -> Void) { guard let targetSnapshotView = targetView.snapshotContentTree(unhide: true) else { completion() return } let sourceFrame = itemNode.view.convert(itemNode.bounds, to: self.view) var selfTargetBounds = targetView.bounds if let itemNode = self.itemNode, case .builtin = itemNode.item.reaction.rawValue { selfTargetBounds = selfTargetBounds.insetBy(dx: -selfTargetBounds.width * 0.5, dy: -selfTargetBounds.height * 0.5) } var targetFrame = self.view.convert(targetView.convert(selfTargetBounds, to: nil), from: nil) if let itemNode = self.itemNode, case .builtin = itemNode.item.reaction.rawValue { targetFrame = targetFrame.insetBy(dx: -targetFrame.width * 0.5, dy: -targetFrame.height * 0.5) } targetSnapshotView.frame = targetFrame self.view.insertSubview(targetSnapshotView, belowSubview: itemNode.view) var completedTarget = false var targetScaleCompleted = false let intermediateCompletion: () -> Void = { if completedTarget && targetScaleCompleted { completion() } } let targetPosition = targetFrame.center let duration: Double = 0.16 itemNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: duration * 0.9, removeOnCompletion: false) itemNode.layer.animatePosition(from: itemNode.layer.position, to: targetPosition, duration: duration, removeOnCompletion: false) targetSnapshotView.alpha = 1.0 targetSnapshotView.layer.animateAlpha(from: 0.0, to: 1.0, duration: duration * 0.8) targetSnapshotView.layer.animatePosition(from: sourceFrame.center, to: targetPosition, duration: duration, removeOnCompletion: false) targetSnapshotView.layer.animateScale(from: itemNode.bounds.width / targetSnapshotView.bounds.width, to: 1.0, duration: duration, removeOnCompletion: false, completion: { [weak targetSnapshotView] _ in completedTarget = true intermediateCompletion() targetSnapshotView?.isHidden = true if hideNode { targetView.alpha = 1.0 targetView.isHidden = false if let targetView = targetView as? ReactionIconView { targetView.updateIsAnimationHidden(isAnimationHidden: false, transition: .immediate) } targetSnapshotView?.isHidden = true targetScaleCompleted = true intermediateCompletion() } else { targetScaleCompleted = true intermediateCompletion() } }) itemNode.layer.animateScale(from: 1.0, to: (targetSnapshotView.bounds.width * 1.0) / itemNode.bounds.width, duration: duration, removeOnCompletion: false) } public func addRelativeContentOffset(_ offset: CGPoint, transition: ContainedViewLayoutTransition) { self.bounds = self.bounds.offsetBy(dx: 0.0, dy: offset.y) transition.animateOffsetAdditive(node: self, offset: -offset.y) } public func cancel() { self.isCancelled = true if let targetView = self.targetView { if let targetView = targetView as? ReactionIconView, self.itemNodeIsEmbedded { targetView.updateIsAnimationHidden(isAnimationHidden: false, transition: .immediate) } else { targetView.alpha = 1.0 targetView.isHidden = false } } if self.itemNodeIsEmbedded { self.itemNode?.removeFromSupernode() } } } public final class StandaloneDismissReactionAnimation: ASDisplayNode { private let hapticFeedback = HapticFeedback() override public init() { super.init() self.isUserInteractionEnabled = false } public func animateReactionDismiss(sourceView: UIView, hideNode: Bool, isIncoming: Bool, completion: @escaping () -> Void) { guard let sourceSnapshotView = sourceView.snapshotContentTree() else { completion() return } if hideNode { sourceView.isHidden = true } let sourceRect = self.view.convert(sourceView.bounds, from: sourceView) sourceSnapshotView.frame = sourceRect self.view.addSubview(sourceSnapshotView) var targetOffset: CGFloat = 120.0 if !isIncoming { targetOffset = -targetOffset } let targetPoint = CGPoint(x: sourceRect.midX + targetOffset, y: sourceRect.midY) let hapticFeedback = self.hapticFeedback hapticFeedback.prepareImpact(.soft) let keyframes = generateParabollicMotionKeyframes(from: sourceRect.center, to: targetPoint, elevation: 25.0) let transition: ContainedViewLayoutTransition = .animated(duration: 0.18, curve: .easeInOut) sourceSnapshotView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.04, delay: 0.18 - 0.04, timingFunction: CAMediaTimingFunctionName.linear.rawValue, removeOnCompletion: false, completion: { [weak sourceSnapshotView, weak hapticFeedback] _ in sourceSnapshotView?.removeFromSuperview() hapticFeedback?.impact(.soft) completion() }) transition.animatePositionWithKeyframes(layer: sourceSnapshotView.layer, keyframes: keyframes, removeOnCompletion: false) } public func addRelativeContentOffset(_ offset: CGPoint, transition: ContainedViewLayoutTransition) { self.bounds = self.bounds.offsetBy(dx: 0.0, dy: offset.y) transition.animateOffsetAdditive(node: self, offset: -offset.y) } } private func generateParabollicMotionKeyframes(from sourcePoint: CGPoint, to targetPosition: CGPoint, elevation: CGFloat) -> [CGPoint] { let midPoint = CGPoint(x: (sourcePoint.x + targetPosition.x) / 2.0, y: sourcePoint.y - elevation) let x1 = sourcePoint.x let y1 = sourcePoint.y let x2 = midPoint.x let y2 = midPoint.y let x3 = targetPosition.x let y3 = targetPosition.y var keyframes: [CGPoint] = [] if abs(y1 - y3) < 5.0 && abs(x1 - x3) < 5.0 { for i in 0 ..< 10 { let k = CGFloat(i) / CGFloat(10 - 1) let x = sourcePoint.x * (1.0 - k) + targetPosition.x * k let y = sourcePoint.y * (1.0 - k) + targetPosition.y * k keyframes.append(CGPoint(x: x, y: y)) } } else { let a = (x3 * (y2 - y1) + x2 * (y1 - y3) + x1 * (y3 - y2)) / ((x1 - x2) * (x1 - x3) * (x2 - x3)) let b = (x1 * x1 * (y2 - y3) + x3 * x3 * (y1 - y2) + x2 * x2 * (y3 - y1)) / ((x1 - x2) * (x1 - x3) * (x2 - x3)) let c = (x2 * x2 * (x3 * y1 - x1 * y3) + x2 * (x1 * x1 * y3 - x3 * x3 * y1) + x1 * x3 * (x3 - x1) * y2) / ((x1 - x2) * (x1 - x3) * (x2 - x3)) for i in 0 ..< 10 { let k = CGFloat(i) / CGFloat(10 - 1) let x = sourcePoint.x * (1.0 - k) + targetPosition.x * k let y = a * x * x + b * x + c keyframes.append(CGPoint(x: x, y: y)) } } return keyframes }