@lodev09/react-native-true-sheet 3.4.2 → 3.5.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,5 @@
1
1
  package com.lodev09.truesheet
2
2
 
3
- import android.animation.ValueAnimator
4
3
  import android.annotation.SuppressLint
5
4
  import android.graphics.Color
6
5
  import android.graphics.drawable.ShapeDrawable
@@ -12,7 +11,6 @@ import android.view.View
12
11
  import android.view.WindowManager
13
12
  import android.view.accessibility.AccessibilityNodeInfo
14
13
  import android.widget.FrameLayout
15
- import androidx.core.view.ViewCompat
16
14
  import androidx.core.view.isNotEmpty
17
15
  import androidx.core.view.isVisible
18
16
  import com.facebook.react.R
@@ -29,6 +27,11 @@ import com.google.android.material.bottomsheet.BottomSheetBehavior
29
27
  import com.google.android.material.bottomsheet.BottomSheetDialog
30
28
  import com.lodev09.truesheet.core.GrabberOptions
31
29
  import com.lodev09.truesheet.core.RNScreensFragmentObserver
30
+ import com.lodev09.truesheet.core.TrueSheetAnimator
31
+ import com.lodev09.truesheet.core.TrueSheetAnimatorProvider
32
+ import com.lodev09.truesheet.core.TrueSheetDetentCalculator
33
+ import com.lodev09.truesheet.core.TrueSheetDetentMeasurements
34
+ import com.lodev09.truesheet.core.TrueSheetDialogObserver
32
35
  import com.lodev09.truesheet.core.TrueSheetDimView
33
36
  import com.lodev09.truesheet.core.TrueSheetGrabberView
34
37
  import com.lodev09.truesheet.core.TrueSheetKeyboardObserver
