@lodev09/react-native-true-sheet 4.0.0-beta.3 → 4.0.0-beta.30

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 (244) hide show
  1. package/README.md +28 -5
  2. package/android/build.gradle +23 -1
  3. package/android/proguard-rules.pro +3 -0
  4. package/android/src/main/java/com/lodev09/truesheet/TrueSheetContainerView.kt +50 -16
  5. package/android/src/main/java/com/lodev09/truesheet/TrueSheetContentView.kt +303 -184
  6. package/android/src/main/java/com/lodev09/truesheet/TrueSheetContentViewManager.kt +5 -1
  7. package/android/src/main/java/com/lodev09/truesheet/TrueSheetFooterView.kt +38 -109
  8. package/android/src/main/java/com/lodev09/truesheet/TrueSheetModule.kt +13 -0
  9. package/android/src/main/java/com/lodev09/truesheet/TrueSheetOverlayView.kt +127 -0
  10. package/android/src/main/java/com/lodev09/truesheet/TrueSheetOverlayViewManager.kt +45 -0
  11. package/android/src/main/java/com/lodev09/truesheet/TrueSheetPackage.kt +2 -1
  12. package/android/src/main/java/com/lodev09/truesheet/TrueSheetStateUpdater.kt +29 -3
  13. package/android/src/main/java/com/lodev09/truesheet/TrueSheetView.kt +234 -66
  14. package/android/src/main/java/com/lodev09/truesheet/TrueSheetViewController.kt +491 -186
  15. package/android/src/main/java/com/lodev09/truesheet/TrueSheetViewManager.kt +64 -10
  16. package/android/src/main/java/com/lodev09/truesheet/core/RNScreensEventObserver.kt +31 -14
  17. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetBottomSheetBehavior.kt +135 -0
  18. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetBottomSheetView.kt +23 -23
  19. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetCoordinatorLayout.kt +130 -6
  20. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetDetentCalculator.kt +104 -39
  21. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetDimView.kt +14 -17
  22. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetGrabberView.kt +2 -4
  23. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetOverlayRootView.kt +148 -0
  24. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetStackManager.kt +33 -40
  25. package/android/src/main/java/com/lodev09/truesheet/events/TrueSheetLifecycleEvents.kt +15 -0
  26. package/android/src/main/java/com/lodev09/truesheet/utils/ColorUtils.kt +19 -0
  27. package/android/src/main/java/com/lodev09/truesheet/utils/KeyboardUtils.kt +15 -2
  28. package/android/src/main/java/com/lodev09/truesheet/utils/ScreenUtils.kt +0 -12
  29. package/android/src/main/java/com/lodev09/truesheet/utils/ViewUtils.kt +19 -7
  30. package/android/src/main/jni/TrueSheetSpec.h +1 -0
  31. package/android/src/main/jni/TrueSheetStateUpdater.cpp +37 -6
  32. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewComponentDescriptor.h +1 -9
  33. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewShadowNode.cpp +114 -30
  34. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewShadowNode.h +30 -7
  35. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewState.cpp +2 -2
  36. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewState.h +9 -8
  37. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetFooterViewComponentDescriptor.h +3 -5
  38. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetFooterViewShadowNode.cpp +14 -3
  39. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetFooterViewShadowNode.h +5 -6
  40. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetFooterViewState.cpp +1 -1
  41. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetFooterViewState.h +10 -6
  42. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetInsets.cpp +1 -1
  43. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetInsets.h +1 -1
  44. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetLayoutUtils.h +43 -0
  45. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetOverlayViewComponentDescriptor.h +21 -0
  46. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetOverlayViewShadowNode.cpp +17 -0
  47. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetOverlayViewShadowNode.h +33 -0
  48. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetViewComponentDescriptor.h +3 -5
  49. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetViewShadowNode.cpp +7 -40
  50. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetViewShadowNode.h +3 -7
  51. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetViewState.cpp +3 -5
  52. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetViewState.h +4 -5
  53. package/ios/TrueSheetContainerView.h +23 -7
  54. package/ios/TrueSheetContainerView.mm +45 -25
  55. package/ios/TrueSheetContentView.h +44 -15
  56. package/ios/TrueSheetContentView.mm +221 -190
  57. package/ios/TrueSheetFooterView.mm +133 -28
  58. package/ios/TrueSheetModule.h +2 -7
  59. package/ios/TrueSheetModule.mm +18 -18
  60. package/ios/TrueSheetOverlayView.h +42 -0
  61. package/ios/TrueSheetOverlayView.mm +175 -0
  62. package/ios/TrueSheetView.h +2 -0
  63. package/ios/TrueSheetView.mm +268 -52
  64. package/ios/TrueSheetViewController.h +36 -3
  65. package/ios/TrueSheetViewController.mm +797 -102
  66. package/ios/core/RNScreensEventObserver.mm +31 -28
  67. package/ios/core/TrueSheetBlurView.h +0 -2
  68. package/ios/core/TrueSheetBlurView.mm +3 -36
  69. package/ios/core/TrueSheetDetentCalculator.h +18 -1
  70. package/ios/core/TrueSheetDetentCalculator.mm +44 -9
  71. package/ios/core/TrueSheetDimView.h +27 -0
  72. package/ios/core/TrueSheetDimView.mm +41 -0
  73. package/ios/core/TrueSheetGrabberView.mm +13 -3
  74. package/ios/core/TrueSheetKeyboardObserver.h +7 -0
  75. package/ios/core/TrueSheetKeyboardObserver.mm +107 -15
  76. package/ios/core/TrueSheetOverlayContainerView.h +31 -0
  77. package/ios/core/TrueSheetOverlayContainerView.mm +35 -0
  78. package/ios/events/TrueSheetLifecycleEvents.h +2 -0
  79. package/ios/events/TrueSheetLifecycleEvents.mm +8 -0
  80. package/ios/tvos/TrueSheetStubs.mm +12 -0
  81. package/ios/utils/UIView+ScrollEdgeInteraction.h +1 -0
  82. package/ios/utils/UIView+ScrollEdgeInteraction.mm +24 -2
  83. package/lib/module/TrueSheet.js +222 -54
  84. package/lib/module/TrueSheet.js.map +1 -1
  85. package/lib/module/TrueSheet.web.js +364 -275
  86. package/lib/module/TrueSheet.web.js.map +1 -1
  87. package/lib/module/TrueSheetBackground.js +15 -0
  88. package/lib/module/TrueSheetBackground.js.map +1 -0
  89. package/lib/module/TrueSheetOverlay.js +30 -0
  90. package/lib/module/TrueSheetOverlay.js.map +1 -0
  91. package/lib/module/TrueSheetOverlay.web.js +35 -0
  92. package/lib/module/TrueSheetOverlay.web.js.map +1 -0
  93. package/lib/module/TrueSheetPeek.web.js +20 -15
  94. package/lib/module/TrueSheetPeek.web.js.map +1 -1
  95. package/lib/module/TrueSheetProvider.web.js +13 -9
  96. package/lib/module/TrueSheetProvider.web.js.map +1 -1
  97. package/lib/module/detentBackgrounds.js +21 -0
  98. package/lib/module/detentBackgrounds.js.map +1 -0
  99. package/lib/module/fabric/TrueSheetContentViewNativeComponent.ts +1 -1
  100. package/lib/module/fabric/TrueSheetFooterViewNativeComponent.ts +2 -3
  101. package/lib/module/fabric/TrueSheetOverlayViewNativeComponent.ts +12 -0
  102. package/lib/module/fabric/TrueSheetViewNativeComponent.ts +89 -73
  103. package/lib/module/index.js +1 -0
  104. package/lib/module/index.js.map +1 -1
  105. package/lib/module/mocks/index.js +18 -2
  106. package/lib/module/mocks/index.js.map +1 -1
  107. package/lib/module/mocks/navigationExpoRouter.js.map +1 -1
  108. package/lib/module/navigation/base-types.js +2 -0
  109. package/lib/module/navigation/base-types.js.map +1 -0
  110. package/lib/module/navigation/createTrueSheetRouter.js +158 -100
  111. package/lib/module/navigation/createTrueSheetRouter.js.map +1 -1
  112. package/lib/module/navigation/expo-router/base-types.js +2 -0
  113. package/lib/module/navigation/expo-router/base-types.js.map +1 -0
  114. package/lib/module/navigation/expo-router/extendTrueSheetRouter.js +71 -0
  115. package/lib/module/navigation/expo-router/extendTrueSheetRouter.js.map +1 -0
  116. package/lib/module/navigation/expo-router/index.js +18 -7
  117. package/lib/module/navigation/expo-router/index.js.map +1 -1
  118. package/lib/module/navigation/expo-router/types.js +4 -0
  119. package/lib/module/navigation/expo-router/types.js.map +1 -0
  120. package/lib/module/navigation/index.js.map +1 -1
  121. package/lib/module/navigation/screen/useSheetScreenState.js +1 -0
  122. package/lib/module/navigation/screen/useSheetScreenState.js.map +1 -1
  123. package/lib/module/scrollable.js +28 -0
  124. package/lib/module/scrollable.js.map +1 -0
  125. package/lib/module/web/constants.js +1 -1
  126. package/lib/module/web/constants.js.map +1 -1
  127. package/lib/module/web/keyboard.js +69 -0
  128. package/lib/module/web/keyboard.js.map +1 -0
  129. package/lib/module/web/layout.js +168 -0
  130. package/lib/module/web/layout.js.map +1 -0
  131. package/lib/module/web/vaul/constants.js +9 -0
  132. package/lib/module/web/vaul/constants.js.map +1 -1
  133. package/lib/module/web/vaul/context.js +4 -0
  134. package/lib/module/web/vaul/context.js.map +1 -1
  135. package/lib/module/web/vaul/helpers.js +1 -0
  136. package/lib/module/web/vaul/helpers.js.map +1 -1
  137. package/lib/module/web/vaul/index.js +83 -21
  138. package/lib/module/web/vaul/index.js.map +1 -1
  139. package/lib/module/web/vaul/use-snap-points.js +57 -28
  140. package/lib/module/web/vaul/use-snap-points.js.map +1 -1
  141. package/lib/typescript/src/TrueSheet.d.ts +18 -1
  142. package/lib/typescript/src/TrueSheet.d.ts.map +1 -1
  143. package/lib/typescript/src/TrueSheet.types.d.ts +209 -35
  144. package/lib/typescript/src/TrueSheet.types.d.ts.map +1 -1
  145. package/lib/typescript/src/TrueSheet.web.d.ts.map +1 -1
  146. package/lib/typescript/src/TrueSheetBackground.d.ts +3 -0
  147. package/lib/typescript/src/TrueSheetBackground.d.ts.map +1 -0
  148. package/lib/typescript/src/TrueSheetOverlay.d.ts +8 -0
  149. package/lib/typescript/src/TrueSheetOverlay.d.ts.map +1 -0
  150. package/lib/typescript/src/TrueSheetOverlay.web.d.ts +8 -0
  151. package/lib/typescript/src/TrueSheetOverlay.web.d.ts.map +1 -0
  152. package/lib/typescript/src/TrueSheetPeek.web.d.ts +8 -4
  153. package/lib/typescript/src/TrueSheetPeek.web.d.ts.map +1 -1
  154. package/lib/typescript/src/TrueSheetProvider.web.d.ts +5 -1
  155. package/lib/typescript/src/TrueSheetProvider.web.d.ts.map +1 -1
  156. package/lib/typescript/src/detentBackgrounds.d.ts +10 -0
  157. package/lib/typescript/src/detentBackgrounds.d.ts.map +1 -0
  158. package/lib/typescript/src/fabric/TrueSheetFooterViewNativeComponent.d.ts +2 -3
  159. package/lib/typescript/src/fabric/TrueSheetFooterViewNativeComponent.d.ts.map +1 -1
  160. package/lib/typescript/src/fabric/TrueSheetOverlayViewNativeComponent.d.ts +6 -0
  161. package/lib/typescript/src/fabric/TrueSheetOverlayViewNativeComponent.d.ts.map +1 -0
  162. package/lib/typescript/src/fabric/TrueSheetViewNativeComponent.d.ts +76 -65
  163. package/lib/typescript/src/fabric/TrueSheetViewNativeComponent.d.ts.map +1 -1
  164. package/lib/typescript/src/index.d.ts +1 -0
  165. package/lib/typescript/src/index.d.ts.map +1 -1
  166. package/lib/typescript/src/mocks/index.d.ts +4 -0
  167. package/lib/typescript/src/mocks/index.d.ts.map +1 -1
  168. package/lib/typescript/src/mocks/navigationExpoRouter.d.ts +3 -3
  169. package/lib/typescript/src/mocks/navigationExpoRouter.d.ts.map +1 -1
  170. package/lib/typescript/src/navigation/base-types.d.ts +9 -0
  171. package/lib/typescript/src/navigation/base-types.d.ts.map +1 -0
  172. package/lib/typescript/src/navigation/createTrueSheetRouter.d.ts +36 -10
  173. package/lib/typescript/src/navigation/createTrueSheetRouter.d.ts.map +1 -1
  174. package/lib/typescript/src/navigation/expo-router/base-types.d.ts +8 -0
  175. package/lib/typescript/src/navigation/expo-router/base-types.d.ts.map +1 -0
  176. package/lib/typescript/src/navigation/expo-router/extendTrueSheetRouter.d.ts +33 -0
  177. package/lib/typescript/src/navigation/expo-router/extendTrueSheetRouter.d.ts.map +1 -0
  178. package/lib/typescript/src/navigation/expo-router/index.d.ts +9 -6
  179. package/lib/typescript/src/navigation/expo-router/index.d.ts.map +1 -1
  180. package/lib/typescript/src/navigation/expo-router/types.d.ts +44 -0
  181. package/lib/typescript/src/navigation/expo-router/types.d.ts.map +1 -0
  182. package/lib/typescript/src/navigation/index.d.ts +3 -4
  183. package/lib/typescript/src/navigation/index.d.ts.map +1 -1
  184. package/lib/typescript/src/navigation/screen/useSheetScreenState.d.ts +2 -1
  185. package/lib/typescript/src/navigation/screen/useSheetScreenState.d.ts.map +1 -1
  186. package/lib/typescript/src/navigation/types.d.ts +43 -6
  187. package/lib/typescript/src/navigation/types.d.ts.map +1 -1
  188. package/lib/typescript/src/scrollable.d.ts +2 -0
  189. package/lib/typescript/src/scrollable.d.ts.map +1 -0
  190. package/lib/typescript/src/web/constants.d.ts +1 -1
  191. package/lib/typescript/src/web/constants.d.ts.map +1 -1
  192. package/lib/typescript/src/web/keyboard.d.ts +15 -0
  193. package/lib/typescript/src/web/keyboard.d.ts.map +1 -0
  194. package/lib/typescript/src/web/layout.d.ts +20 -0
  195. package/lib/typescript/src/web/layout.d.ts.map +1 -0
  196. package/lib/typescript/src/web/vaul/constants.d.ts +3 -0
  197. package/lib/typescript/src/web/vaul/constants.d.ts.map +1 -1
  198. package/lib/typescript/src/web/vaul/context.d.ts +5 -1
  199. package/lib/typescript/src/web/vaul/context.d.ts.map +1 -1
  200. package/lib/typescript/src/web/vaul/helpers.d.ts +1 -0
  201. package/lib/typescript/src/web/vaul/helpers.d.ts.map +1 -1
  202. package/lib/typescript/src/web/vaul/index.d.ts +20 -1
  203. package/lib/typescript/src/web/vaul/index.d.ts.map +1 -1
  204. package/lib/typescript/src/web/vaul/types.d.ts +9 -0
  205. package/lib/typescript/src/web/vaul/types.d.ts.map +1 -1
  206. package/lib/typescript/src/web/vaul/use-snap-points.d.ts +6 -4
  207. package/lib/typescript/src/web/vaul/use-snap-points.d.ts.map +1 -1
  208. package/package.json +18 -8
  209. package/react-native.config.js +1 -0
  210. package/src/TrueSheet.tsx +257 -62
  211. package/src/TrueSheet.types.ts +225 -35
  212. package/src/TrueSheet.web.tsx +445 -333
  213. package/src/TrueSheetBackground.tsx +17 -0
  214. package/src/TrueSheetOverlay.tsx +24 -0
  215. package/src/TrueSheetOverlay.web.tsx +33 -0
  216. package/src/TrueSheetPeek.web.tsx +25 -23
  217. package/src/TrueSheetProvider.web.tsx +22 -13
  218. package/src/detentBackgrounds.ts +24 -0
  219. package/src/fabric/TrueSheetContentViewNativeComponent.ts +1 -1
  220. package/src/fabric/TrueSheetFooterViewNativeComponent.ts +2 -3
  221. package/src/fabric/TrueSheetOverlayViewNativeComponent.ts +12 -0
  222. package/src/fabric/TrueSheetViewNativeComponent.ts +89 -73
  223. package/src/index.ts +1 -0
  224. package/src/mocks/index.ts +17 -2
  225. package/src/mocks/navigationExpoRouter.ts +6 -5
  226. package/src/navigation/base-types.ts +20 -0
  227. package/src/navigation/createTrueSheetRouter.ts +220 -144
  228. package/src/navigation/expo-router/base-types.ts +18 -0
  229. package/src/navigation/expo-router/extendTrueSheetRouter.ts +99 -0
  230. package/src/navigation/expo-router/index.ts +41 -30
  231. package/src/navigation/expo-router/types.ts +116 -0
  232. package/src/navigation/index.ts +6 -7
  233. package/src/navigation/screen/useSheetScreenState.ts +2 -0
  234. package/src/navigation/types.ts +81 -23
  235. package/src/scrollable.ts +25 -0
  236. package/src/web/constants.ts +1 -1
  237. package/src/web/keyboard.ts +79 -0
  238. package/src/web/layout.ts +196 -0
  239. package/src/web/vaul/constants.ts +9 -0
  240. package/src/web/vaul/context.ts +7 -1
  241. package/src/web/vaul/helpers.ts +5 -0
  242. package/src/web/vaul/index.tsx +113 -16
  243. package/src/web/vaul/types.ts +10 -0
  244. package/src/web/vaul/use-snap-points.ts +86 -33
