react-native-nitro-modal 0.2.0 → 0.3.0-rc

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.
Files changed (83) hide show
  1. package/README.md +48 -4
  2. package/android/src/main/java/com/margelo/nitro/nitromodal/BottomSheetPresenter.kt +335 -34
  3. package/android/src/main/java/com/margelo/nitro/nitromodal/HybridNitroModal.kt +63 -22
  4. package/android/src/main/java/com/margelo/nitro/nitromodal/ModalConfig.kt +31 -1
  5. package/android/src/main/java/com/margelo/nitro/nitromodal/ModalContentRoot.kt +48 -5
  6. package/android/src/main/java/com/margelo/nitro/nitromodal/ModalPresenter.kt +63 -4
  7. package/android/src/main/java/com/margelo/nitro/nitromodal/NitroModalHostView.kt +68 -8
  8. package/ios/HybridNitroModal.swift +111 -20
  9. package/ios/ModalConfig.swift +44 -2
  10. package/ios/NitroModalHostView.swift +73 -8
  11. package/ios/SheetView.swift +575 -0
  12. package/ios/SheetViewController.swift +19 -450
  13. package/lib/module/NitroModal.js +38 -5
  14. package/lib/module/NitroModal.js.map +1 -1
  15. package/lib/module/NitroModalView.web.js +55 -25
  16. package/lib/module/NitroModalView.web.js.map +1 -1
  17. package/lib/module/index.js.map +1 -1
  18. package/lib/module/web/AnimatedValue.js +3 -0
  19. package/lib/module/web/AnimatedValue.js.map +1 -1
  20. package/lib/module/web/ModalConfig.js +8 -4
  21. package/lib/module/web/ModalConfig.js.map +1 -1
  22. package/lib/module/web/ModalController.js +29 -2
  23. package/lib/module/web/ModalController.js.map +1 -1
  24. package/lib/module/web/ModalPresenter.js +47 -12
  25. package/lib/module/web/ModalPresenter.js.map +1 -1
  26. package/lib/module/web/SheetPresenter.js +54 -29
  27. package/lib/module/web/SheetPresenter.js.map +1 -1
  28. package/lib/module/web/dom.js +13 -0
  29. package/lib/module/web/dom.js.map +1 -1
  30. package/lib/typescript/src/NitroModal.d.ts +33 -2
  31. package/lib/typescript/src/NitroModal.d.ts.map +1 -1
  32. package/lib/typescript/src/NitroModal.nitro.d.ts +29 -2
  33. package/lib/typescript/src/NitroModal.nitro.d.ts.map +1 -1
  34. package/lib/typescript/src/NitroModalView.web.d.ts.map +1 -1
  35. package/lib/typescript/src/index.d.ts +2 -2
  36. package/lib/typescript/src/index.d.ts.map +1 -1
  37. package/lib/typescript/src/web/AnimatedValue.d.ts +1 -0
  38. package/lib/typescript/src/web/AnimatedValue.d.ts.map +1 -1
  39. package/lib/typescript/src/web/ModalConfig.d.ts +6 -0
  40. package/lib/typescript/src/web/ModalConfig.d.ts.map +1 -1
  41. package/lib/typescript/src/web/ModalController.d.ts +5 -0
  42. package/lib/typescript/src/web/ModalController.d.ts.map +1 -1
  43. package/lib/typescript/src/web/ModalPresenter.d.ts +9 -0
  44. package/lib/typescript/src/web/ModalPresenter.d.ts.map +1 -1
  45. package/lib/typescript/src/web/SheetPresenter.d.ts +1 -1
  46. package/lib/typescript/src/web/SheetPresenter.d.ts.map +1 -1
  47. package/lib/typescript/src/web/dom.d.ts +2 -0
  48. package/lib/typescript/src/web/dom.d.ts.map +1 -1
  49. package/nitrogen/generated/android/c++/JHybridNitroModalSpec.cpp +60 -6
  50. package/nitrogen/generated/android/c++/JHybridNitroModalSpec.hpp +10 -2
  51. package/nitrogen/generated/android/c++/JNamedSheetDetent.hpp +64 -0
  52. package/nitrogen/generated/android/c++/JSheetDetent.cpp +26 -0
  53. package/nitrogen/generated/android/c++/JSheetDetent.hpp +42 -37
  54. package/nitrogen/generated/android/c++/views/JHybridNitroModalStateUpdater.cpp +20 -0
  55. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitromodal/HybridNitroModalSpec.kt +40 -0
  56. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitromodal/NamedSheetDetent.kt +25 -0
  57. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitromodal/SheetDetent.kt +47 -10
  58. package/nitrogen/generated/android/nitromodal+autolinking.cmake +1 -0
  59. package/nitrogen/generated/ios/NitroModal-Swift-Cxx-Bridge.hpp +53 -8
  60. package/nitrogen/generated/ios/NitroModal-Swift-Cxx-Umbrella.hpp +4 -3
  61. package/nitrogen/generated/ios/c++/HybridNitroModalSpecSwift.hpp +32 -5
  62. package/nitrogen/generated/ios/c++/views/HybridNitroModalComponent.mm +24 -0
  63. package/nitrogen/generated/ios/swift/HybridNitroModalSpec.swift +4 -0
  64. package/nitrogen/generated/ios/swift/HybridNitroModalSpec_cxx.swift +110 -5
  65. package/nitrogen/generated/ios/swift/NamedSheetDetent.swift +48 -0
  66. package/nitrogen/generated/ios/swift/SheetDetent.swift +15 -33
  67. package/nitrogen/generated/shared/c++/HybridNitroModalSpec.cpp +8 -0
  68. package/nitrogen/generated/shared/c++/HybridNitroModalSpec.hpp +14 -5
  69. package/nitrogen/generated/shared/c++/{SheetDetent.hpp → NamedSheetDetent.hpp} +17 -17
  70. package/nitrogen/generated/shared/c++/views/HybridNitroModalComponent.cpp +9 -1
  71. package/nitrogen/generated/shared/c++/views/HybridNitroModalComponent.hpp +15 -2
  72. package/nitrogen/generated/shared/json/NitroModalConfig.json +4 -0
  73. package/package.json +1 -1
  74. package/src/NitroModal.nitro.ts +30 -2
  75. package/src/NitroModal.tsx +68 -4
  76. package/src/NitroModalView.web.tsx +54 -12
  77. package/src/index.tsx +2 -0
  78. package/src/web/AnimatedValue.ts +4 -0
  79. package/src/web/ModalConfig.ts +13 -3
  80. package/src/web/ModalController.ts +35 -2
  81. package/src/web/ModalPresenter.ts +46 -11
  82. package/src/web/SheetPresenter.ts +98 -33
  83. package/src/web/dom.ts +17 -0
