@swmansion/react-native-bottom-sheet 0.9.0-next.3 → 0.9.1-next.1

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.
package/README.md CHANGED
@@ -10,6 +10,7 @@ React Native.
10
10
  ## Highlights
11
11
 
12
12
  - Native implementation for optimal performance.
13
+ - Both inline and modal sheet components.
13
14
  - Bring your own sheet surface.
14
15
  - Dynamic, content‍-‍based sizing out of the box.
15
16
  - Automatic handling of vertically scrollable children.
@@ -70,7 +71,8 @@ const insets = useSafeAreaInsets();
70
71
 
71
72
  ### Modal
72
73
 
73
- `ModalBottomSheet` renders above other content with a scrim.
74
+ `ModalBottomSheet` renders above other content with an optional scrim
75
+ (transparent by default).
74
76
 
75
77
  ```tsx
76
78
  const [index, setIndex] = useState(0);
@@ -106,6 +108,25 @@ its color:
106
108
  </ModalBottomSheet>
107
109
  ```
108
110
 
111
+ ### Scrollable negotiation
112
+
113
+ By default, the sheet coordinates vertical gestures with nested scrollables,
114
+ such as `ScrollView` and&nbsp;`FlatList`.
115
+
116
+ If you want gestures that start inside a nested scrollable to stay with that
117
+ scrollable even when it cannot scroll any further,
118
+ set&nbsp;`disableScrollableNegotiation`:
119
+
120
+ ```tsx
121
+ <BottomSheet
122
+ index={index}
123
+ onIndexChange={setIndex}
124
+ disableScrollableNegotiation
125
+ >
126
+ {/* ... */}
127
+ </BottomSheet>
128
+ ```
129
+
109
130
  ### Detents and index
110
131
 
111
132
  Detents are the points to which the sheet snaps. Each detent is either a number
@@ -139,6 +160,9 @@ const [index, setIndex] = useState(0);
139
160
  </BottomSheet>
