@lodev09/react-native-true-sheet 3.4.1 → 3.5.0-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.
@@ -1,18 +1,15 @@
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
7
6
  import android.graphics.drawable.shapes.RoundRectShape
8
- import android.util.Log
9
7
  import android.util.TypedValue
10
8
  import android.view.MotionEvent
11
9
  import android.view.View
12
10
  import android.view.WindowManager
13
11
  import android.view.accessibility.AccessibilityNodeInfo
14
12
  import android.widget.FrameLayout
15
- import androidx.core.view.ViewCompat
16
13
  import androidx.core.view.isNotEmpty
17
14
  import androidx.core.view.isVisible
18
15
  import com.facebook.react.R
@@ -29,6 +26,11 @@ import com.google.android.material.bottomsheet.BottomSheetBehavior
29
26
  import com.google.android.material.bottomsheet.BottomSheetDialog
30
27
  import com.lodev09.truesheet.core.GrabberOptions
31
28
  import com.lodev09.truesheet.core.RNScreensFragmentObserver
29
+ import com.lodev09.truesheet.core.TrueSheetAnimator
30
+ import com.lodev09.truesheet.core.TrueSheetAnimatorProvider
31
+ import com.lodev09.truesheet.core.TrueSheetDetentCalculator
32
+ import com.lodev09.truesheet.core.TrueSheetDetentMeasurements
33
+ import com.lodev09.truesheet.core.TrueSheetDialogObserver
32
34
  import com.lodev09.truesheet.core.TrueSheetDimView
33
35
  import com.lodev09.truesheet.core.TrueSheetGrabberView
34
36
  import com.lodev09.truesheet.core.TrueSheetKeyboardObserver
