mirror of
https://github.com/Swiftgram/Telegram-iOS.git
synced 2025-06-16 05:55:20 +00:00
562 lines
21 KiB
Swift
562 lines
21 KiB
Swift
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<DemoPageEnvironment>, transition: Transition) -> CGSize {
|
|
let isDisplaying = environment[DemoPageEnvironment.self].isDisplaying
|
|
|
|
if self.node == nil {
|
|
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<DemoPageEnvironment>, 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<Int>()
|
|
|
|
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 sortedReactions: [AvailableReactions.Reaction] = []
|
|
for emoji in order {
|
|
if let reaction = reactionMap[emoji] {
|
|
sortedReactions.append(reaction)
|
|
}
|
|
}
|
|
|
|
for reaction in reactions {
|
|
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()
|
|
|
|
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: false, 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: 1.0, repeat: true, completion: { [weak self] in
|
|
if let strongSelf = self {
|
|
let currentTimestamp = CACurrentMediaTime()
|
|
if currentTimestamp > strongSelf.previousInteractionTimestamp + 4.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)?
|
|
func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
|
|
if self.scrollStartPosition == nil {
|
|
self.scrollStartPosition = (scrollView.contentOffset.x, self.currentPosition)
|
|
}
|
|
}
|
|
|
|
private let hapticFeedback = HapticFeedback()
|
|
func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
|
if scrollView.isTracking {
|
|
self.previousInteractionTimestamp = CACurrentMediaTime()
|
|
}
|
|
|
|
if let animator = self.animator {
|
|
animator.invalidate()
|
|
self.animator = nil
|
|
}
|
|
|
|
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, 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.updateLayout(size: size, transition: .immediate)
|
|
}
|
|
}
|
|
|
|
func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>) {
|
|
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()
|
|
|
|
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()
|
|
|
|
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 relativeAngle = calculateRelativeAngle(angle)
|
|
let distance = abs(relativeAngle) / CGFloat.pi
|
|
|
|
var updatedAngle = angle
|
|
updatedAngle += 10 * cos(updatedAngle)
|
|
|
|
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)
|
|
}
|
|
}
|
|
}
|