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
@@ -12,6 +12,9 @@ import UIKit
12
12
  /// Presentation is a small state machine so that rapid `isOpen` toggles,
13
13
  /// interactive dismissals and unmounts always settle on a consistent state,
14
14
  /// and every accepted present request ends with exactly one `onDismiss`.
15
+ ///
16
+ /// A modal is presented as a view controller; an inline sheet is a
17
+ /// `SheetView` shown inside `view`.
15
18
  final class HybridNitroModal: HybridNitroModalSpec {
16
19
  let view = NitroModalHostView()
17
20
 
@@ -29,7 +32,8 @@ final class HybridNitroModal: HybridNitroModalSpec {
29
32
  }
30
33
 
31
34
  var mode: ModalMode = .bottomsheet
32
- var detents: [SheetDetent] = [.fitcontent]
35
+ var isInline = false
36
+ var detents: [SheetDetent] = [.first(.fitcontent)]
33
37
  var initialDetentIndex: Double = 0
34
38
  var backdropColor: Double = 0xFF00_0000
35
39
  var backdropOpacity: Double = 0.4
@@ -42,6 +46,9 @@ final class HybridNitroModal: HybridNitroModalSpec {
42
46
  var contentBackgroundColor: Double?
43
47
  var keyboardBehavior: KeyboardBehavior = .pan
44
48
  var popupAnimation: PopupAnimation = .scale
49
+ var pullToRefreshEnabled = false
50
+ var refreshing: Bool?
51
+ var refreshIndicatorColor: Double?
45
52
 
46
53
  var onPresent: (() -> Void)?
47
54
  var onDismiss: ((DismissReason) -> Void)?
@@ -49,6 +56,8 @@ final class HybridNitroModal: HybridNitroModalSpec {
49
56
  var onBackdropPress: (() -> Void)?
50
57
  var onBackButtonPress: (() -> Void)?
51
58
  var onContentAreaChange: ((ModalContentArea) -> Void)?
59
+ var onPullToRefresh: (() -> Void)?
60
+ var onRestingTopChange: ((Double) -> Void)?
52
61
 
53
62
  // MARK: State
54
63
 
@@ -66,6 +75,7 @@ final class HybridNitroModal: HybridNitroModalSpec {
66
75
  private var reconcileScheduled = false
67
76
  private var isDropped = false
68
77
  private var presentation: ModalPresenting?
78
+ private var inlineSheet: SheetView?
69
79
  private var lastContentArea: CGSize?
70
80
 
71
81
  override init() {
@@ -73,17 +83,24 @@ final class HybridNitroModal: HybridNitroModalSpec {
73
83
  view.onWindowChange = { [weak self] in
74
84
  self?.hostWindowDidChange()
75
85
  }
86
+ view.onLayoutChange = { [weak self] in
87
+ self?.hostLayoutDidChange()
88
+ }
76
89
  view.contentView.onContentSizeChange = { [weak self] _ in
77
90
  self?.presentation?.contentSizeDidChange()
91
+ self?.inlineSheet?.contentSizeDidChange()
78
92
  }
79
93
  }
80
94
 
81
95
  deinit {
82
96
  // The last reference can be dropped from the JS thread; UIKit work must
83
- // happen on main. `controller` is captured strongly on purpose.
84
- guard let controller = presentation else { return }
97
+ // happen on main. `controller` and `sheet` are captured strongly on purpose.
98
+ let controller = presentation
99
+ let sheet = inlineSheet
100
+ guard controller != nil || sheet != nil else { return }
85
101
  DispatchQueue.main.async {
86
- controller.presentingViewController?.dismiss(animated: false)
102
+ controller?.presentingViewController?.dismiss(animated: false)
103
+ sheet?.removeFromSuperview()
87
104
  }
88
105
  }
89
106
 
@@ -93,8 +110,11 @@ final class HybridNitroModal: HybridNitroModalSpec {
93
110
  let next = makeConfig()
94
111
  guard next != config else { return }
95
112
  config = next
113
+ view.isInline = next.isInline
96
114
  if let presentation {
97
115
  presentation.apply(next)
116
+ } else if let inlineSheet {
117
+ inlineSheet.apply(next)
98
118
  } else {
99
119
  reportEstimatedContentArea()
100
120
  }
@@ -104,9 +124,13 @@ final class HybridNitroModal: HybridNitroModalSpec {
104
124
  isDropped = true
105
125
  wantsOpen = false
106
126
  sessionActive = false
127
+ phase = .idle
128
+ if let sheet = inlineSheet {
129
+ inlineSheet = nil
130
+ sheet.removeFromSuperview()
131
+ }
107
132
  guard let controller = presentation else { return }
108
133
  presentation = nil
109
- phase = .idle
110
134
  controller.presentingViewController?.dismiss(animated: false)
111
135
  }
112
136
 
@@ -128,6 +152,7 @@ final class HybridNitroModal: HybridNitroModalSpec {
128
152
  guard index.isFinite else { return }
129
153
  DispatchQueue.main.async { [weak self] in
130
154
  self?.presentation?.snapToDetent(at: Int(index))
155
+ self?.inlineSheet?.snapToDetent(at: Int(index))
131
156
  }
132
157
  }
133
158
 
@@ -178,6 +203,10 @@ final class HybridNitroModal: HybridNitroModalSpec {
178
203
  }
179
204
 
180
205
  private func startPresent() {
206
+ if config.isInline {
207
+ startInlinePresent()
208
+ return
209
+ }
181
210
  // Without a window there is nothing to present from; `hostWindowDidChange` retries.
182
211
  guard let presenter = topViewController() else { return }
183
212
 
@@ -190,12 +219,7 @@ final class HybridNitroModal: HybridNitroModalSpec {
190
219
 
191
220
  presenter.present(controller, animated: isAnimated) { [weak self, weak controller] in
192
221
  guard let self, let controller, self.presentation === controller else { return }
193
- self.phase = .presented
194
- if !self.isDropped {
195
- self.onPresent?()
196
- }
197
- UIAccessibility.post(notification: .screenChanged, argument: controller.view)
198
- self.reconcile()
222
+ self.didPresent(announcing: controller.view)
199
223
  }
200
224
 
201
225
  if controller.presentingViewController == nil {
@@ -208,7 +232,43 @@ final class HybridNitroModal: HybridNitroModalSpec {
208
232
  }
209
233
  }
210
234
 
235
+ /// Slides an inline sheet up inside `view`.
236
+ private func startInlinePresent() {
237
+ // Needs its laid-out area; `hostLayoutDidChange` retries.
238
+ guard view.window != nil, view.bounds.width > 0, view.bounds.height > 0 else { return }
239
+
240
+ let sheet = SheetView(contentView: view.contentView, config: config, isInline: true)
241
+ sheet.delegate = self
242
+ inlineSheet = sheet
243
+ phase = .presenting
244
+ view.showInlineSheet(sheet)
245
+ sheet.layoutIfNeeded()
246
+ sheet.animateTransition(toOffscreen: false, animated: true, duration: 0.5) { [weak self, weak sheet] in
247
+ guard let self, let sheet, self.inlineSheet === sheet else { return }
248
+ self.didPresent(announcing: nil)
249
+ }
250
+ }
251
+
252
+ private func didPresent(announcing announcedView: UIView?) {
253
+ phase = .presented
254
+ if !isDropped {
255
+ onPresent?()
256
+ }
257
+ if let announcedView {
258
+ UIAccessibility.post(notification: .screenChanged, argument: announcedView)
259
+ }
260
+ reconcile()
261
+ }
262
+
211
263
  private func startDismiss() {
264
+ if let sheet = inlineSheet {
265
+ phase = .dismissing
266
+ sheet.endEditing(true)
267
+ sheet.animateTransition(toOffscreen: true, animated: true, duration: 0.35) { [weak self] in
268
+ self?.finishDismiss()
269
+ }
270
+ return
271
+ }
212
272
  guard let controller = presentation, let presenting = controller.presentingViewController else {
213
273
  finishDismiss()
214
274
  return
@@ -223,6 +283,11 @@ final class HybridNitroModal: HybridNitroModalSpec {
223
283
  private func finishDismiss() {
224
284
  phase = .idle
225
285
  presentation = nil
286
+ if let sheet = inlineSheet {
287
+ inlineSheet = nil
288
+ sheet.unlockScroll()
289
+ sheet.removeFromSuperview()
290
+ }
226
291
  view.contentView.removeFromSuperview()
227
292
  endSession()
228
293
  reconcile()
@@ -246,20 +311,26 @@ final class HybridNitroModal: HybridNitroModalSpec {
246
311
  }
247
312
 
248
313
  private func makeConfig() -> ModalConfig {
249
- ModalConfig(
314
+ let inline = isInline && mode == .bottomsheet
315
+ return ModalConfig(
250
316
  mode: mode,
251
- detents: detents,
317
+ inlinePresentation: isInline,
318
+ detents: detents.map(Detent.init),
252
319
  initialDetentIndex: initialDetentIndex.isFinite ? Int(initialDetentIndex) : 0,
253
320
  backdropColor: UIColor(processedColor: backdropColor),
254
321
  backdropOpacity: CGFloat(backdropOpacity),
255
322
  backdropBlurRadius: CGFloat(backdropBlurRadius),
256
- dismissOnBackdropPress: dismissOnBackdropPress,
257
- dismissOnSwipe: dismissOnSwipe,
323
+ // An inline sheet is part of the screen; the user can't dismiss it.
324
+ dismissOnBackdropPress: dismissOnBackdropPress && !inline,
325
+ dismissOnSwipe: dismissOnSwipe && !inline,
258
326
  grabberVisible: grabberVisible,
259
327
  cornerRadius: cornerRadius >= 0 ? CGFloat(cornerRadius) : nil,
260
328
  contentBackgroundColor: contentBackgroundColor.map(UIColor.init(processedColor:)) ?? .systemBackground,
261
329
  keyboardBehavior: keyboardBehavior,
262
- popupAnimation: popupAnimation
330
+ popupAnimation: popupAnimation,
331
+ pullToRefreshEnabled: pullToRefreshEnabled,
332
+ refreshing: refreshing,
333
+ refreshIndicatorColor: refreshIndicatorColor.map(UIColor.init(processedColor:))
263
334
  )
264
335
  }
265
336
 
@@ -273,23 +344,37 @@ final class HybridNitroModal: HybridNitroModalSpec {
273
344
 
274
345
  private func hostWindowDidChange() {
275
346
  guard view.window != nil else { return }
276
- if presentation == nil {
347
+ if presentation == nil, inlineSheet == nil {
277
348
  reportEstimatedContentArea()
278
349
  }
279
350
  scheduleReconcile()
280
351
  }
281
352
 
353
+ private func hostLayoutDidChange() {
354
+ guard config.isInline, inlineSheet == nil else { return }
355
+ reportEstimatedContentArea()
356
+ if wantsOpen {
357
+ scheduleReconcile()
358
+ }
359
+ }
360
+
282
361
  /// Lets React size the content before the first presentation.
283
362
  private func reportEstimatedContentArea() {
284
363
  guard let window = view.window else { return }
364
+ if config.isInline {
365
+ let bounds = view.bounds
366
+ let maximum = SheetView.maximumDetentValue(height: bounds.height, insets: view.safeAreaInsets, isInline: true)
367
+ reportContentArea(CGSize(width: bounds.width, height: SheetView.contentHeight(for: config.detents, maximum: maximum)))
368
+ return
369
+ }
285
370
  switch config.mode {
286
371
  case .popup:
287
372
  reportContentArea(PopupViewController.availableRect(in: window.bounds, safeArea: window.safeAreaInsets).size)
288
373
  case .bottomsheet:
289
- let maximum = SheetViewController.estimatedMaximumDetentValue(in: window)
374
+ let maximum = SheetView.maximumDetentValue(height: window.bounds.height, insets: window.safeAreaInsets, isInline: false)
290
375
  reportContentArea(CGSize(
291
- width: SheetViewController.estimatedWidth(in: window),
292
- height: SheetViewController.contentHeight(for: config.detents, maximum: maximum)
376
+ width: SheetView.estimatedWidth(in: window),
377
+ height: SheetView.contentHeight(for: config.detents, maximum: maximum)
293
378
  ))
294
379
  }
295
380
  }
@@ -325,4 +410,14 @@ extension HybridNitroModal: ModalPresentationDelegate {
325
410
  func modalPresentationDidChangeContentArea(_ size: CGSize) {
326
411
  reportContentArea(size)
327
412
  }
413
+
414
+ func modalPresentationDidPullToRefresh() {
415
+ guard !isDropped else { return }
416
+ onPullToRefresh?()
417
+ }
418
+
419
+ func modalPresentationDidChangeRestingTop(_ top: CGFloat) {
420
+ guard !isDropped else { return }
421
+ onRestingTopChange?(Double(top))
422
+ }
328
423
  }
@@ -5,10 +5,41 @@
5
5
 
6
6
  import UIKit
7
7
 
8
+ /// A resting height of a `bottomSheet`.
9
+ enum Detent: Equatable {
10
+ case small, medium, large, fitContent
11
+ /// Content height in points, above the bottom safe area.
12
+ case height(CGFloat)
13
+
14
+ init(_ detent: SheetDetent) {
15
+ switch detent {
16
+ case .first(.small): self = .small
17
+ case .first(.medium): self = .medium
18
+ case .first(.large): self = .large
19
+ case .first(.fitcontent): self = .fitContent
20
+ case .second(let points): self = points.isFinite ? .height(CGFloat(points)) : .large
21
+ }
22
+ }
23
+
24
+ /// Content height at this detent, given the tallest height the content may
25
+ /// occupy and the measured height of the content (for `fitContent`).
26
+ func height(maximum: CGFloat, contentHeight: CGFloat) -> CGFloat {
27
+ switch self {
28
+ case .small: return maximum * 0.25
29
+ case .medium: return maximum * 0.5
30
+ case .large: return maximum
31
+ case .fitContent: return min(max(contentHeight, 1), maximum)
32
+ case .height(let points): return min(max(points, 1), maximum)
33
+ }
34
+ }
35
+ }
36
+
8
37
  /// Snapshot of the React props that shape a presentation.
9
38
  struct ModalConfig: Equatable {
10
39
  var mode: ModalMode = .bottomsheet
11
- var detents: [SheetDetent] = [.fitcontent]
40
+ /// An inline `bottomSheet` (see `isInline`), as opposed to a modal one.
41
+ var inlinePresentation = false
42
+ var detents: [Detent] = [.fitContent]
12
43
  var initialDetentIndex = 0
13
44
  var backdropColor: UIColor = .black
14
45
  var backdropOpacity: CGFloat = 0.4
@@ -21,19 +52,34 @@ struct ModalConfig: Equatable {
21
52
  var contentBackgroundColor: UIColor = .systemBackground
22
53
  var keyboardBehavior: KeyboardBehavior = .pan
23
54
  var popupAnimation: PopupAnimation = .scale
55
+ var pullToRefreshEnabled = false
56
+ /// A refresh is in progress; `nil` shows no refresh indicator.
57
+ var refreshing: Bool?
58
+ /// `nil` uses the platform default.
59
+ var refreshIndicatorColor: UIColor?
60
+
61
+ /// A `bottomSheet` living in the host view's bounds. Popups are always modal.
62
+ var isInline: Bool {
63
+ inlinePresentation && mode == .bottomsheet
64
+ }
24
65
 
25
66
  var clampedInitialDetentIndex: Int {
26
67
  min(max(initialDetentIndex, 0), max(detents.count - 1, 0))
27
68
  }
28
69
  }
29
70
 
30
- /// Callbacks from a presented modal controller back to `HybridNitroModal`.
71
+ /// Callbacks from a presented modal controller (or an inline sheet) back to
72
+ /// `HybridNitroModal`.
31
73
  protocol ModalPresentationDelegate: AnyObject {
32
74
  func modalPresentationDidTapBackdrop()
33
75
  /// The user swiped the sheet down far enough to dismiss it.
34
76
  func modalPresentationDidRequestSwipeDismiss()
35
77
  func modalPresentationDidChangeDetent(_ index: Int)
36
78
  func modalPresentationDidChangeContentArea(_ size: CGSize)
79
+ /// The user pulled the sheet past its lowest detent and let go.
80
+ func modalPresentationDidPullToRefresh()
81
+ /// Inline sheets: the sheet's top at rest, in the host view.
82
+ func modalPresentationDidChangeRestingTop(_ top: CGFloat)
37
83
  }
38
84
 
39
85
  /// A view controller that presents the React content in one of the modal modes.
@@ -5,12 +5,24 @@
5
5
 
6
6
  import UIKit
7
7
 
8
- /// The view Nitro mounts into the React tree. It is an invisible placeholder:
9
- /// React children mounted into it (see `HybridNitroModalComponent+Children.mm`)
10
- /// are hosted by `contentView`, which travels into the presented controller.
8
+ /// The view Nitro mounts into the React tree. React children mounted into it
9
+ /// (see `HybridNitroModalComponent+Children.mm`) are hosted by `contentView`.
10
+ ///
11
+ /// Modal: an invisible placeholder; `contentView` travels into the presented
12
+ /// controller. Inline: the area the sheet lives in. It hosts the `SheetView`
13
+ /// and only takes touches that land on the sheet.
11
14
  final class NitroModalHostView: UIView {
12
15
  let contentView = NitroModalContentView()
13
16
  var onWindowChange: (() -> Void)?
17
+ /// The bounds or safe area changed.
18
+ var onLayoutChange: (() -> Void)?
19
+
20
+ var isInline = false {
21
+ didSet {
22
+ isUserInteractionEnabled = isInline
23
+ contentView.isInline = isInline
24
+ }
25
+ }
14
26
 
15
27
  override init(frame: CGRect) {
16
28
  super.init(frame: frame)
@@ -27,6 +39,27 @@ final class NitroModalHostView: UIView {
27
39
  onWindowChange?()
28
40
  }
29
41
 
42
+ override func layoutSubviews() {
43
+ super.layoutSubviews()
44
+ onLayoutChange?()
45
+ }
46
+
47
+ override func safeAreaInsetsDidChange() {
48
+ super.safeAreaInsetsDidChange()
49
+ onLayoutChange?()
50
+ }
51
+
52
+ override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
53
+ let hit = super.hitTest(point, with: event)
54
+ return hit === self ? nil : hit
55
+ }
56
+
57
+ func showInlineSheet(_ sheet: SheetView) {
58
+ sheet.frame = bounds
59
+ sheet.autoresizingMask = [.flexibleWidth, .flexibleHeight]
60
+ addSubview(sheet)
61
+ }
62
+
30
63
  @objc(nitroModalMountChild:atIndex:)
31
64
  func mountChild(_ child: UIView, atIndex index: Int) {
32
65
  contentView.insertReactChild(child, at: index)
@@ -42,22 +75,44 @@ final class NitroModalHostView: UIView {
42
75
  }
43
76
  }
44
77
 
45
- /// Holds the React children inside the presented modal and reports the size
46
- /// they occupy, which drives the `fitContent` detent and the popup card size.
78
+ /// Holds the React children inside the modal and reports the size they
79
+ /// occupy, which drives the `fitContent` detent and the popup card size.
47
80
  final class NitroModalContentView: UIView {
48
81
  var onContentSizeChange: ((CGSize) -> Void)?
49
82
 
83
+ /// Hosted by an inline sheet, inside the React root view.
84
+ ///
85
+ /// The Fabric touch handler attached to this view is then disabled: a modal
86
+ /// lives outside the root view and needs its own, but inline the root's
87
+ /// handler already sees these touches.
88
+ var isInline = false {
89
+ didSet {
90
+ updateTouchHandlers()
91
+ setNeedsLayout()
92
+ }
93
+ }
94
+
50
95
  private var reactChildren: [UIView] = []
51
96
  private var observations: [ObjectIdentifier: [NSKeyValueObservation]] = [:]
52
97
  private var lastReportedSize: CGSize = .zero
53
98
 
99
+ /// Where React placed the children. Inline, React lays the content out at
100
+ /// the sheet's resting top so that `measure()` matches the screen; this
101
+ /// view's bounds origin cancels that offset out on screen.
102
+ private var reactOrigin: CGPoint {
103
+ guard isInline else { return .zero }
104
+ let minY = reactChildren.map { $0.center.y - $0.bounds.height / 2 }.min() ?? 0
105
+ return CGPoint(x: 0, y: minY)
106
+ }
107
+
54
108
  /// Bounding size of the React children. Fabric positions them with
55
109
  /// `center`/`bounds`, so this stays correct even with transforms applied.
56
110
  var contentSize: CGSize {
57
- reactChildren.reduce(.zero) { size, child in
111
+ let origin = reactOrigin
112
+ return reactChildren.reduce(.zero) { size, child in
58
113
  CGSize(
59
- width: max(size.width, child.center.x + child.bounds.width / 2),
60
- height: max(size.height, child.center.y + child.bounds.height / 2)
114
+ width: max(size.width, child.center.x + child.bounds.width / 2 - origin.x),
115
+ height: max(size.height, child.center.y + child.bounds.height / 2 - origin.y)
61
116
  )
62
117
  }
63
118
  }
@@ -71,6 +126,8 @@ final class NitroModalContentView: UIView {
71
126
  child.layer.observe(\.bounds) { [weak self] _, _ in self?.setNeedsLayout() },
72
127
  child.layer.observe(\.position) { [weak self] _, _ in self?.setNeedsLayout() },
73
128
  ]
129
+ // The touch handler is attached right before the first child mounts.
130
+ updateTouchHandlers()
74
131
  setNeedsLayout()
75
132
  }
76
133
 
@@ -83,9 +140,17 @@ final class NitroModalContentView: UIView {
83
140
 
84
141
  override func layoutSubviews() {
85
142
  super.layoutSubviews()
143
+ let origin = reactOrigin
144
+ if bounds.origin != origin {
145
+ bounds.origin = origin
146
+ }
86
147
  let size = contentSize
87
148
  guard size != lastReportedSize else { return }
88
149
  lastReportedSize = size
89
150
  onContentSizeChange?(size)
90
151
  }
152
+
153
+ private func updateTouchHandlers() {
154
+ gestureRecognizers?.forEach { $0.isEnabled = !isInline }
155
+ }
91
156
  }