@lodev09/react-native-true-sheet 3.5.1-beta.0 → 3.5.1-beta.2

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.
@@ -43,7 +43,6 @@ class TrueSheetView(private val reactContext: ThemedReactContext) :
43
43
  var eventDispatcher: EventDispatcher? = null
44
44
 
45
45
  // Initial present configuration (set by ViewManager before mount)
46
- var pendingInitialPresent: Boolean = false
47
46
  var initialDetentIndex: Int = -1
48
47
  var initialDetentAnimated: Boolean = true
49
48
 
@@ -105,7 +104,7 @@ class TrueSheetView(private val reactContext: ThemedReactContext) :
105
104
  viewController.createDialog()
106
105
 
107
106
  if (initialDetentIndex >= 0) {
108
- pendingInitialPresent = true
107
+ post { present(initialDetentIndex, initialDetentAnimated) { } }
109
108
  }
110
109
 
111
110
  val surfaceId = UIManagerHelper.getSurfaceId(this)
@@ -292,16 +291,7 @@ class TrueSheetView(private val reactContext: ThemedReactContext) :
292
291
  * Uses post() to ensure all layout passes complete before reconfiguring.
293
292
  */
294
293
  fun updateSheetIfNeeded() {
295
- if (!viewController.isPresented) {
296
- // Handle initial present if pending
297
- if (pendingInitialPresent) {
298
- viewController.setupSheetDetents()
299
- present(initialDetentIndex, initialDetentAnimated) { }
300
- pendingInitialPresent = false
301
- }
302
- return
303
- }
304
-
294
+ if (!viewController.isPresented) return
305
295
  if (isSheetUpdatePending) return
306
296
 
307
297
  isSheetUpdatePending = true
@@ -320,7 +310,7 @@ class TrueSheetView(private val reactContext: ThemedReactContext) :
320
310
  * Propagates additional translation to parent so the entire stack stays visually consistent.
321
311
  */
322
312
  fun updateTranslationForChild(childSheetTop: Int) {
323
- if (!viewController.isDialogVisible || viewController.isExpanded) return
313
+ if (viewController.isExpanded) return
324
314
 
325
315
  val mySheetTop = viewController.getExpectedSheetTop(viewController.currentDetentIndex)
326
316
  val newTranslation = maxOf(0, childSheetTop - mySheetTop)
@@ -338,12 +328,24 @@ class TrueSheetView(private val reactContext: ThemedReactContext) :
338
328
  * Recursively adds translation to this sheet and all parent sheets.
339
329
  */
340
330
  private fun addTranslation(amount: Int) {
341
- if (!viewController.isDialogVisible || viewController.isExpanded) return
331
+ if (viewController.isExpanded) return
342
332
 
343
333
  viewController.translateDialog(viewController.currentTranslationY + amount)
344
334
  TrueSheetDialogObserver.getParentSheet(this)?.addTranslation(amount)
345
335
  }
346
336
 
337
+ /**
338
+ * Resets this sheet's translation and updates parent sheets.
339
+ * This sheet resets to 0 (it's now topmost), but parent recalculates based on this sheet's position.
340
+ */
341
+ fun resetTranslation() {
342
+ viewController.translateDialog(0)
343
+
344
+ // Parent should recalculate its translation based on this sheet's position
345
+ val mySheetTop = viewController.getExpectedSheetTop(viewController.currentDetentIndex)
346
+ TrueSheetDialogObserver.getParentSheet(this)?.updateTranslationForChild(mySheetTop)
347
+ }
348
+
347
349
  // ==================== TrueSheetViewControllerDelegate ====================
348
350
 
349
351
  override fun viewControllerWillPresent(index: Int, position: Float, detent: Float) {
@@ -1,15 +1,13 @@
1
1
  package com.lodev09.truesheet
2
2
 
3
3
  import android.annotation.SuppressLint
4
- import android.graphics.Color
5
- import android.graphics.drawable.ShapeDrawable
6
- import android.graphics.drawable.shapes.RoundRectShape
7
- import android.util.TypedValue
4
+ import android.util.Log
8
5
  import android.view.MotionEvent
9
6
  import android.view.View
10
7
  import android.view.WindowManager
11
8
  import android.view.accessibility.AccessibilityNodeInfo
12
9
  import android.widget.FrameLayout
10
+ import androidx.appcompat.app.AppCompatActivity
13
11
  import androidx.core.view.isNotEmpty
14
12
  import androidx.core.view.isVisible
15
13
  import com.facebook.react.R
@@ -30,9 +28,10 @@ import com.lodev09.truesheet.core.TrueSheetAnimator
30
28
  import com.lodev09.truesheet.core.TrueSheetAnimatorProvider
31
29
  import com.lodev09.truesheet.core.TrueSheetDetentCalculator
32
30
  import com.lodev09.truesheet.core.TrueSheetDetentMeasurements
31
+ import com.lodev09.truesheet.core.TrueSheetDialogFragment
32
+ import com.lodev09.truesheet.core.TrueSheetDialogFragmentDelegate
33
33
  import com.lodev09.truesheet.core.TrueSheetDialogObserver
34
34
  import com.lodev09.truesheet.core.TrueSheetDimView
35
- import com.lodev09.truesheet.core.TrueSheetGrabberView
36
35
  import com.lodev09.truesheet.core.TrueSheetKeyboardObserver
37
36
  import com.lodev09.truesheet.core.TrueSheetKeyboardObserverDelegate
38
37
  import com.lodev09.truesheet.utils.ScreenUtils
@@ -66,7 +65,7 @@ interface TrueSheetViewControllerDelegate {
66
65
  // =============================================================================
67
66
 
68
67
  /**
69
- * Manages the bottom sheet dialog and its presentation lifecycle.
68
+ * Manages the bottom sheet dialog fragment and its presentation lifecycle.
70
69
  * Acts as a RootView to properly dispatch touch events to React Native.
71
70
  */
72
71
  @SuppressLint("ClickableViewAccessibility", "ViewConstructor")
@@ -74,13 +73,13 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
74
73
  ReactViewGroup(reactContext),
75
74
  RootView,
76
75
  TrueSheetDetentMeasurements,
77
- TrueSheetAnimatorProvider {
76
+ TrueSheetAnimatorProvider,
77
+ TrueSheetDialogFragmentDelegate {
78
78
 
79
79
  companion object {
80
80
  const val TAG_NAME = "TrueSheet"
81
81
 
82
- // Prevents fully expanded ratio which causes behavior issues
83
- private const val GRABBER_TAG = "TrueSheetGrabber"
82
+ private const val FRAGMENT_TAG = "TrueSheetDialogFragment"
84
83
  private const val DEFAULT_MAX_WIDTH = 640 // dp
85
84
  private const val DEFAULT_CORNER_RADIUS = 16 // dp
86
85
  private const val TRANSLATE_ANIMATION_DURATION = 200L
@@ -102,8 +101,8 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
102
101
 
103
102
  var delegate: TrueSheetViewControllerDelegate? = null
104
103
 
105
- // Dialog & Views
106
- private var dialog: BottomSheetDialog? = null
104
+ // Dialog Fragment
105
+ private var dialogFragment: TrueSheetDialogFragment? = null
107
106
  private var dimView: TrueSheetDimView? = null
108
107
  private var parentDimView: TrueSheetDimView? = null
109
108
 
@@ -164,23 +163,20 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
164
163
  var sheetCornerRadius: Float = DEFAULT_CORNER_RADIUS.dpToPx()
165
164
  set(value) {
166
165
  field = if (value < 0) DEFAULT_CORNER_RADIUS.dpToPx() else value
167
- if (isPresented) setupBackground()
166
+ dialogFragment?.sheetCornerRadius = field
167
+ if (isPresented) dialogFragment?.setupBackground()
168
168
  }
169
169
 
170
170
  var dismissible: Boolean = true
171
171
  set(value) {
172
172
  field = value
173
- dialog?.apply {
174
- setCanceledOnTouchOutside(value)
175
- setCancelable(value)
176
- behavior.isHideable = value
177
- }
173
+ dialogFragment?.dismissible = value
178
174
  }
179
175
 
180
176
  var draggable: Boolean = true
181
177
  set(value) {
182
178
  field = value
183
- behavior?.isDraggable = value
179
+ dialogFragment?.updateDraggable(value)
184
180
  }
185
181
 
186
182
  // =============================================================================
@@ -188,14 +184,17 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
188
184
  // =============================================================================
189
185
 
190
186
  // Dialog
187
+ private val dialog: BottomSheetDialog?
188
+ get() = dialogFragment?.bottomSheetDialog
189
+
191
190
  private val behavior: BottomSheetBehavior<FrameLayout>?
192
- get() = dialog?.behavior
191
+ get() = dialogFragment?.behavior
193
192
 
194
193
  private val sheetContainer: FrameLayout?
195
194
  get() = this.parent as? FrameLayout
196
195
 
197
196
  override val bottomSheetView: FrameLayout?
198
- get() = dialog?.findViewById(com.google.android.material.R.id.design_bottom_sheet)
197
+ get() = dialogFragment?.bottomSheetView
199
198
 
200
199
  private val containerView: TrueSheetContainerView?
201
200
  get() = if (this.isNotEmpty()) getChildAt(0) as? TrueSheetContainerView else null
@@ -267,53 +266,29 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
267
266
  }
268
267
 
269
268
  // =============================================================================
270
- // MARK: - Dialog Creation & Cleanup
269
+ // MARK: - Fragment Creation & Cleanup
271
270
  // =============================================================================
272
271
 
273
272
  fun createDialog() {
274
- if (dialog != null) return
275
-
276
- val style = if (edgeToEdgeEnabled) {
277
- com.lodev09.truesheet.R.style.TrueSheetEdgeToEdgeEnabledDialog
278
- } else {
279
- com.lodev09.truesheet.R.style.TrueSheetDialog
273
+ if (dialogFragment != null) return
274
+
275
+ dialogFragment = TrueSheetDialogFragment.newInstance().apply {
276
+ this.delegate = this@TrueSheetViewController
277
+ this.contentView = this@TrueSheetViewController
278
+ this.reactContext = this@TrueSheetViewController.reactContext
279
+ this.sheetCornerRadius = this@TrueSheetViewController.sheetCornerRadius
280
+ this.sheetBackgroundColor = this@TrueSheetViewController.sheetBackgroundColor
281
+ this.edgeToEdgeFullScreen = this@TrueSheetViewController.edgeToEdgeFullScreen
282
+ this.grabberEnabled = this@TrueSheetViewController.grabber
283
+ this.grabberOptions = this@TrueSheetViewController.grabberOptions
284
+ this.dismissible = this@TrueSheetViewController.dismissible
285
+ this.draggable = this@TrueSheetViewController.draggable
280
286
  }
281
287
 
282
- dialog = BottomSheetDialog(reactContext, style).apply {
283
- setContentView(this@TrueSheetViewController)
284
-
285
- window?.apply {
286
- setWindowAnimations(0)
287
- setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING)
288
- }
289
-
290
- setupModalObserver()
291
- setupDialogListeners(this)
292
- setupBottomSheetBehavior(this)
293
-
294
- setCanceledOnTouchOutside(dismissible)
295
- setCancelable(dismissible)
296
- behavior.isHideable = dismissible
297
- behavior.isDraggable = draggable
298
-
299
- onBackPressedDispatcher.addCallback(object : androidx.activity.OnBackPressedCallback(true) {
300
- override fun handleOnBackPressed() {
301
- this@TrueSheetViewController.delegate?.viewControllerDidBackPress()
302
- if (dismissible) {
303
- dismiss()
304
- }
305
- }
306
- })
307
- }
288
+ setupModalObserver()
308
289
  }
309
290
 
310
291
  private fun cleanupDialog() {
311
- dialog?.apply {
312
- setOnShowListener(null)
313
- setOnCancelListener(null)
314
- setOnDismissListener(null)
315
- }
316
-
317
292
  cleanupKeyboardObserver()
318
293
  cleanupModalObserver()
319
294
  sheetAnimator.cancel()
@@ -323,7 +298,7 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
323
298
  parentDimView = null
324
299
  sheetContainer?.removeView(this)
325
300
 
326
- dialog = null
301
+ dialogFragment = null
327
302
  interactionState = InteractionState.Idle
328
303
  isDismissing = false
329
304
  isPresented = false
@@ -334,94 +309,94 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
334
309
  }
335
310
 
336
311
  // =============================================================================
337
- // MARK: - Dialog Listeners
312
+ // MARK: - TrueSheetDialogFragmentDelegate
338
313
  // =============================================================================
339
314
 
340
- private fun setupDialogListeners(dialog: BottomSheetDialog) {
341
- dialog.setOnShowListener {
342
- bottomSheetView?.visibility = VISIBLE
315
+ override fun onDialogShow() {
316
+ bottomSheetView?.visibility = VISIBLE
343
317
 
344
- isPresented = true
345
- isDialogVisible = true
318
+ isPresented = true
319
+ isDialogVisible = true
346
320
 
347
- emitWillPresentEvents()
321
+ emitWillPresentEvents()
348
322
 
349
- setupSheetDetents()
350
- setupBackground()
351
- setupGrabber()
352
- setupKeyboardObserver()
353
-
354
- if (shouldAnimatePresent) {
355
- val toTop = getExpectedSheetTop(currentDetentIndex)
356
- sheetAnimator.animatePresent(
357
- toTop = toTop,
358
- onUpdate = { effectiveTop ->
359
- emitChangePositionDelegate(effectiveTop)
360
- positionFooter()
361
- updateDimAmount(effectiveTop)
362
- },
363
- onEnd = { finishPresent() }
364
- )
365
- } else {
366
- val toTop = getExpectedSheetTop(currentDetentIndex)
367
- emitChangePositionDelegate(toTop)
368
- positionFooter()
369
- finishPresent()
370
- }
371
- }
323
+ setupSheetDetents()
324
+ setupDimmedBackground(currentDetentIndex)
325
+ setupKeyboardObserver()
372
326
 
373
- dialog.setOnDismissListener {
374
- emitDidDismissEvents()
375
- cleanupDialog()
327
+ if (shouldAnimatePresent) {
328
+ val toTop = getExpectedSheetTop(currentDetentIndex)
329
+ sheetAnimator.animatePresent(
330
+ toTop = toTop,
331
+ onUpdate = { effectiveTop ->
332
+ emitChangePositionDelegate(effectiveTop)
333
+ positionFooter()
334
+ updateDimAmount(effectiveTop)
335
+ },
336
+ onEnd = { finishPresent() }
337
+ )
338
+ } else {
339
+ val toTop = getExpectedSheetTop(currentDetentIndex)
340
+ emitChangePositionDelegate(toTop)
341
+ positionFooter()
342
+ finishPresent()
376
343
  }
377
344
  }
378
345
 
379
- private fun setupBottomSheetBehavior(dialog: BottomSheetDialog) {
380
- dialog.behavior.addBottomSheetCallback(
381
- object : BottomSheetBehavior.BottomSheetCallback() {
382
- override fun onSlide(sheetView: View, slideOffset: Float) {
383
- val behavior = behavior ?: return
346
+ override fun onDialogDismiss() {
347
+ emitDidDismissEvents()
348
+ cleanupDialog()
349
+ }
384
350
 
385
- when (behavior.state) {
386
- BottomSheetBehavior.STATE_DRAGGING,
387
- BottomSheetBehavior.STATE_SETTLING -> handleDragChange(sheetView)
351
+ override fun onDialogCancel() {
352
+ // Cancel is called before dismiss for user-initiated cancellation
353
+ }
388
354
 
389
- else -> { }
390
- }
355
+ override fun onStateChanged(sheetView: View, newState: Int) {
356
+ if (newState == BottomSheetBehavior.STATE_HIDDEN) {
357
+ if (isDismissing) return
358
+ isDismissing = true
359
+ emitWillDismissEvents()
360
+ dialogFragment?.dismiss()
361
+ return
362
+ }
391
363
 
392
- if (!sheetAnimator.isAnimating) {
393
- emitChangePositionDelegate(sheetView.top)
364
+ if (!isPresented) return
394
365
 
395
- if (!isKeyboardTransitioning) {
396
- positionFooter(slideOffset)
397
- updateDimAmount(sheetView.top)
398
- }
399
- }
400
- }
366
+ when (newState) {
367
+ BottomSheetBehavior.STATE_DRAGGING -> handleDragBegin(sheetView)
401
368
 
402
- override fun onStateChanged(sheetView: View, newState: Int) {
403
- if (newState == BottomSheetBehavior.STATE_HIDDEN) {
404
- if (isDismissing) return
405
- isDismissing = true
406
- emitWillDismissEvents()
407
- dialog.dismiss()
408
- return
409
- }
369
+ BottomSheetBehavior.STATE_EXPANDED,
370
+ BottomSheetBehavior.STATE_COLLAPSED,
371
+ BottomSheetBehavior.STATE_HALF_EXPANDED -> handleStateSettled(sheetView, newState)
410
372
 
411
- if (!isPresented) return
373
+ else -> {}
374
+ }
375
+ }
412
376
 
413
- when (newState) {
414
- BottomSheetBehavior.STATE_DRAGGING -> handleDragBegin(sheetView)
377
+ override fun onSlide(sheetView: View, slideOffset: Float) {
378
+ // Skip if our custom animator is handling the animation
379
+ if (sheetAnimator.isAnimating) return
415
380
 
416
- BottomSheetBehavior.STATE_EXPANDED,
417
- BottomSheetBehavior.STATE_COLLAPSED,
418
- BottomSheetBehavior.STATE_HALF_EXPANDED -> handleStateSettled(sheetView, newState)
381
+ val behavior = behavior ?: return
419
382
 
420
- else -> {}
421
- }
422
- }
423
- }
424
- )
383
+ when (behavior.state) {
384
+ BottomSheetBehavior.STATE_DRAGGING,
385
+ BottomSheetBehavior.STATE_SETTLING -> handleDragChange(sheetView)
386
+
387
+ else -> { }
388
+ }
389
+
390
+ emitChangePositionDelegate(sheetView.top)
391
+
392
+ if (!isKeyboardTransitioning) {
393
+ positionFooter(slideOffset)
394
+ updateDimAmount(sheetView.top)
395
+ }
396
+ }
397
+
398
+ override fun onBackPressed() {
399
+ delegate?.viewControllerDidBackPress()
425
400
  }
426
401
 
427
402
  private fun handleStateSettled(sheetView: View, newState: Int) {
@@ -526,24 +501,32 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
526
501
  // =============================================================================
527
502
 
528
503
  fun present(detentIndex: Int, animated: Boolean = true) {
529
- val dialog = this.dialog ?: run {
530
- RNLog.w(reactContext, "TrueSheet: No dialog available. Ensure the sheet is mounted before presenting.")
504
+ val fragment = this.dialogFragment ?: run {
505
+ RNLog.w(reactContext, "TrueSheet: No dialog fragment available. Ensure the sheet is mounted before presenting.")
531
506
  return
532
507
  }
533
508
 
534
- setupDimmedBackground(detentIndex)
535
- setStateForDetentIndex(detentIndex)
509
+ val activity = reactContext.currentActivity as? AppCompatActivity ?: run {
510
+ RNLog.w(reactContext, "TrueSheet: No AppCompatActivity available for fragment transaction.")
511
+ return
512
+ }
536
513
 
537
- if (!isPresented) {
514
+ if (isPresented) {
515
+ setupDimmedBackground(detentIndex)
516
+ setStateForDetentIndex(detentIndex)
517
+ } else {
538
518
  shouldAnimatePresent = animated
539
519
  currentDetentIndex = detentIndex
540
520
  interactionState = InteractionState.Idle
541
521
 
542
- // Position off-screen until animation starts
543
- bottomSheetView?.translationY = realScreenHeight.toFloat()
544
- bottomSheetView?.visibility = INVISIBLE
522
+ // Show the fragment - detents are configured in onDialogShow
523
+ if (!fragment.isAdded) {
524
+ fragment.show(activity.supportFragmentManager, FRAGMENT_TAG)
525
+ }
545
526
 
546
- dialog.show()
527
+ // Execute pending transactions to ensure fragment is added
528
+ activity.supportFragmentManager.executePendingTransactions()
529
+ bottomSheetView?.visibility = INVISIBLE
547
530
  }
548
531
  }
549
532
 
@@ -560,11 +543,11 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
560
543
  positionFooter()
561
544
  updateDimAmount(effectiveTop)
562
545
  },
563
- onEnd = { dialog?.dismiss() }
546
+ onEnd = { dialogFragment?.dismiss() }
564
547
  )
565
548
  } else {
566
549
  emitChangePositionDelegate(realScreenHeight)
567
- dialog?.dismiss()
550
+ dialogFragment?.dismiss()
568
551
  }
569
552
  }
570
553
 
@@ -583,51 +566,57 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
583
566
  // =============================================================================
584
567
 
585
568
  fun setupSheetDetents() {
569
+ val fragment = this.dialogFragment ?: return
586
570
  val behavior = this.behavior ?: return
587
571
 
588
572
  interactionState = InteractionState.Reconfiguring
589
573
  val edgeToEdgeTopInset: Int = if (!edgeToEdgeFullScreen) topInset else 0
590
574
 
591
- behavior.apply {
592
- isFitToContents = false
593
- maxWidth = DEFAULT_MAX_WIDTH.dpToPx().toInt()
575
+ behavior.isFitToContents = false
594
576
 
595
- val maxAvailableHeight = realScreenHeight - edgeToEdgeTopInset
577
+ val maxAvailableHeight = realScreenHeight - edgeToEdgeTopInset
596
578
 
597
- setPeekHeight(detentCalculator.getDetentHeight(detents[0]), isPresented)
579
+ val peekHeight = detentCalculator.getDetentHeight(detents[0])
598
580
 
599
- val halfExpandedDetentHeight = when (detents.size) {
600
- 1 -> peekHeight
601
- else -> detentCalculator.getDetentHeight(detents[1])
602
- }
581
+ val halfExpandedDetentHeight = when (detents.size) {
582
+ 1 -> peekHeight
583
+ else -> detentCalculator.getDetentHeight(detents[1])
584
+ }
603
585
 
604
- val maxDetentHeight = detentCalculator.getDetentHeight(detents.last())
586
+ val maxDetentHeight = detentCalculator.getDetentHeight(detents.last())
605
587
 
606
- val adjustedHalfExpandedHeight = minOf(halfExpandedDetentHeight, maxAvailableHeight)
607
- halfExpandedRatio = (adjustedHalfExpandedHeight.toFloat() / realScreenHeight.toFloat())
608
- .coerceIn(0f, 0.999f)
588
+ val adjustedHalfExpandedHeight = minOf(halfExpandedDetentHeight, maxAvailableHeight)
589
+ val halfExpandedRatio = (adjustedHalfExpandedHeight.toFloat() / realScreenHeight.toFloat())
590
+ .coerceIn(0f, 0.999f)
609
591
 
610
- expandedOffset = maxOf(edgeToEdgeTopInset, realScreenHeight - maxDetentHeight)
592
+ val expandedOffset = maxOf(edgeToEdgeTopInset, realScreenHeight - maxDetentHeight)
611
593
 
612
- // fitToContents works better with <= 2 detents when no expanded offset
613
- isFitToContents = detents.size < 3 && expandedOffset == 0
594
+ // fitToContents works better with <= 2 detents when no expanded offset
595
+ val fitToContents = detents.size < 3 && expandedOffset == 0
614
596
 
615
- val offset = if (expandedOffset == 0) topInset else 0
616
- val newHeight = realScreenHeight - expandedOffset - offset
617
- val newWidth = minOf(screenWidth, maxWidth)
597
+ fragment.configureDetents(
598
+ peekHeight = peekHeight,
599
+ halfExpandedRatio = halfExpandedRatio,
600
+ expandedOffset = expandedOffset,
601
+ fitToContents = fitToContents,
602
+ animate = isPresented
603
+ )
618
604
 
619
- if (lastStateWidth != newWidth || lastStateHeight != newHeight) {
620
- lastStateWidth = newWidth
621
- lastStateHeight = newHeight
622
- delegate?.viewControllerDidChangeSize(newWidth, newHeight)
623
- }
605
+ val offset = if (expandedOffset == 0) topInset else 0
606
+ val newHeight = realScreenHeight - expandedOffset - offset
607
+ val newWidth = minOf(screenWidth, DEFAULT_MAX_WIDTH.dpToPx().toInt())
624
608
 
625
- if (isPresented) {
626
- setStateForDetentIndex(currentDetentIndex)
627
- }
609
+ if (lastStateWidth != newWidth || lastStateHeight != newHeight) {
610
+ lastStateWidth = newWidth
611
+ lastStateHeight = newHeight
612
+ delegate?.viewControllerDidChangeSize(newWidth, newHeight)
613
+ }
628
614
 
629
- interactionState = InteractionState.Idle
615
+ if (isPresented) {
616
+ setStateForDetentIndex(currentDetentIndex)
630
617
  }
618
+
619
+ interactionState = InteractionState.Idle
631
620
  }
632
621
 
633
622
  fun setupSheetDetentsForSizeChange() {
@@ -636,7 +625,7 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
636
625
  }
637
626
 
638
627
  fun setStateForDetentIndex(index: Int) {
639
- behavior?.state = detentCalculator.getStateForDetentIndex(index)
628
+ dialogFragment?.setState(detentCalculator.getStateForDetentIndex(index))
640
629
  }
641
630
 
642
631
  // =============================================================================
@@ -644,19 +633,11 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
644
633
  // =============================================================================
645
634
 
646
635
  fun setupGrabber() {
647
- val bottomSheet = bottomSheetView ?: return
648
-
649
- bottomSheet.findViewWithTag<View>(GRABBER_TAG)?.let {
650
- bottomSheet.removeView(it)
651
- }
652
-
653
- if (!grabber || !draggable) return
654
-
655
- val grabberView = TrueSheetGrabberView(reactContext, grabberOptions).apply {
656
- tag = GRABBER_TAG
636
+ dialogFragment?.apply {
637
+ grabberEnabled = this@TrueSheetViewController.grabber
638
+ grabberOptions = this@TrueSheetViewController.grabberOptions
639
+ setupGrabber()
657
640
  }
658
-
659
- bottomSheet.addView(grabberView)
660
641
  }
661
642
 
662
643
  // =============================================================================
@@ -664,25 +645,11 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
664
645
  // =============================================================================
665
646
 
666
647
  fun setupBackground() {
667
- val bottomSheet = bottomSheetView ?: return
668
-
669
- // Rounded corners only on top
670
- val outerRadii = floatArrayOf(
671
- sheetCornerRadius,
672
- sheetCornerRadius,
673
- sheetCornerRadius,
674
- sheetCornerRadius,
675
- 0f,
676
- 0f,
677
- 0f,
678
- 0f
679
- )
680
- val backgroundColor = sheetBackgroundColor ?: getDefaultBackgroundColor()
681
-
682
- bottomSheet.background = ShapeDrawable(RoundRectShape(outerRadii, null, null)).apply {
683
- paint.color = backgroundColor
648
+ dialogFragment?.apply {
649
+ sheetCornerRadius = this@TrueSheetViewController.sheetCornerRadius
650
+ sheetBackgroundColor = this@TrueSheetViewController.sheetBackgroundColor
651
+ setupBackground()
684
652
  }
685
- bottomSheet.clipToOutline = true
686
653
  }
687
654
 
688
655
  fun setupDimmedBackground(detentIndex: Int) {
@@ -754,25 +721,12 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
754
721
  }
755
722
  }
756
723
 
757
- fun getDefaultBackgroundColor(): Int {
758
- val typedValue = TypedValue()
759
- return if (reactContext.theme.resolveAttribute(
760
- com.google.android.material.R.attr.colorSurfaceContainerLow,
761
- typedValue,
762
- true
763
- )
764
- ) {
765
- typedValue.data
766
- } else {
767
- Color.WHITE
768
- }
769
- }
770
-
771
724
  // =============================================================================
772
725
  // MARK: - Footer Positioning
773
726
  // =============================================================================
774
727
 
775
728
  fun positionFooter(slideOffset: Float? = null) {
729
+ if (!isPresented) return
776
730
  val footerView = containerView?.footerView ?: return
777
731
  val bottomSheet = bottomSheetView ?: return
778
732
 
@@ -827,7 +781,10 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
827
781
  isKeyboardTransitioning = false
828
782
  }
829
783
 
830
- override fun keyboardDidChangeHeight(height: Int) {}
784
+ override fun keyboardDidChangeHeight(height: Int) {
785
+ if (!shouldHandleKeyboard()) return
786
+ positionFooter()
787
+ }
831
788
  }
832
789
  start()
833
790
  }
@@ -44,18 +44,20 @@ class TrueSheetAnimator(private val provider: TrueSheetAnimatorProvider) {
44
44
  return
45
45
  }
46
46
 
47
- val fromY = (provider.realScreenHeight - toTop).toFloat()
47
+ val fromTop = provider.realScreenHeight
48
+ val distance = fromTop - toTop
48
49
 
49
50
  presentAnimator?.cancel()
50
- presentAnimator = ValueAnimator.ofFloat(1f, 0f).apply {
51
+ presentAnimator = ValueAnimator.ofFloat(0f, 1f).apply {
51
52
  duration = PRESENT_DURATION
52
53
  interpolator = DecelerateInterpolator()
53
54
 
54
55
  addUpdateListener { animator ->
55
56
  val fraction = animator.animatedValue as Float
56
- bottomSheet.translationY = fromY * fraction
57
-
58
- val effectiveTop = bottomSheet.top + bottomSheet.translationY.toInt()
57
+ // Calculate effective top based on animation progress
58
+ val effectiveTop = fromTop - (distance * fraction).toInt()
59
+ // Adjust translationY to compensate for bottomSheet.top position
60
+ bottomSheet.translationY = (effectiveTop - bottomSheet.top).toFloat()
59
61
  onUpdate(effectiveTop)
60
62
  }
61
63
 
@@ -0,0 +1,320 @@
1
+ package com.lodev09.truesheet.core
2
+
3
+ import android.app.Dialog
4
+ import android.graphics.Color
5
+ import android.graphics.drawable.ShapeDrawable
6
+ import android.graphics.drawable.shapes.RoundRectShape
7
+ import android.os.Bundle
8
+ import android.util.TypedValue
9
+ import android.view.LayoutInflater
10
+ import android.view.View
11
+ import android.view.ViewGroup
12
+ import android.view.WindowManager
13
+ import android.widget.FrameLayout
14
+ import androidx.activity.OnBackPressedCallback
15
+ import com.facebook.react.uimanager.PixelUtil.dpToPx
16
+ import com.facebook.react.uimanager.ThemedReactContext
17
+ import com.google.android.material.bottomsheet.BottomSheetBehavior
18
+ import com.google.android.material.bottomsheet.BottomSheetDialog
19
+ import com.google.android.material.bottomsheet.BottomSheetDialogFragment
20
+ import com.lodev09.truesheet.BuildConfig
21
+ import com.lodev09.truesheet.R
22
+ import com.lodev09.truesheet.utils.ScreenUtils
23
+
24
+ // =============================================================================
25
+ // MARK: - Delegate Protocol
26
+ // =============================================================================
27
+
28
+ interface TrueSheetDialogFragmentDelegate {
29
+ fun onDialogShow()
30
+ fun onDialogDismiss()
31
+ fun onDialogCancel()
32
+ fun onStateChanged(sheetView: View, newState: Int)
33
+ fun onSlide(sheetView: View, slideOffset: Float)
34
+ fun onBackPressed()
35
+ }
36
+
37
+ // =============================================================================
38
+ // MARK: - TrueSheetDialogFragment
39
+ // =============================================================================
40
+
41
+ /**
42
+ * Custom BottomSheetDialogFragment for TrueSheet.
43
+ * Provides a Material Design bottom sheet with proper lifecycle management.
44
+ *
45
+ * This fragment handles:
46
+ * - Dialog creation with proper theming (edge-to-edge support)
47
+ * - BottomSheetBehavior configuration and callbacks
48
+ * - Background styling with corner radius
49
+ * - Grabber view management
50
+ * - Back press handling
51
+ *
52
+ * The parent TrueSheetViewController handles:
53
+ * - React Native touch dispatching
54
+ * - Detent calculations
55
+ * - Animations
56
+ * - Keyboard/modal observers
57
+ * - Stacking and dimming
58
+ */
59
+ class TrueSheetDialogFragment : BottomSheetDialogFragment() {
60
+
61
+ companion object {
62
+ private const val GRABBER_TAG = "TrueSheetGrabber"
63
+ private const val DEFAULT_MAX_WIDTH = 640 // dp
64
+ private const val DEFAULT_CORNER_RADIUS = 16f // dp
65
+
66
+ fun newInstance(): TrueSheetDialogFragment = TrueSheetDialogFragment()
67
+ }
68
+
69
+ // =============================================================================
70
+ // MARK: - Properties
71
+ // =============================================================================
72
+
73
+ var delegate: TrueSheetDialogFragmentDelegate? = null
74
+
75
+ // Content view provided by the controller
76
+ var contentView: View? = null
77
+
78
+ // React context for theme resolution and screen utils
79
+ var reactContext: ThemedReactContext? = null
80
+
81
+ // Configuration
82
+ var sheetCornerRadius: Float = DEFAULT_CORNER_RADIUS.dpToPx()
83
+ var sheetBackgroundColor: Int? = null
84
+ var edgeToEdgeFullScreen: Boolean = false
85
+ var grabberEnabled: Boolean = true
86
+ var grabberOptions: GrabberOptions? = null
87
+ var draggable: Boolean = true
88
+
89
+ var dismissible: Boolean = true
90
+ set(value) {
91
+ field = value
92
+ (dialog as? BottomSheetDialog)?.apply {
93
+ setCanceledOnTouchOutside(value)
94
+ setCancelable(value)
95
+ behavior.isHideable = value
96
+ }
97
+ }
98
+
99
+ // =============================================================================
100
+ // MARK: - Computed Properties
101
+ // =============================================================================
102
+
103
+ val bottomSheetDialog: BottomSheetDialog?
104
+ get() = dialog as? BottomSheetDialog
105
+
106
+ val behavior: BottomSheetBehavior<FrameLayout>?
107
+ get() = bottomSheetDialog?.behavior
108
+
109
+ val bottomSheetView: FrameLayout?
110
+ get() = dialog?.findViewById(com.google.android.material.R.id.design_bottom_sheet)
111
+
112
+ private val edgeToEdgeEnabled: Boolean
113
+ get() {
114
+ val defaultEnabled = android.os.Build.VERSION.SDK_INT >= 36
115
+ return BuildConfig.EDGE_TO_EDGE_ENABLED || bottomSheetDialog?.edgeToEdgeEnabled == true || defaultEnabled
116
+ }
117
+
118
+ val topInset: Int
119
+ get() = reactContext?.let { if (edgeToEdgeEnabled) ScreenUtils.getInsets(it).top else 0 } ?: 0
120
+
121
+ // =============================================================================
122
+ // MARK: - Lifecycle
123
+ // =============================================================================
124
+
125
+ override fun onCreate(savedInstanceState: Bundle?) {
126
+ super.onCreate(savedInstanceState)
127
+ // Prevent dialog from being recreated on configuration change
128
+ // The controller manages state separately
129
+ retainInstance = true
130
+ }
131
+
132
+ override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
133
+ val ctx = reactContext ?: requireContext()
134
+
135
+ val style = if (edgeToEdgeEnabled) {
136
+ R.style.TrueSheetEdgeToEdgeEnabledDialog
137
+ } else {
138
+ R.style.TrueSheetDialog
139
+ }
140
+
141
+ val dialog = BottomSheetDialog(ctx, style)
142
+
143
+ dialog.window?.apply {
144
+ setWindowAnimations(0)
145
+ setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING)
146
+ // Clear default dim - TrueSheet uses custom TrueSheetDimView for dimming
147
+ clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND)
148
+ }
149
+
150
+ dialog.setOnShowListener {
151
+ setupBottomSheetBehavior()
152
+ setupBackground()
153
+ setupGrabber()
154
+ // Re-apply dismissible after show since behavior may reset it
155
+ dialog.behavior.isHideable = dismissible
156
+ delegate?.onDialogShow()
157
+ }
158
+
159
+ dialog.setCanceledOnTouchOutside(dismissible)
160
+ dialog.setCancelable(dismissible)
161
+ dialog.behavior.isHideable = dismissible
162
+ dialog.behavior.isDraggable = draggable
163
+ dialog.behavior.maxWidth = DEFAULT_MAX_WIDTH.dpToPx().toInt()
164
+
165
+ // Handle back press
166
+ dialog.onBackPressedDispatcher.addCallback(
167
+ this,
168
+ object : OnBackPressedCallback(true) {
169
+ override fun handleOnBackPressed() {
170
+ delegate?.onBackPressed()
171
+ if (dismissible) {
172
+ dismiss()
173
+ }
174
+ }
175
+ }
176
+ )
177
+
178
+ return dialog
179
+ }
180
+
181
+ override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? = contentView
182
+
183
+ override fun onCancel(dialog: android.content.DialogInterface) {
184
+ super.onCancel(dialog)
185
+ delegate?.onDialogCancel()
186
+ }
187
+
188
+ override fun onDismiss(dialog: android.content.DialogInterface) {
189
+ super.onDismiss(dialog)
190
+ delegate?.onDialogDismiss()
191
+ }
192
+
193
+ override fun onDestroyView() {
194
+ // Detach content view to prevent it from being destroyed with the fragment
195
+ (contentView?.parent as? ViewGroup)?.removeView(contentView)
196
+ super.onDestroyView()
197
+ }
198
+
199
+ // =============================================================================
200
+ // MARK: - Setup
201
+ // =============================================================================
202
+
203
+ private fun setupBottomSheetBehavior() {
204
+ val behavior = this.behavior ?: return
205
+
206
+ behavior.addBottomSheetCallback(object : BottomSheetBehavior.BottomSheetCallback() {
207
+ override fun onSlide(sheetView: View, slideOffset: Float) {
208
+ delegate?.onSlide(sheetView, slideOffset)
209
+ }
210
+
211
+ override fun onStateChanged(sheetView: View, newState: Int) {
212
+ delegate?.onStateChanged(sheetView, newState)
213
+ }
214
+ })
215
+ }
216
+
217
+ fun setupBackground() {
218
+ val bottomSheet = bottomSheetView ?: return
219
+ val ctx = reactContext ?: return
220
+
221
+ val radius = if (sheetCornerRadius < 0) DEFAULT_CORNER_RADIUS.dpToPx() else sheetCornerRadius
222
+
223
+ // Rounded corners only on top
224
+ val outerRadii = floatArrayOf(
225
+ radius,
226
+ radius,
227
+ radius,
228
+ radius,
229
+ 0f,
230
+ 0f,
231
+ 0f,
232
+ 0f
233
+ )
234
+ val backgroundColor = sheetBackgroundColor ?: getDefaultBackgroundColor(ctx)
235
+
236
+ bottomSheet.background = ShapeDrawable(RoundRectShape(outerRadii, null, null)).apply {
237
+ paint.color = backgroundColor
238
+ }
239
+ bottomSheet.clipToOutline = true
240
+ }
241
+
242
+ fun setupGrabber() {
243
+ val bottomSheet = bottomSheetView ?: return
244
+ val ctx = reactContext ?: return
245
+
246
+ // Remove existing grabber
247
+ bottomSheet.findViewWithTag<View>(GRABBER_TAG)?.let {
248
+ bottomSheet.removeView(it)
249
+ }
250
+
251
+ if (!grabberEnabled || !draggable) return
252
+
253
+ val grabberView = TrueSheetGrabberView(ctx, grabberOptions).apply {
254
+ tag = GRABBER_TAG
255
+ }
256
+
257
+ bottomSheet.addView(grabberView)
258
+ }
259
+
260
+ // =============================================================================
261
+ // MARK: - Configuration
262
+ // =============================================================================
263
+
264
+ /**
265
+ * Configure detent-related behavior settings.
266
+ * Called by the controller when detents change.
267
+ */
268
+ fun configureDetents(
269
+ peekHeight: Int,
270
+ halfExpandedRatio: Float,
271
+ expandedOffset: Int,
272
+ fitToContents: Boolean,
273
+ skipCollapsed: Boolean = false,
274
+ animate: Boolean = false
275
+ ) {
276
+ val behavior = this.behavior ?: return
277
+
278
+ behavior.apply {
279
+ isFitToContents = fitToContents
280
+ this.skipCollapsed = skipCollapsed
281
+ setPeekHeight(peekHeight, animate)
282
+ this.halfExpandedRatio = halfExpandedRatio.coerceIn(0f, 0.999f)
283
+ this.expandedOffset = expandedOffset
284
+ }
285
+ }
286
+
287
+ /**
288
+ * Set the behavior state.
289
+ */
290
+ fun setState(state: Int) {
291
+ behavior?.state = state
292
+ }
293
+
294
+ /**
295
+ * Update draggable state.
296
+ */
297
+ fun updateDraggable(enabled: Boolean) {
298
+ draggable = enabled
299
+ behavior?.isDraggable = enabled
300
+ if (isAdded) setupGrabber()
301
+ }
302
+
303
+ // =============================================================================
304
+ // MARK: - Helpers
305
+ // =============================================================================
306
+
307
+ private fun getDefaultBackgroundColor(context: ThemedReactContext): Int {
308
+ val typedValue = TypedValue()
309
+ return if (context.theme.resolveAttribute(
310
+ com.google.android.material.R.attr.colorSurfaceContainerLow,
311
+ typedValue,
312
+ true
313
+ )
314
+ ) {
315
+ typedValue.data
316
+ } else {
317
+ Color.WHITE
318
+ }
319
+ }
320
+ }
@@ -40,7 +40,7 @@ object TrueSheetDialogObserver {
40
40
  synchronized(presentedSheetStack) {
41
41
  presentedSheetStack.remove(sheetView)
42
42
  if (hadParent) {
43
- presentedSheetStack.lastOrNull()?.viewController?.translateDialog(0)
43
+ presentedSheetStack.lastOrNull()?.resetTranslation()
44
44
  }
45
45
  }
46
46
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lodev09/react-native-true-sheet",
3
- "version": "3.5.1-beta.0",
3
+ "version": "3.5.1-beta.2",
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",