@swmansion/react-native-bottom-sheet 0.10.2 → 0.12.0

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 (32) hide show
  1. package/README.md +125 -25
  2. package/android/src/main/java/com/swmansion/reactnativebottomsheet/BottomSheetPackage.kt +1 -1
  3. package/android/src/main/java/com/swmansion/reactnativebottomsheet/BottomSheetSurfaceView.kt +10 -0
  4. package/android/src/main/java/com/swmansion/reactnativebottomsheet/BottomSheetSurfaceViewManager.kt +27 -0
  5. package/android/src/main/java/com/swmansion/reactnativebottomsheet/BottomSheetView.kt +97 -28
  6. package/android/src/main/java/com/swmansion/reactnativebottomsheet/BottomSheetViewManager.kt +11 -0
  7. package/common/cpp/react/renderer/components/ReactNativeBottomSheetSpec/ComponentDescriptors.h +4 -0
  8. package/common/cpp/react/renderer/components/ReactNativeBottomSheetSpec/ShadowNodes.h +12 -0
  9. package/ios/BottomSheetComponentView.mm +21 -2
  10. package/ios/BottomSheetContentView.h +3 -0
  11. package/ios/BottomSheetContentView.mm +15 -0
  12. package/ios/BottomSheetHostingView.swift +104 -34
  13. package/ios/BottomSheetSurfaceComponentView.h +13 -0
  14. package/ios/BottomSheetSurfaceComponentView.mm +21 -0
  15. package/lib/module/BottomSheet.js +17 -4
  16. package/lib/module/BottomSheet.js.map +1 -1
  17. package/lib/module/BottomSheetNativeComponent.ts +1 -0
  18. package/lib/module/BottomSheetSurfaceNativeComponent.ts +9 -0
  19. package/lib/module/ModalBottomSheet.js.map +1 -1
  20. package/lib/typescript/src/BottomSheet.d.ts +28 -2
  21. package/lib/typescript/src/BottomSheet.d.ts.map +1 -1
  22. package/lib/typescript/src/BottomSheetNativeComponent.d.ts +1 -0
  23. package/lib/typescript/src/BottomSheetNativeComponent.d.ts.map +1 -1
  24. package/lib/typescript/src/BottomSheetSurfaceNativeComponent.d.ts +6 -0
  25. package/lib/typescript/src/BottomSheetSurfaceNativeComponent.d.ts.map +1 -0
  26. package/lib/typescript/src/ModalBottomSheet.d.ts.map +1 -1
  27. package/package.json +20 -18
  28. package/react-native.config.js +4 -1
  29. package/src/BottomSheet.tsx +46 -1
  30. package/src/BottomSheetNativeComponent.ts +1 -0
  31. package/src/BottomSheetSurfaceNativeComponent.ts +9 -0
  32. package/src/ModalBottomSheet.tsx +4 -2
package/README.md CHANGED
@@ -18,6 +18,47 @@ React Native.
18
18
  - Programmatic‍-‍only detents for snap points unreachable
19
19
  by dragging.
20
20
 
