@lodev09/react-native-true-sheet 3.5.0-beta.1 → 3.5.1-beta.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.
@@ -4,7 +4,6 @@ import android.annotation.SuppressLint
4
4
  import android.graphics.Color
5
5
  import android.graphics.drawable.ShapeDrawable
6
6
  import android.graphics.drawable.shapes.RoundRectShape
7
- import android.util.Log
8
7
  import android.util.TypedValue
9
8
  import android.view.MotionEvent
10
9
  import android.view.View
@@ -38,6 +37,10 @@ import com.lodev09.truesheet.core.TrueSheetKeyboardObserver
38
37
  import com.lodev09.truesheet.core.TrueSheetKeyboardObserverDelegate
39
38
  import com.lodev09.truesheet.utils.ScreenUtils
40
39
 
40
+ // =============================================================================
41
+ // MARK: - Data Types & Delegate Protocol
42
+ // =============================================================================
43
+
41
44
  data class DetentInfo(val index: Int, val position: Float)
42
45
 
43
46
  interface TrueSheetViewControllerDelegate {
@@ -58,9 +61,13 @@ interface TrueSheetViewControllerDelegate {
58
61
  fun viewControllerDidBackPress()
59
62
  }
60
63
 
64
+ // =============================================================================
65
+ // MARK: - TrueSheetViewController
66
+ // =============================================================================
67
+
61
68
  /**
62
- * TrueSheetViewController manages the bottom sheet dialog and its presentation lifecycle.
63
- * This view also acts as a RootView to properly handle and dispatch touch events to React Native.
69
+ * Manages the bottom sheet dialog and its presentation lifecycle.
70
+ * Acts as a RootView to properly dispatch touch events to React Native.
64
71
  */
65
72
  @SuppressLint("ClickableViewAccessibility", "ViewConstructor")
66
73
  class TrueSheetViewController(private val reactContext: ThemedReactContext) :
@@ -72,56 +79,35 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
72
79
  companion object {
73
80
  const val TAG_NAME = "TrueSheet"
74
81
 
75
- private const val MAX_HALF_EXPANDED_RATIO = 0.999f
82
+ // Prevents fully expanded ratio which causes behavior issues
76
83
  private const val GRABBER_TAG = "TrueSheetGrabber"
77
84
  private const val DEFAULT_MAX_WIDTH = 640 // dp
78
85
  private const val DEFAULT_CORNER_RADIUS = 16 // dp
79
86
  private const val TRANSLATE_ANIMATION_DURATION = 200L
80
87
  }
81
88
 
82
- // ====================================================================
83
- // MARK: - Delegate
84
- // ====================================================================
85
-
86
- var delegate: TrueSheetViewControllerDelegate? = null
87
-
88
- // ====================================================================
89
- // MARK: - Dialog & Views
90
- // ====================================================================
91
-
92
- private var dialog: BottomSheetDialog? = null
93
- private var dimView: TrueSheetDimView? = null
94
- private var parentDimView: TrueSheetDimView? = null
95
-
96
- private val behavior: BottomSheetBehavior<FrameLayout>?
97
- get() = dialog?.behavior
98
-
99
- private val sheetContainer: FrameLayout?
100
- get() = this.parent as? FrameLayout
101
-
102
- override val bottomSheetView: FrameLayout?
103
- get() = dialog?.findViewById(com.google.android.material.R.id.design_bottom_sheet)
104
-
105
- private val containerView: TrueSheetContainerView?
106
- get() = if (this.isNotEmpty()) getChildAt(0) as? TrueSheetContainerView else null
107
-
108
- override val contentHeight: Int
109
- get() = containerView?.contentHeight ?: 0
89
+ // =============================================================================
90
+ // MARK: - Types
91
+ // =============================================================================
110
92
 
111
- override val headerHeight: Int
112
- get() = containerView?.headerHeight ?: 0
113
-
114
- // ====================================================================
115
- // MARK: - State
116
- // ====================================================================
117
-
118
- /** Interaction state for the sheet */
119
93
  private sealed class InteractionState {
120
94
  data object Idle : InteractionState()
121
95
  data class Dragging(val startTop: Int) : InteractionState()
122
96
  data object Reconfiguring : InteractionState()
123
97
  }
124
98
 
99
+ // =============================================================================
100
+ // MARK: - Properties
101
+ // =============================================================================
102
+
103
+ var delegate: TrueSheetViewControllerDelegate? = null
104
+
105
+ // Dialog & Views
106
+ private var dialog: BottomSheetDialog? = null
107
+ private var dimView: TrueSheetDimView? = null
108
+ private var parentDimView: TrueSheetDimView? = null
109
+
110
+ // Presentation State
125
111
  var isPresented = false
126
112
  private set
127
113
 
@@ -139,48 +125,47 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
139
125
  private var lastStateWidth: Int = 0
140
126
  private var lastStateHeight: Int = 0
141
127
  private var lastEmittedPositionPx: Int = -1
128
+
129
+ // Keyboard State
142
130
  private var detentIndexBeforeKeyboard: Int = -1
143
131
  private var isKeyboardTransitioning: Boolean = false
144
132
 
133
+ // Promises
145
134
  var presentPromise: (() -> Unit)? = null
146
135
  var dismissPromise: (() -> Unit)? = null
147
136
 
148
- // Reference to parent TrueSheetView (if presented from another sheet)
137
+ // For stacked sheets
149
138
  var parentSheetView: TrueSheetView? = null
150
139
 
151
- // ====================================================================
152
- // MARK: - Helper Classes
153
- // ====================================================================
154
-
140
+ // Helper Objects
155
141
  private val sheetAnimator = TrueSheetAnimator(this)
156
142
  private var keyboardObserver: TrueSheetKeyboardObserver? = null
157
143
  private var rnScreensObserver: RNScreensFragmentObserver? = null
144
+ private val detentCalculator = TrueSheetDetentCalculator(this)
158
145
 
159
- // ====================================================================
160
- // MARK: - Configuration Properties
161
- // ====================================================================
162
-
163
- override val screenHeight: Int
164
- get() = ScreenUtils.getScreenHeight(reactContext)
165
- val screenWidth: Int
166
- get() = ScreenUtils.getScreenWidth(reactContext)
167
- override val realScreenHeight: Int
168
- get() = ScreenUtils.getRealScreenHeight(reactContext)
146
+ // Touch Dispatchers
147
+ internal var eventDispatcher: EventDispatcher? = null
148
+ private val jSTouchDispatcher = JSTouchDispatcher(this)
149
+ private var jSPointerDispatcher: JSPointerDispatcher? = null
169
150
 
151
+ // Detent Configuration
170
152
  override var maxSheetHeight: Int? = null
171
153
  override var detents: MutableList<Double> = mutableListOf(0.5, 1.0)
172
154
 
155
+ // Appearance Configuration
173
156
  var dimmed = true
174
157
  var dimmedDetentIndex = 0
175
158
  var grabber: Boolean = true
176
159
  var grabberOptions: GrabberOptions? = null
160
+ var sheetBackgroundColor: Int? = null
161
+ var edgeToEdgeFullScreen: Boolean = false
162
+ var insetAdjustment: String = "automatic"
163
+
177
164
  var sheetCornerRadius: Float = DEFAULT_CORNER_RADIUS.dpToPx()
178
165
  set(value) {
179
166
  field = if (value < 0) DEFAULT_CORNER_RADIUS.dpToPx() else value
180
167
  if (isPresented) setupBackground()
181
168
  }
182
- var sheetBackgroundColor: Int? = null
183
- var edgeToEdgeFullScreen: Boolean = false
184
169
 
185
170
  var dismissible: Boolean = true
186
171
  set(value) {
@@ -198,13 +183,47 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
198
183
  behavior?.isDraggable = value
199
184
  }
200
185
 
201
- // ====================================================================
186
+ // =============================================================================
202
187
  // MARK: - Computed Properties
203
- // ====================================================================
188
+ // =============================================================================
189
+
190
+ // Dialog
191
+ private val behavior: BottomSheetBehavior<FrameLayout>?
192
+ get() = dialog?.behavior
193
+
194
+ private val sheetContainer: FrameLayout?
195
+ get() = this.parent as? FrameLayout
196
+
197
+ override val bottomSheetView: FrameLayout?
198
+ get() = dialog?.findViewById(com.google.android.material.R.id.design_bottom_sheet)
199
+
200
+ private val containerView: TrueSheetContainerView?
201
+ get() = if (this.isNotEmpty()) getChildAt(0) as? TrueSheetContainerView else null
202
+
203
+ // Screen Measurements
204
+ override val screenHeight: Int
205
+ get() = ScreenUtils.getScreenHeight(reactContext)
206
+
207
+ val screenWidth: Int
208
+ get() = ScreenUtils.getScreenWidth(reactContext)
209
+
210
+ // Includes system bars for accurate positioning
211
+ override val realScreenHeight: Int
212
+ get() = ScreenUtils.getRealScreenHeight(reactContext)
213
+
214
+ // Content Measurements
215
+ override val contentHeight: Int
216
+ get() = containerView?.contentHeight ?: 0
217
+
218
+ override val headerHeight: Int
219
+ get() = containerView?.headerHeight ?: 0
204
220
 
221
+ // Insets
222
+ // Target keyboard height used for detent calculations
205
223
  override val keyboardInset: Int
206
224
  get() = keyboardObserver?.targetHeight ?: 0
207
225
 
226
+ // Current animated keyboard height for positioning
208
227
  private val currentKeyboardInset: Int
209
228
  get() = keyboardObserver?.currentHeight ?: 0
210
229
 
@@ -214,8 +233,6 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
214
233
  val topInset: Int
215
234
  get() = if (edgeToEdgeEnabled) ScreenUtils.getInsets(reactContext).top else 0
216
235
 
217
- var insetAdjustment: String = "automatic"
218
-
219
236
  override val contentBottomInset: Int
220
237
  get() = if (insetAdjustment == "automatic") bottomInset else 0
221
238
 
@@ -225,24 +242,33 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
225
242
  return BuildConfig.EDGE_TO_EDGE_ENABLED || dialog?.edgeToEdgeEnabled == true || defaultEnabled
226
243
  }
