@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.
- package/ios/BottomSheetHostingView.swift +128 -55
- package/ios/CriticalSpring.swift +44 -0
- package/package.json +1 -1
|
@@ -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