@lodev09/react-native-true-sheet 3.1.0-beta.0 → 3.1.0-beta.10

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 (61) hide show
  1. package/android/src/main/java/com/lodev09/truesheet/TrueSheetModule.kt +12 -10
  2. package/android/src/main/java/com/lodev09/truesheet/TrueSheetView.kt +19 -7
  3. package/android/src/main/java/com/lodev09/truesheet/TrueSheetViewController.kt +119 -208
  4. package/android/src/main/java/com/lodev09/truesheet/core/RNScreensFragmentObserver.kt +17 -5
  5. package/android/src/main/jni/CMakeLists.txt +6 -30
  6. package/android/src/main/res/anim/true_sheet_slide_in.xml +4 -4
  7. package/android/src/main/res/anim/true_sheet_slide_out.xml +4 -3
  8. package/ios/TrueSheetContainerView.mm +4 -0
  9. package/ios/TrueSheetContentView.mm +4 -0
  10. package/ios/TrueSheetFooterView.mm +4 -0
  11. package/ios/TrueSheetHeaderView.mm +4 -0
  12. package/ios/TrueSheetModule.mm +24 -5
  13. package/ios/TrueSheetView.h +2 -0
  14. package/ios/TrueSheetView.mm +17 -5
  15. package/ios/TrueSheetViewController.h +3 -2
  16. package/ios/TrueSheetViewController.mm +204 -95
  17. package/lib/module/TrueSheet.js +12 -8
  18. package/lib/module/TrueSheet.js.map +1 -1
  19. package/lib/module/navigation/TrueSheetRouter.js +119 -0
  20. package/lib/module/navigation/TrueSheetRouter.js.map +1 -0
  21. package/lib/module/navigation/TrueSheetView.js +169 -0
  22. package/lib/module/navigation/TrueSheetView.js.map +1 -0
  23. package/lib/module/navigation/createTrueSheetNavigator.js +59 -0
  24. package/lib/module/navigation/createTrueSheetNavigator.js.map +1 -0
  25. package/lib/module/navigation/index.js +6 -0
  26. package/lib/module/navigation/index.js.map +1 -0
  27. package/lib/module/navigation/types.js +4 -0
  28. package/lib/module/navigation/types.js.map +1 -0
  29. package/lib/module/navigation/useTrueSheetNavigation.js +26 -0
  30. package/lib/module/navigation/useTrueSheetNavigation.js.map +1 -0
  31. package/lib/module/reanimated/ReanimatedTrueSheetProvider.js +3 -3
  32. package/lib/module/reanimated/ReanimatedTrueSheetProvider.js.map +1 -1
  33. package/lib/module/specs/NativeTrueSheetModule.js.map +1 -1
  34. package/lib/typescript/src/TrueSheet.d.ts +8 -4
  35. package/lib/typescript/src/TrueSheet.d.ts.map +1 -1
  36. package/lib/typescript/src/navigation/TrueSheetRouter.d.ts +57 -0
  37. package/lib/typescript/src/navigation/TrueSheetRouter.d.ts.map +1 -0
  38. package/lib/typescript/src/navigation/TrueSheetView.d.ts +10 -0
  39. package/lib/typescript/src/navigation/TrueSheetView.d.ts.map +1 -0
  40. package/lib/typescript/src/navigation/createTrueSheetNavigator.d.ts +35 -0
  41. package/lib/typescript/src/navigation/createTrueSheetNavigator.d.ts.map +1 -0
  42. package/lib/typescript/src/navigation/index.d.ts +6 -0
  43. package/lib/typescript/src/navigation/index.d.ts.map +1 -0
  44. package/lib/typescript/src/navigation/types.d.ts +125 -0
  45. package/lib/typescript/src/navigation/types.d.ts.map +1 -0
  46. package/lib/typescript/src/navigation/useTrueSheetNavigation.d.ts +23 -0
  47. package/lib/typescript/src/navigation/useTrueSheetNavigation.d.ts.map +1 -0
  48. package/lib/typescript/src/reanimated/ReanimatedTrueSheetProvider.d.ts.map +1 -1
  49. package/lib/typescript/src/specs/NativeTrueSheetModule.d.ts +4 -2
  50. package/lib/typescript/src/specs/NativeTrueSheetModule.d.ts.map +1 -1
  51. package/package.json +13 -2
  52. package/src/TrueSheet.tsx +16 -8
  53. package/src/__mocks__/index.js +6 -5
  54. package/src/navigation/TrueSheetRouter.ts +172 -0
  55. package/src/navigation/TrueSheetView.tsx +271 -0
  56. package/src/navigation/createTrueSheetNavigator.tsx +89 -0
  57. package/src/navigation/index.ts +14 -0
  58. package/src/navigation/types.ts +176 -0
  59. package/src/navigation/useTrueSheetNavigation.ts +28 -0
  60. package/src/reanimated/ReanimatedTrueSheetProvider.tsx +6 -9
  61. package/src/specs/NativeTrueSheetModule.ts +4 -2