@@ -62,21 +64,18 @@ interface TrueSheetViewControllerDelegate {
62
64
  @SuppressLint("ClickableViewAccessibility", "ViewConstructor")
63
65
  class TrueSheetViewController(private val reactContext: ThemedReactContext) :
64
66
  ReactViewGroup(reactContext),
65
- RootView {
67
+ RootView,
68
+ TrueSheetDetentMeasurements,
69
+ TrueSheetAnimatorProvider {
66
70
 
67
71
  companion object {
68
72
  const val TAG_NAME = "TrueSheet"
69
73
 
70
74
  private const val MAX_HALF_EXPANDED_RATIO = 0.999f
71
-
72
75
  private const val GRABBER_TAG = "TrueSheetGrabber"
73
76
  private const val DEFAULT_MAX_WIDTH = 640 // dp
74
77
  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
78
+ private const val TRANSLATE_ANIMATION_DURATION = 200L
80
79
  }
81
80
 
82
81
  // ====================================================================
@@ -99,22 +98,32 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
99
98
  private val sheetContainer: FrameLayout?
100
99
  get() = this.parent as? FrameLayout
101
100
 
102
- private val bottomSheetView: FrameLayout?
101
+ override val bottomSheetView: FrameLayout?
103
102
  get() = dialog?.findViewById(com.google.android.material.R.id.design_bottom_sheet)
104
103
 
105
104
  private val containerView: TrueSheetContainerView?
106
105
  get() = if (this.isNotEmpty()) getChildAt(0) as? TrueSheetContainerView else null
107
106
 
108
- private val contentHeight: Int
107
+ override val contentHeight: Int
109
108
  get() = containerView?.contentHeight ?: 0
110
109
 
111
- private val headerHeight: Int
110
+ override val headerHeight: Int
112
111
  get() = containerView?.headerHeight ?: 0
113
112
 
113
+ override val keyboardHeight: Int
114
+ get() = keyboardObserver?.currentHeight ?: 0
115
+
114
116
  // ====================================================================
115
117
  // MARK: - State
116
118
  // ====================================================================
117
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
+
118
127
  var isPresented = false
119
128
  private set
120
129
 
@@ -124,36 +133,42 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
124
133
  var currentDetentIndex: Int = -1
125
134
  private set
126
135
 
136
+ private var interactionState: InteractionState = InteractionState.Idle
137
+ private var isDismissing = false
138
+ private var wasHiddenByModal = false
139
+ private var shouldAnimatePresent = true
140
+
127
141
  private var lastStateWidth: Int = 0
128
142
  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
143
  private var lastEmittedPositionPx: Int = -1
134
144
 
135
- /** Tracks if this sheet was hidden due to a RN Screens modal (vs sheet stacking) */
136
- private var wasHiddenByModal = false
137
-
138
145
  var presentPromise: (() -> Unit)? = null
139
146
  var dismissPromise: (() -> Unit)? = null
140
147
 
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
@@ -195,11 +210,9 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
195
210
 
196
211
  var insetAdjustment: String = "automatic"
197
212
 
198
- /** Auto add bottom inset for consistency with iOS when insetAdjustment is 'automatic' */
199
- val contentBottomInset: Int
213
+ override val contentBottomInset: Int
200
214
  get() = if (insetAdjustment == "automatic") bottomInset else 0
201
215
 
202
- /** Edge-to-edge enabled by default on API 36+, or when explicitly configured. */
203
216
  private val edgeToEdgeEnabled: Boolean
204
217
  get() {
205
218
  val defaultEnabled = android.os.Build.VERSION.SDK_INT >= 36
@@ -210,8 +223,8 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
210
223
  private val jSTouchDispatcher = JSTouchDispatcher(this)
211
224
  private var jSPointerDispatcher: JSPointerDispatcher? = null
212
225
 
213
- /** Hides/shows the sheet when RN Screens modals are presented/dismissed. */
214
- private var rnScreensObserver: RNScreensFragmentObserver? = null
226
+ /** Single instance that reads current values via TrueSheetDetentMeasurements interface */
227
+ private val detentCalculator = TrueSheetDetentCalculator(this)
215
228
 
216
229
  // ====================================================================
217
230
  // MARK: - Initialization
@@ -238,8 +251,7 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
238
251
  setContentView(this@TrueSheetViewController)
239
252
 
240
253
  window?.apply {
241
- windowAnimation = attributes.windowAnimations
242
- // Disable default keyboard avoidance - sheet handles it via setupKeyboardObserver
254
+ setWindowAnimations(0)
243
255
  setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING)
244
256
  }
245
257
 
@@ -272,6 +284,7 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
272
284
 
273
285
  cleanupKeyboardObserver()
274
286
  cleanupModalObserver()
287
+ sheetAnimator.cancel()
275
288
  dimView?.detach()
276
289
  dimView = null
277
290
  parentDimView?.detach()
@@ -279,58 +292,45 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
279
292
  sheetContainer?.removeView(this)
280
293
 
281
294
  dialog = null
282
- isDragging = false
295
+ interactionState = InteractionState.Idle
283
296
  isDismissing = false
284
297
  isPresented = false
285
298
  isDialogVisible = false
286
299
  wasHiddenByModal = false
287
300
  lastEmittedPositionPx = -1
301
+ shouldAnimatePresent = true
288
302
  }
289
303
 
290
304
  private fun setupDialogListeners(dialog: BottomSheetDialog) {
291
305
  dialog.setOnShowListener {
306
+ bottomSheetView?.visibility = VISIBLE
292
307
  isPresented = true
293
308
  isDialogVisible = true
294
- resetAnimation()
295
- setupBackground()
296
- setupGrabber()
297
- setupKeyboardObserver()
298
309
 
299
- val toTop = getExpectedSheetTop(currentDetentIndex)
300
- setupTransitionTracker(realScreenHeight, toTop, PRESENT_ANIMATION_DURATION)
301
- animateDimAlpha(show = true)
310
+ setupKeyboardObserver()
302
311
 
303
- sheetContainer?.post {
312
+ if (shouldAnimatePresent) {
313
+ val toTop = getExpectedSheetTop(currentDetentIndex)
314
+ sheetAnimator.animatePresent(
315
+ toTop = toTop,
316
+ onUpdate = { effectiveTop ->
317
+ emitChangePositionDelegate(effectiveTop)
318
+ positionFooter()
319
+ updateDimAmount(effectiveTop)
320
+ },
321
+ onEnd = { finishPresent() }
322
+ )
323
+ } else {
324
+ val toTop = getExpectedSheetTop(currentDetentIndex)
325
+ emitChangePositionDelegate(toTop)
304
326
  positionFooter()
327
+ finishPresent()
305
328
  }
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
329
  }
328
330
 
329
331
  dialog.setOnDismissListener {
330
- android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({
331
- emitDidDismissEvents()
332
- cleanupDialog()
333
- }, DISMISS_ANIMATION_DURATION)
332
+ emitDidDismissEvents()
333
+ cleanupDialog()
334
334
  }
335
335
  }
336
336
 
@@ -350,7 +350,7 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
350
350
  }
351
351
 
352
352
  positionFooter(slideOffset)
353
- updateDimAmount()
353
+ updateDimAmount(sheetView.top)
354
354
  }
355
355
 
356
356
  override fun onStateChanged(sheetView: View, newState: Int) {
@@ -369,30 +369,7 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
369
369
 
370
370
  BottomSheetBehavior.STATE_EXPANDED,
371
371
  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
- }
372
+ BottomSheetBehavior.STATE_HALF_EXPANDED -> handleStateSettled(sheetView, newState)
396
373
 
397
374
  else -> {}
398
375
  }