@@ -1,7 +1,9 @@
1
1
  package com.lodev09.truesheet
2
2
 
3
+ import android.animation.ValueAnimator
3
4
  import android.annotation.SuppressLint
4
5
  import android.graphics.Canvas
6
+ import android.graphics.Color
5
7
  import android.os.Build
6
8
  import android.view.MotionEvent
7
9
  import android.view.View
@@ -10,6 +12,7 @@ import android.view.accessibility.AccessibilityEvent
10
12
  import android.view.accessibility.AccessibilityNodeInfo
11
13
  import android.widget.ImageView
12
14
  import androidx.coordinatorlayout.widget.CoordinatorLayout
15
+ import androidx.core.graphics.ColorUtils
13
16
  import androidx.core.graphics.createBitmap
14
17
  import androidx.core.view.isNotEmpty
15
18
  import com.facebook.react.R
@@ -34,12 +37,15 @@ import com.lodev09.truesheet.core.TrueSheetDetentCalculator
34
37
  import com.lodev09.truesheet.core.TrueSheetDetentCalculatorDelegate
35
38
  import com.lodev09.truesheet.core.TrueSheetDimView
36
39
  import com.lodev09.truesheet.core.TrueSheetDimViewDelegate
40
+ import com.lodev09.truesheet.core.TrueSheetDismissThreshold
37
41
  import com.lodev09.truesheet.core.TrueSheetKeyboardObserver
38
42
  import com.lodev09.truesheet.core.TrueSheetKeyboardObserverDelegate
39
43
  import com.lodev09.truesheet.core.TrueSheetStackManager
44
+ import com.lodev09.truesheet.utils.Insets
40
45
  import com.lodev09.truesheet.utils.KeyboardUtils
