@swmansion/react-native-bottom-sheet 0.15.0-next.3 → 0.15.0-next.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -4,7 +4,6 @@ import android.content.Context
|
|
|
4
4
|
import android.graphics.Canvas
|
|
5
5
|
import android.graphics.Color
|
|
6
6
|
import android.graphics.Paint
|
|
7
|
-
import android.view.Choreographer
|
|
8
7
|
import android.view.MotionEvent
|
|
9
8
|
import android.view.VelocityTracker
|
|
10
9
|
import android.view.View
|
|
@@ -71,7 +70,6 @@ class BottomSheetView(context: Context) : ReactViewGroup(context) {
|
|
|
71
70
|
private var activeAnimation: SpringAnimation? = null
|
|
72
71
|
private var activeAnimationEmitsSettle = false
|
|
73
72
|
private var velocityTracker: VelocityTracker? = null
|
|
74
|
-
private var choreographerCallback: Choreographer.FrameCallback? = null
|
|
75
73
|
private val density = context.resources.displayMetrics.density
|
|
76
74
|
private val touchSlop = ViewConfiguration.get(context).scaledTouchSlop
|
|
77
75
|
|
|
@@ -94,7 +92,9 @@ class BottomSheetView(context: Context) : ReactViewGroup(context) {
|
|
|
94
92
|
private var surfaceView: View? = null
|
|
95
93
|
|
|
96
94
|
private val contentHeightMarkerLayoutListener =
|
|
97
|
-
View.OnLayoutChangeListener { _, _, _, _, _, _, _, _, _ ->
|
|
95
|
+
View.OnLayoutChangeListener { _, _, _, _, _, _, _, _, _ ->
|
|
96
|
+
refreshDetentsFromLayout()
|
|
97
|
+
}
|
|
98
98
|
|
|
99
99
|
init {
|
|
100
100
|
clipChildren = false
|
|
@@ -226,17 +226,16 @@ class BottomSheetView(context: Context) : ReactViewGroup(context) {
|
|
|
226
226
|
// MARK: - Prop setters
|
|
227
227
|
|
|
228
228
|
fun setDetents(raw: List<Map<String, Any>>) {
|
|
229
|
-
rawDetentSpecs =
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
}
|
|
229
|
+
rawDetentSpecs = raw.mapNotNull { dict ->
|
|
230
|
+
val value = (dict["value"] as? Number)?.toDouble() ?: return@mapNotNull null
|
|
231
|
+
val kind =
|
|
232
|
+
when ((dict["kind"] as? String)?.lowercase()) {
|
|
233
|
+
"content" -> DetentKind.CONTENT
|
|
234
|
+
else -> DetentKind.POINTS
|
|
235
|
+
}
|
|
236
|
+
val programmatic = dict["programmatic"] as? Boolean ?: false
|
|
237
|
+
RawDetentSpec(value = (value * density).toFloat(), kind = kind, programmatic = programmatic)
|
|
238
|
+
}
|
|
240
239
|
refreshDetentsFromLayout()
|
|
241
240
|
}
|
|
242
241
|
|
|
@@ -342,7 +341,6 @@ class BottomSheetView(context: Context) : ReactViewGroup(context) {
|
|
|
342
341
|
activeAnimation?.cancel()
|
|
343
342
|
activeAnimation = null
|
|
344
343
|
activeAnimationEmitsSettle = false
|
|
345
|
-
stopChoreographer()
|
|
346
344
|
// Re-anchor the in-flight position to the new container height so the
|
|
347
345
|
// sheet surface keeps the same on-screen height across the resize.
|
|
348
346
|
val visibleHeight = previousMaxHeight - currentTy
|
|
@@ -513,26 +511,6 @@ class BottomSheetView(context: Context) : ReactViewGroup(context) {
|
|
|
513
511
|
sw.updateState(map)
|
|
514
512
|
}
|
|
515
513
|
|
|
516
|
-
// MARK: - Choreographer (position tracking during animation)
|
|
517
|
-
|
|
518
|
-
private fun startChoreographer() {
|
|
519
|
-
if (choreographerCallback != null) return
|
|
520
|
-
val callback =
|
|
521
|
-
object : Choreographer.FrameCallback {
|
|
522
|
-
override fun doFrame(frameTimeNanos: Long) {
|
|
523
|
-
emitPosition()
|
|
524
|
-
choreographerCallback?.let { Choreographer.getInstance().postFrameCallback(it) }
|
|
525
|
-
}
|
|
526
|
-
}
|
|
527
|
-
choreographerCallback = callback
|
|
528
|
-
Choreographer.getInstance().postFrameCallback(callback)
|
|
529
|
-
}
|
|
530
|
-
|
|
531
|
-
private fun stopChoreographer() {
|
|
532
|
-
choreographerCallback?.let { Choreographer.getInstance().removeFrameCallback(it) }
|
|
533
|
-
choreographerCallback = null
|
|
534
|
-
}
|
|
535
|
-
|
|
536
514
|
// MARK: - Spring animation
|
|
537
515
|
|
|
538
516
|
private fun snapToIndex(
|
|
@@ -566,11 +544,15 @@ class BottomSheetView(context: Context) : ReactViewGroup(context) {
|
|
|
566
544
|
setMinValue(minOf(minDetentTranslationY, currentTy, targetTy))
|
|
567
545
|
setMaxValue(maxOf(maxDetentTranslationY, currentTy, targetTy))
|
|
568
546
|
setStartVelocity(velocity)
|
|
547
|
+
// Forward the position on every frame of the settle. The listener fires
|
|
548
|
+
// immediately after the spring writes `translationY`, so `emitPosition`
|
|
549
|
+
// reads the value being shown this frame — keeping followers that track
|
|
550
|
+
// `onPositionChange` (e.g. a Reanimated view) in lockstep with the sheet.
|
|
551
|
+
addUpdateListener { _, _, _ -> emitPosition() }
|
|
569
552
|
addEndListener { _, canceled, _, _ ->
|
|
570
553
|
if (canceled) {
|
|
571
554
|
return@addEndListener
|
|
572
555
|
}
|
|
573
|
-
stopChoreographer()
|
|
574
556
|
activeAnimation = null
|
|
575
557
|
activeAnimationEmitsSettle = false
|
|
576
558
|
suppressScrimForClosingTarget = false
|
|
@@ -586,7 +568,6 @@ class BottomSheetView(context: Context) : ReactViewGroup(context) {
|
|
|
586
568
|
}
|
|
587
569
|
|
|
588
570
|
activeAnimation = spring
|
|
589
|
-
startChoreographer()
|
|
590
571
|
// Report the index change as soon as the snap is committed, not when it
|
|
591
572
|
// finishes: targetIndex is already set, and a programmatic snap's start is
|
|
592
573
|
// known to the caller. onSettle remains the signal for movement end.
|
|
@@ -777,7 +758,6 @@ class BottomSheetView(context: Context) : ReactViewGroup(context) {
|
|
|
777
758
|
activeAnimation?.let {
|
|
778
759
|
it.cancel()
|
|
779
760
|
activeAnimation = null
|
|
780
|
-
stopChoreographer()
|
|
781
761
|
}
|
|
782
762
|
}
|
|
783
763
|
|
|
@@ -850,7 +830,6 @@ class BottomSheetView(context: Context) : ReactViewGroup(context) {
|
|
|
850
830
|
fun destroy() {
|
|
851
831
|
activeAnimation?.cancel()
|
|
852
832
|
activeAnimation = null
|
|
853
|
-
stopChoreographer()
|
|
854
833
|
velocityTracker?.recycle()
|
|
855
834
|
velocityTracker = null
|
|
856
835
|
contentHeightMarker?.removeOnLayoutChangeListener(contentHeightMarkerLayoutListener)
|
|
@@ -68,8 +68,9 @@ public final class BottomSheetHostingView: UIView {
|
|
|
68
68
|
public let sheetContainer = UIView()
|
|
69
69
|
private let scrimView = UIControl()
|
|
70
70
|
private var panGesture: UIPanGestureRecognizer!
|
|
71
|
-
private var
|
|
72
|
-
private var
|
|
71
|
+
private var activeSpring: CriticalSpring?
|
|
72
|
+
private var activeSpringTargetIndex: Int = 0
|
|
73
|
+
private var activeSpringEmitsSettle = false
|
|
73
74
|
private var scrimPinnedFull = false
|
|
74
75
|
private var displayLink: CADisplayLink?
|
|
75
76
|
private var pendingIndex: Int?
|
|
@@ -81,6 +82,7 @@ public final class BottomSheetHostingView: UIView {
|
|
|
81
82
|
private var contentHeightMarker: UIView?
|
|
82
83
|
private weak var surfaceView: UIView?
|
|
83
84
|
private static var markerObservationContext = 0
|
|
85
|
+
private static let springAnimationKey = "bottomSheetSettle"
|
|
84
86
|
|
|
85
87
|
override public init(frame: CGRect) {
|
|
86
88
|
super.init(frame: frame)
|
|
@@ -181,13 +183,13 @@ public final class BottomSheetHostingView: UIView {
|
|
|
181
183
|
return
|
|
182
184
|
}
|
|
183
185
|
|
|
184
|
-
if
|
|
186
|
+
if activeSpring != nil || isPanning { return }
|
|
185
187
|
sheetContainer.transform = CGAffineTransform(translationX: 0, y: translationY(for: targetIndex))
|
|
186
188
|
updateScrim()
|
|
187
189
|
}
|
|
188
190
|
|
|
189
191
|
private var presentedSheetFrame: CGRect {
|
|
190
|
-
if
|
|
192
|
+
if activeSpring != nil, let presentation = sheetContainer.layer.presentation() {
|
|
191
193
|
return presentation.frame
|
|
192
194
|
}
|
|
193
195
|
return sheetContainer.frame
|
|
@@ -269,9 +271,10 @@ public final class BottomSheetHostingView: UIView {
|
|
|
269
271
|
}
|
|
270
272
|
|
|
271
273
|
public func resetSheetState() {
|
|
272
|
-
|
|
273
|
-
|
|
274
|
+
activeSpring = nil
|
|
275
|
+
activeSpringEmitsSettle = false
|
|
274
276
|
stopDisplayLink()
|
|
277
|
+
sheetContainer.layer.removeAnimation(forKey: Self.springAnimationKey)
|
|
275
278
|
rawDetentSpecs = []
|
|
276
279
|
detentSpecs = []
|
|
277
280
|
targetIndex = 0
|
|
@@ -351,9 +354,11 @@ public final class BottomSheetHostingView: UIView {
|
|
|
351
354
|
modal && !scrimView.isHidden
|
|
352
355
|
}
|
|
353
356
|
|
|
354
|
-
|
|
357
|
+
/// `overrideTy` is the spring's predicted translationY for the upcoming frame
|
|
358
|
+
/// (passed during a settle). Without it we read the current on-screen value.
|
|
359
|
+
private func emitPosition(overrideTy: CGFloat? = nil) {
|
|
355
360
|
let maxHeight = sheetContainerHeight
|
|
356
|
-
let ty = currentTranslationY
|
|
361
|
+
let ty = overrideTy ?? currentTranslationY
|
|
357
362
|
let position = maxHeight - ty
|
|
358
363
|
updateScrim(forPosition: position)
|
|
359
364
|
updateSheetVisibility(forPosition: position)
|
|
@@ -364,7 +369,7 @@ public final class BottomSheetHostingView: UIView {
|
|
|
364
369
|
|
|
365
370
|
private func startDisplayLink() {
|
|
366
371
|
guard displayLink == nil else { return }
|
|
367
|
-
let link = CADisplayLink(target: self, selector: #selector(displayLinkFired))
|
|
372
|
+
let link = CADisplayLink(target: self, selector: #selector(displayLinkFired(_:)))
|
|
368
373
|
link.add(to: .main, forMode: .common)
|
|
369
374
|
displayLink = link
|
|
370
375
|
}
|
|
@@ -385,8 +390,13 @@ public final class BottomSheetHostingView: UIView {
|
|
|
385
390
|
isContentInteractionDisabled = !isEnabled
|
|
386
391
|
}
|
|
387
392
|
|
|
388
|
-
@objc private func displayLinkFired() {
|
|
389
|
-
|
|
393
|
+
@objc private func displayLinkFired(_ link: CADisplayLink) {
|
|
394
|
+
// `targetTimestamp` is the predicted display time of the next frame
|
|
395
|
+
// Ideal synchronization with animations running on the render server
|
|
396
|
+
// hasn't been achieved. Render server is a black box and it's hard to
|
|
397
|
+
// find out why exactly. This approach however gives better results
|
|
398
|
+
// than lagging one frame behind.
|
|
399
|
+
stepSpring(targetTime: link.targetTimestamp)
|
|
390
400
|
}
|
|
391
401
|
|
|
392
402
|
@objc private func handleScrimPress() {
|
|
@@ -394,7 +404,7 @@ public final class BottomSheetHostingView: UIView {
|
|
|
394
404
|
modal,
|
|
395
405
|
let closedIndex,
|
|
396
406
|
targetIndex != closedIndex,
|
|
397
|
-
|
|
407
|
+
activeSpring == nil || currentSheetHeight > 0.5
|
|
398
408
|
else {
|
|
399
409
|
return
|
|
400
410
|
}
|
|
@@ -415,44 +425,102 @@ public final class BottomSheetHostingView: UIView {
|
|
|
415
425
|
scrimPinnedFull = false
|
|
416
426
|
}
|
|
417
427
|
|
|
418
|
-
let currentTy
|
|
428
|
+
let currentTy: CGFloat
|
|
429
|
+
if activeSpring != nil {
|
|
430
|
+
currentTy = cancelActiveSpring()
|
|
431
|
+
} else {
|
|
432
|
+
currentTy = sheetContainer.transform.ty
|
|
433
|
+
}
|
|
419
434
|
let targetTy = translationY(for: index)
|
|
420
435
|
let distance = targetTy - currentTy
|
|
436
|
+
|
|
421
437
|
let velocityRatio = distance != 0 ? velocity / distance : 0
|
|
422
438
|
let clampedRatio = min(max(velocityRatio, -5), 5)
|
|
423
|
-
let
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
let
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
439
|
+
let v0 = clampedRatio * distance
|
|
440
|
+
|
|
441
|
+
let duration: CFTimeInterval = 0.45
|
|
442
|
+
// Pick the stiffness so the sheet looks settled (within ~0.5% of target)
|
|
443
|
+
// right at `duration`. For a critically-damped spring that point is
|
|
444
|
+
// ω·t ≈ 8, so ω = 8 / duration.
|
|
445
|
+
let omega = 8.0 / CGFloat(duration)
|
|
446
|
+
activeSpringEmitsSettle = emitSettle
|
|
447
|
+
activeSpringTargetIndex = index
|
|
448
|
+
|
|
449
|
+
// The single instant both the modal animation and the follower curve are anchored to.
|
|
450
|
+
let startTime = CACurrentMediaTime()
|
|
451
|
+
let spring = CriticalSpring(
|
|
452
|
+
from: currentTy,
|
|
453
|
+
target: targetTy,
|
|
454
|
+
v0: v0,
|
|
455
|
+
omega: omega,
|
|
456
|
+
startTime: startTime,
|
|
457
|
+
duration: duration
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
// The sheet is animated on the render server from samples of the same spring
|
|
461
|
+
// that feeds the follower position events.
|
|
462
|
+
let animation = CAKeyframeAnimation(keyPath: "transform.translation.y")
|
|
463
|
+
let sampleCount = max(Int((duration * 120).rounded()), 1)
|
|
464
|
+
animation.values = spring.keyframeValues(count: sampleCount)
|
|
465
|
+
animation.keyTimes = (0...sampleCount).map {
|
|
466
|
+
NSNumber(value: Double($0) / Double(sampleCount))
|
|
467
|
+
}
|
|
468
|
+
animation.duration = duration
|
|
469
|
+
animation.calculationMode = .linear
|
|
470
|
+
animation.beginTime = sheetContainer.layer.convertTime(startTime, from: nil)
|
|
471
|
+
animation.isRemovedOnCompletion = false
|
|
472
|
+
animation.fillMode = .forwards
|
|
473
|
+
animation.delegate = self
|
|
474
|
+
|
|
475
|
+
sheetContainer.transform = CGAffineTransform(translationX: 0, y: targetTy)
|
|
476
|
+
sheetContainer.layer.add(animation, forKey: Self.springAnimationKey)
|
|
477
|
+
activeSpring = spring
|
|
478
|
+
|
|
479
|
+
startDisplayLink()
|
|
480
|
+
|
|
447
481
|
// Report the index change as soon as the snap is committed, not when it
|
|
448
482
|
// finishes: `targetIndex` is already set, and a programmatic snap's start is
|
|
449
483
|
// known to the caller. `onSettle` remains the signal for movement end.
|
|
450
484
|
if emitIndexChange {
|
|
451
485
|
eventDelegate?.bottomSheetHostingView(self, didChangeIndex: index)
|
|
452
486
|
}
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
private func stepSpring(targetTime: CFTimeInterval) {
|
|
490
|
+
guard let spring = activeSpring else { return }
|
|
491
|
+
emitPosition(overrideTy: spring.value(at: targetTime))
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
private func finishSpring() {
|
|
495
|
+
guard activeSpring != nil else { return }
|
|
496
|
+
let index = activeSpringTargetIndex
|
|
497
|
+
let emitSettle = activeSpringEmitsSettle
|
|
498
|
+
let targetTy = translationY(for: index)
|
|
499
|
+
|
|
500
|
+
activeSpring = nil
|
|
501
|
+
activeSpringEmitsSettle = false
|
|
502
|
+
stopDisplayLink()
|
|
503
|
+
sheetContainer.layer.removeAnimation(forKey: Self.springAnimationKey)
|
|
504
|
+
|
|
505
|
+
sheetContainer.transform = CGAffineTransform(translationX: 0, y: targetTy)
|
|
506
|
+
emitPosition()
|
|
507
|
+
scrimPinnedFull = false
|
|
508
|
+
setContentInteractionEnabled(true)
|
|
509
|
+
updateInteractionState()
|
|
510
|
+
if emitSettle {
|
|
511
|
+
eventDelegate?.bottomSheetHostingView(self, didSettle: index)
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
@discardableResult
|
|
516
|
+
private func cancelActiveSpring() -> CGFloat {
|
|
517
|
+
let visualTy = sheetContainer.layer.presentation()?.affineTransform().ty ?? sheetContainer.transform.ty
|
|
518
|
+
activeSpring = nil
|
|
519
|
+
activeSpringEmitsSettle = false
|
|
520
|
+
stopDisplayLink()
|
|
521
|
+
sheetContainer.layer.removeAnimation(forKey: Self.springAnimationKey)
|
|
522
|
+
sheetContainer.transform = CGAffineTransform(translationX: 0, y: visualTy)
|
|
523
|
+
return visualTy
|
|
456
524
|
}
|
|
457
525
|
|
|
458
526
|
@objc private func handlePan(_ gesture: UIPanGestureRecognizer) {
|
|
@@ -470,12 +538,8 @@ public final class BottomSheetHostingView: UIView {
|
|
|
470
538
|
handler.isEnabled = true
|
|
471
539
|
}
|
|
472
540
|
gesture.setTranslation(.zero, in: self)
|
|
473
|
-
if
|
|
474
|
-
|
|
475
|
-
let visual = sheetContainer.layer.presentation()?.affineTransform() ?? sheetContainer.transform
|
|
476
|
-
animator.stopAnimation(true)
|
|
477
|
-
sheetContainer.transform = visual
|
|
478
|
-
activeAnimator = nil
|
|
541
|
+
if activeSpring != nil {
|
|
542
|
+
cancelActiveSpring()
|
|
479
543
|
}
|
|
480
544
|
|
|
481
545
|
case .changed:
|
|
@@ -693,13 +757,9 @@ public final class BottomSheetHostingView: UIView {
|
|
|
693
757
|
let newMaxHeight = sheetContainerHeight
|
|
694
758
|
let targetTy = translationY(for: targetIndex)
|
|
695
759
|
|
|
696
|
-
if
|
|
697
|
-
|
|
698
|
-
let visualTy =
|
|
699
|
-
let shouldEmitSettle = activeAnimatorEmitsSettle
|
|
700
|
-
animator.stopAnimation(true)
|
|
701
|
-
activeAnimator = nil
|
|
702
|
-
activeAnimatorEmitsSettle = false
|
|
760
|
+
if activeSpring != nil {
|
|
761
|
+
let shouldEmitSettle = activeSpringEmitsSettle
|
|
762
|
+
let visualTy = cancelActiveSpring()
|
|
703
763
|
// Re-anchor the in-flight position to the new container height so the
|
|
704
764
|
// sheet surface keeps the same on-screen height across the resize.
|
|
705
765
|
let visibleHeight = previousMaxHeight - visualTy
|
|
@@ -786,6 +846,8 @@ public final class BottomSheetHostingView: UIView {
|
|
|
786
846
|
|
|
787
847
|
deinit {
|
|
788
848
|
stopObservingContentHeightMarker()
|
|
849
|
+
displayLink?.invalidate()
|
|
850
|
+
sheetContainer.layer.removeAnimation(forKey: Self.springAnimationKey)
|
|
789
851
|
}
|
|
790
852
|
|
|
791
853
|
private func findContentHeightMarker() -> UIView? {
|
|
@@ -821,6 +883,13 @@ public final class BottomSheetHostingView: UIView {
|
|
|
821
883
|
}
|
|
822
884
|
}
|
|
823
885
|
|
|
886
|
+
extension BottomSheetHostingView: CAAnimationDelegate {
|
|
887
|
+
public func animationDidStop(_: CAAnimation, finished: Bool) {
|
|
888
|
+
guard finished, activeSpring != nil else { return }
|
|
889
|
+
finishSpring()
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
|
|
824
893
|
extension BottomSheetHostingView: UIGestureRecognizerDelegate {
|
|
825
894
|
public func gestureRecognizer(
|
|
826
895
|
_ gestureRecognizer: UIGestureRecognizer,
|
|
@@ -840,7 +909,11 @@ extension BottomSheetHostingView: UIGestureRecognizerDelegate {
|
|
|
840
909
|
|
|
841
910
|
private extension BottomSheetHostingView {
|
|
842
911
|
var currentTranslationY: CGFloat {
|
|
843
|
-
|
|
912
|
+
// During a settle the modal is driven by a keyframe animation on the render
|
|
913
|
+
// server, so the live on-screen value lives on the presentation layer (the
|
|
914
|
+
// model `transform` already holds the final target). A drag assigns
|
|
915
|
+
// `transform` directly, so outside a settle the model value is correct.
|
|
916
|
+
if activeSpring != nil, let presentation = sheetContainer.layer.presentation() {
|
|
844
917
|
return presentation.affineTransform().ty
|
|
845
918
|
}
|
|
846
919
|
return sheetContainer.transform.ty
|
|
@@ -868,7 +941,7 @@ private extension BottomSheetHostingView {
|
|
|
868
941
|
if
|
|
869
942
|
let closedIndex,
|
|
870
943
|
targetIndex == closedIndex,
|
|
871
|
-
|
|
944
|
+
activeSpring == nil,
|
|
872
945
|
!isPanning
|
|
873
946
|
{
|
|
874
947
|
scrimView.alpha = 0
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import QuartzCore
|
|
2
|
+
|
|
3
|
+
/// Critically damped (ζ = 1): reaches the target as fast as possible without overshooting.
|
|
4
|
+
struct CriticalSpring {
|
|
5
|
+
let from: CGFloat
|
|
6
|
+
let target: CGFloat
|
|
7
|
+
/// Initial velocity (points/sec) — e.g. carried over from a finger flick.
|
|
8
|
+
let v0: CGFloat
|
|
9
|
+
/// Angular frequency (rad/sec) — the spring's stiffness/speed. Higher ω snaps faster.
|
|
10
|
+
let omega: CGFloat
|
|
11
|
+
/// Absolute media time the curve starts at — the same instant the modal's
|
|
12
|
+
/// keyframe animation is pinned to via its `beginTime`, so `value(at:)` and the
|
|
13
|
+
/// modal share one clock.
|
|
14
|
+
let startTime: CFTimeInterval
|
|
15
|
+
let duration: CFTimeInterval
|
|
16
|
+
|
|
17
|
+
/// Position at an absolute `time`, from the closed-form solution of a
|
|
18
|
+
/// critically-damped spring (ζ = 1):
|
|
19
|
+
/// x(t) = target + e^(−ω·t)·[A + (v0 + ω·A)·t]
|
|
20
|
+
/// where A is the starting offset from the target. The `e^(−ω·t)` term decays
|
|
21
|
+
/// the offset toward 0 (so x → target), and the linear `…·t` factor is what
|
|
22
|
+
/// lets a critically-damped spring carry initial velocity without oscillating.
|
|
23
|
+
func value(at time: CFTimeInterval) -> CGFloat {
|
|
24
|
+
// Seconds since the spring started (clamped so a past `time` reads as t = 0).
|
|
25
|
+
let t = CGFloat(max(0, time - startTime))
|
|
26
|
+
// A: how far `from` is from `target` — the offset the spring must close.
|
|
27
|
+
let a = from - target
|
|
28
|
+
// Exponential envelope: 1 at t = 0, shrinking toward 0 as time passes.
|
|
29
|
+
let decay = exp(-omega * t)
|
|
30
|
+
return target + decay * (a + (v0 + omega * a) * t)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/// Samples this curve into `count + 1` evenly-spaced points over `[0, duration]`
|
|
34
|
+
/// for use as `CAKeyframeAnimation.values`. CA replays these samples on the
|
|
35
|
+
/// render server, while `value(at:)` feeds the follower from the analytical
|
|
36
|
+
/// spring on the app thread.
|
|
37
|
+
func keyframeValues(count: Int) -> [CGFloat] {
|
|
38
|
+
let n = max(count, 1)
|
|
39
|
+
return (0...n).map { i in
|
|
40
|
+
let t = duration * CFTimeInterval(i) / CFTimeInterval(n)
|
|
41
|
+
return value(at: startTime + t)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
package/package.json
CHANGED