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
@@ -5,67 +5,29 @@
5
5
 
6
6
  import UIKit
7
7
 
8
- /// `bottomSheet` mode: an edge-attached sheet presented `.overFullScreen`.
8
+ /// Modal `bottomSheet` mode: an edge-attached `SheetView` presented
9
+ /// `.overFullScreen`.
9
10
  ///
10
11
  /// `UISheetPresentationController` is not used because iOS 26 always floats
11
12
  /// sheets inset from the screen edges at partial detents, with no API to opt
12
13
  /// out. Detents, dragging, swipe-to-dismiss and the hand-off to scroll views
13
- /// inside the content are implemented here with a pan gesture instead.
14
- final class SheetViewController: UIViewController, ModalPresenting, UIViewControllerTransitioningDelegate, UIGestureRecognizerDelegate {
15
- weak var presentationDelegate: ModalPresentationDelegate?
16
-
17
- /// Gap kept above the sheet at its tallest, like a system page sheet.
18
- private static let topGap: CGFloat = 10
19
- /// Extra sheet height below the screen so it stays attached to the bottom
20
- /// edge while rubber-banding upward.
21
- private static let overscroll: CGFloat = 400
22
- private static let defaultCornerRadius: CGFloat = 16
23
- private static let regularMaxWidth: CGFloat = 704
24
- private static let grabberSize = CGSize(width: 36, height: 5)
25
-
26
- private let contentView: NitroModalContentView
27
- private let backdrop = BackdropView()
28
- private let sheetView = UIView()
29
- private let grabber = UIView()
30
- private var config: ModalConfig
31
- private var selectedIndex: Int
32
- private var keyboardHeight: CGFloat = 0
33
-
34
- /// The sheet sits below the screen (before presenting / while dismissing).
35
- private var isOffscreen = true
36
- /// Top of the sheet while the user drags it; `nil` rests on `selectedIndex`.
37
- private var dragTop: CGFloat?
38
- private var drag: DragState?
39
- /// Vertical velocity of the swipe that requested the dismissal.
40
- private var releaseVelocity: CGFloat = 0
41
-
42
- private var lockedScrollView: UIScrollView?
43
- private var lockedOffsetY: CGFloat = 0
44
- private var scrollLock: NSKeyValueObservation?
45
-
46
- private struct DragState {
47
- /// Unconstrained sheet top, before rubber-banding.
48
- var rawTop: CGFloat
49
- var lastTranslation: CGFloat = 0
50
- var scrollView: UIScrollView?
51
- /// Whether the last movement moved the sheet rather than the scroll view.
52
- var sheetDrove = false
14
+ /// inside the content are implemented by `SheetView` with a pan gesture.
15
+ final class SheetViewController: UIViewController, ModalPresenting, UIViewControllerTransitioningDelegate {
16
+ weak var presentationDelegate: ModalPresentationDelegate? {
17
+ didSet { sheet.delegate = presentationDelegate }
53
18
  }
54
19
 
20
+ private let sheet: SheetView
21
+
55
22
  init(contentView: NitroModalContentView, config: ModalConfig) {
56
- self.contentView = contentView
57
- self.config = config
58
- selectedIndex = config.clampedInitialDetentIndex
23
+ sheet = SheetView(contentView: contentView, config: config, isInline: false)
59
24
  super.init(nibName: nil, bundle: nil)
60
25
  modalPresentationStyle = .overFullScreen
61
26
  transitioningDelegate = self
62
- backdrop.onTap = { [weak self] in
63
- self?.presentationDelegate?.modalPresentationDidTapBackdrop()
27
+ sheet.isTransitioning = { [weak self] in
28
+ guard let self else { return false }
29
+ return self.isBeingPresented || self.isBeingDismissed
64
30
  }
65
-
66
- let center = NotificationCenter.default
67
- center.addObserver(self, selector: #selector(keyboardWillChangeFrame(_:)), name: UIResponder.keyboardWillChangeFrameNotification, object: nil)
68
- center.addObserver(self, selector: #selector(keyboardWillHide(_:)), name: UIResponder.keyboardWillHideNotification, object: nil)
69
31
  }
70
32
 
71
33
  @available(*, unavailable)
@@ -74,67 +36,30 @@ final class SheetViewController: UIViewController, ModalPresenting, UIViewContro
74
36
  }
75
37
 
76
38
  override func loadView() {
77
- let root = UIView()
78
- root.backgroundColor = .clear
79
- root.addSubview(backdrop)
80
-
81
- sheetView.clipsToBounds = true
82
- sheetView.layer.cornerCurve = .continuous
83
- sheetView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
84
- sheetView.accessibilityViewIsModal = true
85
- sheetView.addSubview(contentView)
86
-
87
- grabber.isUserInteractionEnabled = false
88
- grabber.backgroundColor = .tertiaryLabel
89
- grabber.layer.cornerRadius = Self.grabberSize.height / 2
90
- sheetView.addSubview(grabber)
91
- root.addSubview(sheetView)
92
-
93
- let pan = UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:)))
94
- pan.delegate = self
95
- sheetView.addGestureRecognizer(pan)
96
-
97
- view = root
98
- applyAppearance()
99
- }
100
-
101
- override func viewDidLayoutSubviews() {
102
- super.viewDidLayoutSubviews()
103
- layoutSheet()
104
- reportContentArea()
39
+ view = sheet
105
40
  }
106
41
 
107
42
  override func viewDidDisappear(_ animated: Bool) {
108
43
  super.viewDidDisappear(animated)
109
- unlockScroll()
44
+ sheet.unlockScroll()
110
45
  }
111
46
 
112
47
  override func accessibilityPerformEscape() -> Bool {
113
- guard config.dismissOnSwipe else { return false }
114
- presentationDelegate?.modalPresentationDidRequestSwipeDismiss()
115
- return true
48
+ sheet.performEscape()
116
49
  }
117
50
 
118
51
  // MARK: ModalPresenting
119
52
 
120
53
  func apply(_ newConfig: ModalConfig) {
121
- config = newConfig
122
- selectedIndex = min(selectedIndex, max(detents.count - 1, 0))
123
- guard isViewLoaded else { return }
124
- applyAppearance()
125
- animateToRest()
54
+ sheet.apply(newConfig)
126
55
  }
127
56
 
128
57
  func contentSizeDidChange() {
129
- guard config.detents.contains(.fitcontent) else { return }
130
- animateToRest()
58
+ sheet.contentSizeDidChange()
131
59
  }
132
60
 
133
61
  func snapToDetent(at index: Int) {
134
- guard detents.indices.contains(index) else { return }
135
- selectedIndex = index
136
- animateToRest()
137
- presentationDelegate?.modalPresentationDidChangeDetent(index)
62
+ sheet.snapToDetent(at: index)
138
63
  }
139
64
 
140
65
  // MARK: UIViewControllerTransitioningDelegate
@@ -151,349 +76,8 @@ final class SheetViewController: UIViewController, ModalPresenting, UIViewContro
151
76
  SheetTransition(presenting: false)
152
77
  }
153
78
 
154
- /// Slides the sheet in or out; a swipe dismissal keeps its release velocity.
155
79
  fileprivate func animateTransition(toOffscreen offscreen: Bool, animated: Bool, duration: TimeInterval, completion: @escaping () -> Void) {
156
- let from = sheetView.frame.minY
157
- let velocity = offscreen ? releaseVelocity : 0
158
- releaseVelocity = 0
159
- isOffscreen = offscreen
160
- dragTop = nil
161
- guard animated else {
162
- layoutSheet()
163
- completion()
164
- return
165
- }
166
- animate(
167
- distance: currentTop - from,
168
- velocity: velocity,
169
- damping: offscreen ? 1 : 0.88,
170
- duration: duration,
171
- animations: { self.layoutSheet() },
172
- completion: completion
173
- )
174
- }
175
-
176
- // MARK: Layout
177
-
178
- private var detents: [SheetDetent] {
179
- config.detents.isEmpty ? [.fitcontent] : config.detents
180
- }
181
-
182
- /// Tallest height the content may occupy (excludes the bottom safe area).
183
- private var maximumDetentValue: CGFloat {
184
- let insets = view.safeAreaInsets
185
- return max(0, view.bounds.height - insets.top - insets.bottom - Self.topGap)
186
- }
187
-
188
- private var keyboardLift: CGFloat {
189
- config.keyboardBehavior == .none ? 0 : max(0, keyboardHeight - view.safeAreaInsets.bottom)
190
- }
191
-
192
- private func height(of detent: SheetDetent) -> CGFloat {
193
- let maximum = maximumDetentValue
194
- switch detent {
195
- case .small: return maximum * 0.25
196
- case .medium: return maximum * 0.5
197
- case .large: return maximum
198
- case .fitcontent: return min(max(contentView.contentSize.height, 1), maximum)
199
- }
200
- }
201
-
202
- /// Sheet top when resting on `detents[index]`. The bottom safe area is
203
- /// added below the content.
204
- private func restingTop(for index: Int) -> CGFloat {
205
- let detent = detents[min(max(index, 0), detents.count - 1)]
206
- let top = view.bounds.height - view.safeAreaInsets.bottom - keyboardLift - height(of: detent)
207
- return max(top, view.safeAreaInsets.top + Self.topGap)
208
- }
209
-
210
- private var detentTops: [CGFloat] {
211
- detents.indices.map(restingTop(for:))
212
- }
213
-
214
- private var currentTop: CGFloat {
215
- if isOffscreen { return view.bounds.height }
216
- return dragTop ?? restingTop(for: selectedIndex)
217
- }
218
-
219
- private func layoutSheet() {
220
- let bounds = view.bounds
221
- let width = Self.width(for: bounds.width, sizeClass: traitCollection.horizontalSizeClass)
222
- let top = currentTop
223
- backdrop.frame = bounds
224
- sheetView.frame = CGRect(x: (bounds.width - width) / 2, y: top, width: width, height: bounds.height + Self.overscroll)
225
- contentView.frame = CGRect(x: 0, y: 0, width: width, height: maximumDetentValue)
226
- grabber.frame = CGRect(
227
- origin: CGPoint(x: (width - Self.grabberSize.width) / 2, y: 5),
228
- size: Self.grabberSize
229
- )
230
- backdrop.alpha = backdropAlpha(forTop: top)
231
- }
232
-
233
- /// Fully dimmed at every detent; fades out below the lowest one.
234
- private func backdropAlpha(forTop top: CGFloat) -> CGFloat {
235
- let bottom = view.bounds.height
236
- guard let lowest = detentTops.max(), bottom > lowest else { return top >= bottom ? 0 : 1 }
237
- return min(max((bottom - top) / (bottom - lowest), 0), 1)
238
- }
239
-
240
- private func applyAppearance() {
241
- backdrop.configure(color: config.backdropColor, opacity: config.backdropOpacity, blurRadius: config.backdropBlurRadius)
242
- sheetView.backgroundColor = config.contentBackgroundColor
243
- sheetView.layer.cornerRadius = config.cornerRadius ?? Self.defaultCornerRadius
244
- grabber.isHidden = !config.grabberVisible
245
- }
246
-
247
- private func animateToRest() {
248
- guard isViewLoaded, drag == nil, !isOffscreen else { return }
249
- dragTop = nil
250
- UIView.animate(withDuration: 0.3, delay: 0, options: [.beginFromCurrentState, .allowUserInteraction]) {
251
- self.view.setNeedsLayout()
252
- self.view.layoutIfNeeded()
253
- }
254
- }
255
-
256
- private func animate(
257
- distance: CGFloat,
258
- velocity: CGFloat,
259
- damping: CGFloat = 0.9,
260
- duration: TimeInterval = 0.45,
261
- animations: @escaping () -> Void,
262
- completion: (() -> Void)? = nil
263
- ) {
264
- // UIKit expects the initial velocity relative to the distance travelled.
265
- let relativeVelocity = abs(distance) > 1 ? min(max(velocity / distance, -30), 30) : 0
266
- UIView.animate(
267
- withDuration: duration,
268
- delay: 0,
269
- usingSpringWithDamping: damping,
270
- initialSpringVelocity: relativeVelocity,
271
- options: [.allowUserInteraction, .beginFromCurrentState],
272
- animations: animations,
273
- completion: { _ in completion?() }
274
- )
275
- }
276
-
277
- // MARK: Content area
278
-
279
- private func reportContentArea() {
280
- guard isViewLoaded, view.bounds.width > 0 else { return }
281
- var height = Self.contentHeight(for: config.detents, maximum: maximumDetentValue)
282
- if config.keyboardBehavior == .resize {
283
- height -= keyboardLift
284
- }
285
- let width = Self.width(for: view.bounds.width, sizeClass: traitCollection.horizontalSizeClass)
286
- presentationDelegate?.modalPresentationDidChangeContentArea(CGSize(width: width, height: max(height, 0)))
287
- }
288
-
289
- /// Height the content may occupy at the largest of `detents`.
290
- static func contentHeight(for detents: [SheetDetent], maximum: CGFloat) -> CGFloat {
291
- if detents.isEmpty || detents.contains(.large) || detents.contains(.fitcontent) {
292
- return maximum
293
- }
294
- return detents.contains(.medium) ? maximum * 0.5 : maximum * 0.25
295
- }
296
-
297
- /// Best guess of `maximumDetentValue` before the sheet is on screen.
298
- static func estimatedMaximumDetentValue(in window: UIWindow) -> CGFloat {
299
- let insets = window.safeAreaInsets
300
- return window.bounds.height - insets.top - insets.bottom - topGap
301
- }
302
-
303
- /// Best guess of the sheet width before it is on screen.
304
- static func estimatedWidth(in window: UIWindow) -> CGFloat {
305
- width(for: window.bounds.width, sizeClass: window.traitCollection.horizontalSizeClass)
306
- }
307
-
308
- private static func width(for available: CGFloat, sizeClass: UIUserInterfaceSizeClass) -> CGFloat {
309
- sizeClass == .regular ? min(available, regularMaxWidth) : available
310
- }
311
-
312
- // MARK: Dragging
313
-
314
- func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
315
- guard let pan = gestureRecognizer as? UIPanGestureRecognizer,
316
- !isBeingPresented, !isBeingDismissed, !isOffscreen else { return false }
317
- let velocity = pan.velocity(in: view)
318
- return abs(velocity.y) > abs(velocity.x)
319
- }
320
-
321
- /// Scroll views inside the content keep scrolling; `handlePan` decides
322
- /// frame by frame whether the sheet or the scroll view moves.
323
- func gestureRecognizer(
324
- _ gestureRecognizer: UIGestureRecognizer,
325
- shouldRecognizeSimultaneouslyWith other: UIGestureRecognizer
326
- ) -> Bool {
327
- guard let scrollView = other.view as? UIScrollView else { return false }
328
- return other === scrollView.panGestureRecognizer && scrollView.isDescendant(of: sheetView)
329
- }
330
-
331
- @objc private func handlePan(_ pan: UIPanGestureRecognizer) {
332
- switch pan.state {
333
- case .began:
334
- // Grab the sheet where it is, even mid-animation.
335
- let top = sheetView.layer.presentation()?.frame.minY ?? sheetView.frame.minY
336
- sheetView.layer.removeAllAnimations()
337
- backdrop.layer.removeAllAnimations()
338
- let translation = pan.translation(in: sheetView)
339
- let location = pan.location(in: sheetView)
340
- let start = CGPoint(x: location.x - translation.x, y: location.y - translation.y)
341
- var state = DragState(rawTop: top)
342
- state.lastTranslation = pan.translation(in: view).y
343
- state.scrollView = verticalScrollView(at: start)
344
- if let scrollView = state.scrollView {
345
- lockedOffsetY = scrollView.contentOffset.y
346
- }
347
- drag = state
348
- dragTop = top
349
- layoutSheet()
350
-
351
- case .changed:
352
- guard var state = drag, let minTop = detentTops.min() else { return }
353
- let translation = pan.translation(in: view).y
354
- let delta = translation - state.lastTranslation
355
- state.lastTranslation = translation
356
-
357
- if let scrollView = state.scrollView {
358
- // Up: expand the sheet before scrolling. Down: scroll back to the top
359
- // before collapsing the sheet.
360
- let drive = delta < 0 ? state.rawTop > minTop + 0.5 : scrollView.isScrolledToTop
361
- if drive {
362
- state.rawTop = max(state.rawTop + delta, minTop)
363
- lockScroll(scrollView, at: delta < 0 ? lockedOffsetY : scrollView.topOffsetY)
364
- } else {
365
- unlockScroll()
366
- lockedOffsetY = scrollView.contentOffset.y
367
- }
368
- state.sheetDrove = drive
369
- } else {
370
- state.rawTop += delta
371
- state.sheetDrove = true
372
- }
373
- drag = state
374
- dragTop = constrained(state.rawTop)
375
- layoutSheet()
376
-
377
- case .ended, .cancelled, .failed:
378
- guard let state = drag else { return }
379
- drag = nil
380
- let velocity = state.sheetDrove ? pan.velocity(in: view).y : 0
381
- endDrag(at: dragTop ?? currentTop, velocity: velocity, holdingScroll: state.sheetDrove)
382
-
383
- default:
384
- break
385
- }
386
- }
387
-
388
- private func endDrag(at top: CGFloat, velocity: CGFloat, holdingScroll: Bool) {
389
- if !holdingScroll {
390
- unlockScroll()
391
- }
392
- let tops = detentTops
393
- guard let lowest = tops.max() else { return }
394
- // Where a fling of this velocity would come to rest.
395
- let projected = top + velocity * 0.2
396
-
397
- if config.dismissOnSwipe, velocity >= 0, projected > lowest + (view.bounds.height - lowest) / 2 {
398
- releaseVelocity = velocity
399
- presentationDelegate?.modalPresentationDidRequestSwipeDismiss()
400
- if isBeingDismissed {
401
- return
402
- }
403
- releaseVelocity = 0
404
- }
405
-
406
- let index = tops.indices.min { abs(tops[$0] - projected) < abs(tops[$1] - projected) } ?? selectedIndex
407
- if index != selectedIndex {
408
- selectedIndex = index
409
- presentationDelegate?.modalPresentationDidChangeDetent(index)
410
- }
411
- dragTop = nil
412
- animate(distance: restingTop(for: index) - top, velocity: velocity, animations: { self.layoutSheet() }) { [weak self] in
413
- guard let self, self.drag == nil else { return }
414
- self.unlockScroll()
415
- }
416
- }
417
-
418
- /// Rubber-bands above the tallest detent, and below the lowest one when
419
- /// swiping cannot dismiss.
420
- private func constrained(_ top: CGFloat) -> CGFloat {
421
- let tops = detentTops
422
- guard let minTop = tops.min(), let maxTop = tops.max() else { return top }
423
- let dimension = max(view.bounds.height, 1)
424
- if top < minTop {
425
- return minTop - Self.rubberBand(minTop - top, dimension: dimension)
426
- }
427
- if !config.dismissOnSwipe, top > maxTop {
428
- return maxTop + Self.rubberBand(top - maxTop, dimension: dimension)
429
- }
430
- return top
431
- }
432
-
433
- private static func rubberBand(_ offset: CGFloat, dimension: CGFloat) -> CGFloat {
434
- (1 - 1 / (offset * 0.55 / dimension + 1)) * dimension
435
- }
436
-
437
- /// The innermost vertically scrollable view under `point` (in `sheetView`).
438
- private func verticalScrollView(at point: CGPoint) -> UIScrollView? {
439
- var candidate = sheetView.hitTest(point, with: nil)
440
- while let current = candidate, current !== sheetView {
441
- if let scrollView = current as? UIScrollView, scrollView.isScrollEnabled, scrollView.canScrollVertically {
442
- return scrollView
443
- }
444
- candidate = current.superview
445
- }
446
- return nil
447
- }
448
-
449
- /// Pins the scroll view's offset while the sheet moves, so content does not
450
- /// scroll (or bounce) at the same time.
451
- private func lockScroll(_ scrollView: UIScrollView, at offsetY: CGFloat) {
452
- if lockedScrollView !== scrollView {
453
- unlockScroll()
454
- lockedScrollView = scrollView
455
- }
456
- lockedOffsetY = offsetY
457
- if scrollView.contentOffset.y != offsetY {
458
- scrollView.contentOffset.y = offsetY
459
- }
460
- guard scrollLock == nil else { return }
461
- scrollLock = scrollView.observe(\.contentOffset) { [weak self] scrollView, _ in
462
- guard let self, scrollView.contentOffset.y != self.lockedOffsetY else { return }
463
- scrollView.contentOffset.y = self.lockedOffsetY
464
- }
465
- }
466
-
467
- private func unlockScroll() {
468
- guard let scrollView = lockedScrollView else { return }
469
- scrollLock?.invalidate()
470
- scrollLock = nil
471
- lockedScrollView = nil
472
- // Stops any deceleration left over from a fling that moved the sheet.
473
- scrollView.setContentOffset(scrollView.contentOffset, animated: false)
474
- }
475
-
476
- // MARK: Keyboard
477
-
478
- @objc private func keyboardWillChangeFrame(_ notification: Notification) {
479
- guard isViewLoaded, let window = view.window,
480
- let frame = (notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue)?.cgRectValue else { return }
481
- let keyboard = window.convert(frame, from: window.screen.coordinateSpace)
482
- updateKeyboardHeight(max(0, window.bounds.maxY - keyboard.minY), notification: notification)
483
- }
484
-
485
- @objc private func keyboardWillHide(_ notification: Notification) {
486
- updateKeyboardHeight(0, notification: notification)
487
- }
488
-
489
- private func updateKeyboardHeight(_ height: CGFloat, notification: Notification) {
490
- guard isViewLoaded, height != keyboardHeight else { return }
491
- keyboardHeight = height
492
- guard drag == nil else { return }
493
- notification.animateAlongsideKeyboard {
494
- self.view.setNeedsLayout()
495
- self.view.layoutIfNeeded()
496
- }
80
+ sheet.animateTransition(toOffscreen: offscreen, animated: animated, duration: duration, completion: completion)
497
81
  }
