@swmansion/react-native-bottom-sheet 0.15.0-next.3 → 0.15.0-next.4

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.
@@ -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 activeAnimator: UIViewPropertyAnimator?
72
- private var activeAnimatorEmitsSettle = false
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 activeAnimator != nil || isPanning { return }
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 activeAnimator != nil, let presentation = sheetContainer.layer.presentation() {
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
- activeAnimator?.stopAnimation(true)
273
- activeAnimator = nil
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
- private func emitPosition() {
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
- emitPosition()
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
- activeAnimator == nil || currentSheetHeight > 0.5
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 = sheetContainer.transform.ty
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 initialVelocity = CGVector(dx: 0, dy: clampedRatio)
424
-
425
- activeAnimatorEmitsSettle = emitSettle
426
- activeAnimator?.stopAnimation(true)
427
-
428
- let spring = UISpringTimingParameters(dampingRatio: 1.0, initialVelocity: initialVelocity)
429
- let animator = UIViewPropertyAnimator(duration: 0.45, timingParameters: spring)
430
-
431
- animator.addAnimations {
432
- self.sheetContainer.transform = CGAffineTransform(translationX: 0, y: targetTy)
433
- }
434
- animator.addCompletion { [weak self] position in
435
- guard let self, position == .end else { return }
436
- self.stopDisplayLink()
437
- self.emitPosition()
438
- self.activeAnimator = nil
439
- self.activeAnimatorEmitsSettle = false
440
- self.scrimPinnedFull = false
441
- self.setContentInteractionEnabled(true)
442
- self.updateInteractionState()
443
- if emitSettle {
444
- self.eventDelegate?.bottomSheetHostingView(self, didSettle: index)
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
- animator.startAnimation()
454
- activeAnimator = animator
455
- startDisplayLink()
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 let animator = activeAnimator {
474
- stopDisplayLink()
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 let animator = activeAnimator {
697
- stopDisplayLink()
698
- let visualTy = sheetContainer.layer.presentation()?.affineTransform().ty ?? sheetContainer.transform.ty
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
- if activeAnimator != nil, let presentation = sheetContainer.layer.presentation() {
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
- activeAnimator == nil,
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swmansion/react-native-bottom-sheet",
3
- "version": "0.15.0-next.3",
3
+ "version": "0.15.0-next.4",
4
4
  "description": "Provides bottom-sheet components for React Native.",
5
5
  "main": "./lib/module/index.js",
6
6
  "types": "./lib/typescript/src/index.d.ts",