@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 { _, _, _, _, _, _, _, _, _ -> refreshDetentsFromLayout() }
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
- raw.mapNotNull { dict ->
231
- val value = (dict["value"] as? Number)?.toDouble() ?: return@mapNotNull null
232
- val kind =
233
- when ((dict["kind"] as? String)?.lowercase()) {
234
- "content" -> DetentKind.CONTENT
235
- else -> DetentKind.POINTS
236
- }
237
- val programmatic = dict["programmatic"] as? Boolean ?: false
238
- RawDetentSpec(value = (value * density).toFloat(), kind = kind, programmatic = programmatic)
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 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.5",
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",