@@ -62,21 +65,18 @@ interface TrueSheetViewControllerDelegate {
62
65
  @SuppressLint("ClickableViewAccessibility", "ViewConstructor")
63
66
  class TrueSheetViewController(private val reactContext: ThemedReactContext) :
64
67
  ReactViewGroup(reactContext),
65
- RootView {
68
+ RootView,
69
+ TrueSheetDetentMeasurements,
70
+ TrueSheetAnimatorProvider {
66
71
 
67
72
  companion object {
68
73
  const val TAG_NAME = "TrueSheet"
69
74
 
70
75
  private const val MAX_HALF_EXPANDED_RATIO = 0.999f
71
-
72
76
  private const val GRABBER_TAG = "TrueSheetGrabber"
73
77
  private const val DEFAULT_MAX_WIDTH = 640 // dp
74
78
  private const val DEFAULT_CORNER_RADIUS = 16 // dp
75
-
76
- // Animation durations from res/anim/true_sheet_slide_in.xml and true_sheet_slide_out.xml
77
- private const val PRESENT_ANIMATION_DURATION = 250L
78
- private const val DISMISS_ANIMATION_DURATION = 150L
79
- private const val TRANSLATE_ANIMATION_DURATION = 100L
79
+ private const val TRANSLATE_ANIMATION_DURATION = 200L
80
80
  }
81
81
 
82
82
  // ====================================================================
@@ -99,22 +99,29 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
99
99
  private val sheetContainer: FrameLayout?
100
100
  get() = this.parent as? FrameLayout
101
101
 
102
- private val bottomSheetView: FrameLayout?
102
+ override val bottomSheetView: FrameLayout?
103
103
  get() = dialog?.findViewById(com.google.android.material.R.id.design_bottom_sheet)
104
104
 
105
105
  private val containerView: TrueSheetContainerView?
106
106
  get() = if (this.isNotEmpty()) getChildAt(0) as? TrueSheetContainerView else null
107
107
 
108
- private val contentHeight: Int
108
+ override val contentHeight: Int
109
109
  get() = containerView?.contentHeight ?: 0
110
110
 
111
- private val headerHeight: Int
111
+ override val headerHeight: Int
112
112
  get() = containerView?.headerHeight ?: 0
113
113
 
114
114
  // ====================================================================
115
115
  // MARK: - State
116
116
  // ====================================================================
117
117
 
118
+ /** Interaction state for the sheet */
119
+ private sealed class InteractionState {
120
+ data object Idle : InteractionState()
121
+ data class Dragging(val startTop: Int) : InteractionState()
122
+ data object Reconfiguring : InteractionState()
123
+ }
124
+
118
125
  var isPresented = false
119
126
  private set
120
127
 
@@ -124,16 +131,16 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
124
131
  var currentDetentIndex: Int = -1
125
132
  private set
126
133
 
134
+ private var interactionState: InteractionState = InteractionState.Idle
135
+ private var isDismissing = false
136
+ private var wasHiddenByModal = false
137
+ private var shouldAnimatePresent = true
138
+
127
139
  private var lastStateWidth: Int = 0
128
140
  private var lastStateHeight: Int = 0
129
- private var isDragging = false
130
- private var isDismissing = false
131
- private var isReconfiguring = false
132
- private var windowAnimation: Int = 0
133
141
  private var lastEmittedPositionPx: Int = -1
134
-
135
- /** Tracks if this sheet was hidden due to a RN Screens modal (vs sheet stacking) */
136
- private var wasHiddenByModal = false
142
+ private var detentIndexBeforeKeyboard: Int = -1
143
+ private var isKeyboardTransitioning: Boolean = false
137
144
 
138
145
  var presentPromise: (() -> Unit)? = null
139
146
  var dismissPromise: (() -> Unit)? = null
@@ -141,19 +148,27 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
141
148
  // Reference to parent TrueSheetView (if presented from another sheet)
142
149
  var parentSheetView: TrueSheetView? = null
143
150
 
151
+ // ====================================================================
152
+ // MARK: - Helper Classes
153
+ // ====================================================================
154
+
155
+ private val sheetAnimator = TrueSheetAnimator(this)
156
+ private var keyboardObserver: TrueSheetKeyboardObserver? = null
157
+ private var rnScreensObserver: RNScreensFragmentObserver? = null
158
+
144
159
  // ====================================================================
145
160
  // MARK: - Configuration Properties
146
161
  // ====================================================================
147
162
 
148
- val screenHeight: Int
163
+ override val screenHeight: Int
149
164
  get() = ScreenUtils.getScreenHeight(reactContext)
150
165
  val screenWidth: Int
151
166
  get() = ScreenUtils.getScreenWidth(reactContext)
152
- val realScreenHeight: Int
167
+ override val realScreenHeight: Int
153
168
  get() = ScreenUtils.getRealScreenHeight(reactContext)
154
169
 
155
- var maxSheetHeight: Int? = null
156
- var detents = mutableListOf(0.5, 1.0)
170
+ override var maxSheetHeight: Int? = null
171
+ override var detents: MutableList<Double> = mutableListOf(0.5, 1.0)
157
172
 
158
173
  var dimmed = true
159
174
  var dimmedDetentIndex = 0
@@ -162,7 +177,7 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
162
177
  var sheetCornerRadius: Float = DEFAULT_CORNER_RADIUS.dpToPx()
163
178
  set(value) {
164
179
  field = if (value < 0) DEFAULT_CORNER_RADIUS.dpToPx() else value
165
- setupBackground()
180
+ if (isPresented) setupBackground()
166
181
  }
167
182
  var sheetBackgroundColor: Int? = null
168
183
  var edgeToEdgeFullScreen: Boolean = false
@@ -187,6 +202,12 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
187
202
  // MARK: - Computed Properties
188
203
  // ====================================================================
189
204
 
205
+ override val keyboardInset: Int
206
+ get() = keyboardObserver?.targetHeight ?: 0
207
+
208
+ private val currentKeyboardInset: Int
209
+ get() = keyboardObserver?.currentHeight ?: 0
210
+
190
211
  val bottomInset: Int
191
212
  get() = if (edgeToEdgeEnabled) ScreenUtils.getInsets(reactContext).bottom else 0
192
213
 
@@ -195,11 +216,9 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
195
216
 
196
217
  var insetAdjustment: String = "automatic"
197
218
 
198
- /** Auto add bottom inset for consistency with iOS when insetAdjustment is 'automatic' */
199
- val contentBottomInset: Int
219
+ override val contentBottomInset: Int
200
220
  get() = if (insetAdjustment == "automatic") bottomInset else 0
201
221
 
202
- /** Edge-to-edge enabled by default on API 36+, or when explicitly configured. */
203
222
  private val edgeToEdgeEnabled: Boolean
204
223
  get() {
205
224
  val defaultEnabled = android.os.Build.VERSION.SDK_INT >= 36
@@ -210,8 +229,8 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
210
229
  private val jSTouchDispatcher = JSTouchDispatcher(this)
211
230
  private var jSPointerDispatcher: JSPointerDispatcher? = null
212
231
 
213
- /** Hides/shows the sheet when RN Screens modals are presented/dismissed. */
214
- private var rnScreensObserver: RNScreensFragmentObserver? = null
232
+ /** Single instance that reads current values via TrueSheetDetentMeasurements interface */
233
+ private val detentCalculator = TrueSheetDetentCalculator(this)
215
234
 
216
235
  // ====================================================================
217
236
  // MARK: - Initialization
@@ -238,8 +257,7 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
238
257
  setContentView(this@TrueSheetViewController)
239
258
 
240
259
  window?.apply {
241
- windowAnimation = attributes.windowAnimations
242
- // Disable default keyboard avoidance - sheet handles it via setupKeyboardObserver
260
+ setWindowAnimations(0)
243
261
  setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING)
244
262
  }
245
263
 
@@ -272,6 +290,7 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
272
290
 
273
291
  cleanupKeyboardObserver()
274
292
  cleanupModalObserver()
293
+ sheetAnimator.cancel()
275
294
  dimView?.detach()
276
295
  dimView = null
277
296
  parentDimView?.detach()
@@ -279,58 +298,45 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
279
298
  sheetContainer?.removeView(this)
280
299
 
281
300
  dialog = null
282
- isDragging = false
301
+ interactionState = InteractionState.Idle
283
302
  isDismissing = false
284
303
  isPresented = false
285
304
  isDialogVisible = false
286
305
  wasHiddenByModal = false
287
306
  lastEmittedPositionPx = -1
307
+ shouldAnimatePresent = true
288
308
  }
289
309
 
290
310
  private fun setupDialogListeners(dialog: BottomSheetDialog) {
291
311
  dialog.setOnShowListener {
312
+ bottomSheetView?.visibility = VISIBLE
292
313
  isPresented = true
293
314
  isDialogVisible = true
294
- resetAnimation()
295
- setupBackground()
296
- setupGrabber()
297
- setupKeyboardObserver()
298
315
 
299
- val toTop = getExpectedSheetTop(currentDetentIndex)
300
- setupTransitionTracker(realScreenHeight, toTop, PRESENT_ANIMATION_DURATION)
301
- animateDimAlpha(show = true)
316
+ setupKeyboardObserver()
302
317
 
303
- sheetContainer?.post {
318
+ if (shouldAnimatePresent) {
319
+ val toTop = getExpectedSheetTop(currentDetentIndex)
320
+ sheetAnimator.animatePresent(
321
+ toTop = toTop,
322
+ onUpdate = { effectiveTop ->
323
+ emitChangePositionDelegate(effectiveTop)
324
+ positionFooter()
325
+ updateDimAmount(effectiveTop)
326
+ },
327
+ onEnd = { finishPresent() }
328
+ )
329
+ } else {
330
+ val toTop = getExpectedSheetTop(currentDetentIndex)
331
+ emitChangePositionDelegate(toTop)
304
332
  positionFooter()
333
+ finishPresent()
305
334
  }
306
-
307
- sheetContainer?.postDelayed({
308
- val (index, position, detent) = getDetentInfoWithValue(currentDetentIndex)
309
-
310
- delegate?.viewControllerDidPresent(index, position, detent)
311
- parentSheetView?.viewControllerDidBlur()
312
- delegate?.viewControllerDidFocus()
313
-
314
- presentPromise?.invoke()
315
- presentPromise = null
316
- }, PRESENT_ANIMATION_DURATION)
317
- }
318
-
319
- dialog.setOnCancelListener {
320
- if (isDismissing) return@setOnCancelListener
321
-
322
- isDismissing = true
323
- val fromTop = bottomSheetView?.top ?: getExpectedSheetTop(currentDetentIndex)
324
- setupTransitionTracker(fromTop, realScreenHeight, DISMISS_ANIMATION_DURATION)
325
- animateDimAlpha(show = false)
326
- emitWillDismissEvents()
327
335
  }
328
336
 
329
337
  dialog.setOnDismissListener {
330
- android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({
331
- emitDidDismissEvents()
332
- cleanupDialog()
333
- }, DISMISS_ANIMATION_DURATION)
338
+ emitDidDismissEvents()
339
+ cleanupDialog()
334
340
  }
335
341
  }
336
342
 
@@ -349,8 +355,10 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
349
355
  else -> { }
350
356
  }
351
357
 
352
- positionFooter(slideOffset)
353
- updateDimAmount()
358
+ if (!isKeyboardTransitioning) {
359
+ positionFooter(slideOffset)
360
+ updateDimAmount(sheetView.top)
361
+ }
354
362
  }
355
363
 
356
364
  override fun onStateChanged(sheetView: View, newState: Int) {
@@ -369,30 +377,7 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
369
377
 
370
378
  BottomSheetBehavior.STATE_EXPANDED,
371
379
  BottomSheetBehavior.STATE_COLLAPSED,
372
- BottomSheetBehavior.STATE_HALF_EXPANDED -> {
373
- if (isReconfiguring) return
374
-
375
- getDetentInfoForState(newState)?.let { detentInfo ->
376
- if (isDragging) {
377
- val detent = getDetentValueForIndex(detentInfo.index)
378
- delegate?.viewControllerDidDragEnd(detentInfo.index, detentInfo.position, detent)
379
-
380
- if (detentInfo.index != currentDetentIndex) {
381
- presentPromise?.invoke()
382
- presentPromise = null
383
- currentDetentIndex = detentInfo.index
384
- setupDimmedBackground(detentInfo.index)
385
- delegate?.viewControllerDidChangeDetent(detentInfo.index, detentInfo.position, detent)
386
- }
387
-
388
- isDragging = false
389
- } else if (detentInfo.index != currentDetentIndex) {
390
- val detent = getDetentValueForIndex(detentInfo.index)
391
- currentDetentIndex = detentInfo.index
392
- delegate?.viewControllerDidChangeDetent(detentInfo.index, detentInfo.position, detent)
393
- }
394
- }
395
- }
380
+ BottomSheetBehavior.STATE_HALF_EXPANDED -> handleStateSettled(sheetView, newState)
396
381
 
397
382
  else -> {}
398
383
  }
@@ -401,30 +386,67 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
401
386
  )