21
+ ## How it compares
22
+
23
+ React Native already has strong bottom‍-‍sheet options, but they make
24
+ different tradeoffs. React Native Bottom Sheet gives you composable React Native
25
+ primitives backed by native sheet mechanics: You compose the surface in React,
26
+ while the sheet host, gestures, snapping, and scroll negotiation run in
27
+ native code.
28
+
29
+ [`@gorhom/bottom-sheet`](https://gorhom.dev/react-native-bottom-sheet) is the
30
+ closest match in day‍-‍to‍-‍day functionality: configurable
31
+ detents, dynamic sizing, scrollable coordination, inline sheets, and modal
32
+ presentation. The main difference is the implementation model. React Native
33
+ Bottom Sheet moves the sheet host, gestures, snapping, and scroll negotiation
34
+ into native code, so heavy React rendering and busy JS work are less likely to
35
+ affect drag and snap performance. It also does not require Reanimated or React
36
+ Native Gesture Handler. Because scroll coordination is native, regular React
37
+ Native scrollables work inside the sheet without
38
+ bottom‍-‍sheet‍-‍specific list components or wrapper factories.
39
+
40
+ [Expo UI](https://docs.expo.dev/versions/latest/sdk/ui) sheets,
41
+ [Expo Router form sheets](https://docs.expo.dev/router/advanced/modals/#form-sheet),
42
+ and native modal‍-‍sheet libraries such as
43
+ [True Sheet](https://sheet.lodev09.com) lean into platform presentation APIs.
44
+ That is a good fit when you want a system‍-‍style presented sheet, but
45
+ it also means the platform and presentation system decide more of the behavior.
46
+ React Native Bottom Sheet is built as a lower‍-‍level sheet primitive
47
+ instead: The same native implementation powers both persistent inline sheets and
48
+ modal sheets, you provide the complete sheet surface in React, and detents can
49
+ include app‍-‍level behavior such as programmatic‍-‍only
50
+ snap points.
51
+
52
+ That difference also matters for layering. A platform‍-‍presented sheet
53
+ can disable dimming and allow background interaction, but it is still drawn as a
54
+ presented native sheet over the React Native view hierarchy. `BottomSheet` is
55
+ actually inline: It renders in your screen’s React Native hierarchy and can be
56
+ layered alongside nearby content. When you do need a modal, `ModalBottomSheet`
57
+ is rendered through `BottomSheetProvider`’s portal rather than through a
58
+ separate native window, so global UI such as toasts, menus, floating controls,
59
+ or debug overlays can be arranged above or below it by where you place them
60
+ relative to the provider.
61
+
21
62
  ## Getting started
22
63
 
23
64
  1. Install React Native Bottom Sheet:
@@ -29,7 +70,7 @@ React Native.
29
70
  2. Ensure the peer dependency is installed:
30
71
 
31
72
  ```sh
32
- npm i react-native-safe-area-context@^4.0.0
73
+ npm i react-native-safe-area-context
33
74
  ```
34
75
 
35
76
  3. Wrap your app with `BottomSheetProvider`:
@@ -41,9 +82,9 @@ React Native.
41
82
  ## Usage
42
83
 
43
84
  The library provides two components: `BottomSheet` (inline) and
44
- `ModalBottomSheet` (modal). Both render their children as the sheet content
45
- (including any background) and are controlled via `detents`, `index`,
46
- and `onIndexChange`. Use `onSettle` for
85
+ `ModalBottomSheet` (modal). Both render their children as the sheet content,
86
+ with a `surface` prop for the background behind it, and are controlled via
87
+ `detents`, `index`, and `onIndexChange`. Use `onSettle` for
47
88
  post‍-‍snap observability.
48
89
 
49
90
  ### Inline
@@ -56,14 +97,14 @@ const insets = useSafeAreaInsets();
56
97
  ```
57
98
 
58
99
  ```tsx
59
- <BottomSheet index={index} onIndexChange={setIndex}>
60
- <View
61
- style={{
62
- backgroundColor: 'white',
63
- padding: 16,
64
- paddingBottom: insets.bottom + 16,
65
- }}
66
- >
100
+ <BottomSheet
101
+ index={index}
102
+ onIndexChange={setIndex}
103
+ surface={
104
+ <View style={[StyleSheet.absoluteFill, { backgroundColor: 'white' }]} />
105
+ }
106
+ >
107
+ <View style={{ padding: 16, paddingBottom: insets.bottom + 16 }}>
67
108
  <Text>Sheet content</Text>
68
109
  </View>
69
110
  </BottomSheet>
@@ -80,14 +121,14 @@ const insets = useSafeAreaInsets();
80
121
  ```
81
122
 
82
123
  ```tsx
83
- <ModalBottomSheet index={index} onIndexChange={setIndex}>
84
- <View
85
- style={{
86
- backgroundColor: 'white',
87
- padding: 16,
88
- paddingBottom: insets.bottom + 16,
89
- }}
90
- >
124
+ <ModalBottomSheet
125
+ index={index}
126
+ onIndexChange={setIndex}
127
+ surface={
128
+ <View style={[StyleSheet.absoluteFill, { backgroundColor: 'white' }]} />
129
+ }
130
+ >
131
+ <View style={{ padding: 16, paddingBottom: insets.bottom + 16 }}>
91
132
  <Text>Sheet content</Text>
92
133
  </View>
93
134
  </ModalBottomSheet>
@@ -102,12 +143,64 @@ its&nbsp;color:
102
143
  <ModalBottomSheet
103
144
  index={index}
104
145
  onIndexChange={setIndex}
146
+ surface={/* ... */}
105
147
  scrimColor="rgba(0, 0, 0, 0.3)"
106
148
  >
107
149
  {/* ... */}
108
150
  </ModalBottomSheet>
109
151
  ```
110
152
 
153
+ By default, the scrim fades in as the sheet opens and then holds at full
154
+ opacity, so detents above the first share the same scrim. Use `scrimOpacities`
155
+ to control the opacity at each detent: It takes one value in 0–1 per detent,
156
+ indexed to match `detents`, and interpolates linearly as the sheet is dragged
157
+ between them. A shorter array reuses its last value for any remaining detents.
158
+
159
+ The default maps each detent to 0 when it is closed and 1 otherwise, so the
160
+ scrim is transparent at any closed detent and fully opaque at every open one,
161
+ whatever order the detents are passed in.
162
+
163
+ To keep the scrim deepening across every detent, pass one value per detent:
164
+
165
+ ```tsx
166
+ <ModalBottomSheet
167
+ index={index}
168
+ onIndexChange={setIndex}
169
+ detents={[0, 300, 'content']}
170
+ scrimColor="rgba(0, 0, 0, 0.3)"
171
+ scrimOpacities={[0, 0.5, 1]}
172
+ surface={/* ... */}
173
+ >
174
+ {/* ... */}
175
+ </ModalBottomSheet>
176
+ ```
177
+
178
+ ### Surface
179
+
180
+ Provide the sheet’s background through the `surface` prop. The library renders
181
+ it behind your content and sizes it natively to cover the whole sheet,
182
+ independently of the content&nbsp;height.
183
+
184
+ Decoupling the surface this way keeps the sheet covered as the content height
185
+ changes. When content shrinks, the sheet animates to its new height without the
186
+ background briefly exposing blank space behind the&nbsp;content.
187
+
188
+ Give the surface a filling style such as `StyleSheet.absoluteFill`. It is
189
+ mounted in a full&zwj;-&zwj;size host, so a surface sized only by its own
190
+ content would collapse and not&nbsp;show.
191
+
192
+ ```tsx
193
+ <BottomSheet // Or `ModalBottomSheet`.
194
+ index={index}
195
+ onIndexChange={setIndex}
196
+ surface={
197
+ <View style={[StyleSheet.absoluteFill, { backgroundColor: 'white' }]} />
198
+ }
199
+ >
200
+ <Text>Sheet content</Text>
201
+ </BottomSheet>
202
+ ```
203
+
111
204
  ### Scrollable negotiation
112
205
 
113
206
  By default, the sheet coordinates vertical gestures with nested scrollables,
@@ -121,6 +214,7 @@ set&nbsp;`disableScrollableNegotiation`:
121
214
  <BottomSheet
122
215
  index={index}
123
216
  onIndexChange={setIndex}
217
+ surface={/* ... */}
124
218
  disableScrollableNegotiation
125
219
  >
126
220
  {/* ... */}
@@ -134,18 +228,20 @@ Detents are the points to which the sheet snaps. Each detent is either a number
134
228
  the available screen height). The default detents are `[0, 'content']`.
135
229
 
136
230
  Sheet children are laid out in a flex container. For a full&zwj;-&zwj;height
137
- sheet, apply `flex: 1` to your sheet surface and use the
138
- `'content'`&nbsp;detent:
231
+ sheet, apply `flex: 1` to your content and use the `'content'`&nbsp;detent.
232
+ `surface` is sized by the library, so `flex: 1` only ever belongs on your
233
+ content, never on the&nbsp;surface:
139
234
 
140
235
  ```tsx
141
236
  <BottomSheet
142
237
  // `detents` defaults to `[0, 'content']`.
143
238
  index={index}
144
239
  onIndexChange={setIndex}
240
+ surface={
241
+ <View style={[StyleSheet.absoluteFill, { backgroundColor: 'white' }]} />
242
+ }
145
243
  >
146
- <View style={{ flex: 1, backgroundColor: 'white' }}>
147
- {/* Full-height sheet content. */}
148
- </View>
244
+ <View style={{ flex: 1 }}>{/* Full-height sheet content. */}</View>
149
245
  </BottomSheet>
150
246
  ```
151
247
 
@@ -168,6 +264,7 @@ const [index, setIndex] = useState(0);
168
264
  detents={[0, 300, 'content']} // Collapsed, 300 px, content height.
169
265
  index={index}
170
266
  onIndexChange={setIndex} // Keep controlled state in sync.
267
+ surface={/* ... */}
171
268
  onSettle={(nextIndex) => {
172
269
  if (nextIndex === 0) console.log('Sheet collapsed.');
173
270
  }}
@@ -190,6 +287,7 @@ drag snapping but can still be targeted via `index`&nbsp;updates.
190
287
  detents={[0, programmatic(300), 'content']}
191
288
  index={index}
192
289
  onIndexChange={setIndex}
290
+ surface={/* ... */}
193
291
  onSettle={(nextIndex) => {
194
292
  console.log(`Settled at ${nextIndex}.`);
195
293
  }}
@@ -207,6 +305,7 @@ pixels from the bottom of the screen to the top of the&nbsp;sheet).
207
305
  <BottomSheet // Or `ModalBottomSheet`.
208
306
  index={index}
209
307
  onIndexChange={setIndex}
308
+ surface={/* ... */}
210
309
  onPositionChange={(position) => {
211
310
  console.log(position);
212
311
  }}
@@ -226,6 +325,7 @@ const position = useSharedValue(0);
226
325
  <BottomSheet
227
326
  index={index}
228
327
  onIndexChange={setIndex}
328
+ surface={/* ... */}
229
329
  onPositionChange={(nextPosition) => {
230
330
  position.value = nextPosition;
231
331
  }}
@@ -10,5 +10,5 @@ class BottomSheetPackage : ReactPackage {
10
10
  emptyList()
11
11
 
12
12
  override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> =
13
- listOf(BottomSheetViewManager())
13
+ listOf(BottomSheetViewManager(), BottomSheetSurfaceViewManager())
14
14
  }
@@ -0,0 +1,10 @@
1
+ package com.swmansion.reactnativebottomsheet
2
+
3
+ import android.content.Context
4
+ import com.facebook.react.views.view.ReactViewGroup
5
+
6
+ // Visual surface that sits behind the sheet content. It carries no behavior of
7
+ // its own; the BottomSheetView identifies it by this type and owns its geometry,
8
+ // laying it out to cover the full sheet so a content shrink never exposes blank
9
+ // space. Its React children provide the appearance only.
10
+ class BottomSheetSurfaceView(context: Context) : ReactViewGroup(context)
@@ -0,0 +1,27 @@
1
+ package com.swmansion.reactnativebottomsheet
2
+
3
+ import com.facebook.react.module.annotations.ReactModule
4
+ import com.facebook.react.uimanager.ThemedReactContext
5
+ import com.facebook.react.uimanager.ViewGroupManager
6
+ import com.facebook.react.uimanager.ViewManagerDelegate
7
+ import com.facebook.react.viewmanagers.BottomSheetSurfaceViewManagerDelegate
8
+ import com.facebook.react.viewmanagers.BottomSheetSurfaceViewManagerInterface
9
+
10
+ @ReactModule(name = BottomSheetSurfaceViewManager.NAME)
11
+ class BottomSheetSurfaceViewManager :
12
+ ViewGroupManager<BottomSheetSurfaceView>(),
13
+ BottomSheetSurfaceViewManagerInterface<BottomSheetSurfaceView> {
14
+
15
+ companion object {
16
+ const val NAME = "BottomSheetSurfaceView"
17
+ }
18
+
19
+ private val delegate = BottomSheetSurfaceViewManagerDelegate(this)
20
+
21
+ override fun getDelegate(): ViewManagerDelegate<BottomSheetSurfaceView> = delegate
22
+
23
+ override fun getName(): String = NAME
24
+
25
+ override fun createViewInstance(context: ThemedReactContext): BottomSheetSurfaceView =
26
+ BottomSheetSurfaceView(context)
27
+ }
@@ -83,11 +83,15 @@ class BottomSheetView(context: Context) : ReactViewGroup(context) {
83
83
  private var scrimPressed = false
84
84
  private var scrimTouchActive = false
85
85
  private var scrimColor = Color.TRANSPARENT
86
+ // The JS layer always supplies a per-detent array; the fully-opaque fallback
87
+ // only guards against empty input (indexing requires a non-empty array).
88
+ private var scrimOpacities = listOf(1f)
86
89
  private var scrimProgress = 0f
87
90
  private var suppressScrimForClosingTarget = false
88
91
  private var scrimPinnedFull = false
89
92
  private var maxDetentHeight = Float.NaN
90
93
  private var contentHeightMarker: View? = null
94
+ private var surfaceView: View? = null
91
95
 
92
96
  private val contentHeightMarkerLayoutListener =
93
97
  View.OnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> refreshDetentsFromLayout() }
@@ -166,7 +170,7 @@ class BottomSheetView(context: Context) : ReactViewGroup(context) {
166
170
  if (animateIn && isInvalidContentDetentTarget(clampedIndex)) {
167
171
  targetIndex = clampedIndex
168
172
  pendingIndex = clampedIndex
169
- val closedTy = detentSpecs.maxOfOrNull { it.height } ?: h.toFloat()
173
+ val closedTy = resolvedMaxDetentHeight(h)
170
174
  sheetContainer.translationY = closedTy
171
175
  emitPosition()
172
176
  return
@@ -177,7 +181,7 @@ class BottomSheetView(context: Context) : ReactViewGroup(context) {
177
181
  targetIndex = clampedIndex
178
182
 
179
183
  if (animateIn) {
180
- val closedTy = detentSpecs.maxOfOrNull { it.height } ?: h.toFloat()
184
+ val closedTy = resolvedMaxDetentHeight(h)
181
185
  sheetContainer.translationY = closedTy
182
186
  emitPosition()
183
187
  snapToIndex(targetIndex, 0f, emitIndexChange = false, emitSettle = true)
@@ -198,18 +202,25 @@ class BottomSheetView(context: Context) : ReactViewGroup(context) {
198
202
  super.dispatchDraw(canvas)
199
203
  }
200
204
 
201
- private fun layoutSheetChildren() {
205
+ private fun layoutSheetChildren(containerWidth: Int, containerHeight: Int) {
202
206
  for (i in 0 until sheetContainer.childCount) {
203
207
  val child = sheetContainer.getChildAt(i)
204
- child.layout(0, 0, child.measuredWidth, child.measuredHeight)
208
+ if (child === surfaceView) {
209
+ // The surface fills the full container so it always covers the visible
210
+ // sheet (the container is translated to the current sheet position),
211
+ // regardless of how short the content becomes.
212
+ child.layout(0, 0, containerWidth, containerHeight)
213
+ } else {
214
+ child.layout(0, 0, child.measuredWidth, child.measuredHeight)
215
+ }
205
216
  }
206
217
  }
207
218
 
208
219
  private fun layoutSheetContainer(viewWidth: Int, viewHeight: Int) {
209
- val maxHeight = detentSpecs.maxOfOrNull { it.height } ?: resolvedMaxDetentHeight(viewHeight)
220
+ val maxHeight = resolvedMaxDetentHeight(viewHeight)
210
221
  val containerTop = (viewHeight - maxHeight).toInt()
211
222
  sheetContainer.layout(0, containerTop, viewWidth, containerTop + maxHeight.toInt())
212
- layoutSheetChildren()
223
+ layoutSheetChildren(viewWidth, maxHeight.toInt())
213
224
  }
214
225
 
215
226
  // MARK: - Prop setters
@@ -247,11 +258,22 @@ class BottomSheetView(context: Context) : ReactViewGroup(context) {
247
258
  invalidate()
248
259
  }
249
260
 
261
+ fun setScrimOpacities(values: List<Float>) {
262
+ scrimOpacities = if (values.isEmpty()) listOf(1f) else values
263
+ updateScrim()
264
+ }
265
+
250
266
  fun setMaxDetentHeight(maxDetentHeight: Double) {
251
267
  this.maxDetentHeight = (maxDetentHeight * density).toFloat()
252
268
  refreshDetentsFromLayout()
253
269
  }
254
270
 
271
+ // Stable coordinate base for the sheet container. The container is sized to
272
+ // the full available height rather than the tallest detent, so it stays a
273
+ // fixed-size canvas: when content — and thus the `content` detent — shrinks,
274
+ // the container does not collapse underneath the sheet, leaving room to
275
+ // animate the sheet down to its new height. The surface fills this canvas, so
276
+ // the area below the shrunken content stays covered throughout.
255
277
  private fun resolvedMaxDetentHeight(viewHeight: Int = height): Float {
256
278
  val viewHeightPx = viewHeight.toFloat()
257
279
  if (!maxDetentHeight.isFinite() || maxDetentHeight <= 0f) {
@@ -294,7 +316,7 @@ class BottomSheetView(context: Context) : ReactViewGroup(context) {
294
316
  return
295
317
  }
296
318
 
297
- val previousMaxHeight = detentSpecs.maxOfOrNull { it.height } ?: resolvedMaxDetentHeight()
319
+ val previousMaxHeight = resolvedMaxDetentHeight()
298
320
  // Whether the scrim is currently fully opaque, i.e. the sheet is settled at
299
321
  // or above the first non-zero detent. If so, a detent resize must not dip
300
322
  // the scrim while the sheet re-anchors to the new geometry.
@@ -308,7 +330,7 @@ class BottomSheetView(context: Context) : ReactViewGroup(context) {
308
330
 
309
331
  if (hasLaidOut && !isPanning) {
310
332
  targetIndex = targetIndex.coerceIn(0, detentSpecs.size - 1)
311
- val newMaxHeight = detentSpecs.maxOfOrNull { it.height } ?: resolvedMaxDetentHeight()
333
+ val newMaxHeight = resolvedMaxDetentHeight()
312
334
  val targetTy = translationY(targetIndex)
313
335
  if (activeAnimation != null && isTargetingClosedDetent) {
314
336
  suppressScrimForClosingTarget = true
@@ -337,20 +359,15 @@ class BottomSheetView(context: Context) : ReactViewGroup(context) {
337
359
  } else {
338
360
  val currentVisibleHeight = previousMaxHeight - sheetContainer.translationY
339
361
  val targetHeight = detentSpecs.getOrNull(targetIndex)?.height ?: 0f
340
- val isContentDetent = rawDetentSpecs.getOrNull(targetIndex)?.kind == DetentKind.CONTENT
341
- val didShrink = targetHeight < currentVisibleHeight - 0.5f
342
- if (isContentDetent && didShrink) {
343
- // Content shrank: snap immediately. Animating here would expose
344
- // blank space below the shrunken content.
345
- sheetContainer.translationY = targetTy
346
- emitPosition()
347
- } else if (kotlin.math.abs(targetHeight - currentVisibleHeight) <= 0.5f) {
362
+ if (kotlin.math.abs(targetHeight - currentVisibleHeight) <= 0.5f) {
348
363
  // No meaningful change.
349
364
  sheetContainer.translationY = targetTy
350
365
  emitPosition()
351
366
  } else {
352
- // Detent value changed (or content grew): re-anchor at the current
353
- // visible height, then animate to the new target.
367
+ // The content detent changed (grew or shrank): re-anchor at the
368
+ // current visible height, then animate to the new target. The
369
+ // surface covers the full sheet, so a shrink no longer exposes
370
+ // blank space.
354
371
  sheetContainer.translationY =
355
372
  (newMaxHeight - currentVisibleHeight).coerceIn(0f, newMaxHeight)
356
373
  scrimPinnedFull = scrimPinnedFull || wasScrimFull
@@ -386,6 +403,7 @@ class BottomSheetView(context: Context) : ReactViewGroup(context) {
386
403
  }
387
404
 
388
405
  private fun refreshContentHeightMarker() {
406
+ surfaceView = findSurfaceView()
389
407
  val marker = findContentHeightMarker()
390
408
  if (marker === contentHeightMarker) return
391
409
  contentHeightMarker?.removeOnLayoutChangeListener(contentHeightMarkerLayoutListener)
@@ -393,8 +411,21 @@ class BottomSheetView(context: Context) : ReactViewGroup(context) {
393
411
  contentHeightMarker?.addOnLayoutChangeListener(contentHeightMarkerLayoutListener)
394
412
  }
395
413
 
414
+ private fun findSurfaceView(): View? {
415
+ for (i in 0 until sheetContainer.childCount) {
416
+ val child = sheetContainer.getChildAt(i)
417
+ if (child is BottomSheetSurfaceView) return child
418
+ }
419
+ return null
420
+ }
421
+
396
422
  private fun findContentHeightMarker(): View? {
397
- val contentView = sheetContainer.getChildAt(0) as? ViewGroup ?: return null
423
+ // The surface is a sibling of the content wrapper; skip it so the marker is
424
+ // always read from the content, never from the surface.
425
+ val contentView =
426
+ (0 until sheetContainer.childCount)
427
+ .map { sheetContainer.getChildAt(it) }
428
+ .firstOrNull { it !== surfaceView } as? ViewGroup ?: return null
398
429
  if (contentView.childCount == 0) return null
399
430
  return contentView.getChildAt(contentView.childCount - 1)
400
431
  }
@@ -402,7 +433,7 @@ class BottomSheetView(context: Context) : ReactViewGroup(context) {
402
433
  // MARK: - Snap logic
403
434
 
404
435
  private fun translationY(index: Int): Float {
405
- val maxHeight = detentSpecs.maxOfOrNull { it.height } ?: resolvedMaxDetentHeight()
436
+ val maxHeight = resolvedMaxDetentHeight()
406
437
  val snapHeight = detentSpecs.getOrNull(index)?.height ?: 0f
407
438
  return maxHeight - snapHeight
408
439
  }
@@ -447,7 +478,7 @@ class BottomSheetView(context: Context) : ReactViewGroup(context) {
447
478
  }
448
479
 
449
480
  private fun emitPosition() {
450
- val maxHeight = detentSpecs.maxOfOrNull { it.height } ?: resolvedMaxDetentHeight()
481
+ val maxHeight = resolvedMaxDetentHeight()
451
482
  val ty = sheetContainer.translationY
452
483
  val position = maxHeight - ty
453
484
  updateScrim(position)
@@ -464,7 +495,7 @@ class BottomSheetView(context: Context) : ReactViewGroup(context) {
464
495
  private var lastShadowOffsetY = Float.NaN
465
496
 
466
497
  private fun updateShadowState(translationY: Float) {
467
- val maxDetentHeight = detentSpecs.maxOfOrNull { it.height } ?: resolvedMaxDetentHeight()
498
+ val maxDetentHeight = resolvedMaxDetentHeight()
468
499
  val containerTop = height.toFloat() - maxDetentHeight
469
500
  val offsetY = ((containerTop + translationY) / density).toDouble()
470
501
  if (offsetY.toFloat() == lastShadowOffsetY) return
@@ -703,7 +734,7 @@ class BottomSheetView(context: Context) : ReactViewGroup(context) {
703
734
  v
704
735
  } ?: 0f
705
736
  velocityTracker = null
706
- val maxHeight = detentSpecs.maxOfOrNull { it.height } ?: resolvedMaxDetentHeight()
737
+ val maxHeight = resolvedMaxDetentHeight()
707
738
  val currentHeight = maxHeight - sheetContainer.translationY
708
739
  val index = bestSnapIndex(currentHeight, velocity, panStartingIndex)
709
740
  panStartingIndex = null
@@ -814,6 +845,7 @@ class BottomSheetView(context: Context) : ReactViewGroup(context) {
814
845
  velocityTracker = null
815
846
  contentHeightMarker?.removeOnLayoutChangeListener(contentHeightMarkerLayoutListener)
816
847
  contentHeightMarker = null
848
+ surfaceView = null
817
849
  rawDetentSpecs = emptyList()
818
850
  detentSpecs = emptyList()
819
851
  targetIndex = 0
@@ -854,18 +886,55 @@ class BottomSheetView(context: Context) : ReactViewGroup(context) {
854
886
 
855
887
  // While the sheet is fully open and only its content/detent geometry is
856
888
  // resizing, the position momentarily lags the grown detent height. Keep the
857
- // scrim at full opacity instead of dipping it until the re-anchor settles.
889
+ // scrim pinned to the fully-open opacity instead of dipping it until the
890
+ // re-anchor settles.
858
891
  if (scrimPinnedFull) {
859
- scrimProgress = 1f
892
+ scrimProgress = fullyOpenScrimOpacity()
860
893
  invalidate()
861
894
  return
862
895
  }
863
896
 
864
- val threshold = firstNonZeroDetentHeight
865
- scrimProgress = if (threshold <= 0f) 0f else (position / threshold).coerceIn(0f, 1f)
897
+ scrimProgress = scrimOpacityAt(position)
866
898
  invalidate()
867
899
  }
868
900
 
901
+ /** The opacity at the tallest detent, held while the sheet re-anchors. */
902
+ private fun fullyOpenScrimOpacity(): Float {
903
+ val maxHeight = detentSpecs.maxOfOrNull { it.height } ?: return 1f
904
+ return scrimOpacityAt(maxHeight)
905
+ }
906
+
907
+ /**
908
+ * Interpolates the scrim opacity for a sheet height by bracketing it between adjacent detent
909
+ * heights and lerping each detent index's configured value.
910
+ */
911
+ private fun scrimOpacityAt(position: Float): Float {
912
+ if (detentSpecs.isEmpty()) return 0f
913
+ val pairs =
914
+ detentSpecs.indices
915
+ .map { index ->
916
+ detentSpecs[index].height to
917
+ scrimOpacities[index.coerceAtMost(scrimOpacities.size - 1)].coerceIn(0f, 1f)
918
+ }
919
+ .sortedBy { it.first }
920
+
921
+ val first = pairs.first()
922
+ val last = pairs.last()
923
+ if (position <= first.first) return first.second
924
+ if (position >= last.first) return last.second
925
+
926
+ for (i in 1 until pairs.size) {
927
+ val upper = pairs[i]
928
+ if (position <= upper.first) {
929
+ val lower = pairs[i - 1]
930
+ val span = upper.first - lower.first
931
+ val t = if (span <= 0f) 1f else (position - lower.first) / span
932
+ return lower.second + (upper.second - lower.second) * t
933
+ }
934
+ }
935
+ return last.second
936
+ }
937
+
869
938
  private fun hideScrim() {
870
939
  scrimProgress = 0f
871
940
  invalidate()
@@ -881,7 +950,7 @@ class BottomSheetView(context: Context) : ReactViewGroup(context) {
881
950
  }
882
951
 
883
952
  private fun currentSheetHeight(): Float {
884
- val maxHeight = detentSpecs.maxOfOrNull { it.height } ?: resolvedMaxDetentHeight()
953
+ val maxHeight = resolvedMaxDetentHeight()
885
954
  return maxHeight - sheetContainer.translationY
886
955
  }
887
956
 
@@ -138,6 +138,17 @@ class BottomSheetViewManager :
138
138
  view.setScrimColor(scrimColor)
139
139
  }
140
140
 
141
+ @ReactProp(name = "scrimOpacities")
142
+ override fun setScrimOpacities(view: BottomSheetView, value: ReadableArray?) {
143
+ val opacities = mutableListOf<Float>()
144
+ if (value != null) {
145
+ for (i in 0 until value.size()) {
146
+ opacities.add(value.getDouble(i).toFloat())
147
+ }
148
+ }
149
+ view.setScrimOpacities(opacities)
150
+ }
151
+
141
152
  override fun onDropViewInstance(view: BottomSheetView) {
142
153
  super.onDropViewInstance(view)
143
154
  view.destroy()
@@ -19,4 +19,8 @@ class BottomSheetViewComponentDescriptor final
19
19
  }
20
20
  };
21
21
 
22
+ // The surface needs no custom initial state, so this mirrors the codegen alias.
23
+ using BottomSheetSurfaceViewComponentDescriptor =
24
+ ConcreteComponentDescriptor<BottomSheetSurfaceViewShadowNode>;
25
+
22
26
  } // namespace facebook::react
@@ -5,6 +5,7 @@
5
5
  #include <react/renderer/components/ReactNativeBottomSheetSpec/EventEmitters.h>
6
6
  #include <react/renderer/components/ReactNativeBottomSheetSpec/Props.h>
7
7
  #include <react/renderer/components/view/ConcreteViewShadowNode.h>
8
+ #include <react/renderer/core/StateData.h>
8
9
 
9
10
  namespace facebook::react {
10
11
 
@@ -22,4 +23,15 @@ class JSI_EXPORT BottomSheetViewShadowNode final
22
23
  Point getContentOriginOffset(bool includeTransform) const override;
23
24
  };
24
25
 
26
+ JSI_EXPORT extern const char BottomSheetSurfaceViewComponentName[];
27
+
28
+ // The surface needs no custom shadow-node behavior, so this mirrors the codegen
29
+ // alias exactly (state is the default StateData). It only lives here because the
30
+ // custom header search path shadows the generated ShadowNodes.h.
31
+ using BottomSheetSurfaceViewShadowNode = ConcreteViewShadowNode<
32
+ BottomSheetSurfaceViewComponentName,
33
+ BottomSheetSurfaceViewProps,
34
+ BottomSheetSurfaceViewEventEmitter,
35
+ StateData>;
36
+
25
37
  } // namespace facebook::react
@@ -1,5 +1,6 @@
1
1
  #import "BottomSheetComponentView.h"
2
2
  #import "BottomSheetContentView.h"
3
+ #import "BottomSheetSurfaceComponentView.h"
3
4
  #import "../common/cpp/react/renderer/components/ReactNativeBottomSheetSpec/BottomSheetStateHelper.h"
4
5
  #import "../common/cpp/react/renderer/components/ReactNativeBottomSheetSpec/ComponentDescriptors.h"
5
6
 
@@ -85,6 +86,14 @@ using namespace facebook::react;
85
86
  [_sheetView setScrimColor:RCTUIColorFromSharedColor(newViewProps.scrimColor)];
86
87
  }
87
88
 
89
+ if (newViewProps.scrimOpacities != oldViewProps.scrimOpacities) {
90
+ NSMutableArray<NSNumber *> *opacities = [NSMutableArray new];
91
+ for (const auto &opacity : newViewProps.scrimOpacities) {
92
+ [opacities addObject:@(opacity)];
93
+ }
94
+ [_sheetView setScrimOpacities:opacities];
95
+ }
96
+
88
97
  [super updateProps:props oldProps:oldProps];
89
98
  }
90
99
 
@@ -95,12 +104,22 @@ using namespace facebook::react;
95
104
 
96
105
  - (void)mountChildComponentView:(UIView<RCTComponentViewProtocol> *)childComponentView index:(NSInteger)index
97
106
  {
98
- [_sheetView mountChildComponentView:childComponentView atIndex:index];
107
+ // Identify the visual surface by component type so the host can own its
108
+ // geometry. Everything else is treated as content.
109
+ if ([childComponentView isKindOfClass:BottomSheetSurfaceComponentView.class]) {
110
+ [_sheetView mountSurfaceComponentView:childComponentView atIndex:index];
111
+ } else {
112
+ [_sheetView mountChildComponentView:childComponentView atIndex:index];
113
+ }
99
114
  }
100
115
 
101
116
  - (void)unmountChildComponentView:(UIView<RCTComponentViewProtocol> *)childComponentView index:(NSInteger)index
102
117
  {
103
- [_sheetView unmountChildComponentView:childComponentView];
118
+ if ([childComponentView isKindOfClass:BottomSheetSurfaceComponentView.class]) {
119
+ [_sheetView unmountSurfaceComponentView:childComponentView];
120
+ } else {
121
+ [_sheetView unmountChildComponentView:childComponentView];
122
+ }
104
123
  }
105
124
 
106
125
  #pragma mark - BottomSheetContentViewDelegate
@@ -23,9 +23,12 @@ NS_ASSUME_NONNULL_BEGIN
23
23
  - (void)setMaxDetentHeight:(CGFloat)maxDetentHeight;
24
24
  - (void)setDetentIndex:(NSInteger)newIndex;
25
25
  - (void)setScrimColor:(UIColor *_Nullable)color;
26
+ - (void)setScrimOpacities:(NSArray<NSNumber *> *)opacities;
26
27
  - (CGFloat)currentContentOffsetY;
27
28
  - (void)mountChildComponentView:(UIView *)childView atIndex:(NSInteger)index;
28
29
  - (void)unmountChildComponentView:(UIView *)childView;
30
+ - (void)mountSurfaceComponentView:(UIView *)surfaceView atIndex:(NSInteger)index;
31
+ - (void)unmountSurfaceComponentView:(UIView *)surfaceView;
29
32
  - (void)resetSheetState;
30
33
 
31
34
  @end