import Foundation import UIKit import Display import SwiftSignalKit import AsyncDisplayKit import ComponentFlow import TelegramCore import AccountContext import ReactionSelectionNode import TelegramPresentationData import AccountContext final class ReactionsCarouselComponent: Component { public typealias EnvironmentType = DemoPageEnvironment let context: AccountContext let theme: PresentationTheme let reactions: [AvailableReactions.Reaction] public init( context: AccountContext, theme: PresentationTheme, reactions: [AvailableReactions.Reaction] ) { self.context = context self.theme = theme self.reactions = reactions } public static func ==(lhs: ReactionsCarouselComponent, rhs: ReactionsCarouselComponent) -> Bool { if lhs.context !== rhs.context { return false } if lhs.theme !== rhs.theme { return false } if lhs.reactions != rhs.reactions { return false } return true } public final class View: UIView { private var component: ReactionsCarouselComponent? private var node: ReactionCarouselNode? private var isVisible = false public func update(component: ReactionsCarouselComponent, availableSize: CGSize, environment: Environment, transition: Transition) -> CGSize { let isDisplaying = environment[DemoPageEnvironment.self].isDisplaying if self.node == nil && !component.reactions.isEmpty { let node = ReactionCarouselNode( context: component.context, theme: component.theme, reactions: component.reactions ) self.node = node self.addSubnode(node) } self.component = component if let node = self.node { node.frame = CGRect(origin: CGPoint(x: 0.0, y: -20.0), size: availableSize) node.updateLayout(size: availableSize, transition: .immediate) } if isDisplaying && !self.isVisible { var fast = false if let _ = transition.userData(DemoAnimateInTransition.self) { fast = true } self.node?.setVisible(true, fast: fast) } else if !isDisplaying && self.isVisible { self.node?.setVisible(false) } self.isVisible = isDisplaying return availableSize } } public func makeView() -> View { return View() } public func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { return view.update(component: self, availableSize: availableSize, environment: environment, transition: transition) } } private let itemSize = CGSize(width: 110.0, height: 110.0) private let order = ["😍","👌","🥴","🐳","🥱","🕊","🤡"] private class ReactionCarouselNode: ASDisplayNode, UIScrollViewDelegate { private let context: AccountContext private let theme: PresentationTheme private let reactions: [AvailableReactions.Reaction] private var itemContainerNodes: [ASDisplayNode] = [] private var itemNodes: [ReactionNode] = [] private let scrollNode: ASScrollNode private let tapNode: ASDisplayNode private var standaloneReactionAnimation: StandaloneReactionAnimation? private var animator: DisplayLinkAnimator? private var currentPosition: CGFloat = 0.0 private var currentIndex: Int = 0 private var validLayout: CGSize? private var playingIndices = Set() private let positionDelta: Double private var previousInteractionTimestamp: Double = 0.0 private var timer: SwiftSignalKit.Timer? init(context: AccountContext, theme: PresentationTheme, reactions: [AvailableReactions.Reaction]) { self.context = context self.theme = theme var reactionMap: [String: AvailableReactions.Reaction] = [:] for reaction in reactions { reactionMap[reaction.value] = reaction } var addedReactions = Set() var sortedReactions: [AvailableReactions.Reaction] = [] for emoji in order { if let reaction = reactionMap[emoji] { sortedReactions.append(reaction) addedReactions.insert(emoji) } } for reaction in reactions { if !addedReactions.contains(reaction.value) { sortedReactions.append(reaction) } } self.reactions = sortedReactions self.scrollNode = ASScrollNode() self.tapNode = ASDisplayNode() self.positionDelta = 1.0 / CGFloat(self.reactions.count) super.init() self.addSubnode(self.scrollNode) self.scrollNode.addSubnode(self.tapNode) self.setup() } deinit { self.timer?.invalidate() } override func didLoad() { super.didLoad() self.scrollNode.view.delegate = self self.scrollNode.view.showsHorizontalScrollIndicator = false self.scrollNode.view.canCancelContentTouches = true self.tapNode.view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.reactionTapped(_:)))) } @objc private func reactionTapped(_ gestureRecognizer: UITapGestureRecognizer) { self.previousInteractionTimestamp = CACurrentMediaTime() + 1.0 if let animator = self.animator { animator.invalidate() self.animator = nil } guard self.scrollStartPosition == nil else { return } let point = gestureRecognizer.location(in: self.view) guard let index = self.itemContainerNodes.firstIndex(where: { $0.frame.contains(point) }) else { return } self.scrollTo(index, playReaction: true, immediately: true, duration: 0.85) self.hapticFeedback.impact(.light) } func setVisible(_ visible: Bool, fast: Bool = false) { if visible { self.animateIn(fast: fast) } else { self.animator?.invalidate() self.animator = nil self.scrollTo(0, playReaction: false, immediately: false, duration: 0.0, clockwise: false) self.timer?.invalidate() self.timer = nil self.playingIndices.removeAll() self.standaloneReactionAnimation?.removeFromSupernode() } } func animateIn(fast: Bool) { let duration: Double = fast ? 1.4 : 2.2 let delay: Double = fast ? 0.5 : 0.8 self.scrollTo(1, playReaction: false, immediately: true, duration: duration, damping: 0.75, clockwise: true) Queue.mainQueue().after(delay, { self.playReaction(index: 1) }) if self.timer == nil { self.previousInteractionTimestamp = CACurrentMediaTime() self.timer = SwiftSignalKit.Timer(timeout: 0.2, repeat: true, completion: { [weak self] in if let strongSelf = self { let currentTimestamp = CACurrentMediaTime() if currentTimestamp > strongSelf.previousInteractionTimestamp + 2.0 { var nextIndex = strongSelf.currentIndex - 1 if nextIndex < 0 { nextIndex = strongSelf.reactions.count + nextIndex } strongSelf.scrollTo(nextIndex, playReaction: true, immediately: true, duration: 0.85, clockwise: true) strongSelf.previousInteractionTimestamp = currentTimestamp } } }, queue: Queue.mainQueue()) self.timer?.start() } } func animateOut() { if let standaloneReactionAnimation = self.standaloneReactionAnimation { standaloneReactionAnimation.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false) } } func springCurveFunc(_ t: Double, zeta: Double) -> Double { let v0 = 0.0 let omega = 20.285 let y: Double if abs(zeta - 1.0) < 1e-8 { let c1 = -1.0 let c2 = v0 - omega y = (c1 + c2 * t) * exp(-omega * t) } else if zeta > 1 { let s1 = omega * (-zeta + sqrt(zeta * zeta - 1)) let s2 = omega * (-zeta - sqrt(zeta * zeta - 1)) let c1 = (-s2 - v0) / (s2 - s1) let c2 = (s1 + v0) / (s2 - s1) y = c1 * exp(s1 * t) + c2 * exp(s2 * t) } else { let a = -omega * zeta let b = omega * sqrt(1 - zeta * zeta) let c2 = (v0 + a) / b let theta = atan(c2) // Alternatively y = (-cos(b * t) + c2 * sin(b * t)) * exp(a * t) y = sqrt(1 + c2 * c2) * exp(a * t) * cos(b * t + theta + Double.pi) } return y + 1 } func scrollTo(_ index: Int, playReaction: Bool, immediately: Bool, duration: Double, damping: Double = 0.6, clockwise: Bool? = nil) { guard index >= 0 && index < self.itemNodes.count else { return } self.currentIndex = index let delta = self.positionDelta let startPosition = self.currentPosition let newPosition = delta * CGFloat(index) var change = newPosition - startPosition if let clockwise = clockwise { if clockwise { if change > 0.0 { change = change - 1.0 } } else { if change < 0.0 { change = 1.0 + change } } } else { if change > 0.5 { change = change - 1.0 } else if change < -0.5 { change = 1.0 + change } } if immediately { self.playReaction(index: index) } if duration.isZero { self.currentPosition = newPosition if let size = self.validLayout { self.updateLayout(size: size, transition: .immediate) } } else { self.animator = DisplayLinkAnimator(duration: duration * UIView.animationDurationFactor(), from: 0.0, to: 1.0, update: { [weak self] t in var t = t if duration <= 0.2 { t = listViewAnimationCurveSystem(t) } else { t = self?.springCurveFunc(t, zeta: damping) ?? 0.0 } var updatedPosition = startPosition + change * t while updatedPosition >= 1.0 { updatedPosition -= 1.0 } while updatedPosition < 0.0 { updatedPosition += 1.0 } self?.currentPosition = updatedPosition if let size = self?.validLayout { self?.updateLayout(size: size, transition: .immediate) } }, completion: { [weak self] in self?.animator = nil if playReaction && !immediately { self?.playReaction(index: nil) } }) } } func setup() { for reaction in self.reactions { guard let centerAnimation = reaction.centerAnimation else { continue } guard let aroundAnimation = reaction.aroundAnimation else { continue } let containerNode = ASDisplayNode() let itemNode = ReactionNode(context: self.context, theme: self.theme, item: ReactionItem( reaction: ReactionItem.Reaction(rawValue: reaction.value), appearAnimation: reaction.appearAnimation, stillAnimation: reaction.selectAnimation, listAnimation: centerAnimation, largeListAnimation: reaction.activateAnimation, applicationAnimation: aroundAnimation, largeApplicationAnimation: reaction.effectAnimation ), hasAppearAnimation: false) containerNode.isUserInteractionEnabled = false containerNode.addSubnode(itemNode) self.addSubnode(containerNode) self.itemContainerNodes.append(containerNode) self.itemNodes.append(itemNode) } } private var ignoreContentOffsetChange = false private func resetScrollPosition() { self.scrollStartPosition = nil self.ignoreContentOffsetChange = true self.scrollNode.view.contentOffset = CGPoint(x: 5000.0 - self.scrollNode.frame.width * 0.5, y: 0.0) self.ignoreContentOffsetChange = false } func playReaction(index: Int?) { let index = index ?? max(0, Int(round(self.currentPosition / self.positionDelta)) % self.itemNodes.count) guard !self.playingIndices.contains(index) else { return } if let current = self.standaloneReactionAnimation, let dismiss = current.currentDismissAnimation { dismiss() current.currentDismissAnimation = nil self.playingIndices.removeAll() } let reaction = self.reactions[index] let targetContainerNode = self.itemContainerNodes[index] let targetView = self.itemNodes[index].view guard let centerAnimation = reaction.centerAnimation else { return } guard let aroundAnimation = reaction.aroundAnimation else { return } self.playingIndices.insert(index) targetContainerNode.view.superview?.bringSubviewToFront(targetContainerNode.view) let standaloneReactionAnimation = StandaloneReactionAnimation() self.standaloneReactionAnimation = standaloneReactionAnimation targetContainerNode.addSubnode(standaloneReactionAnimation) standaloneReactionAnimation.frame = targetContainerNode.bounds standaloneReactionAnimation.animateReactionSelection( context: self.context, theme: self.theme, reaction: ReactionItem( reaction: ReactionItem.Reaction(rawValue: reaction.value), appearAnimation: reaction.appearAnimation, stillAnimation: reaction.selectAnimation, listAnimation: centerAnimation, largeListAnimation: reaction.activateAnimation, applicationAnimation: aroundAnimation, largeApplicationAnimation: reaction.effectAnimation ), avatarPeers: [], playHaptic: false, isLarge: true, forceSmallEffectAnimation: true, targetView: targetView, addStandaloneReactionAnimation: nil, currentItemNode: self.itemNodes[index], completion: { [weak standaloneReactionAnimation, weak self] in standaloneReactionAnimation?.removeFromSupernode() if self?.standaloneReactionAnimation === standaloneReactionAnimation { self?.standaloneReactionAnimation = nil self?.playingIndices.remove(index) } } ) } private var scrollStartPosition: (contentOffset: CGFloat, position: CGFloat, inverse: Bool)? func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { var inverse = false let tapLocation = scrollView.panGestureRecognizer.location(in: scrollView) if tapLocation.y < scrollView.frame.height / 2.0 { inverse = true } if let scrollStartPosition = self.scrollStartPosition { self.scrollStartPosition = (scrollStartPosition.contentOffset, scrollStartPosition.position, inverse) } else { self.scrollStartPosition = (scrollView.contentOffset.x, self.currentPosition, inverse) } } private let hapticFeedback = HapticFeedback() func scrollViewDidScroll(_ scrollView: UIScrollView) { if scrollView.isTracking { self.previousInteractionTimestamp = CACurrentMediaTime() + 1.0 } if let animator = self.animator { animator.invalidate() self.animator = nil } guard !self.ignoreContentOffsetChange, let (startContentOffset, startPosition, inverse) = self.scrollStartPosition else { return } let delta = scrollView.contentOffset.x - startContentOffset var positionDelta = delta * -0.001 if inverse { positionDelta *= -1.0 } var updatedPosition = startPosition + positionDelta while updatedPosition >= 1.0 { updatedPosition -= 1.0 } while updatedPosition < 0.0 { updatedPosition += 1.0 } self.currentPosition = updatedPosition let indexDelta = self.positionDelta let index = max(0, Int(round(self.currentPosition / indexDelta)) % self.itemNodes.count) if index != self.currentIndex { self.currentIndex = index if self.scrollNode.view.isTracking || self.scrollNode.view.isDecelerating { self.hapticFeedback.tap() } } if let size = self.validLayout { self.ignoreContentOffsetChange = true self.updateLayout(size: size, transition: .immediate) self.ignoreContentOffsetChange = false } } func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer) { guard let (startContentOffset, _, _) = self.scrollStartPosition, abs(velocity.x) > 0.0 else { return } let delta = self.positionDelta let scrollDelta = targetContentOffset.pointee.x - startContentOffset let positionDelta = scrollDelta * -0.001 let positionCounts = round(positionDelta / delta) let adjustedPositionDelta = delta * positionCounts let adjustedScrollDelta = adjustedPositionDelta * -1000.0 targetContentOffset.pointee = CGPoint(x: startContentOffset + adjustedScrollDelta, y: 0.0) } func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { if !decelerate { self.previousInteractionTimestamp = CACurrentMediaTime() + 1.0 self.resetScrollPosition() let delta = self.positionDelta let index = max(0, Int(round(self.currentPosition / delta)) % self.itemNodes.count) self.scrollTo(index, playReaction: true, immediately: true, duration: 0.2) } } func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { self.previousInteractionTimestamp = CACurrentMediaTime() + 1.0 self.resetScrollPosition() self.playReaction(index: nil) } func updateLayout(size: CGSize, transition: ContainedViewLayoutTransition) { self.validLayout = size self.scrollNode.frame = CGRect(origin: CGPoint(), size: size) if self.scrollNode.view.contentSize.width.isZero { self.scrollNode.view.contentSize = CGSize(width: 10000000.0, height: size.height) self.tapNode.frame = CGRect(origin: CGPoint(), size: self.scrollNode.view.contentSize) self.resetScrollPosition() } let delta = self.positionDelta let areaSize = CGSize(width: floor(size.width * 0.7), height: size.height * 0.44) for i in 0 ..< self.itemNodes.count { let itemNode = self.itemNodes[i] let containerNode = self.itemContainerNodes[i] var angle = CGFloat.pi * 0.5 + CGFloat(i) * delta * CGFloat.pi * 2.0 - self.currentPosition * CGFloat.pi * 2.0 if angle < 0.0 { angle = CGFloat.pi * 2.0 + angle } if angle > CGFloat.pi * 2.0 { angle = angle - CGFloat.pi * 2.0 } func calculateRelativeAngle(_ angle: CGFloat) -> CGFloat { var relativeAngle = angle - CGFloat.pi * 0.5 if relativeAngle > CGFloat.pi { relativeAngle = (2.0 * CGFloat.pi - relativeAngle) * -1.0 } return relativeAngle } let rotatedAngle = angle - CGFloat.pi / 2.0 var updatedAngle = rotatedAngle + 0.5 * sin(rotatedAngle) updatedAngle = updatedAngle + CGFloat.pi / 2.0 let relativeAngle = calculateRelativeAngle(updatedAngle) let distance = abs(relativeAngle) / CGFloat.pi let point = CGPoint( x: cos(updatedAngle), y: sin(updatedAngle) ) let itemFrame = CGRect(origin: CGPoint(x: size.width * 0.5 + point.x * areaSize.width * 0.5 - itemSize.width * 0.5, y: size.height * 0.5 + point.y * areaSize.height * 0.5 - itemSize.height * 0.5), size: itemSize) containerNode.bounds = CGRect(origin: CGPoint(), size: itemFrame.size) containerNode.position = CGPoint(x: itemFrame.midX, y: itemFrame.midY) transition.updateTransformScale(node: containerNode, scale: 1.0 - distance * 0.8) itemNode.frame = CGRect(origin: CGPoint(), size: itemFrame.size) itemNode.updateLayout(size: itemFrame.size, isExpanded: false, largeExpanded: false, isPreviewing: false, transition: transition) } } }