498
82
  }
499
83
 
@@ -531,18 +115,3 @@ private final class SheetTransition: NSObject, UIViewControllerAnimatedTransitio
531
115
  }
532
116
  }
533
117
  }
534
-
535
- private extension UIScrollView {
536
- var topOffsetY: CGFloat {
537
- -adjustedContentInset.top
538
- }
539
-
540
- var isScrolledToTop: Bool {
541
- contentOffset.y <= topOffsetY + 0.5
542
- }
543
-
544
- var canScrollVertically: Bool {
545
- alwaysBounceVertical
546
- || contentSize.height + adjustedContentInset.top + adjustedContentInset.bottom > bounds.height + 0.5
547
- }
548
- }
@@ -1,11 +1,23 @@
1
1
  "use strict";
2
2
 
3
- import { useImperativeHandle, useLayoutEffect, useMemo, useRef, useState } from 'react';
3
+ import { useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState } from 'react';
4
4
  import { processColor, StyleSheet, useWindowDimensions, View } from 'react-native';
5
5
  import { callback, NitroModalView } from './NitroModalView';
6
+
7
+ /**
8
+ * Where a `bottomSheet` lives.
9
+ * - `modal`: presented over the whole app, with a backdrop.
10
+ * - `inline`: inside the component's own frame (`style`), as part of your
11
+ * screen. No backdrop, the views behind it stay interactive, and the user
12
+ * can't dismiss it.
13
+ */
6
14
  import { jsx as _jsx } from "react/jsx-runtime";
