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

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