227
244
 
228
- internal var eventDispatcher: EventDispatcher? = null
229
- private val jSTouchDispatcher = JSTouchDispatcher(this)
230
- private var jSPointerDispatcher: JSPointerDispatcher? = null
245
+ // Sheet State
246
+ val isExpanded: Boolean
247
+ get() {
248
+ val sheetTop = bottomSheetView?.top ?: return false
249
+ return sheetTop <= topInset
250
+ }
231
251
 
232
- /** Single instance that reads current values via TrueSheetDetentMeasurements interface */
233
- private val detentCalculator = TrueSheetDetentCalculator(this)
252
+ val currentTranslationY: Int
253
+ get() = bottomSheetView?.translationY?.toInt() ?: 0
254
+
255
+ private val isTopmostSheet: Boolean
256
+ get() {
257
+ val hostView = delegate as? TrueSheetView ?: return true
258
+ return TrueSheetDialogObserver.isTopmostSheet(hostView)
259
+ }
234
260
 
235
- // ====================================================================
261
+ // =============================================================================
236
262
  // MARK: - Initialization
237
- // ====================================================================
263
+ // =============================================================================
238
264
 
239
265
  init {
240
266
  jSPointerDispatcher = JSPointerDispatcher(this)
241
267
  }
242
268
 