@@ -401,30 +378,78 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
401
378
  )
402
379
  }
403
380
 
381
+ private fun handleStateSettled(sheetView: View, newState: Int) {
382
+ if (interactionState is InteractionState.Reconfiguring) return
383
+
384
+ val index = detentCalculator.getDetentIndexForState(newState) ?: return
385
+ val position = detentCalculator.getPositionDp(detentCalculator.getVisibleSheetHeight(sheetView.top))
386
+ val detentInfo = DetentInfo(index, position)
387
+
388
+ when (interactionState) {
389
+ is InteractionState.Dragging -> {
390
+ val draggingState = interactionState as InteractionState.Dragging
391
+ val detent = detentCalculator.getDetentValueForIndex(detentInfo.index)
392
+ delegate?.viewControllerDidDragEnd(detentInfo.index, detentInfo.position, detent)
393
+
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
+ 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
+ val detent = detentCalculator.getDetentValueForIndex(detentInfo.index)
416
+ delegate?.viewControllerDidChangeDetent(detentInfo.index, detentInfo.position, detent)
417
+ }
418
+ }
419
+ }
420
+ }
421
+
404
422
  private fun setupModalObserver() {
405
423
  rnScreensObserver = RNScreensFragmentObserver(
406
424
  reactContext = reactContext,
407
425
  onModalPresented = {
408
426
  if (isPresented && isDialogVisible) {
409
427
  isDialogVisible = false
410
- dialog?.window?.setWindowAnimations(com.lodev09.truesheet.R.style.TrueSheetFadeOutAnimation)
411
- dialog?.window?.decorView?.visibility = INVISIBLE
428
+ wasHiddenByModal = true
429
+
430
+ bottomSheetView?.animate()?.alpha(0f)?.setDuration(200)?.start()
412
431
  dimView?.visibility = INVISIBLE
413
432
  parentDimView?.visibility = INVISIBLE
414
- wasHiddenByModal = true
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
+ )
415
437
  }
416
438
  },
417
- onModalDismissed = {
418
- // Only show if we were the one hidden by modal, not by sheet stacking
439
+ onModalWillDismiss = {
419
440
  if (isPresented && wasHiddenByModal) {
420
441
  isDialogVisible = true
421
- dialog?.window?.decorView?.visibility = VISIBLE
442
+
443
+ dialog?.window?.clearFlags(
444
+ WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE or WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
445
+ )
446
+ bottomSheetView?.alpha = 1f
422
447
  dimView?.visibility = VISIBLE
423
448
  parentDimView?.visibility = VISIBLE
424
- // Restore animation after visibility change to avoid slide animation
425
- sheetContainer?.post {
426
- dialog?.window?.setWindowAnimations(windowAnimation)
427
- }
449
+ }
450
+ },
451
+ onModalDidDismiss = {
452
+ if (isPresented && wasHiddenByModal) {
428
453
  wasHiddenByModal = false
429
454
  }
430
455
  }
@@ -437,6 +462,17 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
437
462
  rnScreensObserver = null
438
463
  }
439
464
 
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
+
440
476
  private fun emitWillDismissEvents() {
441
477
  delegate?.viewControllerWillBlur()
442
478
  delegate?.viewControllerWillDismiss()
@@ -455,11 +491,23 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
455
491
  dismissPromise = null
456
492
  }
457
493
 
458
- /** Helper to get detent info with its screen fraction value. */
494
+ private fun emitChangePositionDelegate(currentTop: Int, realtime: Boolean = true) {
495
+ if (currentTop == lastEmittedPositionPx) return
496
+
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
+
459
505
  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)
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)
463
511
  }