7
15
  const DEFAULT_DETENTS = ['fitContent'];
8
16
  const BLACK = 0xff000000;
17
+ function parseDetent(value) {
18
+ const points = Number(value);
19
+ return value !== '' && Number.isFinite(points) ? points : value;
20
+ }
9
21
  function toNativeColor(color) {
10
22
  if (color == null) return undefined;
11
23
  const processed = processColor(color);
@@ -15,6 +27,8 @@ function toNativeColor(color) {
15
27
  export function NitroModal({
16
28
  isOpen,
17
29
  mode = 'bottomSheet',
30
+ presentation = 'modal',
31
+ style,
18
32
  detents = DEFAULT_DETENTS,
19
33
  initialDetentIndex = 0,
20
34
  backdropColor = 'black',
@@ -34,11 +48,15 @@ export function NitroModal({
34
48
  onDetentChange,
35
49
  onBackdropPress,
36
50
  onBackButtonPress,
51
+ onPullToRefresh,
52
+ refreshing,
53
+ refreshIndicatorColor,
37
54
  children,
38
55
  testID,
39
56
  ref
40
57
  }) {
41
58
  const isControlled = isOpen !== undefined;
59
+ const isInline = presentation === 'inline' && mode === 'bottomSheet';
42
60
  const [internalOpen, setInternalOpen] = useState(false);
43
61
  const open = isControlled ? isOpen : internalOpen;
44
62
 
@@ -52,26 +70,42 @@ export function NitroModal({
52
70
  }
53
71
  const renderContent = open || sessionActive;
54
72
  const [area, setArea] = useState(null);
73
+ // Inline (iOS/Android): where the sheet's top rests in the host. The content
74
+ // is laid out there so `measure()`, which presses rely on, matches the
75
+ // screen; native cancels the offset out on screen.
76
+ const [restingTop, setRestingTop] = useState(0);
77
+ // Native starts refreshing as soon as a pull is released. Reporting `true`
78
+ // for the render that follows, then `refreshing` again, stops it if the
79
+ // handler didn't start a refresh (unchanged props never reach native).
80
+ const isRefreshControlled = refreshing !== undefined;
81
+ const [pullPending, setPullPending] = useState(false);
82
+ useEffect(() => {
83
+ if (pullPending) setPullPending(false);
84
+ }, [pullPending]);
55
85
  const hybridRef = useRef(null);
56
86
 
57
87
  // Native callbacks are created once (stable props, no native updates on
58
88
  // re-render) and read the latest handlers from this ref.
59
89
  const latest = useRef({
60
90
  isControlled,
91
+ isRefreshControlled,
61
92
  onPresent,
62
93
  onDismiss,
63
94
  onDetentChange,
64
95
  onBackdropPress,
65
- onBackButtonPress
96
+ onBackButtonPress,
97
+ onPullToRefresh
66
98
  });
67
99
  useLayoutEffect(() => {
68
100
  latest.current = {
69
101
  isControlled,
102
+ isRefreshControlled,
70
103
  onPresent,
71
104
  onDismiss,
72
105
  onDetentChange,
73
106
  onBackdropPress,
74
- onBackButtonPress
107
+ onBackButtonPress,
108
+ onPullToRefresh
75
109
  };
76
110
  });
77
111
  const nativeCallbacks = useMemo(() => ({
@@ -96,6 +130,13 @@ export function NitroModal({
96
130
  onBackButtonPress: callback(() => {
97
131
  latest.current.onBackButtonPress?.();
98
132
  }),
133
+ onPullToRefresh: callback(() => {
134
+ if (latest.current.isRefreshControlled) setPullPending(true);
135
+ latest.current.onPullToRefresh?.();
136
+ }),
137
+ onRestingTopChange: callback(top => {
138
+ setRestingTop(top);
139
+ }),
99
140
  onContentAreaChange: callback(next => {
100
141
  setArea(current => current?.width === next.width && current.height === next.height ? current : next);
101
142
  })
@@ -122,7 +163,7 @@ export function NitroModal({
122
163
 
123
164
  // Arrays are diffed by identity, so keep one instance per distinct value.
124
165
  const detentsKey = detents.join(',');
125
- const nativeDetents = useMemo(() => [...new Set(detentsKey.split(','))], [detentsKey]);
166
+ const nativeDetents = useMemo(() => [...new Set(detentsKey.split(','))].map(parseDetent), [detentsKey]);
126
167
 
127
168
  // Native reports the exact area; this only covers the first frame.
128
169
  const windowSize = useWindowDimensions();
@@ -141,9 +182,11 @@ export function NitroModal({
141
182
  height: contentArea.height
142
183
  };
143
184
  return /*#__PURE__*/_jsx(NitroModalView, {
144
- style: styles.host,
185
+ style: isInline ? [StyleSheet.absoluteFill, style] : styles.host,
186
+ pointerEvents: isInline ? 'box-none' : undefined,
145
187
  isOpen: open,
146
188
  mode: mode,
189
+ isInline: isInline,
147
190
  detents: nativeDetents,
148
191
  initialDetentIndex: initialDetentIndex,
149
192
  backdropColor: toNativeColor(backdropColor) ?? BLACK,
@@ -157,11 +200,16 @@ export function NitroModal({
157
200
  contentBackgroundColor: toNativeColor(backgroundColor),
158
201
  keyboardBehavior: keyboardBehavior,
159
202
  popupAnimation: popupAnimation,
203
+ pullToRefreshEnabled: onPullToRefresh != null,
204
+ refreshing: isRefreshControlled ? refreshing || pullPending : undefined,
205
+ refreshIndicatorColor: toNativeColor(refreshIndicatorColor),
160
206
  ...nativeCallbacks,
161
207
  children: renderContent ? /*#__PURE__*/_jsx(View, {
162
208
  collapsable: false,
163
209
  testID: testID,
164
- style: [styles.container, containerStyle, contentContainerStyle],
210
+ style: [styles.container, containerStyle, contentContainerStyle, isInline && restingTop > 0 ? {
211
+ top: restingTop
212
+ } : null],
165
213
  children: children
166
214
  }) : null
167
215
  });