@lodev09/react-native-true-sheet 4.0.0-beta.2 → 4.0.0-beta.21

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 (205) hide show
  1. package/README.md +25 -4
  2. package/android/build.gradle +22 -1
  3. package/android/src/main/java/com/lodev09/truesheet/TrueSheetContainerView.kt +50 -16
  4. package/android/src/main/java/com/lodev09/truesheet/TrueSheetContentView.kt +303 -184
  5. package/android/src/main/java/com/lodev09/truesheet/TrueSheetContentViewManager.kt +5 -1
  6. package/android/src/main/java/com/lodev09/truesheet/TrueSheetFooterView.kt +21 -110
  7. package/android/src/main/java/com/lodev09/truesheet/TrueSheetOverlayView.kt +116 -0
  8. package/android/src/main/java/com/lodev09/truesheet/TrueSheetOverlayViewManager.kt +45 -0
  9. package/android/src/main/java/com/lodev09/truesheet/TrueSheetPackage.kt +2 -1
  10. package/android/src/main/java/com/lodev09/truesheet/TrueSheetStateUpdater.kt +18 -0
  11. package/android/src/main/java/com/lodev09/truesheet/TrueSheetView.kt +124 -50
  12. package/android/src/main/java/com/lodev09/truesheet/TrueSheetViewController.kt +369 -163
  13. package/android/src/main/java/com/lodev09/truesheet/TrueSheetViewManager.kt +59 -12
  14. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetBottomSheetBehavior.kt +126 -0
  15. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetBottomSheetView.kt +22 -9
  16. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetCoordinatorLayout.kt +84 -5
  17. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetDetentCalculator.kt +104 -39
  18. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetDimView.kt +3 -0
  19. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetOverlayRootView.kt +137 -0
  20. package/android/src/main/java/com/lodev09/truesheet/events/TrueSheetLifecycleEvents.kt +15 -0
  21. package/android/src/main/java/com/lodev09/truesheet/utils/KeyboardUtils.kt +15 -2
  22. package/android/src/main/java/com/lodev09/truesheet/utils/ScreenUtils.kt +0 -12
  23. package/android/src/main/java/com/lodev09/truesheet/utils/ViewUtils.kt +19 -7
  24. package/android/src/main/jni/TrueSheetSpec.h +1 -0
  25. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewComponentDescriptor.h +1 -9
  26. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewShadowNode.cpp +110 -30
  27. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewShadowNode.h +30 -7
  28. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewState.cpp +2 -2
  29. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewState.h +9 -8
  30. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetFooterViewComponentDescriptor.h +3 -5
  31. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetFooterViewShadowNode.cpp +9 -3
  32. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetFooterViewShadowNode.h +3 -6
  33. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetFooterViewState.cpp +1 -1
  34. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetFooterViewState.h +4 -6
  35. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetInsets.cpp +1 -1
  36. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetInsets.h +1 -1
  37. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetLayoutUtils.h +43 -0
  38. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetOverlayViewComponentDescriptor.h +21 -0
  39. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetOverlayViewShadowNode.cpp +17 -0
  40. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetOverlayViewShadowNode.h +33 -0
  41. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetViewComponentDescriptor.h +3 -5
  42. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetViewShadowNode.cpp +7 -40
  43. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetViewShadowNode.h +3 -7
  44. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetViewState.cpp +3 -5
  45. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetViewState.h +4 -5
  46. package/ios/TrueSheetContainerView.h +27 -7
  47. package/ios/TrueSheetContainerView.mm +31 -11
  48. package/ios/TrueSheetContentView.h +43 -15
  49. package/ios/TrueSheetContentView.mm +211 -189
  50. package/ios/TrueSheetFooterView.h +6 -0
  51. package/ios/TrueSheetFooterView.mm +93 -17
  52. package/ios/TrueSheetModule.h +2 -7
  53. package/ios/TrueSheetModule.mm +7 -16
  54. package/ios/TrueSheetOverlayView.h +32 -0
  55. package/ios/TrueSheetOverlayView.mm +161 -0
  56. package/ios/TrueSheetView.mm +275 -56
  57. package/ios/TrueSheetViewController.h +31 -3
  58. package/ios/TrueSheetViewController.mm +393 -94
  59. package/ios/core/TrueSheetBlurView.h +0 -2
  60. package/ios/core/TrueSheetBlurView.mm +3 -36
  61. package/ios/core/TrueSheetDetentCalculator.h +7 -1
  62. package/ios/core/TrueSheetDetentCalculator.mm +27 -4
  63. package/ios/core/TrueSheetGrabberView.mm +13 -3
  64. package/ios/core/TrueSheetKeyboardObserver.mm +2 -0
  65. package/ios/core/TrueSheetOverlayContainerView.h +31 -0
  66. package/ios/core/TrueSheetOverlayContainerView.mm +35 -0
  67. package/ios/events/TrueSheetLifecycleEvents.h +2 -0
  68. package/ios/events/TrueSheetLifecycleEvents.mm +8 -0
  69. package/ios/tvos/TrueSheetStubs.mm +12 -0
  70. package/ios/utils/UIView+ScrollEdgeInteraction.h +1 -0
  71. package/ios/utils/UIView+ScrollEdgeInteraction.mm +24 -2
  72. package/lib/module/TrueSheet.js +175 -39
  73. package/lib/module/TrueSheet.js.map +1 -1
  74. package/lib/module/TrueSheet.web.js +220 -243
  75. package/lib/module/TrueSheet.web.js.map +1 -1
  76. package/lib/module/TrueSheetBackground.js +15 -0
  77. package/lib/module/TrueSheetBackground.js.map +1 -0
  78. package/lib/module/TrueSheetOverlay.js +30 -0
  79. package/lib/module/TrueSheetOverlay.js.map +1 -0
  80. package/lib/module/TrueSheetOverlay.web.js +35 -0
  81. package/lib/module/TrueSheetOverlay.web.js.map +1 -0
  82. package/lib/module/TrueSheetPeek.web.js +20 -15
  83. package/lib/module/TrueSheetPeek.web.js.map +1 -1
  84. package/lib/module/detentBackgrounds.js +21 -0
  85. package/lib/module/detentBackgrounds.js.map +1 -0
  86. package/lib/module/fabric/TrueSheetContentViewNativeComponent.ts +1 -1
  87. package/lib/module/fabric/TrueSheetOverlayViewNativeComponent.ts +12 -0
  88. package/lib/module/fabric/TrueSheetViewNativeComponent.ts +15 -10
  89. package/lib/module/index.js +1 -0
  90. package/lib/module/index.js.map +1 -1
  91. package/lib/module/mocks/index.js +18 -2
  92. package/lib/module/mocks/index.js.map +1 -1
  93. package/lib/module/mocks/navigationExpoRouter.js.map +1 -1
  94. package/lib/module/navigation/base-types.js +2 -0
  95. package/lib/module/navigation/base-types.js.map +1 -0
  96. package/lib/module/navigation/createTrueSheetRouter.js +0 -9
  97. package/lib/module/navigation/createTrueSheetRouter.js.map +1 -1
  98. package/lib/module/navigation/expo-router/base-types.js +2 -0
  99. package/lib/module/navigation/expo-router/base-types.js.map +1 -0
  100. package/lib/module/navigation/expo-router/index.js +10 -7
  101. package/lib/module/navigation/expo-router/index.js.map +1 -1
  102. package/lib/module/navigation/expo-router/types.js +4 -0
  103. package/lib/module/navigation/expo-router/types.js.map +1 -0
  104. package/lib/module/navigation/index.js.map +1 -1
  105. package/lib/module/navigation/screen/useSheetScreenState.js +1 -0
  106. package/lib/module/navigation/screen/useSheetScreenState.js.map +1 -1
  107. package/lib/module/web/constants.js +1 -1
  108. package/lib/module/web/constants.js.map +1 -1
  109. package/lib/module/web/layout.js +168 -0
  110. package/lib/module/web/layout.js.map +1 -0
  111. package/lib/module/web/vaul/constants.js +9 -0
  112. package/lib/module/web/vaul/constants.js.map +1 -1
  113. package/lib/module/web/vaul/context.js.map +1 -1
  114. package/lib/module/web/vaul/helpers.js +1 -0
  115. package/lib/module/web/vaul/helpers.js.map +1 -1
  116. package/lib/module/web/vaul/index.js +47 -7
  117. package/lib/module/web/vaul/index.js.map +1 -1
  118. package/lib/module/web/vaul/use-snap-points.js +21 -10
  119. package/lib/module/web/vaul/use-snap-points.js.map +1 -1
  120. package/lib/typescript/src/TrueSheet.d.ts +6 -0
  121. package/lib/typescript/src/TrueSheet.d.ts.map +1 -1
  122. package/lib/typescript/src/TrueSheet.types.d.ts +157 -34
  123. package/lib/typescript/src/TrueSheet.types.d.ts.map +1 -1
  124. package/lib/typescript/src/TrueSheet.web.d.ts.map +1 -1
  125. package/lib/typescript/src/TrueSheetBackground.d.ts +3 -0
  126. package/lib/typescript/src/TrueSheetBackground.d.ts.map +1 -0
  127. package/lib/typescript/src/TrueSheetOverlay.d.ts +8 -0
  128. package/lib/typescript/src/TrueSheetOverlay.d.ts.map +1 -0
  129. package/lib/typescript/src/TrueSheetOverlay.web.d.ts +8 -0
  130. package/lib/typescript/src/TrueSheetOverlay.web.d.ts.map +1 -0
  131. package/lib/typescript/src/TrueSheetPeek.web.d.ts +8 -4
  132. package/lib/typescript/src/TrueSheetPeek.web.d.ts.map +1 -1
  133. package/lib/typescript/src/detentBackgrounds.d.ts +10 -0
  134. package/lib/typescript/src/detentBackgrounds.d.ts.map +1 -0
  135. package/lib/typescript/src/fabric/TrueSheetOverlayViewNativeComponent.d.ts +6 -0
  136. package/lib/typescript/src/fabric/TrueSheetOverlayViewNativeComponent.d.ts.map +1 -0
  137. package/lib/typescript/src/fabric/TrueSheetViewNativeComponent.d.ts +12 -7
  138. package/lib/typescript/src/fabric/TrueSheetViewNativeComponent.d.ts.map +1 -1
  139. package/lib/typescript/src/index.d.ts +1 -0
  140. package/lib/typescript/src/index.d.ts.map +1 -1
  141. package/lib/typescript/src/mocks/index.d.ts +4 -0
  142. package/lib/typescript/src/mocks/index.d.ts.map +1 -1
  143. package/lib/typescript/src/mocks/navigationExpoRouter.d.ts +3 -3
  144. package/lib/typescript/src/mocks/navigationExpoRouter.d.ts.map +1 -1
  145. package/lib/typescript/src/navigation/base-types.d.ts +9 -0
  146. package/lib/typescript/src/navigation/base-types.d.ts.map +1 -0
  147. package/lib/typescript/src/navigation/createTrueSheetRouter.d.ts +2 -13
  148. package/lib/typescript/src/navigation/createTrueSheetRouter.d.ts.map +1 -1
  149. package/lib/typescript/src/navigation/expo-router/base-types.d.ts +8 -0
  150. package/lib/typescript/src/navigation/expo-router/base-types.d.ts.map +1 -0
  151. package/lib/typescript/src/navigation/expo-router/index.d.ts +8 -6
  152. package/lib/typescript/src/navigation/expo-router/index.d.ts.map +1 -1
  153. package/lib/typescript/src/navigation/expo-router/types.d.ts +44 -0
  154. package/lib/typescript/src/navigation/expo-router/types.d.ts.map +1 -0
  155. package/lib/typescript/src/navigation/index.d.ts +3 -4
  156. package/lib/typescript/src/navigation/index.d.ts.map +1 -1
  157. package/lib/typescript/src/navigation/screen/useSheetScreenState.d.ts +2 -1
  158. package/lib/typescript/src/navigation/screen/useSheetScreenState.d.ts.map +1 -1
  159. package/lib/typescript/src/navigation/types.d.ts +41 -5
  160. package/lib/typescript/src/navigation/types.d.ts.map +1 -1
  161. package/lib/typescript/src/web/constants.d.ts +1 -1
  162. package/lib/typescript/src/web/constants.d.ts.map +1 -1
  163. package/lib/typescript/src/web/layout.d.ts +19 -0
  164. package/lib/typescript/src/web/layout.d.ts.map +1 -0
  165. package/lib/typescript/src/web/vaul/constants.d.ts +3 -0
  166. package/lib/typescript/src/web/vaul/constants.d.ts.map +1 -1
  167. package/lib/typescript/src/web/vaul/context.d.ts +1 -0
  168. package/lib/typescript/src/web/vaul/context.d.ts.map +1 -1
  169. package/lib/typescript/src/web/vaul/helpers.d.ts +1 -0
  170. package/lib/typescript/src/web/vaul/helpers.d.ts.map +1 -1
  171. package/lib/typescript/src/web/vaul/index.d.ts +13 -1
  172. package/lib/typescript/src/web/vaul/index.d.ts.map +1 -1
  173. package/lib/typescript/src/web/vaul/use-snap-points.d.ts +2 -1
  174. package/lib/typescript/src/web/vaul/use-snap-points.d.ts.map +1 -1
  175. package/package.json +17 -8
  176. package/react-native.config.js +1 -0
  177. package/src/TrueSheet.tsx +208 -48
  178. package/src/TrueSheet.types.ts +166 -35
  179. package/src/TrueSheet.web.tsx +272 -290
  180. package/src/TrueSheetBackground.tsx +17 -0
  181. package/src/TrueSheetOverlay.tsx +24 -0
  182. package/src/TrueSheetOverlay.web.tsx +33 -0
  183. package/src/TrueSheetPeek.web.tsx +25 -23
  184. package/src/detentBackgrounds.ts +24 -0
  185. package/src/fabric/TrueSheetContentViewNativeComponent.ts +1 -1
  186. package/src/fabric/TrueSheetOverlayViewNativeComponent.ts +12 -0
  187. package/src/fabric/TrueSheetViewNativeComponent.ts +15 -10
  188. package/src/index.ts +1 -0
  189. package/src/mocks/index.ts +17 -2
  190. package/src/mocks/navigationExpoRouter.ts +6 -5
  191. package/src/navigation/base-types.ts +20 -0
  192. package/src/navigation/createTrueSheetRouter.ts +5 -33
  193. package/src/navigation/expo-router/base-types.ts +18 -0
  194. package/src/navigation/expo-router/index.ts +28 -30
  195. package/src/navigation/expo-router/types.ts +116 -0
  196. package/src/navigation/index.ts +6 -7
  197. package/src/navigation/screen/useSheetScreenState.ts +2 -0
  198. package/src/navigation/types.ts +75 -22
  199. package/src/web/constants.ts +1 -1
  200. package/src/web/layout.ts +196 -0
  201. package/src/web/vaul/constants.ts +9 -0
  202. package/src/web/vaul/context.ts +1 -0
  203. package/src/web/vaul/helpers.ts +5 -0
  204. package/src/web/vaul/index.tsx +75 -5
  205. package/src/web/vaul/use-snap-points.ts +32 -15
