@swmansion/react-native-bottom-sheet 0.15.0-next.1 → 0.15.0-next.2

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?
@@ -181,20 +182,13 @@ public final class BottomSheetHostingView: UIView {
181
182
  return
182
183
  }
183
184
 
184
- if activeAnimator != nil || isPanning { return }
185
+ if activeSpring != nil || isPanning { return }
185
186
  sheetContainer.transform = CGAffineTransform(translationX: 0, y: translationY(for: targetIndex))
186
187
  updateScrim()
187
188
  }
188
189
 
189
- private var presentedSheetFrame: CGRect {
190
- if activeAnimator != nil, let presentation = sheetContainer.layer.presentation() {
191
- return presentation.frame
192
- }
193
- return sheetContainer.frame
194
- }
195
-
196
190
  override public func point(inside point: CGPoint, with _: UIEvent?) -> Bool {
197
- if presentedSheetFrame.contains(point) {
191
+ if sheetContainer.frame.contains(point) {
198
192
  return true
199
193
  }
200
194
 
@@ -204,7 +198,7 @@ public final class BottomSheetHostingView: UIView {
204
198
  override public func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
205
199
  guard self.point(inside: point, with: event) else { return nil }
206
200
 
207
- if isScrimVisible, !presentedSheetFrame.contains(point) {
201
+ if isScrimVisible, !sheetContainer.frame.contains(point) {
208
202
  let scrimPoint = convert(point, to: scrimView)
209
203
  return scrimView.hitTest(scrimPoint, with: event)
210
204
  }
@@ -269,8 +263,8 @@ public final class BottomSheetHostingView: UIView {
269
263
  }
270
264
 
271
265
  public func resetSheetState() {
272
- activeAnimator?.stopAnimation(true)
273
- activeAnimator = nil
266
+ activeSpring = nil
267
+ activeSpringEmitsSettle = false
274
268
  stopDisplayLink()
275
269
  rawDetentSpecs = []
276
270
  detentSpecs = []
@@ -364,7 +358,7 @@ public final class BottomSheetHostingView: UIView {
364
358
 
365
359
  private func startDisplayLink() {
366
360
  guard displayLink == nil else { return }
367
- let link = CADisplayLink(target: self, selector: #selector(displayLinkFired))
361
+ let link = CADisplayLink(target: self, selector: #selector(displayLinkFired(_:)))
368
362
  link.add(to: .main, forMode: .common)
369
363
  displayLink = link
370
364
  }
@@ -385,8 +379,9 @@ public final class BottomSheetHostingView: UIView {
385
379
  isContentInteractionDisabled = !isEnabled
386
380
  }
387
381
 
388
- @objc private func displayLinkFired() {
389
- emitPosition()
382
+ @objc private func displayLinkFired(_ link: CADisplayLink) {
383
+ // `targetTimestamp` is predicted when the NEXT frame will be shown
384
+ stepSpring(targetTime: link.targetTimestamp)
390
385
  }
391
386
 
392
387
  @objc private func handleScrimPress() {
@@ -394,7 +389,7 @@ public final class BottomSheetHostingView: UIView {
394
389
  modal,
395
390
  let closedIndex,
396
391
  targetIndex != closedIndex,
397
- activeAnimator == nil || currentSheetHeight > 0.5
392
+ activeSpring == nil || currentSheetHeight > 0.5
398
393
  else {
399
394
  return
400
395
  }
@@ -418,43 +413,71 @@ public final class BottomSheetHostingView: UIView {
418
413
  let currentTy = sheetContainer.transform.ty
419
414
  let targetTy = translationY(for: index)
420
415
  let distance = targetTy - currentTy
416
+
421
417
  let velocityRatio = distance != 0 ? velocity / distance : 0
422
418
  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
- }
419
+ let v0 = clampedRatio * distance
420
+
421
+ let duration: CFTimeInterval = 0.45
422
+ // Pick the stiffness so the sheet looks settled (within ~0.5% of target)
423
+ // right at `duration`. For a critically-damped spring that point is
424
+ // ω·t ≈ 8, so ω = 8 / duration. Exact agreement with UIKit's spring doesn't
425
+ // matter here we drive the modal from this curve.
426
+ //
427
+ // NOTE: This solution may affect the animation performance when the main thread is busy.
428
+ // If that becomes a problem, consider adopting this solution: https://github.com/kirillzyusko/react-native-keyboard-controller/pull/412
429
+ let omega = 8.0 / CGFloat(duration)
430
+ activeSpringEmitsSettle = emitSettle
431
+ activeSpringTargetIndex = index
432
+
433
+ activeSpring = CriticalSpring(
434
+ from: currentTy,
435
+ target: targetTy,
436
+ v0: v0,
437
+ omega: omega,
438
+ startTime: CACurrentMediaTime(),
439
+ duration: duration
440
+ )
441
+
447
442
  // Report the index change as soon as the snap is committed, not when it
448
443
  // finishes: `targetIndex` is already set, and a programmatic snap's start is
449
444
  // known to the caller. `onSettle` remains the signal for movement end.
450
445
  if emitIndexChange {
451
446
  eventDelegate?.bottomSheetHostingView(self, didChangeIndex: index)
452
447
  }
453
- animator.startAnimation()
454
- activeAnimator = animator
455
448
  startDisplayLink()
456
449
  }
457
450
 
451
+ private func stepSpring(targetTime: CFTimeInterval) {
452
+ guard let spring = activeSpring else { return }
453
+ if spring.isFinished(at: targetTime) {
454
+ finishSpring()
455
+ return
456
+ }
457
+ let ty = spring.value(at: targetTime)
458
+ sheetContainer.transform = CGAffineTransform(translationX: 0, y: ty)
459
+ emitPosition()
460
+ }
461
+
462
+ private func finishSpring() {
463
+ let index = activeSpringTargetIndex
464
+ let emitSettle = activeSpringEmitsSettle
465
+ let targetTy = translationY(for: index)
466
+
467
+ activeSpring = nil
468
+ activeSpringEmitsSettle = false
469
+ stopDisplayLink()
470
+
471
+ sheetContainer.transform = CGAffineTransform(translationX: 0, y: targetTy)
472
+ emitPosition()
473
+ scrimPinnedFull = false
474
+ setContentInteractionEnabled(true)
475
+ updateInteractionState()
476
+ if emitSettle {
477
+ eventDelegate?.bottomSheetHostingView(self, didSettle: index)
478
+ }
479
+ }
480
+
458
481
  @objc private func handlePan(_ gesture: UIPanGestureRecognizer) {
459
482
  let maxHeight = sheetContainerHeight
460
483
 
@@ -470,12 +493,10 @@ public final class BottomSheetHostingView: UIView {
470
493
  handler.isEnabled = true
471
494
  }
472
495
  gesture.setTranslation(.zero, in: self)
473
- if let animator = activeAnimator {
496
+ if activeSpring != nil {
474
497
  stopDisplayLink()
475
- let visual = sheetContainer.layer.presentation()?.affineTransform() ?? sheetContainer.transform
476
- animator.stopAnimation(true)
477
- sheetContainer.transform = visual
478
- activeAnimator = nil
498
+ activeSpring = nil
499
+ activeSpringEmitsSettle = false
479
500
  }
480
501
 
481
502
  case .changed:
@@ -693,13 +714,13 @@ public final class BottomSheetHostingView: UIView {
693
714
  let newMaxHeight = sheetContainerHeight
694
715
  let targetTy = translationY(for: targetIndex)
695
716
 
696
- if let animator = activeAnimator {
717
+ if activeSpring != nil {
697
718
  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
719
+ // `transform.ty` is the live on-screen value (set each frame).
720
+ let visualTy = sheetContainer.transform.ty
721
+ let shouldEmitSettle = activeSpringEmitsSettle
722
+ activeSpring = nil
723
+ activeSpringEmitsSettle = false
703
724
  // Re-anchor the in-flight position to the new container height so the
704
725
  // sheet surface keeps the same on-screen height across the resize.
705
726
  let visibleHeight = previousMaxHeight - visualTy
@@ -840,9 +861,10 @@ extension BottomSheetHostingView: UIGestureRecognizerDelegate {
840
861
 
841
862
  private extension BottomSheetHostingView {
842
863
  var currentTranslationY: CGFloat {
843
- if activeAnimator != nil, let presentation = sheetContainer.layer.presentation() {
844
- return presentation.affineTransform().ty
845
- }
864
+ // Both the drag and the settle assign `transform` directly every frame, so
865
+ // it always holds the live on-screen value. (If the settle were run by a
866
+ // UIViewPropertyAnimator instead, the in-flight value would live on the
867
+ // render server and we'd have to read `layer.presentation()` here.)
846
868
  return sheetContainer.transform.ty
847
869
  }
848
870
 
@@ -868,7 +890,7 @@ private extension BottomSheetHostingView {
868
890
  if
869
891
  let closedIndex,
870
892
  targetIndex == closedIndex,
871
- activeAnimator == nil,
893
+ activeSpring == nil,
872
894
  !isPanning
873
895
  {
874
896
  scrimView.alpha = 0
@@ -0,0 +1,39 @@
1
+ import QuartzCore
2
+
3
+ /// A spring we evaluate ourselves each frame instead of letting
4
+ /// `UIViewPropertyAnimator` run the settle. The animator runs on the "render
5
+ /// server", so its value is only readable one frame late via `presentation()` —
6
+ /// the cause of the follower view (fed by `onPositionChange`) trailing the
7
+ /// modal. Computing it in-process lets us emit the exact value we set.
8
+ /// Critically damped (ζ = 1): reaches the target as fast as possible without
9
+ /// overshooting.
10
+ struct CriticalSpring {
11
+ let from: CGFloat
12
+ let target: CGFloat
13
+ /// Initial velocity (points/sec) — e.g. carried over from a finger flick.
14
+ let v0: CGFloat
15
+ /// Angular frequency (rad/sec) — the spring's stiffness/speed. Higher ω snaps faster;
16
+ let omega: CGFloat
17
+ let startTime: CFTimeInterval
18
+ let duration: CFTimeInterval
19
+
20
+ /// Position at an absolute `time`, from the closed-form solution of a
21
+ /// critically-damped spring (ζ = 1):
22
+ /// x(t) = target + e^(−ω·t)·[A + (v0 + ω·A)·t]
23
+ /// where A is the starting offset from the target. The `e^(−ω·t)` term decays
24
+ /// the offset toward 0 (so x → target), and the linear `…·t` factor is what
25
+ /// lets a critically-damped spring carry initial velocity without oscillating.
26
+ func value(at time: CFTimeInterval) -> CGFloat {
27
+ // Seconds since the spring started (clamped so a past `time` reads as t = 0).
28
+ let t = CGFloat(max(0, time - startTime))
29
+ // A: how far `from` is from `target` — the offset the spring must close.
30
+ let a = from - target
31
+ // Exponential envelope: 1 at t = 0, shrinking toward 0 as time passes.
32
+ let decay = exp(-omega * t)
33
+ return target + decay * (a + (v0 + omega * a) * t)
34
+ }
35
+
36
+ func isFinished(at time: CFTimeInterval) -> Bool {
37
+ time - startTime >= duration
38
+ }
39
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swmansion/react-native-bottom-sheet",
3
- "version": "0.15.0-next.1",
3
+ "version": "0.15.0-next.2",
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",