@swmansion/react-native-bottom-sheet 0.15.0-next.2 → 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.
@@ -82,6 +82,7 @@ public final class BottomSheetHostingView: UIView {
82
82
  private var contentHeightMarker: UIView?
83
83
  private weak var surfaceView: UIView?
84
84
  private static var markerObservationContext = 0
85
+ private static let springAnimationKey = "bottomSheetSettle"
85
86
 
86
87
  override public init(frame: CGRect) {
87
88
  super.init(frame: frame)
@@ -187,8 +188,15 @@ public final class BottomSheetHostingView: UIView {
187
188
  updateScrim()
188
189
  }
189
190
 
191
+ private var presentedSheetFrame: CGRect {
192
+ if activeSpring != nil, let presentation = sheetContainer.layer.presentation() {
193
+ return presentation.frame
194
+ }
195
+ return sheetContainer.frame
196
+ }
197
+
190
198
  override public func point(inside point: CGPoint, with _: UIEvent?) -> Bool {
191
- if sheetContainer.frame.contains(point) {
199
+ if presentedSheetFrame.contains(point) {
192
200
  return true
193
201
  }
194
202
 
@@ -198,7 +206,7 @@ public final class BottomSheetHostingView: UIView {
198
206
  override public func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
199
207
  guard self.point(inside: point, with: event) else { return nil }
200
208
 
201
- if isScrimVisible, !sheetContainer.frame.contains(point) {
209
+ if isScrimVisible, !presentedSheetFrame.contains(point) {
202
210
  let scrimPoint = convert(point, to: scrimView)
203
211
  return scrimView.hitTest(scrimPoint, with: event)
204
212
  }
@@ -266,6 +274,7 @@ public final class BottomSheetHostingView: UIView {
266
274
  activeSpring = nil
267
275
  activeSpringEmitsSettle = false
268
276
  stopDisplayLink()
277
+ sheetContainer.layer.removeAnimation(forKey: Self.springAnimationKey)
269
278
  rawDetentSpecs = []
270
279
  detentSpecs = []
271
280
  targetIndex = 0
@@ -345,9 +354,11 @@ public final class BottomSheetHostingView: UIView {
345
354
  modal && !scrimView.isHidden
346
355
  }
347
356
 
348
- 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) {
349
360
  let maxHeight = sheetContainerHeight
350
- let ty = currentTranslationY
361
+ let ty = overrideTy ?? currentTranslationY
351
362
  let position = maxHeight - ty
352
363
  updateScrim(forPosition: position)
353
364
  updateSheetVisibility(forPosition: position)
@@ -380,7 +391,11 @@ public final class BottomSheetHostingView: UIView {
380
391
  }
381
392
 
382
393
  @objc private func displayLinkFired(_ link: CADisplayLink) {
383
- // `targetTimestamp` is predicted when the NEXT frame will be shown
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.
384
399
  stepSpring(targetTime: link.targetTimestamp)
385
400
  }
386
401
 
@@ -410,7 +425,12 @@ public final class BottomSheetHostingView: UIView {
410
425
  scrimPinnedFull = false
411
426
  }
412
427
 
413
- let currentTy = sheetContainer.transform.ty
428
+ let currentTy: CGFloat
429
+ if activeSpring != nil {
430
+ currentTy = cancelActiveSpring()
431
+ } else {
432
+ currentTy = sheetContainer.transform.ty
433
+ }
414
434
  let targetTy = translationY(for: index)
415
435
  let distance = targetTy - currentTy
416
436
 
@@ -421,45 +441,58 @@ public final class BottomSheetHostingView: UIView {
421
441
  let duration: CFTimeInterval = 0.45
422
442
  // Pick the stiffness so the sheet looks settled (within ~0.5% of target)
423
443
  // 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
444
+ // ω·t ≈ 8, so ω = 8 / duration.
429
445
  let omega = 8.0 / CGFloat(duration)
430
446
  activeSpringEmitsSettle = emitSettle
431
447
  activeSpringTargetIndex = index
432
448
 
433
- activeSpring = CriticalSpring(
449
+ // The single instant both the modal animation and the follower curve are anchored to.
450
+ let startTime = CACurrentMediaTime()
451
+ let spring = CriticalSpring(
434
452
  from: currentTy,
435
453
  target: targetTy,
436
454
  v0: v0,
437
455
  omega: omega,
438
- startTime: CACurrentMediaTime(),
456
+ startTime: startTime,
439
457
  duration: duration
440
458
  )
441
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
+
442
481
  // Report the index change as soon as the snap is committed, not when it
443
482
  // finishes: `targetIndex` is already set, and a programmatic snap's start is
444
483
  // known to the caller. `onSettle` remains the signal for movement end.
445
484
  if emitIndexChange {
446
485
  eventDelegate?.bottomSheetHostingView(self, didChangeIndex: index)
447
486
  }
448
- startDisplayLink()
449
487
  }
450
488
 
451
489
  private func stepSpring(targetTime: CFTimeInterval) {
452
490
  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()
491
+ emitPosition(overrideTy: spring.value(at: targetTime))
460
492
  }
461
493
 
462
494
  private func finishSpring() {
495
+ guard activeSpring != nil else { return }
463
496
  let index = activeSpringTargetIndex
464
497
  let emitSettle = activeSpringEmitsSettle
465
498
  let targetTy = translationY(for: index)
@@ -467,6 +500,7 @@ public final class BottomSheetHostingView: UIView {
467
500
  activeSpring = nil
468
501
  activeSpringEmitsSettle = false
469
502
  stopDisplayLink()
503
+ sheetContainer.layer.removeAnimation(forKey: Self.springAnimationKey)
470
504
 
471
505
  sheetContainer.transform = CGAffineTransform(translationX: 0, y: targetTy)
472
506
  emitPosition()
@@ -478,6 +512,17 @@ public final class BottomSheetHostingView: UIView {
478
512
  }
479
513
  }
480
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
524
+ }
525
+
481
526
  @objc private func handlePan(_ gesture: UIPanGestureRecognizer) {
482
527
  let maxHeight = sheetContainerHeight
483
528
 
@@ -494,9 +539,7 @@ public final class BottomSheetHostingView: UIView {
494
539
  }
495
540
  gesture.setTranslation(.zero, in: self)
496
541
  if activeSpring != nil {
497
- stopDisplayLink()
498
- activeSpring = nil
499
- activeSpringEmitsSettle = false
542
+ cancelActiveSpring()
500
543
  }
501
544
 
502
545
  case .changed:
@@ -715,12 +758,8 @@ public final class BottomSheetHostingView: UIView {
715
758
  let targetTy = translationY(for: targetIndex)
716
759
 
717
760
  if activeSpring != nil {
718
- stopDisplayLink()
719
- // `transform.ty` is the live on-screen value (set each frame).
720
- let visualTy = sheetContainer.transform.ty
721
761
  let shouldEmitSettle = activeSpringEmitsSettle
722
- activeSpring = nil
723
- activeSpringEmitsSettle = false
762
+ let visualTy = cancelActiveSpring()
724
763
  // Re-anchor the in-flight position to the new container height so the
725
764
  // sheet surface keeps the same on-screen height across the resize.
726
765
  let visibleHeight = previousMaxHeight - visualTy
@@ -807,6 +846,8 @@ public final class BottomSheetHostingView: UIView {
807
846
 
808
847
  deinit {
809
848
  stopObservingContentHeightMarker()
849
+ displayLink?.invalidate()
850
+ sheetContainer.layer.removeAnimation(forKey: Self.springAnimationKey)
810
851
  }
811
852
 
812
853
  private func findContentHeightMarker() -> UIView? {
@@ -842,6 +883,13 @@ public final class BottomSheetHostingView: UIView {
842
883
  }
843
884
  }
844
885
 
886
+ extension BottomSheetHostingView: CAAnimationDelegate {
887
+ public func animationDidStop(_: CAAnimation, finished: Bool) {
888
+ guard finished, activeSpring != nil else { return }
889
+ finishSpring()
890
+ }
891
+ }
892
+
845
893
  extension BottomSheetHostingView: UIGestureRecognizerDelegate {
846
894
  public func gestureRecognizer(
847
895
  _ gestureRecognizer: UIGestureRecognizer,
@@ -861,10 +909,13 @@ extension BottomSheetHostingView: UIGestureRecognizerDelegate {
861
909
 
862
910
  private extension BottomSheetHostingView {
863
911
  var currentTranslationY: CGFloat {
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.)
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() {
917
+ return presentation.affineTransform().ty
918
+ }
868
919
  return sheetContainer.transform.ty
869
920
  }
870
921
 
@@ -1,19 +1,16 @@
1
1
  import QuartzCore
2
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.
3
+ /// Critically damped = 1): reaches the target as fast as possible without overshooting.
10
4
  struct CriticalSpring {
11
5
  let from: CGFloat
12
6
  let target: CGFloat
13
7
  /// Initial velocity (points/sec) — e.g. carried over from a finger flick.
14
8
  let v0: CGFloat
15
- /// Angular frequency (rad/sec) — the spring's stiffness/speed. Higher ω snaps faster;
9
+ /// Angular frequency (rad/sec) — the spring's stiffness/speed. Higher ω snaps faster.
16
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.
17
14
  let startTime: CFTimeInterval
18
15
  let duration: CFTimeInterval
19
16
 
@@ -33,7 +30,15 @@ struct CriticalSpring {
33
30
  return target + decay * (a + (v0 + omega * a) * t)
34
31
  }
35
32
 
36
- func isFinished(at time: CFTimeInterval) -> Bool {
37
- time - startTime >= duration
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
+ }
38
43
  }
39
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.2",
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",