import Foundation import AsyncDisplayKit import Display import AnimatedStickerNode import TelegramCore import TelegramPresentationData import AccountContext import TelegramAnimatedStickerNode import ReactionButtonListComponent import SwiftSignalKit import Lottie import AppBundle import AvatarNode public final class ReactionItem { public struct Reaction: Equatable { public var rawValue: String public init(rawValue: String) { 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 init( reaction: ReactionItem.Reaction, appearAnimation: TelegramMediaFile, stillAnimation: TelegramMediaFile, listAnimation: TelegramMediaFile, largeListAnimation: TelegramMediaFile, applicationAnimation: TelegramMediaFile, largeApplicationAnimation: TelegramMediaFile ) { self.reaction = reaction self.appearAnimation = appearAnimation self.stillAnimation = stillAnimation self.listAnimation = listAnimation self.largeListAnimation = largeListAnimation self.applicationAnimation = applicationAnimation self.largeApplicationAnimation = largeApplicationAnimation } } public enum ReactionContextItem { case reaction(ReactionItem) case premium public var reaction: ReactionItem.Reaction? { if case let .reaction(item) = self { return item.reaction } else { return nil } } } private let largeCircleSize: CGFloat = 16.0 private let smallCircleSize: CGFloat = 8.0 public final class ReactionContextNode: ASDisplayNode, UIScrollViewDelegate { private let context: AccountContext private let theme: PresentationTheme private let items: [ReactionContextItem] private let backgroundNode: ReactionContextBackgroundNode private let contentContainer: ASDisplayNode private let contentContainerMask: UIImageView private let leftBackgroundMaskNode: ASDisplayNode private let rightBackgroundMaskNode: ASDisplayNode private let backgroundMaskNode: ASDisplayNode private let scrollNode: ASScrollNode private let previewingItemContainer: ASDisplayNode private var visibleItemNodes: [Int: ReactionItemNode] = [:] private var visibleItemMaskNodes: [Int: ASDisplayNode] = [:] private var longPressRecognizer: UILongPressGestureRecognizer? private var longPressTimer: SwiftSignalKit.Timer? private var highlightedReaction: ReactionItem.Reaction? private var didTriggerExpandedReaction: Bool = false private var continuousHaptic: Any? private var validLayout: (CGSize, UIEdgeInsets, CGRect)? private var isLeftAligned: Bool = true public var reactionSelected: ((ReactionContextItem, Bool) -> Void)? private var hapticFeedback: HapticFeedback? private var standaloneReactionAnimation: StandaloneReactionAnimation? private weak var animationTargetView: UIView? private var animationHideNode: Bool = false private var didAnimateIn: Bool = false public init(context: AccountContext, theme: PresentationTheme, items: [ReactionContextItem]) { self.context = context self.theme = theme self.items = items 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.scrollNode = ASScrollNode() 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.contentContainerMask = UIImageView() self.contentContainerMask.image = generateImage(CGSize(width: 52.0, height: 52.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(52.0 / 2.0), topCapHeight: Int(52.0 / 2.0)) self.contentContainer.view.mask = self.contentContainerMask //self.contentContainer.view.addSubview(self.contentContainerMask) super.init() self.addSubnode(self.backgroundNode) self.scrollNode.view.delegate = self self.addSubnode(self.contentContainer) self.addSubnode(self.previewingItemContainer) } 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) } public func updateLayout(size: CGSize, insets: UIEdgeInsets, anchorRect: CGRect, transition: ContainedViewLayoutTransition) { self.updateLayout(size: size, insets: insets, anchorRect: anchorRect, transition: transition, animateInFromAnchorRect: nil, animateOutToAnchorRect: nil) } public func updateIsIntersectingContent(isIntersectingContent: Bool, transition: ContainedViewLayoutTransition) { self.backgroundNode.updateIsIntersectingContent(isIntersectingContent: isIntersectingContent, transition: transition) } private func calculateBackgroundFrame(containerSize: CGSize, insets: UIEdgeInsets, anchorRect: CGRect, contentSize: CGSize) -> (backgroundFrame: CGRect, visualBackgroundFrame: CGRect, isLeftAligned: Bool, cloudSourcePoint: CGFloat) { var contentSize = contentSize contentSize.width = max(52.0, contentSize.width) contentSize.height = 52.0 let sideInset: CGFloat = 11.0 let backgroundOffset: CGPoint = CGPoint(x: 22.0, y: -7.0) var rect: CGRect let isLeftAligned: Bool 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 } let cloudSourcePoint: CGFloat if isLeftAligned { cloudSourcePoint = min(rect.maxX - rect.height / 2.0, anchorRect.maxX - 4.0) } else { cloudSourcePoint = max(rect.minX + rect.height / 2.0, anchorRect.minX) } let visualRect = rect /*if self.highlightedReaction != nil { visualRect.origin.x -= 4.0 visualRect.size.width += 8.0 }*/ return (rect, visualRect, isLeftAligned, cloudSourcePoint) } public func scrollViewDidScroll(_ scrollView: UIScrollView) { self.updateScrolling(transition: .immediate) } private func updateScrolling(transition: ContainedViewLayoutTransition) { let sideInset: CGFloat = 11.0 let itemSpacing: CGFloat = 9.0 let itemSize: CGFloat = 40.0 let containerHeight: CGFloat = 52.0 var contentHeight: CGFloat = containerHeight if self.highlightedReaction != nil { contentHeight = floor(contentHeight * 0.9) } //let highlightItemOffset: CGFloat = floor(itemSize * 0.8 / 2.0 * 0.5) let totalVisibleCount: CGFloat = CGFloat(self.items.count)//7.0 let totalVisibleWidth: CGFloat = totalVisibleCount * itemSize + (totalVisibleCount - 1.0) * itemSpacing //width = count * itemSize + (count - 1) * spacing //count * itemSize = width - (count - 1) * spacing //itemSize = (width - (count - 1) * spacing) / count let selectedItemSize = floor(itemSize * 1.5) let remainingVisibleWidth = totalVisibleWidth - selectedItemSize let remainingVisibleCount = totalVisibleCount - 1.0 let remainingItemSize = floor((remainingVisibleWidth - (remainingVisibleCount - 1.0) * itemSpacing) / remainingVisibleCount) let highlightItemSpacing: CGFloat = floor(itemSize * 0.2) _ = highlightItemSpacing //print("self.highlightedReaction = \(String(describing: self.highlightedReaction))") 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? 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 + itemSpacing /*if let highlightedReactionIndex = highlightedReactionIndex { let indexDistance = i - highlightedReactionIndex _ = indexDistance if i > highlightedReactionIndex { baseItemFrame.origin.x += highlightItemOffset// - highlightItemSpacing * CGFloat(indexDistance) } else if i == highlightedReactionIndex { //baseItemFrame.origin.x += highlightItemOffset * 0.5 } else { baseItemFrame.origin.x -= highlightItemOffset// - highlightItemSpacing * CGFloat(indexDistance) } }*/ if appearBounds.intersects(baseItemFrame) || (self.visibleItemNodes[i] != nil && visibleBounds.intersects(baseItemFrame)) { validIndices.insert(i) let itemFrame = baseItemFrame var isPreviewing = false if let highlightedReaction = self.highlightedReaction, highlightedReaction == self.items[i].reaction { //let updatedSize = CGSize(width: floor(itemFrame.width * 2.5), height: floor(itemFrame.height * 2.5)) //itemFrame = CGRect(origin: CGPoint(x: itemFrame.midX - updatedSize.width / 2.0, y: itemFrame.maxY + 4.0 - updatedSize.height), size: updatedSize) isPreviewing = true } else if self.highlightedReaction != nil { //let updatedSize = CGSize(width: floor(itemFrame.width * 0.8), height: floor(itemFrame.height * 0.8)) //itemFrame = CGRect(origin: CGPoint(x: itemFrame.midX - updatedSize.width / 2.0, y: itemFrame.midY - updatedSize.height / 2.0), size: updatedSize) } 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 if case let .reaction(item) = self.items[i] { itemNode = ReactionNode(context: self.context, theme: self.theme, item: item) maskNode = nil } else { itemNode = PremiumReactionsNode(theme: self.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 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 !itemNode.isExtracted { if isPreviewing { if itemNode.supernode !== self.previewingItemContainer { self.previewingItemContainer.addSubnode(itemNode) } } itemTransition.updateFrame(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 animateIn { itemNode.appear(animated: !self.context.sharedContext.currentPresentationData.with({ $0 }).reduceMotion) } } } } 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: 52.0)) transition.updateFrame(node: self.rightBackgroundMaskNode, frame: CGRect(x: currentMaskFrame.maxX, y: 0.0, width: 1000.0, height: 52.0)) } else { self.leftBackgroundMaskNode.frame = CGRect(x: 0.0, y: 0.0, width: 1000.0, height: 52.0) 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, transition: ContainedViewLayoutTransition, animateInFromAnchorRect: CGRect?, animateOutToAnchorRect: CGRect?, animateReactionHighlight: Bool = false) { self.validLayout = (size, insets, anchorRect) let sideInset: CGFloat = 11.0 let itemSpacing: CGFloat = 9.0 let itemSize: CGFloat = 40.0 let verticalInset: CGFloat = 13.0 let rowHeight: CGFloat = 30.0 let completeContentWidth = CGFloat(self.items.count) * itemSize + (CGFloat(self.items.count) - 1.0) * itemSpacing + sideInset * 2.0 let minVisibleItemCount: CGFloat = min(CGFloat(self.items.count), 6.5) var visibleContentWidth = floor(minVisibleItemCount * itemSize + (minVisibleItemCount - 1.0) * itemSpacing + sideInset * 2.0) if visibleContentWidth > size.width - sideInset * 2.0 { visibleContentWidth = size.width - sideInset * 2.0 } 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)) self.isLeftAligned = isLeftAligned transition.updateFrame(node: self.contentContainer, frame: visualBackgroundFrame, beginWithCurrentState: true) transition.updateFrame(view: self.contentContainerMask, frame: CGRect(origin: CGPoint(), size: visualBackgroundFrame.size), beginWithCurrentState: true) transition.updateFrame(node: self.scrollNode, frame: CGRect(origin: CGPoint(), size: actualBackgroundFrame.size), beginWithCurrentState: true) transition.updateFrame(node: self.previewingItemContainer, frame: visualBackgroundFrame, beginWithCurrentState: true) self.scrollNode.view.contentSize = CGSize(width: completeContentWidth, height: visualBackgroundFrame.size.height) self.updateScrolling(transition: transition) transition.updateFrame(node: self.backgroundNode, frame: visualBackgroundFrame, beginWithCurrentState: true) self.backgroundNode.update( theme: self.theme, size: visualBackgroundFrame.size, cloudSourcePoint: cloudSourcePoint - visualBackgroundFrame.minX, isLeftAligned: isLeftAligned, isMinimized: self.highlightedReaction != nil, transition: transition ) if let animateInFromAnchorRect = animateInFromAnchorRect { let springDuration: Double = 0.3 let springDamping: CGFloat = 104.0 let springDelay: Double = 0.05 let sourceBackgroundFrame = self.calculateBackgroundFrame(containerSize: size, insets: backgroundInsets, anchorRect: animateInFromAnchorRect, contentSize: CGSize(width: visualBackgroundFrame.height, height: contentHeight)).0 self.backgroundNode.animateInFromAnchorRect(size: visualBackgroundFrame.size, sourceBackgroundFrame: sourceBackgroundFrame.offsetBy(dx: -visualBackgroundFrame.minX, dy: -visualBackgroundFrame.minY)) self.contentContainer.layer.animateSpring(from: NSValue(cgPoint: CGPoint(x: sourceBackgroundFrame.midX - visualBackgroundFrame.midX, 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(), 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)).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) } } public func animateIn(from sourceAnchorRect: CGRect) { self.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.15) if let (size, insets, anchorRect) = self.validLayout { self.updateLayout(size: size, insets: insets, anchorRect: anchorRect, transition: .immediate, animateInFromAnchorRect: sourceAnchorRect, animateOutToAnchorRect: nil) } //let mainCircleDuration: Double = 0.5 let mainCircleDelay: Double = 0.01 self.backgroundNode.animateIn() self.didAnimateIn = true if !self.context.sharedContext.currentPresentationData.with({ $0 }).reduceMotion { for i in 0 ..< self.items.count { guard let itemNode = self.visibleItemNodes[i] else { continue } let itemDelay = mainCircleDelay + Double(i) * 0.06 DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + itemDelay, execute: { [weak itemNode] in itemNode?.appear(animated: true) }) } } 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.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 targetAnchorRect = targetAnchorRect, let (size, insets, anchorRect) = self.validLayout { self.updateLayout(size: size, insets: insets, anchorRect: anchorRect, 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 } let sourceFrame = itemNode.view.convert(itemNode.bounds, to: self.view) let targetFrame = self.view.convert(targetView.convert(targetView.bounds, to: nil), from: nil) 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.imageView.alpha = 1.0 } 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: String) { for (_, itemNode) in self.visibleItemNodes { if let itemNode = itemNode as? ReactionNode, itemNode.item.reaction.rawValue == value { itemNode.isExtracted = true } } } public func animateOutToReaction(value: String, targetView: UIView, hideNode: Bool, animateTargetContainer: UIView?, addStandaloneReactionAnimation: ((StandaloneReactionAnimation) -> Void)?, completion: @escaping () -> Void) { var foundItemNode: ReactionNode? for (_, itemNode) in self.visibleItemNodes { if let itemNode = itemNode as? ReactionNode, itemNode.item.reaction.rawValue == value { foundItemNode = itemNode break } } guard let itemNode = foundItemNode else { completion() return } 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, completion: { [weak targetView] completed in if completed { targetView?.isHidden = true } }) } } itemNode.isExtracted = true let selfSourceRect = itemNode.view.convert(itemNode.view.bounds, to: self.view) let selfTargetRect = self.view.convert(targetView.bounds, from: targetView) var expandedSize: CGSize = selfTargetRect.size if self.didTriggerExpandedReaction { 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 self.didTriggerExpandedReaction { 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) } 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() let additionalAnimation: TelegramMediaFile if self.didTriggerExpandedReaction { additionalAnimation = itemNode.item.largeApplicationAnimation if incomingMessage { additionalAnimationNode.transform = CATransform3DMakeScale(-1.0, 1.0, 1.0) } } else { additionalAnimation = itemNode.item.applicationAnimation } additionalAnimationNode.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: itemNode.context.account.postbox.mediaBox.shortLivedResourceCachePathPrefix(additionalAnimation.resource.id))) additionalAnimationNode.frame = effectFrame additionalAnimationNode.updateLayout(size: effectFrame.size) self.addSubnode(additionalAnimationNode) var mainAnimationCompleted = false var additionalAnimationCompleted = false let intermediateCompletion: () -> Void = { if mainAnimationCompleted && additionalAnimationCompleted { completion() } } additionalAnimationNode.completed = { _ in additionalAnimationCompleted = true intermediateCompletion() } 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 DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 0.1, execute: { guard let strongSelf = self else { return } if strongSelf.didTriggerExpandedReaction { return } guard let itemNode = itemNode else { return } guard let targetView = targetView as? ReactionIconView else { return } if let animateTargetContainer = animateTargetContainer { animateTargetContainer.isHidden = false } targetView.isHidden = false targetView.alpha = 1.0 targetView.imageView.alpha = 0.0 targetView.addSubnode(itemNode) itemNode.frame = targetView.bounds if strongSelf.hapticFeedback == nil { strongSelf.hapticFeedback = HapticFeedback() } strongSelf.hapticFeedback?.tap() }) }) 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) } }) 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() addStandaloneReactionAnimation(standaloneReactionAnimation) standaloneReactionAnimation.animateReactionSelection( context: strongSelf.context, theme: strongSelf.context.sharedContext.currentPresentationData.with({ $0 }).theme, reaction: itemNode.item, avatarPeers: [], playHaptic: false, isLarge: false, targetView: targetView, addStandaloneReactionAnimation: nil, completion: { [weak standaloneReactionAnimation] in standaloneReactionAnimation?.removeFromSupernode() } ) } mainAnimationCompleted = true intermediateCompletion() }) } else { if hideNode { targetView.alpha = 1.0 targetView.isHidden = false if let targetView = targetView as? ReactionIconView { targetView.imageView.alpha = 1.0 itemNode.removeFromSupernode() } } mainAnimationCompleted = true intermediateCompletion() } }) } override public func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { 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 { 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) = self.validLayout { self.updateLayout(size: size, insets: insets, anchorRect: anchorRect, 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) = self.validLayout { self.updateLayout(size: size, insets: insets, anchorRect: anchorRect, 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 let reaction = self.reaction(at: point) { self.reactionSelected?(reaction, false) } default: break } } public func highlightGestureMoved(location: CGPoint) { let highlightedReaction = self.previewReaction(at: location)?.reaction if self.highlightedReaction != highlightedReaction { self.highlightedReaction = highlightedReaction if self.hapticFeedback == nil { self.hapticFeedback = HapticFeedback() } self.hapticFeedback?.tap() if let (size, insets, anchorRect) = self.validLayout { self.updateLayout(size: size, insets: insets, anchorRect: anchorRect, transition: .animated(duration: 0.18, curve: .easeInOut), animateInFromAnchorRect: nil, animateOutToAnchorRect: nil, animateReactionHighlight: true) } } } public func highlightGestureFinished(performAction: Bool) { self.highlightGestureFinished(performAction: performAction, isLarge: false) } private func highlightGestureFinished(performAction: Bool, isLarge: Bool) { if let highlightedReaction = self.highlightedReaction { self.highlightedReaction = nil if performAction { self.performReactionSelection(reaction: highlightedReaction, isLarge: isLarge) } else { if let (size, insets, anchorRect) = self.validLayout { self.updateLayout(size: size, insets: insets, anchorRect: anchorRect, 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 { 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 } 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 { return .reaction(itemNode.item) } 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 { self.reactionSelected?(.reaction(itemNode.item), 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) = self.validLayout { self.updateLayout(size: size, insets: insets, anchorRect: anchorRect, transition: .animated(duration: 0.18, curve: .easeInOut), animateInFromAnchorRect: nil, animateOutToAnchorRect: nil, animateReactionHighlight: true) } } } public final class StandaloneReactionAnimation: ASDisplayNode { private var itemNode: ReactionNode? = nil private var itemNodeIsEmbedded: Bool = false private let hapticFeedback = HapticFeedback() private var isCancelled: Bool = false private weak var targetView: UIView? //private var colorCallbacks: [LOTColorValueCallback] = [] override public init() { super.init() self.isUserInteractionEnabled = false } public func animateReactionSelection(context: AccountContext, theme: PresentationTheme, reaction: ReactionItem, avatarPeers: [EnginePeer], playHaptic: Bool, isLarge: Bool, forceSmallEffectAnimation: Bool = false, targetView: UIView, addStandaloneReactionAnimation: ((StandaloneReactionAnimation) -> Void)?, completion: @escaping () -> Void) { self.animateReactionSelection(context: context, theme: theme, reaction: reaction, avatarPeers: avatarPeers, playHaptic: playHaptic, isLarge: isLarge, forceSmallEffectAnimation: forceSmallEffectAnimation, targetView: targetView, addStandaloneReactionAnimation: addStandaloneReactionAnimation, currentItemNode: nil, completion: completion) } public var currentDismissAnimation: (() -> Void)? public func animateReactionSelection(context: AccountContext, theme: PresentationTheme, reaction: ReactionItem, avatarPeers: [EnginePeer], playHaptic: Bool, isLarge: Bool, forceSmallEffectAnimation: 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 let currentItemNode = currentItemNode { itemNode = currentItemNode } else { itemNode = ReactionNode(context: context, theme: theme, item: reaction) } self.itemNode = itemNode if !forceSmallEffectAnimation { if let targetView = targetView as? ReactionIconView, !isLarge { self.itemNodeIsEmbedded = true targetView.addSubnode(itemNode) } else { self.addSubnode(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.imageView.isHidden = true } else { targetView.isHidden = true } } itemNode.isExtracted = true let selfTargetRect = self.view.convert(targetView.bounds, 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 self.itemNodeIsEmbedded { itemNode.frame = targetView.bounds } 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) let additionalAnimationNode = DefaultAnimatedStickerNodeImpl() let additionalAnimation: TelegramMediaFile if isLarge && !forceSmallEffectAnimation { additionalAnimation = itemNode.item.largeApplicationAnimation if incomingMessage { additionalAnimationNode.transform = CATransform3DMakeScale(-1.0, 1.0, 1.0) } } else { additionalAnimation = itemNode.item.applicationAnimation } var additionalCachePathPrefix: String? additionalCachePathPrefix = itemNode.context.account.postbox.mediaBox.shortLivedResourceCachePathPrefix(additionalAnimation.resource.id) //#if DEBUG additionalCachePathPrefix = nil //#endif additionalAnimationNode.setup(source: AnimatedStickerResourceSource(account: itemNode.context.account, resource: additionalAnimation.resource), width: Int(effectFrame.width * 1.33), height: Int(effectFrame.height * 1.33), playbackMode: .once, mode: .direct(cachePathPrefix: additionalCachePathPrefix)) additionalAnimationNode.frame = effectFrame additionalAnimationNode.updateLayout(size: effectFrame.size) self.addSubnode(additionalAnimationNode) if !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] in if !didBeginDismissAnimation { didBeginDismissAnimation = true guard let strongSelf = self else { mainAnimationCompleted = true intermediateCompletion() return } if forceSmallEffectAnimation { 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 { strongSelf.animateFromItemNodeToReaction(itemNode: itemNode, targetView: targetView, hideNode: true, completion: { if let addStandaloneReactionAnimation = addStandaloneReactionAnimation { let standaloneReactionAnimation = StandaloneReactionAnimation() addStandaloneReactionAnimation(standaloneReactionAnimation) standaloneReactionAnimation.animateReactionSelection( context: itemNode.context, theme: itemNode.context.sharedContext.currentPresentationData.with({ $0 }).theme, reaction: itemNode.item, 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.imageView.isHidden = false } 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 { itemNode.mainAnimationCompletion = { mainAnimationCompleted = true maybeBeginDismissAnimation() } } 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() } } additionalAnimationNode.visibility = 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) let targetFrame = self.view.convert(targetView.convert(targetView.bounds, to: nil), from: nil) 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.imageView.alpha = 1.0 } 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.imageView.isHidden = false } 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 }