464
512
 
465
513
  // ====================================================================
@@ -472,18 +520,14 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
472
520
  return sheetTop <= topInset
473
521
  }
474
522
 
475
- val currentSheetTop: Int
476
- get() = bottomSheetView?.top ?: screenHeight
477
-
478
523
  val currentTranslationY: Int
479
524
  get() = bottomSheetView?.translationY?.toInt() ?: 0
480
525
 
481
526
  fun getExpectedSheetTop(detentIndex: Int): Int {
482
527
  if (detentIndex < 0 || detentIndex >= detents.size) return screenHeight
483
- return realScreenHeight - getDetentHeight(detents[detentIndex])
528
+ return realScreenHeight - detentCalculator.getDetentHeight(detents[detentIndex])
484
529
  }
485
530
 
486
- /** Translates the sheet when stacking. Pass 0 to reset. */
487
531
  fun translateDialog(translationY: Int) {
488
532
  val bottomSheet = bottomSheetView ?: return
489
533
 
@@ -512,20 +556,18 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
512
556
  if (isPresented) {
513
557
  setStateForDetentIndex(detentIndex)
514
558
  } else {
559
+ shouldAnimatePresent = animated
515
560
  currentDetentIndex = detentIndex
516
- isDragging = false
517
- setupSheetDetents()
518
- setStateForDetentIndex(detentIndex)
561
+ interactionState = InteractionState.Idle
519
562
 
520
- val (index, position, detent) = getDetentInfoWithValue(detentIndex)
563
+ emitWillPresentEvents()
521
564
 
522
- parentSheetView?.viewControllerWillBlur()
523
- delegate?.viewControllerWillPresent(index, position, detent)
524
- delegate?.viewControllerWillFocus()
565
+ setupSheetDetents()
566
+ setStateForDetentIndex(detentIndex)
567
+ setupBackground()
568
+ setupGrabber()
525
569
 
526
- if (!animated) {
527
- dialog.window?.setWindowAnimations(0)
528
- }
570
+ bottomSheetView?.visibility = INVISIBLE
529
571
 
530
572
  dialog.show()
531
573
  }
@@ -535,18 +577,33 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
535
577
  if (isDismissing) return
536
578
 
537
579
  isDismissing = true
538
- val fromTop = bottomSheetView?.top ?: getExpectedSheetTop(currentDetentIndex)
539
- setupTransitionTracker(fromTop, realScreenHeight, DISMISS_ANIMATION_DURATION)
540
580
  emitWillDismissEvents()
541
581
 
542
- if (!animated) {
543
- dialog?.window?.setWindowAnimations(0)
544
- post { dialog?.dismiss() }
582
+ if (animated) {
583
+ sheetAnimator.animateDismiss(
584
+ onUpdate = { effectiveTop ->
585
+ emitChangePositionDelegate(effectiveTop)
586
+ positionFooter()
587
+ updateDimAmount(effectiveTop)
588
+ },
589
+ onEnd = { dialog?.dismiss() }
590
+ )
545
591
  } else {
592
+ emitChangePositionDelegate(realScreenHeight)
546
593
  dialog?.dismiss()
547
594
  }
548
595
  }
549
596
 
597
+ private fun finishPresent() {
598
+ val (index, position, detent) = getDetentInfoWithValue(currentDetentIndex)
599
+ delegate?.viewControllerDidPresent(index, position, detent)
600
+ parentSheetView?.viewControllerDidBlur()
601
+ delegate?.viewControllerDidFocus()
602
+
603
+ presentPromise?.invoke()
604
+ presentPromise = null
605
+ }
606
+
550
607
  // ====================================================================
551
608
  // MARK: - Sheet Configuration
552
609
  // ====================================================================
@@ -554,7 +611,7 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
554
611
  fun setupSheetDetents() {
555
612
  val behavior = this.behavior ?: return
556
613
 
557
- isReconfiguring = true
614
+ interactionState = InteractionState.Reconfiguring
558
615
  val edgeToEdgeTopInset: Int = if (!edgeToEdgeFullScreen) topInset else 0
559
616
 
560
617
  behavior.apply {
@@ -563,14 +620,14 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
563
620
 
564
621
  val maxAvailableHeight = realScreenHeight - edgeToEdgeTopInset
565
622
 
566
- setPeekHeight(getDetentHeight(detents[0]), isPresented)
623
+ setPeekHeight(detentCalculator.getDetentHeight(detents[0]), isPresented)
567
624
 
568
625
  val halfExpandedDetentHeight = when (detents.size) {
569
626
  1 -> peekHeight
570
- else -> getDetentHeight(detents[1])
627
+ else -> detentCalculator.getDetentHeight(detents[1])
571
628
  }
572
629
 
573
- val maxDetentHeight = getDetentHeight(detents.last())
630
+ val maxDetentHeight = detentCalculator.getDetentHeight(detents.last())
574
631
 
575
632
  val adjustedHalfExpandedHeight = minOf(halfExpandedDetentHeight, maxAvailableHeight)
576
633
  halfExpandedRatio = minOf(adjustedHalfExpandedHeight.toFloat() / realScreenHeight.toFloat(), MAX_HALF_EXPANDED_RATIO)
@@ -592,7 +649,7 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
592
649
  setStateForDetentIndex(currentDetentIndex)
593
650
  }
594
651
 
595
- isReconfiguring = false
652
+ interactionState = InteractionState.Idle
596
653
  }
597
654
  }
