@lodev09/react-native-true-sheet 3.9.0-beta.3 → 3.9.0-beta.5

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.
Files changed (45) hide show
  1. package/README.md +2 -2
  2. package/android/build.gradle +2 -1
  3. package/android/src/main/java/com/lodev09/truesheet/TrueSheetContainerView.kt +5 -0
  4. package/android/src/main/java/com/lodev09/truesheet/TrueSheetContentView.kt +21 -2
  5. package/android/src/main/java/com/lodev09/truesheet/TrueSheetView.kt +61 -28
  6. package/android/src/main/java/com/lodev09/truesheet/TrueSheetViewController.kt +53 -38
  7. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetCoordinatorLayout.kt +30 -2
  8. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetKeyboardObserver.kt +16 -9
  9. package/android/src/main/java/com/lodev09/truesheet/utils/ViewUtils.kt +1 -0
  10. package/ios/TrueSheetView.mm +1 -1
  11. package/ios/TrueSheetViewController.mm +26 -14
  12. package/ios/core/RNScreensEventObserver.mm +5 -1
  13. package/lib/module/TrueSheet.js +17 -7
  14. package/lib/module/TrueSheet.js.map +1 -1
  15. package/lib/module/TrueSheet.web.js +34 -28
  16. package/lib/module/TrueSheet.web.js.map +1 -1
  17. package/lib/module/navigation/screen/ReanimatedTrueSheetScreen.js +1 -1
  18. package/lib/module/navigation/screen/ReanimatedTrueSheetScreen.js.map +1 -1
  19. package/lib/module/navigation/screen/TrueSheetScreen.js +1 -1
  20. package/lib/module/navigation/screen/TrueSheetScreen.js.map +1 -1
  21. package/lib/module/navigation/screen/useSheetScreenState.js +21 -21
  22. package/lib/module/navigation/screen/useSheetScreenState.js.map +1 -1
  23. package/lib/module/reanimated/ReanimatedTrueSheetProvider.js +3 -3
  24. package/lib/module/reanimated/ReanimatedTrueSheetProvider.js.map +1 -1
  25. package/lib/typescript/src/TrueSheet.d.ts +2 -0
  26. package/lib/typescript/src/TrueSheet.d.ts.map +1 -1
  27. package/lib/typescript/src/TrueSheet.types.d.ts +14 -0
  28. package/lib/typescript/src/TrueSheet.types.d.ts.map +1 -1
  29. package/lib/typescript/src/TrueSheet.web.d.ts.map +1 -1
  30. package/lib/typescript/src/navigation/screen/types.d.ts +3 -5
  31. package/lib/typescript/src/navigation/screen/types.d.ts.map +1 -1
  32. package/lib/typescript/src/navigation/screen/useSheetScreenState.d.ts.map +1 -1
  33. package/lib/typescript/src/navigation/types.d.ts +3 -2
  34. package/lib/typescript/src/navigation/types.d.ts.map +1 -1
  35. package/lib/typescript/src/reanimated/ReanimatedTrueSheetProvider.d.ts.map +1 -1
  36. package/package.json +1 -1
  37. package/src/TrueSheet.tsx +22 -7
  38. package/src/TrueSheet.types.ts +16 -0
  39. package/src/TrueSheet.web.tsx +40 -31
  40. package/src/navigation/screen/ReanimatedTrueSheetScreen.tsx +1 -1
  41. package/src/navigation/screen/TrueSheetScreen.tsx +1 -1
  42. package/src/navigation/screen/types.ts +8 -6
  43. package/src/navigation/screen/useSheetScreenState.ts +27 -20
  44. package/src/navigation/types.ts +17 -6
  45. package/src/reanimated/ReanimatedTrueSheetProvider.tsx +5 -6
package/README.md CHANGED
@@ -28,7 +28,7 @@ The true native bottom sheet experience for your React Native Apps. 💩
28
28
 
29
29
  ### Prerequisites
30
30
 
31
- - React Native 0.80+
31
+ - React Native 0.81+
32
32
  - New Architecture enabled
33
33
  - Xcode 26.1+
34
34
 
@@ -36,7 +36,7 @@ The true native bottom sheet experience for your React Native Apps. 💩
36
36
 
37
37
  | TrueSheet | React Native | Expo SDK |
38
38
  |-----------|--------------|----------|
39
- | 3.7+ | 0.80+ | 54+ |
39
+ | 3.7+ | 0.81+ | 54+ |
40
40
  | 3.6 | 0.79 | 52-53 |