@@ -0,0 +1,575 @@
1
+ //
2
+ // SheetView.swift
3
+ // NitroModal
4
+ //
5
+
6
+ import UIKit
7
+
8
+ /// The `bottomSheet` surface: detents, dragging, swipe-to-dismiss, the
9
+ /// hand-off to scroll views inside the content, pull-to-refresh and keyboard
10
+ /// tracking, implemented with a pan gesture.
11
+ ///
12
+ /// A modal sheet fills the screen as the view of `SheetViewController`. An
13
+ /// inline sheet fills `NitroModalHostView`, in the screen's own layout: it has
14
+ /// no backdrop, lets touches outside the sheet reach the views behind it,
15
+ /// ignores the keyboard and can't be dismissed by the user.
16
+ final class SheetView: UIView, UIGestureRecognizerDelegate {
17
+ weak var delegate: ModalPresentationDelegate?
18
+ /// Whether a modal present/dismiss transition is running; the sheet can't be
19
+ /// dragged meanwhile.
20
+ var isTransitioning: () -> Bool = { false }
21
+
22
+ /// Gap kept above a modal sheet at its tallest, like a system page sheet.
23
+ private static let topGap: CGFloat = 10
24
+ /// Extra sheet height below the screen so it stays attached to the bottom
25
+ /// edge while rubber-banding upward.
26
+ private static let overscroll: CGFloat = 400
27
+ private static let defaultCornerRadius: CGFloat = 16
28
+ private static let regularMaxWidth: CGFloat = 704
29
+ private static let grabberSize = CGSize(width: 36, height: 5)
30
+ /// How far below its lowest detent the sheet must be pulled to refresh.
31
+ private static let refreshThreshold: CGFloat = 56
32
+ private static let touchHandlerClass: AnyClass? = NSClassFromString("RCTSurfaceTouchHandler")
33
+
34
+ let isInline: Bool
35
+ private let contentView: NitroModalContentView
36
+ private let backdrop = BackdropView()
37
+ private let sheetView = UIView()
38
+ private let grabber = UIView()
39
+ private var config: ModalConfig
40
+ private var selectedIndex: Int
41
+ private var keyboardHeight: CGFloat = 0
42
+
43
+ /// The sheet sits below the bottom edge (before presenting / while dismissing).
44
+ private var isOffscreen = true
45
+ /// Top of the sheet while the user drags it; `nil` rests on `selectedIndex`.
46
+ private var dragTop: CGFloat?
47
+ private var drag: DragState?
48
+ /// Vertical velocity of the swipe that requested the dismissal.
49
+ private var releaseVelocity: CGFloat = 0
50
+ private var reportedRestingTop: CGFloat?
51
+ private var refreshFeedback: UIImpactFeedbackGenerator?
52
+
53
+ private var lockedScrollView: UIScrollView?
54
+ private var lockedOffsetY: CGFloat = 0
55
+ private var scrollLock: NSKeyValueObservation?
56
+
57
+ private struct DragState {
58
+ /// Unconstrained sheet top, before rubber-banding.
59
+ var rawTop: CGFloat
60
+ var lastTranslation: CGFloat = 0
61
+ var scrollView: UIScrollView?
62
+ /// Whether the last movement moved the sheet rather than the scroll view.
63
+ var sheetDrove = false
64
+ /// The scroll view moved during this gesture. It then keeps the downward
65
+ /// part too: scrolled content goes back to its top, and only a new
66
+ /// gesture moves the sheet.
67
+ var contentScrolled = false
68
+ /// Started with the sheet resting on its lowest detent, so pulling it
69
+ /// further down refreshes.
70
+ var canRefresh = false
71
+ /// Pulled far enough that letting go refreshes.
72
+ var refreshArmed = false
73
+ var cancelledReactTouches = false
74
+ }
75
+
76
+ init(contentView: NitroModalContentView, config: ModalConfig, isInline: Bool) {
77
+ self.contentView = contentView
78
+ self.config = config
79
+ self.isInline = isInline
80
+ selectedIndex = config.clampedInitialDetentIndex
81
+ super.init(frame: .zero)
82
+ backgroundColor = .clear
83
+
84
+ if isInline {
85
+ // The sheet travels below the host's bottom edge; nothing may show there.
86
+ clipsToBounds = true
87
+ } else {
88
+ backdrop.onTap = { [weak self] in
89
+ self?.delegate?.modalPresentationDidTapBackdrop()
90
+ }
91
+ addSubview(backdrop)
92
+ let center = NotificationCenter.default
93
+ center.addObserver(self, selector: #selector(keyboardWillChangeFrame(_:)), name: UIResponder.keyboardWillChangeFrameNotification, object: nil)
94
+ center.addObserver(self, selector: #selector(keyboardWillHide(_:)), name: UIResponder.keyboardWillHideNotification, object: nil)
95
+ }
96
+
97
+ sheetView.clipsToBounds = true
98
+ sheetView.layer.cornerCurve = .continuous
99
+ sheetView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
100
+ sheetView.accessibilityViewIsModal = !isInline
101
+ sheetView.addSubview(contentView)
102
+
103
+ grabber.isUserInteractionEnabled = false
104
+ grabber.backgroundColor = .tertiaryLabel
105
+ grabber.layer.cornerRadius = Self.grabberSize.height / 2
106
+ sheetView.addSubview(grabber)
107
+ addSubview(sheetView)
108
+
109
+ let pan = UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:)))
110
+ pan.delegate = self
111
+ sheetView.addGestureRecognizer(pan)
112
+
113
+ applyAppearance()
114
+ }
115
+
116
+ @available(*, unavailable)
117
+ required init?(coder: NSCoder) {
118
+ fatalError("init(coder:) is not supported")
119
+ }
120
+
121
+ override func layoutSubviews() {
122
+ super.layoutSubviews()
123
+ layoutSheet()
124
+ reportContentArea()
125
+ }
126
+
127
+ override func safeAreaInsetsDidChange() {
128
+ super.safeAreaInsetsDidChange()
129
+ setNeedsLayout()
130
+ }
131
+
132
+ override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
133
+ let hit = super.hitTest(point, with: event)
134
+ // Inline, only the sheet takes touches; the rest reach the screen behind.
135
+ return isInline && hit === self ? nil : hit
136
+ }
137
+
138
+ // MARK: Presentation
139
+
140
+ func apply(_ newConfig: ModalConfig) {
141
+ config = newConfig
142
+ selectedIndex = min(selectedIndex, max(detents.count - 1, 0))
143
+ applyAppearance()
144
+ animateToRest()
145
+ }
146
+
147
+ func contentSizeDidChange() {
148
+ guard config.detents.contains(.fitContent) else { return }
149
+ animateToRest()
150
+ }
151
+
152
+ func snapToDetent(at index: Int) {
153
+ guard detents.indices.contains(index) else { return }
154
+ selectedIndex = index
155
+ animateToRest()
156
+ delegate?.modalPresentationDidChangeDetent(index)
157
+ }
158
+
159
+ /// Escape gesture (VoiceOver), modal sheets only.
160
+ func performEscape() -> Bool {
161
+ guard !isInline, config.dismissOnSwipe else { return false }
162
+ delegate?.modalPresentationDidRequestSwipeDismiss()
163
+ return true
164
+ }
165
+
166
+ /// Slides the sheet in or out; a swipe dismissal keeps its release velocity.
167
+ func animateTransition(toOffscreen offscreen: Bool, animated: Bool, duration: TimeInterval, completion: @escaping () -> Void) {
168
+ let from = sheetView.frame.minY
169
+ let velocity = offscreen ? releaseVelocity : 0
170
+ releaseVelocity = 0
171
+ isOffscreen = offscreen
172
+ dragTop = nil
173
+ guard animated else {
174
+ layoutSheet()
175
+ completion()
176
+ return
177
+ }
178
+ animate(
179
+ distance: currentTop - from,
180
+ velocity: velocity,
181
+ damping: offscreen ? 1 : 0.88,
182
+ duration: duration,
183
+ animations: { self.layoutSheet() },
184
+ completion: completion
185
+ )
186
+ }
187
+
188
+ /// Releases the scroll view pinned by a drag.
189
+ func unlockScroll() {
190
+ guard let scrollView = lockedScrollView else { return }
191
+ scrollLock?.invalidate()
192
+ scrollLock = nil
193
+ lockedScrollView = nil
194
+ // Stops any deceleration left over from a fling that moved the sheet.
195
+ scrollView.setContentOffset(scrollView.contentOffset, animated: false)
196
+ }
197
+
198
+ // MARK: Layout
199
+
200
+ private var detents: [Detent] {
201
+ config.detents.isEmpty ? [.fitContent] : config.detents
202
+ }
203
+
204
+ /// Tallest height the content may occupy (excludes the bottom safe area).
205
+ private var maximumDetentValue: CGFloat {
206
+ Self.maximumDetentValue(height: bounds.height, insets: safeAreaInsets, isInline: isInline)
207
+ }
208
+
209
+ private var keyboardLift: CGFloat {
210
+ isInline || config.keyboardBehavior == .none ? 0 : max(0, keyboardHeight - safeAreaInsets.bottom)
211
+ }
212
+
213
+ private var sheetWidth: CGFloat {
214
+ isInline ? bounds.width : Self.width(for: bounds.width, sizeClass: traitCollection.horizontalSizeClass)
215
+ }
216
+
217
+ /// Sheet top when resting on `detents[index]`. The bottom safe area is
218
+ /// added below the content.
219
+ private func restingTop(for index: Int) -> CGFloat {
220
+ let detent = detents[min(max(index, 0), detents.count - 1)]
221
+ let height = detent.height(maximum: maximumDetentValue, contentHeight: contentView.contentSize.height)
222
+ let top = bounds.height - safeAreaInsets.bottom - keyboardLift - height
223
+ return max(top, safeAreaInsets.top + (isInline ? 0 : Self.topGap))
224
+ }
225
+
226
+ private var detentTops: [CGFloat] {
227
+ detents.indices.map(restingTop(for:))
228
+ }
229
+
230
+ private var currentTop: CGFloat {
231
+ if isOffscreen { return bounds.height }
232
+ return dragTop ?? restingTop(for: selectedIndex)
233
+ }
234
+
235
+ private func layoutSheet() {
236
+ let width = sheetWidth
237
+ let top = currentTop
238
+ backdrop.frame = bounds
239
+ sheetView.frame = CGRect(x: (bounds.width - width) / 2, y: top, width: width, height: bounds.height + Self.overscroll)
240
+ contentView.frame = CGRect(x: 0, y: 0, width: width, height: maximumDetentValue)
241
+ grabber.frame = CGRect(
242
+ origin: CGPoint(x: (width - Self.grabberSize.width) / 2, y: 5),
243
+ size: Self.grabberSize
244
+ )
245
+ backdrop.alpha = backdropAlpha(forTop: top)
246
+ reportRestingTop()
247
+ }
248
+
249
+ /// Fully dimmed at every detent; fades out below the lowest one.
250
+ private func backdropAlpha(forTop top: CGFloat) -> CGFloat {
251
+ let bottom = bounds.height
252
+ guard let lowest = detentTops.max(), bottom > lowest else { return top >= bottom ? 0 : 1 }
253
+ return min(max((bottom - top) / (bottom - lowest), 0), 1)
254
+ }
255
+
256
+ private func applyAppearance() {
257
+ backdrop.isHidden = isInline
258
+ backdrop.configure(color: config.backdropColor, opacity: config.backdropOpacity, blurRadius: config.backdropBlurRadius)
259
+ sheetView.backgroundColor = config.contentBackgroundColor
260
+ sheetView.layer.cornerRadius = config.cornerRadius ?? Self.defaultCornerRadius
261
+ grabber.isHidden = !config.grabberVisible
262
+ }
263
+
264
+ private func animateToRest() {
265
+ guard drag == nil, !isOffscreen else { return }
266
+ dragTop = nil
267
+ UIView.animate(withDuration: 0.3, delay: 0, options: [.beginFromCurrentState, .allowUserInteraction]) {
268
+ self.setNeedsLayout()
269
+ self.layoutIfNeeded()
270
+ }
271
+ }
272
+
273
+ private func animate(
274
+ distance: CGFloat,
275
+ velocity: CGFloat,
276
+ damping: CGFloat = 0.9,
277
+ duration: TimeInterval = 0.45,
278
+ animations: @escaping () -> Void,
279
+ completion: (() -> Void)? = nil
280
+ ) {
281
+ // UIKit expects the initial velocity relative to the distance travelled.
282
+ let relativeVelocity = abs(distance) > 1 ? min(max(velocity / distance, -30), 30) : 0
283
+ UIView.animate(
284
+ withDuration: duration,
285
+ delay: 0,
286
+ usingSpringWithDamping: damping,
287
+ initialSpringVelocity: relativeVelocity,
288
+ options: [.allowUserInteraction, .beginFromCurrentState],
289
+ animations: animations,
290
+ completion: { _ in completion?() }
291
+ )
292
+ }
293
+
294
+ // MARK: Content area
295
+
296
+ private func reportContentArea() {
297
+ guard bounds.width > 0 else { return }
298
+ var height = Self.contentHeight(for: config.detents, maximum: maximumDetentValue)
299
+ if config.keyboardBehavior == .resize {
300
+ height -= keyboardLift
301
+ }
302
+ delegate?.modalPresentationDidChangeContentArea(CGSize(width: sheetWidth, height: max(height, 0)))
303
+ }
304
+
305
+ /// Inline: React lays the content out at the sheet's resting top, so what
306
+ /// `measure()` reports (and presses rely on) matches the screen. Reported
307
+ /// as soon as the target detent is known, before the sheet settles.
308
+ private func reportRestingTop() {
309
+ guard isInline, bounds.height > 0 else { return }
310
+ let top = restingTop(for: selectedIndex).rounded()
311
+ guard top != reportedRestingTop else { return }
312
+ reportedRestingTop = top
313
+ delegate?.modalPresentationDidChangeRestingTop(top)
314
+ }
315
+
316
+ /// Height the content may occupy at the largest of `detents`.
317
+ static func contentHeight(for detents: [Detent], maximum: CGFloat) -> CGFloat {
318
+ guard !detents.isEmpty else { return maximum }
319
+ // `fitContent` content can grow up to the maximum.
320
+ return detents.map { $0.height(maximum: maximum, contentHeight: maximum) }.max() ?? maximum
321
+ }
322
+
323
+ /// Tallest height the content may occupy in an area `height` tall.
324
+ static func maximumDetentValue(height: CGFloat, insets: UIEdgeInsets, isInline: Bool) -> CGFloat {
325
+ max(0, height - insets.top - insets.bottom - (isInline ? 0 : topGap))
326
+ }
327
+
328
+ /// Best guess of the sheet width before a modal sheet is on screen.
329
+ static func estimatedWidth(in window: UIWindow) -> CGFloat {
330
+ width(for: window.bounds.width, sizeClass: window.traitCollection.horizontalSizeClass)
331
+ }
332
+
333
+ private static func width(for available: CGFloat, sizeClass: UIUserInterfaceSizeClass) -> CGFloat {
334
+ sizeClass == .regular ? min(available, regularMaxWidth) : available
335
+ }
336
+
337
+ // MARK: Dragging
338
+
339
+ override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
340
+ guard let pan = gestureRecognizer as? UIPanGestureRecognizer, pan.view === sheetView else {
341
+ return super.gestureRecognizerShouldBegin(gestureRecognizer)
342
+ }
343
+ guard !isTransitioning(), !isOffscreen else { return false }
344
+ let velocity = pan.velocity(in: self)
345
+ return abs(velocity.y) > abs(velocity.x)
346
+ }
347
+
348
+ /// Scroll views inside the content keep scrolling; `handlePan` decides
349
+ /// frame by frame whether the sheet or the scroll view moves.
350
+ func gestureRecognizer(
351
+ _ gestureRecognizer: UIGestureRecognizer,
352
+ shouldRecognizeSimultaneouslyWith other: UIGestureRecognizer
353
+ ) -> Bool {
354
+ guard let scrollView = other.view as? UIScrollView else { return false }
355
+ return other === scrollView.panGestureRecognizer && scrollView.isDescendant(of: sheetView)
356
+ }
357
+
358
+ @objc private func handlePan(_ pan: UIPanGestureRecognizer) {
359
+ switch pan.state {
360
+ case .began:
361
+ // Grab the sheet where it is, even mid-animation.
362
+ let isSettled = sheetView.layer.animationKeys()?.isEmpty ?? true
363
+ let top = sheetView.layer.presentation()?.frame.minY ?? sheetView.frame.minY
364
+ sheetView.layer.removeAllAnimations()
365
+ backdrop.layer.removeAllAnimations()
366
+ let translation = pan.translation(in: sheetView)
367
+ let location = pan.location(in: sheetView)
368
+ let start = CGPoint(x: location.x - translation.x, y: location.y - translation.y)
369
+ var state = DragState(rawTop: top)
370
+ state.lastTranslation = pan.translation(in: self).y
371
+ state.scrollView = verticalScrollView(at: start)
372
+ if let scrollView = state.scrollView {
373
+ lockedOffsetY = scrollView.contentOffset.y
374
+ }
375
+ if config.pullToRefreshEnabled, !config.dismissOnSwipe, isSettled,
376
+ let lowest = detentTops.max(), abs(top - lowest) < 1 {
377
+ state.canRefresh = true
378
+ refreshFeedback = UIImpactFeedbackGenerator(style: .medium)
379
+ refreshFeedback?.prepare()
380
+ }
381
+ drag = state
382
+ dragTop = top
383
+ layoutSheet()
384
+
385
+ case .changed:
386
+ guard var state = drag, let minTop = detentTops.min() else { return }
387
+ let translation = pan.translation(in: self).y
388
+ let delta = translation - state.lastTranslation
389
+ state.lastTranslation = translation
390
+
391
+ if let scrollView = state.scrollView {
392
+ // Up: expand the sheet before scrolling. Down: scroll back to the top
393
+ // before collapsing the sheet, in a separate gesture.
394
+ let drive = delta < 0
395
+ ? state.rawTop > minTop + 0.5
396
+ : !state.contentScrolled && scrollView.isScrolledToTop
397
+ if drive {
398
+ state.rawTop = max(state.rawTop + delta, minTop)
399
+ lockScroll(scrollView, at: delta < 0 ? lockedOffsetY : scrollView.topOffsetY)
400
+ } else {
401
+ unlockScroll()
402
+ lockedOffsetY = scrollView.contentOffset.y
403
+ if delta != 0 {
404
+ state.contentScrolled = true
405
+ }
406
+ }
407
+ state.sheetDrove = drive
408
+ } else {
409
+ state.rawTop += delta
410
+ state.sheetDrove = true
411
+ }
412
+ let top = constrained(state.rawTop)
413
+ if state.sheetDrove, isInline, !state.cancelledReactTouches {
414
+ state.cancelledReactTouches = true
415
+ cancelReactTouches()
416
+ }
417
+ if state.canRefresh, let lowest = detentTops.max() {
418
+ let armed = top - lowest >= Self.refreshThreshold
419
+ if armed, !state.refreshArmed {
420
+ refreshFeedback?.impactOccurred()
421
+ }
422
+ state.refreshArmed = armed
423
+ }
424
+ drag = state
425
+ dragTop = top
426
+ layoutSheet()
427
+
428
+ case .ended, .cancelled, .failed:
429
+ guard let state = drag else { return }
430
+ drag = nil
431
+ refreshFeedback = nil
432
+ let velocity = state.sheetDrove ? pan.velocity(in: self).y : 0
433
+ endDrag(at: dragTop ?? currentTop, velocity: velocity, holdingScroll: state.sheetDrove)
434
+ if state.refreshArmed, pan.state == .ended {
435
+ delegate?.modalPresentationDidPullToRefresh()
436
+ }
437
+
438
+ default:
439
+ break
440
+ }
441
+ }
442
+
443
+ private func endDrag(at top: CGFloat, velocity: CGFloat, holdingScroll: Bool) {
444
+ if !holdingScroll {
445
+ unlockScroll()
446
+ }
447
+ let tops = detentTops
448
+ guard let lowest = tops.max() else { return }
449
+ // Where a fling of this velocity would come to rest.
450
+ let projected = top + velocity * 0.2
451
+
452
+ if config.dismissOnSwipe, velocity >= 0, projected > lowest + (bounds.height - lowest) / 2 {
453
+ releaseVelocity = velocity
454
+ delegate?.modalPresentationDidRequestSwipeDismiss()
455
+ if isOffscreen || isTransitioning() {
456
+ return
457
+ }
458
+ releaseVelocity = 0
459
+ }
460
+
461
+ let index = tops.indices.min { abs(tops[$0] - projected) < abs(tops[$1] - projected) } ?? selectedIndex
462
+ let changed = index != selectedIndex
463
+ selectedIndex = index
464
+ dragTop = nil
465
+ if changed {
466
+ delegate?.modalPresentationDidChangeDetent(index)
467
+ }
468
+ animate(distance: restingTop(for: index) - top, velocity: velocity, animations: { self.layoutSheet() }) { [weak self] in
469
+ guard let self, self.drag == nil else { return }
470
+ self.unlockScroll()
471
+ }
472
+ }
473
+
474
+ /// Rubber-bands above the tallest detent, and below the lowest one when
475
+ /// swiping cannot dismiss.
476
+ private func constrained(_ top: CGFloat) -> CGFloat {
477
+ let tops = detentTops
478
+ guard let minTop = tops.min(), let maxTop = tops.max() else { return top }
479
+ let dimension = max(bounds.height, 1)
480
+ if top < minTop {
481
+ return minTop - Self.rubberBand(minTop - top, dimension: dimension)
482
+ }
483
+ if !config.dismissOnSwipe, top > maxTop {
484
+ return maxTop + Self.rubberBand(top - maxTop, dimension: dimension)
485
+ }
486
+ return top
487
+ }
488
+
489
+ private static func rubberBand(_ offset: CGFloat, dimension: CGFloat) -> CGFloat {
490
+ (1 - 1 / (offset * 0.55 / dimension + 1)) * dimension
491
+ }
492
+
493
+ /// The innermost vertically scrollable view under `point` (in `sheetView`).
494
+ private func verticalScrollView(at point: CGPoint) -> UIScrollView? {
495
+ var candidate = sheetView.hitTest(point, with: nil)
496
+ while let current = candidate, current !== sheetView {
497
+ if let scrollView = current as? UIScrollView, scrollView.isScrollEnabled, scrollView.canScrollVertically {
498
+ return scrollView
499
+ }
500
+ candidate = current.superview
501
+ }
502
+ return nil
503
+ }
504
+
505
+ /// Pins the scroll view's offset while the sheet moves, so content does not
506
+ /// scroll (or bounce) at the same time.
507
+ private func lockScroll(_ scrollView: UIScrollView, at offsetY: CGFloat) {
508
+ if lockedScrollView !== scrollView {
509
+ unlockScroll()
510
+ lockedScrollView = scrollView
511
+ }
512
+ lockedOffsetY = offsetY
513
+ if scrollView.contentOffset.y != offsetY {
514
+ scrollView.contentOffset.y = offsetY
515
+ }
516
+ guard scrollLock == nil else { return }
517
+ scrollLock = scrollView.observe(\.contentOffset) { [weak self] scrollView, _ in
518
+ guard let self, scrollView.contentOffset.y != self.lockedOffsetY else { return }
519
+ scrollView.contentOffset.y = self.lockedOffsetY
520
+ }
521
+ }
522
+
523
+ /// An inline sheet lives inside the React root view, whose touch handler
524
+ /// only yields to gestures outside of it. Cancel its touches when the sheet
525
+ /// starts moving, so the press under the finger doesn't fire on release.
526
+ private func cancelReactTouches() {
527
+ guard let handlerClass = Self.touchHandlerClass else { return }
528
+ var current = superview
529
+ while let view = current {
530
+ for recognizer in view.gestureRecognizers ?? [] where recognizer.isEnabled && recognizer.isKind(of: handlerClass) {
531
+ recognizer.isEnabled = false
532
+ recognizer.isEnabled = true
533
+ }
534
+ current = view.superview
535
+ }
536
+ }
537
+
538
+ // MARK: Keyboard (modal only)
539
+
540
+ @objc private func keyboardWillChangeFrame(_ notification: Notification) {
541
+ guard let window,
542
+ let frame = (notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue)?.cgRectValue else { return }
543
+ let keyboard = window.convert(frame, from: window.screen.coordinateSpace)
544
+ updateKeyboardHeight(max(0, window.bounds.maxY - keyboard.minY), notification: notification)
545
+ }
546
+
547
+ @objc private func keyboardWillHide(_ notification: Notification) {
548
+ updateKeyboardHeight(0, notification: notification)
549
+ }
550
+
551
+ private func updateKeyboardHeight(_ height: CGFloat, notification: Notification) {
552
+ guard height != keyboardHeight else { return }
553
+ keyboardHeight = height
554
+ guard drag == nil else { return }
555
+ notification.animateAlongsideKeyboard {
556
+ self.setNeedsLayout()
557
+ self.layoutIfNeeded()
558
+ }
559
+ }
560
+ }
561
+
562
+ private extension UIScrollView {
563
+ var topOffsetY: CGFloat {
564
+ -adjustedContentInset.top
565
+ }
566
+
567
+ var isScrolledToTop: Bool {
568
+ contentOffset.y <= topOffsetY + 0.5
569
+ }
570
+
571
+ var canScrollVertically: Bool {
572
+ alwaysBounceVertical
573
+ || contentSize.height + adjustedContentInset.top + adjustedContentInset.bottom > bounds.height + 0.5
574
+ }
575
+ }