402
387
  }
403
388
 
389
+ private fun handleStateSettled(sheetView: View, newState: Int) {
390
+ if (interactionState is InteractionState.Reconfiguring) return
391
+
392
+ val index = detentCalculator.getDetentIndexForState(newState) ?: return
393
+ val position = detentCalculator.getPositionDp(detentCalculator.getVisibleSheetHeight(sheetView.top))
394
+ val detentInfo = DetentInfo(index, position)
395
+
396
+ when (interactionState) {
397
+ is InteractionState.Dragging -> {
398
+ val detent = detentCalculator.getDetentValueForIndex(detentInfo.index)
399
+ delegate?.viewControllerDidDragEnd(detentInfo.index, detentInfo.position, detent)
400
+
401
+ if (detentInfo.index != currentDetentIndex) {
402
+ presentPromise?.invoke()
403
+ presentPromise = null
404
+ currentDetentIndex = detentInfo.index
405
+ setupDimmedBackground(detentInfo.index)
406
+ delegate?.viewControllerDidChangeDetent(detentInfo.index, detentInfo.position, detent)
407
+ }
408
+
409
+ interactionState = InteractionState.Idle
410
+ }
411
+
412
+ else -> {
413
+ if (detentInfo.index != currentDetentIndex) {
414
+ currentDetentIndex = detentInfo.index
415
+ if (!isKeyboardTransitioning) {
416
+ val detent = detentCalculator.getDetentValueForIndex(detentInfo.index)
417
+ delegate?.viewControllerDidChangeDetent(detentInfo.index, detentInfo.position, detent)
418
+ }
419
+ }
420
+ }
421
+ }
422
+ }
423
+
404
424
  private fun setupModalObserver() {
405
425
  rnScreensObserver = RNScreensFragmentObserver(
406
426
  reactContext = reactContext,
407
427
  onModalPresented = {
408
428
  if (isPresented && isDialogVisible) {
409
429
  isDialogVisible = false
410
- dialog?.window?.setWindowAnimations(com.lodev09.truesheet.R.style.TrueSheetFadeOutAnimation)
411
- dialog?.window?.decorView?.visibility = INVISIBLE
430
+ wasHiddenByModal = true
431
+
432
+ dialog?.window?.setWindowAnimations(com.lodev09.truesheet.R.style.TrueSheetFastFadeOut)
433
+ dialog?.window?.decorView?.visibility = GONE
412
434
  dimView?.visibility = INVISIBLE
413
435
  parentDimView?.visibility = INVISIBLE
414
- wasHiddenByModal = true
415
436
  }
416
437
  },
417
- onModalDismissed = {
418
- // Only show if we were the one hidden by modal, not by sheet stacking
438
+ onModalWillDismiss = {
419
439
  if (isPresented && wasHiddenByModal) {
420
440
  isDialogVisible = true
441
+
442
+ dialog?.window?.setWindowAnimations(0)
421
443
  dialog?.window?.decorView?.visibility = VISIBLE
422
444
  dimView?.visibility = VISIBLE
423
445
  parentDimView?.visibility = VISIBLE
424
- // Restore animation after visibility change to avoid slide animation
425
- sheetContainer?.post {
426
- dialog?.window?.setWindowAnimations(windowAnimation)
427
- }
446
+ }
447
+ },
448
+ onModalDidDismiss = {
449
+ if (isPresented && wasHiddenByModal) {
428
450
  wasHiddenByModal = false
429
451
  }
430
452
  }
@@ -437,6 +459,17 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
437
459
  rnScreensObserver = null
438
460
  }
439
461
 
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
+
440
473
  private fun emitWillDismissEvents() {
441
474
  delegate?.viewControllerWillBlur()
442
475
  delegate?.viewControllerWillDismiss()
@@ -455,11 +488,23 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
455
488
  dismissPromise = null
456
489
  }
457
490
 
458
- /** Helper to get detent info with its screen fraction value. */
491
+ private fun emitChangePositionDelegate(currentTop: Int, realtime: Boolean = true) {
492
+ if (currentTop == lastEmittedPositionPx) return
493
+
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
+ }
501
+
459
502
  private fun getDetentInfoWithValue(index: Int): Triple<Int, Float, Float> {
460
- val detentInfo = getDetentInfoForIndex(index)
461
- val detent = getDetentValueForIndex(detentInfo.index)
462
- return Triple(detentInfo.index, detentInfo.position, detent)
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)
463
508
  }
464
509
 
465
510
  // ====================================================================
@@ -472,18 +517,14 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
472
517
  return sheetTop <= topInset
473
518
  }