@@ -1,5 +1,6 @@
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
5
6
  import android.os.Build
@@ -34,9 +35,11 @@ import com.lodev09.truesheet.core.TrueSheetDetentCalculator
34
35
  import com.lodev09.truesheet.core.TrueSheetDetentCalculatorDelegate
35
36
  import com.lodev09.truesheet.core.TrueSheetDimView
36
37
  import com.lodev09.truesheet.core.TrueSheetDimViewDelegate
38
+ import com.lodev09.truesheet.core.TrueSheetDismissThreshold
37
39
  import com.lodev09.truesheet.core.TrueSheetKeyboardObserver
38
40
  import com.lodev09.truesheet.core.TrueSheetKeyboardObserverDelegate
39
41
  import com.lodev09.truesheet.core.TrueSheetStackManager
42
+ import com.lodev09.truesheet.utils.Insets
40
43
  import com.lodev09.truesheet.utils.KeyboardUtils
41
44
  import com.lodev09.truesheet.utils.ScreenUtils
42
45
  import com.lodev09.truesheet.utils.TouchEventDeduper
@@ -54,6 +57,7 @@ interface TrueSheetViewControllerDelegate {
54
57
  fun viewControllerDidPresent(index: Int, position: Float, detent: Float)
55
58
  fun viewControllerWillDismiss()
56
59
  fun viewControllerDidDismiss(parent: TrueSheetView?)
60
+ fun viewControllerDidAttemptDismiss()
57
61
  fun viewControllerDidChangeDetent(index: Int, position: Float, detent: Float)
58
62
  fun viewControllerDidDragBegin(index: Int, position: Float, detent: Float)
59
63
  fun viewControllerDidDragChange(index: Int, position: Float, detent: Float)
@@ -78,17 +82,19 @@ interface TrueSheetViewControllerDelegate {
78
82
  * enabling touch pass-through to underlying views. Handles detent configuration, drag interactions,
79
83
  * keyboard avoidance, dimmed backgrounds, back button, and lifecycle events for stacked sheets.
80
84
  */
81
- enum class TrueSheetAnchor {
82
- LEFT,
85
+ enum class TrueSheetPlacement {
86
+ AUTOMATIC,
87
+ LEADING,
83
88
  CENTER,
84
- RIGHT;
89
+ TRAILING;
85
90
 
86
91
  companion object {
87
- fun fromString(value: String?): TrueSheetAnchor =
92
+ fun fromString(value: String?): TrueSheetPlacement =
88
93
  when (value) {
89
- "left" -> LEFT
90
- "right" -> RIGHT
91
- else -> CENTER
94
+ "leading" -> LEADING
95
+ "center" -> CENTER
96
+ "trailing" -> TRAILING
97
+ else -> AUTOMATIC
92
98
  }
93
99
  }
94
100
  }
@@ -118,10 +124,14 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
118
124
  companion object {
119
125
  private const val DEFAULT_MAX_WIDTH = 640 // dp
120
126
  private const val DEFAULT_CORNER_RADIUS = 16 // dp
121
- private const val DEFAULT_ANCHOR_OFFSET = 16 // dp
127
+ private const val DEFAULT_PLACEMENT_OFFSET = 16 // dp
122
128
  private const val TRANSLATE_ANIMATION_DURATION = 200L
123
129
  private const val DISMISS_DURATION = 200L
124
130
  private const val SCREEN_FADE_DURATION = 150L
131
+
132
+ // Mirrors BottomSheetBehavior.HIDE_THRESHOLD — how far past the lowest stop,
133
+ // as a fraction of its height, a release hides a dismissible sheet
134
+ private const val HIDE_THRESHOLD = 0.5f
125
135
  }
126
136
 
127
137
  // =============================================================================
@@ -162,6 +172,10 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
162
172
  private var pendingDetentIndex: Int = -1
163
173
 
164
174
  private var interactionState: InteractionState = InteractionState.Idle
175
+
176
+ // A content/header/footer size change arrived mid-drag — reconfiguring then
177
+ // would reset behavior state and kill the gesture, so it's applied on settle.
178
+ private var hasPendingSizeChange = false
165
179
  internal var isBeingDismissed = false
166
180
  private set
167
181
  var wasHiddenByScreen = false
@@ -175,6 +189,9 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
175
189
  // Keyboard State
176
190
  private var detentIndexBeforeKeyboard: Int = -1
177
191
  private var isKeyboardDismissProgrammatic = false
192
+
193
+ // Sheet top when a keyboard-floor drag was released; -1 once the settle target is known
194
+ private var keyboardSettleStartTop = -1
178
195
  private var focusedViewBeforeBlur: View? = null
179
196
 
180
197
  // Promises
@@ -194,12 +211,6 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
194
211
  // Touch Dispatchers
195
212
  private val jsTouchDispatcher = JSTouchDispatcher(this)
196
213
  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
214
  private val touchDeduper = TouchEventDeduper()
204
215
 
205
216
  private val eventDispatcher
@@ -208,8 +219,8 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
208
219
  // Detent Configuration
209
220
  override var maxContentHeight: Int? = null
210
221
  override var maxContentWidth: Int? = null
211
- override var anchor: TrueSheetAnchor = TrueSheetAnchor.CENTER
212
- override var anchorOffset: Int = DEFAULT_ANCHOR_OFFSET.dpToPx().toInt()
222
+ override var placement: TrueSheetPlacement = TrueSheetPlacement.AUTOMATIC
223
+ override var placementOffset: Int = DEFAULT_PLACEMENT_OFFSET.dpToPx().toInt()
213
224
  override var detents: MutableList<Double> = mutableListOf(0.5, 1.0)
214
225
 
215
226
  // Appearance Configuration
@@ -218,7 +229,13 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
218
229
  override var grabber: Boolean = true
219
230
  override var grabberOptions: GrabberOptions? = null
220
231
  override var accessibilityOptions: AccessibilityOptions? = null
232
+ var backgroundColor: Int? = null
233
+ var detentBackgrounds: List<Int?> = emptyList()
234
+ private var displayedBackgroundIndex = 0
235
+ private var backgroundAnimator: ValueAnimator? = null
236
+ private var targetBackgroundColor: Int? = null
221
237
  override var sheetBackgroundColor: Int? = null
238
+ private set
222
239
  var insetAdjustment: TrueSheetInsetAdjustment = TrueSheetInsetAdjustment.AUTOMATIC
223
240
 
224
241
  var scrollableOptions: ScrollableOptions? = null
@@ -227,6 +244,9 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
227
244
  behavior?.scrollingExpandsSheet = value?.scrollingExpandsSheet ?: true
228
245
  }
229
246
 
247
+ // React tag of the user-provided scrollable within the content (see the scrollableRef prop)
248
+ var scrollableHandle: Int = -1
249
+
230
250
  override var sheetCornerRadius: Float = DEFAULT_CORNER_RADIUS.dpToPx()
231
251
  set(value) {
232
252
  field = if (value < 0) DEFAULT_CORNER_RADIUS.dpToPx() else value
@@ -252,6 +272,12 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
252
272
  if (isPresented) sheetView?.setupGrabber()
253
273
  }
254
274
 
275
+ var dismissThreshold: TrueSheetDismissThreshold = TrueSheetDismissThreshold.HALF
276
+ set(value) {
277
+ field = value
278
+ behavior?.dismissThreshold = value
279
+ }
280
+
255
281
  // =============================================================================
256
282
  // MARK: - Computed Properties
257
283
  // =============================================================================
@@ -270,9 +296,29 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
270
296
  val screenWidth: Int
271
297
  get() = ScreenUtils.getScreenWidth(reactContext)
272
298
 
299
+ // Cached per layout/configuration pass — the WindowManager and
300
+ // rootWindowInsets queries behind these allocate, and they're read many
301
+ // times per animation frame (position emission, detent interpolation,
302
+ // stacked sheet sync)
303
+ private var cachedRealScreenHeight: Int = 0
304
+ private var cachedInsets: Insets? = null
305
+
306
+ private fun invalidateScreenMetrics() {
307
+ cachedRealScreenHeight = 0
308
+ cachedInsets = null
309
+ }
310
+
273
311
  // Includes system bars for accurate positioning
274
312
  override val realScreenHeight: Int
275
- get() = ScreenUtils.getRealScreenHeight(reactContext)
313
+ get() {
314
+ if (cachedRealScreenHeight == 0) {
315
+ cachedRealScreenHeight = ScreenUtils.getRealScreenHeight(reactContext)
316
+ }
317
+ return cachedRealScreenHeight
318
+ }
319
+
320
+ private val insets: Insets
321
+ get() = cachedInsets ?: ScreenUtils.getInsets(reactContext).also { cachedInsets = it }
276
322
 
277
323
  // Content Measurements
278
324
  // Cached values used during dismiss when container is unmounted
@@ -294,6 +340,9 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
294
340
 
295
341
  override var absoluteFooter: Boolean = false
296
342
 
343
+ override val footerInsetAdjustment: Boolean
344
+ get() = absoluteFooter && scrollableHandle > 0 && scrollableOptions?.contentInsetAdjustment?.footer != false
345
+
297
346
  override val peekContentHeight: Int
298
347
  get() = containerView?.peekContentHeight ?: cachedPeekContentHeight
299
348
 
@@ -309,16 +358,30 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
309
358
  private val isKeyboardTransitioning: Boolean
310
359
  get() = keyboardObserver?.isTransitioning ?: false
311
360
 
361
+ // Keyboard is up or animating — detents are (or are about to be) keyboard-shifted
362
+ val isKeyboardActive: Boolean
363
+ get() = keyboardInset > 0 || isKeyboardTransitioning
364
+
365
+ // While the keyboard holds the sheet up, the keyboard-free first detent is the
366
+ // collapsed stop — landing there dismisses the keyboard, and (when dismissible)
367
+ // dragging more than half its height below it hides the sheet too, like iOS.
368
+ override val hasKeyboardFloor: Boolean
369
+ get() = keyboardInset > 0
370
+
371
+ // The detent JS last heard about — the keyboard expansion is never reported
372
+ private val knownDetentIndex: Int
373
+ get() = if (detentIndexBeforeKeyboard >= 0) detentIndexBeforeKeyboard else currentDetentIndex
374
+
312
375
  fun isFocusedViewWithinSheet(): Boolean {
313
376
  val sheet = sheetView ?: return false
314
377
  return keyboardObserver?.isFocusedViewWithinSheet(sheet) ?: false
315
378
  }
316
379
 
317
380
  val bottomInset: Int
318
- get() = if (edgeToEdgeEnabled) ScreenUtils.getInsets(reactContext).bottom else 0
381
+ get() = if (edgeToEdgeEnabled) insets.bottom else 0
319
382
 
320
383
  override val topInset: Int
321
- get() = if (edgeToEdgeEnabled) ScreenUtils.getInsets(reactContext).top else 0
384
+ get() = if (edgeToEdgeEnabled) insets.top else 0
322
385
 
323
386
  override val contentBottomInset: Int
324
387
  get() = if (insetAdjustment == TrueSheetInsetAdjustment.AUTOMATIC) bottomInset else 0
@@ -331,6 +394,9 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
331
394
  }
332
395
 
333
396
  // Sheet State
397
+ val isSettling: Boolean
398
+ get() = behavior?.state == BottomSheetBehavior.STATE_SETTLING
399
+
334
400
  val isExpanded: Boolean
335
401
  get() {
336
402
  val sheetTop = sheetView?.top ?: return false
@@ -373,6 +439,9 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
373
439
 
374
440
  private fun cleanupSheet() {
375
441
  cleanupKeyboardObserver()
442
+ backgroundAnimator?.cancel()
443
+ backgroundAnimator = null
444
+ targetBackgroundColor = null
376
445
  sheetView?.animate()?.cancel()
377
446
 
378
447
  // Cleanup dim views
@@ -392,6 +461,7 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
392
461
  sheetView = null
393
462
 
394
463
  interactionState = InteractionState.Idle
464
+ hasPendingSizeChange = false
395
465
  isBeingDismissed = false
396
466
  isPresented = false
397
467
  isSheetVisible = false
@@ -440,13 +510,15 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
440
510
  // =============================================================================
441
511
 
442
512
  override fun coordinatorLayoutDidLayout(changed: Boolean) {
443
- // Reposition footer when layout changes
513
+ if (changed) invalidateScreenMetrics()
514
+
444
515
  if (isPresented && changed) {
445
- positionFooter()
516
+ setupSheetDetentsForSizeChange()
446
517
  }
447
518
  }
448
519
 
449
520
  override fun coordinatorLayoutDidChangeConfiguration() {
521
+ invalidateScreenMetrics()
450
522
  if (!isPresented) return
451
523
 
452
524
  sheetView?.updateGravity()
@@ -458,6 +530,28 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
458
530
  override fun findScrollView(): ViewGroup? = containerView?.contentView?.findScrollView()
459
531
  override fun findSheetView(): TrueSheetBottomSheetView? = sheetView
460
532
 
533
+ override fun coordinatorLayoutDidPullDown(overdragPx: Int, keyboardWasVisible: Boolean) {
534
+ if (!isPresented || dismissible || !draggable) return
535
+ val sheet = sheetView ?: return
536
+
537
+ // Only a pull clamped at the lowest stop is a blocked drag-to-dismiss — a
538
+ // child that swallowed the gesture higher up isn't
539
+ val lowestTop = detentCalculator.getLowestSheetTop()
540
+ if (sheet.top < lowestTop) return
541
+
542
+ if (keyboardWasVisible) {
543
+ // A sheet that couldn't move at all (first detent already full height) never
544
+ // began a drag, so the keyboard is still up — dismiss it here
545
+ if (hasKeyboardFloor && !isKeyboardDismissProgrammatic) commitDetentAndDismissKeyboard(0, sheet)
546
+
547
+ // Past the floor by the distance that would hide a dismissible sheet, like iOS
548
+ val floorHeight = realScreenHeight - lowestTop
549
+ if (overdragPx < floorHeight * HIDE_THRESHOLD) return
550
+ }
551
+
552
+ delegate?.viewControllerDidAttemptDismiss()
553
+ }
554
+
461
555
  // =============================================================================
462
556
  // MARK: - TrueSheetDimViewDelegate
463
557
  // =============================================================================
@@ -495,24 +589,30 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
495
589
 
496
590
  override fun bottomSheetViewDidTapGrabber() {
497
591
  val nextIndex = (currentDetentIndex + 1) % detents.size
498
- if (nextIndex == 0 && detents.size == 1 && dismissible) {
499
- dismiss(animated = true)
592
+ if (nextIndex == 0 && detents.size == 1) {
593
+ if (dismissible) {
594
+ dismiss(animated = true)
595
+ } else {
596
+ delegate?.viewControllerDidAttemptDismiss()
597
+ }
500
598
  } else {
501
- setStateForDetentIndex(nextIndex)
599
+ resize(nextIndex)
502
600
  }
503
601
  }
504
602
 
505
603
  override fun bottomSheetViewDidAccessibilityIncrement() {
506
604
  if (currentDetentIndex < detents.size - 1) {
507
- setStateForDetentIndex(currentDetentIndex + 1)
605
+ resize(currentDetentIndex + 1)
508
606
  }
509
607
  }
510
608
 
511
609
  override fun bottomSheetViewDidAccessibilityDecrement() {
512
610
  if (currentDetentIndex > 0) {
513
- setStateForDetentIndex(currentDetentIndex - 1)
611
+ resize(currentDetentIndex - 1)
514
612
  } else if (dismissible) {
515
613
  dismiss(animated = true)
614
+ } else {
615
+ delegate?.viewControllerDidAttemptDismiss()
516
616
  }
517
617
  }
518
618
 
@@ -526,7 +626,7 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
526
626
  }
527
627
 
528
628
  override fun onSlide(sheetView: View, slideOffset: Float) {
529
- handleSlide(sheetView, slideOffset)
629
+ handleSlide(sheetView)
530
630
  }
531
631
  }
532
632
 
@@ -555,27 +655,44 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
555
655
  }
556
656
  }
557
657
 
558
- private fun handleSlide(sheetView: View, slideOffset: Float) {
658
+ private fun handleSlide(sheetView: View) {
559
659
  // Skip during dismiss animation
560
660
  if (isBeingDismissed) return
561
661
 
562
662
  val behavior = behavior ?: return
563
663
 
664
+ if (interactionState is InteractionState.Dragging || pendingDetentIndex >= 0) {
665
+ updateStateDimensionsForPosition(sheetView.top)
666
+ }
667
+
564
668
  when (behavior.state) {
565
- BottomSheetBehavior.STATE_DRAGGING,
566
- BottomSheetBehavior.STATE_SETTLING -> handleDragChange(sheetView)
669
+ BottomSheetBehavior.STATE_DRAGGING -> handleDragChange(sheetView)
670
+
671
+ BottomSheetBehavior.STATE_SETTLING -> {
672
+ handleDragChange(sheetView)
673
+ dismissKeyboardForSettleTarget(sheetView)
674
+ }
567
675
 
568
676
  else -> { }
569
677
  }
570
678
 
571
679
  emitChangePositionDelegate(sheetView.top)
572
680
 
681
+ // Drive the parent's stack translation from our actual frame position —
682
+ // an independently-timed animation lags behind (e.g. auto detent shrink)
683
+ // and reveals the parent above this sheet's top edge. Skipped while
684
+ // dragging so the parent stays put during drag-to-dismiss, and while the
685
+ // keyboard is active so growing for it doesn't move the parent (like iOS).
686
+ if (!isPresentAnimating && interactionState !is InteractionState.Dragging && !isKeyboardActive) {
687
+ syncParentTranslation(sheetView.top)
688
+ }
689
+
573
690
  // Position on every slide frame — during keyboard transitions the IME
574
691
  // animation callbacks are sparser than the sheet's settle frames, and the
575
692
  // footer rides the sheet between them, jittering against the keyboard.
576
693
  // positionFooter derives from current state, so double-driving it with
577
694
  // keyboardDidChangeHeight is harmless.
578
- positionFooter(slideOffset)
695
+ positionFooter()
579
696
 
580
697
  if (!isKeyboardTransitioning) {
581
698
  updateDimAmount(sheetView.top)
@@ -585,11 +702,22 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
585
702
  private fun handleStateSettled(sheetView: View, newState: Int) {
586
703
  if (interactionState is InteractionState.Reconfiguring) return
587
704
 
705
+ // A release already at a stop settles without a slide frame
706
+ keyboardSettleStartTop = -1
707
+
588
708
  // Sheet has reached a stable state — any in-flight resize target is now stale,
589
709
  // whether settled at the target, interrupted by drag, or superseded.
590
710
  pendingDetentIndex = -1
591
711
 
592
- val index = detentCalculator.getDetentIndexForState(newState) ?: return
712
+ val index = detentCalculator.getDetentIndexForState(newState) ?: run {
713
+ // Unmapped settle — end any drag anyway so deferred size changes aren't
714
+ // dropped forever (a leaked Dragging state skips every reconfigure)
715
+ if (interactionState is InteractionState.Dragging) {
716
+ interactionState = InteractionState.Idle
717
+ }
718
+ flushPendingSizeChange()
719
+ return
720
+ }
593
721
  val position = getPositionDpForView(sheetView)
594
722
  val detentInfo = DetentInfo(index, position)
595
723
 
@@ -597,21 +725,30 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
597
725
  if (isPresentAnimating) {
598
726
  isPresentAnimating = false
599
727
  finishPresent()
728
+ flushPendingSizeChange()
600
729
  return
601
730
  }
602
731
 
732
+ val wasDragging = interactionState is InteractionState.Dragging
733
+
603
734
  when (interactionState) {
604
735
  is InteractionState.Dragging -> {
605
736
  val detent = detentCalculator.getDetentValueForIndex(detentInfo.index)
606
737
  delegate?.viewControllerDidDragEnd(detentInfo.index, detentInfo.position, detent)
607
738
 
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)
739
+ // Skip detent change if keyboard inset is still active — the keyboard floor
740
+ // landing below commits the detent instead.
741
+ if (keyboardInset == 0) {
742
+ val changed = detentInfo.index != knownDetentIndex
743
+ detentIndexBeforeKeyboard = -1
744
+ if (detentInfo.index != currentDetentIndex) {
745
+ currentDetentIndex = detentInfo.index
746
+ setupDimmedBackground()
747
+ }
748
+ if (changed) {
749
+ delegate?.viewControllerDidChangeDetent(detentInfo.index, detentInfo.position, detent)
750
+ this@TrueSheetViewController.sheetView?.updateGrabberAccessibilityValue(detentInfo.index, detents.size)
751
+ }
615
752
  }
616
753
 
617
754
  interactionState = InteractionState.Idle
@@ -629,14 +766,28 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
629
766
  }
630
767
  }
631
768
 
632
- // Settled — resize the container to the settled detent (applies deferred shrinks)
633
- updateStateDimensions()
769
+ if (hasKeyboardFloor && wasDragging && newState != BottomSheetBehavior.STATE_EXPANDED) {
770
+ // Dragged down onto a keyboard-free stop (the floor or the keyboard detent) —
771
+ // dismiss the keyboard and commit that detent. keyboardWillHide reconfigures
772
+ // the detents (and the container) to it.
773
+ commitDetentAndDismissKeyboard(index, sheetView)
774
+ } else {
775
+ updateStateDimensions()
776
+ }
634
777
 
635
778
  if (!isKeyboardTransitioning) {
636
779
  updateDimAmount(animated = true)
637
780
  }
638
781
 
639
782
  TrueSheetStackManager.updateBackgroundAccessibility()
783
+
784
+ flushPendingSizeChange()
785
+ }
786
+
787
+ private fun flushPendingSizeChange() {
788
+ if (!hasPendingSizeChange) return
789
+ hasPendingSizeChange = false
790
+ setupSheetDetentsForSizeChange()
640
791
  }
641
792
 
642
793
  // =============================================================================
@@ -698,6 +849,7 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
698
849
  val sheet = this.sheetView ?: return
699
850
  if (isPresented) return
700
851
 
852
+ invalidateScreenMetrics()
701
853
  shouldAnimatePresent = animated
702
854
  currentDetentIndex = detentIndex
703
855
  interactionState = InteractionState.Idle
@@ -711,6 +863,8 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
711
863
  setupDimmedBackground()
712
864
  setupKeyboardObserver()
713
865
 
866
+ displayedBackgroundIndex = detentIndex
867
+ setupBackground(animated = false)
714
868
  sheet.setupBackground()
715
869
  sheet.setupElevation()
716
870
  sheet.setupGrabber()
@@ -735,13 +889,18 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
735
889
  fun resize(detentIndex: Int) {
736
890
  if (!isPresented) return
737
891
 
738
- // Commit the new index so keyboardWillHide restores to it instead of the stale one
892
+ // Commit the new index so keyboardWillHide restores to it instead of the stale
893
+ // one; the sheet keeps avoiding the keyboard meanwhile
739
894
  if (detentIndexBeforeKeyboard >= 0) {
740
895
  detentIndexBeforeKeyboard = detentIndex
896
+ if (hasKeyboardFloor) {
897
+ resizePromise?.invoke()
898
+ resizePromise = null
899
+ return
900
+ }
741
901
  }
742
902
 
743
903
  pendingDetentIndex = detentIndex
744
- updateStateDimensions(deferShrink = true)
745
904
  setupDimmedBackground()
746
905
  setStateForDetentIndex(detentIndex)
747
906
  resizePromise?.invoke()
@@ -763,6 +922,8 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
763
922
  behavior.isHideable = true
764
923
  behavior.isDraggable = draggable
765
924
  behavior.scrollingExpandsSheet = scrollableOptions?.scrollingExpandsSheet ?: true
925
+ behavior.dismissThreshold = dismissThreshold
926
+ behavior.lowestSheetTop = { detentCalculator.getLowestSheetTop() }
766
927
  behavior.state = BottomSheetBehavior.STATE_HIDDEN
767
928
  behavior.addBottomSheetCallback(sheetCallback)
768
929
 
@@ -866,11 +1027,15 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
866
1027
 
867
1028
  val maxAvailableHeight = realScreenHeight - topInset
868
1029
 
869
- val peekHeight = minOf(detentCalculator.getDetentHeight(detents[0]), maxAvailableHeight)
1030
+ // With a keyboard floor only the last detent stays keyboard-shifted; the
1031
+ // collapsed and half stops are the keyboard-free first and second detents,
1032
+ // so a short drag down from the keyboard-expanded sheet rests at the second
1033
+ // and a long one at the first, like iOS
1034
+ val peekHeight = minOf(detentCalculator.getDetentHeight(detents[0], includeKeyboard = !hasKeyboardFloor), maxAvailableHeight)
870
1035
 
871
1036
  val halfExpandedDetentHeight = when (detents.size) {
872
1037
  1 -> peekHeight
873
- else -> detentCalculator.getDetentHeight(detents[1])
1038
+ else -> detentCalculator.getDetentHeight(detents[1], includeKeyboard = !hasKeyboardFloor)
874
1039
  }
875
1040
 
876
1041
  val maxDetentHeight = minOf(detentCalculator.getDetentHeight(detents.last()), maxAvailableHeight)
@@ -880,8 +1045,9 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
880
1045
 
881
1046
  val expandedOffset = realScreenHeight - maxDetentHeight
882
1047
 
883
- // fitToContents works better with <= 2 detents when no expanded offset
884
- val fitToContents = detents.size < 3 && expandedOffset == 0
1048
+ // fitToContents works better with <= 2 detents when no expanded offset, but
1049
+ // it drops the half stop the keyboard floor relies on
1050
+ val fitToContents = !hasKeyboardFloor && detents.size < 3 && expandedOffset == 0
885
1051
 
886
1052
  configureDetents(
887
1053
  behavior = behavior,
@@ -892,13 +1058,25 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
892
1058
  animate = isPresented
893
1059
  )
894
1060
 
895
- updateStateDimensions()
896
-
897
1061
  if (isPresented && applyState) {
898
1062
  // Prefer the pending target while a resize animation is in flight so a
899
1063
  // layout-driven reconfigure doesn't revert to the stale currentDetentIndex.
900
1064
  val targetIndex = if (pendingDetentIndex >= 0) pendingDetentIndex else currentDetentIndex
1065
+
1066
+ // Track the settle like resize() — the container (and the footer pinned
1067
+ // to its bottom) follows the sheet's visible height instead of jumping
1068
+ // to the target and riding the animation
1069
+ pendingDetentIndex = targetIndex
1070
+ sheetView?.let { updateStateDimensionsForPosition(it.top) }
901
1071
  setStateForDetentIndex(targetIndex)
1072
+
1073
+ // Already at the target — no slide frames will follow
1074
+ if (!isSettling) {
1075
+ pendingDetentIndex = -1
1076
+ updateStateDimensions()
1077
+ }
1078
+ } else {
1079
+ updateStateDimensions()
902
1080
  }
903
1081
 
904
1082
  interactionState = InteractionState.Idle
@@ -922,10 +1100,13 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
922
1100
  }
923
1101
 
924
1102
  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
1103
+ // Reconfiguring while the container tracks the sheet can interrupt its
1104
+ // motion. Apply content size changes once the drag or resize settles.
1105
+ if (interactionState is InteractionState.Dragging || isSettling) {
1106
+ hasPendingSizeChange = true
1107
+ return
1108
+ }
1109
+ hasPendingSizeChange = false
929
1110
 
930
1111
  setupSheetDetents()
931
1112
  positionFooter()
@@ -939,6 +1120,30 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
939
1120
  // MARK: - Dimmed Background
940
1121
  // =============================================================================
941
1122
 
1123
+ fun setupBackground(animated: Boolean = true) {
1124
+ val sheet = sheetView ?: return
1125
+ val color = detentBackgrounds.getOrNull(displayedBackgroundIndex)
1126
+ ?: backgroundColor ?: sheet.getDefaultBackgroundColor()
1127
+ if (animated && color == targetBackgroundColor) return
1128
+
1129
+ backgroundAnimator?.cancel()
1130
+ targetBackgroundColor = color
1131
+ val startColor = sheetBackgroundColor ?: color
1132
+ if (!animated || !isPresented || startColor == color) {
1133
+ sheetBackgroundColor = color
1134
+ sheet.updateBackgroundColor(color)
1135
+ return
1136
+ }
1137
+ backgroundAnimator = ValueAnimator.ofArgb(startColor, color).apply {
1138
+ duration = 200L
1139
+ addUpdateListener {
1140
+ sheetBackgroundColor = it.animatedValue as Int
1141
+ sheet.updateBackgroundColor(it.animatedValue as Int)
1142
+ }
1143
+ start()
1144
+ }
1145
+ }
1146
+
942
1147
  fun setupDimmedBackground() {
943
1148
  val coordinator = this.coordinatorLayout ?: run {
944
1149
  RNLog.e(reactContext, "TrueSheet: coordinatorLayout is null in setupDimmedBackground")
@@ -978,10 +1183,9 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
978
1183
 
979
1184
  fun updateDimAmount(sheetTop: Int? = null, animated: Boolean = false) {
980
1185
  if (!dimmed) return
981
- if (contentHeight == 0) return
982
1186
 
983
1187
  // While keyboard is active or transitioning, use the target detent position for dim
984
- val top = if (keyboardInset > 0 || isKeyboardTransitioning) {
1188
+ val top = if (isKeyboardActive) {
985
1189
  detentCalculator.getSheetTopForDetentIndex(currentDetentIndex)
986
1190
  } else {
987
1191
  val keyboardOffset = if (isBeingDismissed) 0 else currentKeyboardInset
@@ -1006,13 +1210,17 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
1006
1210
 
1007
1211
  var footerKeyboardOffset: Int = 0
1008
1212
 
1009
- fun positionFooter(slideOffset: Float? = null) {
1213
+ /**
1214
+ * false keeps an absolute footer pinned to the sheet's bottom edge behind
1215
+ * the keyboard instead of rising above it.
1216
+ */
1217
+ override var footerAvoidsKeyboard: Boolean = true
1218
+
1219
+ fun positionFooter() {
1010
1220
  if (!isPresented) return
1011
1221
  val footerView = containerView?.footerView ?: return
1012
1222
  val sheet = sheetView ?: return
1013
1223
 
1014
- val keyboardShift = if (currentKeyboardInset > 0) maxOf(0, currentKeyboardInset + footerKeyboardOffset) else 0
1015
-
1016
1224
  // A relative footer is laid out by Yoga (pinned to the container's bottom
1017
1225
  // edge) and stays in the layout flow behind the keyboard — only an
1018
1226
  // absolute footer floats above it
@@ -1021,24 +1229,31 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
1021
1229
  return
1022
1230
  }
1023
1231
 
1232
+ // The footer's absorbed safe-area inset rides below the keyboard's top
1233
+ // edge — its content stays flush with the keyboard without dropping the
1234
+ // inset padding from the layout, which would jump at the transition edges
1235
+ val keyboardShift = if (currentKeyboardInset > 0 && footerAvoidsKeyboard) {
1236
+ maxOf(0, currentKeyboardInset + footerKeyboardOffset - footerView.bottomInset)
1237
+ } else {
1238
+ 0
1239
+ }
1240
+
1024
1241
  val footerHeight = footerView.height
1025
1242
  val sheetHeight = sheet.height
1026
- val sheetTop = sheet.top
1027
-
1028
- var footerY = (sheetHeight - sheetTop - footerHeight - keyboardShift).toFloat()
1029
1243
 
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
- }
1244
+ // Pinned to the screen bottom (above the keyboard) while the sheet sits at
1245
+ // or above its lowest stop; dragged past it the footer rides down with the
1246
+ // sheet instead, like iOS. A resize settle always pins — the sheet may be
1247
+ // animating up from below a stop that just grew
1248
+ val anchorTop = if (pendingDetentIndex >= 0) sheet.top else minOf(sheet.top, detentCalculator.getLowestSheetTop())
1249
+ val footerY = (sheetHeight - anchorTop - footerHeight - keyboardShift).toFloat()
1038
1250
 
1039
1251
  // Clamp to prevent footer going above safe area
1040
1252
  val maxAllowedY = (sheetHeight - topInset - footerHeight).toFloat()
1041
1253
  footerView.y = minOf(footerY, maxAllowedY)
1254
+ if (!footerAvoidsKeyboard) {
1255
+ containerView?.contentView?.updateContentInset()
1256
+ }
1042
1257
  }
1043
1258
 
1044
1259
  // =============================================================================
@@ -1064,7 +1279,7 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
1064
1279
  if (!viewToFocus.isAttachedToWindow) return
1065
1280
  if (viewToFocus.requestFocus()) {
1066
1281
  viewToFocus.postDelayed({
1067
- KeyboardUtils.show(viewToFocus)
1282
+ if (viewToFocus.isFocused) KeyboardUtils.show(viewToFocus)
1068
1283
  }, 100)
1069
1284
  }
1070
1285
  }
@@ -1079,9 +1294,6 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
1079
1294
  delegate = object : TrueSheetKeyboardObserverDelegate {
1080
1295
  override fun keyboardWillShow(height: Int) {
1081
1296
  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
1297
  // If a resize is in flight, restore to its target — not the stale current
1086
1298
  detentIndexBeforeKeyboard = if (pendingDetentIndex >= 0) pendingDetentIndex else currentDetentIndex
1087
1299
  pendingDetentIndex = -1
@@ -1094,17 +1306,22 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
1094
1306
 
1095
1307
  override fun keyboardWillHide() {
1096
1308
  if (!shouldHandleKeyboard(checkFocus = false)) return
1097
- containerView?.footerView?.keyboardVisible = false
1309
+ // Mid-drag (the drag itself dismissed the keyboard) the release decides
1310
+ // the detent and a reconfigure would kill the gesture — the floor stops
1311
+ // stay put until the settle flushes the pending reconfigure
1312
+ if (interactionState is InteractionState.Dragging) {
1313
+ hasPendingSizeChange = true
1314
+ return
1315
+ }
1098
1316
  val restoring = !isBeingDismissed && detentIndexBeforeKeyboard >= 0
1099
1317
 
1100
1318
  // Skip reconfigure during interactive keyboard dismiss (e.g. keyboardDismissMode="on-drag")
1101
1319
  // to prevent the sheet from jumping. keyboardDidHide will reconfigure after.
1102
1320
  if (restoring || isKeyboardDismissProgrammatic) {
1103
- setupSheetDetents()
1104
1321
  if (restoring) {
1105
1322
  currentDetentIndex = detentIndexBeforeKeyboard
1106
- setStateForDetentIndex(currentDetentIndex)
1107
1323
  }
1324
+ setupSheetDetents()
1108
1325
  }
1109
1326
 
1110
1327
  // Same-frame reposition — the reconfigure above shrinks the container
@@ -1119,9 +1336,13 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
1119
1336
 
1120
1337
  override fun keyboardDidHide() {
1121
1338
  if (!shouldHandleKeyboard(checkFocus = false)) return
1122
- containerView?.footerView?.keyboardVisible = false
1123
- detentIndexBeforeKeyboard = -1
1124
1339
  isKeyboardDismissProgrammatic = false
1340
+ // Keep detentIndexBeforeKeyboard for the settle's detent-change check
1341
+ if (interactionState is InteractionState.Dragging) {
1342
+ hasPendingSizeChange = true
1343
+ return
1344
+ }
1345
+ detentIndexBeforeKeyboard = -1
1125
1346
  setupSheetDetents(applyState = false)
1126
1347
  positionFooter()
1127
1348
  updateDimAmount(
@@ -1141,7 +1362,6 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
1141
1362
  // Handle case where keyboard is already visible and focus moves into the sheet
1142
1363
  if (!shouldHandleKeyboard()) return
1143
1364
  if (detentIndexBeforeKeyboard < 0 && (keyboardObserver?.currentHeight ?: 0) > 0) {
1144
- if (absoluteFooter) containerView?.footerView?.keyboardVisible = true
1145
1365
  detentIndexBeforeKeyboard = currentDetentIndex
1146
1366
  currentDetentIndex = detents.size - 1
1147
1367
  setupSheetDetents()
@@ -1169,8 +1389,28 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
1169
1389
  detentIndexBeforeKeyboard = -1
1170
1390
  }
1171
1391
 
1392
+ private fun handleSettling(sheetView: View) {
1393
+ // A keyboard-floor drag was released — the first settle frame reveals the target
1394
+ keyboardSettleStartTop = if (hasKeyboardFloor && interactionState is InteractionState.Dragging) sheetView.top else -1
1395
+ }
1396
+
1397
+ /**
1398
+ * Dismisses the keyboard as soon as a released drag is known to land on a
1399
+ * keyboard-free stop, rather than waiting for the landing. BottomSheetBehavior
1400
+ * keeps its target private, so read it off the first settle frame: heading
1401
+ * down means the half stop or the floor; heading up from below the half stop
1402
+ * means the half stop; only heading up from above it means the expanded stop.
1403
+ */
1404
+ private fun dismissKeyboardForSettleTarget(sheetView: View) {
1405
+ if (keyboardSettleStartTop < 0) return
1406
+ val startTop = keyboardSettleStartTop
1407
+ keyboardSettleStartTop = -1
1408
+
1409
+ val headingToKeyboardFreeStop = sheetView.top > startTop || startTop > detentCalculator.getKeyboardHalfStopTop()
1410
+ if (headingToKeyboardFreeStop && shouldHandleKeyboard()) dismissKeyboard()
1411
+ }
1412
+
1172
1413
  private fun handleDragBegin(sheetView: View) {
1173
- detentIndexBeforeKeyboard = -1
1174
1414
  pendingDetentIndex = -1
1175
1415
 
1176
1416
  val position = getPositionDpForView(sheetView)
@@ -1179,43 +1419,25 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
1179
1419
  interactionState = InteractionState.Dragging(startTop = sheetView.top)
1180
1420
  }
1181
1421
 
1182
- private fun handleSettling(sheetView: View) {
1183
- if (interactionState !is InteractionState.Dragging) return
1184
- if (keyboardInset <= 0) return
1422
+ // Commits a non-keyboard detent and hides the keyboard; keyboardWillHide then
1423
+ // reconfigures the detents so the sheet settles at the committed index.
1424
+ private fun commitDetentAndDismissKeyboard(index: Int, sheetView: View) {
1425
+ val changed = index != knownDetentIndex
1426
+ detentIndexBeforeKeyboard = -1
1427
+ currentDetentIndex = index
1428
+ dismissKeyboard()
1429
+ setupDimmedBackground()
1185
1430
 
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
- }
1431
+ if (!changed) return
1432
+ val position = getPositionDpForView(sheetView)
1433
+ val detent = detentCalculator.getDetentValueForIndex(index)
1434
+ delegate?.viewControllerDidChangeDetent(index, position, detent)
1435
+ this.sheetView?.updateGrabberAccessibilityValue(index, detents.size)
1212
1436
  }
1213
1437
 
1214
1438
  private fun handleDragChange(sheetView: View) {
1215
1439
  if (interactionState !is InteractionState.Dragging) return
1216
1440
 
1217
- updateStateDimensionsForDrag(sheetView.top)
1218
-
1219
1441
  val position = getPositionDpForView(sheetView)
1220
1442
  val detent = detentCalculator.getDetentValueForIndex(currentDetentIndex)
1221
1443
  delegate?.viewControllerDidDragChange(currentDetentIndex, position, detent)
@@ -1250,14 +1472,20 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
1250
1472
  }
1251
1473
 
1252
1474
  private fun emitChangePositionDelegate(currentTop: Int, realtime: Boolean = true) {
1475
+ if (isPresented && !isPresentAnimating && !isBeingDismissed) {
1476
+ val index = detentCalculator.getBackgroundIndex(currentTop, displayedBackgroundIndex)
1477
+ if (index != displayedBackgroundIndex) {
1478
+ displayedBackgroundIndex = index
1479
+ setupBackground()
1480
+ }
1481
+ }
1253
1482
  // Dedupe emissions for same position
1254
1483
  if (currentTop == lastEmittedPositionPx) return
1255
1484
 
1256
1485
  lastEmittedPositionPx = currentTop
1257
1486
  val visibleHeight = realScreenHeight - currentTop
1258
1487
  val position = detentCalculator.getPositionDp(visibleHeight)
1259
- val interpolatedIndex = detentCalculator.getInterpolatedIndexForPosition(currentTop)
1260
- val detent = detentCalculator.getInterpolatedDetentForPosition(currentTop)
1488
+ val (interpolatedIndex, detent) = detentCalculator.getInterpolatedPositionInfo(currentTop)
1261
1489
  delegate?.viewControllerDidChangePosition(interpolatedIndex, position, detent, realtime)
1262
1490
  }
1263
1491
 
@@ -1265,9 +1493,9 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
1265
1493
  * Updates position emission, footer, and dim amount together.
1266
1494
  * This pattern is commonly used during animations and state changes.
1267
1495
  */
1268
- private fun updateSheetVisuals(effectiveTop: Int, slideOffset: Float? = null) {
1496
+ private fun updateSheetVisuals(effectiveTop: Int) {
1269
1497
  emitChangePositionDelegate(effectiveTop)
1270
- positionFooter(slideOffset)
1498
+ positionFooter()
1271
1499
  updateDimAmount(effectiveTop)
1272
1500
  }
1273
1501
 
@@ -1287,13 +1515,8 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
1287
1515
  /**
1288
1516
  * Updates the Fabric state with the current/target detent size so Yoga sizes
1289
1517
  * 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
1518
  */
1296
- private fun updateStateDimensions(deferShrink: Boolean = false) {
1519
+ private fun updateStateDimensions() {
1297
1520
  if (detents.isEmpty()) return
1298
1521
 
1299
1522
  val targetIndex = (if (pendingDetentIndex >= 0) pendingDetentIndex else currentDetentIndex)
@@ -1302,21 +1525,21 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
1302
1525
  val newHeight = minOf(detentCalculator.getDetentHeight(detents[targetIndex]), maxAvailableHeight)
1303
1526
  val newWidth = getStateWidth()
1304
1527
 
1305
- if (deferShrink && newWidth == lastStateWidth && newHeight < lastStateHeight) return
1306
-
1307
1528
  setStateDimensions(newWidth, newHeight)
1308
1529
  }
1309
1530
 
1310
1531
  /**
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
1532
+ * Tracks the sheet's visible height during drags and resize animations. Clamped to the
1313
1533
  * smallest detent — dragging below it slides the sheet out without resizing.
1314
1534
  */
1315
- private fun updateStateDimensionsForDrag(sheetTop: Int) {
1535
+ private fun updateStateDimensionsForPosition(sheetTop: Int) {
1316
1536
  if (detents.isEmpty()) return
1317
1537
 
1318
1538
  val maxAvailableHeight = realScreenHeight - topInset
1319
- val minHeight = minOf(detentCalculator.getDetentHeight(detents.first()), maxAvailableHeight)
1539
+ val minHeight = minOf(
1540
+ detentCalculator.getDetentHeight(detents.first(), includeKeyboard = !hasKeyboardFloor),
1541
+ maxAvailableHeight
1542
+ )
1320
1543
  val newHeight = detentCalculator.getVisibleSheetHeight(sheetTop).coerceIn(minHeight, maxAvailableHeight)
1321
1544
 
1322
1545
  setStateDimensions(getStateWidth(), newHeight)
@@ -1336,9 +1559,27 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
1336
1559
  delegate?.viewControllerDidChangeSize(width, height)
1337
1560
  }
1338
1561
 
1339
- fun translateSheet(translationY: Int, onEnd: (() -> Unit)? = null) {
1562
+ /**
1563
+ * Realtime (non-animated) counterpart of TrueSheetStackManager.updateParentTranslation,
1564
+ * driven by onSlide so the parent tracks this sheet's actual position.
1565
+ */
1566
+ private fun syncParentTranslation(sheetTop: Int) {
1567
+ val parent = parentSheetView ?: return
1568
+ val minSheetTop = detentCalculator.getSheetTopForDetentIndex(0)
1569
+ parent.syncTranslationForChild(sheetTop, minSheetTop)
1570
+ }
1571
+
1572
+ fun translateSheet(translationY: Int, animated: Boolean = true, onEnd: (() -> Unit)? = null) {
1340
1573
  val sheet = sheetView ?: return
1341
1574
 
1575
+ if (!animated) {
1576
+ sheet.animate().cancel()
1577
+ sheet.translationY = translationY.toFloat()
1578
+ emitChangePositionDelegate(sheet.top + translationY)
1579
+ onEnd?.invoke()
1580
+ return
1581
+ }
1582
+
1342
1583
  sheet.animate()
1343
1584
  .translationY(translationY.toFloat())
1344
1585
  .setDuration(TRANSLATE_ANIMATION_DURATION)
@@ -1385,41 +1626,6 @@ class TrueSheetViewController(private val reactContext: ThemedReactContext) :
1385
1626
  // MARK: - RootView Touch Handling
1386
1627
  // =============================================================================
1387
1628
 
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
1629
  override fun onInterceptTouchEvent(event: MotionEvent): Boolean {
1424
1630
  eventDispatcher?.let {
1425
1631
  if (touchDeduper.shouldDispatch(event)) {