mirror of
https://github.com/Swiftgram/Telegram-iOS.git
synced 2025-06-16 05:55:20 +00:00
1386 lines
65 KiB
Swift
1386 lines
65 KiB
Swift
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<Int>()
|
|
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 = AnimatedStickerNode()
|
|
|
|
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 = AnimatedStickerNode()
|
|
|
|
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
|
|
}
|
|
|
|
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)
|
|
|
|
if !isLarge, !avatarPeers.isEmpty, let url = getAppBundle().url(forResource: "effectavatar", withExtension: "json"), let composition = LOTComposition(filePath: url.path) {
|
|
let view = LOTAnimationView(model: composition, in: getAppBundle())
|
|
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)
|
|
|
|
view.addSubview(avatarContainer, toKeypathLayer: LOTKeypath(string: "Avatar \(i).Ellipse 1"))
|
|
}
|
|
|
|
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
|
|
}
|