598
655
 
@@ -617,15 +674,29 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
617
674
  bottomSheet.addView(grabberView)
618
675
  }
619
676
 
620
- private var keyboardObserver: TrueSheetKeyboardObserver? = null
621
- private var positionAnimator: ValueAnimator? = null
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
+ }
622
687
 
623
688
  fun setupKeyboardObserver() {
624
689
  val bottomSheet = bottomSheetView ?: return
625
690
  keyboardObserver = TrueSheetKeyboardObserver(bottomSheet, reactContext).apply {
626
691
  delegate = object : TrueSheetKeyboardObserverDelegate {
627
- override fun keyboardHeightDidChange(height: Int) {
692
+ override fun keyboardWillShow(height: Int) {}
693
+
694
+ override fun keyboardWillHide() {}
695
+
696
+ override fun keyboardDidChangeHeight(height: Int) {
697
+ if (!shouldHandleKeyboard()) return
628
698
  setupSheetDetents()
699
+ positionFooter()
629
700
  }
630
701
  }
631
702
  start()
@@ -637,37 +708,9 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
637
708
  keyboardObserver = null
638
709
  }
639
710
 
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
- }
711
+ // ====================================================================
712
+ // MARK: - Background & Dimming
713
+ // ====================================================================
671
714
 
672
715
  fun setupBackground() {
673
716
  val bottomSheet = bottomSheetView ?: return
@@ -719,8 +762,12 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
719
762
  }
720
763
 
721
764
  if (shouldDimAtDetent) {
722
- touchOutside.setOnTouchListener(null)
723
- dialog.setCanceledOnTouchOutside(dismissible)
765
+ touchOutside.setOnTouchListener { _, event ->
766
+ if (event.action == MotionEvent.ACTION_UP && dismissible) {
767
+ dismiss()
768
+ }
769
+ true
770
+ }
724
771
  } else {
725
772
  touchOutside.setOnTouchListener { v, event ->
726
773
  event.setLocation(event.rawX - v.x, event.rawY - v.y)
@@ -735,25 +782,13 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
735
782
  }
736
783
  }
737
784
 
738
- fun resetAnimation() {
739
- dialog?.window?.setWindowAnimations(windowAnimation)
740
- }
741
-
742
- private fun animateDimAlpha(show: Boolean) {
743
- 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)
747
- }
748
-
749
- fun updateDimAmount() {
785
+ fun updateDimAmount(sheetTop: Int? = null) {
750
786
  if (!dimmed) return
751
- val sheetTop = bottomSheetView?.top ?: return
752
- dimView?.interpolateAlpha(sheetTop, dimmedDetentIndex, ::getSheetTopForDetentIndex)
753
- parentDimView?.interpolateAlpha(sheetTop, dimmedDetentIndex, ::getSheetTopForDetentIndex)
787
+ val top = sheetTop ?: bottomSheetView?.top ?: return
788
+ dimView?.interpolateAlpha(top, dimmedDetentIndex, detentCalculator::getSheetTopForDetentIndex)
789
+ parentDimView?.interpolateAlpha(top, dimmedDetentIndex, detentCalculator::getSheetTopForDetentIndex)
754
790
  }
755
791
 
756
- /** Positions footer at bottom of sheet, adjusting during drag via slideOffset. */
757
792
  fun positionFooter(slideOffset: Float? = null) {
758
793
  val footerView = containerView?.footerView ?: return
759
794
  val bottomSheet = bottomSheetView ?: return
@@ -762,20 +797,17 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
762
797
  val sheetHeight = bottomSheet.height
763
798
  val sheetTop = bottomSheet.top
764
799
 
765
- // Footer Y relative to sheet: place at bottom of sheet container minus footer height
766
800
  var footerY = (sheetHeight - sheetTop - footerHeight - keyboardHeight).toFloat()
767
-
768
801
  if (slideOffset != null && slideOffset < 0) {
769
802
  footerY -= (footerHeight * slideOffset)
770
803
  }
771
804
 
772
- // Clamp to prevent footer from going above visible area
773
805
  val maxAllowedY = (sheetHeight - topInset - footerHeight).toFloat()
774
806
  footerView.y = minOf(footerY, maxAllowedY)
775
807
  }
776
808
 
777
809
  fun setStateForDetentIndex(index: Int) {
778
- behavior?.state = getStateForDetentIndex(index)
810
+ behavior?.state = detentCalculator.getStateForDetentIndex(index)
779
811
  }
780
812
 
781
813
  fun getDefaultBackgroundColor(): Int {
@@ -793,193 +825,65 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
793
825
  }
794
826
 
795
827
  // ====================================================================
796
- // MARK: - Position & Drag Handling
828
+ // MARK: - Drag Handling
797
829
  // ====================================================================
798
830
 
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
831
  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
832
+ val position = detentCalculator.getPositionDp(detentCalculator.getVisibleSheetHeight(sheetView.top))
833
+ val detent = detentCalculator.getDetentValueForIndex(currentDetentIndex)
834
+ delegate?.viewControllerDidDragBegin(currentDetentIndex, position, detent)
835
+ interactionState = InteractionState.Dragging(
836
+ startTop = sheetView.top,
837
+ startKeyboardHeight = keyboardHeight
838
+ )
905
839
  }
