@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.
- package/ios/BottomSheetHostingView.swift +81 -59
- package/ios/CriticalSpring.swift +39 -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?
|
|
@@ -181,20 +182,13 @@ public final class BottomSheetHostingView: UIView {
|
|
|
181
182
|
return
|
|
182
183
|
}
|
|
183
184
|
|
|
184
|
-
if
|
|
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
|
|
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, !
|
|
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
|
-
|
|
273
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
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
|
|
496
|
+
if activeSpring != nil {
|
|
474
497
|
stopDisplayLink()
|
|
475
|
-
|
|
476
|
-
|
|
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
|
|
717
|
+
if activeSpring != nil {
|
|
697
718
|
stopDisplayLink()
|
|
698
|
-
|
|
699
|
-
let
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
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
|
-
|
|
844
|
-
|
|
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
|
-
|
|
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