41
41
 
42
42
  ### Expo
@@ -96,10 +96,11 @@ repositories {
96
96
  }
97
97
 
98
98
  def kotlin_version = getExtOrDefault("kotlinVersion")
99
+ def material_version = getExtOrDefault("materialVersion") ?: "1.12.0"
99
100
 
100
101
  dependencies {
101
102
  implementation "com.facebook.react:react-android"
102
103
  implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
103
- implementation "com.google.android.material:material:1.12.0"
104
+ implementation "com.google.android.material:material:$material_version"
104
105
  }
105
106
 
@@ -10,6 +10,7 @@ import com.facebook.react.views.view.ReactViewGroup
10
10
  interface TrueSheetContainerViewDelegate {
11
11
  val eventDispatcher: EventDispatcher?
12
12
  fun containerViewContentDidChangeSize(width: Int, height: Int)
13
+ fun containerViewContentDidScroll()
13
14
  fun containerViewHeaderDidChangeSize(width: Int, height: Int)
14
15
  fun containerViewFooterDidChangeSize(width: Int, height: Int)
15
16
  }
@@ -119,6 +120,10 @@ class TrueSheetContainerView(reactContext: ThemedReactContext) :
119
120
  delegate?.containerViewContentDidChangeSize(width, height)
120
121
  }
121
122
 
