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