@@ -61,15 +61,15 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
61
61
  companion object {
62
62
  const val TAG_NAME = "TrueSheet"
63
63
 
64
+ private const val MAX_HALF_EXPANDED_RATIO = 0.999f
65
+
64
66
  private const val GRABBER_TAG = "TrueSheetGrabber"
65
67
  private const val DEFAULT_MAX_WIDTH = 640 // dp
66
68
  private const val DEFAULT_CORNER_RADIUS = 16 // dp
67
69
 
68
- /**
69
- * Gets the effective sheet height by subtracting headerHeight * 2.
70
- * This is needed because both native layout and Yoga layout account for the header separately.
71
- */
72
- fun getEffectiveSheetHeight(sheetHeight: Int, headerHeight: Int): Int = sheetHeight - headerHeight * 2
70
+ // Animation durations from res/anim/true_sheet_slide_in.xml and true_sheet_slide_out.xml
71
+ private const val PRESENT_ANIMATION_DURATION = 200L
72
+ private const val DISMISS_ANIMATION_DURATION = 150L
73
73
  }
74
74
 
75
75
  // ====================================================================
@@ -119,6 +119,7 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
119
119
  private val resolvedDetentPositions = mutableListOf<Int>()
120
120
 
121
121
  private var isDragging = false
122
+ private var isDismissing = false
122
123
  private var isReconfiguring = false
123
124
  private var windowAnimation: Int = 0
124
125
  private var lastEmittedPositionPx: Int = -1
@@ -169,48 +170,26 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
169
170
  val statusBarHeight: Int
170
171
  get() = ScreenUtils.getStatusBarHeight(reactContext)
171
172
 
172
- /**
173
- * The bottom inset (navigation bar height) to add to sheet height.
174
- * This matches iOS behavior where the system adds bottom safe area inset internally.
175
- */
173
+ /** Navigation bar height, added to sheet height to match iOS behavior. */
176
174
  private val bottomInset: Int
177
175
  get() = ScreenUtils.getNavigationBarHeight(reactContext)
178
176
 
179
- /**
180
- * Edge-to-edge is enabled by default on API 36+, or when explicitly configured.
181
- */
177
+ /** Edge-to-edge enabled by default on API 36+, or when explicitly configured. */
182
178
  private val edgeToEdgeEnabled: Boolean
183
179
  get() {
184
180
  val defaultEnabled = android.os.Build.VERSION.SDK_INT >= 36
185
181
  return BuildConfig.EDGE_TO_EDGE_ENABLED || dialog?.edgeToEdgeEnabled == true || defaultEnabled
186
182
  }
187
183
 
188
- /**
189
- * The top inset to apply when edge-to-edge is enabled but not full-screen.
190
- * This prevents the sheet from going under the status bar.
191
- */
184
+ /** Top inset when edge-to-edge is enabled but not full-screen. */
192
185
  private val sheetTopInset: Int
193
186
  get() = if (edgeToEdgeEnabled && !edgeToEdgeFullScreen) statusBarHeight else 0
194
187
 
195
- // ====================================================================
196
- // MARK: - Touch Dispatchers
197
- // ====================================================================
198
-
199
- /**
200
- * Touch dispatchers are required for RootView to properly forward touch events to React Native.
201
- */
202
188
  internal var eventDispatcher: EventDispatcher? = null
203
189
  private val jSTouchDispatcher = JSTouchDispatcher(this)
204
190
  private var jSPointerDispatcher: JSPointerDispatcher? = null
205
191
 
206
- // ====================================================================
207
- // MARK: - Modal Observer
208
- // ====================================================================
209
-
210
- /**
211
- * Observes react-native-screens modal fragments to hide/show the sheet appropriately.
212
- * This prevents the sheet from rendering on top of modals.
213
- */
192
+ /** Hides/shows the sheet when RN Screens modals are presented/dismissed. */
214
193
  private var rnScreensObserver: RNScreensFragmentObserver? = null
215
194
 
216
195
  // ====================================================================
@@ -252,7 +231,6 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
252
231
  behavior.isHideable = dismissible
253
232
  behavior.isDraggable = draggable
254
233
 
