react-native-nitro-modal 0.1.5 → 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 (99) hide show
  1. package/README.md +65 -11
  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 +39 -7
  14. package/lib/module/NitroModal.js.map +1 -1
  15. package/lib/module/NitroModalView.js +4 -3
  16. package/lib/module/NitroModalView.js.map +1 -1
  17. package/lib/module/NitroModalView.native.js +3 -0
  18. package/lib/module/NitroModalView.native.js.map +1 -1
  19. package/lib/module/NitroModalView.web.js +159 -0
  20. package/lib/module/NitroModalView.web.js.map +1 -0
  21. package/lib/module/index.js.map +1 -1
  22. package/lib/module/web/AnimatedValue.js +126 -0
  23. package/lib/module/web/AnimatedValue.js.map +1 -0
  24. package/lib/module/web/ModalConfig.js +30 -0
  25. package/lib/module/web/ModalConfig.js.map +1 -0
  26. package/lib/module/web/ModalController.js +306 -0
  27. package/lib/module/web/ModalController.js.map +1 -0
  28. package/lib/module/web/ModalPresenter.js +280 -0
  29. package/lib/module/web/ModalPresenter.js.map +1 -0
  30. package/lib/module/web/PopupPresenter.js +117 -0
  31. package/lib/module/web/PopupPresenter.js.map +1 -0
  32. package/lib/module/web/SheetPresenter.js +494 -0
  33. package/lib/module/web/SheetPresenter.js.map +1 -0
  34. package/lib/module/web/dom.js +138 -0
  35. package/lib/module/web/dom.js.map +1 -0
  36. package/lib/typescript/src/NitroModal.d.ts +35 -4
  37. package/lib/typescript/src/NitroModal.d.ts.map +1 -1
  38. package/lib/typescript/src/NitroModal.nitro.d.ts +29 -2
  39. package/lib/typescript/src/NitroModal.nitro.d.ts.map +1 -1
  40. package/lib/typescript/src/NitroModalView.d.ts +2 -1
  41. package/lib/typescript/src/NitroModalView.d.ts.map +1 -1
  42. package/lib/typescript/src/NitroModalView.native.d.ts +1 -0
  43. package/lib/typescript/src/NitroModalView.native.d.ts.map +1 -1
  44. package/lib/typescript/src/NitroModalView.web.d.ts +6 -0
  45. package/lib/typescript/src/NitroModalView.web.d.ts.map +1 -0
  46. package/lib/typescript/src/index.d.ts +2 -2
  47. package/lib/typescript/src/index.d.ts.map +1 -1
  48. package/lib/typescript/src/web/AnimatedValue.d.ts +49 -0
  49. package/lib/typescript/src/web/AnimatedValue.d.ts.map +1 -0
  50. package/lib/typescript/src/web/ModalConfig.d.ts +30 -0
  51. package/lib/typescript/src/web/ModalConfig.d.ts.map +1 -0
  52. package/lib/typescript/src/web/ModalController.d.ts +70 -0
  53. package/lib/typescript/src/web/ModalController.d.ts.map +1 -0
  54. package/lib/typescript/src/web/ModalPresenter.d.ts +107 -0
  55. package/lib/typescript/src/web/ModalPresenter.d.ts.map +1 -0
  56. package/lib/typescript/src/web/PopupPresenter.d.ts +20 -0
  57. package/lib/typescript/src/web/PopupPresenter.d.ts.map +1 -0
  58. package/lib/typescript/src/web/SheetPresenter.d.ts +65 -0
  59. package/lib/typescript/src/web/SheetPresenter.d.ts.map +1 -0
  60. package/lib/typescript/src/web/dom.d.ts +42 -0
  61. package/lib/typescript/src/web/dom.d.ts.map +1 -0
  62. package/nitrogen/generated/android/c++/JHybridNitroModalSpec.cpp +60 -6
  63. package/nitrogen/generated/android/c++/JHybridNitroModalSpec.hpp +10 -2
  64. package/nitrogen/generated/android/c++/JNamedSheetDetent.hpp +64 -0
  65. package/nitrogen/generated/android/c++/JSheetDetent.cpp +26 -0
  66. package/nitrogen/generated/android/c++/JSheetDetent.hpp +42 -37
  67. package/nitrogen/generated/android/c++/views/JHybridNitroModalStateUpdater.cpp +20 -0
  68. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitromodal/HybridNitroModalSpec.kt +40 -0
  69. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitromodal/NamedSheetDetent.kt +25 -0
  70. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nitromodal/SheetDetent.kt +47 -10
  71. package/nitrogen/generated/android/nitromodal+autolinking.cmake +1 -0
  72. package/nitrogen/generated/ios/NitroModal-Swift-Cxx-Bridge.hpp +53 -8
  73. package/nitrogen/generated/ios/NitroModal-Swift-Cxx-Umbrella.hpp +4 -3
  74. package/nitrogen/generated/ios/c++/HybridNitroModalSpecSwift.hpp +32 -5
  75. package/nitrogen/generated/ios/c++/views/HybridNitroModalComponent.mm +24 -0
  76. package/nitrogen/generated/ios/swift/HybridNitroModalSpec.swift +4 -0
  77. package/nitrogen/generated/ios/swift/HybridNitroModalSpec_cxx.swift +110 -5
  78. package/nitrogen/generated/ios/swift/NamedSheetDetent.swift +48 -0
  79. package/nitrogen/generated/ios/swift/SheetDetent.swift +15 -33
  80. package/nitrogen/generated/shared/c++/HybridNitroModalSpec.cpp +8 -0
  81. package/nitrogen/generated/shared/c++/HybridNitroModalSpec.hpp +14 -5
  82. package/nitrogen/generated/shared/c++/{SheetDetent.hpp → NamedSheetDetent.hpp} +17 -17
  83. package/nitrogen/generated/shared/c++/views/HybridNitroModalComponent.cpp +9 -1
  84. package/nitrogen/generated/shared/c++/views/HybridNitroModalComponent.hpp +15 -2
  85. package/nitrogen/generated/shared/json/NitroModalConfig.json +4 -0
  86. package/package.json +3 -2
  87. package/src/NitroModal.nitro.ts +30 -2
  88. package/src/NitroModal.tsx +71 -8
  89. package/src/NitroModalView.native.tsx +4 -0
  90. package/src/NitroModalView.tsx +9 -4
  91. package/src/NitroModalView.web.tsx +180 -0
  92. package/src/index.tsx +2 -0
  93. package/src/web/AnimatedValue.ts +193 -0
  94. package/src/web/ModalConfig.ts +67 -0
  95. package/src/web/ModalController.ts +338 -0
  96. package/src/web/ModalPresenter.ts +333 -0
  97. package/src/web/PopupPresenter.ts +129 -0
  98. package/src/web/SheetPresenter.ts +647 -0
  99. package/src/web/dom.ts +171 -0