140
161
  ```
141
162
 
163
+ Detents can also change over time. When you update `detents`, the sheet keeps
164
+ the current index and animates to the updated detent height when needed.
165
+
142
166
  #### Programmatic-only detents
143
167
 
144
168
  If you want a detent to be reachable only via code (not by dragging), use the
@@ -17,8 +17,8 @@ import androidx.dynamicanimation.animation.SpringForce
17
17
  import com.facebook.react.bridge.Arguments
18
18
  import com.facebook.react.uimanager.PointerEvents
19
19
  import com.facebook.react.uimanager.StateWrapper
20
- import com.facebook.react.views.view.ReactViewGroup
21
20
  import com.facebook.react.uimanager.events.NativeGestureUtil
21
+ import com.facebook.react.views.view.ReactViewGroup
22
22
  import kotlin.math.abs
23
23
 
24
24
  private enum class DetentKind {
@@ -27,11 +27,14 @@ private enum class DetentKind {
27
27
  }
28
28
 
29
29
  private data class RawDetentSpec(val value: Float, val kind: DetentKind, val programmatic: Boolean)
30
+
30
31
  private data class DetentSpec(val height: Float, val programmatic: Boolean)
31
32
 
32
33
  interface BottomSheetViewListener {
33
34
  fun onIndexChange(index: Int)
35
+
34
36
  fun onSettle(index: Int)
37
+
35
38
  fun onPositionChange(position: Double)
36
39
  }
37
40
 
@@ -54,6 +57,7 @@ class BottomSheetView(context: Context) : ReactViewGroup(context) {
54
57
  updateInteractionState()
55
58
  updateScrim()
56
59
  }
60
+
57
61
  var disableScrollableNegotiation: Boolean = false
58
62
  private var pendingIndex: Int? = null
59
63
  private var hasLaidOut = false
@@ -76,15 +80,15 @@ class BottomSheetView(context: Context) : ReactViewGroup(context) {
76
80
  private var lastTouchY = 0f
77
81
  private var activePointerId = MotionEvent.INVALID_POINTER_ID
78
82
  private var scrimPressed = false
83
+ private var scrimTouchActive = false
79
84
  private var scrimColor = Color.TRANSPARENT
80
85
  private var scrimProgress = 0f
86
+ private var suppressScrimForClosingTarget = false
81
87
  private var maxDetentHeight = Float.NaN
82
88
  private var contentHeightMarker: View? = null
83
89
 
84
90
  private val contentHeightMarkerLayoutListener =
85
- View.OnLayoutChangeListener { _, _, _, _, _, _, _, _, _ ->
86
- refreshDetentsFromLayout()
87
- }
91
+ View.OnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> refreshDetentsFromLayout() }
88
92
 
89
93
  init {
90
94
  clipChildren = false
@@ -143,10 +147,10 @@ class BottomSheetView(context: Context) : ReactViewGroup(context) {
143
147
 
144
148
  // MARK: - Layout
145
149
 
146
- override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
147
- super.onLayout(changed, l, t, r, b)
148
- val w = r - l
149
- val h = b - t
150
+ override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
151
+ super.onLayout(changed, left, top, right, bottom)
152
+ val w = right - left
153
+ val h = bottom - top
150
154
  if (w <= 0 || h <= 0) return
151
155
 
152
156
  refreshContentHeightMarker()
@@ -154,10 +158,21 @@ class BottomSheetView(context: Context) : ReactViewGroup(context) {
154
158
  layoutSheetContainer(w, h)
155
159
 
156
160
  if (!hasLaidOut && detentSpecs.isNotEmpty()) {
157
- hasLaidOut = true
158
161
  val indexToApply = pendingIndex ?: targetIndex
162
+ val clampedIndex = indexToApply.coerceIn(0, detentSpecs.size - 1)
163
+
164
+ if (animateIn && isInvalidContentDetentTarget(clampedIndex)) {
165
+ targetIndex = clampedIndex
166
+ pendingIndex = clampedIndex
167
+ val closedTy = detentSpecs.maxOfOrNull { it.height } ?: h.toFloat()
168
+ sheetContainer.translationY = closedTy
169
+ emitPosition()
170
+ return
171
+ }
172
+
173
+ hasLaidOut = true
159
174
  pendingIndex = null
160
- targetIndex = indexToApply.coerceIn(0, detentSpecs.size - 1)
175
+ targetIndex = clampedIndex
161
176
 
162
177
  if (animateIn) {
163
178
  val closedTy = detentSpecs.maxOfOrNull { it.height } ?: h.toFloat()
@@ -198,16 +213,17 @@ class BottomSheetView(context: Context) : ReactViewGroup(context) {
198
213
  // MARK: - Prop setters
199
214
 
200
215
  fun setDetents(raw: List<Map<String, Any>>) {
201
- rawDetentSpecs = raw.mapNotNull { dict ->
202
- val value = (dict["value"] as? Number)?.toDouble() ?: return@mapNotNull null
203
- val kind =
204
- when ((dict["kind"] as? String)?.lowercase()) {
205
- "content" -> DetentKind.CONTENT
206
- else -> DetentKind.POINTS
207
- }
208
- val programmatic = dict["programmatic"] as? Boolean ?: false
209
- RawDetentSpec(value = (value * density).toFloat(), kind = kind, programmatic = programmatic)
210
- }
216
+ rawDetentSpecs =
217
+ raw.mapNotNull { dict ->
218
+ val value = (dict["value"] as? Number)?.toDouble() ?: return@mapNotNull null
219
+ val kind =
220
+ when ((dict["kind"] as? String)?.lowercase()) {
221
+ "content" -> DetentKind.CONTENT
222
+ else -> DetentKind.POINTS
223
+ }
224
+ val programmatic = dict["programmatic"] as? Boolean ?: false
225
+ RawDetentSpec(value = (value * density).toFloat(), kind = kind, programmatic = programmatic)
226
+ }
211
227
  refreshDetentsFromLayout()
212
228
  }
213
229
 
@@ -244,7 +260,7 @@ class BottomSheetView(context: Context) : ReactViewGroup(context) {
244
260
 
245
261
  private fun resolveDetentSpecs(): List<DetentSpec> {
246
262
  val maxHeight = resolvedMaxDetentHeight()
247
- val contentHeight = currentContentHeight().takeIf { it.isFinite() && it > 0f } ?: maxHeight
263
+ val contentHeight = validContentHeight().takeIf { it.isFinite() } ?: maxHeight
248
264
  return rawDetentSpecs.map { spec ->
249
265
  val height =
250
266
  when (spec.kind) {
@@ -256,6 +272,11 @@ class BottomSheetView(context: Context) : ReactViewGroup(context) {
256
272
  }
257
273
 
258
274
  private fun refreshDetentsFromLayout() {
275
+ if (hasLaidOut && isInvalidContentDetentTarget(targetIndex)) {
276
+ updateScrim()
277
+ return
278
+ }
279
+
259
280
  val resolvedDetents = resolveDetentSpecs()
260
281
  if (resolvedDetents == detentSpecs) {
261
282
  updateScrim()
@@ -268,6 +289,10 @@ class BottomSheetView(context: Context) : ReactViewGroup(context) {
268
289
 
269
290
  if (hasLaidOut && !isPanning) {
270
291
  targetIndex = targetIndex.coerceIn(0, detentSpecs.size - 1)
292
+ if (activeAnimation != null && isTargetingClosedDetent) {
293
+ suppressScrimForClosingTarget = true
294
+ hideScrim()
295
+ }
271
296
  if (activeAnimation != null) {
272
297
  val currentTy = sheetContainer.translationY
273
298
  val shouldEmitSettle = activeAnimationEmitsSettle
@@ -301,6 +326,15 @@ class BottomSheetView(context: Context) : ReactViewGroup(context) {
301
326
  return marker.top.toFloat()
302
327
  }
303
328
 
329
+ private fun validContentHeight(): Float {
330
+ return currentContentHeight().takeIf { it.isFinite() && it > 0f } ?: Float.NaN
331
+ }
332
+
333
+ private fun isInvalidContentDetentTarget(index: Int): Boolean {
334
+ return rawDetentSpecs.getOrNull(index)?.kind == DetentKind.CONTENT &&
335
+ !validContentHeight().isFinite()
336
+ }
337
+
304
338
  private fun refreshContentHeightMarker() {
305
339
  val marker = findContentHeightMarker()
306
340
  if (marker === contentHeightMarker) return
@@ -323,12 +357,21 @@ class BottomSheetView(context: Context) : ReactViewGroup(context) {
323
357
  return maxHeight - snapHeight
324
358
  }
325
359
 
360
+ private val minDetentTranslationY: Float
361
+ get() = detentSpecs.indices.minOfOrNull(::translationY) ?: 0f
362
+
363
+ private val maxDetentTranslationY: Float
364
+ get() = detentSpecs.indices.maxOfOrNull(::translationY) ?: 0f
365
+
326
366
  private val closedIndex: Int?
327
367
  get() = detentSpecs.indexOfFirst { it.height == 0f }.takeIf { it >= 0 }
328
368
 
329
369
  private val firstNonZeroDetentHeight: Float
330
370
  get() = detentSpecs.firstOrNull { it.height > 0f }?.height ?: 0f
331
371
 
372
+ private val isTargetingClosedDetent: Boolean
373
+ get() = closedIndex?.let { targetIndex == it } == true
374
+
332
375
  private val draggableMinTy: Float
333
376
  get() {
334
377
  val highestIndex = detentSpecs.indices.lastOrNull { !detentSpecs[it].programmatic } ?: 0
@@ -377,12 +420,13 @@ class BottomSheetView(context: Context) : ReactViewGroup(context) {
377
420
 
378
421
  private fun startChoreographer() {
379
422
  if (choreographerCallback != null) return
380
- val callback = object : Choreographer.FrameCallback {
381
- override fun doFrame(frameTimeNanos: Long) {
382
- emitPosition()
383
- choreographerCallback?.let { Choreographer.getInstance().postFrameCallback(it) }
423
+ val callback =
424
+ object : Choreographer.FrameCallback {
425
+ override fun doFrame(frameTimeNanos: Long) {
426
+ emitPosition()
427
+ choreographerCallback?.let { Choreographer.getInstance().postFrameCallback(it) }
428
+ }
384
429
  }
385
- }
386
430
  choreographerCallback = callback
387
431
  Choreographer.getInstance().postFrameCallback(callback)
388
432
  }
@@ -402,30 +446,42 @@ class BottomSheetView(context: Context) : ReactViewGroup(context) {
402
446
  ) {
403
447
  if (index < 0 || index >= detentSpecs.size) return
404
448
  targetIndex = index
449
+ if (!isTargetingClosedDetent) {
450
+ suppressScrimForClosingTarget = false
451
+ }
405
452
 
406
453
  val targetTy = translationY(index)
407
454
  activeAnimationEmitsSettle = emitSettle
408
455
  activeAnimation?.cancel()
409
456
 
410
- val spring = SpringAnimation(sheetContainer, DynamicAnimation.TRANSLATION_Y, targetTy).apply {
411
- spring = SpringForce(targetTy).apply {
412
- dampingRatio = SpringForce.DAMPING_RATIO_NO_BOUNCY
413
- stiffness = SpringForce.STIFFNESS_MEDIUM
414
- }
415
- setStartVelocity(velocity)
416
- addEndListener { _, canceled, _, _ ->
417
- if (canceled) {
418
- return@addEndListener
457
+ val spring =
458
+ SpringAnimation(sheetContainer, DynamicAnimation.TRANSLATION_Y, targetTy).apply {
459
+ spring =
460
+ SpringForce(targetTy).apply {
461
+ dampingRatio = SpringForce.DAMPING_RATIO_NO_BOUNCY
462
+ stiffness = SpringForce.STIFFNESS_MEDIUM
463
+ }
464
+ setMinValue(minDetentTranslationY)
465
+ setMaxValue(maxDetentTranslationY)
466
+ setStartVelocity(velocity)
467
+ addEndListener { _, canceled, _, _ ->
468
+ if (canceled) {
469
+ return@addEndListener
470
+ }
471
+ stopChoreographer()
472
+ activeAnimation = null
473
+ activeAnimationEmitsSettle = false
474
+ suppressScrimForClosingTarget = false
475
+ if (closedIndex == index) {
476
+ sheetContainer.translationY = translationY(index)
477
+ hideScrim()
478
+ }
479
+ emitPosition()
480
+ updateInteractionState()
481
+ if (emitIndexChange) listener?.onIndexChange(index)
482
+ if (emitSettle) listener?.onSettle(index)
419
483
  }
420
- stopChoreographer()
421
- emitPosition()
422
- activeAnimation = null
423
- activeAnimationEmitsSettle = false
424
- updateInteractionState()
425
- if (emitIndexChange) listener?.onIndexChange(index)
426
- if (emitSettle) listener?.onSettle(index)
427
484
  }
428
- }
429
485
 
430
486
  activeAnimation = spring
431
487
  startChoreographer()
@@ -440,11 +496,13 @@ class BottomSheetView(context: Context) : ReactViewGroup(context) {
440
496
 
441
497
  if (velocity < -flickThreshold) {
442
498
  return draggable.firstOrNull { it.value.height > currentHeight }?.index
443
- ?: draggable.lastOrNull()?.index ?: targetIndex
499
+ ?: draggable.lastOrNull()?.index
500
+ ?: targetIndex
444
501
  }
445
502
  if (velocity > flickThreshold) {
446
503
  return draggable.lastOrNull { it.value.height < currentHeight }?.index
447
- ?: draggable.firstOrNull()?.index ?: targetIndex
504
+ ?: draggable.firstOrNull()?.index
505
+ ?: targetIndex
448
506
  }
449
507
 
450
508
  return draggable.minByOrNull { abs(it.value.height - currentHeight) }?.index ?: targetIndex
@@ -452,32 +510,34 @@ class BottomSheetView(context: Context) : ReactViewGroup(context) {
452
510
 
453
511
  // MARK: - Touch handling
454
512
 
455
- override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
513
+ override fun onInterceptTouchEvent(event: MotionEvent): Boolean {
456
514
  val sheetTop = sheetContainer.top + sheetContainer.translationY
457
- if (ev.actionMasked == MotionEvent.ACTION_DOWN && ev.y < sheetTop) {
515
+ if (event.actionMasked == MotionEvent.ACTION_DOWN && event.y < sheetTop) {
458
516
  if (isScrimVisible()) {
459
- initialTouchX = ev.x
460
- initialTouchY = ev.y
461
- lastTouchY = ev.y
517
+ initialTouchX = event.x
518
+ initialTouchY = event.y
519
+ lastTouchY = event.y
520
+ activePointerId = event.getPointerId(0)
462
521
  scrimPressed = true
522
+ scrimTouchActive = true
463
523
  return true
464
524
  }
465
525
  return false
466
526
  }
467
527
 
468
- when (ev.actionMasked) {
528
+ when (event.actionMasked) {
469
529
  MotionEvent.ACTION_DOWN -> {
470
- initialTouchX = ev.x
471
- initialTouchY = ev.y
472
- lastTouchY = ev.y
473
- activePointerId = ev.getPointerId(0)
530
+ initialTouchX = event.x
531
+ initialTouchY = event.y
532
+ lastTouchY = event.y
533
+ activePointerId = event.getPointerId(0)
474
534
  }
475
535
  MotionEvent.ACTION_MOVE -> {
476
536
  if (activePointerId == MotionEvent.INVALID_POINTER_ID) return false
477
- val pointerIndex = ev.findPointerIndex(activePointerId)
537
+ val pointerIndex = event.findPointerIndex(activePointerId)
478
538
  if (pointerIndex < 0) return false
479
- val x = ev.getX(pointerIndex)
480
- val y = ev.getY(pointerIndex)
539
+ val x = event.getX(pointerIndex)
540
+ val y = event.getY(pointerIndex)
481
541
  val dx = x - initialTouchX
482
542
  val dy = y - initialTouchY
483
543
 
@@ -492,29 +552,31 @@ class BottomSheetView(context: Context) : ReactViewGroup(context) {
492
552
  // processes events at the root view level before onInterceptTouchEvent
493
553
  // runs, so without this the JS side never sees a cancel and Pressable
494
554
  // would still fire onPress.
495
- NativeGestureUtil.notifyNativeGestureStarted(this, ev)
555
+ NativeGestureUtil.notifyNativeGestureStarted(this, event)
496
556
  return true
497
557
  }
498
558
  if (dy > 0 && isScrollViewAtTop()) {
499
559
  lastTouchY = y
500
560
  requestDisallowInterceptTouchEvent(false)
501
- NativeGestureUtil.notifyNativeGestureStarted(this, ev)
561
+ NativeGestureUtil.notifyNativeGestureStarted(this, event)
502
562
  return true
503
563
  }
504
564
  }
505
565
  }
506
- MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
566
+ MotionEvent.ACTION_UP,
567
+ MotionEvent.ACTION_CANCEL -> {
507
568
  initialTouchX = 0f
508
569
  initialTouchY = 0f
509
570
  activePointerId = MotionEvent.INVALID_POINTER_ID
510
571
  scrimPressed = false
572
+ scrimTouchActive = false
511
573
  }
512
574
  }
513
575
  return false
514
576
  }
515
577
 
516
578
  override fun onTouchEvent(event: MotionEvent): Boolean {
517
- if (scrimPressed) {
579
+ if (scrimTouchActive) {
518
580
  when (event.actionMasked) {
519
581
  MotionEvent.ACTION_MOVE -> {
520
582
  val sheetTop = sheetContainer.top + sheetContainer.translationY
@@ -525,14 +587,22 @@ class BottomSheetView(context: Context) : ReactViewGroup(context) {
525
587
  }
526
588
  MotionEvent.ACTION_UP -> {
527
589
  val closeIndex = closedIndex
590
+ val shouldDismiss = scrimPressed && isScrimVisible()
528
591
  scrimPressed = false
529
- if (closeIndex != null && isScrimVisible()) {
592
+ scrimTouchActive = false
593
+ activePointerId = MotionEvent.INVALID_POINTER_ID
594
+ if (shouldDismiss && closeIndex != null) {
530
595
  snapToIndex(closeIndex, 0f)
531
596
  }
532
597
  return true
533
598
  }
534
599
  MotionEvent.ACTION_CANCEL -> {
535
600
  scrimPressed = false
601
+ scrimTouchActive = false
602
+ activePointerId = MotionEvent.INVALID_POINTER_ID
603
+ return true
604
+ }
605
+ MotionEvent.ACTION_POINTER_UP -> {
536
606
  return true
537
607
  }
538
608
  }
@@ -553,15 +623,17 @@ class BottomSheetView(context: Context) : ReactViewGroup(context) {
553
623
  emitPosition()
554
624
  return true
555
625
  }
556
- MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
626
+ MotionEvent.ACTION_UP,
627
+ MotionEvent.ACTION_CANCEL -> {
557
628
  isPanning = false
558
629
  activePointerId = MotionEvent.INVALID_POINTER_ID
559
- val velocity = velocityTracker?.let { tracker ->
560
- tracker.computeCurrentVelocity(1000)
561
- val v = tracker.yVelocity
562
- tracker.recycle()
563
- v
564
- } ?: 0f
630
+ val velocity =
631
+ velocityTracker?.let { tracker ->
632
+ tracker.computeCurrentVelocity(1000)
633
+ val v = tracker.yVelocity
634
+ tracker.recycle()
635
+ v
636
+ } ?: 0f
565
637
  velocityTracker = null
566
638
  val maxHeight = detentSpecs.maxOfOrNull { it.height } ?: resolvedMaxDetentHeight()
567
639
  val currentHeight = maxHeight - sheetContainer.translationY
@@ -616,9 +688,9 @@ class BottomSheetView(context: Context) : ReactViewGroup(context) {
616
688
  val containerY = initialTouchY - sheetContainer.top - sheetContainer.translationY
617
689
  if (
618
690
  containerX < 0f ||
619
- containerX >= sheetContainer.width ||
620
- containerY < 0f ||
621
- containerY >= sheetContainer.height
691
+ containerX >= sheetContainer.width ||
692
+ containerY < 0f ||
693
+ containerY >= sheetContainer.height
622
694
  ) {
623
695
  return null
624
696
  }
@@ -636,7 +708,9 @@ class BottomSheetView(context: Context) : ReactViewGroup(context) {
636
708
  if (childX < 0f || childX >= child.width || childY < 0f || childY >= child.height) {
637
709
  continue
638
710
  }
639
- findScrollableAtPoint(child, childX, childY)?.let { return it }
711
+ findScrollableAtPoint(child, childX, childY)?.let {
712
+ return it
713
+ }
640
714
  }
641
715
  }
642
716
 
@@ -680,8 +754,10 @@ class BottomSheetView(context: Context) : ReactViewGroup(context) {
680
754
  lastTouchY = 0f
681
755
  activePointerId = MotionEvent.INVALID_POINTER_ID
682
756
  scrimPressed = false
757
+ scrimTouchActive = false
683
758
  sheetContainer.translationY = 0f
684
759
  scrimProgress = 0f
760
+ suppressScrimForClosingTarget = false
685
761
  sheetContainer.removeAllViews()
686
762
  stateWrapper = null
687
763
  lastShadowOffsetY = Float.NaN
@@ -696,15 +772,21 @@ class BottomSheetView(context: Context) : ReactViewGroup(context) {
696
772
 
697
773
  // When settled at the closed detent, dynamic content updates can briefly
698
774
  // produce stale non-zero positions. Keep scrim hidden in this state.
699
- if (closedIndex != null && targetIndex == closedIndex && activeAnimation == null && !isPanning) {
700
- scrimProgress = 0f
701
- invalidate()
775
+ if (
776
+ (isTargetingClosedDetent && activeAnimation == null && !isPanning) ||
777
+ (suppressScrimForClosingTarget && isTargetingClosedDetent)
778
+ ) {
779
+ hideScrim()
702
780
  return
703
781
  }
704
782
 
705
783
  val threshold = firstNonZeroDetentHeight
706
- scrimProgress =
707
- if (threshold <= 0f) 0f else (position / threshold).coerceIn(0f, 1f)
784
+ scrimProgress = if (threshold <= 0f) 0f else (position / threshold).coerceIn(0f, 1f)
785
+ invalidate()
786
+ }
787
+
788
+ private fun hideScrim() {
789
+ scrimProgress = 0f
708
790
  invalidate()
709
791
  }
710
792
 
@@ -14,8 +14,7 @@ import com.facebook.react.viewmanagers.BottomSheetViewManagerInterface
14
14
 
15
15
  @ReactModule(name = BottomSheetViewManager.NAME)
16
16
  class BottomSheetViewManager :
17
- ViewGroupManager<BottomSheetView>(),
18
- BottomSheetViewManagerInterface<BottomSheetView> {
17
+ ViewGroupManager<BottomSheetView>(), BottomSheetViewManagerInterface<BottomSheetView> {
19
18
 
20
19
  companion object {
21
20
  const val NAME = "BottomSheetView"
@@ -29,37 +28,37 @@ class BottomSheetViewManager :
29
28
 
30
29
  override fun createViewInstance(context: ThemedReactContext): BottomSheetView {
31
30
  val view = BottomSheetView(context)
32
- view.listener = object : BottomSheetViewListener {
33
- override fun onIndexChange(index: Int) {
34
- val event = com.facebook.react.bridge.Arguments.createMap().apply {
35
- putInt("index", index)
31
+ view.listener =
32
+ object : BottomSheetViewListener {
33
+ override fun onIndexChange(index: Int) {
34
+ val event =
35
+ com.facebook.react.bridge.Arguments.createMap().apply { putInt("index", index) }
36
+ val reactContext = view.context as? ThemedReactContext ?: return
37
+ reactContext
38
+ .getJSModule(com.facebook.react.uimanager.events.RCTEventEmitter::class.java)
39
+ .receiveEvent(view.id, "topIndexChange", event)
36
40
  }
37
- val reactContext = view.context as? ThemedReactContext ?: return
38
- reactContext
39
- .getJSModule(com.facebook.react.uimanager.events.RCTEventEmitter::class.java)
40
- .receiveEvent(view.id, "topIndexChange", event)
41
- }
42
41
 
43
- override fun onSettle(index: Int) {
44
- val event = com.facebook.react.bridge.Arguments.createMap().apply {
45
- putInt("index", index)
42
+ override fun onSettle(index: Int) {
43
+ val event =
44
+ com.facebook.react.bridge.Arguments.createMap().apply { putInt("index", index) }
45
+ val reactContext = view.context as? ThemedReactContext ?: return
46
+ reactContext
47
+ .getJSModule(com.facebook.react.uimanager.events.RCTEventEmitter::class.java)
48
+ .receiveEvent(view.id, "topSettle", event)
46
49
  }
47
- val reactContext = view.context as? ThemedReactContext ?: return
48
- reactContext
49
- .getJSModule(com.facebook.react.uimanager.events.RCTEventEmitter::class.java)
50
- .receiveEvent(view.id, "topSettle", event)
51
- }
52
50
 
53
- override fun onPositionChange(position: Double) {
54
- val event = com.facebook.react.bridge.Arguments.createMap().apply {
55
- putDouble("position", position)
51
+ override fun onPositionChange(position: Double) {
52
+ val event =
53
+ com.facebook.react.bridge.Arguments.createMap().apply {
54
+ putDouble("position", position)
55
+ }
56
+ val reactContext = view.context as? ThemedReactContext ?: return
57
+ reactContext
58
+ .getJSModule(com.facebook.react.uimanager.events.RCTEventEmitter::class.java)
59
+ .receiveEvent(view.id, "topPositionChange", event)
56
60
  }
57
- val reactContext = view.context as? ThemedReactContext ?: return
58
- reactContext
59
- .getJSModule(com.facebook.react.uimanager.events.RCTEventEmitter::class.java)
60
- .receiveEvent(view.id, "topPositionChange", event)
61
61
  }
62
- }
63
62
  return view
64
63
  }
65
64
 
@@ -69,7 +68,8 @@ class BottomSheetViewManager :
69
68
 
70
69
  override fun getChildCount(parent: BottomSheetView): Int = parent.sheetChildCount
71
70
 
72
- override fun getChildAt(parent: BottomSheetView, index: Int): View? = parent.getSheetChildAt(index)
71
+ override fun getChildAt(parent: BottomSheetView, index: Int): View? =
72
+ parent.getSheetChildAt(index)
73
73
 
74
74
  override fun removeViewAt(parent: BottomSheetView, index: Int) {
75
75
  parent.removeSheetChildAt(index)
@@ -28,12 +28,15 @@ public final class RNSBottomSheetHostingView: UIView {
28
28
  public var modal: Bool = false {
29
29
  didSet { updateScrim() }
30
30
  }
31
+
31
32
  public var scrimColor: UIColor? = .clear {
32
33
  didSet { scrimView.backgroundColor = scrimColor }
33
34
  }
35
+
34
36
  public var maxDetentHeight: CGFloat = .nan {
35
37
  didSet { refreshDetentsFromLayout() }
36
38
  }
39
+
37
40
  public var disableScrollableNegotiation: Bool = false
38
41
 
39
42
  private var rawDetentSpecs: [RawDetentSpec] = []
@@ -59,7 +62,7 @@ public final class RNSBottomSheetHostingView: UIView {
59
62
  private var isContentInteractionDisabled = false
60
63
  private weak var contentHeightMarker: UIView?
61
64
 
62
- public override init(frame: CGRect) {
65
+ override public init(frame: CGRect) {
63
66
  super.init(frame: frame)
64
67
  backgroundColor = .clear
65
68
  clipsToBounds = false
@@ -84,19 +87,20 @@ public final class RNSBottomSheetHostingView: UIView {
84
87
  sheetContainer.addGestureRecognizer(panGesture)
85
88
  }
86
89
 
87
- public required init?(coder: NSCoder) {
90
+ @available(*, unavailable)
91
+ public required init?(coder _: NSCoder) {
88
92
  fatalError("init(coder:) has not been implemented")
89
93
  }
90
94
 
91
- // RCTSurfaceTouchHandler dispatches touch events to JS independently of the
92
- // pan gesture (it fires in touchesBegan: regardless of its recognizer state).
93
- // We cache it here and toggle isEnabled in handlePan(.began) to force a
94
- // touchesCancelled dispatch to JS, preventing Pressable from firing onPress
95
- // during a sheet drag. This is the iOS equivalent of Android's
96
- // NativeGestureUtil.notifyNativeGestureStarted.
95
+ /// RCTSurfaceTouchHandler dispatches touch events to JS independently of the
96
+ /// pan gesture (it fires in touchesBegan: regardless of its recognizer state).
97
+ /// We cache it here and toggle isEnabled in handlePan(.began) to force a
98
+ /// touchesCancelled dispatch to JS, preventing Pressable from firing onPress
99
+ /// during a sheet drag. This is the iOS equivalent of Android's
100
+ /// NativeGestureUtil.notifyNativeGestureStarted.
97
101
  private weak var surfaceTouchHandler: UIGestureRecognizer?
98
102
 
99
- public override func didMoveToWindow() {
103
+ override public func didMoveToWindow() {
100
104
  super.didMoveToWindow()
101
105
  surfaceTouchHandler = nil
102
106
  guard window != nil else { return }
@@ -112,7 +116,7 @@ public final class RNSBottomSheetHostingView: UIView {
112
116
  }
113
117
  }
114
118
 
115
- public override func layoutSubviews() {
119
+ override public func layoutSubviews() {
116
120
  super.layoutSubviews()
117
121
  guard bounds.width > 0, bounds.height > 0 else { return }
118
122
 
@@ -123,10 +127,21 @@ public final class RNSBottomSheetHostingView: UIView {
123
127
  sheetContainer.center = CGPoint(x: bounds.width / 2, y: bounds.height - maxHeight / 2)
124
128
 
125
129
  if !hasLaidOut && !detentSpecs.isEmpty {
126
- hasLaidOut = true
127
130
  let indexToApply = pendingIndex ?? targetIndex
131
+ let clampedIndex = max(0, min(detentSpecs.count - 1, indexToApply))
132
+
133
+ if animateIn && isInvalidContentDetentTarget(clampedIndex) {
134
+ targetIndex = clampedIndex
135
+ pendingIndex = clampedIndex
136
+ let closedTy = maximumResolvedDetentHeight ?? bounds.height
137
+ sheetContainer.transform = CGAffineTransform(translationX: 0, y: closedTy)
138
+ emitPosition()
139
+ return
140
+ }
141
+
142
+ hasLaidOut = true
128
143
  pendingIndex = nil
129
- targetIndex = max(0, min(detentSpecs.count - 1, indexToApply))
144
+ targetIndex = clampedIndex
130
145
 
131
146
  if animateIn {
132
147
  let closedTy = maximumResolvedDetentHeight ?? bounds.height
@@ -152,7 +167,7 @@ public final class RNSBottomSheetHostingView: UIView {
152
167
  return sheetContainer.frame
153
168
  }
154
169
 
155
- public override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
170
+ override public func point(inside point: CGPoint, with _: UIEvent?) -> Bool {
156
171
  if presentedSheetFrame.contains(point) {
157
172
  return true
158
173
  }
@@ -160,10 +175,10 @@ public final class RNSBottomSheetHostingView: UIView {
160
175
  return isScrimVisible && bounds.contains(point)
161
176
  }
162
177
 
163
- public override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
178
+ override public func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
164
179
  guard self.point(inside: point, with: event) else { return nil }
165
180
 
166
- if isScrimVisible && !presentedSheetFrame.contains(point) {
181
+ if isScrimVisible, !presentedSheetFrame.contains(point) {
167
182
  let scrimPoint = convert(point, to: scrimView)
168
183
  return scrimView.hitTest(scrimPoint, with: event)
169
184
  }
@@ -475,7 +490,7 @@ public final class RNSBottomSheetHostingView: UIView {
475
490
  return nil
476
491
  }
477
492
 
478
- public override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
493
+ override public func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
479
494
  guard gestureRecognizer === panGesture else { return true }
480
495
 
481
496
  let velocity = panGesture.velocity(in: self)
@@ -525,7 +540,7 @@ public final class RNSBottomSheetHostingView: UIView {
525
540
 
526
541
  private func resolveDetentSpecs() -> [DetentSpec] {
527
542
  let maxHeight = resolvedMaxDetentHeight
528
- let contentHeight = currentContentHeight.map { min($0, maxHeight) } ?? maxHeight
543
+ let contentHeight = validContentHeight.map { min($0, maxHeight) } ?? maxHeight
529
544
  return rawDetentSpecs.map { spec in
530
545
  let height: CGFloat
531
546
  switch spec.kind {
@@ -540,6 +555,11 @@ public final class RNSBottomSheetHostingView: UIView {
540
555
 
541
556
  private func refreshDetentsFromLayout() {
542
557
  refreshContentHeightMarker()
558
+ if hasLaidOut, isInvalidContentDetentTarget(targetIndex) {
559
+ updateScrim()
560
+ return
561
+ }
562
+
543
563
  let resolvedDetents = resolveDetentSpecs()
544
564
  guard resolvedDetents != detentSpecs else {
545
565
  updateScrim()
@@ -552,7 +572,7 @@ public final class RNSBottomSheetHostingView: UIView {
552
572
  return
553
573
  }
554
574
 
555
- if hasLaidOut && !isPanning {
575
+ if hasLaidOut, !isPanning {
556
576
  targetIndex = max(0, min(detentSpecs.count - 1, targetIndex))
557
577
 
558
578
  if let animator = activeAnimator {
@@ -594,6 +614,25 @@ public final class RNSBottomSheetHostingView: UIView {
594
614
  guard let marker = contentHeightMarker else { return nil }
595
615
  return marker.frame.minY.isFinite ? marker.frame.minY : nil
596
616
  }
617
+
618
+ private var validContentHeight: CGFloat? {
619
+ guard let height = currentContentHeight, height.isFinite, height > 0 else {
620
+ return nil
621
+ }
622
+ return height
623
+ }
624
+
625
+ private func isInvalidContentDetentTarget(_ index: Int) -> Bool {
626
+ guard rawDetentSpecs.indices.contains(index) else {
627
+ return false
628
+ }
629
+ switch rawDetentSpecs[index].kind {
630
+ case .points:
631
+ return false
632
+ case .content:
633
+ return validContentHeight == nil
634
+ }
635
+ }
597
636
  }
598
637
 
599
638
  extension RNSBottomSheetHostingView: UIGestureRecognizerDelegate {
@@ -606,8 +645,8 @@ extension RNSBottomSheetHostingView: UIGestureRecognizerDelegate {
606
645
  }
607
646
 
608
647
  public func gestureRecognizer(
609
- _ gestureRecognizer: UIGestureRecognizer,
610
- shouldRecognizeSimultaneouslyWith other: UIGestureRecognizer
648
+ _: UIGestureRecognizer,
649
+ shouldRecognizeSimultaneouslyWith _: UIGestureRecognizer
611
650
  ) -> Bool {
612
651
  return false
613
652
  }
@@ -1,16 +1,13 @@
1
1
  "use strict";
2
2
 
3
- import { createContext, useContext, useEffect, useId, useReducer, useState } from 'react';
3
+ import { createContext, useContext, useId, useLayoutEffect, useState, useSyncExternalStore } from 'react';
4
4
  import { StyleSheet, View } from 'react-native';
5
5
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
6
6
  const PortalContext = /*#__PURE__*/createContext(null);
7
7
  const PortalHost = () => {
8
8
  const context = useContext(PortalContext);
9
- const [, forceRender] = useReducer(x => x + 1, 0);
10
- useEffect(() => {
11
- return context.subscribe(forceRender);
12
- }, [context]);
13
- return Array.from(context.getPortals().entries()).map(([key, element]) => /*#__PURE__*/_jsx(View, {
9
+ const portals = useSyncExternalStore(context.subscribe, context.getSnapshot, context.getSnapshot);
10
+ return portals.map(([key, element]) => /*#__PURE__*/_jsx(View, {
14
11
  style: StyleSheet.absoluteFill,
15
12
  pointerEvents: "box-none",
16
13
  children: element
@@ -24,7 +21,9 @@ export const BottomSheetProvider = ({
24
21
  const [context] = useState(() => {
25
22
  const portals = new Map();
26
23
  const subscribers = new Set();
24
+ let snapshot = [];
27
25
  const notify = () => {
26
+ snapshot = Array.from(portals.entries());
28
27
  subscribers.forEach(subscriber => subscriber());
29
28
  };
30
29
  return {
@@ -42,7 +41,7 @@ export const BottomSheetProvider = ({
42
41
  subscribers.delete(callback);
43
42
  };
44
43
  },
45
- getPortals: () => portals
44
+ getSnapshot: () => snapshot
46
45
  };
47
46
  });
48
47
  return /*#__PURE__*/_jsxs(PortalContext.Provider, {
@@ -62,10 +61,10 @@ export const Portal = ({
62
61
  removePortal
63
62
  } = context;
64
63
  const id = useId();
65
- useEffect(() => {
64
+ useLayoutEffect(() => {
66
65
  addPortal(id, children);
67
66
  }, [id, children, addPortal]);
68
- useEffect(() => {
67
+ useLayoutEffect(() => {
69
68
  return () => {
70
69
  removePortal(id);
71
70
  };
@@ -1 +1 @@
1
- {"version":3,"names":["createContext","useContext","useEffect","useId","useReducer","useState","StyleSheet","View","jsx","_jsx","jsxs","_jsxs","PortalContext","PortalHost","context","forceRender","x","subscribe","Array","from","getPortals","entries","map","key","element","style","absoluteFill","pointerEvents","children","BottomSheetProvider","portals","Map","subscribers","Set","notify","forEach","subscriber","addPortal","set","removePortal","delete","callback","add","Provider","value","Portal","Error","id"],"sourceRoot":"../../src","sources":["BottomSheetProvider.tsx"],"mappings":"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","ignoreList":[]}
1
+ {"version":3,"names":["createContext","useContext","useId","useLayoutEffect","useState","useSyncExternalStore","StyleSheet","View","jsx","_jsx","jsxs","_jsxs","PortalContext","PortalHost","context","portals","subscribe","getSnapshot","map","key","element","style","absoluteFill","pointerEvents","children","BottomSheetProvider","Map","subscribers","Set","snapshot","notify","Array","from","entries","forEach","subscriber","addPortal","set","removePortal","delete","callback","add","Provider","value","Portal","Error","id"],"sourceRoot":"../../src","sources":["BottomSheetProvider.tsx"],"mappings":"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","ignoreList":[]}
@@ -1 +1 @@
1
- {"version":3,"file":"BottomSheetProvider.d.ts","sourceRoot":"","sources":["../../../src/BottomSheetProvider.tsx"],"names":[],"mappings":"AAQA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AA0BvC,iEAAiE;AACjE,eAAO,MAAM,mBAAmB,GAAI,cAAc;IAAE,QAAQ,EAAE,SAAS,CAAA;CAAE,4CAgCxE,CAAC;AAEF,eAAO,MAAM,MAAM,GAAI,cAAc;IAAE,QAAQ,EAAE,SAAS,CAAA;CAAE,SAkB3D,CAAC"}
1
+ {"version":3,"file":"BottomSheetProvider.d.ts","sourceRoot":"","sources":["../../../src/BottomSheetProvider.tsx"],"names":[],"mappings":"AAQA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AA6BvC,iEAAiE;AACjE,eAAO,MAAM,mBAAmB,GAAI,cAAc;IAAE,QAAQ,EAAE,SAAS,CAAA;CAAE,4CAkCxE,CAAC;AAEF,eAAO,MAAM,MAAM,GAAI,cAAc;IAAE,QAAQ,EAAE,SAAS,CAAA;CAAE,SAkB3D,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swmansion/react-native-bottom-sheet",
3
- "version": "0.9.0-next.3",
3
+ "version": "0.9.1-next.1",
4
4
  "description": "Provides bottom-sheet components for React Native.",
5
5
  "main": "./lib/module/index.js",
6
6
  "types": "./lib/typescript/src/index.d.ts",
@@ -35,7 +35,7 @@
35
35
  "scripts": {
36
36
  "example": "yarn workspace @swmansion/react-native-bottom-sheet-example",
37
37
  "clean": "del-cli lib",
38
- "prepare": "bob build",
38
+ "prepare": "lefthook install && bob build",
39
39
  "typecheck": "tsc",
40
40
  "lint": "eslint \"**/*.{js,ts,tsx}\""
41
41
  },
@@ -69,6 +69,7 @@
69
69
  "eslint": "9.39.2",
70
70
  "eslint-config-prettier": "10.1.8",
71
71
  "eslint-plugin-prettier": "5.5.5",
72
+ "lefthook": "2.1.6",
72
73
  "prettier": "2.8.8",
73
74
  "react": "19.1.0",
74
75
  "react-native": "0.81.5",
@@ -1,31 +1,34 @@
1
1
  import {
2
2
  createContext,
3
3
  useContext,
4
- useEffect,
5
4
  useId,
6
- useReducer,
5
+ useLayoutEffect,
7
6
  useState,
7
+ useSyncExternalStore,
8
8
  } from 'react';
9
9
  import type { ReactNode } from 'react';
10
10
  import { StyleSheet, View } from 'react-native';
11
11
 
12
+ type PortalSnapshot = Array<[string, ReactNode]>;
13
+
12
14
  interface PortalContextType {
13
15
  addPortal: (key: string, element: ReactNode) => void;
14
16
  removePortal: (key: string) => void;
15
17
  subscribe: (callback: () => void) => () => void;
16
- getPortals: () => Map<string, ReactNode>;
18
+ getSnapshot: () => PortalSnapshot;
17
19
  }
18
20
 
19
21
  const PortalContext = createContext<PortalContextType | null>(null);
20
22
 
21
23
  const PortalHost = () => {
22
24
  const context = useContext(PortalContext)!;
23
- const [, forceRender] = useReducer((x: number) => x + 1, 0);
24
- useEffect(() => {
25
- return context.subscribe(forceRender);
26
- }, [context]);
25
+ const portals = useSyncExternalStore(
26
+ context.subscribe,
27
+ context.getSnapshot,
28
+ context.getSnapshot
29
+ );
27
30
 
28
- return Array.from(context.getPortals().entries()).map(([key, element]) => (
31
+ return portals.map(([key, element]) => (
29
32
  <View key={key} style={StyleSheet.absoluteFill} pointerEvents="box-none">
30
33
  {element}
31
34
  </View>
@@ -37,7 +40,9 @@ export const BottomSheetProvider = ({ children }: { children: ReactNode }) => {
37
40
  const [context] = useState<PortalContextType>(() => {
38
41
  const portals = new Map<string, ReactNode>();
39
42
  const subscribers = new Set<() => void>();
43
+ let snapshot: PortalSnapshot = [];
40
44
  const notify = () => {
45
+ snapshot = Array.from(portals.entries());
41
46
  subscribers.forEach((subscriber) => subscriber());
42
47
  };
43
48
  return {
@@ -55,7 +60,7 @@ export const BottomSheetProvider = ({ children }: { children: ReactNode }) => {
55
60
  subscribers.delete(callback);
56
61
  };
57
62
  },
58
- getPortals: () => portals,
63
+ getSnapshot: () => snapshot,
59
64
  };
60
65
  });
61
66
 
@@ -76,10 +81,10 @@ export const Portal = ({ children }: { children: ReactNode }) => {
76
81
  const { addPortal, removePortal } = context;
77
82
  const id = useId();
78
83
 
79
- useEffect(() => {
84
+ useLayoutEffect(() => {
80
85
  addPortal(id, children);
81
86
  }, [id, children, addPortal]);
82
- useEffect(() => {
87
+ useLayoutEffect(() => {
83
88
  return () => {
84
89
  removePortal(id);
85
90
  };