906
840
 
907
841
  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)
912
- }
913
-
914
- // ====================================================================
915
- // MARK: - Detent Calculations
916
- // ====================================================================
917
-
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")
842
+ val draggingState = interactionState as? InteractionState.Dragging ?: return
843
+ val position = detentCalculator.getPositionDp(detentCalculator.getVisibleSheetHeight(sheetView.top))
844
+ val detent = detentCalculator.getDetentValueForIndex(currentDetentIndex)
845
+ delegate?.viewControllerDidDragChange(currentDetentIndex, position, detent)
846
+
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
851
+
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)
927
858
  }
928
- (detent * screenHeight).toInt() + contentBottomInset + keyboardHeight
929
859
  }
930
-
931
- val maxAllowedHeight = screenHeight + contentBottomInset
932
- return maxSheetHeight?.let { minOf(height, it, maxAllowedHeight) } ?: minOf(height, maxAllowedHeight)
933
860
  }
934
861
 
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
- }
862
+ // ====================================================================
863
+ // MARK: - Detent Helpers
864
+ // ====================================================================
940
865
 
941
- /** Maps BottomSheetBehavior state to DetentInfo based on detent count. */
942
866
  fun getDetentInfoForState(state: Int): DetentInfo? {
943
- val stateMap = getDetentStateMap() ?: return null
944
- val index = stateMap[state] ?: return null
867
+ val index = detentCalculator.getDetentIndexForState(state) ?: return null
945
868
  return DetentInfo(index, getPositionForDetentIndex(index))
946
869
  }
