2022-01-09 22:58:38 +04:00

1026 lines
47 KiB
Swift

import Foundation
import AsyncDisplayKit
import Display
import AnimatedStickerNode
import TelegramCore
import TelegramPresentationData
import AccountContext
import TelegramAnimatedStickerNode
import ReactionButtonListComponent
public final class ReactionContextItem {
public struct Reaction: Equatable {
public var rawValue: String
public init(rawValue: String) {
self.rawValue = rawValue
}
}
public let reaction: ReactionContextItem.Reaction
public let appearAnimation: TelegramMediaFile
public let stillAnimation: TelegramMediaFile
public let listAnimation: TelegramMediaFile
public let applicationAnimation: TelegramMediaFile
public init(
reaction: ReactionContextItem.Reaction,
appearAnimation: TelegramMediaFile,
stillAnimation: TelegramMediaFile,
listAnimation: TelegramMediaFile,
applicationAnimation: TelegramMediaFile
) {
self.reaction = reaction
self.appearAnimation = appearAnimation
self.stillAnimation = stillAnimation
self.listAnimation = listAnimation
self.applicationAnimation = applicationAnimation
}
}
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 scrollNode: ASScrollNode
private let previewingItemContainer: ASDisplayNode
private var visibleItemNodes: [Int: ReactionNode] = [:]
private weak var currentLongPressItemNode: ReactionNode?
private var isExpanded: Bool = true
private var highlightedReaction: ReactionContextItem.Reaction?
private var continuousHaptic: Any?
private var validLayout: (CGSize, UIEdgeInsets, CGRect)?
private var isLeftAligned: Bool = true
public var reactionSelected: ((ReactionContextItem) -> 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.backgroundNode = ReactionContextBackgroundNode(largeCircleSize: largeCircleSize, smallCircleSize: smallCircleSize)
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()
let maskGradientWidth: CGFloat = 10.0
self.contentContainerMask.image = generateImage(CGSize(width: maskGradientWidth * 2.0 + 1.0, height: 8.0), rotatedContext: { size, context in
context.clear(CGRect(origin: CGPoint(), size: size))
let shadowColor = UIColor.black
let stepCount = 10
var colors: [CGColor] = []
var locations: [CGFloat] = []
for i in 0 ... stepCount {
let t = CGFloat(i) / CGFloat(stepCount)
colors.append(shadowColor.withAlphaComponent(t * t).cgColor)
locations.append(t)
}
let gradient = CGGradient(colorsSpace: deviceColorSpace, colors: colors as CFArray, locations: &locations)!
context.drawLinearGradient(gradient, start: CGPoint(), end: CGPoint(x: maskGradientWidth, y: 0.0), options: CGGradientDrawingOptions())
context.drawLinearGradient(gradient, start: CGPoint(x: size.width, y: 0.0), end: CGPoint(x: maskGradientWidth + 1.0, y: 0.0), options: CGGradientDrawingOptions())
context.setFillColor(shadowColor.cgColor)
context.fill(CGRect(origin: CGPoint(x: maskGradientWidth, y: 0.0), size: CGSize(width: 1.0, height: size.height)))
})?.stretchableImage(withLeftCapWidth: Int(maskGradientWidth), topCapHeight: 0)
self.contentContainer.view.mask = 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 longPressGesture = UILongPressGestureRecognizer(target: self, action: #selector(self.longPressGesture(_:)))
longPressGesture.minimumPressDuration = 0.2
self.view.addGestureRecognizer(longPressGesture)
}
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)
}
var 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 verticalInset: CGFloat = 13.0
let rowHeight: CGFloat = 30.0
let visibleBounds = self.scrollNode.view.bounds
let appearBounds = self.scrollNode.view.bounds.insetBy(dx: 16.0, dy: 0.0)
self.previewingItemContainer.bounds = visibleBounds
let highlightedReactionIndex = self.items.firstIndex(where: { $0.reaction == self.highlightedReaction })
var validIndices = Set<Int>()
for i in 0 ..< self.items.count {
let columnIndex = i
let column = CGFloat(columnIndex)
let itemOffsetY: CGFloat = -1.0
var baseItemFrame = CGRect(origin: CGPoint(x: sideInset + column * (itemSize + itemSpacing), y: verticalInset + floor((rowHeight - itemSize) / 2.0) + itemOffsetY), size: CGSize(width: itemSize, height: itemSize))
if let highlightedReactionIndex = highlightedReactionIndex {
if i > highlightedReactionIndex {
baseItemFrame.origin.x += 8.0
} else if i == highlightedReactionIndex {
baseItemFrame.origin.x += 4.0
}
}
if appearBounds.intersects(baseItemFrame) || (self.visibleItemNodes[i] != nil && visibleBounds.intersects(baseItemFrame)) {
validIndices.insert(i)
var itemFrame = baseItemFrame
var isPreviewing = false
if self.highlightedReaction == self.items[i].reaction {
let updatedSize = CGSize(width: floor(itemFrame.width * 1.66), height: floor(itemFrame.height * 1.66))
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.9), height: floor(itemFrame.height * 0.9))
itemFrame = CGRect(origin: CGPoint(x: itemFrame.midX - updatedSize.width / 2.0, y: itemFrame.midY - updatedSize.height / 2.0), size: updatedSize)
}
var animateIn = false
let itemNode: ReactionNode
if let current = self.visibleItemNodes[i] {
itemNode = current
} else {
animateIn = self.didAnimateIn
itemNode = ReactionNode(context: self.context, theme: self.theme, item: self.items[i])
self.visibleItemNodes[i] = itemNode
self.scrollNode.addSubnode(itemNode)
}
if !itemNode.isExtracted {
if isPreviewing {
if itemNode.supernode !== self.previewingItemContainer {
self.previewingItemContainer.addSubnode(itemNode)
}
}
transition.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, isPreviewing: isPreviewing, transition: transition)
if animateIn {
itemNode.appear(animated: !self.context.sharedContext.currentPresentationData.with({ $0 }).reduceMotion)
}
}
}
}
var removedIndices: [Int] = []
for (index, itemNode) in self.visibleItemNodes {
if !validIndices.contains(index) {
removedIndices.append(index)
itemNode.removeFromSupernode()
}
}
for index in removedIndices {
self.visibleItemNodes.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,
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) {
if "".isEmpty {
if hideNode {
targetView.alpha = 1.0
targetView.isHidden = false
}
completion()
return
}
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.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
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 itemNode.item.reaction.rawValue != value {
continue
}
itemNode.isExtracted = true
}
}
public func animateOutToReaction(value: String, targetView: UIView, hideNode: Bool, animateTargetContainer: UIView?, completion: @escaping () -> Void) {
for (_, itemNode) in self.visibleItemNodes {
if itemNode.item.reaction.rawValue != value {
continue
}
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)
let expandedSize: CGSize = selfTargetRect.size
let expandedFrame = CGRect(origin: CGPoint(x: selfTargetRect.midX - expandedSize.width / 2.0, y: selfTargetRect.midY - expandedSize.height / 2.0), size: expandedSize)
let effectFrame = expandedFrame.insetBy(dx: -60.0, dy: -60.0)
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, isPreviewing: false, transition: transition)
let additionalAnimationNode = AnimatedStickerNode()
additionalAnimationNode.setup(source: AnimatedStickerResourceSource(account: itemNode.context.account, resource: itemNode.item.applicationAnimation.resource), width: Int(effectFrame.width * 2.0), height: Int(effectFrame.height * 2.0), playbackMode: .once, mode: .direct(cachePathPrefix: itemNode.context.account.postbox.mediaBox.shortLivedResourceCachePathPrefix(itemNode.item.applicationAnimation.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))
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: {
self.animateFromItemNodeToReaction(itemNode: itemNode, targetView: targetView, hideNode: hideNode, completion: {
mainAnimationCompleted = true
intermediateCompletion()
})
})
return
}
completion()
}
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
}
@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) {
self.highlightedReaction = itemNode.item.reaction
if #available(iOS 13.0, *) {
self.continuousHaptic = try? ContinuousHaptic(duration: 2.5)
}
//itemNode.updateIsLongPressing(isLongPressing: true)
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: 2.5, curve: .linear), animateInFromAnchorRect: nil, animateOutToAnchorRect: nil, animateReactionHighlight: true)
}
}
case .ended, .cancelled:
self.continuousHaptic = nil
if let itemNode = self.currentLongPressItemNode {
self.currentLongPressItemNode = nil
self.reactionSelected?(itemNode.item)
itemNode.updateIsLongPressing(isLongPressing: false)
}
self.highlightGestureFinished(performAction: 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)
}
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) {
if let highlightedReaction = self.highlightedReaction {
self.highlightedReaction = nil
if performAction {
self.performReactionSelection(reaction: highlightedReaction)
} 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) -> ReactionContextItem? {
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 {
return self.visibleItemNodes[closestItem.index]?.item
}
return nil
}
private func reactionItemNode(at point: CGPoint) -> ReactionNode? {
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? {
return self.reactionItemNode(at: point)?.item
}
public func performReactionSelection(reaction: ReactionContextItem.Reaction) {
for (_, itemNode) in self.visibleItemNodes {
if itemNode.item.reaction == reaction {
self.reactionSelected?(itemNode.item)
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: ReactionContextItem.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)
}
}
@objc private func disclosurePressed() {
self.isExpanded = true
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)
}
}
}
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 hideNode: Bool = false
override public init() {
super.init()
self.isUserInteractionEnabled = false
}
public func animateReactionSelection(context: AccountContext, theme: PresentationTheme, reaction: ReactionContextItem, targetView: UIView, hideNode: Bool, completion: @escaping () -> Void) {
self.animateReactionSelection(context: context, theme: theme, reaction: reaction, targetView: targetView, currentItemNode: nil, hideNode: hideNode, completion: completion)
}
func animateReactionSelection(context: AccountContext, theme: PresentationTheme, reaction: ReactionContextItem, targetView: UIView, currentItemNode: ReactionNode?, hideNode: Bool, completion: @escaping () -> Void) {
guard let sourceSnapshotView = targetView.snapshotContentTree() else {
completion()
return
}
self.targetView = targetView
self.hideNode = hideNode
let itemNode: ReactionNode
if let currentItemNode = currentItemNode {
itemNode = currentItemNode
} else {
itemNode = ReactionNode(context: context, theme: theme, item: reaction)
}
self.itemNode = itemNode
if let targetView = targetView as? ReactionIconView {
self.itemNodeIsEmbedded = true
targetView.addSubnode(itemNode)
targetView.imageView.isHidden = true
} else {
self.addSubnode(itemNode)
if hideNode {
targetView.isHidden = true
}
}
itemNode.isExtracted = true
let selfTargetRect = self.view.convert(targetView.bounds, from: targetView)
let expandedSize: CGSize = selfTargetRect.size
let expandedFrame = CGRect(origin: CGPoint(x: selfTargetRect.midX - expandedSize.width / 2.0, y: selfTargetRect.midY - expandedSize.height / 2.0), size: expandedSize)
let effectFrame = expandedFrame.insetBy(dx: -60.0, dy: -60.0)
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.4)
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.4)
if targetView.bounds.width < 25.0 {
itemNode.layer.animateScale(from: 0.01, to: 1.0, duration: 0.15)
itemNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.15)
}
}
itemNode.updateLayout(size: expandedFrame.size, isExpanded: true, isPreviewing: false, transition: .immediate)
let additionalAnimationNode = AnimatedStickerNode()
additionalAnimationNode.setup(source: AnimatedStickerResourceSource(account: itemNode.context.account, resource: itemNode.item.applicationAnimation.resource), width: Int(effectFrame.width * 2.0), height: Int(effectFrame.height * 2.0), playbackMode: .once, mode: .direct(cachePathPrefix: itemNode.context.account.postbox.mediaBox.shortLivedResourceCachePathPrefix(itemNode.item.applicationAnimation.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()
}
}
var didBeginDismissAnimation = false
let beginDismissAnimation: () -> Void = { [weak self] in
if !didBeginDismissAnimation {
didBeginDismissAnimation = true
guard let strongSelf = self else {
mainAnimationCompleted = true
intermediateCompletion()
return
}
if let targetView = strongSelf.targetView {
if let targetView = targetView as? ReactionIconView {
targetView.imageView.isHidden = false
} else {
if strongSelf.hideNode {
targetView.alpha = 1.0
targetView.isHidden = false
}
}
}
if strongSelf.itemNodeIsEmbedded {
strongSelf.itemNode?.removeFromSupernode()
}
mainAnimationCompleted = true
intermediateCompletion()
}
}
additionalAnimationNode.completed = { _ in
additionalAnimationCompleted = true
intermediateCompletion()
beginDismissAnimation()
}
additionalAnimationNode.visibility = true
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 2.0, execute: {
beginDismissAnimation()
})
}
/*private func animateFromItemNodeToReaction(itemNode: ReactionNode, targetView: UIView, hideNode: Bool, completion: @escaping () -> Void) {
if "".isEmpty {
if hideNode {
targetView.alpha = 1.0
targetView.isHidden = false
}
completion()
return
}
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.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
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, self.hideNode {
if let targetView = targetView as? ReactionIconView {
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, 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 sourceRect.midX > self.bounds.width / 2.0 {
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
}