mirror of
https://github.com/Swiftgram/Telegram-iOS.git
synced 2026-02-22 02:23:16 +00:00
778 lines
29 KiB
Swift
778 lines
29 KiB
Swift
import UIKit
|
|
import simd
|
|
import Display
|
|
|
|
final class Transform3DView: UIView {
|
|
override class var layerClass: AnyClass { CATransformLayer.self }
|
|
}
|
|
|
|
final class PassthroughView: UIView {
|
|
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
|
|
for subview in self.subviews where !subview.isHidden && subview.alpha > 0 && subview.isUserInteractionEnabled {
|
|
let converted = self.convert(point, to: subview)
|
|
if subview.point(inside: converted, with: event) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
}
|
|
|
|
final class CubeAnimationView: UIView {
|
|
private let cubeSize: CGFloat
|
|
private var perspective: CGFloat = 400.0
|
|
private let stickerSize: CGFloat
|
|
private let stickerGap: CGFloat
|
|
|
|
private let camera = UIView()
|
|
private let cubeContainer = Transform3DView()
|
|
private var faces: [UIView] = []
|
|
private var faceOccupants: [Int: UIView] = [:]
|
|
|
|
let stickerContainer = PassthroughView()
|
|
|
|
private var stickers: [UIView] = []
|
|
private var isRunning = false
|
|
|
|
private var displayLink: SharedDisplayLinkDriver.Link?
|
|
private var lastTimestamp: CFTimeInterval = 0
|
|
private var warpDisplayLink: SharedDisplayLinkDriver.Link?
|
|
private weak var warpView: UIView?
|
|
private var warpStartQuad: Quad?
|
|
private var warpEndQuad: Quad?
|
|
private var warpDuration: TimeInterval = 0
|
|
private var warpDynamicTarget: (() -> Quad)?
|
|
private var warpCompletion: (() -> Void)?
|
|
private var warpStartTimestamp: CFTimeInterval = 0
|
|
private var warpLastProgress: CGFloat = 0
|
|
private var warpCurrentQuad: Quad?
|
|
private var warpHasCompleted = false
|
|
private var warpSnapshot: UIView?
|
|
|
|
private var rotation = SIMD3<Float>(repeating: 0)
|
|
private var angularVelocity = SIMD3<Float>(repeating: 0)
|
|
|
|
private let dampingPerSecond: Float = 0.66
|
|
private let finishSpringX: Float = 28.0
|
|
private let finishSpringY: Float = 18.0
|
|
private let finishDampingX: Float = 2.0 * sqrt(28.0)
|
|
private let finishDampingY: Float = 2.0 * sqrt(18.0)
|
|
private let finishWobbleAmplitudeZ: Float = 10.0 * .pi / 180.0
|
|
private let finishWobbleCycles: Float = 1.0
|
|
private let finishWobbleDampingExponent: Float = 0.6
|
|
private let finishSuccessScale: Float = 1.3
|
|
private let finishSuccessScaleTriggerAngle: Float = 0.4 * .pi
|
|
private let finishApproachTriggerAngle: Float = 1.5 * .pi
|
|
private let baseImpulseStrength: Float = 4.0
|
|
private let impactNudgeDistance: CGFloat = 20.0
|
|
private let impactNudgeEmphasis: CGFloat = 28.0
|
|
|
|
private var isFinishingX = false
|
|
private var isFinishingY = false
|
|
private var finishTargetX: Float = 0.0
|
|
private var finishTargetY: Float = 0.0
|
|
private var finishDirectionY: Float = 1.0
|
|
private var finishRotationY: Float = 0.0
|
|
private var finishTargetYUnwrapped: Float = 0.0
|
|
private var finishRemainingYStart: Float = 0.0
|
|
private var finishDelayTimerX: Timer?
|
|
private var finishDelayTimerY: Timer?
|
|
private var cubeScale: Float = 1.0
|
|
private var hasFiredFinishApproach = false
|
|
|
|
var isSuccess = false
|
|
|
|
var onFinishApproach: ((Bool) -> Void)?
|
|
|
|
private let defaultStickOrder: [Int] = [0, 5, 4, 3]
|
|
private let sequenceStickOrders: [String: [Int]] = [
|
|
"0": [0],
|
|
"0,1": [0, 5],
|
|
"0,2": [0, 5],
|
|
"0,3": [0, 5],
|
|
"0,1,2": [0, 5, 4],
|
|
"0,1,3": [0, 5, 2],
|
|
"0,2,3": [0, 5, 1],
|
|
"0,1,2,3": [0, 5, 4, 3]
|
|
]
|
|
private var activeStickOrder: [Int] = []
|
|
|
|
init(cubeSize: CGFloat = 110.0, stickerSize: CGFloat = 76.0, stickerGap: CGFloat = 30.0) {
|
|
self.cubeSize = cubeSize
|
|
self.stickerSize = stickerSize
|
|
self.stickerGap = stickerGap
|
|
|
|
super.init(frame: .zero)
|
|
|
|
self.activeStickOrder = self.defaultStickOrder
|
|
|
|
self.camera.backgroundColor = .clear
|
|
self.camera.clipsToBounds = false
|
|
self.addSubview(self.camera)
|
|
|
|
self.cubeContainer.backgroundColor = .clear
|
|
self.cubeContainer.clipsToBounds = false
|
|
self.camera.addSubview(self.cubeContainer)
|
|
|
|
var p = CATransform3DIdentity
|
|
p.m34 = -1.0 / self.perspective
|
|
self.camera.layer.sublayerTransform = p
|
|
self.stickerContainer.layer.sublayerTransform = p
|
|
|
|
self.stickerContainer.backgroundColor = .clear
|
|
self.stickerContainer.clipsToBounds = false
|
|
self.addSubview(self.stickerContainer)
|
|
|
|
#if DEBUG
|
|
let pan = UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:)))
|
|
self.camera.addGestureRecognizer(pan)
|
|
#endif
|
|
}
|
|
|
|
required init?(coder: NSCoder) {
|
|
preconditionFailure()
|
|
}
|
|
|
|
override func layoutSubviews() {
|
|
super.layoutSubviews()
|
|
|
|
self.camera.bounds = CGRect(x: 0, y: 0, width: self.cubeSize, height: self.cubeSize)
|
|
self.camera.center = CGPoint(x: self.bounds.midX, y: self.bounds.midY)
|
|
|
|
self.cubeContainer.frame = self.camera.bounds
|
|
self.stickerContainer.frame = self.bounds
|
|
|
|
self.layoutStickers()
|
|
self.layoutFaces()
|
|
self.applyCubeRotation()
|
|
}
|
|
|
|
func setStickers(_ views: [UIView]) {
|
|
self.stickers = views
|
|
for view in views {
|
|
view.layer.anchorPoint = .zero
|
|
view.isUserInteractionEnabled = true
|
|
|
|
if view.superview !== self.stickerContainer {
|
|
self.stickerContainer.addSubview(view)
|
|
}
|
|
}
|
|
self.layoutStickers()
|
|
}
|
|
|
|
func setSticker(_ sticker: UIView?, face index: Int, mirror: Bool) {
|
|
guard self.faces.indices.contains(index) else {
|
|
return
|
|
}
|
|
|
|
if let existing = self.faceOccupants[index] {
|
|
existing.removeFromSuperview()
|
|
self.faceOccupants[index] = nil
|
|
}
|
|
|
|
guard let sticker else {
|
|
return
|
|
}
|
|
|
|
if let priorIndex = self.faceOccupants.first(where: { $0.value === sticker })?.key {
|
|
self.faceOccupants[priorIndex] = nil
|
|
}
|
|
sticker.removeFromSuperview()
|
|
|
|
let targetFace = self.faces[index]
|
|
targetFace.addSubview(sticker)
|
|
self.faceOccupants[index] = sticker
|
|
|
|
sticker.layer.removeAllAnimations()
|
|
sticker.transform = .identity
|
|
sticker.layer.transform = CATransform3DIdentity
|
|
sticker.layer.anchorPoint = CGPoint(x: 0.5, y: 0.5)
|
|
sticker.layer.isDoubleSided = false
|
|
sticker.clipsToBounds = false
|
|
sticker.isUserInteractionEnabled = false
|
|
|
|
let faceStickerSize = self.cubeSize
|
|
sticker.bounds = CGRect(x: 0, y: 0, width: faceStickerSize, height: faceStickerSize)
|
|
sticker.center = CGPoint(x: self.cubeSize / 2, y: self.cubeSize / 2)
|
|
|
|
var snappedAngle: CGFloat = 0.0
|
|
if mirror {
|
|
snappedAngle += .pi
|
|
}
|
|
sticker.transform = CGAffineTransform(rotationAngle: snappedAngle)
|
|
}
|
|
|
|
func startStickerSequence(indices: [Int]? = nil) {
|
|
guard !self.isRunning else {
|
|
return
|
|
}
|
|
guard self.stickers.contains(where: { $0.superview === self.stickerContainer }) else {
|
|
return
|
|
}
|
|
self.isRunning = true
|
|
|
|
let sequence: [Int]
|
|
if let indices, !indices.isEmpty {
|
|
var seen = Set<Int>()
|
|
var result: [Int] = []
|
|
for index in indices where self.stickers.indices.contains(index) {
|
|
if seen.insert(index).inserted {
|
|
result.append(index)
|
|
}
|
|
}
|
|
sequence = result
|
|
} else {
|
|
sequence = Array(self.stickers.indices)
|
|
}
|
|
|
|
var stickOrder: [Int]
|
|
let key = sequence.map(String.init).joined(separator: ",")
|
|
if let order = self.sequenceStickOrders[key] {
|
|
stickOrder = order
|
|
} else {
|
|
stickOrder = Array(self.defaultStickOrder.prefix(sequence.count))
|
|
}
|
|
self.activeStickOrder = stickOrder
|
|
|
|
self.scheduleStickerSequence(from: 0, indices: sequence)
|
|
}
|
|
|
|
func resetAll() {
|
|
self.isRunning = false
|
|
self.resetStickers()
|
|
self.resetCube()
|
|
self.activeStickOrder = self.defaultStickOrder
|
|
}
|
|
|
|
func setFaces(_ views: [UIView]) {
|
|
guard views.count == 6 else {
|
|
return
|
|
}
|
|
self.faces.forEach { $0.removeFromSuperview() }
|
|
self.faces = views
|
|
for face in views {
|
|
face.layer.isDoubleSided = false
|
|
self.cubeContainer.addSubview(face)
|
|
}
|
|
self.layoutFaces()
|
|
}
|
|
|
|
private func layoutFaces() {
|
|
guard self.faces.count == 6 else {
|
|
return
|
|
}
|
|
let half = self.cubeSize / 2
|
|
|
|
for face in self.faces {
|
|
face.bounds = CGRect(x: 0, y: 0, width: self.cubeSize, height: self.cubeSize)
|
|
face.center = CGPoint(x: self.cubeSize / 2, y: self.cubeSize / 2)
|
|
}
|
|
|
|
func faceTransform(rx: CGFloat, ry: CGFloat) -> CATransform3D {
|
|
var m = CATransform3DIdentity
|
|
m = CATransform3DRotate(m, rx, 1, 0, 0)
|
|
m = CATransform3DRotate(m, ry, 0, 1, 0)
|
|
m = CATransform3DTranslate(m, 0, 0, half)
|
|
return m
|
|
}
|
|
|
|
self.faces[0].layer.transform = faceTransform(rx: 0, ry: 0)
|
|
self.faces[1].layer.transform = faceTransform(rx: 0, ry: .pi / 2)
|
|
self.faces[2].layer.transform = faceTransform(rx: 0, ry: .pi)
|
|
self.faces[3].layer.transform = faceTransform(rx: 0, ry: -.pi / 2)
|
|
self.faces[4].layer.transform = faceTransform(rx: -.pi / 2, ry: 0)
|
|
self.faces[5].layer.transform = faceTransform(rx: .pi / 2, ry: 0)
|
|
}
|
|
|
|
private func animateWarp(for view: UIView, from startQuad: Quad, to targetQuad: Quad, duration: TimeInterval, dynamicTarget: (() -> Quad)? = nil, completion: @escaping () -> Void) {
|
|
self.cancelWarp()
|
|
self.warpView = view
|
|
self.warpStartQuad = startQuad
|
|
self.warpEndQuad = targetQuad
|
|
self.warpDuration = duration
|
|
self.warpDynamicTarget = dynamicTarget
|
|
self.warpCompletion = completion
|
|
self.warpStartTimestamp = 0
|
|
self.warpLastProgress = 0
|
|
self.warpHasCompleted = false
|
|
self.warpCurrentQuad = startQuad
|
|
startQuad.apply(to: view)
|
|
|
|
let link = SharedDisplayLinkDriver.shared.add(framesPerSecond: .max) { [weak self] _ in
|
|
self?.stepWarp()
|
|
}
|
|
link.isPaused = false
|
|
self.warpDisplayLink = link
|
|
}
|
|
|
|
private func stepWarp() {
|
|
guard let view = self.warpView, let currentQuad = self.warpCurrentQuad, let endQuad = self.warpEndQuad else {
|
|
self.finishWarp()
|
|
return
|
|
}
|
|
|
|
if self.warpStartTimestamp == 0 {
|
|
self.warpStartTimestamp = CACurrentMediaTime()
|
|
}
|
|
|
|
let elapsed = CACurrentMediaTime() - self.warpStartTimestamp
|
|
let progress = self.warpDuration > 0 ? min(1.0, elapsed / self.warpDuration) : 1.0
|
|
let t = CGFloat(progress)
|
|
let eased = t * t * (3 - 2 * t)
|
|
let target = self.warpDynamicTarget?() ?? endQuad
|
|
let delta = eased - self.warpLastProgress
|
|
let remaining = max(1 - self.warpLastProgress, 0.0001)
|
|
let weight = max(0, min(1, delta / remaining))
|
|
let nextQuad = currentQuad.interpolated(to: target, t: weight)
|
|
nextQuad.apply(to: view)
|
|
self.warpCurrentQuad = nextQuad
|
|
self.warpLastProgress = eased
|
|
|
|
if progress >= 1.0 {
|
|
self.finishWarp()
|
|
}
|
|
}
|
|
|
|
private func cancelWarp() {
|
|
self.warpHasCompleted = true
|
|
self.warpDisplayLink?.invalidate()
|
|
self.warpDisplayLink = nil
|
|
self.warpCompletion = nil
|
|
self.clearWarpState()
|
|
}
|
|
|
|
private func finishWarp() {
|
|
guard !self.warpHasCompleted else { return }
|
|
self.warpHasCompleted = true
|
|
self.warpDisplayLink?.invalidate()
|
|
self.warpDisplayLink = nil
|
|
self.warpCompletion?()
|
|
self.warpCompletion = nil
|
|
self.clearWarpState()
|
|
}
|
|
|
|
private func clearWarpState() {
|
|
self.warpView = nil
|
|
self.warpStartQuad = nil
|
|
self.warpEndQuad = nil
|
|
self.warpDynamicTarget = nil
|
|
self.warpStartTimestamp = 0
|
|
self.warpLastProgress = 0
|
|
self.warpCurrentQuad = nil
|
|
}
|
|
|
|
private func projectedQuad(for face: UIView) -> ProjectedFace {
|
|
let bounds = face.bounds
|
|
|
|
func project(_ p: CGPoint) -> CGPoint {
|
|
let inRoot = face.layer.convert(p, to: self.layer)
|
|
return self.stickerContainer.layer.convert(inRoot, from: self.layer)
|
|
}
|
|
|
|
var topLeft = project(CGPoint(x: bounds.minX, y: bounds.minY))
|
|
var topRight = project(CGPoint(x: bounds.maxX, y: bounds.minY))
|
|
var bottomLeft = project(CGPoint(x: bounds.minX, y: bounds.maxY))
|
|
var bottomRight = project(CGPoint(x: bounds.maxX, y: bounds.maxY))
|
|
|
|
func center(_ a: CGPoint, _ b: CGPoint) -> CGPoint {
|
|
CGPoint(x: (a.x + b.x) * 0.5, y: (a.y + b.y) * 0.5)
|
|
}
|
|
|
|
func normalized(_ v: CGPoint) -> CGPoint? {
|
|
let len = hypot(v.x, v.y)
|
|
guard len > 1e-5 else { return nil }
|
|
return CGPoint(x: v.x / len, y: v.y / len)
|
|
}
|
|
|
|
func dot(_ a: CGPoint, _ b: CGPoint) -> CGFloat {
|
|
a.x * b.x + a.y * b.y
|
|
}
|
|
|
|
let screenUp = CGPoint(x: 0, y: -1)
|
|
let screenRight = CGPoint(x: 1, y: 0)
|
|
|
|
if let up = normalized(CGPoint(
|
|
x: center(topLeft, topRight).x - center(bottomLeft, bottomRight).x,
|
|
y: center(topLeft, topRight).y - center(bottomLeft, bottomRight).y
|
|
)), dot(up, screenUp) < 0 {
|
|
swap(&topLeft, &bottomLeft)
|
|
swap(&topRight, &bottomRight)
|
|
}
|
|
|
|
let faceOrigin = project(.zero)
|
|
let faceX = project(CGPoint(x: 1, y: 0))
|
|
|
|
if let right = normalized(CGPoint(
|
|
x: center(topRight, bottomRight).x - center(topLeft, bottomLeft).x,
|
|
y: center(topRight, bottomRight).y - center(topLeft, bottomLeft).y
|
|
)), dot(right, screenRight) < 0 {
|
|
swap(&topLeft, &topRight)
|
|
swap(&bottomLeft, &bottomRight)
|
|
}
|
|
|
|
let quad = Quad(topLeft: topLeft, topRight: topRight, bottomLeft: bottomLeft, bottomRight: bottomRight)
|
|
|
|
let desiredTopVector = CGPoint(x: quad.topRight.x - quad.topLeft.x, y: quad.topRight.y - quad.topLeft.y)
|
|
let baseTopVector = CGPoint(x: faceX.x - faceOrigin.x, y: faceX.y - faceOrigin.y)
|
|
|
|
let desiredAngle = atan2(desiredTopVector.y, desiredTopVector.x)
|
|
let baseAngle = atan2(baseTopVector.y, baseTopVector.x)
|
|
let rotation = normalizeAngle(desiredAngle - baseAngle)
|
|
|
|
return ProjectedFace(quad: quad, rotation: rotation)
|
|
}
|
|
|
|
private func layoutStickers() {
|
|
guard !self.stickers.isEmpty else {
|
|
return
|
|
}
|
|
|
|
let cubeCenterInSticker = self.stickerContainer.convert(self.camera.center, from: self)
|
|
let r = self.cubeSize / 2 + self.stickerGap + self.stickerSize / 2
|
|
let scale = self.stickerSize / self.cubeSize
|
|
|
|
let positions = [
|
|
CGPoint(x: cubeCenterInSticker.x - r, y: cubeCenterInSticker.y - r * 0.4),
|
|
CGPoint(x: cubeCenterInSticker.x + r, y: cubeCenterInSticker.y - r * 0.4),
|
|
CGPoint(x: cubeCenterInSticker.x - r, y: cubeCenterInSticker.y + r * 0.4),
|
|
CGPoint(x: cubeCenterInSticker.x + r, y: cubeCenterInSticker.y + r * 0.4)
|
|
]
|
|
|
|
for (i, view) in self.stickers.enumerated() {
|
|
if view.superview !== self.stickerContainer {
|
|
continue
|
|
}
|
|
view.bounds = CGRect(x: 0, y: 0, width: self.cubeSize, height: self.cubeSize)
|
|
view.transform = CGAffineTransform(scaleX: scale, y: scale)
|
|
view.center = CGPoint(x: positions[i].x - self.stickerSize * 0.5, y: positions[i].y - self.stickerSize * 0.5)
|
|
}
|
|
}
|
|
|
|
@objc private func handlePan(_ gesture: UIPanGestureRecognizer) {
|
|
let translation = gesture.translation(in: self.camera)
|
|
switch gesture.state {
|
|
case .changed:
|
|
let delta = CGPoint(x: translation.x, y: translation.y)
|
|
|
|
self.rotation.y += Float(delta.x) * 0.018
|
|
self.rotation.x += Float(-delta.y) * 0.018
|
|
self.rotation = normalizedRotation(self.rotation)
|
|
self.applyCubeRotation()
|
|
|
|
gesture.setTranslation(.zero, in: self.camera)
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
|
|
func launchStickerView(_ sticker: UIView, emphasized: Bool, willFinish: Bool = false) {
|
|
guard sticker.superview === self.stickerContainer else {
|
|
return
|
|
}
|
|
var number = 0
|
|
if self.faceOccupants.count < self.activeStickOrder.count {
|
|
number = self.activeStickOrder[self.faceOccupants.count]
|
|
}
|
|
let faceIndex = number
|
|
guard self.faces.count > faceIndex else { return }
|
|
let targetFace = self.faces[faceIndex]
|
|
|
|
let startCenterInSticker = sticker.center
|
|
let cubeCenterInSticker = self.stickerContainer.convert(self.camera.center, from: self)
|
|
|
|
sticker.isUserInteractionEnabled = false
|
|
sticker.layer.isDoubleSided = false
|
|
|
|
let faceStickerSize = self.cubeSize
|
|
let duration: TimeInterval = 0.2
|
|
let startQuad = Quad(rect: sticker.frame)
|
|
let animationView: UIView
|
|
if let snapshot = sticker.snapshotView(afterScreenUpdates: false) {
|
|
self.warpSnapshot?.removeFromSuperview()
|
|
self.warpSnapshot = snapshot
|
|
|
|
snapshot.bounds = sticker.bounds
|
|
snapshot.center = sticker.center
|
|
snapshot.layer.anchorPoint = sticker.layer.anchorPoint
|
|
snapshot.layer.transform = sticker.layer.transform
|
|
snapshot.layer.isDoubleSided = sticker.layer.isDoubleSided
|
|
snapshot.isUserInteractionEnabled = false
|
|
self.stickerContainer.addSubview(snapshot)
|
|
|
|
sticker.isHidden = true
|
|
animationView = snapshot
|
|
} else {
|
|
animationView = sticker
|
|
}
|
|
sticker.transform = .identity
|
|
|
|
let projectedFace = self.projectedQuad(for: targetFace)
|
|
let targetQuad = projectedFace.quad
|
|
let dynamicTarget: () -> Quad = { [weak self, weak targetFace] in
|
|
guard let self, let face = targetFace else {
|
|
return targetQuad
|
|
}
|
|
return self.projectedQuad(for: face).quad
|
|
}
|
|
|
|
self.animateWarp(for: animationView, from: startQuad, to: targetQuad, duration: duration, dynamicTarget: dynamicTarget) { [weak self, weak sticker, weak targetFace, weak animationView] in
|
|
guard let self, let sticker, let targetFace else {
|
|
return
|
|
}
|
|
|
|
if let animationView, animationView !== sticker {
|
|
animationView.removeFromSuperview()
|
|
self.warpSnapshot = nil
|
|
sticker.isHidden = false
|
|
}
|
|
|
|
sticker.removeFromSuperview()
|
|
targetFace.addSubview(sticker)
|
|
self.faceOccupants[faceIndex] = sticker
|
|
|
|
sticker.bounds = CGRect(x: 0, y: 0, width: faceStickerSize, height: faceStickerSize)
|
|
sticker.layer.anchorPoint = CGPoint(x: 0.5, y: 0.5)
|
|
sticker.center = CGPoint(x: self.cubeSize / 2, y: self.cubeSize / 2)
|
|
sticker.layer.transform = CATransform3DIdentity
|
|
let finalProjection = self.projectedQuad(for: targetFace)
|
|
let snappedAngle = snappedRightAngle(finalProjection.rotation)
|
|
sticker.transform = CGAffineTransform(rotationAngle: snappedAngle)
|
|
|
|
let delta = SIMD2<Float>(Float(cubeCenterInSticker.x - startCenterInSticker.x), Float(cubeCenterInSticker.y - startCenterInSticker.y))
|
|
let direction = normalize2(delta)
|
|
self.applyImpulse(direction: direction, emphasized: emphasized, replace: true)
|
|
self.applyImpactSpring(direction: direction, emphasized: emphasized)
|
|
if willFinish {
|
|
self.startFinishingAnimation()
|
|
}
|
|
self.startSpinLoopIfNeeded()
|
|
}
|
|
}
|
|
|
|
private func resetStickers() {
|
|
self.cancelWarp()
|
|
self.warpSnapshot?.removeFromSuperview()
|
|
self.warpSnapshot = nil
|
|
|
|
for sticker in self.stickers {
|
|
sticker.layer.removeAllAnimations()
|
|
sticker.transform = .identity
|
|
sticker.layer.transform = CATransform3DIdentity
|
|
sticker.layer.anchorPoint = .zero
|
|
sticker.layer.isDoubleSided = true
|
|
sticker.clipsToBounds = false
|
|
sticker.isUserInteractionEnabled = true
|
|
sticker.removeFromSuperview()
|
|
self.stickerContainer.addSubview(sticker)
|
|
}
|
|
|
|
self.faceOccupants.removeAll()
|
|
self.layoutStickers()
|
|
}
|
|
|
|
private func resetCube() {
|
|
self.displayLink?.invalidate()
|
|
self.displayLink = nil
|
|
self.angularVelocity = .zero
|
|
self.lastTimestamp = 0
|
|
self.isFinishingX = false
|
|
self.isFinishingY = false
|
|
self.finishDelayTimerX?.invalidate()
|
|
self.finishDelayTimerX = nil
|
|
self.finishDelayTimerY?.invalidate()
|
|
self.finishDelayTimerY = nil
|
|
self.cubeScale = 1.0
|
|
self.hasFiredFinishApproach = false
|
|
|
|
self.rotation = SIMD3<Float>(repeating: 0)
|
|
self.cubeScale = 1.0
|
|
self.applyCubeRotation()
|
|
}
|
|
|
|
private func scheduleStickerSequence(from index: Int, indices: [Int]) {
|
|
guard self.isRunning else {
|
|
return
|
|
}
|
|
guard index < indices.count else {
|
|
self.isRunning = false
|
|
return
|
|
}
|
|
|
|
let delay: TimeInterval = index == 0 ? 0.0 : 1.0
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
|
|
guard let self else {
|
|
return
|
|
}
|
|
guard self.isRunning else {
|
|
return
|
|
}
|
|
let stickerIndex = indices[index]
|
|
if self.stickers.indices.contains(stickerIndex) {
|
|
let isLast = index == indices.count - 1
|
|
self.launchStickerView(self.stickers[stickerIndex], emphasized: isLast, willFinish: isLast)
|
|
}
|
|
self.scheduleStickerSequence(from: index + 1, indices: indices)
|
|
}
|
|
}
|
|
|
|
private func applyImpulse(direction: SIMD2<Float>, emphasized: Bool, replace: Bool) {
|
|
var xStrength = self.baseImpulseStrength
|
|
var yStrength = self.baseImpulseStrength
|
|
if emphasized {
|
|
xStrength *= 10.0
|
|
yStrength *= 4.0
|
|
}
|
|
let impulseX: Float = -direction.y * xStrength
|
|
let impulseY: Float = direction.x * yStrength
|
|
let impulseZ: Float = 0.0
|
|
|
|
if replace {
|
|
self.angularVelocity = SIMD3<Float>(impulseX, impulseY, impulseZ)
|
|
} else {
|
|
self.angularVelocity += SIMD3<Float>(impulseX, impulseY, impulseZ)
|
|
}
|
|
}
|
|
|
|
private func applyImpactSpring(direction: SIMD2<Float>, emphasized: Bool) {
|
|
guard simd_length(direction) > 0.0001 else {
|
|
return
|
|
}
|
|
let distance = emphasized ? self.impactNudgeEmphasis : self.impactNudgeDistance
|
|
let offsetX = CGFloat(direction.x) * distance
|
|
let offsetY = CGFloat(direction.y) * distance
|
|
|
|
let currentTransform = self.camera.layer.presentation()?.affineTransform() ?? self.camera.transform
|
|
self.camera.layer.removeAllAnimations()
|
|
let impactTransform = currentTransform.translatedBy(x: offsetX, y: offsetY)
|
|
|
|
UIView.animate(withDuration: 0.08, delay: 0.0, options: [.curveEaseOut, .beginFromCurrentState]) {
|
|
self.camera.transform = impactTransform
|
|
} completion: { _ in
|
|
UIView.animate(withDuration: 0.55, delay: 0, usingSpringWithDamping: 0.72, initialSpringVelocity: 0.2, options: .beginFromCurrentState) {
|
|
self.camera.transform = .identity
|
|
}
|
|
}
|
|
}
|
|
|
|
private func startSpinLoopIfNeeded() {
|
|
if self.displayLink == nil {
|
|
let link = SharedDisplayLinkDriver.shared.add(framesPerSecond: .max) { [weak self] _ in
|
|
self?.tick()
|
|
}
|
|
link.isPaused = false
|
|
self.displayLink = link
|
|
self.lastTimestamp = 0.0
|
|
}
|
|
}
|
|
|
|
private func tick() {
|
|
let ts = CACurrentMediaTime()
|
|
if self.lastTimestamp == 0 { self.lastTimestamp = ts; return }
|
|
let dt = Float(ts - self.lastTimestamp)
|
|
self.lastTimestamp = ts
|
|
|
|
self.rotation += self.angularVelocity * dt
|
|
if self.isFinishingX {
|
|
let delta = shortestAngleDelta(from: self.rotation.x, to: self.finishTargetX)
|
|
let accel = self.finishSpringX * delta - self.finishDampingX * self.angularVelocity.x
|
|
self.angularVelocity.x += accel * dt
|
|
if abs(delta) < 0.0006 && abs(self.angularVelocity.x) < 0.001 {
|
|
self.rotation.x = self.finishTargetX
|
|
self.angularVelocity.x = 0.0
|
|
self.isFinishingX = false
|
|
}
|
|
}
|
|
if self.isFinishingY {
|
|
self.finishRotationY += self.angularVelocity.y * dt
|
|
let remaining = self.finishTargetYUnwrapped - self.finishRotationY
|
|
let accel = self.finishSpringY * remaining - self.finishDampingY * self.angularVelocity.y
|
|
self.angularVelocity.y += accel * dt
|
|
self.rotation.y = normalizeAngle(self.finishRotationY)
|
|
let total = max(abs(self.finishRemainingYStart), 0.0001)
|
|
let progress = min(max(1.0 - abs(remaining) / total, 0.0), 1.0)
|
|
let damping = pow(1.0 - progress, self.finishWobbleDampingExponent)
|
|
let phase = 2.0 * Float.pi * self.finishWobbleCycles * progress
|
|
self.rotation.z = self.finishWobbleAmplitudeZ * sin(phase) * damping
|
|
let absRemaining = abs(remaining)
|
|
if !self.hasFiredFinishApproach && absRemaining <= self.finishApproachTriggerAngle {
|
|
self.hasFiredFinishApproach = true
|
|
let upsideDown = abs(shortestAngleDelta(from: self.rotation.x, to: Float.pi)) < (Float.pi / 2)
|
|
self.onFinishApproach?(upsideDown)
|
|
}
|
|
if self.isSuccess, absRemaining <= self.finishSuccessScaleTriggerAngle {
|
|
let raw = (self.finishSuccessScaleTriggerAngle - absRemaining) / self.finishSuccessScaleTriggerAngle
|
|
let eased = raw * raw * (3 - 2 * raw)
|
|
self.cubeScale = 1.0 + (self.finishSuccessScale - 1.0) * eased
|
|
} else if !self.isSuccess {
|
|
self.cubeScale = 1.0
|
|
}
|
|
if abs(remaining) < 0.0008 && abs(self.angularVelocity.y) < 0.0015 {
|
|
self.finishRotationY = self.finishTargetYUnwrapped
|
|
self.rotation.y = self.finishTargetY
|
|
self.angularVelocity.y = 0.0
|
|
self.isFinishingY = false
|
|
self.rotation.z = 0.0
|
|
self.angularVelocity.z = 0.0
|
|
}
|
|
} else if self.rotation.z != 0 {
|
|
self.rotation.z = 0.0
|
|
}
|
|
self.rotation = normalizedRotation(self.rotation)
|
|
|
|
let damp = pow(self.dampingPerSecond, dt)
|
|
self.angularVelocity *= damp
|
|
|
|
self.applyCubeRotation()
|
|
}
|
|
|
|
private func startFinishingAnimation() {
|
|
self.finishDelayTimerX?.invalidate()
|
|
self.finishDelayTimerX = Timer.scheduledTimer(withTimeInterval: 0.75, repeats: false) { [weak self] _ in
|
|
self?.beginFinishingX()
|
|
}
|
|
}
|
|
|
|
private func beginFinishingX() {
|
|
let deltaToZero = abs(shortestAngleDelta(from: self.rotation.x, to: 0))
|
|
let deltaToPi = abs(shortestAngleDelta(from: self.rotation.x, to: Float.pi))
|
|
self.finishTargetX = deltaToZero <= deltaToPi ? 0 : Float.pi
|
|
self.finishTargetY = self.finishTargetX == 0 ? 0 : Float.pi
|
|
self.isFinishingX = true
|
|
self.finishDelayTimerY?.invalidate()
|
|
self.finishDelayTimerY = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) { [weak self] _ in
|
|
self?.beginFinishingY()
|
|
}
|
|
}
|
|
|
|
private func beginFinishingY() {
|
|
self.finishRotationY = self.rotation.y
|
|
let directionY = nonZeroSign(self.angularVelocity.y, fallback: 1)
|
|
self.finishDirectionY = directionY
|
|
let startMod = normalizeAnglePositive(self.finishRotationY)
|
|
let targetMod = normalizeAnglePositive(self.finishTargetY)
|
|
let baseDelta: Float
|
|
if directionY >= 0 {
|
|
baseDelta = targetMod >= startMod ? targetMod - startMod : (Float.pi * 2) - (startMod - targetMod)
|
|
} else {
|
|
baseDelta = startMod >= targetMod ? startMod - targetMod : (Float.pi * 2) - (targetMod - startMod)
|
|
}
|
|
var delta = baseDelta
|
|
if delta < Float.pi {
|
|
delta += Float.pi * 2
|
|
}
|
|
self.finishTargetYUnwrapped = self.finishRotationY + directionY * delta
|
|
self.finishRemainingYStart = self.finishTargetYUnwrapped - self.finishRotationY
|
|
self.isFinishingY = true
|
|
self.hasFiredFinishApproach = false
|
|
}
|
|
|
|
private func applyCubeRotation() {
|
|
var m = CATransform3DIdentity
|
|
m = CATransform3DRotate(m, CGFloat(self.rotation.x), 1, 0, 0)
|
|
m = CATransform3DRotate(m, CGFloat(self.rotation.y), 0, 1, 0)
|
|
m = CATransform3DRotate(m, CGFloat(self.rotation.z), 0, 0, 1)
|
|
m = CATransform3DScale(m, CGFloat(self.cubeScale), CGFloat(self.cubeScale), 1)
|
|
self.cubeContainer.layer.transform = m
|
|
}
|
|
}
|