@@ -4,10 +4,35 @@ import android.content.Context
4
4
  import android.content.res.Configuration
5
5
  import android.graphics.Color
6
6
 
7
+ /** A resting height of a `bottomSheet`. */
8
+ internal sealed interface Detent {
9
+ data object Small : Detent
10
+ data object Medium : Detent
11
+ data object Large : Detent
12
+ data object FitContent : Detent
13
+
14
+ /** Content height in dp, above the bottom system inset. */
15
+ data class Height(val dp: Float) : Detent
16
+
17
+ companion object {
18
+ fun from(detent: SheetDetent): Detent = when (detent) {
19
+ is SheetDetent.First -> when (detent.value) {
20
+ NamedSheetDetent.SMALL -> Small
21
+ NamedSheetDetent.MEDIUM -> Medium
22
+ NamedSheetDetent.LARGE -> Large
23
+ NamedSheetDetent.FITCONTENT -> FitContent
24
+ }
25
+ is SheetDetent.Second -> if (detent.value.isFinite()) Height(detent.value.toFloat()) else Large
26
+ }
27
+ }
28
+ }
29
+
7
30
  /** Snapshot of the React props that shape a presentation. */
8
31
  internal data class ModalConfig(
9
32
  val mode: ModalMode = ModalMode.BOTTOMSHEET,
10
- val detents: List<SheetDetent> = listOf(SheetDetent.FITCONTENT),
33
+ /** An inline `bottomSheet` (see [isInline]), as opposed to a modal one. */
34
+ val inlinePresentation: Boolean = false,
35
+ val detents: List<Detent> = listOf(Detent.FitContent),
11
36
  val initialDetentIndex: Int = 0,
12
37
  val backdropColor: Int = Color.BLACK,
13
38
  val backdropOpacity: Float = 0.4f,
@@ -23,7 +48,12 @@ internal data class ModalConfig(
23
48
  val contentBackgroundColor: Int? = null,
24
49
  val keyboardBehavior: KeyboardBehavior = KeyboardBehavior.PAN,
25
50
  val popupAnimation: PopupAnimation = PopupAnimation.SCALE,
51
+ val pullToRefreshEnabled: Boolean = false,
26
52
  ) {
53
+ /** A `bottomSheet` living in the host view's bounds. Popups are always modal. */
54
+ val isInline: Boolean
55
+ get() = inlinePresentation && mode == ModalMode.BOTTOMSHEET
56
+
27
57
  val clampedInitialDetentIndex: Int
28
58
  get() = initialDetentIndex.coerceIn(0, (detents.size - 1).coerceAtLeast(0))
29
59
 
@@ -8,6 +8,7 @@ import com.facebook.react.config.ReactFeatureFlags
8
8
  import com.facebook.react.uimanager.JSPointerDispatcher
9
9
  import com.facebook.react.uimanager.JSTouchDispatcher
10
10
  import com.facebook.react.uimanager.RootView
11
+ import com.facebook.react.uimanager.RootViewUtil
11
12
  import com.facebook.react.uimanager.ThemedReactContext
12
13
  import com.facebook.react.uimanager.events.EventDispatcher
13
14
  import com.facebook.react.views.view.ReactViewGroup
@@ -19,6 +20,9 @@ import com.facebook.react.views.view.ReactViewGroup
19
20
  * root view. Like React Native's own `Modal`, this view is a [RootView] that
20
21
  * forwards touch and pointer events to JS itself. Children are laid out by
21
22
  * Fabric; [ReactViewGroup.onLayout] is a no-op.
23
+ *
24
+ * An inline sheet keeps this view inside the React root view, which already
25
+ * forwards its touches: then it only passes native gestures on to that root.
22
26
  */
23
27
  @SuppressLint("ViewConstructor")
24
28
  internal class ModalContentRoot(private val reactContext: ThemedReactContext) :
@@ -29,40 +33,68 @@ internal class ModalContentRoot(private val reactContext: ThemedReactContext) :
29
33
  /** Called when a child's laid-out bounds change. */
30
34
  var onContentSizeChange: (() -> Unit)? = null
31
35
 
36
+ /** Hosted by an inline sheet, inside the React root view. */
37
+ var isInline: Boolean = false
38
+ set(value) {
39
+ if (field == value) return
40
+ field = value
41
+ updateReactOrigin()
42
+ }
43
+
32
44
  private val touchDispatcher = JSTouchDispatcher(this)
33
45
  private val pointerDispatcher: JSPointerDispatcher? =
34
46
  if (ReactFeatureFlags.dispatchPointerEvents) JSPointerDispatcher(this) else null
35
47
 
48
+ /** Dispatches touches to JS itself: only outside the React root view. */
49
+ private val activeEventDispatcher: EventDispatcher?
50
+ get() = if (isInline) null else eventDispatcher
51
+
36
52
  private val childLayoutListener =
37
53
  OnLayoutChangeListener { _, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom ->
38
54
  if (left != oldLeft || top != oldTop || right != oldRight || bottom != oldBottom) {
55
+ updateReactOrigin()
39
56
  onContentSizeChange?.invoke()
40
57
  }
41
58
  }
42
59
 
60
+ /**
61
+ * Where React placed the children, in px. Inline, React lays the content
62
+ * out at the sheet's resting top so that `measure()` matches the screen;
63
+ * scrolling this view by the same amount cancels that offset out on screen.
64
+ */
65
+ private val reactOriginY: Int
66
+ get() = if (isInline) (0 until childCount).minOfOrNull { getChildAt(it).top } ?: 0 else 0
67
+
43
68
  /** Bounding size of the React children, in px. */
44
69
  val contentWidth: Int
45
70
  get() = (0 until childCount).maxOfOrNull { getChildAt(it).right } ?: 0
46
71
 
47
72
  val contentHeight: Int
48
- get() = (0 until childCount).maxOfOrNull { getChildAt(it).bottom } ?: 0
73
+ get() = ((0 until childCount).maxOfOrNull { getChildAt(it).bottom } ?: 0) - reactOriginY
49
74
 
50
75
  override fun onViewAdded(child: View) {
51
76
  super.onViewAdded(child)
52
77
  child.addOnLayoutChangeListener(childLayoutListener)
78
+ updateReactOrigin()
53
79
  onContentSizeChange?.invoke()
54
80
  }
55
81
 
56
82
  override fun onViewRemoved(child: View) {
57
83
  super.onViewRemoved(child)
58
84
  child.removeOnLayoutChangeListener(childLayoutListener)
85
+ updateReactOrigin()
59
86
  onContentSizeChange?.invoke()
60
87
  }
61
88
 
89
+ private fun updateReactOrigin() {
90
+ val originY = reactOriginY
91
+ if (scrollY != originY) scrollTo(0, originY)
92
+ }
93
+
62
94
  // region Touch forwarding (mirrors ReactModalHostView.DialogRootViewGroup)
63
95
 
64
96
  override fun onInterceptTouchEvent(event: MotionEvent): Boolean {
65
- eventDispatcher?.let {
97
+ activeEventDispatcher?.let {
66
98
  touchDispatcher.handleTouchEvent(event, it, reactContext)
67
99
  pointerDispatcher?.handleMotionEvent(event, it, true)
68
100
  }
@@ -71,7 +103,7 @@ internal class ModalContentRoot(private val reactContext: ThemedReactContext) :
71
103
 
72
104
  @SuppressLint("ClickableViewAccessibility")
73
105
  override fun onTouchEvent(event: MotionEvent): Boolean {
74
- eventDispatcher?.let {
106
+ activeEventDispatcher?.let {
75
107
  touchDispatcher.handleTouchEvent(event, it, reactContext)
76
108
  pointerDispatcher?.handleMotionEvent(event, it, false)
77
109
  }
@@ -81,17 +113,21 @@ internal class ModalContentRoot(private val reactContext: ThemedReactContext) :
81
113
  }
82
114
 
83
115
  override fun onInterceptHoverEvent(event: MotionEvent): Boolean {
84
- eventDispatcher?.let { pointerDispatcher?.handleMotionEvent(event, it, true) }
116
+ activeEventDispatcher?.let { pointerDispatcher?.handleMotionEvent(event, it, true) }
85
117
  return super.onInterceptHoverEvent(event)
86
118
  }
87
119
 
88
120
  override fun onHoverEvent(event: MotionEvent): Boolean {
89
- eventDispatcher?.let { pointerDispatcher?.handleMotionEvent(event, it, false) }
121
+ activeEventDispatcher?.let { pointerDispatcher?.handleMotionEvent(event, it, false) }
90
122
  return super.onHoverEvent(event)
91
123
  }
92
124
 
93
125
  @OptIn(UnstableReactNativeAPI::class)
94
126
  override fun onChildStartedNativeGesture(childView: View?, ev: MotionEvent) {
127
+ if (isInline) {
128
+ outerRootView()?.onChildStartedNativeGesture(childView, ev)
129
+ return
130
+ }
95
131
  eventDispatcher?.let {
96
132
  touchDispatcher.onChildStartedNativeGesture(ev, it, reactContext)
97
133
  pointerDispatcher?.onChildStartedNativeGesture(childView, ev, it)
@@ -99,10 +135,17 @@ internal class ModalContentRoot(private val reactContext: ThemedReactContext) :
99
135
  }
100
136
 
101
137
  override fun onChildEndedNativeGesture(childView: View, ev: MotionEvent) {
138
+ if (isInline) {
139
+ outerRootView()?.onChildEndedNativeGesture(childView, ev)
140
+ return
141
+ }
102
142
  eventDispatcher?.let { touchDispatcher.onChildEndedNativeGesture(ev, it) }
103
143
  pointerDispatcher?.onChildEndedNativeGesture()
104
144
  }
105
145
 
146
+ /** The React root view an inline sheet lives in. */
147
+ private fun outerRootView(): RootView? = (parent as? View)?.let { RootViewUtil.getRootView(it) }
148
+
106
149
  override fun handleException(t: Throwable) {
107
150
  reactContext.reactApplicationContext.handleException(RuntimeException(t))
108
151
  }
@@ -22,6 +22,10 @@ import com.facebook.react.uimanager.ThemedReactContext
22
22
  * drive the enter/exit animations; this class handles the window, edge-to-edge
23
23
  * insets, keyboard tracking, back handling and teardown.
24
24
  *
25
+ * With an [inlineHost], there is no window: the layout fills that view, in
26
+ * the screen's own hierarchy, and the keyboard and back button are left to
27
+ * the screen.
28
+ *
25
29
  * Transitions are interruptible like native ones: dismissing mid-enter turns
26
30
  * the enter around, and [cancelDismiss] turns an exit back around. Once an
27
31
  * exit starts the window stops taking input, as a dismissed dialog would.
@@ -34,6 +38,7 @@ internal abstract class ModalPresenter(
34
38
  protected val contentRoot: ModalContentRoot,
35
39
  config: ModalConfig,
36
40
  protected val listener: Listener,
41
+ private val inlineHost: NitroModalHostView? = null,
37
42
  ) {
38
43
  interface Listener {
39
44
  /** An enter transition settled: the first one, or one that reversed an exit. */
@@ -45,12 +50,20 @@ internal abstract class ModalPresenter(
45
50
  fun onDetentChange(index: Int)
46
51
  /** Area available to the content, in px. */
47
52
  fun onContentAreaChange(width: Int, height: Int)
53
+ /** The user pulled the sheet past its lowest detent and let go. */
54
+ fun onPullToRefresh()
55
+ /** Inline sheets: the sheet's top at rest, in the host view, in px. */
56
+ fun onRestingTopChange(top: Int)
48
57
  }
49
58
 
50
59
  protected var config: ModalConfig = config
51
60
  private set
52
61
 
53
- protected val dialog = ComponentDialog(activity, R.style.NitroModal_Dialog)
62
+ /** Lives in [inlineHost] rather than in a window of its own. */
63
+ protected val isInline: Boolean
64
+ get() = inlineHost != null
65
+
66
+ protected val dialog by lazy { ComponentDialog(activity, R.style.NitroModal_Dialog) }
54
67
  protected val backdrop: View = BackdropView(activity)
55
68
 
56
69
  /** System bars + display cutout. */
@@ -119,6 +132,9 @@ internal abstract class ModalPresenter(
119
132
 
120
133
  open fun snapToDetent(index: Int) = Unit
121
134
 
135
+ /** Runs once, right before the layout is torn down. */
136
+ protected open fun onTeardown() = Unit
137
+
122
138
  protected open fun onBackGestureStarted(event: BackEventCompat) = Unit
123
139
  protected open fun onBackGestureProgressed(event: BackEventCompat) = Unit
124
140
  protected open fun onBackGestureCancelled() = Unit
@@ -130,6 +146,13 @@ internal abstract class ModalPresenter(
130
146
  fun show() {
131
147
  (contentRoot.parent as? ViewGroup)?.removeView(contentRoot)
132
148
  val root = createLayout()
149
+ inlineHost?.let { host ->
150
+ host.addInlineRoot(root)
151
+ updateInlineInsets()
152
+ entering = true
153
+ animateIn()
154
+ return
155
+ }
133
156
  backdrop.setBackgroundColor(config.effectiveBackdropColor)
134
157
  backdrop.alpha = 0f
135
158
  backdrop.setOnClickListener { if (!isDismissing) listener.onBackdropPress() }
@@ -179,10 +202,15 @@ internal abstract class ModalPresenter(
179
202
  if (previous == newConfig) return
180
203
  config = newConfig
181
204
  backdrop.setBackgroundColor(config.effectiveBackdropColor)
182
- if (previous.backdropBlurRadius != newConfig.backdropBlurRadius) applyBlur()
205
+ if (!isInline && previous.backdropBlurRadius != newConfig.backdropBlurRadius) applyBlur()
183
206
  onConfigChanged(previous)
184
207
  }
185
208
 
209
+ /** The inline host moved or resized. */
210
+ fun onHostLayoutChanged() {
211
+ if (isInline && !finished) updateInlineInsets()
212
+ }
213
+
186
214
  /**
187
215
  * Animated dismissal, also from mid-enter. [Listener.onDismissed] follows
188
216
  * when it completes, unless [cancelDismiss] turns it around first.
@@ -207,7 +235,7 @@ internal abstract class ModalPresenter(
207
235
  if (!isDismissing || finished || !canReverseDismiss) return false
208
236
  isDismissing = false
209
237
  dismissReason = DismissReason.PROGRAMMATIC
210
- dialog.window?.clearFlags(INPUT_RELEASE_FLAGS)
238
+ if (!isInline) dialog.window?.clearFlags(INPUT_RELEASE_FLAGS)
211
239
  entering = true
212
240
  animateIn()
213
241
  return true
@@ -257,6 +285,7 @@ internal abstract class ModalPresenter(
257
285
  * then would cut the keyboard's hide animation short.
258
286
  */
259
287
  private fun releaseInput() {
288
+ if (isInline) return
260
289
  val window = dialog.window ?: return
261
290
  val keyboardVisible = imeAnimating || imeHeight > 0
262
291
  window.addFlags(
@@ -273,6 +302,12 @@ internal abstract class ModalPresenter(
273
302
 
274
303
  private fun teardown() {
275
304
  backdrop.animate().cancel()
305
+ onTeardown()
306
+ if (inlineHost != null) {
307
+ (contentRoot.parent as? ViewGroup)?.removeView(contentRoot)
308
+ inlineHost.removeAllViews()
309
+ return
310
+ }
276
311
  dialog.window?.let { reactContext.onExtraWindowDestroy(it) }
277
312
  if (dialog.isShowing && !activity.isFinishing && !activity.isDestroyed) {
278
313
  try {
@@ -285,11 +320,35 @@ internal abstract class ModalPresenter(
285
320
  }
286
321
 
287
322
  private fun hideKeyboard() {
288
- val token = dialog.window?.decorView?.windowToken ?: return
323
+ if (isInline && !contentRoot.hasFocus()) return
324
+ val token = (if (isInline) contentRoot.windowToken else dialog.window?.decorView?.windowToken) ?: return
289
325
  (activity.getSystemService(Activity.INPUT_METHOD_SERVICE) as? InputMethodManager)
290
326
  ?.hideSoftInputFromWindow(token, 0)
291
327
  }
292
328
 
329
+ /**
330
+ * Inline: the system bars and cutout overlapping the host view, so the
331
+ * sheet keeps its content clear of them like a modal sheet does.
332
+ */
333
+ private fun updateInlineInsets() {
334
+ val host = inlineHost ?: return
335
+ val window = ViewCompat.getRootWindowInsets(host)
336
+ ?.getInsets(WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout())
337
+ ?: Insets.NONE
338
+ val location = IntArray(2)
339
+ host.getLocationInWindow(location)
340
+ val root = host.rootView
341
+ val next = Insets.of(
342
+ (window.left - location[0]).coerceIn(0, host.width),
343
+ (window.top - location[1]).coerceIn(0, host.height),
344
+ (location[0] + host.width - (root.width - window.right)).coerceIn(0, host.width),
345
+ (location[1] + host.height - (root.height - window.bottom)).coerceIn(0, host.height),
346
+ )
347
+ if (next == systemInsets) return
348
+ systemInsets = next
349
+ onInsetsChanged()
350
+ }
351
+
293
352
  private fun applyBlur() {
294
353
  val window = dialog.window ?: return
295
354
  if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return
@@ -4,21 +4,51 @@ import android.annotation.SuppressLint
4
4
  import android.view.View
5
5
  import android.view.ViewGroup
6
6
  import android.view.accessibility.AccessibilityEvent
7
+ import com.facebook.react.uimanager.PointerEvents
8
+ import com.facebook.react.uimanager.ReactPointerEventsView
7
9
  import com.facebook.react.uimanager.ThemedReactContext
8
10
 
9
11
  /**
10
- * The view Nitro mounts into the React tree. It is an invisible placeholder:
11
- * React children added to it (via [NitroModalViewManager]) are hosted by
12
- * [contentRoot], which travels into the modal window.
12
+ * The view Nitro mounts into the React tree. React children added to it (via
13
+ * [NitroModalViewManager]) are hosted by [contentRoot].
14
+ *
15
+ * Modal: an invisible placeholder; [contentRoot] travels into the modal
16
+ * window. Inline: the area the sheet lives in. Its only child is the sheet's
17
+ * layout, which it lays out itself, and only touches on the sheet hit it.
13
18
  */
14
19
  @SuppressLint("ViewConstructor")
15
- class NitroModalHostView(context: ThemedReactContext) : ViewGroup(context) {
20
+ class NitroModalHostView(context: ThemedReactContext) : ViewGroup(context), ReactPointerEventsView {
16
21
  internal val contentRoot = ModalContentRoot(context)
17
22
  internal var onAttachedChange: ((attached: Boolean) -> Unit)? = null
18
23
 
24
+ /** The size or position changed. */
25
+ internal var onLayoutChange: (() -> Unit)? = null
26
+
27
+ internal var isInline: Boolean = false
28
+ set(value) {
29
+ if (field == value) return
30
+ field = value
31
+ contentRoot.isInline = value
32
+ }
33
+
19
34
  internal val reactChildCount: Int
20
35
  get() = contentRoot.childCount
21
36
 
37
+ // Inline: touches that miss the sheet go to the views behind it.
38
+ override val pointerEvents: PointerEvents
39
+ get() = if (isInline) PointerEvents.BOX_NONE else PointerEvents.AUTO
40
+
41
+ private var layoutPending = false
42
+
43
+ private val measureAndLayout = Runnable {
44
+ layoutPending = false
45
+ measure(
46
+ MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
47
+ MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY),
48
+ )
49
+ layout(left, top, right, bottom)
50
+ }
51
+
22
52
  internal fun addReactChild(child: View, index: Int) = contentRoot.addView(child, index)
23
53
 
24
54
  internal fun getReactChildAt(index: Int): View? = contentRoot.getChildAt(index)
@@ -31,8 +61,31 @@ class NitroModalHostView(context: ThemedReactContext) : ViewGroup(context) {
31
61
  contentRoot.id = id
32
62
  }
33
63
 
64
+ override fun requestLayout() {
65
+ super.requestLayout()
66
+ // React Native views drop layout requests; the inline sheet's layout
67
+ // (CoordinatorLayout, BottomSheetBehavior) relies on them. Also called
68
+ // from the super constructor, before any child exists.
69
+ if (childCount > 0 && !layoutPending) {
70
+ layoutPending = true
71
+ post(measureAndLayout)
72
+ }
73
+ }
74
+
34
75
  override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
35
- // Laid out by Fabric; our children live in the modal window.
76
+ // Laid out by Fabric. A modal's children live in the modal window; an
77
+ // inline sheet's layout fills this view.
78
+ val width = r - l
79
+ val height = b - t
80
+ for (i in 0 until childCount) {
81
+ val child = getChildAt(i)
82
+ child.measure(
83
+ MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
84
+ MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY),
85
+ )
86
+ child.layout(0, 0, width, height)
87
+ }
88
+ if (changed) onLayoutChange?.invoke()
36
89
  }
37
90
 
38
91
  override fun onAttachedToWindow() {
@@ -45,8 +98,15 @@ class NitroModalHostView(context: ThemedReactContext) : ViewGroup(context) {
45
98
  onAttachedChange?.invoke(false)
46
99
  }
47
100
 
48
- // Accessibility for the content is handled inside the modal window.
49
- override fun addChildrenForAccessibility(outChildren: ArrayList<View>) = Unit
101
+ // A modal's content is handled for accessibility inside the modal window.
102
+ override fun addChildrenForAccessibility(outChildren: ArrayList<View>) {
103
+ if (isInline) super.addChildrenForAccessibility(outChildren)
104
+ }
50
105
 
51
- override fun dispatchPopulateAccessibilityEvent(event: AccessibilityEvent): Boolean = false
106
+ override fun dispatchPopulateAccessibilityEvent(event: AccessibilityEvent): Boolean =
107
+ isInline && super.dispatchPopulateAccessibilityEvent(event)
108
+
109
+ internal fun addInlineRoot(root: ViewGroup) {
110
+ addView(root, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT))
111
+ }
52
112
  }