@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.
- package/ios/BottomSheetHostingView.swift +84 -33
- package/ios/CriticalSpring.swift +15 -10
- package/package.json +1 -1
|
@@ -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
|
|
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, !
|
|
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
|
-
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
865
|
-
//
|
|
866
|
-
//
|
|
867
|
-
//
|
|
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
|
|
package/ios/CriticalSpring.swift
CHANGED
|
@@ -1,19 +1,16 @@
|
|
|
1
1
|
import QuartzCore
|
|
2
2
|
|
|
3
|
-
///
|
|
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
|
-
|
|
37
|
-
|
|
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