import Foundation import UIKit import Display import AsyncDisplayKit import TelegramCore import AccountContext import ReactionSelectionNode import TelegramPresentationData import AccountContext private let itemSize = CGSize(width: 110.0, height: 110.0) final 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 init(context: AccountContext, theme: PresentationTheme, reactions: [AvailableReactions.Reaction]) { self.context = context self.theme = theme self.reactions = Array(reactions.shuffled().prefix(6)) 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() } 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) { guard self.animator == nil, 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, duration: 0.4) } func animateIn() { self.scrollTo(1, playReaction: true, duration: 0.5, clockwise: true) } func animateOut() { if let standaloneReactionAnimation = self.standaloneReactionAnimation { standaloneReactionAnimation.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false) } } func scrollTo(_ index: Int, playReaction: Bool, duration: Double, 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 } } self.animator = DisplayLinkAnimator(duration: duration * UIView.animationDurationFactor(), from: 0.0, to: 1.0, update: { [weak self] t in let t = listViewAnimationCurveSystem(t) 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 { self?.playReaction() } }) } 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() { let delta = self.positionDelta let index = max(0, min(self.itemNodes.count - 1, Int(round(self.currentPosition / delta)))) guard !self.playingIndices.contains(index) else { return } 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, completion: { [weak standaloneReactionAnimation, weak self] in standaloneReactionAnimation?.removeFromSupernode() self?.standaloneReactionAnimation = nil self?.playingIndices.remove(index) } ) } private var scrollStartPosition: (contentOffset: CGFloat, position: CGFloat)? func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { if self.scrollStartPosition == nil { self.scrollStartPosition = (scrollView.contentOffset.x, self.currentPosition) } } func scrollViewDidScroll(_ scrollView: UIScrollView) { guard !self.ignoreContentOffsetChange, let (startContentOffset, startPosition) = self.scrollStartPosition else { return } let delta = scrollView.contentOffset.x - startContentOffset let positionDelta = delta * -0.001 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, min(self.itemNodes.count - 1, Int(round(self.currentPosition / indexDelta)))) if index != self.currentIndex { self.currentIndex = index print(index) } if let size = self.validLayout { self.updateLayout(size: size, transition: .immediate) } } 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.resetScrollPosition() let delta = self.positionDelta let index = max(0, min(self.itemNodes.count - 1, Int(round(self.currentPosition / delta)))) self.scrollTo(index, playReaction: true, duration: 0.2) } } func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { self.resetScrollPosition() self.playReaction() } 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, 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.5) 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 relativeAngle = calculateRelativeAngle(angle) let distance = abs(relativeAngle) / CGFloat.pi let point = CGPoint( x: cos(angle), y: sin(angle) ) 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 = itemFrame.center transition.updateTransformScale(node: containerNode, scale: 1.0 - distance * 0.45) itemNode.frame = CGRect(origin: CGPoint(), size: itemFrame.size) itemNode.updateLayout(size: itemFrame.size, isExpanded: false, largeExpanded: false, isPreviewing: false, transition: transition) } } }