123
+ override fun contentViewDidScroll() {
124
+ delegate?.containerViewContentDidScroll()
125
+ }
126
+
122
127
  override fun headerViewDidChangeSize(width: Int, height: Int) {
123
128
  headerHeight = height
124
129
  delegate?.containerViewHeaderDidChangeSize(width, height)
@@ -10,12 +10,14 @@ import com.facebook.react.uimanager.ThemedReactContext
10
10
  import com.facebook.react.views.view.ReactViewGroup
11
11
  import com.lodev09.truesheet.core.TrueSheetKeyboardObserver
12
12
  import com.lodev09.truesheet.core.TrueSheetKeyboardObserverDelegate
13
+ import com.lodev09.truesheet.utils.isDescendantOf
13
14
 
14
15
  /**
15
16
  * Delegate interface for content view size changes
16
17
  */
17
18
  interface TrueSheetContentViewDelegate {
18
19
  fun contentViewDidChangeSize(width: Int, height: Int)
20
+ fun contentViewDidScroll()
19
21
  }
20
22
 
21
23
  /**
@@ -30,6 +32,7 @@ class TrueSheetContentView(private val reactContext: ThemedReactContext) : React
30
32
  private var lastHeight = 0
31
33
 
32
34
  private var pinnedScrollView: ScrollView? = null
35
+ private var cachedScrollView: ScrollView? = null
33
36
  private var originalScrollViewPaddingBottom: Int = 0
34
37
  private var bottomInset: Int = 0
35
38
 
@@ -66,7 +69,8 @@ class TrueSheetContentView(private val reactContext: ThemedReactContext) : React
66
69
  val scrollView = findScrollView(this)
67
70
 
68
71
  if (scrollView != pinnedScrollView) {
69
- // Restore previous scroll view's padding
72
+ // Clean up previous scroll view
73
+ pinnedScrollView?.setOnScrollChangeListener(null as View.OnScrollChangeListener?)
70
74
  pinnedScrollView?.setPadding(
71
75
  pinnedScrollView!!.paddingLeft,
72
76
  pinnedScrollView!!.paddingTop,
@@ -76,6 +80,12 @@ class TrueSheetContentView(private val reactContext: ThemedReactContext) : React
76
80
 
77
81
  pinnedScrollView = scrollView
78
82
  originalScrollViewPaddingBottom = scrollView?.paddingBottom ?: 0
83
+
84
+ scrollView?.setOnScrollChangeListener { _, _, scrollY, _, oldScrollY ->
85
+ if (scrollY != oldScrollY) {
86
+ delegate?.contentViewDidScroll()
87
+ }
88
+ }
79
89
  }
80
90
 
81
91
  scrollView?.let {
@@ -90,6 +100,7 @@ class TrueSheetContentView(private val reactContext: ThemedReactContext) : React
90
100
  }
91
101
 
92
102
  fun clearScrollable() {
103
+ pinnedScrollView?.setOnScrollChangeListener(null as View.OnScrollChangeListener?)
93
104
  pinnedScrollView?.setPadding(
94
105
  pinnedScrollView!!.paddingLeft,
95
106
  pinnedScrollView!!.paddingTop,
@@ -97,11 +108,19 @@ class TrueSheetContentView(private val reactContext: ThemedReactContext) : React
97
108
  originalScrollViewPaddingBottom
98
109
  )
99
110
  pinnedScrollView = null
111
+ cachedScrollView = null
100
112
  originalScrollViewPaddingBottom = 0
101
113
  bottomInset = 0
102
114
  }
103
115
 
104
- fun findScrollView(): ScrollView? = findScrollView(this)
116
+ fun findScrollView(): ScrollView? {
117
+ // Return cached if still valid (attached and descendant of this view)
118
+ cachedScrollView?.let {
119
+ if (it.isAttachedToWindow && it.isDescendantOf(this)) return it
120
+ cachedScrollView = null
121
+ }
122
+ return findScrollView(this as View).also { cachedScrollView = it }
123
+ }
105
124
 
106
125
  private fun findScrollView(view: View): ScrollView? {
107
126
  if (view is ScrollView) {
@@ -45,19 +45,20 @@ class TrueSheetView(private val reactContext: ThemedReactContext) :
45
45
  var initialDetentAnimated: Boolean = true
46
46
  private var didInitiallyPresent: Boolean = false
47
47
 
48
+ private var lastContainerWidth: Int = 0
49
+ private var lastContainerHeight: Int = 0
50
+
48
51
  var stateWrapper: StateWrapper? = null
49
52
  set(value) {
53
+ field = value
54
+
50
55
  // On first state wrapper assignment, immediately update state with screen dimensions.
51
56
  // This ensures Yoga has initial width/height for content layout before presenting.
52
- if (field == null && value != null) {
57
+ if (value != null && lastContainerWidth == 0 && lastContainerHeight == 0) {
53
58
  updateState(viewController.screenWidth, viewController.screenHeight)
54
59
  }
55
- field = value
56
60
  }
57
61
 
58
- private var lastContainerWidth: Int = 0
59
- private var lastContainerHeight: Int = 0
60
-
61
62
  // Debounce flag to coalesce rapid layout changes into a single sheet update
62
63
  private var isSheetUpdatePending: Boolean = false
63
64
 
@@ -343,32 +344,49 @@ class TrueSheetView(private val reactContext: ThemedReactContext) :
343
344
 
344
345
  @UiThread
345
346
  fun present(detentIndex: Int, animated: Boolean = true, promiseCallback: () -> Unit) {
346
- if (!viewController.isPresented) {
347
- // Dismiss keyboard if focused view is within a sheet or if target detent will be dimmed
348
- val parentSheet = TrueSheetStackManager.getTopmostSheet()
349
- val isFocusedViewWithinSheet = parentSheet?.viewController?.isFocusedViewWithinSheet() == true
350
- val shouldDismissKeyboard = isFocusedViewWithinSheet || viewController.isDimmedAtDetentIndex(detentIndex)
351
- if (KeyboardUtils.isKeyboardVisible(reactContext) && shouldDismissKeyboard) {
352
- viewController.saveFocusedView()
353
- KeyboardUtils.dismiss(this) {
354
- post { present(detentIndex, animated, promiseCallback) }
355
- }
356
- return
357
- }
347
+ if (viewController.isPresented) {
348
+ RNLog.w(reactContext, "TrueSheet: sheet is already presented. Use resize() to change detent.")
349
+ promiseCallback()
350
+ return
351
+ }
358
352
 
359
- // Attach coordinator to the root container
360
- rootContainerView = findRootContainerView()
361
- viewController.coordinatorLayout?.let { rootContainerView?.addView(it) }
353
+ if (viewController.coordinatorLayout == null || viewController.sheetView == null) {
354
+ RNLog.w(reactContext, "TrueSheet: sheet is not ready. Ensure it is mounted before presenting.")
355
+ promiseCallback()
356
+ return
357
+ }
362
358
 
363
- // Register with observer to track sheet stack hierarchy
364
- viewController.parentSheetView = TrueSheetStackManager.registerSheet(this)
359
+ // Dismiss keyboard if focused view is within a sheet or if target detent will be dimmed
360
+ val parentSheet = TrueSheetStackManager.getTopmostSheet()
361
+ val isFocusedViewWithinSheet = parentSheet?.viewController?.isFocusedViewWithinSheet() == true
362
+ val shouldDismissKeyboard = isFocusedViewWithinSheet || viewController.isDimmedAtDetentIndex(detentIndex)
363
+ if (KeyboardUtils.isKeyboardVisible(reactContext) && shouldDismissKeyboard) {
364
+ viewController.saveFocusedView()
365
+ KeyboardUtils.dismiss(this) {
366
+ post { present(detentIndex, animated, promiseCallback) }
367
+ }
368
+ return
365
369
  }
370
+
371
+ // Attach coordinator to the root container
372
+ rootContainerView = findRootContainerView()
373
+ viewController.coordinatorLayout?.let { rootContainerView?.addView(it) }
374
+
375
+ // Register with observer to track sheet stack hierarchy
376
+ viewController.parentSheetView = TrueSheetStackManager.registerSheet(this)
377
+
366
378
  viewController.presentPromise = promiseCallback
367
379
  viewController.present(detentIndex, animated)
368
380
  }
369
381
 
370
382
  @UiThread
371
383
  fun dismiss(animated: Boolean = true, promiseCallback: () -> Unit) {
384
+ if (viewController.isBeingDismissed || !viewController.isPresented) {
385
+ RNLog.w(reactContext, "TrueSheet: sheet is already dismissed. No need to dismiss it again.")
386
+ promiseCallback()
387
+ return
388
+ }
389
+
372
390
  // Dismiss all sheets above first
373
391
  dismissStack(animated) {}
374
392
 
@@ -379,20 +397,31 @@ class TrueSheetView(private val reactContext: ThemedReactContext) :
379
397
 
380
398
  @UiThread
381
399
  fun dismissStack(animated: Boolean = true, promiseCallback: () -> Unit) {
400
+ if (viewController.isBeingDismissed || !viewController.isPresented) {
401
+ RNLog.w(reactContext, "TrueSheet: sheet is already dismissed. No need to dismiss it again.")
402
+ promiseCallback()
403
+ return
404
+ }
405
+
382
406
  val sheetsAbove = TrueSheetStackManager.getSheetsAbove(this)
383
- if (sheetsAbove.isNotEmpty()) {
384
- // Create snapshot only for topmost sheet (first in reversed list)
385
- sheetsAbove.firstOrNull()?.viewController?.createSheetSnapshot()
407
+ // Create snapshot only for topmost sheet (first in reversed list)
408
+ sheetsAbove.firstOrNull()?.viewController?.createSheetSnapshot()
386
409
 
387
- for (sheet in sheetsAbove) {
388
- sheet.viewController.dismiss(animated)
389
- }
410
+ for (sheet in sheetsAbove) {
411
+ sheet.viewController.dismiss(animated)
390
412
  }
413
+
391
414
  promiseCallback()
392
415
  }
393
416
 
394
417
  @UiThread
395
418
  fun resize(detentIndex: Int, promiseCallback: () -> Unit) {
419
+ if (!viewController.isPresented) {
420
+ RNLog.w(reactContext, "TrueSheet: Cannot resize. Sheet is not presented.")
421
+ promiseCallback()
422
+ return
423
+ }
424
+
396
425
  viewController.resizePromise = promiseCallback
397
426
  viewController.resize(detentIndex)
398
427
  }
@@ -556,6 +585,10 @@ class TrueSheetView(private val reactContext: ThemedReactContext) :
556
585
  updateSheetIfNeeded()
557
586
  }
558
587
 
588
+ override fun containerViewContentDidScroll() {
589
+ viewController.commitKeyboardDetent()
590
+ }
591
+
559
592
  override fun containerViewHeaderDidChangeSize(width: Int, height: Int) {
560
593
  updateSheetIfNeeded()
561
594
  }
@@ -173,6 +173,7 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
173
173
 
174
174
  // Keyboard State
175
175
  private var detentIndexBeforeKeyboard: Int = -1
176
+ private var isKeyboardDismissProgrammatic = false
176
177
  private var focusedViewBeforeBlur: View? = null
177
178
 
178
179
  // Promises
@@ -212,10 +213,6 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
212
213
  var insetAdjustment: TrueSheetInsetAdjustment = TrueSheetInsetAdjustment.AUTOMATIC
213
214
 
214
215
  var scrollable: Boolean = false
215
- set(value) {
216
- field = value
217
- coordinatorLayout?.scrollable = value
218
- }
219
216
 
220
217
  var scrollableOptions: ReadableMap? = null
221
218
 
@@ -344,7 +341,6 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
344
341
  // Create coordinator layout
345
342
  coordinatorLayout = TrueSheetCoordinatorLayout(reactContext).apply {
346
343
  delegate = this@TrueSheetViewController
347
- scrollable = this@TrueSheetViewController.scrollable
348
344
  }
349
345
 
350
346
  sheetView = TrueSheetBottomSheetView(reactContext).apply {
@@ -383,6 +379,7 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
383
379
  isPresentAnimating = false
384
380
  lastEmittedPositionPx = -1
385
381
  detentIndexBeforeKeyboard = -1
382
+ isKeyboardDismissProgrammatic = false
386
383
  focusedViewBeforeBlur = null
387
384
  shouldAnimatePresent = true
388
385
  }
@@ -457,7 +454,9 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
457
454
  sheetView?.let { emitChangePositionDelegate(it.top, realtime = false) }
458
455
  }
459
456
 
457
+ override val isScrollable: Boolean get() = scrollable
460
458
  override fun findScrollView(): ScrollView? = containerView?.contentView?.findScrollView()
459
+ override fun findSheetView(): TrueSheetBottomSheetView? = sheetView
461
460
 
462
461
  // =============================================================================
463
462
  // MARK: - TrueSheetDimViewDelegate
@@ -604,6 +603,10 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
604
603
  }
605
604
  }
606
605
  }
606
+
607
+ if (!isKeyboardTransitioning) {
608
+ updateDimAmount(animated = true)
609
+ }
607
610
  }
608
611
 
609
612
  // =============================================================================
@@ -658,22 +661,9 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
658
661
  // =============================================================================
659
662
 
660
663
  fun present(detentIndex: Int, animated: Boolean = true) {
661
- val coordinator = this.coordinatorLayout ?: run {
662
- RNLog.w(reactContext, "TrueSheet: No coordinator layout available. Ensure the sheet is mounted before presenting.")
663
- return
664
- }
665
-
666
- val sheet = this.sheetView ?: run {
667
- RNLog.w(reactContext, "TrueSheet: No sheet view available.")
668
- return
669
- }
670
-
671
- if (isPresented) {
672
- RNLog.w(reactContext, "TrueSheet: sheet is already presented. Use resize() to change detent.")
673
- presentPromise?.invoke()
674
- presentPromise = null
675
- return
676
- }
664
+ val coordinator = this.coordinatorLayout ?: return
665
+ val sheet = this.sheetView ?: return
666
+ if (isPresented) return
677
667
 
678
668
  shouldAnimatePresent = animated
679
669
  currentDetentIndex = detentIndex
@@ -710,12 +700,7 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
710
700
  }
711
701
 
712
702
  fun resize(detentIndex: Int) {
713
- if (!isPresented) {
714
- RNLog.w(reactContext, "TrueSheet: Cannot resize. Sheet is not presented.")
715
- resizePromise?.invoke()
716
- resizePromise = null
717
- return
718
- }
703
+ if (!isPresented) return
719
704
 
720
705
  // Commit the new index so keyboardWillHide restores to it instead of the stale one
721
706
  if (detentIndexBeforeKeyboard >= 0) {
@@ -765,6 +750,7 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
765
750
  }
766
751
 
767
752
  private fun dismissKeyboard() {
753
+ isKeyboardDismissProgrammatic = true
768
754
  KeyboardUtils.dismiss(reactContext)
769
755
  }
770
756
 
@@ -816,7 +802,7 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
816
802
  // MARK: - Sheet Configuration
817
803
  // =============================================================================
818
804
 
819
- fun setupSheetDetents() {
805
+ fun setupSheetDetents(applyState: Boolean = true) {
820
806
  val behavior = this.behavior ?: run {
821
807
  RNLog.e(reactContext, "TrueSheet: behavior is null in setupSheetDetents")
822
808
  return
@@ -861,7 +847,7 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
861
847
 
862
848
  updateStateDimensions(expandedOffset)
863
849
 
864
- if (isPresented) {
850
+ if (isPresented && applyState) {
865
851
  setStateForDetentIndex(currentDetentIndex)
866
852
  }
867
853
 
@@ -939,8 +925,13 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
939
925
  if (!dimmed) return
940
926
  if (contentHeight == 0) return
941
927
 
942
- val keyboardOffset = if (isBeingDismissed) 0 else currentKeyboardInset
943
- val top = (sheetTop ?: sheetView?.top ?: return) + keyboardOffset
928
+ // While keyboard is active or transitioning, use the target detent position for dim
929
+ val top = if (keyboardInset > 0 || isKeyboardTransitioning) {
930
+ detentCalculator.getSheetTopForDetentIndex(currentDetentIndex)
931
+ } else {
932
+ val keyboardOffset = if (isBeingDismissed) 0 else currentKeyboardInset
933
+ (sheetTop ?: sheetView?.top ?: return) + keyboardOffset
934
+ }
944
935
 
945
936
  if (animated) {
946
937
  val targetAlpha = dimView?.calculateAlpha(
@@ -1019,27 +1010,47 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
1019
1010
  if (!shouldHandleKeyboard()) return
1020
1011
  detentIndexBeforeKeyboard = currentDetentIndex
1021
1012
  setupSheetDetents()
1022
- setStateForDetentIndex(detents.size - 1)
1013
+ currentDetentIndex = detents.size - 1
1014
+ setStateForDetentIndex(currentDetentIndex)
1015
+ updateDimAmount(animated = true)
1023
1016
  }
1024
1017
 
1025
1018
  override fun keyboardWillHide() {
1026
1019
  if (!shouldHandleKeyboard(checkFocus = false)) return
1027
- setupSheetDetents()
1028
- if (!isBeingDismissed && detentIndexBeforeKeyboard >= 0) {
1029
- setStateForDetentIndex(detentIndexBeforeKeyboard)
1020
+ val restoring = !isBeingDismissed && detentIndexBeforeKeyboard >= 0
1021
+
1022
+ // Skip reconfigure during interactive keyboard dismiss (e.g. keyboardDismissMode="on-drag")
1023
+ // to prevent the sheet from jumping. keyboardDidHide will reconfigure after.
1024
+ if (restoring || isKeyboardDismissProgrammatic) {
1025
+ setupSheetDetents()
1026
+ if (restoring) {
1027
+ currentDetentIndex = detentIndexBeforeKeyboard
1028
+ setStateForDetentIndex(currentDetentIndex)
1029
+ }
1030
1030
  }
1031
+
1032
+ updateDimAmount(
1033
+ sheetTop = detentCalculator.getSheetTopForDetentIndex(currentDetentIndex),
1034
+ animated = true
1035
+ )
1031
1036
  }
1032
1037
 
1033
1038
  override fun keyboardDidHide() {
1034
1039
  if (!shouldHandleKeyboard(checkFocus = false)) return
1035
1040
  detentIndexBeforeKeyboard = -1
1041
+ isKeyboardDismissProgrammatic = false
1042
+ setupSheetDetents(applyState = false)
1036
1043
  positionFooter()
1044
+ updateDimAmount(
1045
+ sheetTop = detentCalculator.getSheetTopForDetentIndex(currentDetentIndex),
1046
+ animated = true
1047
+ )
1037
1048
  }
1038
1049
 
1039
1050
  override fun keyboardDidChangeHeight(height: Int) {
1040
- // Skip focus check if already handling keyboard (focus may be lost during hide)
1041
- val isHandlingKeyboard = detentIndexBeforeKeyboard >= 0
1042
- if (!shouldHandleKeyboard(checkFocus = !isHandlingKeyboard)) return
1051
+ // Skip focus check during active keyboard transitions (focus may be lost during hide)
1052
+ val skipFocusCheck = detentIndexBeforeKeyboard >= 0 || isKeyboardTransitioning
1053
+ if (!shouldHandleKeyboard(checkFocus = !skipFocusCheck)) return
1043
1054
  positionFooter()
1044
1055
  }
1045
1056
 
@@ -1069,6 +1080,10 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
1069
1080
  private fun getPositionDpForView(sheetView: View): Float =
1070
1081
  detentCalculator.getPositionDp(detentCalculator.getVisibleSheetHeight(sheetView.top))
1071
1082
 
1083
+ fun commitKeyboardDetent() {
1084
+ detentIndexBeforeKeyboard = -1
1085
+ }
1086
+
1072
1087
  private fun handleDragBegin(sheetView: View) {
1073
1088
  detentIndexBeforeKeyboard = -1
1074
1089
 
@@ -9,11 +9,14 @@ import android.widget.ScrollView
9
9
  import androidx.coordinatorlayout.widget.CoordinatorLayout
10
10
  import com.facebook.react.uimanager.PointerEvents
11
11
  import com.facebook.react.uimanager.ReactPointerEventsView
12
+ import com.lodev09.truesheet.utils.isDescendantOf
12
13
 
13
14
  interface TrueSheetCoordinatorLayoutDelegate {
15
+ val isScrollable: Boolean
14
16
  fun coordinatorLayoutDidLayout(changed: Boolean)
15
17
  fun coordinatorLayoutDidChangeConfiguration()
16
18
  fun findScrollView(): ScrollView?
19
+ fun findSheetView(): TrueSheetBottomSheetView?
17
20
  }
18
21
 
19
22
  /**
@@ -27,7 +30,6 @@ class TrueSheetCoordinatorLayout(context: Context) :
27
30
  ReactPointerEventsView {
28
31
 
29
32
  var delegate: TrueSheetCoordinatorLayoutDelegate? = null
30
- var scrollable: Boolean = false
31
33
 
32
34
  private val touchSlop: Int = ViewConfiguration.get(context).scaledTouchSlop
33
35
  private var dragging = false
@@ -63,6 +65,29 @@ class TrueSheetCoordinatorLayout(context: Context) :
63
65
  override val pointerEvents: PointerEvents
64
66
  get() = PointerEvents.BOX_NONE
65
67
 
68
+ /**
69
+ * Clears stale `nestedScrollingChildRef` from BottomSheetBehavior.
70
+ *
71
+ * `BottomSheetBehavior.onLayoutChild` calls `findScrollingChild()` which traverses the
72
+ * entire sheet subtree. When a child sheet with a ScrollView is dismissed, the ScrollView
73
+ * returns to this sheet's view hierarchy. `onLayoutChild` may then capture a ref to that
74
+ * ScrollView even though it doesn't belong to this sheet, blocking drag interactions.
75
+ */
76
+ private fun clearStaleNestedScrollingChildRef() {
77
+ val sheet = delegate?.findSheetView() ?: return
78
+ val behavior = sheet.behavior ?: return
79
+ try {
80
+ val field = behavior.javaClass.getDeclaredField("nestedScrollingChildRef")
81
+ field.isAccessible = true
82
+ @Suppress("UNCHECKED_CAST")
83
+ val ref = field.get(behavior) as? java.lang.ref.WeakReference<android.view.View> ?: return
84
+ val view = ref.get() ?: return
85
+ if (!view.isDescendantOf(sheet)) {
86
+ ref.clear()
87
+ }
88
+ } catch (_: Exception) {}
89
+ }
90
+
66
91
  /**
67
92
  * Intercepts touch events for ScrollViews that can't scroll (content < viewport),
68
93
  * allowing the sheet to be dragged in these cases.
@@ -71,7 +96,10 @@ class TrueSheetCoordinatorLayout(context: Context) :
71
96
  * See: https://github.com/facebook/react-native/pull/44099
72
97
  */
73
98
  override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
74
- if (!scrollable) {
99
+ if (delegate?.isScrollable != true) {
100
+ if (ev.actionMasked == MotionEvent.ACTION_DOWN) {
101
+ clearStaleNestedScrollingChildRef()
102
+ }
75
103
  return super.onInterceptTouchEvent(ev)
76
104
  }
77
105
 
@@ -64,16 +64,23 @@ class TrueSheetKeyboardObserver(private val targetView: View, private val reactC
64
64
  }
65
65
 
66
66
  fun stop() {
67
- globalLayoutListener?.let { listener ->
68
- activityRootView?.viewTreeObserver?.removeOnGlobalLayoutListener(listener)
69
- globalLayoutListener = null
70
- }
71
- focusChangeListener?.let { listener ->
72
- activityRootView?.viewTreeObserver?.removeOnGlobalFocusChangeListener(listener)
73
- focusChangeListener = null
67
+ try {
68
+ globalLayoutListener?.let { listener ->
69
+ activityRootView?.viewTreeObserver?.let { observer ->
70
+ if (observer.isAlive) observer.removeOnGlobalLayoutListener(listener)
71
+ }
72
+ globalLayoutListener = null
73
+ }
74
+ focusChangeListener?.let { listener ->
75
+ activityRootView?.viewTreeObserver?.let { observer ->
76
+ if (observer.isAlive) observer.removeOnGlobalFocusChangeListener(listener)
77
+ }
78
+ focusChangeListener = null
79
+ }
80
+ } finally {
81
+ activityRootView = null
82
+ ViewCompat.setWindowInsetsAnimationCallback(targetView, null)
74
83
  }
75
- activityRootView = null
76
- ViewCompat.setWindowInsetsAnimationCallback(targetView, null)
77
84
  }
78
85
 
79
86
  private fun updateHeight(from: Int, to: Int, fraction: Float) {
@@ -3,6 +3,7 @@ package com.lodev09.truesheet.utils
3
3
  import android.view.View
4
4
 
5
5
  fun View.isDescendantOf(ancestor: View): Boolean {
6
+ if (!isAttachedToWindow) return false
6
7
  var current: View? = this
7
8
  while (current != null) {
8
9
  if (current === ancestor) return true
@@ -281,7 +281,7 @@ using namespace facebook::react;
281
281
  if (!_state)
282
282
  return;
283
283
 
284
- if (CGSizeEqualToSize(size, _lastStateSize))
284
+ if (fabs(size.width - _lastStateSize.width) < 0.5 && fabs(size.height - _lastStateSize.height) < 0.5)
285
285
  return;
286
286
 
287
287
  _lastStateSize = size;
@@ -82,6 +82,8 @@ using namespace facebook::react;
82
82
  }
83
83
 
84
84
  - (void)dealloc {
85
+ [_transitioningTimer invalidate];
86
+ _transitioningTimer = nil;
85
87
  [[NSNotificationCenter defaultCenter] removeObserver:self];
86
88
  }
87
89
 
@@ -413,22 +415,32 @@ using namespace facebook::react;
413
415
  _transitionFakeView.frame = self.isBeingDismissed ? presentedFrame : dismissedFrame;
414
416
  [self storeResolvedPositionForIndex:self.currentDetentIndex];
415
417
 
416
- auto animation = ^(id<UIViewControllerTransitionCoordinatorContext> _Nonnull context) {
417
- [[context containerView] addSubview:self->_transitionFakeView];
418
- self->_transitionFakeView.frame = self.isBeingDismissed ? dismissedFrame : presentedFrame;
419
-
420
- self->_transitioningTimer = [CADisplayLink displayLinkWithTarget:self selector:@selector(handleTransitionTracker)];
421
- [self->_transitioningTimer addToRunLoop:[NSRunLoop currentRunLoop] forMode:NSDefaultRunLoopMode];
422
- };
418
+ __weak __typeof(self) weakSelf = self;
423
419
 
424
420
  [self.transitionCoordinator
425
- animateAlongsideTransition:animation
426
- completion:^(id<UIViewControllerTransitionCoordinatorContext> _Nonnull context) {
427
- [self->_transitioningTimer setPaused:YES];
428
- [self->_transitioningTimer invalidate];
429
- [self->_transitionFakeView removeFromSuperview];
430
- self->_isTransitioning = NO;
431
- }];
421
+ animateAlongsideTransition:^(id<UIViewControllerTransitionCoordinatorContext> _Nonnull context) {
422
+ __strong __typeof(weakSelf) strongSelf = weakSelf;
423
+ if (!strongSelf)
424
+ return;
425
+
426
+ [[context containerView] addSubview:strongSelf->_transitionFakeView];
427
+ strongSelf->_transitionFakeView.frame = strongSelf.isBeingDismissed ? dismissedFrame : presentedFrame;
428
+
429
+ strongSelf->_transitioningTimer = [CADisplayLink displayLinkWithTarget:strongSelf
430
+ selector:@selector(handleTransitionTracker)];
431
+ [strongSelf->_transitioningTimer addToRunLoop:[NSRunLoop currentRunLoop] forMode:NSDefaultRunLoopMode];
432
+ }
433
+ completion:^(id<UIViewControllerTransitionCoordinatorContext> _Nonnull context) {
434
+ __strong __typeof(weakSelf) strongSelf = weakSelf;
435
+ if (!strongSelf)
436
+ return;
437
+
438
+ [strongSelf->_transitioningTimer setPaused:YES];
439
+ [strongSelf->_transitioningTimer invalidate];
440
+ strongSelf->_transitioningTimer = nil;
441
+ [strongSelf->_transitionFakeView removeFromSuperview];
442
+ strongSelf->_isTransitioning = NO;
443
+ }];
432
444
  }
433
445
 
434
446
  - (void)handleTransitionTracker {
@@ -126,8 +126,12 @@ using namespace facebook::react;
126
126
  _parentScreenController = nil;
127
127
  _window = view.window;
128
128
 
129
+ Class screenViewClass = NSClassFromString(@"RNSScreenView");
130
+ if (!screenViewClass)
131
+ return;
132
+
129
133
  for (UIView *current = view.superview; current; current = current.superview) {
130
- if ([NSStringFromClass([current class]) isEqualToString:@"RNSScreenView"]) {
134
+ if ([current isKindOfClass:screenViewClass]) {
131
135
  [_screenTags addObject:@(current.tag)];
132
136
 
133
137
  UIViewController *screenVC = nil;