474
519
 
475
- val currentSheetTop: Int
476
- get() = bottomSheetView?.top ?: screenHeight
477
-
478
520
  val currentTranslationY: Int
479
521
  get() = bottomSheetView?.translationY?.toInt() ?: 0
480
522
 
481
523
  fun getExpectedSheetTop(detentIndex: Int): Int {
482
524
  if (detentIndex < 0 || detentIndex >= detents.size) return screenHeight
483
- return realScreenHeight - getDetentHeight(detents[detentIndex])
525
+ return realScreenHeight - detentCalculator.getDetentHeight(detents[detentIndex])
484
526
  }
485
527
 
486
- /** Translates the sheet when stacking. Pass 0 to reset. */
487
528
  fun translateDialog(translationY: Int) {
488
529
  val bottomSheet = bottomSheetView ?: return
489
530
 
@@ -512,20 +553,18 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
512
553
  if (isPresented) {
513
554
  setStateForDetentIndex(detentIndex)
514
555
  } else {
556
+ shouldAnimatePresent = animated
515
557
  currentDetentIndex = detentIndex
516
- isDragging = false
517
- setupSheetDetents()
518
- setStateForDetentIndex(detentIndex)
558
+ interactionState = InteractionState.Idle
519
559
 
520
- val (index, position, detent) = getDetentInfoWithValue(detentIndex)
560
+ emitWillPresentEvents()
521
561
 
522
- parentSheetView?.viewControllerWillBlur()
523
- delegate?.viewControllerWillPresent(index, position, detent)
524
- delegate?.viewControllerWillFocus()
562
+ setupSheetDetents()
563
+ setStateForDetentIndex(detentIndex)
564
+ setupBackground()
565
+ setupGrabber()
525
566
 
526
- if (!animated) {
527
- dialog.window?.setWindowAnimations(0)
528
- }
567
+ bottomSheetView?.visibility = INVISIBLE
529
568
 
530
569
  dialog.show()
531
570
  }
@@ -535,18 +574,33 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
535
574
  if (isDismissing) return
536
575
 
537
576
  isDismissing = true
538
- val fromTop = bottomSheetView?.top ?: getExpectedSheetTop(currentDetentIndex)
539
- setupTransitionTracker(fromTop, realScreenHeight, DISMISS_ANIMATION_DURATION)
540
577
  emitWillDismissEvents()
541
578
 
542
- if (!animated) {
543
- dialog?.window?.setWindowAnimations(0)
544
- post { dialog?.dismiss() }
579
+ if (animated) {
580
+ sheetAnimator.animateDismiss(
581
+ onUpdate = { effectiveTop ->
582
+ emitChangePositionDelegate(effectiveTop)
583
+ positionFooter()
584
+ updateDimAmount(effectiveTop)
585
+ },
586
+ onEnd = { dialog?.dismiss() }
587
+ )
545
588
  } else {
589
+ emitChangePositionDelegate(realScreenHeight)
546
590
  dialog?.dismiss()
547
591
  }
548
592
  }
549
593
 
594
+ private fun finishPresent() {
595
+ val (index, position, detent) = getDetentInfoWithValue(currentDetentIndex)
596
+ delegate?.viewControllerDidPresent(index, position, detent)
597
+ parentSheetView?.viewControllerDidBlur()
598
+ delegate?.viewControllerDidFocus()
599
+
600
+ presentPromise?.invoke()
601
+ presentPromise = null
602
+ }
603
+
550
604
  // ====================================================================
551
605
  // MARK: - Sheet Configuration
552
606
  // ====================================================================
@@ -554,7 +608,7 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
554
608
  fun setupSheetDetents() {
555
609
  val behavior = this.behavior ?: return
556
610
 
557
- isReconfiguring = true
611
+ interactionState = InteractionState.Reconfiguring
558
612
  val edgeToEdgeTopInset: Int = if (!edgeToEdgeFullScreen) topInset else 0
559
613
 
560
614
  behavior.apply {
@@ -563,14 +617,14 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
563
617
 
564
618
  val maxAvailableHeight = realScreenHeight - edgeToEdgeTopInset
565
619
 
566
- setPeekHeight(getDetentHeight(detents[0]), isPresented)
620
+ setPeekHeight(detentCalculator.getDetentHeight(detents[0]), isPresented)
567
621
 
568
622
  val halfExpandedDetentHeight = when (detents.size) {
569
623
  1 -> peekHeight
570
- else -> getDetentHeight(detents[1])
624
+ else -> detentCalculator.getDetentHeight(detents[1])
571
625
  }
572
626
 
573
- val maxDetentHeight = getDetentHeight(detents.last())
627
+ val maxDetentHeight = detentCalculator.getDetentHeight(detents.last())
574
628
 
575
629
  val adjustedHalfExpandedHeight = minOf(halfExpandedDetentHeight, maxAvailableHeight)
576
630
  halfExpandedRatio = minOf(adjustedHalfExpandedHeight.toFloat() / realScreenHeight.toFloat(), MAX_HALF_EXPANDED_RATIO)
@@ -592,7 +646,7 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
592
646
  setStateForDetentIndex(currentDetentIndex)
593
647
  }
594
648
 
595
- isReconfiguring = false
649
+ interactionState = InteractionState.Idle
596
650
  }
597
651
  }