255
- // Handle back press
256
234
  onBackPressedDispatcher.addCallback(object : androidx.activity.OnBackPressedCallback(true) {
257
235
  override fun handleOnBackPressed() {
258
236
  this@TrueSheetViewController.delegate?.viewControllerDidBackPress()
@@ -276,6 +254,7 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
276
254
 
277
255
  dialog = null
278
256
  isDragging = false
257
+ isDismissing = false
279
258
  isPresented = false
280
259
  isDialogVisible = false
281
260
  lastEmittedPositionPx = -1
@@ -290,44 +269,38 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
290
269
  setupGrabber()
291
270
 
292
271
  sheetContainer?.post {
272
+ val positionPx = bottomSheetView?.let { ScreenUtils.getScreenY(it) } ?: screenHeight
273
+ storeResolvedPosition(currentDetentIndex)
274
+ emitChangePositionDelegate(positionPx, realtime = false)
275
+ positionFooter()
276
+ }
277
+
278
+ sheetContainer?.postDelayed({
293
279
  val detentInfo = getDetentInfoForIndex(currentDetentIndex)
294
280
  val detent = getDetentValueForIndex(detentInfo.index)
295
- val positionPx = bottomSheetView?.let { ScreenUtils.getScreenY(it) } ?: screenHeight
296
281
 
297
282
  delegate?.viewControllerDidPresent(detentInfo.index, detentInfo.position, detent)
298
-
299
- // Store resolved position for initial detent
300
- storeResolvedPosition(detentInfo.index)
301
- emitChangePositionDelegate(detentInfo.index, positionPx, realtime = false)
302
-
303
- // Notify parent sheet that it has lost focus (after this sheet appeared)
304
283
  parentSheetView?.viewControllerDidBlur()
284
+ delegate?.viewControllerDidFocus()
305
285
 
306
286
  presentPromise?.invoke()
307
287
  presentPromise = null
308
-
309
- positionFooter()
310
- }
288
+ }, PRESENT_ANIMATION_DURATION)
311
289
  }
312
290
 
313
291
  dialog.setOnCancelListener {
314
- delegate?.viewControllerWillDismiss()
292
+ if (isDismissing) return@setOnCancelListener
315
293
 
316
- // Notify parent sheet that it is about to regain focus
317
- parentSheetView?.viewControllerWillFocus()
294
+ isDismissing = true
295
+ emitWillDismissEvents()
296
+ emitChangePositionDelegate(screenHeight, realtime = false)
318
297
  }
319
298
 
320
299
  dialog.setOnDismissListener {
321
- val hadParent = parentSheetView != null
322
-
323
- // Notify parent sheet that it has regained focus
324
- parentSheetView?.viewControllerDidFocus()
325
- parentSheetView = null
326
-
327
- dismissPromise?.invoke()
328
- dismissPromise = null
329
- delegate?.viewControllerDidDismiss(hadParent)
330
- cleanupDialog()
300
+ android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({
301
+ emitDidDismissEvents()
302
+ cleanupDialog()
303
+ }, DISMISS_ANIMATION_DURATION)
331
304
  }
332
305
  }
333
306
 
