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
@@ -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,7 @@ 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
45
50
 
46
51
  var onPresent: (() -> Void)?
47
52
  var onDismiss: ((DismissReason) -> Void)?
@@ -49,6 +54,8 @@ final class HybridNitroModal: HybridNitroModalSpec {
49
54
  var onBackdropPress: (() -> Void)?
50
55
  var onBackButtonPress: (() -> Void)?
51
56
  var onContentAreaChange: ((ModalContentArea) -> Void)?
57
+ var onPullToRefresh: (() -> Void)?
58
+ var onRestingTopChange: ((Double) -> Void)?
52
59
 
53
60
  // MARK: State
54
61
 
@@ -66,6 +73,7 @@ final class HybridNitroModal: HybridNitroModalSpec {
66
73
  private var reconcileScheduled = false
67
74
  private var isDropped = false
68
75
  private var presentation: ModalPresenting?
76
+ private var inlineSheet: SheetView?
69
77
  private var lastContentArea: CGSize?
70
78
 
71
79
  override init() {
@@ -73,17 +81,24 @@ final class HybridNitroModal: HybridNitroModalSpec {
73
81
  view.onWindowChange = { [weak self] in
74
82
  self?.hostWindowDidChange()
75
83
  }
84
+ view.onLayoutChange = { [weak self] in
85
+ self?.hostLayoutDidChange()
86
+ }
76
87
  view.contentView.onContentSizeChange = { [weak self] _ in
77
88
  self?.presentation?.contentSizeDidChange()
89
+ self?.inlineSheet?.contentSizeDidChange()
78
90
  }
79
91
  }
80
92
 
81
93
  deinit {
82
94
  // 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 }
95
+ // happen on main. `controller` and `sheet` are captured strongly on purpose.
96
+ let controller = presentation
97
+ let sheet = inlineSheet
98
+ guard controller != nil || sheet != nil else { return }
85
99
  DispatchQueue.main.async {
86
- controller.presentingViewController?.dismiss(animated: false)
100
+ controller?.presentingViewController?.dismiss(animated: false)
101
+ sheet?.removeFromSuperview()
87
102
  }
88
103
  }
89
104
 
@@ -93,8 +108,11 @@ final class HybridNitroModal: HybridNitroModalSpec {
93
108
  let next = makeConfig()
94
109
  guard next != config else { return }
95
110
  config = next
111
+ view.isInline = next.isInline
96
112
  if let presentation {
97
113
  presentation.apply(next)
114
+ } else if let inlineSheet {
115
+ inlineSheet.apply(next)
98
116
  } else {
99
117
  reportEstimatedContentArea()
100
118
  }
@@ -104,9 +122,13 @@ final class HybridNitroModal: HybridNitroModalSpec {
104
122
  isDropped = true
105
123
  wantsOpen = false
106
124
  sessionActive = false
125
+ phase = .idle
126
+ if let sheet = inlineSheet {
127
+ inlineSheet = nil
128
+ sheet.removeFromSuperview()
129
+ }
107
130
  guard let controller = presentation else { return }
108
131
  presentation = nil
109
- phase = .idle
110
132
  controller.presentingViewController?.dismiss(animated: false)
111
133
  }
112
134
 
@@ -128,6 +150,7 @@ final class HybridNitroModal: HybridNitroModalSpec {
128
150
  guard index.isFinite else { return }
129
151
  DispatchQueue.main.async { [weak self] in
130
152
  self?.presentation?.snapToDetent(at: Int(index))
153
+ self?.inlineSheet?.snapToDetent(at: Int(index))
131
154
  }
132
155
  }
133
156
 
@@ -178,6 +201,10 @@ final class HybridNitroModal: HybridNitroModalSpec {
178
201
  }
179
202
 
180
203
  private func startPresent() {
204
+ if config.isInline {
205
+ startInlinePresent()
206
+ return
207
+ }
181
208
  // Without a window there is nothing to present from; `hostWindowDidChange` retries.
182
209
  guard let presenter = topViewController() else { return }
183
210
 
@@ -190,12 +217,7 @@ final class HybridNitroModal: HybridNitroModalSpec {
190
217
 
191
218
  presenter.present(controller, animated: isAnimated) { [weak self, weak controller] in
192
219
  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()
220
+ self.didPresent(announcing: controller.view)
199
221
  }
200
222
 
201
223
  if controller.presentingViewController == nil {
@@ -208,7 +230,43 @@ final class HybridNitroModal: HybridNitroModalSpec {
208
230
  }
209
231
  }
210
232
 
233
+ /// Slides an inline sheet up inside `view`.
234
+ private func startInlinePresent() {
235
+ // Needs its laid-out area; `hostLayoutDidChange` retries.
236
+ guard view.window != nil, view.bounds.width > 0, view.bounds.height > 0 else { return }
237
+
238
+ let sheet = SheetView(contentView: view.contentView, config: config, isInline: true)
239
+ sheet.delegate = self
240
+ inlineSheet = sheet
241
+ phase = .presenting
242
+ view.showInlineSheet(sheet)
243
+ sheet.layoutIfNeeded()
244
+ sheet.animateTransition(toOffscreen: false, animated: true, duration: 0.5) { [weak self, weak sheet] in
245
+ guard let self, let sheet, self.inlineSheet === sheet else { return }
246
+ self.didPresent(announcing: nil)
247
+ }
248
+ }
249
+
250
+ private func didPresent(announcing announcedView: UIView?) {
251
+ phase = .presented
252
+ if !isDropped {
253
+ onPresent?()
254
+ }
255
+ if let announcedView {
256
+ UIAccessibility.post(notification: .screenChanged, argument: announcedView)
257
+ }
258
+ reconcile()
259
+ }
260
+
211
261
  private func startDismiss() {
262
+ if let sheet = inlineSheet {
263
+ phase = .dismissing
264
+ sheet.endEditing(true)
265
+ sheet.animateTransition(toOffscreen: true, animated: true, duration: 0.35) { [weak self] in
266
+ self?.finishDismiss()
267
+ }
268
+ return
269
+ }
212
270
  guard let controller = presentation, let presenting = controller.presentingViewController else {
213
271
  finishDismiss()
214
272
  return
@@ -223,6 +281,11 @@ final class HybridNitroModal: HybridNitroModalSpec {
223
281
  private func finishDismiss() {
224
282
  phase = .idle
225
283
  presentation = nil
284
+ if let sheet = inlineSheet {
285
+ inlineSheet = nil
286
+ sheet.unlockScroll()
287
+ sheet.removeFromSuperview()
288
+ }
226
289
  view.contentView.removeFromSuperview()
227
290
  endSession()
228
291
  reconcile()
@@ -246,20 +309,24 @@ final class HybridNitroModal: HybridNitroModalSpec {
246
309
  }
247
310
 
248
311
  private func makeConfig() -> ModalConfig {
249
- ModalConfig(
312
+ let inline = isInline && mode == .bottomsheet
313
+ return ModalConfig(
250
314
  mode: mode,
251
- detents: detents,
315
+ inlinePresentation: isInline,
316
+ detents: detents.map(Detent.init),
252
317
  initialDetentIndex: initialDetentIndex.isFinite ? Int(initialDetentIndex) : 0,
253
318
  backdropColor: UIColor(processedColor: backdropColor),
254
319
  backdropOpacity: CGFloat(backdropOpacity),
255
320
  backdropBlurRadius: CGFloat(backdropBlurRadius),
256
- dismissOnBackdropPress: dismissOnBackdropPress,
257
- dismissOnSwipe: dismissOnSwipe,
321
+ // An inline sheet is part of the screen; the user can't dismiss it.
322
+ dismissOnBackdropPress: dismissOnBackdropPress && !inline,
323
+ dismissOnSwipe: dismissOnSwipe && !inline,
258
324
  grabberVisible: grabberVisible,
259
325
  cornerRadius: cornerRadius >= 0 ? CGFloat(cornerRadius) : nil,
260
326
  contentBackgroundColor: contentBackgroundColor.map(UIColor.init(processedColor:)) ?? .systemBackground,
261
327
  keyboardBehavior: keyboardBehavior,
262
- popupAnimation: popupAnimation
328
+ popupAnimation: popupAnimation,
329
+ pullToRefreshEnabled: pullToRefreshEnabled
263
330
  )
264
331
  }
265
332
 
@@ -273,23 +340,37 @@ final class HybridNitroModal: HybridNitroModalSpec {
273
340
 
274
341
  private func hostWindowDidChange() {
275
342
  guard view.window != nil else { return }
276
- if presentation == nil {
343
+ if presentation == nil, inlineSheet == nil {
277
344
  reportEstimatedContentArea()
278
345
  }
279
346
  scheduleReconcile()
280
347
  }
281
348
 
349
+ private func hostLayoutDidChange() {
350
+ guard config.isInline, inlineSheet == nil else { return }
351
+ reportEstimatedContentArea()
352
+ if wantsOpen {
353
+ scheduleReconcile()
354
+ }
355
+ }
356
+
282
357
  /// Lets React size the content before the first presentation.
283
358
  private func reportEstimatedContentArea() {
284
359
  guard let window = view.window else { return }
360
+ if config.isInline {
361
+ let bounds = view.bounds
362
+ let maximum = SheetView.maximumDetentValue(height: bounds.height, insets: view.safeAreaInsets, isInline: true)
363
+ reportContentArea(CGSize(width: bounds.width, height: SheetView.contentHeight(for: config.detents, maximum: maximum)))
364
+ return
365
+ }
285
366
  switch config.mode {
286
367
  case .popup:
287
368
  reportContentArea(PopupViewController.availableRect(in: window.bounds, safeArea: window.safeAreaInsets).size)
288
369
  case .bottomsheet:
289
- let maximum = SheetViewController.estimatedMaximumDetentValue(in: window)
370
+ let maximum = SheetView.maximumDetentValue(height: window.bounds.height, insets: window.safeAreaInsets, isInline: false)
290
371
  reportContentArea(CGSize(
291
- width: SheetViewController.estimatedWidth(in: window),
292
- height: SheetViewController.contentHeight(for: config.detents, maximum: maximum)
372
+ width: SheetView.estimatedWidth(in: window),
373
+ height: SheetView.contentHeight(for: config.detents, maximum: maximum)
293
374
  ))
294
375
  }
295
376
  }
@@ -325,4 +406,14 @@ extension HybridNitroModal: ModalPresentationDelegate {
325
406
  func modalPresentationDidChangeContentArea(_ size: CGSize) {
326
407
  reportContentArea(size)
327
408
  }
409
+
410
+ func modalPresentationDidPullToRefresh() {
411
+ guard !isDropped else { return }
412
+ onPullToRefresh?()
413
+ }
414
+
415
+ func modalPresentationDidChangeRestingTop(_ top: CGFloat) {
416
+ guard !isDropped else { return }
417
+ onRestingTopChange?(Double(top))
418
+ }
328
419
  }
@@ -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,30 @@ 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
+
57
+ /// A `bottomSheet` living in the host view's bounds. Popups are always modal.
58
+ var isInline: Bool {
59
+ inlinePresentation && mode == .bottomsheet
60
+ }
24
61
 
25
62
  var clampedInitialDetentIndex: Int {
26
63
  min(max(initialDetentIndex, 0), max(detents.count - 1, 0))
27
64
  }
28
65
  }
29
66
 
30
- /// Callbacks from a presented modal controller back to `HybridNitroModal`.
67
+ /// Callbacks from a presented modal controller (or an inline sheet) back to
68
+ /// `HybridNitroModal`.
31
69
  protocol ModalPresentationDelegate: AnyObject {
32
70
  func modalPresentationDidTapBackdrop()
33
71
  /// The user swiped the sheet down far enough to dismiss it.
34
72
  func modalPresentationDidRequestSwipeDismiss()
35
73
  func modalPresentationDidChangeDetent(_ index: Int)
36
74
  func modalPresentationDidChangeContentArea(_ size: CGSize)
75
+ /// The user pulled the sheet past its lowest detent and let go.
76
+ func modalPresentationDidPullToRefresh()
77
+ /// Inline sheets: the sheet's top at rest, in the host view.
78
+ func modalPresentationDidChangeRestingTop(_ top: CGFloat)
37
79
  }
38
80
 
39
81
  /// 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
  }