598
652
 
@@ -617,16 +671,44 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
617
671
  bottomSheet.addView(grabberView)
618
672
  }
619
673
 
620
- private var keyboardObserver: TrueSheetKeyboardObserver? = null
621
- private var positionAnimator: ValueAnimator? = null
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
+ }
622
684
 
623
685
  fun setupKeyboardObserver() {
624
686
  val bottomSheet = bottomSheetView ?: return
625
687
  keyboardObserver = TrueSheetKeyboardObserver(bottomSheet, reactContext).apply {
626
688
  delegate = object : TrueSheetKeyboardObserverDelegate {
627
- override fun keyboardHeightDidChange(height: Int) {
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
628
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
629
709
  }
710
+
711
+ override fun keyboardDidChangeHeight(height: Int) {}
630
712
  }
631
713
  start()
632
714
  }
@@ -637,37 +719,9 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
637
719
  keyboardObserver = null
638
720
  }
639
721
 
640
- private fun setupTransitionTracker(fromTop: Int, toTop: Int, duration: Long) {
641
- positionAnimator?.cancel()
642
- positionAnimator = ValueAnimator.ofInt(fromTop, toTop).apply {
643
- this.duration = duration
644
- interpolator = if (fromTop > toTop) {
645
- android.view.animation.DecelerateInterpolator(2f) // present
646
- } else {
647
- android.view.animation.AccelerateInterpolator(2f) // dismiss
648
- }
649
- addUpdateListener { animator ->
650
- emitChangePositionDelegate(animator.animatedValue as Int)
651
- }
652
- addListener(object : android.animation.AnimatorListenerAdapter() {
653
- override fun onAnimationEnd(animation: android.animation.Animator) {
654
- positionAnimator = null
655
- }
656
- })
657
- start()
658
- }
659
- }
660
-
661
- private fun emitChangePositionDelegate(currentTop: Int, realtime: Boolean = true) {
662
- if (currentTop == lastEmittedPositionPx) return
663
-
664
- lastEmittedPositionPx = currentTop
665
- val visibleHeight = realScreenHeight - currentTop
666
- val position = getPositionDp(visibleHeight)
667
- val interpolatedIndex = getInterpolatedIndexForPosition(currentTop)
668
- val detent = getInterpolatedDetentForPosition(currentTop)
669
- delegate?.viewControllerDidChangePosition(interpolatedIndex, position, detent, realtime)
670
- }
722
+ // ====================================================================
723
+ // MARK: - Background & Dimming
724
+ // ====================================================================
671
725
 