@@ -337,9 +310,8 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
337
310
  override fun onSlide(sheetView: View, slideOffset: Float) {
338
311
  val behavior = behavior ?: return
339
312
  val positionPx = getCurrentPositionPx(sheetView)
340
- val detentIndex = getDetentIndexForPosition(positionPx)
341
313
 
342
- emitChangePositionDelegate(detentIndex, positionPx, realtime = true)
314
+ emitChangePositionDelegate(positionPx, realtime = true)
343
315
 
344
316
  when (behavior.state) {
345
317
  BottomSheetBehavior.STATE_DRAGGING,
@@ -353,7 +325,10 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
353
325
 
354
326
  override fun onStateChanged(sheetView: View, newState: Int) {
355
327
  if (newState == BottomSheetBehavior.STATE_HIDDEN) {
356
- dismiss()
328
+ if (isDismissing) return
329
+ isDismissing = true
330
+ emitWillDismissEvents()
331
+ dialog.dismiss()
357
332
  return
358
333
  }
359
334
 
@@ -365,15 +340,12 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
365
340
  BottomSheetBehavior.STATE_EXPANDED,
366
341
  BottomSheetBehavior.STATE_COLLAPSED,
367
342
  BottomSheetBehavior.STATE_HALF_EXPANDED -> {
368
- // Ignore state changes triggered by content size reconfiguration
369
343
  if (isReconfiguring) return
370
344
 
371
345
  getDetentInfoForState(newState)?.let { detentInfo ->
372
- // Store resolved position when sheet settles
373
346
  storeResolvedPosition(detentInfo.index)
374
347
 
375
348
  if (isDragging) {
376
- // Handle drag end
377
349
  val detent = getDetentValueForIndex(detentInfo.index)
378
350
  delegate?.viewControllerDidDragEnd(detentInfo.index, detentInfo.position, detent)
379
351
 
@@ -386,13 +358,10 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
386
358
  }
387
359
 
388
360
  isDragging = false
389
- } else {
390
- // Handle programmatic resize - emit detent change after sheet settles
391
- if (detentInfo.index != currentDetentIndex) {
392
- val detent = getDetentValueForIndex(detentInfo.index)
393
- currentDetentIndex = detentInfo.index
394
- delegate?.viewControllerDidChangeDetent(detentInfo.index, detentInfo.position, detent)
395
- }
361
+ } else if (detentInfo.index != currentDetentIndex) {
362
+ val detent = getDetentValueForIndex(detentInfo.index)
363
+ currentDetentIndex = detentInfo.index
364
+ delegate?.viewControllerDidChangeDetent(detentInfo.index, detentInfo.position, detent)
396
365
  }
397
366
  }
398
367
  }
@@ -407,14 +376,26 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
407
376
  private fun setupModalObserver() {
408
377
  rnScreensObserver = RNScreensFragmentObserver(
409
378
  reactContext = reactContext,
379
+ onModalWillPresent = {
380
+ if (isPresented) {
381
+ delegate?.viewControllerWillBlur()
382
+ }
383
+ },
410
384
  onModalPresented = {
411
385
  if (isPresented) {
412
386
  hideDialog()
387
+ delegate?.viewControllerDidBlur()
388
+ }
389
+ },
390
+ onModalWillDismiss = {
391
+ if (isPresented) {
392
+ delegate?.viewControllerWillFocus()
413
393
  }
414
394
  },
415
395
  onModalDismissed = {
416
396
  if (isPresented) {
417
397
  showDialog()
398
+ delegate?.viewControllerDidFocus()
418
399
  }
419
400
  }
420
401
  )
@@ -426,59 +407,53 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
426
407
  rnScreensObserver = null
427
408
  }
428
409
 
410
+ private fun emitWillDismissEvents() {
411
+ delegate?.viewControllerWillBlur()
412
+ delegate?.viewControllerWillDismiss()
413
+ parentSheetView?.viewControllerWillFocus()
414
+ }
415
+
416
+ private fun emitDidDismissEvents() {
417
+ val hadParent = parentSheetView != null
418
+ parentSheetView?.viewControllerDidFocus()
419
+ parentSheetView = null
420
+
421
+ delegate?.viewControllerDidBlur()
422
+ delegate?.viewControllerDidDismiss(hadParent)
423
+
424
+ dismissPromise?.invoke()
425
+ dismissPromise = null
426
+ }
427
+
429
428
  // ====================================================================
430
429
  // MARK: - Dialog Visibility (for stacking)
431
430
  // ====================================================================
432
431
 
433
- /**
434
- * Returns true if the sheet's top is at or above the status bar.
435
- */
436
432
  val isExpanded: Boolean
437
433
  get() {
438
434
  val sheetTop = bottomSheetView?.top ?: return false
439
435
  return sheetTop <= statusBarHeight
440
436
  }
441
437
 
442
- /**
443
- * Returns the current top position of the sheet (Y coordinate from screen top).
444
- * Used for comparing sheet positions during stacking.
445
- */
446
438
  val currentSheetTop: Int
447
439
  get() = bottomSheetView?.let { ScreenUtils.getScreenY(it) } ?: screenHeight
448
440
 
449
- /**
450
- * Returns the expected top position of the sheet when presented at the given detent index.
451
- * Used for comparing sheet positions before presentation.
452
- */
453
441
  fun getExpectedSheetTop(detentIndex: Int): Int {
454
442
  if (detentIndex < 0 || detentIndex >= detents.size) return screenHeight
455
443
  val detentHeight = getDetentHeight(detents[detentIndex])
456
444
  return screenHeight - detentHeight
457
445
  }
458
446
 
459
- /**
460
- * Hides the dialog without dismissing it.
461
- * Used when another TrueSheet presents on top or when RN screen is presented.
462
- */
447
+ /** Hides without dismissing. Used for sheet stacking and RN Screens modals. */
463
448
  fun hideDialog() {
464
449
  isDialogVisible = false
465
- dialog?.window?.decorView?.visibility = View.INVISIBLE
466
-
467
- // Emit off-screen position (detent = 0 since sheet is fully hidden)
468
- emitChangePositionDelegate(currentDetentIndex, screenHeight, realtime = false)
450
+ dialog?.window?.decorView?.visibility = INVISIBLE
469
451
  }
470
452
 
471
- /**
472
- * Shows a previously hidden dialog.
473
- * Used when the sheet on top dismisses.
474
- */
453
+ /** Shows a previously hidden dialog. */
475
454
  fun showDialog() {
476
455
  isDialogVisible = true
477
- dialog?.window?.decorView?.visibility = View.VISIBLE
478
-
479
- // Emit current position
480
- val positionPx = bottomSheetView?.let { ScreenUtils.getScreenY(it) } ?: screenHeight
481
- emitChangePositionDelegate(currentDetentIndex, positionPx, realtime = false)
456
+ dialog?.window?.decorView?.visibility = VISIBLE
482
457
  }
483
458
 
484
459
  // ====================================================================
@@ -494,8 +469,6 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
494
469
  setupDimmedBackground(detentIndex)
495
470
 
496
471
  if (isPresented) {
497
- // Detent change will be emitted when sheet settles in onStateChanged
498
- // Don't update currentDetentIndex here - it will be updated when sheet settles
499
472
  setStateForDetentIndex(detentIndex)
500
473
  } else {
501
474
  currentDetentIndex = detentIndex
@@ -506,10 +479,9 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
506
479
  val detentInfo = getDetentInfoForIndex(detentIndex)
507
480
  val detent = getDetentValueForIndex(detentInfo.index)
508
481
 
509
- // Notify parent sheet that it is about to lose focus (before this sheet appears)
510
482
  parentSheetView?.viewControllerWillBlur()
511
-
512
483
  delegate?.viewControllerWillPresent(detentInfo.index, detentInfo.position, detent)
484
+ delegate?.viewControllerWillFocus()
513
485
 
514
486
  if (!animated) {
515
487
  dialog.window?.setWindowAnimations(0)
@@ -519,11 +491,20 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
519
491
  }
520
492
  }
521
493
 
522
- fun dismiss() {
494
+ fun dismiss(animated: Boolean = true) {
495
+ if (isDismissing) return
496
+
497
+ isDismissing = true
498
+ emitWillDismissEvents()
499
+
523
500
  this.post {
524
- // Emit off-screen position (detent = 0 since sheet is fully hidden)
525
- emitChangePositionDelegate(currentDetentIndex, screenHeight, realtime = false)
501
+ emitChangePositionDelegate(screenHeight, realtime = false)
526
502
  }
503
+
504
+ if (!animated) {
505
+ dialog?.window?.setWindowAnimations(0)
506
+ }
507
+
527
508
  dialog?.dismiss()
528
509
  }
529
510
 
@@ -534,13 +515,11 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
534
515
  fun setupSheetDetents() {
535
516
  val behavior = this.behavior ?: return
536
517
 
537
- // Reset resolved positions if detents count changed
538
518
  if (resolvedDetentPositions.size != detents.size) {
539
519
  resolvedDetentPositions.clear()
540
520
  repeat(detents.size) { resolvedDetentPositions.add(0) }
541
521
  }
542
522
 
543
- // Always update auto detent positions based on current content height
544
523
  for (i in detents.indices) {
545
524
  if (detents[i] == -1.0) {
546
525
  val detentHeight = getDetentHeight(detents[i])
@@ -548,39 +527,48 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
548
527
  }
549
528
  }
550
529
 
551
- // Flag to prevent state change callbacks from updating detent index during reconfiguration
552
530
  isReconfiguring = true
553
531
 
554
532
  behavior.apply {
555
- skipCollapsed = false
556
533
  isFitToContents = false
557
534
  maxWidth = DEFAULT_MAX_WIDTH.dpToPx().toInt()
558
535
 
536
+ val oldExpandOffset = expandedOffset
537
+
559
538
  when (detents.size) {
560
539
  1 -> {
561
540
  setPeekHeight(getDetentHeight(detents[0]), isPresented)
541
+ halfExpandedRatio = minOf(peekHeight.toFloat() / screenHeight.toFloat(), MAX_HALF_EXPANDED_RATIO)
562
542
  expandedOffset = screenHeight - peekHeight
543
+ isFitToContents = expandedOffset == 0
563
544
  }
564
545
 
565
546
  2 -> {
566
547
  setPeekHeight(getDetentHeight(detents[0]), isPresented)
548
+ halfExpandedRatio = minOf(getDetentHeight(detents[1]).toFloat() / screenHeight.toFloat(), MAX_HALF_EXPANDED_RATIO)
567
549
  expandedOffset = screenHeight - getDetentHeight(detents[1])
550
+ isFitToContents = expandedOffset == 0
568
551
  }
569
552
 
570
553
  3 -> {
571
554
  setPeekHeight(getDetentHeight(detents[0]), isPresented)
572
- halfExpandedRatio = minOf(getDetentHeight(detents[1]).toFloat() / screenHeight.toFloat(), 1.0f)
555
+ halfExpandedRatio = minOf(getDetentHeight(detents[1]).toFloat() / screenHeight.toFloat(), MAX_HALF_EXPANDED_RATIO)
573
556
  expandedOffset = screenHeight - getDetentHeight(detents[2])
574
557
  }
575
558
  }
576
- }
577
559
 
578
- // Re-apply current state to update position after config changes
579
- if (isPresented) {
580
- setStateForDetentIndex(currentDetentIndex)
581
- }
560
+ if (oldExpandOffset != expandedOffset || expandedOffset == 0) {
561
+ val offset = if (expandedOffset == 0) statusBarHeight else 0
562
+ val newHeight = screenHeight - expandedOffset - offset
563
+ delegate?.viewControllerDidChangeSize(width, newHeight)
564
+ }
582
565
 
583
- isReconfiguring = false
566
+ if (isPresented) {
567
+ setStateForDetentIndex(currentDetentIndex)
568
+ }
569
+
570
+ isReconfiguring = false
571
+ }
584
572
  }
585
573
 
586
574
  fun setupGrabber() {
@@ -612,10 +600,7 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
612
600
  bottomSheet.clipToOutline = true
613
601
  }
614
602
 
615
- /**
616
- * Configures the dimmed background based on the current detent index.
617
- * When not dimmed, touch events pass through to the activity behind the sheet.
618
- */
603
+ /** Configures dim and touch-through behavior based on detent index. */
619
604
  fun setupDimmedBackground(detentIndex: Int) {
620
605
  val dialog = this.dialog ?: return
621
606
  dialog.window?.apply {
@@ -624,9 +609,9 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
624
609
  if (dimmed && detentIndex >= dimmedDetentIndex) {
625
610
  view.setOnTouchListener(null)
626
611
  setFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND, WindowManager.LayoutParams.FLAG_DIM_BEHIND)
612
+ setDimAmount(0.32f) // M3 scrim opacity
627
613
  dialog.setCanceledOnTouchOutside(dismissible)
628
614
  } else {
629
- // Forward touch events to the activity when not dimmed
630
615
  view.setOnTouchListener { v, event ->
631
616
  event.setLocation(event.rawX - v.x, event.rawY - v.y)
632
617
  reactContext.currentActivity?.dispatchTouchEvent(event)
@@ -642,11 +627,7 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
642
627
  dialog?.window?.setWindowAnimations(windowAnimation)
643
628
  }
644
629
 
645
- /**
646
- * Positions the footer view at the bottom of the sheet.
647
- * The footer stays fixed at the bottom edge of the visible sheet area,
648
- * adjusting during drag gestures via slideOffset.
649
- */
630
+ /** Positions footer at bottom of sheet, adjusting during drag via slideOffset. */
650
631
  fun positionFooter(slideOffset: Float? = null) {
651
632
  val footerView = containerView?.footerView ?: return
652
633
  val bottomSheet = bottomSheetView ?: return
@@ -656,13 +637,10 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
656
637
 
657
638
  var footerY = (screenHeight - bottomSheetY - footerHeight).toFloat()
658
639
 
659
- // Animate footer down with sheet when below peek height
660
640
  if (slideOffset != null && slideOffset < 0) {
661
641
  footerY -= (footerHeight * slideOffset)
662
642
  }
663
643
 
664
- // Clamp footer position to prevent it from going off screen when positioning at the top
665
- // This happens when fullScreen is enabled in edge-to-edge mode
666
644
  val maxAllowedY = (screenHeight - statusBarHeight - footerHeight).toFloat()
667
645
  footerView.y = minOf(footerY, maxAllowedY)
668
646
  }
@@ -690,16 +668,10 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
690
668
  }
691
669
 
692
670
  // ====================================================================
693
- // MARK: - Position Change Delegate
671
+ // MARK: - Position & Drag Handling
694
672
  // ====================================================================
695
673
 
696
- /**
697
- * Emits position change to the delegate if the position has changed.
698
- * @param index The current detent index (discrete, used as fallback)
699
- * @param positionPx The current position in pixels (screen Y coordinate)
700
- * @param realtime Whether the position is a real-time value (during drag or animation tracking)
701
- */
702
- private fun emitChangePositionDelegate(index: Int, positionPx: Int, realtime: Boolean) {
674
+ private fun emitChangePositionDelegate(positionPx: Int, realtime: Boolean) {
703
675
  if (positionPx == lastEmittedPositionPx) return
704
676
 
705
677
  lastEmittedPositionPx = positionPx
@@ -709,11 +681,6 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
709
681
  delegate?.viewControllerDidChangePosition(interpolatedIndex, position, detent, realtime)
710
682
  }
711
683
 
712
- /**
713
- * Stores the current Y position as the resolved position for the given detent index.
714
- * This is called when the sheet settles at a detent to capture the actual position
715
- * which may differ from the calculated position due to system adjustments.
716
- */
717
684
  private fun storeResolvedPosition(index: Int) {
718
685
  if (index < 0 || index >= resolvedDetentPositions.size) return
719
686
  val positionPx = bottomSheetView?.let { ScreenUtils.getScreenY(it) } ?: return
@@ -722,24 +689,16 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
722
689
  }
723
690
  }
724
691
 
725
- /**
726
- * Stores the resolved position for the current detent.
727
- * Called from TrueSheetView when content size changes.
728
- */
729
692
  fun storeCurrentResolvedPosition() {
730
693
  storeResolvedPosition(currentDetentIndex)
731
694
  }
732
695
 
733
- /**
734
- * Returns the estimated Y position for a detent index, using stored positions when available.
735
- */
736
696
  private fun getEstimatedPositionForIndex(index: Int): Int {
737
697
  if (index < 0 || index >= resolvedDetentPositions.size) return screenHeight
738
698
 
739
699
  val storedPos = resolvedDetentPositions[index]
740
700
  if (storedPos > 0) return storedPos
741
701
 
742
- // Estimate based on getDetentHeight which accounts for bottomInset and maxAllowedHeight
743
702
  if (index < detents.size) {
744
703
  val detentHeight = getDetentHeight(detents[index])
745
704
  return screenHeight - detentHeight
@@ -748,11 +707,7 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
748
707
  return screenHeight
749
708
  }
750
709
 
751
- /**
752
- * Finds the segment index and interpolation progress for a given position.
753
- * Returns a Triple of (fromIndex, toIndex, progress) where progress is 0-1 within that segment.
754
- * Returns null if position count is less than 2.
755
- */
710
+ /** Returns (fromIndex, toIndex, progress) for interpolation, or null if < 2 detents. */
756
711
  private fun findSegmentForPosition(positionPx: Int): Triple<Int, Int, Float>? {
757
712
  val count = resolvedDetentPositions.size
758
713
  if (count < 2) return null
@@ -760,24 +715,21 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
760
715
  val firstPos = getEstimatedPositionForIndex(0)
761
716
  val lastPos = getEstimatedPositionForIndex(count - 1)
762
717
 
763
- // Below first detent
764
718
  if (positionPx > firstPos) {
765
719
  val range = screenHeight - firstPos
766
720
  val progress = if (range > 0) (positionPx - firstPos).toFloat() / range else 0f
767
- return Triple(-1, 0, progress) // Special index -1 for below first
721
+ return Triple(-1, 0, progress)
768
722
  }
769
723
 
770
- // Above last detent
771
724
  if (positionPx < lastPos) {
772
725
  return Triple(count - 1, count - 1, 0f)
773
726
  }
774
727
 
775
- // Find segment (positions decrease as index increases)
776
728
  for (i in 0 until count - 1) {
777
729
  val pos = getEstimatedPositionForIndex(i)
778
730
  val nextPos = getEstimatedPositionForIndex(i + 1)
779
731
 
780
- if (positionPx <= pos && positionPx >= nextPos) {
732
+ if (positionPx in nextPos..pos) {
781
733
  val range = pos - nextPos
782
734
  val progress = if (range > 0) (pos - positionPx).toFloat() / range else 0f
783
735
  return Triple(i, i + 1, maxOf(0f, minOf(1f, progress)))
@@ -787,10 +739,7 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
787
739
  return Triple(count - 1, count - 1, 0f)
788
740
  }
789
741
 
790
- /**
791
- * Calculates the interpolated index based on position.
792
- * Returns a continuous value (e.g., 0.5 means halfway between detent 0 and 1).
793
- */
742
+ /** Returns continuous index (e.g., 0.5 = halfway between detent 0 and 1). */
794
743
  private fun getInterpolatedIndexForPosition(positionPx: Int): Float {
795
744
  val count = resolvedDetentPositions.size
796
745
  if (count == 0) return -1f
@@ -799,16 +748,11 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
799
748
  val segment = findSegmentForPosition(positionPx) ?: return 0f
800
749
  val (fromIndex, _, progress) = segment
801
750
 
802
- // Below first detent
803
751
  if (fromIndex == -1) return -progress
804
-
805
752
  return fromIndex + progress
806
753
  }
807
754
 
808
- /**
809
- * Calculates the interpolated detent value based on position.
810
- * Returns the actual screen fraction, clamped to valid detent range.
811
- */
755
+ /** Returns interpolated screen fraction for position. */
812
756
  private fun getInterpolatedDetentForPosition(positionPx: Int): Float {
813
757
  val count = resolvedDetentPositions.size
814
758
  if (count == 0) return 0f
@@ -816,7 +760,6 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
816
760
  val segment = findSegmentForPosition(positionPx) ?: return getDetentValueForIndex(0)
817
761
  val (fromIndex, toIndex, progress) = segment
818
762
 
819
- // Below first detent
820
763
  if (fromIndex == -1) {
821
764
  val firstDetent = getDetentValueForIndex(0)
822
765
  return maxOf(0f, firstDetent * (1 - progress))
@@ -827,11 +770,7 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
827
770
  return fromDetent + progress * (toDetent - fromDetent)
828
771
  }
829
772
 
830
- /**
831
- * Gets the detent value (fraction) for a given index.
832
- * Returns the raw screen fraction without bottomInset for interpolation calculations.
833
- * Note: bottomInset is only added in getDetentHeight() for actual sheet sizing.
834
- */
773
+ /** Returns raw screen fraction for index (without bottomInset). */
835
774
  private fun getDetentValueForIndex(index: Int): Float {
836
775
  if (index < 0 || index >= detents.size) return 0f
837
776
  val value = detents[index]
@@ -842,10 +781,6 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
842
781
  }
843
782
  }
844
783
 
845
- // ====================================================================
846
- // MARK: - Drag Handling
847
- // ====================================================================
848
-
849
784
  private fun getCurrentDetentInfo(sheetView: View): DetentInfo {
850
785
  val screenY = ScreenUtils.getScreenY(sheetView)
851
786
  return DetentInfo(currentDetentIndex, screenY.pxToDp())
@@ -853,26 +788,6 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
853
788
 
854
789
  private fun getCurrentPositionPx(sheetView: View): Int = ScreenUtils.getScreenY(sheetView)
855
790
 
856
- /**
857
- * Returns the detent index for the current position.
858
- * Only reports a higher index when the sheet has reached that detent's height.
859
- */
860
- private fun getDetentIndexForPosition(positionPx: Int): Int {
861
- if (detents.isEmpty()) return 0
862
-
863
- val sheetHeight = screenHeight - positionPx
864
-
865
- // Find the highest detent index that the sheet has reached
866
- for (i in detents.indices.reversed()) {
867
- val detentHeight = getDetentHeight(detents[i])
868
- if (sheetHeight >= detentHeight) {
869
- return i
870
- }
871
- }
872
-
873
- return 0
874
- }
875
-
876
791
  private fun handleDragBegin(sheetView: View) {
877
792
  val detentInfo = getCurrentDetentInfo(sheetView)
878
793
  val detent = getDetentValueForIndex(detentInfo.index)
@@ -893,14 +808,11 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
893
808
 
894
809
  private fun getDetentHeight(detent: Double): Int {
895
810
  val height: Int = if (detent == -1.0) {
896
- // For auto detent, add bottomInset to match iOS behavior where the system
897
- // adds bottom safe area inset internally to the sheet height
898
811
  contentHeight + headerHeight + bottomInset
899
812
  } else {
900
813
  if (detent <= 0.0 || detent > 1.0) {
901
814
  throw IllegalArgumentException("TrueSheet: detent fraction ($detent) must be between 0 and 1")
902
815
  }
903
- // For fractional detents, add bottomInset to match iOS behavior
904
816
  (detent * screenHeight).toInt() + bottomInset
905
817
  }
906
818
 
@@ -980,7 +892,10 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
980
892
  super.onSizeChanged(w, h, oldw, oldh)
981
893
  if (w == oldw && h == oldh) return
982
894
 
983
- delegate?.viewControllerDidChangeSize(w, h)
895
+ // Skip continuous size changes when fullScreen + edge-to-edge
896
+ if (h + statusBarHeight > screenHeight && isExpanded && oldw == w) {
897
+ return
898
+ }
984
899
 
985
900
  val oldScreenHeight = screenHeight
986
901
  screenHeight = ScreenUtils.getScreenHeight(reactContext, edgeToEdgeEnabled)
@@ -991,7 +906,7 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
991
906
  positionFooter()
992
907
  storeResolvedPosition(currentDetentIndex)
993
908
  val positionPx = bottomSheetView?.let { ScreenUtils.getScreenY(it) } ?: screenHeight
994
- emitChangePositionDelegate(currentDetentIndex, positionPx, realtime = false)
909
+ emitChangePositionDelegate(positionPx, realtime = false)
995
910
  }
996
911
  }
997
912
  }
@@ -1004,11 +919,7 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
1004
919
  // MARK: - Touch Event Handling
1005
920
  // ====================================================================
1006
921
 
1007
- /**
1008
- * Custom touch dispatch to handle footer touch events.
1009
- * The footer is positioned outside the normal view hierarchy, so we need to
1010
- * manually check if touches fall within its bounds and forward them.
1011
- */
922
+ /** Forwards touch events to footer which is positioned outside normal hierarchy. */
1012
923
  override fun dispatchTouchEvent(event: MotionEvent): Boolean {
1013
924
  val footer = containerView?.footerView
1014
925
  if (footer != null && footer.isVisible) {