243
- // ====================================================================
244
- // MARK: - Dialog Lifecycle
245
- // ====================================================================
269
+ // =============================================================================
270
+ // MARK: - Dialog Creation & Cleanup
271
+ // =============================================================================
246
272
 
247
273
  fun createDialog() {
248
274
  if (dialog != null) return
@@ -307,12 +333,22 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
307
333
  shouldAnimatePresent = true
308
334
  }
309
335
 
336
+ // =============================================================================
337
+ // MARK: - Dialog Listeners
338
+ // =============================================================================
339
+
310
340
  private fun setupDialogListeners(dialog: BottomSheetDialog) {
311
341
  dialog.setOnShowListener {
312
342
  bottomSheetView?.visibility = VISIBLE
343
+
313
344
  isPresented = true
314
345
  isDialogVisible = true
315
346
 
347
+ emitWillPresentEvents()
348
+
349
+ setupSheetDetents()
350
+ setupBackground()
351
+ setupGrabber()
316
352
  setupKeyboardObserver()
317
353
 
318
354
  if (shouldAnimatePresent) {
@@ -346,8 +382,6 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
346
382
  override fun onSlide(sheetView: View, slideOffset: Float) {
347
383
  val behavior = behavior ?: return
348
384
 
349
- emitChangePositionDelegate(sheetView.top)
350
-
351
385
  when (behavior.state) {
352
386
  BottomSheetBehavior.STATE_DRAGGING,
353
387
  BottomSheetBehavior.STATE_SETTLING -> handleDragChange(sheetView)
@@ -355,9 +389,13 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
355
389
  else -> { }
356
390
  }
357
391
 
358
- if (!isKeyboardTransitioning) {
359
- positionFooter(slideOffset)
360
- updateDimAmount(sheetView.top)
392
+ if (!sheetAnimator.isAnimating) {
393
+ emitChangePositionDelegate(sheetView.top)
394
+
395
+ if (!isKeyboardTransitioning) {
396
+ positionFooter(slideOffset)
397
+ updateDimAmount(sheetView.top)
398
+ }
361
399
  }
362
400
  }
363
401
 
@@ -421,33 +459,30 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
421
459
  }
422
460
  }
423
461
 
462
+ // =============================================================================
463
+ // MARK: - Modal Observer (react-native-screens)
464
+ // =============================================================================
465
+
424
466
  private fun setupModalObserver() {
425
467
  rnScreensObserver = RNScreensFragmentObserver(
426
468
  reactContext = reactContext,
427
469
  onModalPresented = {
428
- if (isPresented && isDialogVisible) {
429
- isDialogVisible = false
430
- wasHiddenByModal = true
431
-
432
- dialog?.window?.setWindowAnimations(com.lodev09.truesheet.R.style.TrueSheetFastFadeOut)
433
- dialog?.window?.decorView?.visibility = GONE
434
- dimView?.visibility = INVISIBLE
435
- parentDimView?.visibility = INVISIBLE
470
+ if (isPresented && isDialogVisible && isTopmostSheet) {
471
+ hideForModal()
436
472
  }
437
473
  },
438
474
  onModalWillDismiss = {
439
- if (isPresented && wasHiddenByModal) {
440
- isDialogVisible = true
441
-
442
- dialog?.window?.setWindowAnimations(0)
443
- dialog?.window?.decorView?.visibility = VISIBLE
444
- dimView?.visibility = VISIBLE
445
- parentDimView?.visibility = VISIBLE
475
+ if (isPresented && wasHiddenByModal && isTopmostSheet) {
476
+ showAfterModal()
446
477
  }
447
478
  },
448
479
  onModalDidDismiss = {
449
480
  if (isPresented && wasHiddenByModal) {
450
481
  wasHiddenByModal = false
482
+ // Restore parent sheet after this sheet is restored
483
+ parentSheetView?.viewController?.let { parent ->
484
+ post { parent.showAfterModal() }
485
+ }
451
486
  }
452
487
  }
453
488
  )
@@ -459,88 +494,36 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
459
494
  rnScreensObserver = null
460
495
  }
461
496
 
462
- // ====================================================================
463
- // MARK: - Event Emission
464
- // ====================================================================
465
-
466
- private fun emitWillPresentEvents() {
467
- val (index, position, detent) = getDetentInfoWithValue(currentDetentIndex)
468
- parentSheetView?.viewControllerWillBlur()
469
- delegate?.viewControllerWillPresent(index, position, detent)
470
- delegate?.viewControllerWillFocus()
471
- }
472
-
473
- private fun emitWillDismissEvents() {
474
- delegate?.viewControllerWillBlur()
475
- delegate?.viewControllerWillDismiss()
476
- parentSheetView?.viewControllerWillFocus()
477
- }
478
-
479
- private fun emitDidDismissEvents() {
480
- val hadParent = parentSheetView != null
481
- parentSheetView?.viewControllerDidFocus()
482
- parentSheetView = null
483
-
484
- delegate?.viewControllerDidBlur()
485
- delegate?.viewControllerDidDismiss(hadParent)
486
-
487
- dismissPromise?.invoke()
488
- dismissPromise = null
489
- }
497
+ private fun hideForModal() {
498
+ isDialogVisible = false
499
+ wasHiddenByModal = true
490
500
 
491
- private fun emitChangePositionDelegate(currentTop: Int, realtime: Boolean = true) {
492
- if (currentTop == lastEmittedPositionPx) return
501
+ // Prepare for fast fade out
502
+ dimView?.alpha = 0f
503
+ parentDimView?.alpha = 0f
493
504
 
494
- lastEmittedPositionPx = currentTop
495
- val visibleHeight = realScreenHeight - currentTop
496
- val position = detentCalculator.getPositionDp(visibleHeight)
497
- val interpolatedIndex = detentCalculator.getInterpolatedIndexForPosition(currentTop)
498
- val detent = detentCalculator.getInterpolatedDetentForPosition(currentTop)
499
- delegate?.viewControllerDidChangePosition(interpolatedIndex, position, detent, realtime)
500
- }
505
+ dialog?.window?.setWindowAnimations(com.lodev09.truesheet.R.style.TrueSheetFastFadeOut)
506
+ dialog?.window?.decorView?.visibility = GONE
507
+ dimView?.visibility = INVISIBLE
508
+ parentDimView?.visibility = INVISIBLE
501
509
 
502
- private fun getDetentInfoWithValue(index: Int): Triple<Int, Float, Float> {
503
- val state = detentCalculator.getStateForDetentIndex(index)
504
- val detentIndex = detentCalculator.getDetentIndexForState(state) ?: 0
505
- val position = getPositionForDetentIndex(detentIndex)
506
- val detent = detentCalculator.getDetentValueForIndex(detentIndex)
507
- return Triple(detentIndex, position, detent)
510
+ parentSheetView?.viewController?.hideForModal()
508
511
  }
509
512
 
510
- // ====================================================================
511
- // MARK: - Dialog Visibility (for stacking)
512
- // ====================================================================
513
-
514
- val isExpanded: Boolean
515
- get() {
516
- val sheetTop = bottomSheetView?.top ?: return false
517
- return sheetTop <= topInset
518
- }
519
-
520
- val currentTranslationY: Int
521
- get() = bottomSheetView?.translationY?.toInt() ?: 0
522
-
523
- fun getExpectedSheetTop(detentIndex: Int): Int {
524
- if (detentIndex < 0 || detentIndex >= detents.size) return screenHeight
525
- return realScreenHeight - detentCalculator.getDetentHeight(detents[detentIndex])
526
- }
513
+ private fun showAfterModal() {
514
+ isDialogVisible = true
527
515
 
528
- fun translateDialog(translationY: Int) {
529
- val bottomSheet = bottomSheetView ?: return
516
+ dialog?.window?.setWindowAnimations(0)
517
+ dialog?.window?.decorView?.visibility = VISIBLE
518
+ dimView?.visibility = VISIBLE
519
+ parentDimView?.visibility = VISIBLE
530
520
 
531
- bottomSheet.animate()
532
- .translationY(translationY.toFloat())
533
- .setDuration(TRANSLATE_ANIMATION_DURATION)
534
- .setUpdateListener {
535
- val effectiveTop = bottomSheet.top + bottomSheet.translationY.toInt()
536
- emitChangePositionDelegate(effectiveTop)
537
- }
538
- .start()
521
+ updateDimAmount(animated = true)
539
522
  }
540
523
 
541
- // ====================================================================
524
+ // =============================================================================
542
525
  // MARK: - Presentation
543
- // ====================================================================
526
+ // =============================================================================
544
527
 
545
528
  fun present(detentIndex: Int, animated: Boolean = true) {
546
529
  val dialog = this.dialog ?: run {
@@ -549,21 +532,15 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
549
532
  }
550
533
 
551
534
  setupDimmedBackground(detentIndex)
535
+ setStateForDetentIndex(detentIndex)
552
536
 
553
- if (isPresented) {
554
- setStateForDetentIndex(detentIndex)
555
- } else {
537
+ if (!isPresented) {
556
538
  shouldAnimatePresent = animated
557
539
  currentDetentIndex = detentIndex
558
540
  interactionState = InteractionState.Idle
559
541
 
560
- emitWillPresentEvents()
561
-
562
- setupSheetDetents()
563
- setStateForDetentIndex(detentIndex)
564
- setupBackground()
565
- setupGrabber()
566
-
542
+ // Position off-screen until animation starts
543
+ bottomSheetView?.translationY = realScreenHeight.toFloat()
567
544
  bottomSheetView?.visibility = INVISIBLE
568
545
 
569
546
  dialog.show()
@@ -601,9 +578,9 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
601
578
  presentPromise = null
602
579
  }
603
580
 
604
- // ====================================================================
581
+ // =============================================================================
605
582
  // MARK: - Sheet Configuration
606
- // ====================================================================
583
+ // =============================================================================
607
584
 
608
585
  fun setupSheetDetents() {
609
586
  val behavior = this.behavior ?: return
@@ -627,9 +604,12 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
627
604
  val maxDetentHeight = detentCalculator.getDetentHeight(detents.last())
628
605
 
629
606
  val adjustedHalfExpandedHeight = minOf(halfExpandedDetentHeight, maxAvailableHeight)
630
- halfExpandedRatio = minOf(adjustedHalfExpandedHeight.toFloat() / realScreenHeight.toFloat(), MAX_HALF_EXPANDED_RATIO)
607
+ halfExpandedRatio = (adjustedHalfExpandedHeight.toFloat() / realScreenHeight.toFloat())
608
+ .coerceIn(0f, 0.999f)
631
609
 
632
610
  expandedOffset = maxOf(edgeToEdgeTopInset, realScreenHeight - maxDetentHeight)
611
+
612
+ // fitToContents works better with <= 2 detents when no expanded offset
633
613
  isFitToContents = detents.size < 3 && expandedOffset == 0
634
614
 
635
615
  val offset = if (expandedOffset == 0) topInset else 0
@@ -655,6 +635,14 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
655
635
  positionFooter()
656
636
  }
657
637
 
638
+ fun setStateForDetentIndex(index: Int) {
639
+ behavior?.state = detentCalculator.getStateForDetentIndex(index)
640
+ }
641
+
642
+ // =============================================================================
643
+ // MARK: - Grabber
644
+ // =============================================================================
645
+
658
646
  fun setupGrabber() {
659
647
  val bottomSheet = bottomSheetView ?: return
660
648
 
@@ -671,61 +659,14 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
671
659
  bottomSheet.addView(grabberView)
672
660
  }
673
661
 
674
- // ====================================================================
675
- // MARK: - Keyboard Handling
676
- // ====================================================================
677
-
678
- private fun shouldHandleKeyboard(): Boolean {
679
- if (wasHiddenByModal) return false
680
-
681
- val parentView = parentSheetView ?: return true
682
- return TrueSheetDialogObserver.getSheetsAbove(parentView).firstOrNull()?.viewController == this
683
- }
684
-
685
- fun setupKeyboardObserver() {
686
- val bottomSheet = bottomSheetView ?: return
687
- keyboardObserver = TrueSheetKeyboardObserver(bottomSheet, reactContext).apply {
688
- delegate = object : TrueSheetKeyboardObserverDelegate {
689
- override fun keyboardWillShow(height: Int) {
690
- if (!shouldHandleKeyboard()) return
691
- detentIndexBeforeKeyboard = currentDetentIndex
692
- isKeyboardTransitioning = true
693
- setupSheetDetents()
694
- setStateForDetentIndex(detents.size - 1)
695
- }
696
-
697
- override fun keyboardWillHide() {
698
- if (!shouldHandleKeyboard()) return
699
- setupSheetDetents()
700
- if (detentIndexBeforeKeyboard >= 0) {
701
- setStateForDetentIndex(detentIndexBeforeKeyboard)
702
- detentIndexBeforeKeyboard = -1
703
- }
704
- }
705
-
706
- override fun keyboardDidHide() {
707
- if (!shouldHandleKeyboard()) return
708
- isKeyboardTransitioning = false
709
- }
710
-
711
- override fun keyboardDidChangeHeight(height: Int) {}
712
- }
713
- start()
714
- }
715
- }
716
-
717
- fun cleanupKeyboardObserver() {
718
- keyboardObserver?.stop()
719
- keyboardObserver = null
720
- }
721
-
722
- // ====================================================================
662
+ // =============================================================================
723
663
  // MARK: - Background & Dimming
724
- // ====================================================================
664
+ // =============================================================================
725
665
 
726
666
  fun setupBackground() {
727
667
  val bottomSheet = bottomSheetView ?: return
728
668
 
669
+ // Rounded corners only on top
729
670
  val outerRadii = floatArrayOf(
730
671
  sheetCornerRadius,
731
672
  sheetCornerRadius,
@@ -759,6 +700,7 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
759
700
  if (dimView == null) dimView = TrueSheetDimView(reactContext)
760
701
  if (!parentDimVisible) dimView?.attach(null)
761
702
 
703
+ // Attach dim view to parent sheet if stacked
762
704
  val parentController = parentSheetView?.viewController
763
705
  val parentBottomSheet = parentController?.bottomSheetView
764
706
  if (parentBottomSheet != null) {
@@ -780,6 +722,7 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
780
722
  true
781
723
  }
782
724
  } else {
725
+ // Pass through touches to parent or activity when not dimmed
783
726
  touchOutside.setOnTouchListener { v, event ->
784
727
  event.setLocation(event.rawX - v.x, event.rawY - v.y)
785
728
  (
@@ -793,13 +736,42 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
793
736
  }
794
737
  }
795
738
 
796
- fun updateDimAmount(sheetTop: Int? = null) {
739
+ fun updateDimAmount(sheetTop: Int? = null, animated: Boolean = false) {
797
740
  if (!dimmed) return
798
741
  val top = (sheetTop ?: bottomSheetView?.top ?: return) + currentKeyboardInset
799
- dimView?.interpolateAlpha(top, dimmedDetentIndex, detentCalculator::getSheetTopForDetentIndex)
800
- parentDimView?.interpolateAlpha(top, dimmedDetentIndex, detentCalculator::getSheetTopForDetentIndex)
742
+
743
+ if (animated) {
744
+ val targetAlpha = dimView?.calculateAlpha(
745
+ top,
746
+ dimmedDetentIndex,
747
+ detentCalculator::getSheetTopForDetentIndex
748
+ ) ?: 0f
749
+ dimView?.animate()?.alpha(targetAlpha)?.setDuration(200)?.start()
750
+ parentDimView?.animate()?.alpha(targetAlpha)?.setDuration(200)?.start()
751
+ } else {
752
+ dimView?.interpolateAlpha(top, dimmedDetentIndex, detentCalculator::getSheetTopForDetentIndex)
753
+ parentDimView?.interpolateAlpha(top, dimmedDetentIndex, detentCalculator::getSheetTopForDetentIndex)
754
+ }
801
755
  }
802
756
 
757
+ fun getDefaultBackgroundColor(): Int {
758
+ val typedValue = TypedValue()
759
+ return if (reactContext.theme.resolveAttribute(
760
+ com.google.android.material.R.attr.colorSurfaceContainerLow,
761
+ typedValue,
762
+ true
763
+ )
764
+ ) {
765
+ typedValue.data
766
+ } else {
767
+ Color.WHITE
768
+ }
769
+ }
770
+
771
+ // =============================================================================
772
+ // MARK: - Footer Positioning
773
+ // =============================================================================
774
+
803
775
  fun positionFooter(slideOffset: Float? = null) {
804
776
  val footerView = containerView?.footerView ?: return
805
777
  val bottomSheet = bottomSheetView ?: return
@@ -809,35 +781,66 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
809
781
  val sheetTop = bottomSheet.top
810
782
 
811
783
  var footerY = (sheetHeight - sheetTop - footerHeight - currentKeyboardInset).toFloat()
784
+
785
+ // Adjust during dismiss animation when slideOffset is negative
812
786
  if (slideOffset != null && slideOffset < 0) {
813
787
  footerY -= (footerHeight * slideOffset)
814
788
  }
815
789
 
790
+ // Clamp to prevent footer going above safe area
816
791
  val maxAllowedY = (sheetHeight - topInset - footerHeight).toFloat()
817
792
  footerView.y = minOf(footerY, maxAllowedY)
818
793
  }
819
794
 
820
- fun setStateForDetentIndex(index: Int) {
821
- behavior?.state = detentCalculator.getStateForDetentIndex(index)
795
+ // =============================================================================
796
+ // MARK: - Keyboard Handling
797
+ // =============================================================================
798
+
799
+ private fun shouldHandleKeyboard(): Boolean {
800
+ if (wasHiddenByModal) return false
801
+ return isTopmostSheet
822
802
  }
823
803
 
824
- fun getDefaultBackgroundColor(): Int {
825
- val typedValue = TypedValue()
826
- return if (reactContext.theme.resolveAttribute(
827
- com.google.android.material.R.attr.colorSurfaceContainerLow,
828
- typedValue,
829
- true
830
- )
831
- ) {
832
- typedValue.data
833
- } else {
834
- Color.WHITE
804
+ fun setupKeyboardObserver() {
805
+ val bottomSheet = bottomSheetView ?: return
806
+ keyboardObserver = TrueSheetKeyboardObserver(bottomSheet, reactContext).apply {
807
+ delegate = object : TrueSheetKeyboardObserverDelegate {
808
+ override fun keyboardWillShow(height: Int) {
809
+ if (!shouldHandleKeyboard()) return
810
+ detentIndexBeforeKeyboard = currentDetentIndex
811
+ isKeyboardTransitioning = true
812
+ setupSheetDetents()
813
+ setStateForDetentIndex(detents.size - 1)
814
+ }
815
+
816
+ override fun keyboardWillHide() {
817
+ if (!shouldHandleKeyboard()) return
818
+ setupSheetDetents()
819
+ if (detentIndexBeforeKeyboard >= 0) {
820
+ setStateForDetentIndex(detentIndexBeforeKeyboard)
821
+ detentIndexBeforeKeyboard = -1
822
+ }
823
+ }
824
+
825
+ override fun keyboardDidHide() {
826
+ if (!shouldHandleKeyboard()) return
827
+ isKeyboardTransitioning = false
828
+ }
829
+
830
+ override fun keyboardDidChangeHeight(height: Int) {}
831
+ }
832
+ start()
835
833
  }
836
834
  }
837
835
 
838
- // ====================================================================
836
+ fun cleanupKeyboardObserver() {
837
+ keyboardObserver?.stop()
838
+ keyboardObserver = null
839
+ }
840
+
841
+ // =============================================================================
839
842
  // MARK: - Drag Handling
840
- // ====================================================================
843
+ // =============================================================================
841
844
 
842
845
  private fun handleDragBegin(sheetView: View) {
843
846
  val position = detentCalculator.getPositionDp(detentCalculator.getVisibleSheetHeight(sheetView.top))
@@ -853,13 +856,75 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
853
856
  delegate?.viewControllerDidDragChange(currentDetentIndex, position, detent)
854
857
  }
855
858
 
856
- // ====================================================================
859
+ // =============================================================================
860
+ // MARK: - Event Emission
861
+ // =============================================================================
862
+
863
+ private fun emitWillPresentEvents() {
864
+ val (index, position, detent) = getDetentInfoWithValue(currentDetentIndex)
865
+ parentSheetView?.viewControllerWillBlur()
866
+ delegate?.viewControllerWillPresent(index, position, detent)
867
+ delegate?.viewControllerWillFocus()
868
+ }
869
+
870
+ private fun emitWillDismissEvents() {
871
+ delegate?.viewControllerWillBlur()
872
+ delegate?.viewControllerWillDismiss()
873
+ parentSheetView?.viewControllerWillFocus()
874
+ }
875
+
876
+ private fun emitDidDismissEvents() {
877
+ val hadParent = parentSheetView != null
878
+ parentSheetView?.viewControllerDidFocus()
879
+ parentSheetView = null
880
+
881
+ delegate?.viewControllerDidBlur()
882
+ delegate?.viewControllerDidDismiss(hadParent)
883
+
884
+ dismissPromise?.invoke()
885
+ dismissPromise = null
886
+ }
887
+
888
+ private fun emitChangePositionDelegate(currentTop: Int, realtime: Boolean = true) {
889
+ // Dedupe emissions for same position
890
+ if (currentTop == lastEmittedPositionPx) return
891
+
892
+ lastEmittedPositionPx = currentTop
893
+ val visibleHeight = realScreenHeight - currentTop
894
+ val position = detentCalculator.getPositionDp(visibleHeight)
895
+ val interpolatedIndex = detentCalculator.getInterpolatedIndexForPosition(currentTop)
896
+ val detent = detentCalculator.getInterpolatedDetentForPosition(currentTop)
897
+ delegate?.viewControllerDidChangePosition(interpolatedIndex, position, detent, realtime)
898
+ }
899
+
900
+ // =============================================================================
857
901
  // MARK: - Detent Helpers
858
- // ====================================================================
902
+ // =============================================================================
903
+
904
+ fun getExpectedSheetTop(detentIndex: Int): Int {
905
+ if (detentIndex < 0 || detentIndex >= detents.size) return screenHeight
906
+ return realScreenHeight - detentCalculator.getDetentHeight(detents[detentIndex])
907
+ }
908
+
909
+ fun translateDialog(translationY: Int) {
910
+ val bottomSheet = bottomSheetView ?: return
859
911
 
860
- fun getDetentInfoForState(state: Int): DetentInfo? {
861
- val index = detentCalculator.getDetentIndexForState(state) ?: return null
862
- return DetentInfo(index, getPositionForDetentIndex(index))
912
+ bottomSheet.animate()
913
+ .translationY(translationY.toFloat())
914
+ .setDuration(TRANSLATE_ANIMATION_DURATION)
915
+ .setUpdateListener {
916
+ val effectiveTop = bottomSheet.top + bottomSheet.translationY.toInt()
917
+ emitChangePositionDelegate(effectiveTop)
918
+ }
919
+ .start()
920
+ }
921
+
922
+ private fun getDetentInfoWithValue(index: Int): Triple<Int, Float, Float> {
923
+ val state = detentCalculator.getStateForDetentIndex(index)
924
+ val detentIndex = detentCalculator.getDetentIndexForState(state) ?: 0
925
+ val position = getPositionForDetentIndex(detentIndex)
926
+ val detent = detentCalculator.getDetentValueForIndex(detentIndex)
927
+ return Triple(detentIndex, position, detent)
863
928
  }
864
929
 
865
930
  private fun getPositionForDetentIndex(index: Int): Float {
@@ -867,7 +932,7 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
867
932
 
868
933
  bottomSheetView?.let {
869
934
  val visibleSheetHeight = detentCalculator.getVisibleSheetHeight(it.top)
870
- if (visibleSheetHeight > 0 && visibleSheetHeight < realScreenHeight) {
935
+ if (visibleSheetHeight in 1..<realScreenHeight) {
871
936
  return detentCalculator.getPositionDp(visibleSheetHeight)
872
937
  }
873
938
  }
@@ -876,12 +941,9 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
876
941
  return detentCalculator.getPositionDp(detentHeight)
877
942
  }
878
943
 
879
- fun getDetentInfoForIndex(index: Int): DetentInfo =
880
- getDetentInfoForState(detentCalculator.getStateForDetentIndex(index)) ?: DetentInfo(0, 0f)
881
-
882
- // ====================================================================
944
+ // =============================================================================
883
945
  // MARK: - RootView Implementation
884
- // ====================================================================
946
+ // =============================================================================
885
947
 
886
948
  override fun onInitializeAccessibilityNodeInfo(info: AccessibilityNodeInfo) {
887
949
  super.onInitializeAccessibilityNodeInfo(info)
@@ -893,9 +955,11 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
893
955
 
894
956
  if (w == oldw && h == oldh) return
895
957
  if (!isPresented) return
958
+
959
+ // Skip reconfiguration if expanded and only height changed (e.g., keyboard)
896
960
  if (h + topInset >= screenHeight && isExpanded && oldw == w) return
897
961
 
898
- this.post {
962
+ post {
899
963
  setupSheetDetents()
900
964
  positionFooter()
901
965
  bottomSheetView?.let { emitChangePositionDelegate(it.top, realtime = false) }
@@ -906,17 +970,19 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
906
970
  reactContext.reactApplicationContext.handleException(RuntimeException(t))
907
971
  }
908
972
 
909
- // ====================================================================
973
+ // =============================================================================
910
974
  // MARK: - Touch Event Handling
911
- // ====================================================================
975
+ // =============================================================================
912
976
 
913
977
  override fun dispatchTouchEvent(event: MotionEvent): Boolean {
978
+ // Footer needs special handling since it's positioned absolutely
914
979
  val footer = containerView?.footerView
915
980
  if (footer != null && footer.isVisible) {
916
981
  val footerLocation = ScreenUtils.getScreenLocation(footer)
917
982
  val touchScreenX = event.rawX.toInt()
918
983
  val touchScreenY = event.rawY.toInt()
919
984
 
985
+ // Check if touch is within footer bounds
920
986
  if (touchScreenX >= footerLocation[0] &&
921
987
  touchScreenX <= footerLocation[0] + footer.width &&
922
988
  touchScreenY >= footerLocation[1] &&