672
726
  fun setupBackground() {
673
727
  val bottomSheet = bottomSheetView ?: return
@@ -719,8 +773,12 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
719
773
  }
720
774
 
721
775
  if (shouldDimAtDetent) {
722
- touchOutside.setOnTouchListener(null)
723
- dialog.setCanceledOnTouchOutside(dismissible)
776
+ touchOutside.setOnTouchListener { _, event ->
777
+ if (event.action == MotionEvent.ACTION_UP && dismissible) {
778
+ dismiss()
779
+ }
780
+ true
781
+ }
724
782
  } else {
725
783
  touchOutside.setOnTouchListener { v, event ->
726
784
  event.setLocation(event.rawX - v.x, event.rawY - v.y)
@@ -735,25 +793,13 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
735
793
  }
736
794
  }
737
795
 
738
- fun resetAnimation() {
739
- dialog?.window?.setWindowAnimations(windowAnimation)
740
- }
741
-
742
- private fun animateDimAlpha(show: Boolean) {
796
+ fun updateDimAmount(sheetTop: Int? = null) {
743
797
  if (!dimmed) return
744
- val duration = if (show) PRESENT_ANIMATION_DURATION else DISMISS_ANIMATION_DURATION
745
- dimView?.animateAlpha(show, duration, dimmedDetentIndex, currentDetentIndex)
746
- parentDimView?.animateAlpha(show, duration, dimmedDetentIndex, currentDetentIndex)
798
+ val top = (sheetTop ?: bottomSheetView?.top ?: return) + currentKeyboardInset
799
+ dimView?.interpolateAlpha(top, dimmedDetentIndex, detentCalculator::getSheetTopForDetentIndex)
800
+ parentDimView?.interpolateAlpha(top, dimmedDetentIndex, detentCalculator::getSheetTopForDetentIndex)
747
801
  }
748
802
 
749
- fun updateDimAmount() {
750
- if (!dimmed) return
751
- val sheetTop = bottomSheetView?.top ?: return
752
- dimView?.interpolateAlpha(sheetTop, dimmedDetentIndex, ::getSheetTopForDetentIndex)
753
- parentDimView?.interpolateAlpha(sheetTop, dimmedDetentIndex, ::getSheetTopForDetentIndex)
754
- }
755
-
756
- /** Positions footer at bottom of sheet, adjusting during drag via slideOffset. */
757
803
  fun positionFooter(slideOffset: Float? = null) {
758
804
  val footerView = containerView?.footerView ?: return
759
805
  val bottomSheet = bottomSheetView ?: return
@@ -762,20 +808,17 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
762
808
  val sheetHeight = bottomSheet.height
763
809
  val sheetTop = bottomSheet.top
764
810
 
765
- // Footer Y relative to sheet: place at bottom of sheet container minus footer height
766
- var footerY = (sheetHeight - sheetTop - footerHeight - keyboardHeight).toFloat()
767
-
811
+ var footerY = (sheetHeight - sheetTop - footerHeight - currentKeyboardInset).toFloat()
768
812
  if (slideOffset != null && slideOffset < 0) {
769
813
  footerY -= (footerHeight * slideOffset)
770
814
  }
771
815
 
772
- // Clamp to prevent footer from going above visible area
773
816
  val maxAllowedY = (sheetHeight - topInset - footerHeight).toFloat()
774
817
  footerView.y = minOf(footerY, maxAllowedY)
775
818
  }
776
819
 
777
820
  fun setStateForDetentIndex(index: Int) {
778
- behavior?.state = getStateForDetentIndex(index)
821
+ behavior?.state = detentCalculator.getStateForDetentIndex(index)
779
822
  }
780
823
 
781
824
  fun getDefaultBackgroundColor(): Int {
@@ -793,193 +836,48 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
793
836
  }
794
837
 
795
838
  // ====================================================================
796
- // MARK: - Position & Drag Handling
839
+ // MARK: - Drag Handling
797
840
  // ====================================================================
798
841
 
799
- /**
800
- * Calculate the visible sheet height from a sheet view.
801
- * Uses real screen height for consistency across API levels.
802
- */
803
- private fun getVisibleSheetHeight(sheetView: View): Int = realScreenHeight - sheetView.top
804
-
805
- private fun getPositionDp(visibleSheetHeight: Int): Float = (screenHeight - visibleSheetHeight).pxToDp()
806
-
807
- /**
808
- * Get the expected sheetTop position for a detent index.
809
- */
810
- private fun getSheetTopForDetentIndex(index: Int): Int {
811
- if (index < 0 || index >= detents.size) return realScreenHeight
812
- return realScreenHeight - getDetentHeight(detents[index])
813
- }
814
-
815
- /** Returns (fromIndex, toIndex, progress) for interpolation, or null if < 2 detents. */
816
- private fun findSegmentForPosition(positionPx: Int): Triple<Int, Int, Float>? {
817
- val count = detents.size
818
- if (count == 0) return null
819
-
820
- val firstPos = getSheetTopForDetentIndex(0)
821
-
822
- // Above first detent - interpolating toward closed
823
- if (positionPx > firstPos) {
824
- val range = realScreenHeight - firstPos
825
- val progress = if (range > 0) (positionPx - firstPos).toFloat() / range else 0f
826
- return Triple(-1, 0, progress)
827
- }
828
-
829
- // Single detent - at or above the detent
830
- if (count == 1) return Triple(0, 0, 0f)
831
-
832
- val lastPos = getSheetTopForDetentIndex(count - 1)
833
-
834
- // Below last detent
835
- if (positionPx < lastPos) {
836
- return Triple(count - 1, count - 1, 0f)
837
- }
838
-
839
- // Between detents
840
- for (i in 0 until count - 1) {
841
- val pos = getSheetTopForDetentIndex(i)
842
- val nextPos = getSheetTopForDetentIndex(i + 1)
843
-
844
- if (positionPx in nextPos..pos) {
845
- val range = pos - nextPos
846
- val progress = if (range > 0) (pos - positionPx).toFloat() / range else 0f
847
- return Triple(i, i + 1, maxOf(0f, minOf(1f, progress)))
848
- }
849
- }
850
-
851
- return Triple(count - 1, count - 1, 0f)
852
- }
853
-
854
- /** Returns continuous index (e.g., 0.5 = halfway between detent 0 and 1). */
855
- private fun getInterpolatedIndexForPosition(positionPx: Int): Float {
856
- val count = detents.size
857
- if (count == 0) return -1f
858
-
859
- val segment = findSegmentForPosition(positionPx) ?: return 0f
860
- val (fromIndex, _, progress) = segment
861
-
862
- if (fromIndex == -1) return -progress
863
- return fromIndex + progress
864
- }
865
-
866
- /** Returns interpolated screen fraction for position. */
867
- private fun getInterpolatedDetentForPosition(positionPx: Int): Float {
868
- val count = detents.size
869
- if (count == 0) return 0f
870
-
871
- val segment = findSegmentForPosition(positionPx) ?: return getDetentValueForIndex(0)
872
- val (fromIndex, toIndex, progress) = segment
873
-
874
- if (fromIndex == -1) {
875
- val firstDetent = getDetentValueForIndex(0)
876
- return maxOf(0f, firstDetent * (1 - progress))
877
- }
878
-
879
- val fromDetent = getDetentValueForIndex(fromIndex)
880
- val toDetent = getDetentValueForIndex(toIndex)
881
- return fromDetent + progress * (toDetent - fromDetent)
882
- }
883
-
884
- /** Returns raw screen fraction for index (without bottomInset). */
885
- private fun getDetentValueForIndex(index: Int): Float {
886
- if (index < 0 || index >= detents.size) return 0f
887
- val value = detents[index]
888
- return if (value == -1.0) {
889
- (contentHeight + headerHeight).toFloat() / screenHeight.toFloat()
890
- } else {
891
- value.toFloat()
892
- }
893
- }
894
-
895
- private fun getCurrentDetentInfo(sheetView: View): DetentInfo {
896
- val position = getPositionDp(getVisibleSheetHeight(sheetView))
897
- return DetentInfo(currentDetentIndex, position)
898
- }
899
-
900
842
  private fun handleDragBegin(sheetView: View) {
901
- val detentInfo = getCurrentDetentInfo(sheetView)
902
- val detent = getDetentValueForIndex(detentInfo.index)
903
- delegate?.viewControllerDidDragBegin(detentInfo.index, detentInfo.position, detent)
904
- isDragging = true
843
+ val position = detentCalculator.getPositionDp(detentCalculator.getVisibleSheetHeight(sheetView.top))
844
+ val detent = detentCalculator.getDetentValueForIndex(currentDetentIndex)
845
+ delegate?.viewControllerDidDragBegin(currentDetentIndex, position, detent)
846
+ interactionState = InteractionState.Dragging(startTop = sheetView.top)
905
847
  }
906
848
 
907
849
  private fun handleDragChange(sheetView: View) {
908
- if (!isDragging) return
909
- val detentInfo = getCurrentDetentInfo(sheetView)
910
- val detent = getDetentValueForIndex(detentInfo.index)
911
- delegate?.viewControllerDidDragChange(detentInfo.index, detentInfo.position, detent)
850
+ if (interactionState !is InteractionState.Dragging) return
851
+ val position = detentCalculator.getPositionDp(detentCalculator.getVisibleSheetHeight(sheetView.top))
852
+ val detent = detentCalculator.getDetentValueForIndex(currentDetentIndex)
853
+ delegate?.viewControllerDidDragChange(currentDetentIndex, position, detent)
912
854
  }
913
855
 
914
856
  // ====================================================================
915
- // MARK: - Detent Calculations
857
+ // MARK: - Detent Helpers
916
858
  // ====================================================================
917
859
 
918
- private val keyboardHeight: Int
919
- get() = keyboardObserver?.currentHeight ?: 0
920
-
921
- private fun getDetentHeight(detent: Double): Int {
922
- val height = if (detent == -1.0) {
923
- contentHeight + headerHeight + contentBottomInset + keyboardHeight
924
- } else {
925
- if (detent <= 0.0 || detent > 1.0) {
926
- throw IllegalArgumentException("TrueSheet: detent fraction ($detent) must be between 0 and 1")
927
- }
928
- (detent * screenHeight).toInt() + contentBottomInset + keyboardHeight
929
- }
930
-
931
- val maxAllowedHeight = screenHeight + contentBottomInset
932
- return maxSheetHeight?.let { minOf(height, it, maxAllowedHeight) } ?: minOf(height, maxAllowedHeight)
933
- }
934
-
935
- /** Maps detent index to BottomSheetBehavior state based on detent count. */
936
- private fun getStateForDetentIndex(index: Int): Int {
937
- val stateMap = getDetentStateMap() ?: return BottomSheetBehavior.STATE_HIDDEN
938
- return stateMap.entries.find { it.value == index }?.key ?: BottomSheetBehavior.STATE_HIDDEN
939
- }
940
-
941
- /** Maps BottomSheetBehavior state to DetentInfo based on detent count. */
942
860
  fun getDetentInfoForState(state: Int): DetentInfo? {
943
- val stateMap = getDetentStateMap() ?: return null
944
- val index = stateMap[state] ?: return null
861
+ val index = detentCalculator.getDetentIndexForState(state) ?: return null
945
862
  return DetentInfo(index, getPositionForDetentIndex(index))
946
863
  }
947
864
 
948
- /** Returns state-to-index mapping based on detent count. */
949
- private fun getDetentStateMap(): Map<Int, Int>? =
950
- when (detents.size) {
951
- 1 -> mapOf(
952
- BottomSheetBehavior.STATE_COLLAPSED to 0,
953
- BottomSheetBehavior.STATE_EXPANDED to 0
954
- )
955
-
956
- 2 -> mapOf(
957
- BottomSheetBehavior.STATE_COLLAPSED to 0,
958
- BottomSheetBehavior.STATE_EXPANDED to 1
959
- )
960
-
961
- 3 -> mapOf(
962
- BottomSheetBehavior.STATE_COLLAPSED to 0,
963
- BottomSheetBehavior.STATE_HALF_EXPANDED to 1,
964
- BottomSheetBehavior.STATE_EXPANDED to 2
965
- )
966
-
967
- else -> null
968
- }
969
-
970
865
  private fun getPositionForDetentIndex(index: Int): Float {
971
866
  if (index < 0 || index >= detents.size) return screenHeight.pxToDp()
972
867
 
973
868
  bottomSheetView?.let {
974
- val visibleSheetHeight = getVisibleSheetHeight(it)
975
- if (visibleSheetHeight > 0) return getPositionDp(visibleSheetHeight)
869
+ val visibleSheetHeight = detentCalculator.getVisibleSheetHeight(it.top)
870
+ if (visibleSheetHeight > 0 && visibleSheetHeight < realScreenHeight) {
871
+ return detentCalculator.getPositionDp(visibleSheetHeight)
872
+ }
976
873
  }
977
874
 
978
- val detentHeight = getDetentHeight(detents[index])
979
- return getPositionDp(detentHeight)
875
+ val detentHeight = detentCalculator.getDetentHeight(detents[index])
876
+ return detentCalculator.getPositionDp(detentHeight)
980
877
  }
981
878
 
982
- fun getDetentInfoForIndex(index: Int) = getDetentInfoForState(getStateForDetentIndex(index)) ?: DetentInfo(0, 0f)
879
+ fun getDetentInfoForIndex(index: Int): DetentInfo =
880
+ getDetentInfoForState(detentCalculator.getStateForDetentIndex(index)) ?: DetentInfo(0, 0f)
983
881
 
984
882
  // ====================================================================
985
883
  // MARK: - RootView Implementation
@@ -995,11 +893,7 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
995
893
 
996
894
  if (w == oldw && h == oldh) return
997
895
  if (!isPresented) return
998
-
999
- // Skip continuous size changes when fullScreen + edge-to-edge
1000
- if (h + topInset >= screenHeight && isExpanded && oldw == w) {
1001
- return
1002
- }
896
+ if (h + topInset >= screenHeight && isExpanded && oldw == w) return
1003
897
 
1004
898
  this.post {
1005
899
  setupSheetDetents()
@@ -1016,7 +910,6 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
1016
910
  // MARK: - Touch Event Handling
1017
911
  // ====================================================================
1018
912
 
1019
- /** Forwards touch events to footer which is positioned outside normal hierarchy. */
1020
913
  override fun dispatchTouchEvent(event: MotionEvent): Boolean {
1021
914
  val footer = containerView?.footerView
1022
915
  if (footer != null && footer.isVisible) {