947
870
 
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
871
  private fun getPositionForDetentIndex(index: Int): Float {
971
872
  if (index < 0 || index >= detents.size) return screenHeight.pxToDp()
972
873
 
973
874
  bottomSheetView?.let {
974
- val visibleSheetHeight = getVisibleSheetHeight(it)
975
- if (visibleSheetHeight > 0) return getPositionDp(visibleSheetHeight)
875
+ val visibleSheetHeight = detentCalculator.getVisibleSheetHeight(it.top)
876
+ if (visibleSheetHeight > 0 && visibleSheetHeight < realScreenHeight) {
877
+ return detentCalculator.getPositionDp(visibleSheetHeight)
878
+ }
976
879
  }
977
880
 
978
- val detentHeight = getDetentHeight(detents[index])
979
- return getPositionDp(detentHeight)
881
+ val detentHeight = detentCalculator.getDetentHeight(detents[index])
882
+ return detentCalculator.getPositionDp(detentHeight)
980
883
  }
981
884
 
982
- fun getDetentInfoForIndex(index: Int) = getDetentInfoForState(getStateForDetentIndex(index)) ?: DetentInfo(0, 0f)
885
+ fun getDetentInfoForIndex(index: Int): DetentInfo =
886
+ getDetentInfoForState(detentCalculator.getStateForDetentIndex(index)) ?: DetentInfo(0, 0f)
983
887
 
984
888
  // ====================================================================
985
889
  // MARK: - RootView Implementation
@@ -995,11 +899,7 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
995
899
 
996
900
  if (w == oldw && h == oldh) return
997
901
  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
- }
902
+ if (h + topInset >= screenHeight && isExpanded && oldw == w) return
1003
903
 
1004
904
  this.post {
1005
905
  setupSheetDetents()
@@ -1016,7 +916,6 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
1016
916
  // MARK: - Touch Event Handling
1017
917
  // ====================================================================
1018
918
 
1019
- /** Forwards touch events to footer which is positioned outside normal hierarchy. */
1020
919
  override fun dispatchTouchEvent(event: MotionEvent): Boolean {
1021
920
  val footer = containerView?.footerView
1022
921
  if (footer != null && footer.isVisible) {