41
46
  import com.lodev09.truesheet.utils.ScreenUtils
42
47
  import com.lodev09.truesheet.utils.TouchEventDeduper
48
+ import kotlin.math.roundToInt
43
49
 
44
50
  // =============================================================================
45
51
  // MARK: - Data Types & Delegate Protocol
@@ -54,6 +60,7 @@ interface TrueSheetViewControllerDelegate {
54
60
  fun viewControllerDidPresent(index: Int, position: Float, detent: Float)
55
61
  fun viewControllerWillDismiss()
56
62
  fun viewControllerDidDismiss(parent: TrueSheetView?)
63
+ fun viewControllerDidAttemptDismiss()
57
64
  fun viewControllerDidChangeDetent(index: Int, position: Float, detent: Float)
58
65
  fun viewControllerDidDragBegin(index: Int, position: Float, detent: Float)
59
66
  fun viewControllerDidDragChange(index: Int, position: Float, detent: Float)
@@ -78,17 +85,19 @@ interface TrueSheetViewControllerDelegate {
78
85
  * enabling touch pass-through to underlying views. Handles detent configuration, drag interactions,
79
86
  * keyboard avoidance, dimmed backgrounds, back button, and lifecycle events for stacked sheets.
80
87
  */
81
- enum class TrueSheetAnchor {
82
- LEFT,
88
+ enum class TrueSheetPlacement {
89
+ AUTOMATIC,
90
+ LEADING,
83
91
  CENTER,
84
- RIGHT;
92
+ TRAILING;
85
93
 
86
94
  companion object {
87
- fun fromString(value: String?): TrueSheetAnchor =
95
+ fun fromString(value: String?): TrueSheetPlacement =
88
96
  when (value) {
89
- "left" -> LEFT
90
- "right" -> RIGHT
91
- else -> CENTER
97
+ "leading" -> LEADING
98
+ "center" -> CENTER
99
+ "trailing" -> TRAILING
100
+ else -> AUTOMATIC
92
101
  }
93
102
  }
94
103
  }
@@ -116,12 +125,17 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
116
125
  TrueSheetBottomSheetViewDelegate {
117
126
 
118
127
  companion object {
128
+ const val DEFAULT_DIM_OPACITY = 0.5f
119
129
  private const val DEFAULT_MAX_WIDTH = 640 // dp
120
130
  private const val DEFAULT_CORNER_RADIUS = 16 // dp
121
- private const val DEFAULT_ANCHOR_OFFSET = 16 // dp
131
+ private const val DEFAULT_PLACEMENT_OFFSET = 16 // dp
122
132
  private const val TRANSLATE_ANIMATION_DURATION = 200L
123
133
  private const val DISMISS_DURATION = 200L
124
134
  private const val SCREEN_FADE_DURATION = 150L
135
+
136
+ // Mirrors BottomSheetBehavior.HIDE_THRESHOLD — how far past the lowest stop,
137
+ // as a fraction of its height, a release hides a dismissible sheet
138
+ private const val HIDE_THRESHOLD = 0.5f
125
139
  }
126
140
 
127
141
  // =============================================================================
@@ -161,7 +175,14 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
161
175
  // animation doesn't snap the sheet back to the stale currentDetentIndex.
162
176
  private var pendingDetentIndex: Int = -1
163
177
 
178
+ private val isSettlingInPlace: Boolean
179
+ get() = !isBeingDismissed && pendingDetentIndex >= 0 && pendingDetentIndex == currentDetentIndex
180
+
164
181
  private var interactionState: InteractionState = InteractionState.Idle
182
+
183
+ // A content/header/footer size change arrived mid-drag — reconfiguring then
184
+ // would reset behavior state and kill the gesture, so it's applied on settle.
185
+ private var hasPendingSizeChange = false
165
186
  internal var isBeingDismissed = false
166
187
  private set
167
188
  var wasHiddenByScreen = false
@@ -175,6 +196,15 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
175
196
  // Keyboard State
176
197
  private var detentIndexBeforeKeyboard: Int = -1
177
198
  private var isKeyboardDismissProgrammatic = false
199
+
200
+ // The keyboard hid under a touch (e.g. keyboardDismissMode="on-drag") — the
201
+ // keyboard stops stay until it lifts. Settling or moving the stops under a
202
+ // scrolling finger makes the behavior's nested pre-scroll snap the sheet and
203
+ // jump the content.
204
+ private var isKeyboardSettleDeferred = false
205
+
206
+ // Sheet top when a keyboard-floor drag was released; -1 once the settle target is known
207
+ private var keyboardSettleStartTop = -1
178
208
  private var focusedViewBeforeBlur: View? = null
179
209
 
180
210
  // Promises
@@ -194,12 +224,6 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
194
224
  // Touch Dispatchers
195
225
  private val jsTouchDispatcher = JSTouchDispatcher(this)
196
226
  private val jsPointerDispatcher = JSPointerDispatcher(this)
197
-
198
- // True when the active touch stream began inside the footer. Latched on
199
- // ACTION_DOWN so subsequent MOVE events keep routing to the footer even if
200
- // the finger drifts outside the footer's rect mid-gesture.
201
- private var footerOwnsTouchStream = false
202
-
203
227
  private val touchDeduper = TouchEventDeduper()
204
228
 
205
229
  private val eventDispatcher
@@ -208,17 +232,26 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
208
232
  // Detent Configuration
209
233
  override var maxContentHeight: Int? = null
210
234
  override var maxContentWidth: Int? = null
211
- override var anchor: TrueSheetAnchor = TrueSheetAnchor.CENTER
212
- override var anchorOffset: Int = DEFAULT_ANCHOR_OFFSET.dpToPx().toInt()
235
+ override var placement: TrueSheetPlacement = TrueSheetPlacement.AUTOMATIC
236
+ override var placementOffset: Int = DEFAULT_PLACEMENT_OFFSET.dpToPx().toInt()
213
237
  override var detents: MutableList<Double> = mutableListOf(0.5, 1.0)
214
238
 
215
239
  // Appearance Configuration
216
240
  var dimmed = true
217
241
  var dimmedDetentIndex = 0
242
+ var dimColor: Int = Color.BLACK
243
+ var dimOpacity: Float = DEFAULT_DIM_OPACITY
244
+ private var dimProgress = 0f
218
245
  override var grabber: Boolean = true
219
246
  override var grabberOptions: GrabberOptions? = null
220
247
  override var accessibilityOptions: AccessibilityOptions? = null
248
+ var backgroundColor: Int? = null
249
+ var detentBackgrounds: List<Int?> = emptyList()
250
+ private var displayedBackgroundIndex = 0
251
+ private var backgroundAnimator: ValueAnimator? = null
252
+ private var targetBackgroundColor: Int? = null
221
253
  override var sheetBackgroundColor: Int? = null
254
+ private set
222
255
  var insetAdjustment: TrueSheetInsetAdjustment = TrueSheetInsetAdjustment.AUTOMATIC
223
256
 
224
257
  var scrollableOptions: ScrollableOptions? = null
@@ -227,6 +260,9 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
227
260
  behavior?.scrollingExpandsSheet = value?.scrollingExpandsSheet ?: true
228
261
  }
229
262
 
263
+ // React tag of the user-provided scrollable within the content (see the scrollableRef prop)
264
+ var scrollableHandle: Int = -1
265
+
230
266
  override var sheetCornerRadius: Float = DEFAULT_CORNER_RADIUS.dpToPx()
231
267
  set(value) {
232
268
  field = if (value < 0) DEFAULT_CORNER_RADIUS.dpToPx() else value
@@ -252,6 +288,12 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
252
288
  if (isPresented) sheetView?.setupGrabber()
253
289
  }
254
290
 
291
+ var dismissThreshold: TrueSheetDismissThreshold = TrueSheetDismissThreshold.HALF
292
+ set(value) {
293
+ field = value
294
+ behavior?.dismissThreshold = value
295
+ }
296
+
255
297
  // =============================================================================
256
298
  // MARK: - Computed Properties
257
299
  // =============================================================================
@@ -270,9 +312,29 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
270
312
  val screenWidth: Int
271
313
  get() = ScreenUtils.getScreenWidth(reactContext)
272
314
 
315
+ // Cached per layout/configuration pass — the WindowManager and
316
+ // rootWindowInsets queries behind these allocate, and they're read many
317
+ // times per animation frame (position emission, detent interpolation,
318
+ // stacked sheet sync)
319
+ private var cachedRealScreenHeight: Int = 0
320
+ private var cachedInsets: Insets? = null
321
+
322
+ private fun invalidateScreenMetrics() {
323
+ cachedRealScreenHeight = 0
324
+ cachedInsets = null
325
+ }
326
+
273
327
  // Includes system bars for accurate positioning
274
328
  override val realScreenHeight: Int
275
- get() = ScreenUtils.getRealScreenHeight(reactContext)
329
+ get() {
330
+ if (cachedRealScreenHeight == 0) {
331
+ cachedRealScreenHeight = ScreenUtils.getRealScreenHeight(reactContext)
332
+ }
333
+ return cachedRealScreenHeight
334
+ }
335
+
336
+ private val insets: Insets
337
+ get() = cachedInsets ?: ScreenUtils.getInsets(reactContext).also { cachedInsets = it }
276
338
 
277
339
  // Content Measurements
278
340
  // Cached values used during dismiss when container is unmounted
@@ -294,6 +356,9 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
294
356
 
295
357
  override var absoluteFooter: Boolean = false
296
358
 
359
+ override val footerInsetAdjustment: Boolean
360
+ get() = absoluteFooter && scrollableHandle > 0 && scrollableOptions?.contentInsetAdjustment?.footer != false
361
+
297
362
  override val peekContentHeight: Int
298
363
  get() = containerView?.peekContentHeight ?: cachedPeekContentHeight
299
364
 
@@ -309,16 +374,37 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
309
374
  private val isKeyboardTransitioning: Boolean
310
375
  get() = keyboardObserver?.isTransitioning ?: false
311
376
 
377
+ // The footer slides every frame until the sheet and keyboard come to rest
378
+ private val isSheetMoving: Boolean
379
+ get() = isPresentAnimating ||
380
+ isKeyboardTransitioning ||
381
+ behavior?.state == BottomSheetBehavior.STATE_DRAGGING ||
382
+ behavior?.state == BottomSheetBehavior.STATE_SETTLING
383
+
384
+ // Keyboard is up or animating — detents are (or are about to be) keyboard-shifted
385
+ val isKeyboardActive: Boolean
386
+ get() = keyboardInset > 0 || isKeyboardTransitioning
387
+
388
+ // While the keyboard holds the sheet up, the keyboard-free first detent is the
389
+ // collapsed stop — landing there dismisses the keyboard, and (when dismissible)
390
+ // dragging more than half its height below it hides the sheet too, like iOS.
391
+ override val hasKeyboardFloor: Boolean
392
+ get() = keyboardInset > 0
393
+
394
+ // The detent JS last heard about — the keyboard expansion is never reported
395
+ private val knownDetentIndex: Int
396
+ get() = if (detentIndexBeforeKeyboard >= 0) detentIndexBeforeKeyboard else currentDetentIndex
397
+
312
398
  fun isFocusedViewWithinSheet(): Boolean {
313
399
  val sheet = sheetView ?: return false
314
400
  return keyboardObserver?.isFocusedViewWithinSheet(sheet) ?: false
315
401
  }
316
402
 
317
403
  val bottomInset: Int
318
- get() = if (edgeToEdgeEnabled) ScreenUtils.getInsets(reactContext).bottom else 0
404
+ get() = if (edgeToEdgeEnabled) insets.bottom else 0
319
405
 
320
406
  override val topInset: Int
321
- get() = if (edgeToEdgeEnabled) ScreenUtils.getInsets(reactContext).top else 0
407
+ get() = if (edgeToEdgeEnabled) insets.top else 0
322
408
 
323
409
  override val contentBottomInset: Int
324
410
  get() = if (insetAdjustment == TrueSheetInsetAdjustment.AUTOMATIC) bottomInset else 0
@@ -331,6 +417,9 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
331
417
  }
332
418
 
333
419
  // Sheet State
420
+ val isSettling: Boolean
421
+ get() = behavior?.state == BottomSheetBehavior.STATE_SETTLING
422
+
334
423
  val isExpanded: Boolean
335
424
  get() {
336
425
  val sheetTop = sheetView?.top ?: return false
@@ -373,6 +462,9 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
373
462
 
374
463
  private fun cleanupSheet() {
375
464
  cleanupKeyboardObserver()
465
+ backgroundAnimator?.cancel()
466
+ backgroundAnimator = null
467
+ targetBackgroundColor = null
376
468
  sheetView?.animate()?.cancel()
377
469
 
378
470
  // Cleanup dim views
@@ -392,6 +484,7 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
392
484
  sheetView = null
393
485
 
394
486
  interactionState = InteractionState.Idle
487
+ hasPendingSizeChange = false
395
488
  isBeingDismissed = false
396
489
  isPresented = false
397
490
  isSheetVisible = false
@@ -403,6 +496,7 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
403
496
  detentIndexBeforeKeyboard = -1
404
497
  pendingDetentIndex = -1
405
498
  isKeyboardDismissProgrammatic = false
499
+ isKeyboardSettleDeferred = false
406
500
  focusedViewBeforeBlur = null
407
501
  shouldAnimatePresent = true
408
502
  }
@@ -440,13 +534,15 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
440
534
  // =============================================================================
441
535
 
442
536
  override fun coordinatorLayoutDidLayout(changed: Boolean) {
443
- // Reposition footer when layout changes
537
+ if (changed) invalidateScreenMetrics()
538
+
444
539
  if (isPresented && changed) {
445
- positionFooter()
540
+ setupSheetDetentsForSizeChange()
446
541
  }
447
542
  }
448
543
 
449
544
  override fun coordinatorLayoutDidChangeConfiguration() {
545
+ invalidateScreenMetrics()
450
546
  if (!isPresented) return
451
547
 
452
548
  sheetView?.updateGravity()
@@ -458,6 +554,34 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
458
554
  override fun findScrollView(): ViewGroup? = containerView?.contentView?.findScrollView()
459
555
  override fun findSheetView(): TrueSheetBottomSheetView? = sheetView
460
556
 
557
+ override fun coordinatorLayoutDidPullDown(overdragPx: Int, keyboardWasVisible: Boolean) {
558
+ if (!isPresented || dismissible || !draggable) return
559
+ val sheet = sheetView ?: return
560
+
561
+ // Only a pull clamped at the lowest stop is a blocked drag-to-dismiss — a
562
+ // child that swallowed the gesture higher up isn't
563
+ val lowestTop = detentCalculator.getLowestSheetTop()
564
+ if (sheet.top < lowestTop) return
565
+
566
+ if (keyboardWasVisible) {
567
+ // A sheet that couldn't move at all (first detent already full height) never
568
+ // began a drag, so the keyboard is still up — dismiss it here
569
+ if (hasKeyboardFloor && !isKeyboardDismissProgrammatic) commitDetentAndDismissKeyboard(0, sheet)
570
+
571
+ // Past the floor by the distance that would hide a dismissible sheet, like iOS
572
+ val floorHeight = realScreenHeight - lowestTop
573
+ if (overdragPx < floorHeight * HIDE_THRESHOLD) return
574
+ }
575
+
576
+ delegate?.viewControllerDidAttemptDismiss()
577
+ }
578
+
579
+ override fun coordinatorLayoutDidEndTouch() {
580
+ if (!isKeyboardSettleDeferred) return
581
+ isKeyboardSettleDeferred = false
582
+ setupSheetDetentsForSizeChange()
583
+ }
584
+
461
585
  // =============================================================================
462
586
  // MARK: - TrueSheetDimViewDelegate
463
587
  // =============================================================================
@@ -495,24 +619,30 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
495
619
 
496
620
  override fun bottomSheetViewDidTapGrabber() {
497
621
  val nextIndex = (currentDetentIndex + 1) % detents.size
498
- if (nextIndex == 0 && detents.size == 1 && dismissible) {
499
- dismiss(animated = true)
622
+ if (nextIndex == 0 && detents.size == 1) {
623
+ if (dismissible) {
624
+ dismiss(animated = true)
625
+ } else {
626
+ delegate?.viewControllerDidAttemptDismiss()
627
+ }
500
628
  } else {
501
- setStateForDetentIndex(nextIndex)
629
+ resize(nextIndex)
502
630
  }
503
631
  }
504
632
 
505
633
  override fun bottomSheetViewDidAccessibilityIncrement() {
506
634
  if (currentDetentIndex < detents.size - 1) {
507
- setStateForDetentIndex(currentDetentIndex + 1)
635
+ resize(currentDetentIndex + 1)
508
636
  }
509
637
  }
510
638
 
511
639
  override fun bottomSheetViewDidAccessibilityDecrement() {
512
640
  if (currentDetentIndex > 0) {
513
- setStateForDetentIndex(currentDetentIndex - 1)
641
+ resize(currentDetentIndex - 1)
514
642
  } else if (dismissible) {
515
643
  dismiss(animated = true)
644
+ } else {
645
+ delegate?.viewControllerDidAttemptDismiss()
516
646
  }
517
647
  }
518
648
 
@@ -526,7 +656,7 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
526
656
  }
527
657
 
528
658
  override fun onSlide(sheetView: View, slideOffset: Float) {
529
- handleSlide(sheetView, slideOffset)
659
+ handleSlide(sheetView)
530
660
  }
531
661
  }
532
662
 
@@ -540,7 +670,8 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
540
670
  return
541
671
  }
542
672
 
543
- if (!isPresented) return
673
+ // The slide-out owns the sheet now — a present settle landing mid-dismiss must not finish the present
674
+ if (!isPresented || isBeingDismissed) return
544
675
 
545
676
  when (newState) {
546
677
  BottomSheetBehavior.STATE_DRAGGING -> handleDragBegin(sheetView)
@@ -555,27 +686,44 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
555
686
  }
556
687
  }
557
688
 
558
- private fun handleSlide(sheetView: View, slideOffset: Float) {
689
+ private fun handleSlide(sheetView: View) {
559
690
  // Skip during dismiss animation
560
691
  if (isBeingDismissed) return
561
692
 
562
693
  val behavior = behavior ?: return
563
694
 
695
+ if (interactionState is InteractionState.Dragging || pendingDetentIndex >= 0) {
696
+ updateStateDimensionsForPosition(sheetView.top)
697
+ }
698
+
564
699
  when (behavior.state) {
565
- BottomSheetBehavior.STATE_DRAGGING,
566
- BottomSheetBehavior.STATE_SETTLING -> handleDragChange(sheetView)
700
+ BottomSheetBehavior.STATE_DRAGGING -> handleDragChange(sheetView)
701
+
702
+ BottomSheetBehavior.STATE_SETTLING -> {
703
+ handleDragChange(sheetView)
704
+ dismissKeyboardForSettleTarget(sheetView)
705
+ }
567
706
 
568
707
  else -> { }
569
708
  }
570
709
 
571
710
  emitChangePositionDelegate(sheetView.top)
572
711
 
712
+ // Drive the parent's stack translation from our actual frame position —
713
+ // an independently-timed animation lags behind (e.g. auto detent shrink)
714
+ // and reveals the parent above this sheet's top edge. Skipped while
715
+ // dragging so the parent stays put during drag-to-dismiss, and while the
716
+ // keyboard is active so growing for it doesn't move the parent (like iOS).
717
+ if (!isPresentAnimating && interactionState !is InteractionState.Dragging && !isKeyboardActive) {
718
+ syncParentTranslation(sheetView.top)
719
+ }
720
+
573
721
  // Position on every slide frame — during keyboard transitions the IME
574
722
  // animation callbacks are sparser than the sheet's settle frames, and the
575
723
  // footer rides the sheet between them, jittering against the keyboard.
576
724
  // positionFooter derives from current state, so double-driving it with
577
725
  // keyboardDidChangeHeight is harmless.
578
- positionFooter(slideOffset)
726
+ positionFooter()
579
727
 
580
728
  if (!isKeyboardTransitioning) {
581
729
  updateDimAmount(sheetView.top)
@@ -583,13 +731,27 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
583
731
  }
584
732
 
585
733
  private fun handleStateSettled(sheetView: View, newState: Int) {
734
+ // The settle's last slide frame has positioned the footer
735
+ containerView?.footerView?.syncTranslationState()
736
+
586
737
  if (interactionState is InteractionState.Reconfiguring) return
587
738
 
739
+ // A release already at a stop settles without a slide frame
740
+ keyboardSettleStartTop = -1
741
+
588
742
  // Sheet has reached a stable state — any in-flight resize target is now stale,
589
743
  // whether settled at the target, interrupted by drag, or superseded.
590
744
  pendingDetentIndex = -1
591
745
 
592
- val index = detentCalculator.getDetentIndexForState(newState) ?: return
746
+ val index = detentCalculator.getDetentIndexForState(newState) ?: run {
747
+ // Unmapped settle — end any drag anyway so deferred size changes aren't
748
+ // dropped forever (a leaked Dragging state skips every reconfigure)
749
+ if (interactionState is InteractionState.Dragging) {
750
+ interactionState = InteractionState.Idle
751
+ }
752
+ flushPendingSizeChange()
753
+ return
754
+ }
593
755
  val position = getPositionDpForView(sheetView)
594
756
  val detentInfo = DetentInfo(index, position)
595
757
 
@@ -597,21 +759,30 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
597
759
  if (isPresentAnimating) {
598
760
  isPresentAnimating = false
599
761
  finishPresent()
762
+ flushPendingSizeChange()
600
763
  return
601
764
  }
602
765
 
766
+ val wasDragging = interactionState is InteractionState.Dragging
767
+
603
768
  when (interactionState) {
604
769
  is InteractionState.Dragging -> {
605
770
  val detent = detentCalculator.getDetentValueForIndex(detentInfo.index)
606
771
  delegate?.viewControllerDidDragEnd(detentInfo.index, detentInfo.position, detent)
607
772
 
608
- // Skip detent change if keyboard inset is still active — detent mapping is unreliable.
609
- // keyboardWillHide will recalculate detents and settle at the correct index.
610
- if (detentInfo.index != currentDetentIndex && keyboardInset == 0) {
611
- currentDetentIndex = detentInfo.index
612
- setupDimmedBackground()
613
- delegate?.viewControllerDidChangeDetent(detentInfo.index, detentInfo.position, detent)
614
- this@TrueSheetViewController.sheetView?.updateGrabberAccessibilityValue(detentInfo.index, detents.size)
773
+ // Skip detent change if keyboard inset is still active — the keyboard floor
774
+ // landing below commits the detent instead.
775
+ if (keyboardInset == 0) {
776
+ val changed = detentInfo.index != knownDetentIndex
777
+ detentIndexBeforeKeyboard = -1
778
+ if (detentInfo.index != currentDetentIndex) {
779
+ currentDetentIndex = detentInfo.index
780
+ setupDimmedBackground()
781
+ }
782
+ if (changed) {
783
+ delegate?.viewControllerDidChangeDetent(detentInfo.index, detentInfo.position, detent)
784
+ this@TrueSheetViewController.sheetView?.updateGrabberAccessibilityValue(detentInfo.index, detents.size)
785
+ }
615
786
  }
616
787
 
617
788
  interactionState = InteractionState.Idle
@@ -629,14 +800,28 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
629
800
  }
630
801
  }
631
802
 
632
- // Settled — resize the container to the settled detent (applies deferred shrinks)
633
- updateStateDimensions()
803
+ if (hasKeyboardFloor && wasDragging && newState != BottomSheetBehavior.STATE_EXPANDED) {
804
+ // Dragged down onto a keyboard-free stop (the floor or the keyboard detent) —
805
+ // dismiss the keyboard and commit that detent. keyboardWillHide reconfigures
806
+ // the detents (and the container) to it.
807
+ commitDetentAndDismissKeyboard(index, sheetView)
808
+ } else {
809
+ updateStateDimensions()
810
+ }
634
811
 
635
812
  if (!isKeyboardTransitioning) {
636
813
  updateDimAmount(animated = true)
637
814
  }
638
815
 
639
816
  TrueSheetStackManager.updateBackgroundAccessibility()
817
+
818
+ flushPendingSizeChange()
819
+ }
820
+
821
+ private fun flushPendingSizeChange() {
822
+ if (!hasPendingSizeChange) return
823
+ hasPendingSizeChange = false
824
+ setupSheetDetentsForSizeChange()
640
825
  }
641
826
 
642
827
  // =============================================================================
@@ -698,6 +883,7 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
698
883
  val sheet = this.sheetView ?: return
699
884
  if (isPresented) return
700
885
 
886
+ invalidateScreenMetrics()
701
887
  shouldAnimatePresent = animated
702
888
  currentDetentIndex = detentIndex
703
889
  interactionState = InteractionState.Idle
@@ -710,7 +896,11 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
710
896
  setupSheetDetents()
711
897
  setupDimmedBackground()
712
898
  setupKeyboardObserver()
899
+ // Before the slide, so an input focused in onWillPresent insets the scrollable
900
+ containerView?.setupKeyboardHandler()
713
901
 
902
+ displayedBackgroundIndex = detentIndex
903
+ setupBackground(animated = false)
714
904
  sheet.setupBackground()
715
905
  sheet.setupElevation()
716
906
  sheet.setupGrabber()
@@ -735,13 +925,18 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
735
925
  fun resize(detentIndex: Int) {
736
926
  if (!isPresented) return
737
927
 
738
- // Commit the new index so keyboardWillHide restores to it instead of the stale one
928
+ // Commit the new index so keyboardWillHide restores to it instead of the stale
929
+ // one; the sheet keeps avoiding the keyboard meanwhile
739
930
  if (detentIndexBeforeKeyboard >= 0) {
740
931
  detentIndexBeforeKeyboard = detentIndex
932
+ if (hasKeyboardFloor) {
933
+ resizePromise?.invoke()
934
+ resizePromise = null
935
+ return
936
+ }
741
937
  }
742
938
 
743
939
  pendingDetentIndex = detentIndex
744
- updateStateDimensions(deferShrink = true)
745
940
  setupDimmedBackground()
746
941
  setStateForDetentIndex(detentIndex)
747
942
  resizePromise?.invoke()
@@ -763,6 +958,8 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
763
958
  behavior.isHideable = true
764
959
  behavior.isDraggable = draggable
765
960
  behavior.scrollingExpandsSheet = scrollableOptions?.scrollingExpandsSheet ?: true
961
+ behavior.dismissThreshold = dismissThreshold
962
+ behavior.lowestSheetTop = { detentCalculator.getLowestSheetTop() }
766
963
  behavior.state = BottomSheetBehavior.STATE_HIDDEN
767
964
  behavior.addBottomSheetCallback(sheetCallback)
768
965
 
@@ -807,6 +1004,9 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
807
1004
  return
808
1005
  }
809
1006
 
1007
+ // Bring the parent back alongside the slide-out (like iOS) instead of after it
1008
+ parentSheetView?.resetTranslation()
1009
+
810
1010
  sheet.animate()
811
1011
  .y(realScreenHeight.toFloat())
812
1012
  .setDuration(DISMISS_DURATION)
@@ -817,14 +1017,14 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
817
1017
  }
818
1018
 
819
1019
  private fun finishPresent() {
1020
+ coordinatorLayout?.blocksTouches = false
1021
+
820
1022
  // Restore isHideable to actual value after present animation
821
1023
  behavior?.isHideable = dismissible
822
1024
 
823
- containerView?.setupKeyboardHandler()
824
-
825
1025
  val (index, position, detent) = getDetentInfoWithValue(currentDetentIndex)
826
- delegate?.viewControllerDidPresent(index, position, detent)
827
1026
  parentSheetView?.viewControllerDidBlur()
1027
+ delegate?.viewControllerDidPresent(index, position, detent)
828
1028
  delegate?.viewControllerDidFocus()
829
1029
  sheetView?.updateGrabberAccessibilityValue(index, detents.size)
830
1030
 
@@ -866,11 +1066,15 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
866
1066
 
867
1067
  val maxAvailableHeight = realScreenHeight - topInset
868
1068
 
869
- val peekHeight = minOf(detentCalculator.getDetentHeight(detents[0]), maxAvailableHeight)
1069
+ // With a keyboard floor only the last detent stays keyboard-shifted; the
1070
+ // collapsed and half stops are the keyboard-free first and second detents,
1071
+ // so a short drag down from the keyboard-expanded sheet rests at the second
1072
+ // and a long one at the first, like iOS
1073
+ val peekHeight = minOf(detentCalculator.getDetentHeight(detents[0], includeKeyboard = !hasKeyboardFloor), maxAvailableHeight)
870
1074
 
871
1075
  val halfExpandedDetentHeight = when (detents.size) {
872
1076
  1 -> peekHeight
873
- else -> detentCalculator.getDetentHeight(detents[1])
1077
+ else -> detentCalculator.getDetentHeight(detents[1], includeKeyboard = !hasKeyboardFloor)
874
1078
  }
875
1079
 
876
1080
  val maxDetentHeight = minOf(detentCalculator.getDetentHeight(detents.last()), maxAvailableHeight)
@@ -880,8 +1084,9 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
880
1084
 
881
1085
  val expandedOffset = realScreenHeight - maxDetentHeight
882
1086
 
883
- // fitToContents works better with <= 2 detents when no expanded offset
884
- val fitToContents = detents.size < 3 && expandedOffset == 0
1087
+ // fitToContents works better with <= 2 detents when no expanded offset, but
1088
+ // it drops the half stop the keyboard floor relies on
1089
+ val fitToContents = !hasKeyboardFloor && detents.size < 3 && expandedOffset == 0
885
1090
 
886
1091
  configureDetents(
887
1092
  behavior = behavior,
@@ -892,13 +1097,25 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
892
1097
  animate = isPresented
893
1098
  )
894
1099
 
895
- updateStateDimensions()
896
-
897
1100
  if (isPresented && applyState) {
898
1101
  // Prefer the pending target while a resize animation is in flight so a
899
1102
  // layout-driven reconfigure doesn't revert to the stale currentDetentIndex.
900
1103
  val targetIndex = if (pendingDetentIndex >= 0) pendingDetentIndex else currentDetentIndex
1104
+
1105
+ // Track the settle like resize() — the container (and the footer pinned
1106
+ // to its bottom) follows the sheet's visible height instead of jumping
1107
+ // to the target and riding the animation
1108
+ pendingDetentIndex = targetIndex
1109
+ sheetView?.let { updateStateDimensionsForPosition(it.top) }
901
1110
  setStateForDetentIndex(targetIndex)
1111
+
1112
+ // Already at the target — no slide frames will follow
1113
+ if (!isSettling) {
1114
+ pendingDetentIndex = -1
1115
+ updateStateDimensions()
1116
+ }
1117
+ } else {
1118
+ updateStateDimensions()
902
1119
  }
903
1120
 
904
1121
  interactionState = InteractionState.Idle
@@ -922,10 +1139,14 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
922
1139
  }
923
1140
 
924
1141
  fun setupSheetDetentsForSizeChange() {
925
- // Skip while dragging — the size change is driven by the drag itself (the
926
- // container tracks the sheet's visible height), and reconfiguring resets
927
- // behavior state, killing the gesture.
928
- if (interactionState is InteractionState.Dragging) return
1142
+ // Reconfiguring while the container tracks the sheet can interrupt its
1143
+ // motion. Apply content size changes once the drag or resize settles, or
1144
+ // the touch holding a deferred keyboard settle lifts.
1145
+ if (interactionState is InteractionState.Dragging || isSettling || isKeyboardSettleDeferred) {
1146
+ hasPendingSizeChange = true
1147
+ return
1148
+ }
1149
+ hasPendingSizeChange = false
929
1150
 
930
1151
  setupSheetDetents()
931
1152
  positionFooter()
@@ -939,6 +1160,30 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
939
1160
  // MARK: - Dimmed Background
940
1161
  // =============================================================================
941
1162
 
1163
+ fun setupBackground(animated: Boolean = true) {
1164
+ val sheet = sheetView ?: return
1165
+ val color = detentBackgrounds.getOrNull(displayedBackgroundIndex)
1166
+ ?: backgroundColor ?: sheet.getDefaultBackgroundColor()
1167
+ if (animated && color == targetBackgroundColor) return
1168
+
1169
+ backgroundAnimator?.cancel()
1170
+ targetBackgroundColor = color
1171
+ val startColor = sheetBackgroundColor ?: color
1172
+ if (!animated || !isPresented || startColor == color) {
1173
+ sheetBackgroundColor = color
1174
+ sheet.updateBackgroundColor(color)
1175
+ return
1176
+ }
1177
+ backgroundAnimator = ValueAnimator.ofArgb(startColor, color).apply {
1178
+ duration = 200L
1179
+ addUpdateListener {
1180
+ sheetBackgroundColor = it.animatedValue as Int
1181
+ sheet.updateBackgroundColor(it.animatedValue as Int)
1182
+ }
1183
+ start()
1184
+ }
1185
+ }
1186
+
942
1187
  fun setupDimmedBackground() {
943
1188
  val coordinator = this.coordinatorLayout ?: run {
944
1189
  RNLog.e(reactContext, "TrueSheet: coordinatorLayout is null in setupDimmedBackground")
@@ -951,6 +1196,7 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
951
1196
  if (dimView == null) {
952
1197
  dimView = TrueSheetDimView(reactContext).apply {
953
1198
  delegate = this@TrueSheetViewController
1199
+ setDimColor(dimColor)
954
1200
  }
955
1201
  }
956
1202
  if (!parentDimVisible) {
@@ -964,6 +1210,7 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
964
1210
  if (parentDimView == null) {
965
1211
  parentDimView = TrueSheetDimView(reactContext).apply {
966
1212
  delegate = this@TrueSheetViewController
1213
+ setDimColor(dimColor)
967
1214
  }
968
1215
  }
969
1216
  parentDimView?.attach(parentBottomSheet, parentController.sheetCornerRadius)
@@ -977,26 +1224,57 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
977
1224
  }
978
1225
 
979
1226
  fun updateDimAmount(sheetTop: Int? = null, animated: Boolean = false) {
980
- if (!dimmed) return
981
- if (contentHeight == 0) return
982
-
983
- // While keyboard is active or transitioning, use the target detent position for dim
984
- val top = if (keyboardInset > 0 || isKeyboardTransitioning) {
985
- detentCalculator.getSheetTopForDetentIndex(currentDetentIndex)
986
- } else {
987
- val keyboardOffset = if (isBeingDismissed) 0 else currentKeyboardInset
988
- (sheetTop ?: sheetView?.top ?: return) + keyboardOffset
989
- }
1227
+ if (dimmed) {
1228
+ // While keyboard is active or transitioning, or settling in place after a content
1229
+ // resize (the detent top moves before the sheet does), use the target detent position
1230
+ val top = if (isKeyboardActive || isSettlingInPlace) {
1231
+ detentCalculator.getSheetTopForDetentIndex(currentDetentIndex)
1232
+ } else {
1233
+ val keyboardOffset = if (isBeingDismissed) 0 else currentKeyboardInset
1234
+ (sheetTop ?: sheetView?.top ?: return) + keyboardOffset
1235
+ }
990
1236
 
991
- if (animated) {
992
- val targetAlpha = dimView?.calculateAlpha(
1237
+ dimProgress = dimView?.calculateProgress(
993
1238
  top,
994
1239
  dimmedDetentIndex,
995
1240
  detentCalculator::getSheetTopForDetentIndex
996
1241
  ) ?: 0f
997
- dimViews.forEach { it.animate().alpha(targetAlpha).setDuration(200).start() }
998
- } else {
999
- dimViews.forEach { it.interpolateAlpha(top, dimmedDetentIndex, detentCalculator::getSheetTopForDetentIndex) }
1242
+ applyDimAmount(animated)
1243
+ }
1244
+
1245
+ // The sheets below blend this sheet's dim into theirs
1246
+ var parent = parentSheetView?.viewController
1247
+ while (parent != null) {
1248
+ parent.applyDimAmount(animated)
1249
+ parent = parent.parentSheetView?.viewController
1250
+ }
1251
+ }
1252
+
1253
+ /**
1254
+ * Dims by this sheet's progress, blending in the dim of the sheets presented on top by theirs,
1255
+ * so everything below the topmost sheet dims with its color and opacity.
1256
+ */
1257
+ private fun applyDimAmount(animated: Boolean) {
1258
+ if (!dimmed) return
1259
+
1260
+ var color = dimColor
1261
+ var opacity = dimOpacity
1262
+ (delegate as? TrueSheetView)?.let { hostView ->
1263
+ TrueSheetStackManager.getSheetsAbove(hostView).asReversed().forEach {
1264
+ val child = it.viewController
1265
+ if (!child.dimmed) return@forEach
1266
+ val below = (1f - child.dimProgress) * opacity
1267
+ val above = child.dimProgress * child.dimOpacity
1268
+ if (below + above > 0f) color = ColorUtils.blendARGB(color, child.dimColor, above / (below + above))
1269
+ opacity = below + above
1270
+ }
1271
+ }
1272
+
1273
+ // Keep opacity in the color so a transparent dim still receives touches.
1274
+ color = ColorUtils.setAlphaComponent(color, (Color.alpha(color) * opacity).roundToInt())
1275
+ dimViews.forEach {
1276
+ it.setDimColor(color)
1277
+ if (animated) it.animate().alpha(dimProgress).setDuration(200).start() else it.alpha = dimProgress
1000
1278
  }
1001
1279
  }
1002
1280
 
@@ -1006,39 +1284,54 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
1006
1284
 
1007
1285
  var footerKeyboardOffset: Int = 0
1008
1286
 
1009
- fun positionFooter(slideOffset: Float? = null) {
1287
+ /**
1288
+ * false keeps an absolute footer pinned to the sheet's bottom edge behind
1289
+ * the keyboard instead of rising above it.
1290
+ */
1291
+ override var footerAvoidsKeyboard: Boolean = true
1292
+
1293
+ fun positionFooter() {
1010
1294
  if (!isPresented) return
1011
1295
  val footerView = containerView?.footerView ?: return
1012
1296
  val sheet = sheetView ?: return
1013
1297
 
1014
- val keyboardShift = if (currentKeyboardInset > 0) maxOf(0, currentKeyboardInset + footerKeyboardOffset) else 0
1015
-
1016
1298
  // A relative footer is laid out by Yoga (pinned to the container's bottom
1017
1299
  // edge) and stays in the layout flow behind the keyboard — only an
1018
1300
  // absolute footer floats above it
1019
1301
  if (!absoluteFooter) {
1020
1302
  footerView.translationY = 0f
1303
+ footerView.syncTranslationState()
1021
1304
  return
1022
1305
  }
1023
1306
 
1307
+ // The footer's absorbed safe-area inset rides below the keyboard's top
1308
+ // edge — its content stays flush with the keyboard without dropping the
1309
+ // inset padding from the layout, which would jump at the transition edges
1310
+ val keyboardShift = if (currentKeyboardInset > 0 && footerAvoidsKeyboard) {
1311
+ maxOf(0, currentKeyboardInset + footerKeyboardOffset - footerView.bottomInset)
1312
+ } else {
1313
+ 0
1314
+ }
1315
+
1024
1316
  val footerHeight = footerView.height
1025
1317
  val sheetHeight = sheet.height
1026
- val sheetTop = sheet.top
1027
1318
 
1028
- var footerY = (sheetHeight - sheetTop - footerHeight - keyboardShift).toFloat()
1029
-
1030
- // Adjust during dismiss animation when slideOffset is negative. Skipped
1031
- // while the keyboard is open — the detents collapse to the expanded
1032
- // position, so any downward drag reads as a negative slideOffset and the
1033
- // adjustment would sink the footer behind the keyboard, snapping back up
1034
- // when the keyboard detents reset on release.
1035
- if (slideOffset != null && slideOffset < 0 && keyboardShift == 0) {
1036
- footerY -= (footerHeight * slideOffset)
1037
- }
1319
+ // Pinned to the screen bottom (above the keyboard) while the sheet sits at
1320
+ // or above its lowest stop; dragged past it the footer rides down with the
1321
+ // sheet instead, like iOS. A resize settle always pins — the sheet may be
1322
+ // animating up from below a stop that just grew
1323
+ val anchorTop = if (pendingDetentIndex >= 0) sheet.top else minOf(sheet.top, detentCalculator.getLowestSheetTop())
1324
+ val footerY = (sheetHeight - anchorTop - footerHeight - keyboardShift).toFloat()
1038
1325
 
1039
1326
  // Clamp to prevent footer going above safe area
1040
1327
  val maxAllowedY = (sheetHeight - topInset - footerHeight).toFloat()
1041
1328
  footerView.y = minOf(footerY, maxAllowedY)
1329
+ // Mid-motion slides are synced once the sheet settles or the keyboard
1330
+ // lands (handleStateSettled, keyboardDidShow/keyboardDidHide)
1331
+ if (!isSheetMoving) footerView.syncTranslationState()
1332
+ if (!footerAvoidsKeyboard) {
1333
+ containerView?.contentView?.updateContentInset()
1334
+ }
1042
1335
  }
1043
1336
 
1044
1337
  // =============================================================================
@@ -1057,6 +1350,10 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
1057
1350
  ?: reactContext.currentActivity?.currentFocus
1058
1351
  }
1059
1352
 
1353
+ fun clearFocusedView() {
1354
+ focusedViewBeforeBlur = null
1355
+ }
1356
+
1060
1357
  fun restoreFocusedView() {
1061
1358
  val viewToFocus = focusedViewBeforeBlur ?: return
1062
1359
  focusedViewBeforeBlur = null
@@ -1064,7 +1361,7 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
1064
1361
  if (!viewToFocus.isAttachedToWindow) return
1065
1362
  if (viewToFocus.requestFocus()) {
1066
1363
  viewToFocus.postDelayed({
1067
- KeyboardUtils.show(viewToFocus)
1364
+ if (viewToFocus.isFocused) KeyboardUtils.show(viewToFocus)
1068
1365
  }, 100)
1069
1366
  }
1070
1367
  }
@@ -1079,9 +1376,6 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
1079
1376
  delegate = object : TrueSheetKeyboardObserverDelegate {
1080
1377
  override fun keyboardWillShow(height: Int) {
1081
1378
  if (!shouldHandleKeyboard()) return
1082
- // An absolute footer rises above the keyboard, so it drops the bottom
1083
- // inset padding — before detents are configured against its height
1084
- if (absoluteFooter) containerView?.footerView?.keyboardVisible = true
1085
1379
  // If a resize is in flight, restore to its target — not the stale current
1086
1380
  detentIndexBeforeKeyboard = if (pendingDetentIndex >= 0) pendingDetentIndex else currentDetentIndex
1087
1381
  pendingDetentIndex = -1
@@ -1094,17 +1388,25 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
1094
1388
 
1095
1389
  override fun keyboardWillHide() {
1096
1390
  if (!shouldHandleKeyboard(checkFocus = false)) return
1097
- containerView?.footerView?.keyboardVisible = false
1391
+ // Mid-drag (the drag itself dismissed the keyboard) the release decides
1392
+ // the detent and a reconfigure would kill the gesture — the floor stops
1393
+ // stay put until the settle flushes the pending reconfigure
1394
+ if (interactionState is InteractionState.Dragging) {
1395
+ hasPendingSizeChange = true
1396
+ return
1397
+ }
1098
1398
  val restoring = !isBeingDismissed && detentIndexBeforeKeyboard >= 0
1399
+ if (restoring) {
1400
+ currentDetentIndex = detentIndexBeforeKeyboard
1401
+ }
1099
1402
 
1100
- // Skip reconfigure during interactive keyboard dismiss (e.g. keyboardDismissMode="on-drag")
1101
- // to prevent the sheet from jumping. keyboardDidHide will reconfigure after.
1102
- if (restoring || isKeyboardDismissProgrammatic) {
1403
+ // A scroll-committed keyboard detent still holds the keyboard-shifted
1404
+ // top too — reconfigure so the sheet settles with the keyboard, once
1405
+ // any touch on the sheet lifts
1406
+ if (!isBeingDismissed && coordinatorLayout?.isTouchingSheet == true) {
1407
+ isKeyboardSettleDeferred = true
1408
+ } else if (!isBeingDismissed || isKeyboardDismissProgrammatic) {
1103
1409
  setupSheetDetents()
1104
- if (restoring) {
1105
- currentDetentIndex = detentIndexBeforeKeyboard
1106
- setStateForDetentIndex(currentDetentIndex)
1107
- }
1108
1410
  }
1109
1411
 
1110
1412
  // Same-frame reposition — the reconfigure above shrinks the container
@@ -1117,12 +1419,24 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
1117
1419
  )
1118
1420
  }
1119
1421
 
1422
+ override fun keyboardDidShow(height: Int) {
1423
+ // The keyboard's last frame has positioned the footer
1424
+ containerView?.footerView?.syncTranslationState()
1425
+ }
1426
+
1120
1427
  override fun keyboardDidHide() {
1428
+ containerView?.footerView?.syncTranslationState()
1121
1429
  if (!shouldHandleKeyboard(checkFocus = false)) return
1122
- containerView?.footerView?.keyboardVisible = false
1123
- detentIndexBeforeKeyboard = -1
1124
1430
  isKeyboardDismissProgrammatic = false
1125
- setupSheetDetents(applyState = false)
1431
+ // Keep detentIndexBeforeKeyboard for the settle's detent-change check
1432
+ if (interactionState is InteractionState.Dragging) {
1433
+ hasPendingSizeChange = true
1434
+ return
1435
+ }
1436
+ detentIndexBeforeKeyboard = -1
1437
+ if (!isKeyboardSettleDeferred) {
1438
+ setupSheetDetents(applyState = false)
1439
+ }
1126
1440
  positionFooter()
1127
1441
  updateDimAmount(
1128
1442
  sheetTop = detentCalculator.getSheetTopForDetentIndex(currentDetentIndex),
@@ -1141,7 +1455,6 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
1141
1455
  // Handle case where keyboard is already visible and focus moves into the sheet
1142
1456
  if (!shouldHandleKeyboard()) return
1143
1457
  if (detentIndexBeforeKeyboard < 0 && (keyboardObserver?.currentHeight ?: 0) > 0) {
1144
- if (absoluteFooter) containerView?.footerView?.keyboardVisible = true
1145
1458
  detentIndexBeforeKeyboard = currentDetentIndex
1146
1459
  currentDetentIndex = detents.size - 1
1147
1460
  setupSheetDetents()
@@ -1169,8 +1482,28 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
1169
1482
  detentIndexBeforeKeyboard = -1
1170
1483
  }
1171
1484
 
1485
+ private fun handleSettling(sheetView: View) {
1486
+ // A keyboard-floor drag was released — the first settle frame reveals the target
1487
+ keyboardSettleStartTop = if (hasKeyboardFloor && interactionState is InteractionState.Dragging) sheetView.top else -1
1488
+ }
1489
+
1490
+ /**
1491
+ * Dismisses the keyboard as soon as a released drag is known to land on a
1492
+ * keyboard-free stop, rather than waiting for the landing. BottomSheetBehavior
1493
+ * keeps its target private, so read it off the first settle frame: heading
1494
+ * down means the half stop or the floor; heading up from below the half stop
1495
+ * means the half stop; only heading up from above it means the expanded stop.
1496
+ */
1497
+ private fun dismissKeyboardForSettleTarget(sheetView: View) {
1498
+ if (keyboardSettleStartTop < 0) return
1499
+ val startTop = keyboardSettleStartTop
1500
+ keyboardSettleStartTop = -1
1501
+
1502
+ val headingToKeyboardFreeStop = sheetView.top > startTop || startTop > detentCalculator.getKeyboardHalfStopTop()
1503
+ if (headingToKeyboardFreeStop && shouldHandleKeyboard()) dismissKeyboard()
1504
+ }
1505
+
1172
1506
  private fun handleDragBegin(sheetView: View) {
1173
- detentIndexBeforeKeyboard = -1
1174
1507
  pendingDetentIndex = -1
1175
1508
 
1176
1509
  val position = getPositionDpForView(sheetView)
@@ -1179,43 +1512,25 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
1179
1512
  interactionState = InteractionState.Dragging(startTop = sheetView.top)
1180
1513
  }
1181
1514
 
1182
- private fun handleSettling(sheetView: View) {
1183
- if (interactionState !is InteractionState.Dragging) return
1184
- if (keyboardInset <= 0) return
1515
+ // Commits a non-keyboard detent and hides the keyboard; keyboardWillHide then
1516
+ // reconfigures the detents so the sheet settles at the committed index.
1517
+ private fun commitDetentAndDismissKeyboard(index: Int, sheetView: View) {
1518
+ val changed = index != knownDetentIndex
1519
+ detentIndexBeforeKeyboard = -1
1520
+ currentDetentIndex = index
1521
+ dismissKeyboard()
1522
+ setupDimmedBackground()
1185
1523
 
1186
- // After drag release, check if the sheet was dragged past the midpoint between the
1187
- // keyboard-expanded position and a non-keyboard detent position. If so, dismiss
1188
- // keyboard and commit to that detent.
1189
- val maxAvailableHeight = realScreenHeight - topInset
1190
- val keyboardTop = detentCalculator.getSheetTopForDetentIndex(detents.size - 1)
1191
-
1192
- for (i in detents.indices) {
1193
- val nonKeyboardHeight = minOf(detentCalculator.getDetentHeight(detents[i], includeKeyboard = false), maxAvailableHeight)
1194
- val nonKeyboardTop = realScreenHeight - nonKeyboardHeight
1195
- val midpoint = keyboardTop + (nonKeyboardTop - keyboardTop) / 2
1196
-
1197
- if (sheetView.top >= midpoint) {
1198
- // Target position matches the keyboard-expanded position — keep keyboard open
1199
- if (nonKeyboardTop == keyboardTop) break
1200
-
1201
- detentIndexBeforeKeyboard = -1
1202
- currentDetentIndex = i
1203
- dismissKeyboard()
1204
- setupDimmedBackground()
1205
-
1206
- val position = getPositionDpForView(sheetView)
1207
- val detent = detentCalculator.getDetentValueForIndex(i)
1208
- delegate?.viewControllerDidChangeDetent(i, position, detent)
1209
- break
1210
- }
1211
- }
1524
+ if (!changed) return
1525
+ val position = getPositionDpForView(sheetView)
1526
+ val detent = detentCalculator.getDetentValueForIndex(index)
1527
+ delegate?.viewControllerDidChangeDetent(index, position, detent)
1528
+ this.sheetView?.updateGrabberAccessibilityValue(index, detents.size)
1212
1529
  }
1213
1530
 
1214
1531
  private fun handleDragChange(sheetView: View) {
1215
1532
  if (interactionState !is InteractionState.Dragging) return
1216
1533
 
1217
- updateStateDimensionsForDrag(sheetView.top)
1218
-
1219
1534
  val position = getPositionDpForView(sheetView)
1220
1535
  val detent = detentCalculator.getDetentValueForIndex(currentDetentIndex)
1221
1536
  delegate?.viewControllerDidDragChange(currentDetentIndex, position, detent)
@@ -1236,6 +1551,12 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
1236
1551
  delegate?.viewControllerWillBlur()
1237
1552
  delegate?.viewControllerWillDismiss()
1238
1553
  parentSheetView?.viewControllerWillFocus()
1554
+
1555
+ // Dismissed mid-present: finishPresent() won't run. Stop blocking touches, and settle the
1556
+ // pending present well before didDismiss so JS clears isPresenting and unmounts the lazy native view
1557
+ coordinatorLayout?.blocksTouches = false
1558
+ presentPromise?.invoke()
1559
+ presentPromise = null
1239
1560
  }
1240
1561
 
1241
1562
  private fun emitDidDismissEvents() {
@@ -1250,14 +1571,20 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
1250
1571
  }
1251
1572
 
1252
1573
  private fun emitChangePositionDelegate(currentTop: Int, realtime: Boolean = true) {
1574
+ if (isPresented && !isPresentAnimating && !isBeingDismissed) {
1575
+ val index = detentCalculator.getBackgroundIndex(currentTop, displayedBackgroundIndex)
1576
+ if (index != displayedBackgroundIndex) {
1577
+ displayedBackgroundIndex = index
1578
+ setupBackground()
1579
+ }
1580
+ }
1253
1581
  // Dedupe emissions for same position
1254
1582
  if (currentTop == lastEmittedPositionPx) return
1255
1583
 
1256
1584
  lastEmittedPositionPx = currentTop
1257
1585
  val visibleHeight = realScreenHeight - currentTop
1258
1586
  val position = detentCalculator.getPositionDp(visibleHeight)
1259
- val interpolatedIndex = detentCalculator.getInterpolatedIndexForPosition(currentTop)
1260
- val detent = detentCalculator.getInterpolatedDetentForPosition(currentTop)
1587
+ val (interpolatedIndex, detent) = detentCalculator.getInterpolatedPositionInfo(currentTop)
1261
1588
  delegate?.viewControllerDidChangePosition(interpolatedIndex, position, detent, realtime)
1262
1589
  }
1263
1590
 
@@ -1265,9 +1592,9 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
1265
1592
  * Updates position emission, footer, and dim amount together.
1266
1593
  * This pattern is commonly used during animations and state changes.
1267
1594
  */
1268
- private fun updateSheetVisuals(effectiveTop: Int, slideOffset: Float? = null) {
1595
+ private fun updateSheetVisuals(effectiveTop: Int) {
1269
1596
  emitChangePositionDelegate(effectiveTop)
1270
- positionFooter(slideOffset)
1597
+ positionFooter()
1271
1598
  updateDimAmount(effectiveTop)
1272
1599
  }
1273
1600
 
@@ -1287,13 +1614,8 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
1287
1614
  /**
1288
1615
  * Updates the Fabric state with the current/target detent size so Yoga sizes
1289
1616
  * the container to the sheet's visible height.
1290
- *
1291
- * Unlike iOS, resize animations run over multiple frames (ViewDragHelper), so
1292
- * shrinking is deferred to settle ([deferShrink]) when a resize animation is
1293
- * about to run — growing applies immediately so content is laid out before
1294
- * the sheet reveals it.
1295
1617
  */
1296
- private fun updateStateDimensions(deferShrink: Boolean = false) {
1618
+ private fun updateStateDimensions() {
1297
1619
  if (detents.isEmpty()) return
1298
1620
 
1299
1621
  val targetIndex = (if (pendingDetentIndex >= 0) pendingDetentIndex else currentDetentIndex)
@@ -1302,21 +1624,21 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
1302
1624
  val newHeight = minOf(detentCalculator.getDetentHeight(detents[targetIndex]), maxAvailableHeight)
1303
1625
  val newWidth = getStateWidth()
1304
1626
 
1305
- if (deferShrink && newWidth == lastStateWidth && newHeight < lastStateHeight) return
1306
-
1307
1627
  setStateDimensions(newWidth, newHeight)
1308
1628
  }
1309
1629
 
1310
1630
  /**
1311
- * Tracks the sheet's visible height during drag (and the post-release settle
1312
- * animation) so the container resizes with the finger, like iOS. Clamped to the
1631
+ * Tracks the sheet's visible height during drags and resize animations. Clamped to the
1313
1632
  * smallest detent — dragging below it slides the sheet out without resizing.
1314
1633
  */
1315
- private fun updateStateDimensionsForDrag(sheetTop: Int) {
1634
+ private fun updateStateDimensionsForPosition(sheetTop: Int) {
1316
1635
  if (detents.isEmpty()) return
1317
1636
 
1318
1637
  val maxAvailableHeight = realScreenHeight - topInset
1319
- val minHeight = minOf(detentCalculator.getDetentHeight(detents.first()), maxAvailableHeight)
1638
+ val minHeight = minOf(
1639
+ detentCalculator.getDetentHeight(detents.first(), includeKeyboard = !hasKeyboardFloor),
1640
+ maxAvailableHeight
1641
+ )
1320
1642
  val newHeight = detentCalculator.getVisibleSheetHeight(sheetTop).coerceIn(minHeight, maxAvailableHeight)
1321
1643
 
1322
1644
  setStateDimensions(getStateWidth(), newHeight)
@@ -1336,9 +1658,27 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
1336
1658
  delegate?.viewControllerDidChangeSize(width, height)
1337
1659
  }
1338
1660
 
1339
- fun translateSheet(translationY: Int, onEnd: (() -> Unit)? = null) {
1661
+ /**
1662
+ * Realtime (non-animated) counterpart of TrueSheetStackManager.updateParentTranslation,
1663
+ * driven by onSlide so the parent tracks this sheet's actual position.
1664
+ */
1665
+ private fun syncParentTranslation(sheetTop: Int) {
1666
+ val parent = parentSheetView ?: return
1667
+ val minSheetTop = detentCalculator.getSheetTopForDetentIndex(0)
1668
+ parent.syncTranslationForChild(sheetTop, minSheetTop)
1669
+ }
1670
+
1671
+ fun translateSheet(translationY: Int, animated: Boolean = true, onEnd: (() -> Unit)? = null) {
1340
1672
  val sheet = sheetView ?: return
1341
1673
 
1674
+ if (!animated) {
1675
+ sheet.animate().cancel()
1676
+ sheet.translationY = translationY.toFloat()
1677
+ emitChangePositionDelegate(sheet.top + translationY)
1678
+ onEnd?.invoke()
1679
+ return
1680
+ }
1681
+
1342
1682
  sheet.animate()
1343
1683
  .translationY(translationY.toFloat())
1344
1684
  .setDuration(TRANSLATE_ANIMATION_DURATION)
@@ -1385,41 +1725,6 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
1385
1725
  // MARK: - RootView Touch Handling
1386
1726
  // =============================================================================
1387
1727
 
1388
- override fun dispatchTouchEvent(event: MotionEvent): Boolean {
1389
- val footer = containerView?.footerView
1390
- val action = event.actionMasked
1391
-
1392
- if (footer != null && footer.isShown) {
1393
- if (action == MotionEvent.ACTION_DOWN) {
1394
- val loc = ScreenUtils.getScreenLocation(footer)
1395
- val x = event.rawX.toInt()
1396
- val y = event.rawY.toInt()
1397
- footerOwnsTouchStream = x >= loc[0] &&
1398
- x <= loc[0] + footer.width &&
1399
- y >= loc[1] &&
1400
- y <= loc[1] + footer.height
1401
- }
1402
-
1403
- if (footerOwnsTouchStream) {
1404
- val loc = ScreenUtils.getScreenLocation(footer)
1405
- val localEvent = MotionEvent.obtain(event)
1406
- localEvent.setLocation(event.rawX - loc[0], event.rawY - loc[1])
1407
- val handled = footer.dispatchTouchEvent(localEvent)
1408
- localEvent.recycle()
1409
-
1410
- if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL) {
1411
- footerOwnsTouchStream = false
1412
- }
1413
- if (handled) return true
1414
- }
1415
- }
1416
-
1417
- if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL) {
1418
- footerOwnsTouchStream = false
1419
- }
1420
- return super.dispatchTouchEvent(event)
1421
- }
1422
-
1423
1728
  override fun onInterceptTouchEvent(event: MotionEvent): Boolean {
1424
1729
  eventDispatcher?.let {
1425
1730
  if (touchDeduper.shouldDispatch(event)) {