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