@lodev09/react-native-true-sheet 4.0.0-beta.0 → 4.0.0-beta.10

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 (430) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +62 -30
  3. package/RNTrueSheet.podspec +10 -2
  4. package/android/build.gradle +24 -2
  5. package/android/src/main/java/com/lodev09/truesheet/TrueSheetContainerView.kt +189 -47
  6. package/android/src/main/java/com/lodev09/truesheet/TrueSheetContainerViewManager.kt +7 -0
  7. package/android/src/main/java/com/lodev09/truesheet/TrueSheetContentView.kt +401 -19
  8. package/android/src/main/java/com/lodev09/truesheet/TrueSheetContentViewManager.kt +18 -0
  9. package/android/src/main/java/com/lodev09/truesheet/TrueSheetFooterView.kt +145 -21
  10. package/android/src/main/java/com/lodev09/truesheet/TrueSheetFooterViewManager.kt +14 -0
  11. package/android/src/main/java/com/lodev09/truesheet/TrueSheetHeaderView.kt +38 -0
  12. package/android/src/main/java/com/lodev09/truesheet/TrueSheetHeaderViewManager.kt +28 -0
  13. package/android/src/main/java/com/lodev09/truesheet/TrueSheetModule.kt +84 -24
  14. package/android/src/main/java/com/lodev09/truesheet/TrueSheetOverlayView.kt +106 -0
  15. package/android/src/main/java/com/lodev09/truesheet/TrueSheetOverlayViewManager.kt +45 -0
  16. package/android/src/main/java/com/lodev09/truesheet/TrueSheetPackage.kt +6 -4
  17. package/android/src/main/java/com/lodev09/truesheet/TrueSheetPeekView.kt +72 -0
  18. package/android/src/main/java/com/lodev09/truesheet/TrueSheetPeekViewManager.kt +33 -0
  19. package/android/src/main/java/com/lodev09/truesheet/TrueSheetStateUpdater.kt +63 -0
  20. package/android/src/main/java/com/lodev09/truesheet/TrueSheetView.kt +564 -226
  21. package/android/src/main/java/com/lodev09/truesheet/TrueSheetViewController.kt +1399 -598
  22. package/android/src/main/java/com/lodev09/truesheet/TrueSheetViewManager.kt +161 -29
  23. package/android/src/main/java/com/lodev09/truesheet/core/RNScreensEventObserver.kt +63 -0
  24. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetBottomSheetBehavior.kt +36 -0
  25. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetBottomSheetView.kt +255 -0
  26. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetCoordinatorLayout.kt +375 -0
  27. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetDetentCalculator.kt +299 -0
  28. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetDimView.kt +162 -0
  29. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetGrabberView.kt +180 -0
  30. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetKeyboardObserver.kt +207 -0
  31. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetOverlayRootView.kt +137 -0
  32. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetStackManager.kt +220 -0
  33. package/android/src/main/java/com/lodev09/truesheet/events/TrueSheetDragEvents.kt +71 -0
  34. package/android/src/main/java/com/lodev09/truesheet/events/TrueSheetFocusEvents.kt +65 -0
  35. package/android/src/main/java/com/lodev09/truesheet/events/TrueSheetLifecycleEvents.kt +127 -0
  36. package/android/src/main/java/com/lodev09/truesheet/events/TrueSheetStateEvents.kt +56 -0
  37. package/android/src/main/java/com/lodev09/truesheet/utils/KeyboardUtils.kt +84 -0
  38. package/android/src/main/java/com/lodev09/truesheet/utils/ScreenUtils.kt +93 -67
  39. package/android/src/main/java/com/lodev09/truesheet/utils/TouchEventDeduper.kt +31 -0
  40. package/android/src/main/java/com/lodev09/truesheet/utils/ViewUtils.kt +42 -0
  41. package/android/src/main/jni/CMakeLists.txt +63 -0
  42. package/android/src/main/jni/TrueSheetSpec.h +20 -0
  43. package/android/src/main/jni/TrueSheetStateUpdater.cpp +96 -0
  44. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewComponentDescriptor.h +16 -0
  45. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewShadowNode.cpp +68 -0
  46. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewShadowNode.h +33 -0
  47. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewState.cpp +11 -0
  48. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewState.h +39 -0
  49. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetFooterViewComponentDescriptor.h +24 -0
  50. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetFooterViewShadowNode.cpp +63 -0
  51. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetFooterViewShadowNode.h +28 -0
  52. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetFooterViewState.cpp +11 -0
  53. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetFooterViewState.h +41 -0
  54. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetInsets.cpp +18 -0
  55. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetInsets.h +16 -0
  56. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetLayoutUtils.h +46 -0
  57. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetOverlayViewComponentDescriptor.h +21 -0
  58. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetOverlayViewShadowNode.cpp +17 -0
  59. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetOverlayViewShadowNode.h +36 -0
  60. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetViewComponentDescriptor.h +28 -0
  61. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetViewShadowNode.cpp +30 -0
  62. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetViewShadowNode.h +45 -0
  63. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetViewState.cpp +23 -0
  64. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetViewState.h +52 -0
  65. package/ios/TrueSheetContainerView.h +88 -6
  66. package/ios/TrueSheetContainerView.mm +278 -21
  67. package/ios/TrueSheetContentView.h +54 -1
  68. package/ios/TrueSheetContentView.mm +371 -47
  69. package/ios/TrueSheetFooterView.h +33 -2
  70. package/ios/TrueSheetFooterView.mm +187 -35
  71. package/ios/{events/OnWillDismissEvent.h → TrueSheetHeaderView.h} +10 -7
  72. package/ios/TrueSheetHeaderView.mm +64 -0
  73. package/ios/TrueSheetModule.h +2 -7
  74. package/ios/TrueSheetModule.mm +98 -20
  75. package/ios/TrueSheetOverlayView.h +32 -0
  76. package/ios/TrueSheetOverlayView.mm +161 -0
  77. package/ios/TrueSheetPeekView.h +32 -0
  78. package/ios/TrueSheetPeekView.mm +99 -0
  79. package/ios/TrueSheetView.h +7 -15
  80. package/ios/TrueSheetView.mm +775 -215
  81. package/ios/TrueSheetViewController.h +94 -16
  82. package/ios/TrueSheetViewController.mm +1316 -331
  83. package/ios/core/RNScreensEventObserver.h +46 -0
  84. package/ios/core/RNScreensEventObserver.mm +280 -0
  85. package/ios/core/TrueSheetBlurView.h +27 -0
  86. package/ios/core/TrueSheetBlurView.mm +82 -0
  87. package/ios/core/TrueSheetDetentCalculator.h +107 -0
  88. package/ios/core/TrueSheetDetentCalculator.mm +197 -0
  89. package/ios/core/TrueSheetGrabberView.h +72 -0
  90. package/ios/core/TrueSheetGrabberView.mm +188 -0
  91. package/ios/core/TrueSheetKeyboardObserver.h +39 -0
  92. package/ios/core/TrueSheetKeyboardObserver.mm +112 -0
  93. package/ios/core/TrueSheetOverlayContainerView.h +31 -0
  94. package/ios/core/TrueSheetOverlayContainerView.mm +35 -0
  95. package/ios/events/TrueSheetDragEvents.h +39 -0
  96. package/ios/events/TrueSheetDragEvents.mm +62 -0
  97. package/ios/events/{OnPositionChangeEvent.h → TrueSheetFocusEvents.h} +8 -5
  98. package/ios/events/TrueSheetFocusEvents.mm +49 -0
  99. package/ios/events/TrueSheetLifecycleEvents.h +42 -0
  100. package/ios/events/TrueSheetLifecycleEvents.mm +79 -0
  101. package/ios/events/TrueSheetStateEvents.h +35 -0
  102. package/ios/events/TrueSheetStateEvents.mm +49 -0
  103. package/ios/tvos/TrueSheetStubs.mm +166 -0
  104. package/ios/{events/OnMountEvent.h → utils/BlurUtil.h} +5 -7
  105. package/ios/utils/BlurUtil.mm +69 -0
  106. package/ios/utils/GestureUtil.h +7 -0
  107. package/ios/utils/GestureUtil.mm +12 -0
  108. package/ios/utils/PlatformUtil.h +15 -0
  109. package/ios/utils/UIView+FirstResponder.h +15 -0
  110. package/ios/utils/UIView+FirstResponder.mm +26 -0
  111. package/ios/utils/UIView+ScrollEdgeInteraction.h +26 -0
  112. package/ios/utils/UIView+ScrollEdgeInteraction.mm +84 -0
  113. package/ios/utils/WindowUtil.mm +17 -1
  114. package/lib/module/TrueSheet.js +349 -74
  115. package/lib/module/TrueSheet.js.map +1 -1
  116. package/lib/module/TrueSheet.web.js +1270 -0
  117. package/lib/module/TrueSheet.web.js.map +1 -0
  118. package/lib/module/TrueSheetOverlay.js +30 -0
  119. package/lib/module/TrueSheetOverlay.js.map +1 -0
  120. package/lib/module/TrueSheetOverlay.web.js +35 -0
  121. package/lib/module/TrueSheetOverlay.web.js.map +1 -0
  122. package/lib/module/TrueSheetPeek.js +15 -0
  123. package/lib/module/TrueSheetPeek.js.map +1 -0
  124. package/lib/module/TrueSheetPeek.web.js +55 -0
  125. package/lib/module/TrueSheetPeek.web.js.map +1 -0
  126. package/lib/module/TrueSheetProvider.js +28 -0
  127. package/lib/module/TrueSheetProvider.js.map +1 -0
  128. package/lib/module/TrueSheetProvider.web.js +157 -0
  129. package/lib/module/TrueSheetProvider.web.js.map +1 -0
  130. package/lib/module/fabric/TrueSheetContainerViewNativeComponent.ts +1 -1
  131. package/lib/module/fabric/TrueSheetContentViewNativeComponent.ts +5 -1
  132. package/lib/module/fabric/TrueSheetFooterViewNativeComponent.ts +10 -2
  133. package/lib/module/fabric/TrueSheetHeaderViewNativeComponent.ts +8 -0
  134. package/lib/module/fabric/TrueSheetOverlayViewNativeComponent.ts +12 -0
  135. package/lib/module/fabric/TrueSheetPeekViewNativeComponent.ts +8 -0
  136. package/lib/module/fabric/TrueSheetViewNativeComponent.ts +110 -18
  137. package/lib/module/index.js +4 -3
  138. package/lib/module/index.js.map +1 -1
  139. package/lib/module/mocks/index.js +103 -0
  140. package/lib/module/mocks/index.js.map +1 -0
  141. package/lib/module/mocks/navigation.js +95 -0
  142. package/lib/module/mocks/navigation.js.map +1 -0
  143. package/lib/module/mocks/navigationExpoRouter.js +21 -0
  144. package/lib/module/mocks/navigationExpoRouter.js.map +1 -0
  145. package/lib/module/mocks/reanimated.js +91 -0
  146. package/lib/module/mocks/reanimated.js.map +1 -0
  147. package/lib/module/navigation/TrueSheetNavigatorContent.js +73 -0
  148. package/lib/module/navigation/TrueSheetNavigatorContent.js.map +1 -0
  149. package/lib/module/navigation/actions.js +50 -0
  150. package/lib/module/navigation/actions.js.map +1 -0
  151. package/lib/module/navigation/base-types.js +2 -0
  152. package/lib/module/navigation/base-types.js.map +1 -0
  153. package/lib/module/navigation/createTrueSheetRouter.js +157 -0
  154. package/lib/module/navigation/createTrueSheetRouter.js.map +1 -0
  155. package/lib/module/navigation/expo-router/base-types.js +2 -0
  156. package/lib/module/navigation/expo-router/base-types.js.map +1 -0
  157. package/lib/module/navigation/expo-router/index.js +50 -0
  158. package/lib/module/navigation/expo-router/index.js.map +1 -0
  159. package/lib/module/navigation/expo-router/types.js +4 -0
  160. package/lib/module/navigation/expo-router/types.js.map +1 -0
  161. package/lib/module/navigation/index.js +47 -0
  162. package/lib/module/navigation/index.js.map +1 -0
  163. package/lib/module/navigation/navigator.js +10 -0
  164. package/lib/module/navigation/navigator.js.map +1 -0
  165. package/lib/module/navigation/screen/ReanimatedTrueSheetScreen.js +56 -0
  166. package/lib/module/navigation/screen/ReanimatedTrueSheetScreen.js.map +1 -0
  167. package/lib/module/navigation/screen/TrueSheetScreen.js +49 -0
  168. package/lib/module/navigation/screen/TrueSheetScreen.js.map +1 -0
  169. package/lib/module/navigation/screen/index.js +5 -0
  170. package/lib/module/navigation/screen/index.js.map +1 -0
  171. package/lib/module/navigation/screen/types.js +4 -0
  172. package/lib/module/navigation/screen/types.js.map +1 -0
  173. package/lib/module/navigation/screen/useSheetScreenState.js +69 -0
  174. package/lib/module/navigation/screen/useSheetScreenState.js.map +1 -0
  175. package/lib/module/navigation/types.js +4 -0
  176. package/lib/module/navigation/types.js.map +1 -0
  177. package/lib/module/navigation/useTrueSheetNavigation.js +24 -0
  178. package/lib/module/navigation/useTrueSheetNavigation.js.map +1 -0
  179. package/lib/module/reanimated/ReanimatedTrueSheet.js +15 -9
  180. package/lib/module/reanimated/ReanimatedTrueSheet.js.map +1 -1
  181. package/lib/module/reanimated/ReanimatedTrueSheet.web.js +81 -0
  182. package/lib/module/reanimated/ReanimatedTrueSheet.web.js.map +1 -0
  183. package/lib/module/reanimated/ReanimatedTrueSheetProvider.js +4 -2
  184. package/lib/module/reanimated/ReanimatedTrueSheetProvider.js.map +1 -1
  185. package/lib/module/reanimated/index.js +2 -2
  186. package/lib/module/reanimated/index.js.map +1 -1
  187. package/lib/module/reanimated/useReanimatedPositionChangeHandler.web.js +20 -0
  188. package/lib/module/reanimated/useReanimatedPositionChangeHandler.web.js.map +1 -0
  189. package/lib/module/specs/NativeTrueSheetModule.js.map +1 -1
  190. package/lib/module/web/constants.js +16 -0
  191. package/lib/module/web/constants.js.map +1 -0
  192. package/lib/module/web/dom.js +10 -0
  193. package/lib/module/web/dom.js.map +1 -0
  194. package/lib/module/web/vaul/README.md +9 -0
  195. package/lib/module/web/vaul/browser.js +30 -0
  196. package/lib/module/web/vaul/browser.js.map +1 -0
  197. package/lib/module/web/vaul/constants.js +19 -0
  198. package/lib/module/web/vaul/constants.js.map +1 -0
  199. package/lib/module/web/vaul/context.js +59 -0
  200. package/lib/module/web/vaul/context.js.map +1 -0
  201. package/lib/module/web/vaul/helpers.js +99 -0
  202. package/lib/module/web/vaul/helpers.js.map +1 -0
  203. package/lib/module/web/vaul/index.js +1342 -0
  204. package/lib/module/web/vaul/index.js.map +1 -0
  205. package/lib/module/web/vaul/style.css +287 -0
  206. package/lib/module/web/vaul/style.css.d.js +2 -0
  207. package/lib/module/web/vaul/style.css.d.js.map +1 -0
  208. package/lib/module/web/vaul/types.js +2 -0
  209. package/lib/module/web/vaul/types.js.map +1 -0
  210. package/lib/module/web/vaul/use-composed-refs.js +34 -0
  211. package/lib/module/web/vaul/use-composed-refs.js.map +1 -0
  212. package/lib/module/web/vaul/use-controllable-state.js +54 -0
  213. package/lib/module/web/vaul/use-controllable-state.js.map +1 -0
  214. package/lib/module/web/vaul/use-position-fixed.js +123 -0
  215. package/lib/module/web/vaul/use-position-fixed.js.map +1 -0
  216. package/lib/module/web/vaul/use-prevent-scroll.js +258 -0
  217. package/lib/module/web/vaul/use-prevent-scroll.js.map +1 -0
  218. package/lib/module/web/vaul/use-scale-background.js +57 -0
  219. package/lib/module/web/vaul/use-scale-background.js.map +1 -0
  220. package/lib/module/web/vaul/use-snap-points.js +305 -0
  221. package/lib/module/web/vaul/use-snap-points.js.map +1 -0
  222. package/lib/typescript/src/TrueSheet.d.ts +57 -14
  223. package/lib/typescript/src/TrueSheet.d.ts.map +1 -1
  224. package/lib/typescript/src/TrueSheet.types.d.ts +535 -55
  225. package/lib/typescript/src/TrueSheet.types.d.ts.map +1 -1
  226. package/lib/typescript/src/TrueSheet.web.d.ts +5 -0
  227. package/lib/typescript/src/TrueSheet.web.d.ts.map +1 -0
  228. package/lib/typescript/src/TrueSheetOverlay.d.ts +8 -0
  229. package/lib/typescript/src/TrueSheetOverlay.d.ts.map +1 -0
  230. package/lib/typescript/src/TrueSheetOverlay.web.d.ts +8 -0
  231. package/lib/typescript/src/TrueSheetOverlay.web.d.ts.map +1 -0
  232. package/lib/typescript/src/TrueSheetPeek.d.ts +9 -0
  233. package/lib/typescript/src/TrueSheetPeek.d.ts.map +1 -0
  234. package/lib/typescript/src/TrueSheetPeek.web.d.ts +28 -0
  235. package/lib/typescript/src/TrueSheetPeek.web.d.ts.map +1 -0
  236. package/lib/typescript/src/TrueSheetProvider.d.ts +18 -0
  237. package/lib/typescript/src/TrueSheetProvider.d.ts.map +1 -0
  238. package/lib/typescript/src/TrueSheetProvider.web.d.ts +27 -0
  239. package/lib/typescript/src/TrueSheetProvider.web.d.ts.map +1 -0
  240. package/lib/typescript/src/fabric/TrueSheetContainerViewNativeComponent.d.ts.map +1 -1
  241. package/lib/typescript/src/fabric/TrueSheetContentViewNativeComponent.d.ts.map +1 -1
  242. package/lib/typescript/src/fabric/TrueSheetFooterViewNativeComponent.d.ts +2 -0
  243. package/lib/typescript/src/fabric/TrueSheetFooterViewNativeComponent.d.ts.map +1 -1
  244. package/lib/typescript/src/fabric/TrueSheetHeaderViewNativeComponent.d.ts +6 -0
  245. package/lib/typescript/src/fabric/TrueSheetHeaderViewNativeComponent.d.ts.map +1 -0
  246. package/lib/typescript/src/fabric/TrueSheetOverlayViewNativeComponent.d.ts +6 -0
  247. package/lib/typescript/src/fabric/TrueSheetOverlayViewNativeComponent.d.ts.map +1 -0
  248. package/lib/typescript/src/fabric/TrueSheetPeekViewNativeComponent.d.ts +6 -0
  249. package/lib/typescript/src/fabric/TrueSheetPeekViewNativeComponent.d.ts.map +1 -0
  250. package/lib/typescript/src/fabric/TrueSheetViewNativeComponent.d.ts +71 -16
  251. package/lib/typescript/src/fabric/TrueSheetViewNativeComponent.d.ts.map +1 -1
  252. package/lib/typescript/src/index.d.ts +3 -2
  253. package/lib/typescript/src/index.d.ts.map +1 -1
  254. package/lib/typescript/src/mocks/index.d.ts +47 -0
  255. package/lib/typescript/src/mocks/index.d.ts.map +1 -0
  256. package/lib/typescript/src/mocks/navigation.d.ts +70 -0
  257. package/lib/typescript/src/mocks/navigation.d.ts.map +1 -0
  258. package/lib/typescript/src/mocks/navigationExpoRouter.d.ts +22 -0
  259. package/lib/typescript/src/mocks/navigationExpoRouter.d.ts.map +1 -0
  260. package/lib/typescript/src/mocks/reanimated.d.ts +48 -0
  261. package/lib/typescript/src/mocks/reanimated.d.ts.map +1 -0
  262. package/lib/typescript/src/navigation/TrueSheetNavigatorContent.d.ts +5 -0
  263. package/lib/typescript/src/navigation/TrueSheetNavigatorContent.d.ts.map +1 -0
  264. package/lib/typescript/src/navigation/actions.d.ts +71 -0
  265. package/lib/typescript/src/navigation/actions.d.ts.map +1 -0
  266. package/lib/typescript/src/navigation/base-types.d.ts +9 -0
  267. package/lib/typescript/src/navigation/base-types.d.ts.map +1 -0
  268. package/lib/typescript/src/navigation/createTrueSheetRouter.d.ts +3 -0
  269. package/lib/typescript/src/navigation/createTrueSheetRouter.d.ts.map +1 -0
  270. package/lib/typescript/src/navigation/expo-router/base-types.d.ts +8 -0
  271. package/lib/typescript/src/navigation/expo-router/base-types.d.ts.map +1 -0
  272. package/lib/typescript/src/navigation/expo-router/index.d.ts +37 -0
  273. package/lib/typescript/src/navigation/expo-router/index.d.ts.map +1 -0
  274. package/lib/typescript/src/navigation/expo-router/types.d.ts +44 -0
  275. package/lib/typescript/src/navigation/expo-router/types.d.ts.map +1 -0
  276. package/lib/typescript/src/navigation/index.d.ts +45 -0
  277. package/lib/typescript/src/navigation/index.d.ts.map +1 -0
  278. package/lib/typescript/src/navigation/navigator.d.ts +7 -0
  279. package/lib/typescript/src/navigation/navigator.d.ts.map +1 -0
  280. package/lib/typescript/src/navigation/screen/ReanimatedTrueSheetScreen.d.ts +3 -0
  281. package/lib/typescript/src/navigation/screen/ReanimatedTrueSheetScreen.d.ts.map +1 -0
  282. package/lib/typescript/src/navigation/screen/TrueSheetScreen.d.ts +3 -0
  283. package/lib/typescript/src/navigation/screen/TrueSheetScreen.d.ts.map +1 -0
  284. package/lib/typescript/src/navigation/screen/index.d.ts +4 -0
  285. package/lib/typescript/src/navigation/screen/index.d.ts.map +1 -0
  286. package/lib/typescript/src/navigation/screen/types.d.ts +14 -0
  287. package/lib/typescript/src/navigation/screen/types.d.ts.map +1 -0
  288. package/lib/typescript/src/navigation/screen/useSheetScreenState.d.ts +34 -0
  289. package/lib/typescript/src/navigation/screen/useSheetScreenState.d.ts.map +1 -0
  290. package/lib/typescript/src/navigation/types.d.ts +213 -0
  291. package/lib/typescript/src/navigation/types.d.ts.map +1 -0
  292. package/lib/typescript/src/navigation/useTrueSheetNavigation.d.ts +23 -0
  293. package/lib/typescript/src/navigation/useTrueSheetNavigation.d.ts.map +1 -0
  294. package/lib/typescript/src/reanimated/ReanimatedTrueSheet.d.ts.map +1 -1
  295. package/lib/typescript/src/reanimated/ReanimatedTrueSheet.web.d.ts +41 -0
  296. package/lib/typescript/src/reanimated/ReanimatedTrueSheet.web.d.ts.map +1 -0
  297. package/lib/typescript/src/reanimated/ReanimatedTrueSheetProvider.d.ts +8 -2
  298. package/lib/typescript/src/reanimated/ReanimatedTrueSheetProvider.d.ts.map +1 -1
  299. package/lib/typescript/src/reanimated/useReanimatedPositionChangeHandler.web.d.ts +15 -0
  300. package/lib/typescript/src/reanimated/useReanimatedPositionChangeHandler.web.d.ts.map +1 -0
  301. package/lib/typescript/src/specs/NativeTrueSheetModule.d.ts +25 -3
  302. package/lib/typescript/src/specs/NativeTrueSheetModule.d.ts.map +1 -1
  303. package/lib/typescript/src/web/constants.d.ts +14 -0
  304. package/lib/typescript/src/web/constants.d.ts.map +1 -0
  305. package/lib/typescript/src/web/dom.d.ts +7 -0
  306. package/lib/typescript/src/web/dom.d.ts.map +1 -0
  307. package/lib/typescript/src/web/vaul/browser.d.ts +8 -0
  308. package/lib/typescript/src/web/vaul/browser.d.ts.map +1 -0
  309. package/lib/typescript/src/web/vaul/constants.d.ts +14 -0
  310. package/lib/typescript/src/web/vaul/constants.d.ts.map +1 -0
  311. package/lib/typescript/src/web/vaul/context.d.ts +54 -0
  312. package/lib/typescript/src/web/vaul/context.d.ts.map +1 -0
  313. package/lib/typescript/src/web/vaul/helpers.d.ts +17 -0
  314. package/lib/typescript/src/web/vaul/helpers.d.ts.map +1 -0
  315. package/lib/typescript/src/web/vaul/index.d.ts +234 -0
  316. package/lib/typescript/src/web/vaul/index.d.ts.map +1 -0
  317. package/lib/typescript/src/web/vaul/types.d.ts +7 -0
  318. package/lib/typescript/src/web/vaul/types.d.ts.map +1 -0
  319. package/lib/typescript/src/web/vaul/use-composed-refs.d.ts +14 -0
  320. package/lib/typescript/src/web/vaul/use-composed-refs.d.ts.map +1 -0
  321. package/lib/typescript/src/web/vaul/use-controllable-state.d.ts +9 -0
  322. package/lib/typescript/src/web/vaul/use-controllable-state.d.ts.map +1 -0
  323. package/lib/typescript/src/web/vaul/use-position-fixed.d.ts +19 -0
  324. package/lib/typescript/src/web/vaul/use-position-fixed.d.ts.map +1 -0
  325. package/lib/typescript/src/web/vaul/use-prevent-scroll.d.ts +18 -0
  326. package/lib/typescript/src/web/vaul/use-prevent-scroll.d.ts.map +1 -0
  327. package/lib/typescript/src/web/vaul/use-scale-background.d.ts +2 -0
  328. package/lib/typescript/src/web/vaul/use-scale-background.d.ts.map +1 -0
  329. package/lib/typescript/src/web/vaul/use-snap-points.d.ts +38 -0
  330. package/lib/typescript/src/web/vaul/use-snap-points.d.ts.map +1 -0
  331. package/package.json +104 -37
  332. package/react-native.config.js +10 -2
  333. package/src/TrueSheet.tsx +424 -70
  334. package/src/TrueSheet.types.ts +602 -72
  335. package/src/TrueSheet.web.tsx +1437 -0
  336. package/src/TrueSheetOverlay.tsx +24 -0
  337. package/src/TrueSheetOverlay.web.tsx +33 -0
  338. package/src/TrueSheetPeek.tsx +11 -0
  339. package/src/TrueSheetPeek.web.tsx +64 -0
  340. package/src/TrueSheetProvider.tsx +33 -0
  341. package/src/TrueSheetProvider.web.tsx +220 -0
  342. package/src/fabric/TrueSheetContainerViewNativeComponent.ts +1 -1
  343. package/src/fabric/TrueSheetContentViewNativeComponent.ts +5 -1
  344. package/src/fabric/TrueSheetFooterViewNativeComponent.ts +10 -2
  345. package/src/fabric/TrueSheetHeaderViewNativeComponent.ts +8 -0
  346. package/src/fabric/TrueSheetOverlayViewNativeComponent.ts +12 -0
  347. package/src/fabric/TrueSheetPeekViewNativeComponent.ts +8 -0
  348. package/src/fabric/TrueSheetViewNativeComponent.ts +110 -18
  349. package/src/index.ts +3 -2
  350. package/src/mocks/index.ts +96 -0
  351. package/src/mocks/navigation.ts +79 -0
  352. package/src/mocks/navigationExpoRouter.ts +41 -0
  353. package/src/mocks/reanimated.ts +100 -0
  354. package/src/navigation/TrueSheetNavigatorContent.tsx +106 -0
  355. package/src/navigation/actions.ts +78 -0
  356. package/src/navigation/base-types.ts +20 -0
  357. package/src/navigation/createTrueSheetRouter.ts +194 -0
  358. package/src/navigation/expo-router/base-types.ts +18 -0
  359. package/src/navigation/expo-router/index.ts +90 -0
  360. package/src/navigation/expo-router/types.ts +116 -0
  361. package/src/navigation/index.ts +80 -0
  362. package/src/navigation/navigator.ts +18 -0
  363. package/src/navigation/screen/ReanimatedTrueSheetScreen.tsx +61 -0
  364. package/src/navigation/screen/TrueSheetScreen.tsx +54 -0
  365. package/src/navigation/screen/index.ts +3 -0
  366. package/src/navigation/screen/types.ts +19 -0
  367. package/src/navigation/screen/useSheetScreenState.ts +102 -0
  368. package/src/navigation/types.ts +302 -0
  369. package/src/navigation/useTrueSheetNavigation.ts +26 -0
  370. package/src/reanimated/ReanimatedTrueSheet.tsx +12 -7
  371. package/src/reanimated/ReanimatedTrueSheet.web.tsx +75 -0
  372. package/src/reanimated/ReanimatedTrueSheetProvider.tsx +12 -8
  373. package/src/reanimated/useReanimatedPositionChangeHandler.web.ts +31 -0
  374. package/src/specs/NativeTrueSheetModule.ts +28 -3
  375. package/src/web/constants.ts +13 -0
  376. package/src/web/dom.ts +9 -0
  377. package/src/web/vaul/README.md +9 -0
  378. package/src/web/vaul/browser.ts +38 -0
  379. package/src/web/vaul/constants.ts +24 -0
  380. package/src/web/vaul/context.ts +102 -0
  381. package/src/web/vaul/helpers.ts +131 -0
  382. package/src/web/vaul/index.tsx +1785 -0
  383. package/src/web/vaul/style.css +287 -0
  384. package/src/web/vaul/style.css.d.ts +1 -0
  385. package/src/web/vaul/types.ts +7 -0
  386. package/src/web/vaul/use-composed-refs.ts +35 -0
  387. package/src/web/vaul/use-controllable-state.ts +66 -0
  388. package/src/web/vaul/use-position-fixed.ts +147 -0
  389. package/src/web/vaul/use-prevent-scroll.ts +309 -0
  390. package/src/web/vaul/use-scale-background.ts +64 -0
  391. package/src/web/vaul/use-snap-points.ts +455 -0
  392. package/android/src/main/java/com/lodev09/truesheet/events/DetentChangeEvent.kt +0 -26
  393. package/android/src/main/java/com/lodev09/truesheet/events/DidDismissEvent.kt +0 -20
  394. package/android/src/main/java/com/lodev09/truesheet/events/DidPresentEvent.kt +0 -26
  395. package/android/src/main/java/com/lodev09/truesheet/events/DragBeginEvent.kt +0 -26
  396. package/android/src/main/java/com/lodev09/truesheet/events/DragChangeEvent.kt +0 -26
  397. package/android/src/main/java/com/lodev09/truesheet/events/DragEndEvent.kt +0 -26
  398. package/android/src/main/java/com/lodev09/truesheet/events/MountEvent.kt +0 -20
  399. package/android/src/main/java/com/lodev09/truesheet/events/PositionChangeEvent.kt +0 -32
  400. package/android/src/main/java/com/lodev09/truesheet/events/SizeChangeEvent.kt +0 -27
  401. package/android/src/main/java/com/lodev09/truesheet/events/WillDismissEvent.kt +0 -20
  402. package/android/src/main/java/com/lodev09/truesheet/events/WillPresentEvent.kt +0 -26
  403. package/android/src/main/res/values/styles.xml +0 -8
  404. package/ios/events/OnDetentChangeEvent.h +0 -28
  405. package/ios/events/OnDetentChangeEvent.mm +0 -30
  406. package/ios/events/OnDidDismissEvent.h +0 -26
  407. package/ios/events/OnDidDismissEvent.mm +0 -25
  408. package/ios/events/OnDidPresentEvent.h +0 -28
  409. package/ios/events/OnDidPresentEvent.mm +0 -30
  410. package/ios/events/OnDragBeginEvent.h +0 -28
  411. package/ios/events/OnDragBeginEvent.mm +0 -30
  412. package/ios/events/OnDragChangeEvent.h +0 -28
  413. package/ios/events/OnDragChangeEvent.mm +0 -30
  414. package/ios/events/OnDragEndEvent.h +0 -28
  415. package/ios/events/OnDragEndEvent.mm +0 -30
  416. package/ios/events/OnMountEvent.mm +0 -25
  417. package/ios/events/OnPositionChangeEvent.mm +0 -32
  418. package/ios/events/OnSizeChangeEvent.h +0 -28
  419. package/ios/events/OnSizeChangeEvent.mm +0 -30
  420. package/ios/events/OnWillDismissEvent.mm +0 -25
  421. package/ios/events/OnWillPresentEvent.h +0 -28
  422. package/ios/events/OnWillPresentEvent.mm +0 -30
  423. package/ios/utils/LayoutUtil.h +0 -44
  424. package/ios/utils/LayoutUtil.mm +0 -50
  425. package/lib/module/TrueSheetGrabber.js +0 -51
  426. package/lib/module/TrueSheetGrabber.js.map +0 -1
  427. package/lib/typescript/src/TrueSheetGrabber.d.ts +0 -39
  428. package/lib/typescript/src/TrueSheetGrabber.d.ts.map +0 -1
  429. package/src/TrueSheetGrabber.tsx +0 -82
  430. package/src/__mocks__/index.js +0 -67
@@ -1,17 +1,18 @@
1
1
  package com.lodev09.truesheet
2
2
 
3
3
  import android.annotation.SuppressLint
4
- import android.graphics.Color
5
- import android.graphics.drawable.ShapeDrawable
6
- import android.graphics.drawable.shapes.RoundRectShape
4
+ import android.graphics.Canvas
5
+ import android.os.Build
7
6
  import android.view.MotionEvent
8
7
  import android.view.View
9
- import android.view.WindowManager
8
+ import android.view.ViewGroup
9
+ import android.view.accessibility.AccessibilityEvent
10
10
  import android.view.accessibility.AccessibilityNodeInfo
11
- import android.widget.FrameLayout
11
+ import android.widget.ImageView
12
+ import androidx.coordinatorlayout.widget.CoordinatorLayout
13
+ import androidx.core.graphics.createBitmap
12
14
  import androidx.core.view.isNotEmpty
13
15
  import com.facebook.react.R
14
- import com.facebook.react.common.annotations.UnstableReactNativeAPI
15
16
  import com.facebook.react.uimanager.JSPointerDispatcher
16
17
  import com.facebook.react.uimanager.JSTouchDispatcher
17
18
  import com.facebook.react.uimanager.PixelUtil.dpToPx
@@ -19,837 +20,1637 @@ import com.facebook.react.uimanager.PixelUtil.pxToDp
19
20
  import com.facebook.react.uimanager.RootView
20
21
  import com.facebook.react.uimanager.ThemedReactContext
21
22
  import com.facebook.react.uimanager.events.EventDispatcher
23
+ import com.facebook.react.util.RNLog
22
24
  import com.facebook.react.views.view.ReactViewGroup
23
25
  import com.google.android.material.bottomsheet.BottomSheetBehavior
24
- import com.google.android.material.bottomsheet.BottomSheetDialog
26
+ import com.lodev09.truesheet.core.AccessibilityOptions
27
+ import com.lodev09.truesheet.core.GrabberOptions
28
+ import com.lodev09.truesheet.core.TrueSheetBottomSheetBehavior
29
+ import com.lodev09.truesheet.core.TrueSheetBottomSheetView
30
+ import com.lodev09.truesheet.core.TrueSheetBottomSheetViewDelegate
31
+ import com.lodev09.truesheet.core.TrueSheetCoordinatorLayout
32
+ import com.lodev09.truesheet.core.TrueSheetCoordinatorLayoutDelegate
33
+ import com.lodev09.truesheet.core.TrueSheetDetentCalculator
34
+ import com.lodev09.truesheet.core.TrueSheetDetentCalculatorDelegate
35
+ import com.lodev09.truesheet.core.TrueSheetDimView
36
+ import com.lodev09.truesheet.core.TrueSheetDimViewDelegate
37
+ import com.lodev09.truesheet.core.TrueSheetKeyboardObserver
38
+ import com.lodev09.truesheet.core.TrueSheetKeyboardObserverDelegate
39
+ import com.lodev09.truesheet.core.TrueSheetStackManager
40
+ import com.lodev09.truesheet.utils.Insets
41
+ import com.lodev09.truesheet.utils.KeyboardUtils
25
42
  import com.lodev09.truesheet.utils.ScreenUtils
43
+ import com.lodev09.truesheet.utils.TouchEventDeduper
44
+
45
+ // =============================================================================
46
+ // MARK: - Data Types & Delegate Protocol
47
+ // =============================================================================
26
48
 
27
49
  data class DetentInfo(val index: Int, val position: Float)
28
50
 
29
- /**
30
- * Delegate protocol for TrueSheetViewController lifecycle and interaction events.
31
- * Similar to iOS TrueSheetViewControllerDelegate pattern.
32
- */
33
51
  interface TrueSheetViewControllerDelegate {
34
- fun viewControllerWillPresent(index: Int, position: Float)
35
- fun viewControllerDidPresent(index: Int, position: Float)
52
+ val eventDispatcher: EventDispatcher?
53
+ fun findRootContainerView(): ViewGroup?
54
+ fun viewControllerWillPresent(index: Int, position: Float, detent: Float)
55
+ fun viewControllerDidPresent(index: Int, position: Float, detent: Float)
36
56
  fun viewControllerWillDismiss()
37
- fun viewControllerDidDismiss()
38
- fun viewControllerDidChangeDetent(index: Int, position: Float)
39
- fun viewControllerDidDragBegin(index: Int, position: Float)
40
- fun viewControllerDidDragChange(index: Int, position: Float)
41
- fun viewControllerDidDragEnd(index: Int, position: Float)
42
- fun viewControllerDidChangePosition(index: Int, position: Float, transitioning: Boolean)
57
+ fun viewControllerDidDismiss(parent: TrueSheetView?)
58
+ fun viewControllerDidAttemptDismiss()
59
+ fun viewControllerDidChangeDetent(index: Int, position: Float, detent: Float)
60
+ fun viewControllerDidDragBegin(index: Int, position: Float, detent: Float)
61
+ fun viewControllerDidDragChange(index: Int, position: Float, detent: Float)
62
+ fun viewControllerDidDragEnd(index: Int, position: Float, detent: Float)
63
+ fun viewControllerDidChangePosition(index: Float, position: Float, detent: Float, realtime: Boolean)
43
64
  fun viewControllerDidChangeSize(width: Int, height: Int)
65
+ fun viewControllerWillFocus()
66
+ fun viewControllerDidFocus()
67
+ fun viewControllerWillBlur()
68
+ fun viewControllerDidBlur()
69
+ fun viewControllerDidChangeVisibility(visible: Boolean)
44
70
  }
45
71
 
72
+ // =============================================================================
73
+ // MARK: - TrueSheetViewController
74
+ // =============================================================================
75
+
46
76
  /**
47
- * TrueSheetViewController manages the bottom sheet dialog lifecycle and properties.
48
- * Similar to iOS TrueSheetViewController pattern.
77
+ * Controls the presentation and behavior of a bottom sheet.
49
78
  *
50
- * This view acts as both the RootView (handles touch events) and the controller (manages dialog).
79
+ * Uses CoordinatorLayout with BottomSheetBehavior to manage the sheet within the activity window,
80
+ * enabling touch pass-through to underlying views. Handles detent configuration, drag interactions,
81
+ * keyboard avoidance, dimmed backgrounds, back button, and lifecycle events for stacked sheets.
51
82
  */
83
+ enum class TrueSheetAnchor {
84
+ LEFT,
85
+ CENTER,
86
+ RIGHT;
87
+
88
+ companion object {
89
+ fun fromString(value: String?): TrueSheetAnchor =
90
+ when (value) {
91
+ "left" -> LEFT
92
+ "right" -> RIGHT
93
+ else -> CENTER
94
+ }
95
+ }
96
+ }
97
+
98
+ enum class TrueSheetInsetAdjustment {
99
+ AUTOMATIC,
100
+ NEVER;
101
+
102
+ companion object {
103
+ fun fromString(value: String?): TrueSheetInsetAdjustment =
104
+ when (value) {
105
+ "never" -> NEVER
106
+ else -> AUTOMATIC
107
+ }
108
+ }
109
+ }
110
+
52
111
  @SuppressLint("ClickableViewAccessibility", "ViewConstructor")
53
112
  class TrueSheetViewController(private val reactContext: ThemedReactContext) :
54
113
  ReactViewGroup(reactContext),
55
- RootView {
114
+ RootView,
115
+ TrueSheetDetentCalculatorDelegate,
116
+ TrueSheetDimViewDelegate,
117
+ TrueSheetCoordinatorLayoutDelegate,
118
+ TrueSheetBottomSheetViewDelegate {
56
119
 
57
120
  companion object {
58
- private const val TAG_NAME = "TrueSheet"
121
+ private const val DEFAULT_MAX_WIDTH = 640 // dp
122
+ private const val DEFAULT_CORNER_RADIUS = 16 // dp
123
+ private const val DEFAULT_ANCHOR_OFFSET = 16 // dp
124
+ private const val TRANSLATE_ANIMATION_DURATION = 200L
125
+ private const val DISMISS_DURATION = 200L
126
+ private const val SCREEN_FADE_DURATION = 150L
127
+
128
+ // Mirrors BottomSheetBehavior.HIDE_THRESHOLD — how far past the lowest stop,
129
+ // as a fraction of its height, a release hides a dismissible sheet
130
+ private const val HIDE_THRESHOLD = 0.5f
59
131
  }
60
132
 
61
- // ==================== RootView Touch Handling ====================
133
+ // =============================================================================
134
+ // MARK: - Types
135
+ // =============================================================================
62
136
 
63
- internal var eventDispatcher: EventDispatcher? = null
137
+ private sealed class InteractionState {
138
+ data object Idle : InteractionState()
139
+ data class Dragging(val startTop: Int) : InteractionState()
140
+ data object Reconfiguring : InteractionState()
141
+ }
64
142
 
65
- private val jSTouchDispatcher = JSTouchDispatcher(this)
66
- private var jSPointerDispatcher: JSPointerDispatcher? = null
143
+ // =============================================================================
144
+ // MARK: - Properties
145
+ // =============================================================================
67
146
 
68
- /**
69
- * Delegate for handling view controller events
70
- */
71
147
  var delegate: TrueSheetViewControllerDelegate? = null
72
148
 
73
- /**
74
- * The BottomSheetDialog instance - created lazily when container mounts
75
- */
76
- private var dialog: BottomSheetDialog? = null
77
-
78
- /**
79
- * The sheet behavior from the dialog
80
- */
81
- private val behavior: BottomSheetBehavior<FrameLayout>?
82
- get() = dialog?.behavior
149
+ // CoordinatorLayout components (replaces DialogFragment)
150
+ internal var sheetView: TrueSheetBottomSheetView? = null
151
+ internal var coordinatorLayout: TrueSheetCoordinatorLayout? = null
152
+ private var dimView: TrueSheetDimView? = null
153
+ private var parentDimView: TrueSheetDimView? = null
83
154
 
84
- /**
85
- * The sheet container view from Material BottomSheetDialog (our parent)
86
- */
87
- private val sheetContainer: FrameLayout?
88
- get() = this.parent as? FrameLayout
155
+ // Presentation State
156
+ var isPresented = false
157
+ private set
89
158
 
90
- /**
91
- * The actual bottom sheet view used by Material BottomSheetBehavior
92
- * This is the view whose position changes during drag
93
- */
94
- private val bottomSheetView: FrameLayout?
95
- get() = dialog?.findViewById(com.google.android.material.R.id.design_bottom_sheet)
159
+ var isSheetVisible = false
160
+ private set
96
161
 
97
- /**
98
- * Our sheet container view from this root view's only child
99
- */
100
- private val containerView: TrueSheetContainerView?
101
- get() = if (this.isNotEmpty()) {
102
- this.getChildAt(0) as? TrueSheetContainerView
103
- } else {
104
- null
105
- }
162
+ var currentDetentIndex: Int = -1
163
+ private set
106
164
 
107
- /**
108
- * Footer view from the container
109
- */
110
- private val footerView: TrueSheetFooterView?
111
- get() = containerView?.footerView
165
+ // Target detent index during an in-flight resize animation. -1 when idle.
166
+ // Used by setupSheetDetents so a layout-driven reconfigure during the
167
+ // animation doesn't snap the sheet back to the stale currentDetentIndex.
168
+ private var pendingDetentIndex: Int = -1
112
169
 
113
- /**
114
- * Track if the dialog is currently being dragged
115
- */
116
- private var isDragging = false
170
+ private var interactionState: InteractionState = InteractionState.Idle
117
171
 
118
- /**
119
- * Track if the sheet has been presented (after onShow callback)
120
- */
121
- var isPresented = false
172
+ // A content/header/footer size change arrived mid-drag — reconfiguring then
173
+ // would reset behavior state and kill the gesture, so it's applied on settle.
174
+ private var hasPendingSizeChange = false
175
+ internal var isBeingDismissed = false
122
176
  private set
177
+ var wasHiddenByScreen = false
178
+ private var shouldAnimatePresent = false
179
+ private var isPresentAnimating = false
123
180
 
124
- private val edgeToEdgeEnabled: Boolean
125
- get() {
126
- // Auto-enable edge-to-edge for Android 16+ (API level 36) if not explicitly set
127
- val defaultEnabled = android.os.Build.VERSION.SDK_INT >= 36
128
- return BuildConfig.EDGE_TO_EDGE_ENABLED || dialog?.edgeToEdgeEnabled == true || defaultEnabled
129
- }
181
+ private var lastStateWidth: Int = 0
182
+ private var lastStateHeight: Int = 0
183
+ private var lastEmittedPositionPx: Int = -1
130
184
 
131
- /**
132
- * Whether to allow the sheet to extend behind the status bar in edge-to-edge mode
133
- */
134
- var edgeToEdgeFullScreen: Boolean = false
135
-
136
- /**
137
- * Top inset to apply to sheet max height calculation (only when not edgeToEdgeFullScreen)
138
- */
139
- private val sheetTopInset: Int
140
- get() = if (edgeToEdgeEnabled && !edgeToEdgeFullScreen) ScreenUtils.getStatusBarHeight(reactContext) else 0
185
+ // Keyboard State
186
+ private var detentIndexBeforeKeyboard: Int = -1
187
+ private var isKeyboardDismissProgrammatic = false
141
188
 
142
- /**
143
- * Current active detent index
144
- */
145
- var currentDetentIndex: Int = -1
146
- private set
189
+ // Sheet top when a keyboard-floor drag was released; -1 once the settle target is known
190
+ private var keyboardSettleStartTop = -1
191
+ private var focusedViewBeforeBlur: View? = null
147
192
 
148
- /**
149
- * Promise callback to be invoked after present is called
150
- */
193
+ // Promises
151
194
  var presentPromise: (() -> Unit)? = null
152
-
153
- /**
154
- * Promise callback to be invoked after dismiss is called
155
- */
195
+ var resizePromise: (() -> Unit)? = null
156
196
  var dismissPromise: (() -> Unit)? = null
157
197
 
158
- // ==================== Properties ====================
198
+ // For stacked sheets
199
+ var parentSheetView: TrueSheetView? = null
159
200
 
160
- /**
161
- * Specify whether the sheet background is dimmed.
162
- * Set to `false` to allow interaction with the background components.
163
- */
164
- var dimmed = true
201
+ // Helper Objects
202
+ private var keyboardObserver: TrueSheetKeyboardObserver? = null
203
+ internal val detentCalculator = TrueSheetDetentCalculator(reactContext).apply {
204
+ delegate = this@TrueSheetViewController
205
+ }
165
206
 
166
- /**
167
- * The detent index that the sheet should start to dim the background.
168
- * This is ignored if `dimmed` is set to `false`.
169
- */
170
- var dimmedIndex = 0
207
+ // Touch Dispatchers
208
+ private val jsTouchDispatcher = JSTouchDispatcher(this)
209
+ private val jsPointerDispatcher = JSPointerDispatcher(this)
171
210
 
172
- /**
173
- * The maximum window height
174
- */
175
- var maxScreenHeight = 0
211
+ // True when the active touch stream began inside the footer. Latched on
212
+ // ACTION_DOWN so subsequent MOVE events keep routing to the footer even if
213
+ // the finger drifts outside the footer's rect mid-gesture.
214
+ private var footerOwnsTouchStream = false
176
215
 
177
- var maxSheetHeight: Int? = null
216
+ private val touchDeduper = TouchEventDeduper()
178
217
 
179
- /**
180
- * The content height from the container view.
181
- * Set by the host view when content size changes.
182
- */
183
- var contentHeight: Int = 0
218
+ private val eventDispatcher
219
+ get() = delegate?.eventDispatcher
220
+
221
+ // Detent Configuration
222
+ override var maxContentHeight: Int? = null
223
+ override var maxContentWidth: Int? = null
224
+ override var anchor: TrueSheetAnchor = TrueSheetAnchor.CENTER
225
+ override var anchorOffset: Int = DEFAULT_ANCHOR_OFFSET.dpToPx().toInt()
226
+ override var detents: MutableList<Double> = mutableListOf(0.5, 1.0)
227
+
228
+ // Appearance Configuration
229
+ var dimmed = true
230
+ var dimmedDetentIndex = 0
231
+ override var grabber: Boolean = true
232
+ override var grabberOptions: GrabberOptions? = null
233
+ override var accessibilityOptions: AccessibilityOptions? = null
234
+ override var sheetBackgroundColor: Int? = null
235
+ var insetAdjustment: TrueSheetInsetAdjustment = TrueSheetInsetAdjustment.AUTOMATIC
236
+
237
+ var scrollableOptions: ScrollableOptions? = null
238
+ set(value) {
239
+ field = value
240
+ behavior?.scrollingExpandsSheet = value?.scrollingExpandsSheet ?: true
241
+ }
242
+
243
+ // React tag of the user-provided scrollable within the content (see the scrollableRef prop)
244
+ var scrollableHandle: Int = -1
245
+
246
+ override var sheetCornerRadius: Float = DEFAULT_CORNER_RADIUS.dpToPx()
247
+ set(value) {
248
+ field = if (value < 0) DEFAULT_CORNER_RADIUS.dpToPx() else value
249
+ if (isPresented) sheetView?.setupBackground()
250
+ }
251
+
252
+ override var sheetElevation: Float = -1f
253
+ set(value) {
254
+ field = value
255
+ if (isPresented) sheetView?.setupElevation()
256
+ }
184
257
 
185
258
  var dismissible: Boolean = true
186
259
  set(value) {
187
260
  field = value
188
- dialog?.apply {
189
- setCanceledOnTouchOutside(value)
190
- setCancelable(value)
191
- behavior.isHideable = value
261
+ behavior?.isHideable = value
262
+ }
263
+
264
+ override var draggable: Boolean = true
265
+ set(value) {
266
+ field = value
267
+ behavior?.isDraggable = value
268
+ if (isPresented) sheetView?.setupGrabber()
269
+ }
270
+
271
+ // =============================================================================
272
+ // MARK: - Computed Properties
273
+ // =============================================================================
274
+
275
+ // Behavior
276
+ private val behavior: TrueSheetBottomSheetBehavior<TrueSheetBottomSheetView>?
277
+ get() = sheetView?.behavior
278
+
279
+ internal val containerView: TrueSheetContainerView?
280
+ get() = if (this.isNotEmpty()) getChildAt(0) as? TrueSheetContainerView else null
281
+
282
+ // Screen Measurements
283
+ override val screenHeight: Int
284
+ get() = ScreenUtils.getScreenHeight(reactContext)
285
+
286
+ val screenWidth: Int
287
+ get() = ScreenUtils.getScreenWidth(reactContext)
288
+
289
+ // Cached per layout/configuration pass — the WindowManager and
290
+ // rootWindowInsets queries behind these allocate, and they're read many
291
+ // times per animation frame (position emission, detent interpolation,
292
+ // stacked sheet sync)
293
+ private var cachedRealScreenHeight: Int = 0
294
+ private var cachedInsets: Insets? = null
295
+
296
+ private fun invalidateScreenMetrics() {
297
+ cachedRealScreenHeight = 0
298
+ cachedInsets = null
299
+ }
300
+
301
+ // Includes system bars for accurate positioning
302
+ override val realScreenHeight: Int
303
+ get() {
304
+ if (cachedRealScreenHeight == 0) {
305
+ cachedRealScreenHeight = ScreenUtils.getRealScreenHeight(reactContext)
192
306
  }
307
+ return cachedRealScreenHeight
193
308
  }
194
309
 
195
- var cornerRadius: Float = 0f
196
- var sheetBackgroundColor: Int = Color.WHITE
197
- var detents = mutableListOf(0.5, 1.0)
310
+ private val insets: Insets
311
+ get() = cachedInsets ?: ScreenUtils.getInsets(reactContext).also { cachedInsets = it }
312
+
313
+ // Content Measurements
314
+ // Cached values used during dismiss when container is unmounted
315
+ private var cachedContentHeight: Int = 0
316
+ private var cachedHeaderHeight: Int = 0
317
+ private var cachedFooterHeight: Int = 0
318
+ private var cachedPeekContentHeight: Int = 0
198
319
 
199
- private var windowAnimation: Int = 0
320
+ override val contentHeight: Int
321
+ get() = containerView?.contentHeight ?: cachedContentHeight
200
322
 
201
- init {
202
- maxScreenHeight = ScreenUtils.getScreenHeight(reactContext, edgeToEdgeEnabled)
203
- jSPointerDispatcher = JSPointerDispatcher(this)
323
+ override val headerHeight: Int
324
+ get() = containerView?.headerHeight ?: cachedHeaderHeight
325
+
326
+ override var absoluteHeader: Boolean = false
327
+
328
+ override val footerHeight: Int
329
+ get() = containerView?.footerHeight ?: cachedFooterHeight
330
+
331
+ override var absoluteFooter: Boolean = false
332
+
333
+ override val peekContentHeight: Int
334
+ get() = containerView?.peekContentHeight ?: cachedPeekContentHeight
335
+
336
+ // Insets
337
+ // Target keyboard height used for detent calculations
338
+ override val keyboardInset: Int
339
+ get() = keyboardObserver?.targetHeight ?: 0
340
+
341
+ // Current animated keyboard height for positioning
342
+ private val currentKeyboardInset: Int
343
+ get() = keyboardObserver?.currentHeight ?: 0
344
+
345
+ private val isKeyboardTransitioning: Boolean
346
+ get() = keyboardObserver?.isTransitioning ?: false
347
+
348
+ // Keyboard is up or animating — detents are (or are about to be) keyboard-shifted
349
+ val isKeyboardActive: Boolean
350
+ get() = keyboardInset > 0 || isKeyboardTransitioning
351
+
352
+ // While the keyboard holds the sheet up, the keyboard-free first detent is the
353
+ // collapsed stop — landing there dismisses the keyboard, and (when dismissible)
354
+ // dragging more than half its height below it hides the sheet too, like iOS.
355
+ override val hasKeyboardFloor: Boolean
356
+ get() = keyboardInset > 0
357
+
358
+ // The detent JS last heard about — the keyboard expansion is never reported
359
+ private val knownDetentIndex: Int
360
+ get() = if (detentIndexBeforeKeyboard >= 0) detentIndexBeforeKeyboard else currentDetentIndex
361
+
362
+ fun isFocusedViewWithinSheet(): Boolean {
363
+ val sheet = sheetView ?: return false
364
+ return keyboardObserver?.isFocusedViewWithinSheet(sheet) ?: false
204
365
  }
205
366
 
206
- // ==================== Lifecycle ====================
367
+ val bottomInset: Int
368
+ get() = if (edgeToEdgeEnabled) insets.bottom else 0
207
369
 
208
- /**
209
- * Creates the dialog instance. Should be called when container view is mounted.
210
- */
211
- fun createDialog() {
212
- if (dialog != null) return
370
+ override val topInset: Int
371
+ get() = if (edgeToEdgeEnabled) insets.top else 0
213
372
 
214
- val style = if (edgeToEdgeEnabled) {
215
- com.lodev09.truesheet.R.style.TrueSheetEdgeToEdgeEnabledDialog
216
- } else {
217
- 0
373
+ override val contentBottomInset: Int
374
+ get() = if (insetAdjustment == TrueSheetInsetAdjustment.AUTOMATIC) bottomInset else 0
375
+
376
+ @Suppress("KotlinConstantConditions", "SimplifyBooleanWithConstants")
377
+ private val edgeToEdgeEnabled: Boolean
378
+ get() {
379
+ val defaultEnabled = Build.VERSION.SDK_INT >= 36
380
+ return BuildConfig.EDGE_TO_EDGE_ENABLED || defaultEnabled
218
381
  }
219
382
 
220
- dialog = BottomSheetDialog(reactContext, style).apply {
221
- setContentView(this@TrueSheetViewController)
383
+ // Sheet State
384
+ val isSettling: Boolean
385
+ get() = behavior?.state == BottomSheetBehavior.STATE_SETTLING
222
386
 
223
- // Setup window params
224
- window?.apply {
225
- // Store current windowAnimation value to toggle later
226
- windowAnimation = attributes.windowAnimations
227
- }
387
+ val isExpanded: Boolean
388
+ get() {
389
+ val sheetTop = sheetView?.top ?: return false
390
+ return sheetTop <= topInset
391
+ }
228
392
 
229
- // Setup dialog lifecycle listeners
230
- setupDialogListeners(this)
393
+ val currentTranslationY: Int
394
+ get() = sheetView?.translationY?.toInt() ?: 0
231
395
 
232
- // Setup bottom sheet behavior callbacks
233
- setupBottomSheetBehavior(this)
396
+ override val isTopmostSheet: Boolean
397
+ get() {
398
+ val hostView = delegate as? TrueSheetView ?: return true
399
+ return TrueSheetStackManager.isTopmostSheet(hostView)
400
+ }
401
+
402
+ private val dimViews: List<TrueSheetDimView>
403
+ get() = listOfNotNull(dimView, parentDimView)
404
+
405
+ val isDimmedAtCurrentDetent: Boolean
406
+ get() = isDimmedAtDetentIndex(currentDetentIndex)
407
+
408
+ fun isDimmedAtDetentIndex(index: Int): Boolean = dimmed && index >= dimmedDetentIndex
234
409
 
235
- // Apply initial properties
236
- setCanceledOnTouchOutside(dismissible)
237
- setCancelable(dismissible)
238
- behavior.isHideable = dismissible
410
+ // =============================================================================
411
+ // MARK: - Sheet Creation & Cleanup
412
+ // =============================================================================
239
413
 
240
- // Apply background color and corner radius
241
- setupBackground()
414
+ fun createSheet() {
415
+ if (coordinatorLayout != null) return
416
+
417
+ // Create coordinator layout
418
+ coordinatorLayout = TrueSheetCoordinatorLayout(reactContext).apply {
419
+ delegate = this@TrueSheetViewController
242
420
  }
243
- }
244
421
 
245
- /**
246
- * Cleans up the dialog instance. Called when dismissed to ensure clean state.
247
- */
248
- private fun cleanupDialog() {
249
- dialog?.apply {
250
- setOnShowListener(null)
251
- setOnCancelListener(null)
252
- setOnDismissListener(null)
422
+ sheetView = TrueSheetBottomSheetView(reactContext).apply {
423
+ delegate = this@TrueSheetViewController
253
424
  }
425
+ }
426
+
427
+ private fun cleanupSheet() {
428
+ cleanupKeyboardObserver()
429
+ sheetView?.animate()?.cancel()
430
+
431
+ // Cleanup dim views
432
+ dimView?.detach()
433
+ dimView = null
434
+ parentDimView?.detach()
435
+ parentDimView = null
254
436
 
255
- // Remove this view from its parent to allow re-attachment on next presentation
256
- sheetContainer?.removeView(this)
437
+ // Cleanup snapshot
438
+ removeSheetSnapshot()
257
439
 
258
- dialog = null
259
- isDragging = false
440
+ // Detach content from sheet
441
+ sheetView?.removeView(this)
442
+
443
+ containerView?.cleanupKeyboardHandler()
444
+ coordinatorLayout = null
445
+ sheetView = null
446
+
447
+ interactionState = InteractionState.Idle
448
+ hasPendingSizeChange = false
449
+ isBeingDismissed = false
260
450
  isPresented = false
451
+ isSheetVisible = false
452
+ wasHiddenByScreen = false
453
+ cachedContentHeight = 0
454
+ cachedHeaderHeight = 0
455
+ isPresentAnimating = false
456
+ lastEmittedPositionPx = -1
457
+ detentIndexBeforeKeyboard = -1
458
+ pendingDetentIndex = -1
459
+ isKeyboardDismissProgrammatic = false
460
+ focusedViewBeforeBlur = null
461
+ shouldAnimatePresent = true
261
462
  }
262
463
 
263
- /**
264
- * Setup dialog lifecycle listeners
265
- */
266
- private fun setupDialogListeners(dialog: BottomSheetDialog) {
267
- // Setup listener when the dialog has been presented
268
- dialog.setOnShowListener {
269
- isPresented = true
270
-
271
- // Re-enable animation
272
- resetAnimation()
273
-
274
- // Wait for the sheet to settle before notifying didPresent
275
- // The sheet animates to its final position after onShow fires
276
- sheetContainer?.post {
277
- // Notify delegate with the settled position
278
- val detentInfo = getDetentInfoForIndex(currentDetentIndex)
279
- delegate?.viewControllerDidPresent(detentInfo.index, detentInfo.position)
280
-
281
- // Emit position change with transitioning=true so Reanimated can animate it
282
- delegate?.viewControllerDidChangePosition(detentInfo.index, detentInfo.position, transitioning = true)
283
-
284
- // Resolve the present promise
285
- presentPromise?.let { promise ->
286
- promise()
287
- presentPromise = null
288
- }
464
+ private var snapshotView: View? = null
289
465
 
290
- // Initialize footer position after layout is complete
291
- positionFooter()
292
- }
466
+ fun createSheetSnapshot() {
467
+ if (!isPresented) return
468
+ val sheet = sheetView ?: return
469
+ if (sheet.width <= 0 || sheet.height <= 0) return
470
+
471
+ val bitmap = createBitmap(sheet.width, sheet.height)
472
+ val canvas = Canvas(bitmap)
473
+ sheet.draw(canvas)
474
+
475
+ val snapshot = ImageView(reactContext).apply {
476
+ setImageBitmap(bitmap)
477
+ layoutParams = LayoutParams(sheet.width, sheet.height)
293
478
  }
294
479
 
295
- // Setup listener when the dialog is about to be dismissed
296
- dialog.setOnCancelListener {
297
- // Notify delegate
298
- delegate?.viewControllerWillDismiss()
480
+ sheet.addView(snapshot, 0)
481
+ snapshotView = snapshot
482
+ }
483
+
484
+ fun removeSheetSnapshot() {
485
+ snapshotView?.let {
486
+ (it as? ImageView)?.setImageDrawable(null)
487
+ sheetView?.removeView(it)
299
488
  }
489
+ snapshotView = null
490
+ }
300
491
 
301
- // Setup listener when the dialog has been dismissed
302
- dialog.setOnDismissListener {
303
- // Resolve the dismiss promise
304
- dismissPromise?.let { promise ->
305
- promise()
306
- dismissPromise = null
307
- }
492
+ // =============================================================================
493
+ // MARK: - TrueSheetCoordinatorLayout.Delegate
494
+ // =============================================================================
308
495
 
309
- // Notify delegate
310
- delegate?.viewControllerDidDismiss()
496
+ override fun coordinatorLayoutDidLayout(changed: Boolean) {
497
+ if (changed) invalidateScreenMetrics()
311
498
 
312
- // Clean up the dialog for next presentation
313
- cleanupDialog()
499
+ // Reposition footer when layout changes
500
+ if (isPresented && changed) {
501
+ positionFooter()
314
502
  }
315
503
  }
316
504
 
317
- /**
318
- * Setup bottom sheet behavior callbacks
319
- */
320
- private fun setupBottomSheetBehavior(dialog: BottomSheetDialog) {
321
- dialog.behavior.addBottomSheetCallback(
322
- object : BottomSheetBehavior.BottomSheetCallback() {
323
- override fun onSlide(sheetView: View, slideOffset: Float) {
324
- val behavior = behavior ?: return
325
-
326
- // Emit position change event continuously during slide
327
- // Set transitioning=false during drag to get real-time position updates
328
- val detentInfo = getCurrentDetentInfo(sheetView)
329
- delegate?.viewControllerDidChangePosition(detentInfo.index, detentInfo.position, transitioning = false)
330
-
331
- when (behavior.state) {
332
- // For consistency with iOS, we consider SETTLING as dragging change
333
- BottomSheetBehavior.STATE_DRAGGING,
334
- BottomSheetBehavior.STATE_SETTLING -> handleDragChange(sheetView)
335
-
336
- else -> { }
337
- }
505
+ override fun coordinatorLayoutDidChangeConfiguration() {
506
+ invalidateScreenMetrics()
507
+ if (!isPresented) return
338
508
 
339
- // Update footer position during slide
340
- positionFooter(slideOffset)
341
- }
509
+ sheetView?.updateGravity()
510
+ updateBehaviorMaxWidth()
511
+ updateStateDimensions()
512
+ sheetView?.let { emitChangePositionDelegate(it.top, realtime = false) }
513
+ }
342
514
 
343
- override fun onStateChanged(sheetView: View, newState: Int) {
344
- // Handle STATE_HIDDEN before checking isPresented
345
- // This ensures we can dismiss even if dialog state gets out of sync
346
- if (newState == BottomSheetBehavior.STATE_HIDDEN) {
347
- dismiss()
348
- return
349
- }
515
+ override fun findScrollView(): ViewGroup? = containerView?.contentView?.findScrollView()
516
+ override fun findSheetView(): TrueSheetBottomSheetView? = sheetView
350
517
 
351
- if (!isPresented) return
518
+ override fun coordinatorLayoutDidPullDown(overdragPx: Int, keyboardWasVisible: Boolean) {
519
+ if (!isPresented || dismissible || !draggable) return
520
+ val sheet = sheetView ?: return
352
521
 
353
- when (newState) {
354
- // When changed to dragging, we know that the drag has started
355
- BottomSheetBehavior.STATE_DRAGGING -> handleDragBegin(sheetView)
522
+ // Only a pull clamped at the lowest stop is a blocked drag-to-dismiss — a
523
+ // child that swallowed the gesture higher up isn't
524
+ val lowestTop = detentCalculator.getLowestSheetTop()
525
+ if (sheet.top < lowestTop) return
356
526
 
357
- // Either of the following state determines drag end
358
- BottomSheetBehavior.STATE_EXPANDED,
359
- BottomSheetBehavior.STATE_COLLAPSED,
360
- BottomSheetBehavior.STATE_HALF_EXPANDED -> handleDragEnd(newState)
527
+ if (keyboardWasVisible) {
528
+ // A sheet that couldn't move at all (first detent already full height) never
529
+ // began a drag, so the keyboard is still up — dismiss it here
530
+ if (hasKeyboardFloor && !isKeyboardDismissProgrammatic) commitDetentAndDismissKeyboard(0, sheet)
361
531
 
362
- else -> {}
363
- }
364
- }
365
- }
366
- )
532
+ // Past the floor by the distance that would hide a dismissible sheet, like iOS
533
+ val floorHeight = realScreenHeight - lowestTop
534
+ if (overdragPx < floorHeight * HIDE_THRESHOLD) return
535
+ }
536
+
537
+ delegate?.viewControllerDidAttemptDismiss()
367
538
  }
368
539
 
369
- // ==================== Presentation ====================
540
+ // =============================================================================
541
+ // MARK: - TrueSheetDimViewDelegate
542
+ // =============================================================================
370
543
 
371
- /**
372
- * Present the sheet.
373
- */
374
- fun present(detentIndex: Int, animated: Boolean = true) {
375
- val dialog = this.dialog ?: run {
376
- // Dialog not created yet - this shouldn't happen but handle gracefully
544
+ override fun dimViewDidTap() {
545
+ val hostView = delegate as? TrueSheetView ?: return
546
+
547
+ val children = TrueSheetStackManager.getSheetsAbove(hostView)
548
+ val topmostChild = children.firstOrNull()?.viewController
549
+
550
+ // If topmost child is dimmed, only handle that child
551
+ if (topmostChild?.isDimmedAtCurrentDetent == true) {
552
+ if (topmostChild.dismissible) {
553
+ topmostChild.dismiss(animated = true)
554
+ }
377
555
  return
378
556
  }
379
557
 
380
- currentDetentIndex = detentIndex
381
- setupDimmedBackground(detentIndex)
558
+ // Pass through to parent - dismiss all if possible
559
+ val allDismissible = dismissible && children.all { it.viewController.dismissible }
560
+ if (allDismissible) {
561
+ children.forEach { it.viewController.dismiss(animated = true) }
562
+ }
382
563
 
383
- if (isPresented) {
384
- // For consistency with iOS, we notify detent change immediately
385
- // when already presented (not waiting for state to change)
386
- val detentInfo = getDetentInfoForIndex(detentIndex)
387
- delegate?.viewControllerDidChangeDetent(detentInfo.index, detentInfo.position)
564
+ if (dismissible) {
565
+ dismiss(animated = true)
566
+ } else if (parentSheetView == null && isDimmedAtCurrentDetent && dimmedDetentIndex > 0) {
567
+ setStateForDetentIndex(dimmedDetentIndex - 1)
568
+ }
569
+ }
388
570
 
389
- // Note: onSlide will be called during resize animation, no need to emit position change here
390
- setStateForDetentIndex(detentIndex)
571
+ // =============================================================================
572
+ // MARK: - TrueSheetBottomSheetViewDelegate - Grabber Tap
573
+ // =============================================================================
574
+
575
+ override fun bottomSheetViewDidTapGrabber() {
576
+ val nextIndex = (currentDetentIndex + 1) % detents.size
577
+ if (nextIndex == 0 && detents.size == 1) {
578
+ if (dismissible) {
579
+ dismiss(animated = true)
580
+ } else {
581
+ delegate?.viewControllerDidAttemptDismiss()
582
+ }
391
583
  } else {
392
- // Reset drag state before presenting
393
- isDragging = false
584
+ setStateForDetentIndex(nextIndex)
585
+ }
586
+ }
394
587
 
395
- setupSheetDetents()
396
- setStateForDetentIndex(detentIndex)
588
+ override fun bottomSheetViewDidAccessibilityIncrement() {
589
+ if (currentDetentIndex < detents.size - 1) {
590
+ setStateForDetentIndex(currentDetentIndex + 1)
591
+ }
592
+ }
397
593
 
398
- // Notify delegate before showing
399
- val detentInfo = getDetentInfoForIndex(detentIndex)
400
- delegate?.viewControllerWillPresent(detentInfo.index, detentInfo.position)
594
+ override fun bottomSheetViewDidAccessibilityDecrement() {
595
+ if (currentDetentIndex > 0) {
596
+ setStateForDetentIndex(currentDetentIndex - 1)
597
+ } else if (dismissible) {
598
+ dismiss(animated = true)
599
+ } else {
600
+ delegate?.viewControllerDidAttemptDismiss()
601
+ }
602
+ }
401
603
 
402
- if (!animated) {
403
- // Disable animation
404
- dialog.window?.setWindowAnimations(0)
405
- }
604
+ // =============================================================================
605
+ // MARK: - BottomSheetCallback
606
+ // =============================================================================
406
607
 
407
- dialog.show()
608
+ private val sheetCallback = object : BottomSheetBehavior.BottomSheetCallback() {
609
+ override fun onStateChanged(sheetView: View, newState: Int) {
610
+ handleStateChanged(sheetView, newState)
611
+ }
612
+
613
+ override fun onSlide(sheetView: View, slideOffset: Float) {
614
+ handleSlide(sheetView)
408
615
  }
409
616
  }
410
617
 
411
- /**
412
- * Dismiss the sheet.
413
- */
414
- fun dismiss() {
415
- this.post {
416
- // Emit position change with transitioning=true to animate dismissal
417
- // Use maxScreenHeight as the off-screen position (sheet slides down off screen)
418
- val offScreenPosition = maxScreenHeight.pxToDp()
419
- delegate?.viewControllerDidChangePosition(currentDetentIndex, offScreenPosition, transitioning = true)
618
+ private fun handleStateChanged(sheetView: View, newState: Int) {
619
+ if (newState == BottomSheetBehavior.STATE_HIDDEN) {
620
+ if (isBeingDismissed) return
621
+ isBeingDismissed = true
622
+ dismissKeyboard()
623
+ emitWillDismissEvents()
624
+ finishDismiss()
625
+ return
420
626
  }
421
627
 
422
- dialog?.dismiss()
628
+ if (!isPresented) return
629
+
630
+ when (newState) {
631
+ BottomSheetBehavior.STATE_DRAGGING -> handleDragBegin(sheetView)
632
+
633
+ BottomSheetBehavior.STATE_SETTLING -> handleSettling(sheetView)
634
+
635
+ BottomSheetBehavior.STATE_EXPANDED,
636
+ BottomSheetBehavior.STATE_COLLAPSED,
637
+ BottomSheetBehavior.STATE_HALF_EXPANDED -> handleStateSettled(sheetView, newState)
638
+
639
+ else -> {}
640
+ }
423
641
  }
424
642
 
425
- // ==================== Configuration ====================
643
+ private fun handleSlide(sheetView: View) {
644
+ // Skip during dismiss animation
645
+ if (isBeingDismissed) return
426
646
 
427
- /**
428
- * Setup sheet detents based on the detent preference.
429
- */
430
- fun setupSheetDetents() {
431
- val behavior = this.behavior ?: return
647
+ val behavior = behavior ?: return
432
648
 
433
- // Configure sheet sizes
434
- behavior.apply {
435
- skipCollapsed = false
436
- isFitToContents = true
649
+ when (behavior.state) {
650
+ BottomSheetBehavior.STATE_DRAGGING -> handleDragChange(sheetView)
437
651
 
438
- // m3 max width 640dp
439
- maxWidth = 640.0.dpToPx().toInt()
652
+ BottomSheetBehavior.STATE_SETTLING -> {
653
+ handleDragChange(sheetView)
654
+ dismissKeyboardForSettleTarget(sheetView)
655
+ }
440
656
 
441
- when (detents.size) {
442
- 1 -> {
443
- val detentHeight = getDetentHeight(detents[0])
444
- maxHeight = detentHeight
445
- skipCollapsed = true
446
- }
657
+ else -> { }
658
+ }
447
659
 
448
- 2 -> {
449
- val peekHeight = getDetentHeight(detents[0])
450
- val maxHeightValue = getDetentHeight(detents[1])
451
- maxHeight = maxHeightValue
452
- setPeekHeight(peekHeight, isPresented)
453
- }
660
+ emitChangePositionDelegate(sheetView.top)
454
661
 
455
- 3 -> {
456
- // Enables half expanded
457
- isFitToContents = false
662
+ // Drive the parent's stack translation from our actual frame position —
663
+ // an independently-timed animation lags behind (e.g. auto detent shrink)
664
+ // and reveals the parent above this sheet's top edge. Skipped while
665
+ // dragging so the parent stays put during drag-to-dismiss, and while the
666
+ // keyboard is active so growing for it doesn't move the parent (like iOS).
667
+ if (!isPresentAnimating && interactionState !is InteractionState.Dragging && !isKeyboardActive) {
668
+ syncParentTranslation(sheetView.top)
669
+ }
458
670
 
459
- val peekHeightValue = getDetentHeight(detents[0])
460
- val middleDetentHeight = getDetentHeight(detents[1])
461
- val maxHeightValue = getDetentHeight(detents[2])
671
+ // Position on every slide frame — during keyboard transitions the IME
672
+ // animation callbacks are sparser than the sheet's settle frames, and the
673
+ // footer rides the sheet between them, jittering against the keyboard.
674
+ // positionFooter derives from current state, so double-driving it with
675
+ // keyboardDidChangeHeight is harmless.
676
+ positionFooter()
462
677
 
463
- setPeekHeight(peekHeightValue, isPresented)
464
- maxHeight = maxHeightValue
465
- expandedOffset = sheetTopInset
466
- halfExpandedRatio = minOf(middleDetentHeight.toFloat() / maxScreenHeight.toFloat(), 1.0f)
678
+ if (!isKeyboardTransitioning) {
679
+ updateDimAmount(sheetView.top)
680
+ }
681
+ }
682
+
683
+ private fun handleStateSettled(sheetView: View, newState: Int) {
684
+ if (interactionState is InteractionState.Reconfiguring) return
685
+
686
+ // A release already at a stop settles without a slide frame
687
+ keyboardSettleStartTop = -1
688
+
689
+ // Sheet has reached a stable state — any in-flight resize target is now stale,
690
+ // whether settled at the target, interrupted by drag, or superseded.
691
+ pendingDetentIndex = -1
692
+
693
+ val index = detentCalculator.getDetentIndexForState(newState) ?: run {
694
+ // Unmapped settle — end any drag anyway so deferred size changes aren't
695
+ // dropped forever (a leaked Dragging state skips every reconfigure)
696
+ if (interactionState is InteractionState.Dragging) {
697
+ interactionState = InteractionState.Idle
698
+ }
699
+ flushPendingSizeChange()
700
+ return
701
+ }
702
+ val position = getPositionDpForView(sheetView)
703
+ val detentInfo = DetentInfo(index, position)
704
+
705
+ // Handle present animation completion
706
+ if (isPresentAnimating) {
707
+ isPresentAnimating = false
708
+ finishPresent()
709
+ return
710
+ }
711
+
712
+ val wasDragging = interactionState is InteractionState.Dragging
713
+
714
+ when (interactionState) {
715
+ is InteractionState.Dragging -> {
716
+ val detent = detentCalculator.getDetentValueForIndex(detentInfo.index)
717
+ delegate?.viewControllerDidDragEnd(detentInfo.index, detentInfo.position, detent)
718
+
719
+ // Skip detent change if keyboard inset is still active — the keyboard floor
720
+ // landing below commits the detent instead.
721
+ if (keyboardInset == 0) {
722
+ val changed = detentInfo.index != knownDetentIndex
723
+ detentIndexBeforeKeyboard = -1
724
+ if (detentInfo.index != currentDetentIndex) {
725
+ currentDetentIndex = detentInfo.index
726
+ setupDimmedBackground()
727
+ }
728
+ if (changed) {
729
+ delegate?.viewControllerDidChangeDetent(detentInfo.index, detentInfo.position, detent)
730
+ this@TrueSheetViewController.sheetView?.updateGrabberAccessibilityValue(detentInfo.index, detents.size)
731
+ }
467
732
  }
733
+
734
+ interactionState = InteractionState.Idle
468
735
  }
469
736
 
470
- // Force a layout update when sheet is presented (e.g., during rotation)
471
- // This ensures the container respects the new maxHeight constraint
472
- if (isPresented) {
473
- sheetContainer?.apply {
474
- val params = layoutParams
475
- params.height = maxHeight
476
- layoutParams = params
737
+ else -> {
738
+ if (detentInfo.index != currentDetentIndex) {
739
+ currentDetentIndex = detentInfo.index
740
+ if (!isKeyboardTransitioning) {
741
+ val detent = detentCalculator.getDetentValueForIndex(detentInfo.index)
742
+ delegate?.viewControllerDidChangeDetent(detentInfo.index, detentInfo.position, detent)
743
+ }
744
+ this@TrueSheetViewController.sheetView?.updateGrabberAccessibilityValue(detentInfo.index, detents.size)
477
745
  }
478
746
  }
479
747
  }
748
+
749
+ if (hasKeyboardFloor && wasDragging && newState != BottomSheetBehavior.STATE_EXPANDED) {
750
+ // Dragged down onto a keyboard-free stop (the floor or the keyboard detent) —
751
+ // dismiss the keyboard and commit that detent. keyboardWillHide reconfigures
752
+ // the detents (and the container) to it.
753
+ commitDetentAndDismissKeyboard(index, sheetView)
754
+ } else {
755
+ // Settled — resize the container to the settled detent (applies deferred shrinks)
756
+ updateStateDimensions()
757
+ }
758
+
759
+ if (!isKeyboardTransitioning) {
760
+ updateDimAmount(animated = true)
761
+ }
762
+
763
+ TrueSheetStackManager.updateBackgroundAccessibility()
764
+
765
+ flushPendingSizeChange()
480
766
  }
481
767
 
482
- /**
483
- * Setup background color and corner radius.
484
- */
485
- fun setupBackground() {
486
- sheetContainer?.apply {
487
- val outerRadii = floatArrayOf(
488
- cornerRadius,
489
- cornerRadius,
490
- cornerRadius,
491
- cornerRadius,
492
- 0f,
493
- 0f,
494
- 0f,
495
- 0f
496
- )
768
+ private fun flushPendingSizeChange() {
769
+ if (!hasPendingSizeChange) return
770
+ hasPendingSizeChange = false
771
+ setupSheetDetentsForSizeChange()
772
+ }
773
+
774
+ // =============================================================================
775
+ // MARK: - Screen Visibility
776
+ // =============================================================================
497
777
 
498
- val background = ShapeDrawable(RoundRectShape(outerRadii, null, null))
499
- background.paint.color = sheetBackgroundColor
778
+ private fun setSheetVisibility(visible: Boolean) {
779
+ coordinatorLayout?.visibility = if (visible) VISIBLE else GONE
780
+ dimViews.forEach { it.visibility = if (visible) VISIBLE else INVISIBLE }
781
+ }
500
782
 
501
- this.background = background
502
- this.clipToOutline = true
783
+ internal fun hideForScreen() {
784
+ val sheet = sheetView ?: run {
785
+ RNLog.e(reactContext, "TrueSheet: sheetView is null in hideForScreen")
786
+ return
503
787
  }
788
+
789
+ isSheetVisible = false
790
+ wasHiddenByScreen = true
791
+ TrueSheetStackManager.updateBackgroundAccessibility()
792
+ delegate?.viewControllerDidChangeVisibility(false)
793
+ dimViews.forEach { it.animate().alpha(0f).setDuration(SCREEN_FADE_DURATION).start() }
794
+ sheet.animate()
795
+ .alpha(0f)
796
+ .setDuration(SCREEN_FADE_DURATION)
797
+ .withEndAction {
798
+ setSheetVisibility(false)
799
+ }
800
+ .start()
801
+
802
+ // This will hide parent sheets first
803
+ parentSheetView?.viewController?.hideForScreen()
804
+ }
805
+
806
+ internal fun showAfterScreen() {
807
+ isSheetVisible = true
808
+ delegate?.viewControllerDidChangeVisibility(true)
809
+ setSheetVisibility(true)
810
+ sheetView?.alpha = 1f
811
+ updateDimAmount(animated = true)
812
+ TrueSheetStackManager.updateBackgroundAccessibility()
504
813
  }
505
814
 
506
815
  /**
507
- * Setup dimmed sheet.
508
- * `dimmedIndex` will further customize the dimming behavior.
816
+ * Re-applies hidden state after returning from background.
817
+ * Android may restore visibility on activity resume, so we need to hide it again.
509
818
  */
510
- fun setupDimmedBackground(detentIndex: Int) {
511
- val dialog = this.dialog ?: return
512
- dialog.window?.apply {
513
- val view = findViewById<View>(com.google.android.material.R.id.touch_outside)
514
-
515
- if (dimmed && detentIndex >= dimmedIndex) {
516
- // Remove touch listener
517
- view.setOnTouchListener(null)
518
-
519
- // Add the dimmed background
520
- setFlags(
521
- WindowManager.LayoutParams.FLAG_DIM_BEHIND,
522
- WindowManager.LayoutParams.FLAG_DIM_BEHIND
523
- )
524
-
525
- dialog.setCanceledOnTouchOutside(dismissible)
526
- } else {
527
- // Override the background touch and pass it to the components outside
528
- view.setOnTouchListener { v, event ->
529
- event.setLocation(event.rawX - v.x, event.rawY - v.y)
530
- reactContext.currentActivity?.dispatchTouchEvent(event)
531
- false
532
- }
819
+ fun reapplyHiddenState() {
820
+ if (!wasHiddenByScreen) return
821
+ setSheetVisibility(false)
822
+ }
823
+
824
+ // =============================================================================
825
+ // MARK: - Presentation
826
+ // =============================================================================
827
+
828
+ fun present(detentIndex: Int, animated: Boolean = true) {
829
+ val coordinator = this.coordinatorLayout ?: return
830
+ val sheet = this.sheetView ?: return
831
+ if (isPresented) return
832
+
833
+ invalidateScreenMetrics()
834
+ shouldAnimatePresent = animated
835
+ currentDetentIndex = detentIndex
836
+ interactionState = InteractionState.Idle
837
+
838
+ // Setup sheet in coordinator layout
839
+ setupSheetInCoordinator(coordinator, sheet)
533
840
 
534
- // Remove the dimmed background
535
- clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND)
841
+ emitWillPresentEvents()
536
842
 
537
- dialog.setCanceledOnTouchOutside(false)
843
+ setupSheetDetents()
844
+ setupDimmedBackground()
845
+ setupKeyboardObserver()
846
+
847
+ sheet.setupBackground()
848
+ sheet.setupElevation()
849
+ sheet.setupGrabber()
850
+ sheet.setupAccessibility()
851
+
852
+ if (shouldAnimatePresent) {
853
+ isPresentAnimating = true
854
+ post { setStateForDetentIndex(currentDetentIndex) }
855
+ } else {
856
+ setStateForDetentIndex(currentDetentIndex)
857
+ post {
858
+ emitChangePositionDelegate(detentCalculator.getSheetTopForDetentIndex(currentDetentIndex))
859
+ updateDimAmount()
860
+ finishPresent()
538
861
  }
539
862
  }
863
+
864
+ isPresented = true
865
+ isSheetVisible = true
540
866
  }
541
867
 
542
- fun resetAnimation() {
543
- dialog?.window?.apply {
544
- setWindowAnimations(windowAnimation)
868
+ fun resize(detentIndex: Int) {
869
+ if (!isPresented) return
870
+
871
+ // Commit the new index so keyboardWillHide restores to it instead of the stale
872
+ // one; the sheet keeps avoiding the keyboard meanwhile
873
+ if (detentIndexBeforeKeyboard >= 0) {
874
+ detentIndexBeforeKeyboard = detentIndex
875
+ if (hasKeyboardFloor) {
876
+ resizePromise?.invoke()
877
+ resizePromise = null
878
+ return
879
+ }
545
880
  }
881
+
882
+ pendingDetentIndex = detentIndex
883
+ updateStateDimensions(deferShrink = true)
884
+ setupDimmedBackground()
885
+ setStateForDetentIndex(detentIndex)
886
+ resizePromise?.invoke()
887
+ resizePromise = null
546
888
  }
547
889
 
548
- fun positionFooter(slideOffset: Float? = null) {
549
- val footer = footerView ?: return
550
- val bottomSheet = bottomSheetView ?: return
551
- val footerHeight = footer.height
890
+ private fun setupSheetInCoordinator(coordinator: TrueSheetCoordinatorLayout, sheet: TrueSheetBottomSheetView) {
891
+ // Add this controller as content to the sheet
892
+ (parent as? ViewGroup)?.removeView(this)
893
+ sheet.addView(this)
894
+
895
+ // Create layout params with behavior
896
+ val params = sheet.createLayoutParams()
552
897
 
553
- val bottomSheetY = ScreenUtils.getScreenY(bottomSheet)
898
+ @Suppress("UNCHECKED_CAST")
899
+ val behavior = params.behavior as TrueSheetBottomSheetBehavior<TrueSheetBottomSheetView>
554
900
 
555
- // Calculate footer Y position based on bottom sheet position
556
- var footerY = (maxScreenHeight - bottomSheetY - footerHeight).toFloat()
901
+ // Configure behavior
902
+ behavior.isHideable = true
903
+ behavior.isDraggable = draggable
904
+ behavior.scrollingExpandsSheet = scrollableOptions?.scrollingExpandsSheet ?: true
905
+ behavior.state = BottomSheetBehavior.STATE_HIDDEN
906
+ behavior.addBottomSheetCallback(sheetCallback)
557
907
 
558
- // Animate footer down with sheet when below peek height
559
- if (slideOffset != null && slideOffset < 0) {
560
- footerY -= (footerHeight * slideOffset)
908
+ // Add sheet to coordinator
909
+ coordinator.addView(sheet, params)
910
+ }
911
+
912
+ fun dismiss(animated: Boolean = true) {
913
+ if (isBeingDismissed) return
914
+
915
+ isBeingDismissed = true
916
+ dismissKeyboard()
917
+ emitWillDismissEvents()
918
+
919
+ if (animated) {
920
+ animateDismiss()
921
+ } else {
922
+ emitChangePositionDelegate(realScreenHeight)
923
+ finishDismiss()
561
924
  }
925
+ }
926
+
927
+ private fun dismissKeyboard() {
928
+ isKeyboardDismissProgrammatic = true
929
+ KeyboardUtils.dismiss(reactContext)
562
930
 
563
- // Clamp footer position to prevent it from going off screen when positioning at the top
564
- // This happens when fullScreen is enabled in edge-to-edge mode
565
- val statusBarHeight = ScreenUtils.getStatusBarHeight(reactContext)
566
- val maxAllowedY = (maxScreenHeight - statusBarHeight - footerHeight).toFloat()
567
- footer.y = minOf(footerY, maxAllowedY)
931
+ // Clear focus from any focused view within the sheet to prevent
932
+ // Android from autofocusing the next focusable view on the main
933
+ // screen when the sheet is removed from the hierarchy.
934
+ sheetView?.findFocus()?.clearFocus()
568
935
  }
569
936
 
570
- /**
571
- * Set the state based for the given detent index.
572
- */
573
- fun setStateForDetentIndex(index: Int) {
574
- behavior?.state = getStateForDetentIndex(index)
937
+ fun handleBackPress() {
938
+ if (dismissible) {
939
+ dismiss(animated = true)
940
+ }
575
941
  }
576
942
 
577
- fun setSoftInputMode(mode: Int) {
578
- dialog?.window?.setSoftInputMode(mode)
943
+ private fun animateDismiss() {
944
+ val sheet = sheetView ?: run {
945
+ finishDismiss()
946
+ return
947
+ }
948
+
949
+ sheet.animate()
950
+ .y(realScreenHeight.toFloat())
951
+ .setDuration(DISMISS_DURATION)
952
+ .setInterpolator(android.view.animation.AccelerateInterpolator())
953
+ .setUpdateListener { updateSheetVisuals(sheet.y.toInt()) }
954
+ .withEndAction { finishDismiss() }
955
+ .start()
579
956
  }
580
957
 
581
- // ==================== Drag Handling ====================
958
+ private fun finishPresent() {
959
+ // Restore isHideable to actual value after present animation
960
+ behavior?.isHideable = dismissible
582
961
 
583
- /**
584
- * Get current detent info from sheet view position
585
- */
586
- private fun getCurrentDetentInfo(sheetView: View): DetentInfo {
587
- // Get the Y position in screen coordinates (like iOS presentedView.frame.origin.y)
588
- val screenY = ScreenUtils.getScreenY(sheetView)
589
- val position = screenY.pxToDp()
590
- return DetentInfo(currentDetentIndex, position)
962
+ containerView?.setupKeyboardHandler()
963
+
964
+ val (index, position, detent) = getDetentInfoWithValue(currentDetentIndex)
965
+ delegate?.viewControllerDidPresent(index, position, detent)
966
+ parentSheetView?.viewControllerDidBlur()
967
+ delegate?.viewControllerDidFocus()
968
+ sheetView?.updateGrabberAccessibilityValue(index, detents.size)
969
+
970
+ TrueSheetStackManager.updateBackgroundAccessibility()
971
+ // Move accessibility focus into the sheet
972
+ sheetView?.sendAccessibilityEvent(AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED)
973
+
974
+ presentPromise?.invoke()
975
+ presentPromise = null
591
976
  }
592
977
 
593
- /**
594
- * Handle drag begin
595
- */
596
- private fun handleDragBegin(sheetView: View) {
597
- val detentInfo = getCurrentDetentInfo(sheetView)
598
- delegate?.viewControllerDidDragBegin(detentInfo.index, detentInfo.position)
599
- isDragging = true
978
+ private fun finishDismiss() {
979
+ TrueSheetStackManager.updateBackgroundAccessibility()
980
+ restoreFocusedView()
981
+ emitDidDismissEvents()
982
+ cleanupSheet()
600
983
  }
601
984
 
602
- /**
603
- * Handle drag change
604
- */
605
- private fun handleDragChange(sheetView: View) {
606
- if (!isDragging) return
985
+ // =============================================================================
986
+ // MARK: - Sheet Configuration
987
+ // =============================================================================
607
988
 
608
- val detentInfo = getCurrentDetentInfo(sheetView)
609
- delegate?.viewControllerDidDragChange(detentInfo.index, detentInfo.position)
610
- }
989
+ fun setupSheetDetents(applyState: Boolean = true) {
990
+ val behavior = this.behavior ?: run {
991
+ RNLog.e(reactContext, "TrueSheet: behavior is null in setupSheetDetents")
992
+ return
993
+ }
611
994
 
612
- /**
613
- * Handle drag end
614
- */
615
- private fun handleDragEnd(state: Int) {
616
- if (!isDragging) return
617
-
618
- // For consistency with iOS,
619
- // we only handle state changes after dragging.
620
- //
621
- // Changing detent programmatically is handled via the present method.
622
- val detentInfo = getDetentInfoForState(state)
623
- detentInfo?.let {
624
- // Notify delegate of drag end
625
- delegate?.viewControllerDidDragEnd(it.index, it.position)
626
-
627
- if (it.index != currentDetentIndex) {
628
- presentPromise?.let { promise ->
629
- promise()
630
- presentPromise = null
631
- }
995
+ containerView?.let {
996
+ cachedContentHeight = it.contentHeight
997
+ cachedHeaderHeight = it.headerHeight
998
+ cachedFooterHeight = it.footerHeight
999
+ cachedPeekContentHeight = it.peekContentHeight
1000
+ }
632
1001
 
633
- currentDetentIndex = it.index
634
- setupDimmedBackground(it.index)
1002
+ interactionState = InteractionState.Reconfiguring
635
1003
 
636
- // Notify delegate of detent change
637
- delegate?.viewControllerDidChangeDetent(it.index, it.position)
638
- }
1004
+ behavior.isFitToContents = false
1005
+
1006
+ val maxAvailableHeight = realScreenHeight - topInset
1007
+
1008
+ // With a keyboard floor only the last detent stays keyboard-shifted; the
1009
+ // collapsed and half stops are the keyboard-free first and second detents,
1010
+ // so a short drag down from the keyboard-expanded sheet rests at the second
1011
+ // and a long one at the first, like iOS
1012
+ val peekHeight = minOf(detentCalculator.getDetentHeight(detents[0], includeKeyboard = !hasKeyboardFloor), maxAvailableHeight)
1013
+
1014
+ val halfExpandedDetentHeight = when (detents.size) {
1015
+ 1 -> peekHeight
1016
+ else -> detentCalculator.getDetentHeight(detents[1], includeKeyboard = !hasKeyboardFloor)
639
1017
  }
640
1018
 
641
- isDragging = false
1019
+ val maxDetentHeight = minOf(detentCalculator.getDetentHeight(detents.last()), maxAvailableHeight)
1020
+
1021
+ val adjustedHalfExpandedHeight = minOf(halfExpandedDetentHeight, maxAvailableHeight)
1022
+ val halfExpandedRatio = (adjustedHalfExpandedHeight.toFloat() / realScreenHeight.toFloat())
1023
+
1024
+ val expandedOffset = realScreenHeight - maxDetentHeight
1025
+
1026
+ // fitToContents works better with <= 2 detents when no expanded offset, but
1027
+ // it drops the half stop the keyboard floor relies on
1028
+ val fitToContents = !hasKeyboardFloor && detents.size < 3 && expandedOffset == 0
1029
+
1030
+ configureDetents(
1031
+ behavior = behavior,
1032
+ peekHeight = peekHeight,
1033
+ halfExpandedRatio = halfExpandedRatio,
1034
+ expandedOffset = expandedOffset,
1035
+ fitToContents = fitToContents,
1036
+ animate = isPresented
1037
+ )
1038
+
1039
+ updateStateDimensions()
1040
+
1041
+ if (isPresented && applyState) {
1042
+ // Prefer the pending target while a resize animation is in flight so a
1043
+ // layout-driven reconfigure doesn't revert to the stale currentDetentIndex.
1044
+ val targetIndex = if (pendingDetentIndex >= 0) pendingDetentIndex else currentDetentIndex
1045
+ setStateForDetentIndex(targetIndex)
1046
+ }
1047
+
1048
+ interactionState = InteractionState.Idle
642
1049
  }
643
1050
 
644
- // ==================== Detent Calculations ====================
1051
+ private fun configureDetents(
1052
+ behavior: BottomSheetBehavior<TrueSheetBottomSheetView>,
1053
+ peekHeight: Int,
1054
+ halfExpandedRatio: Float,
1055
+ expandedOffset: Int,
1056
+ fitToContents: Boolean,
1057
+ animate: Boolean
1058
+ ) {
1059
+ behavior.apply {
1060
+ isFitToContents = fitToContents
1061
+ skipCollapsed = false
1062
+ setPeekHeight(peekHeight, animate)
1063
+ this.halfExpandedRatio = halfExpandedRatio.coerceIn(0.01f, 0.999f)
1064
+ this.expandedOffset = expandedOffset
1065
+ }
1066
+ }
645
1067
 
646
- /**
647
- * Get the height value based on the detent config value.
648
- */
649
- private fun getDetentHeight(detent: Double): Int {
650
- val height: Int = if (detent == -1.0) {
651
- // -1.0 represents "auto"
652
- contentHeight
653
- } else {
654
- if (detent <= 0.0 || detent > 1.0) {
655
- throw IllegalArgumentException("TrueSheet: detent fraction ($detent) must be between 0 and 1")
656
- }
657
- (detent * maxScreenHeight).toInt()
1068
+ fun setupSheetDetentsForSizeChange() {
1069
+ // Defer while dragging — the size change is driven by the drag itself (the
1070
+ // container tracks the sheet's visible height), and reconfiguring resets
1071
+ // behavior state, killing the gesture. Applied on settle so a real content
1072
+ // change mid-drag (e.g. items removed from a list) isn't lost.
1073
+ if (interactionState is InteractionState.Dragging) {
1074
+ hasPendingSizeChange = true
1075
+ return
658
1076
  }
1077
+ hasPendingSizeChange = false
659
1078
 
660
- // Apply top inset when edge-to-edge is enabled and fullScreen is false
661
- val maxAllowedHeight = maxScreenHeight - sheetTopInset
662
- val finalHeight = maxSheetHeight?.let { minOf(height, it, maxAllowedHeight) } ?: minOf(height, maxAllowedHeight)
663
- return finalHeight
1079
+ setupSheetDetents()
1080
+ positionFooter()
664
1081
  }
665
1082
 
666
- /**
667
- * Determines the state based from the given detent index.
668
- */
669
- private fun getStateForDetentIndex(index: Int): Int =
670
- when (detents.size) {
671
- 1 -> {
672
- BottomSheetBehavior.STATE_EXPANDED
673
- }
1083
+ fun setStateForDetentIndex(index: Int) {
1084
+ behavior?.state = detentCalculator.getStateForDetentIndex(index)
1085
+ }
1086
+
1087
+ // =============================================================================
1088
+ // MARK: - Dimmed Background
1089
+ // =============================================================================
674
1090
 
675
- 2 -> {
676
- when (index) {
677
- 0 -> BottomSheetBehavior.STATE_COLLAPSED
678
- 1 -> BottomSheetBehavior.STATE_EXPANDED
679
- else -> BottomSheetBehavior.STATE_HIDDEN
1091
+ fun setupDimmedBackground() {
1092
+ val coordinator = this.coordinatorLayout ?: run {
1093
+ RNLog.e(reactContext, "TrueSheet: coordinatorLayout is null in setupDimmedBackground")
1094
+ return
1095
+ }
1096
+
1097
+ if (dimmed) {
1098
+ val parentDimVisible = (parentSheetView?.viewController?.dimView?.alpha ?: 0f) > 0f
1099
+
1100
+ if (dimView == null) {
1101
+ dimView = TrueSheetDimView(reactContext).apply {
1102
+ delegate = this@TrueSheetViewController
680
1103
  }
681
1104
  }
1105
+ if (!parentDimVisible) {
1106
+ dimView?.attachToCoordinator(coordinator)
1107
+ }
682
1108
 
683
- 3 -> {
684
- when (index) {
685
- 0 -> BottomSheetBehavior.STATE_COLLAPSED
686
- 1 -> BottomSheetBehavior.STATE_HALF_EXPANDED
687
- 2 -> BottomSheetBehavior.STATE_EXPANDED
688
- else -> BottomSheetBehavior.STATE_HIDDEN
1109
+ // Attach dim view to parent sheet if stacked
1110
+ val parentController = parentSheetView?.viewController
1111
+ val parentBottomSheet = parentController?.sheetView
1112
+ if (parentBottomSheet != null) {
1113
+ if (parentDimView == null) {
1114
+ parentDimView = TrueSheetDimView(reactContext).apply {
1115
+ delegate = this@TrueSheetViewController
1116
+ }
689
1117
  }
1118
+ parentDimView?.attach(parentBottomSheet, parentController.sheetCornerRadius)
690
1119
  }
1120
+ } else {
1121
+ dimView?.detach()
1122
+ dimView = null
1123
+ parentDimView?.detach()
1124
+ parentDimView = null
1125
+ }
1126
+ }
691
1127
 
692
- else -> BottomSheetBehavior.STATE_HIDDEN
1128
+ fun updateDimAmount(sheetTop: Int? = null, animated: Boolean = false) {
1129
+ if (!dimmed) return
1130
+ if (contentHeight == 0) return
1131
+
1132
+ // While keyboard is active or transitioning, use the target detent position for dim
1133
+ val top = if (isKeyboardActive) {
1134
+ detentCalculator.getSheetTopForDetentIndex(currentDetentIndex)
1135
+ } else {
1136
+ val keyboardOffset = if (isBeingDismissed) 0 else currentKeyboardInset
1137
+ (sheetTop ?: sheetView?.top ?: return) + keyboardOffset
693
1138
  }
694
1139
 
695
- /**
696
- * Get the DetentInfo data by state.
697
- */
698
- fun getDetentInfoForState(state: Int): DetentInfo? =
699
- when (detents.size) {
700
- 1 -> {
701
- when (state) {
702
- BottomSheetBehavior.STATE_COLLAPSED -> DetentInfo(0, getPositionForDetentIndex(0))
703
- BottomSheetBehavior.STATE_EXPANDED -> DetentInfo(0, getPositionForDetentIndex(0))
704
- else -> null
1140
+ if (animated) {
1141
+ val targetAlpha = dimView?.calculateAlpha(
1142
+ top,
1143
+ dimmedDetentIndex,
1144
+ detentCalculator::getSheetTopForDetentIndex
1145
+ ) ?: 0f
1146
+ dimViews.forEach { it.animate().alpha(targetAlpha).setDuration(200).start() }
1147
+ } else {
1148
+ dimViews.forEach { it.interpolateAlpha(top, dimmedDetentIndex, detentCalculator::getSheetTopForDetentIndex) }
1149
+ }
1150
+ }
1151
+
1152
+ // =============================================================================
1153
+ // MARK: - Footer Positioning
1154
+ // =============================================================================
1155
+
1156
+ var footerKeyboardOffset: Int = 0
1157
+
1158
+ fun positionFooter() {
1159
+ if (!isPresented) return
1160
+ val footerView = containerView?.footerView ?: return
1161
+ val sheet = sheetView ?: return
1162
+
1163
+ // A relative footer is laid out by Yoga (pinned to the container's bottom
1164
+ // edge) and stays in the layout flow behind the keyboard — only an
1165
+ // absolute footer floats above it
1166
+ if (!absoluteFooter) {
1167
+ footerView.translationY = 0f
1168
+ return
1169
+ }
1170
+
1171
+ // The footer's absorbed safe-area inset rides below the keyboard's top
1172
+ // edge — its content stays flush with the keyboard without dropping the
1173
+ // inset padding from the layout, which would jump at the transition edges
1174
+ val keyboardShift = if (currentKeyboardInset > 0) {
1175
+ maxOf(0, currentKeyboardInset + footerKeyboardOffset - footerView.bottomInset)
1176
+ } else {
1177
+ 0
1178
+ }
1179
+
1180
+ val footerHeight = footerView.height
1181
+ val sheetHeight = sheet.height
1182
+
1183
+ // Pinned to the screen bottom (above the keyboard) while the sheet sits at
1184
+ // or above its lowest stop; past it the footer rides down with the sheet
1185
+ // instead, like iOS
1186
+ val anchorTop = minOf(sheet.top, detentCalculator.getLowestSheetTop())
1187
+ val footerY = (sheetHeight - anchorTop - footerHeight - keyboardShift).toFloat()
1188
+
1189
+ // Clamp to prevent footer going above safe area
1190
+ val maxAllowedY = (sheetHeight - topInset - footerHeight).toFloat()
1191
+ footerView.y = minOf(footerY, maxAllowedY)
1192
+ }
1193
+
1194
+ // =============================================================================
1195
+ // MARK: - Keyboard Handling
1196
+ // =============================================================================
1197
+
1198
+ private fun shouldHandleKeyboard(checkFocus: Boolean = true): Boolean {
1199
+ if (wasHiddenByScreen) return false
1200
+ if (!isTopmostSheet) return false
1201
+ if (checkFocus && !isFocusedViewWithinSheet()) return false
1202
+ return true
1203
+ }
1204
+
1205
+ fun saveFocusedView() {
1206
+ focusedViewBeforeBlur = delegate?.findRootContainerView()?.findFocus()
1207
+ ?: reactContext.currentActivity?.currentFocus
1208
+ }
1209
+
1210
+ fun restoreFocusedView() {
1211
+ val viewToFocus = focusedViewBeforeBlur ?: return
1212
+ focusedViewBeforeBlur = null
1213
+
1214
+ if (!viewToFocus.isAttachedToWindow) return
1215
+ if (viewToFocus.requestFocus()) {
1216
+ viewToFocus.postDelayed({
1217
+ KeyboardUtils.show(viewToFocus)
1218
+ }, 100)
1219
+ }
1220
+ }
1221
+
1222
+ fun setupKeyboardObserver() {
1223
+ val coordinator = coordinatorLayout ?: run {
1224
+ RNLog.e(reactContext, "TrueSheet: coordinatorLayout is null in setupKeyboardObserver")
1225
+ return
1226
+ }
1227
+ cleanupKeyboardObserver()
1228
+ keyboardObserver = TrueSheetKeyboardObserver(coordinator, reactContext).apply {
1229
+ delegate = object : TrueSheetKeyboardObserverDelegate {
1230
+ override fun keyboardWillShow(height: Int) {
1231
+ if (!shouldHandleKeyboard()) return
1232
+ // If a resize is in flight, restore to its target — not the stale current
1233
+ detentIndexBeforeKeyboard = if (pendingDetentIndex >= 0) pendingDetentIndex else currentDetentIndex
1234
+ pendingDetentIndex = -1
1235
+ // Commit the target index first so the container grows before the sheet expands
1236
+ currentDetentIndex = detents.size - 1
1237
+ setupSheetDetents()
1238
+ positionFooter()
1239
+ updateDimAmount(animated = true)
705
1240
  }
706
- }
707
1241
 
708
- 2 -> {
709
- when (state) {
710
- BottomSheetBehavior.STATE_COLLAPSED -> DetentInfo(0, getPositionForDetentIndex(0))
711
- BottomSheetBehavior.STATE_EXPANDED -> DetentInfo(1, getPositionForDetentIndex(1))
712
- else -> null
1242
+ override fun keyboardWillHide() {
1243
+ if (!shouldHandleKeyboard(checkFocus = false)) return
1244
+ // Mid-drag (the drag itself dismissed the keyboard) the release decides
1245
+ // the detent and a reconfigure would kill the gesture — the floor stops
1246
+ // stay put until the settle flushes the pending reconfigure
1247
+ if (interactionState is InteractionState.Dragging) {
1248
+ hasPendingSizeChange = true
1249
+ return
1250
+ }
1251
+ val restoring = !isBeingDismissed && detentIndexBeforeKeyboard >= 0
1252
+
1253
+ // Skip reconfigure during interactive keyboard dismiss (e.g. keyboardDismissMode="on-drag")
1254
+ // to prevent the sheet from jumping. keyboardDidHide will reconfigure after.
1255
+ if (restoring || isKeyboardDismissProgrammatic) {
1256
+ setupSheetDetents()
1257
+ if (restoring) {
1258
+ currentDetentIndex = detentIndexBeforeKeyboard
1259
+ setStateForDetentIndex(currentDetentIndex)
1260
+ }
1261
+ }
1262
+
1263
+ // Same-frame reposition — the reconfigure above shrinks the container
1264
+ // to the target detent, which yanks the bottom-pinned footer up until
1265
+ // the next keyboard animation frame repositions it.
1266
+ positionFooter()
1267
+ updateDimAmount(
1268
+ sheetTop = detentCalculator.getSheetTopForDetentIndex(currentDetentIndex),
1269
+ animated = true
1270
+ )
713
1271
  }
714
- }
715
1272
 
716
- 3 -> {
717
- when (state) {
718
- BottomSheetBehavior.STATE_COLLAPSED -> DetentInfo(0, getPositionForDetentIndex(0))
719
- BottomSheetBehavior.STATE_HALF_EXPANDED -> DetentInfo(1, getPositionForDetentIndex(1))
720
- BottomSheetBehavior.STATE_EXPANDED -> DetentInfo(2, getPositionForDetentIndex(2))
721
- else -> null
1273
+ override fun keyboardDidHide() {
1274
+ if (!shouldHandleKeyboard(checkFocus = false)) return
1275
+ isKeyboardDismissProgrammatic = false
1276
+ // Keep detentIndexBeforeKeyboard for the settle's detent-change check
1277
+ if (interactionState is InteractionState.Dragging) {
1278
+ hasPendingSizeChange = true
1279
+ return
1280
+ }
1281
+ detentIndexBeforeKeyboard = -1
1282
+ setupSheetDetents(applyState = false)
1283
+ positionFooter()
1284
+ updateDimAmount(
1285
+ sheetTop = detentCalculator.getSheetTopForDetentIndex(currentDetentIndex),
1286
+ animated = true
1287
+ )
722
1288
  }
723
- }
724
1289
 
725
- else -> null
1290
+ override fun keyboardDidChangeHeight(height: Int) {
1291
+ // Skip focus check during active keyboard transitions (focus may be lost during hide)
1292
+ val skipFocusCheck = detentIndexBeforeKeyboard >= 0 || isKeyboardTransitioning
1293
+ if (!shouldHandleKeyboard(checkFocus = !skipFocusCheck)) return
1294
+ positionFooter()
1295
+ }
1296
+
1297
+ override fun focusDidChange(newFocus: View) {
1298
+ // Handle case where keyboard is already visible and focus moves into the sheet
1299
+ if (!shouldHandleKeyboard()) return
1300
+ if (detentIndexBeforeKeyboard < 0 && (keyboardObserver?.currentHeight ?: 0) > 0) {
1301
+ detentIndexBeforeKeyboard = currentDetentIndex
1302
+ currentDetentIndex = detents.size - 1
1303
+ setupSheetDetents()
1304
+ positionFooter()
1305
+ }
1306
+ }
1307
+ }
1308
+ start()
726
1309
  }
1310
+ }
1311
+
1312
+ fun cleanupKeyboardObserver() {
1313
+ keyboardObserver?.stop()
1314
+ keyboardObserver = null
1315
+ }
1316
+
1317
+ // =============================================================================
1318
+ // MARK: - Drag Handling
1319
+ // =============================================================================
1320
+
1321
+ private fun getPositionDpForView(sheetView: View): Float =
1322
+ detentCalculator.getPositionDp(detentCalculator.getVisibleSheetHeight(sheetView.top))
1323
+
1324
+ fun commitKeyboardDetent() {
1325
+ detentIndexBeforeKeyboard = -1
1326
+ }
1327
+
1328
+ private fun handleSettling(sheetView: View) {
1329
+ // A keyboard-floor drag was released — the first settle frame reveals the target
1330
+ keyboardSettleStartTop = if (hasKeyboardFloor && interactionState is InteractionState.Dragging) sheetView.top else -1
1331
+ }
727
1332
 
728
1333
  /**
729
- * Calculate the expected Y position for a given detent index.
730
- * Uses actual screen position if available, otherwise calculates based on screen height.
1334
+ * Dismisses the keyboard as soon as a released drag is known to land on a
1335
+ * keyboard-free stop, rather than waiting for the landing. BottomSheetBehavior
1336
+ * keeps its target private, so read it off the first settle frame: heading
1337
+ * down means the half stop or the floor; heading up from below the half stop
1338
+ * means the half stop; only heading up from above it means the expanded stop.
731
1339
  */
732
- private fun getPositionForDetentIndex(index: Int): Float {
733
- if (index < 0 || index >= detents.size) {
734
- return 0f
735
- }
1340
+ private fun dismissKeyboardForSettleTarget(sheetView: View) {
1341
+ if (keyboardSettleStartTop < 0) return
1342
+ val startTop = keyboardSettleStartTop
1343
+ keyboardSettleStartTop = -1
736
1344
 
737
- // Try to get actual position from bottom sheet view first (same view used in behavior callbacks)
738
- bottomSheetView?.let {
739
- it
740
- val screenY = ScreenUtils.getScreenY(it)
741
- // Only use actual position if sheet has been positioned (screenY > 0)
742
- if (screenY > 0) {
743
- return screenY.pxToDp()
744
- }
745
- }
1345
+ val headingToKeyboardFreeStop = sheetView.top > startTop || startTop > detentCalculator.getKeyboardHalfStopTop()
1346
+ if (headingToKeyboardFreeStop && shouldHandleKeyboard()) dismissKeyboard()
1347
+ }
1348
+
1349
+ private fun handleDragBegin(sheetView: View) {
1350
+ pendingDetentIndex = -1
1351
+
1352
+ val position = getPositionDpForView(sheetView)
1353
+ val detent = detentCalculator.getDetentValueForIndex(currentDetentIndex)
1354
+ delegate?.viewControllerDidDragBegin(currentDetentIndex, position, detent)
1355
+ interactionState = InteractionState.Dragging(startTop = sheetView.top)
1356
+ }
1357
+
1358
+ // Commits a non-keyboard detent and hides the keyboard; keyboardWillHide then
1359
+ // reconfigures the detents so the sheet settles at the committed index.
1360
+ private fun commitDetentAndDismissKeyboard(index: Int, sheetView: View) {
1361
+ val changed = index != knownDetentIndex
1362
+ detentIndexBeforeKeyboard = -1
1363
+ currentDetentIndex = index
1364
+ dismissKeyboard()
1365
+ setupDimmedBackground()
1366
+
1367
+ if (!changed) return
1368
+ val position = getPositionDpForView(sheetView)
1369
+ val detent = detentCalculator.getDetentValueForIndex(index)
1370
+ delegate?.viewControllerDidChangeDetent(index, position, detent)
1371
+ this.sheetView?.updateGrabberAccessibilityValue(index, detents.size)
1372
+ }
1373
+
1374
+ private fun handleDragChange(sheetView: View) {
1375
+ if (interactionState !is InteractionState.Dragging) return
1376
+
1377
+ updateStateDimensionsForDrag(sheetView.top)
1378
+
1379
+ val position = getPositionDpForView(sheetView)
1380
+ val detent = detentCalculator.getDetentValueForIndex(currentDetentIndex)
1381
+ delegate?.viewControllerDidDragChange(currentDetentIndex, position, detent)
1382
+ }
1383
+
1384
+ // =============================================================================
1385
+ // MARK: - Event Emission
1386
+ // =============================================================================
1387
+
1388
+ private fun emitWillPresentEvents() {
1389
+ val (index, position, detent) = getDetentInfoWithValue(currentDetentIndex)
1390
+ parentSheetView?.viewControllerWillBlur()
1391
+ delegate?.viewControllerWillPresent(index, position, detent)
1392
+ delegate?.viewControllerWillFocus()
1393
+ }
1394
+
1395
+ private fun emitWillDismissEvents() {
1396
+ delegate?.viewControllerWillBlur()
1397
+ delegate?.viewControllerWillDismiss()
1398
+ parentSheetView?.viewControllerWillFocus()
1399
+ }
1400
+
1401
+ private fun emitDidDismissEvents() {
1402
+ val parent = parentSheetView
1403
+ parentSheetView = null
1404
+
1405
+ delegate?.viewControllerDidBlur()
1406
+ delegate?.viewControllerDidDismiss(parent)
1407
+
1408
+ dismissPromise?.invoke()
1409
+ dismissPromise = null
1410
+ }
746
1411
 
747
- // Fallback: calculate expected position
748
- val detentHeight = getDetentHeight(detents[index])
1412
+ private fun emitChangePositionDelegate(currentTop: Int, realtime: Boolean = true) {
1413
+ // Dedupe emissions for same position
1414
+ if (currentTop == lastEmittedPositionPx) return
749
1415
 
750
- // Position calculation is simple: screen height - sheet height
751
- // In both edge-to-edge and non-edge-to-edge modes, getScreenY returns
752
- // coordinates in screen space, and maxScreenHeight represents the available height
753
- // for the sheet, so the calculation is the same
754
- val positionPx = maxScreenHeight - detentHeight
1416
+ lastEmittedPositionPx = currentTop
1417
+ val visibleHeight = realScreenHeight - currentTop
1418
+ val position = detentCalculator.getPositionDp(visibleHeight)
1419
+ val (interpolatedIndex, detent) = detentCalculator.getInterpolatedPositionInfo(currentTop)
1420
+ delegate?.viewControllerDidChangePosition(interpolatedIndex, position, detent, realtime)
1421
+ }
755
1422
 
756
- return positionPx.pxToDp()
1423
+ /**
1424
+ * Updates position emission, footer, and dim amount together.
1425
+ * This pattern is commonly used during animations and state changes.
1426
+ */
1427
+ private fun updateSheetVisuals(effectiveTop: Int) {
1428
+ emitChangePositionDelegate(effectiveTop)
1429
+ positionFooter()
1430
+ updateDimAmount(effectiveTop)
1431
+ }
1432
+
1433
+ // =============================================================================
1434
+ // MARK: - Detent Helpers
1435
+ // =============================================================================
1436
+
1437
+ fun updateBehaviorMaxWidth() {
1438
+ val behavior = this.behavior ?: return
1439
+ val applyMaxWidth = maxContentWidth != null && !ScreenUtils.isPortraitPhone(reactContext)
1440
+ val newMaxWidth = if (applyMaxWidth) maxContentWidth!! else DEFAULT_MAX_WIDTH.dpToPx().toInt()
1441
+ if (behavior.maxWidth == newMaxWidth) return
1442
+
1443
+ behavior.maxWidth = newMaxWidth
757
1444
  }
758
1445
 
759
1446
  /**
760
- * Get DetentInfo data for given detent index.
1447
+ * Updates the Fabric state with the current/target detent size so Yoga sizes
1448
+ * the container to the sheet's visible height.
1449
+ *
1450
+ * Unlike iOS, resize animations run over multiple frames (ViewDragHelper), so
1451
+ * shrinking is deferred to settle ([deferShrink]) when a resize animation is
1452
+ * about to run — growing applies immediately so content is laid out before
1453
+ * the sheet reveals it.
761
1454
  */
762
- fun getDetentInfoForIndex(index: Int) = getDetentInfoForState(getStateForDetentIndex(index)) ?: DetentInfo(0, 0f)
1455
+ private fun updateStateDimensions(deferShrink: Boolean = false) {
1456
+ if (detents.isEmpty()) return
763
1457
 
764
- // ==================== RootView Implementation ====================
1458
+ val targetIndex = (if (pendingDetentIndex >= 0) pendingDetentIndex else currentDetentIndex)
1459
+ .coerceIn(0, detents.size - 1)
1460
+ val maxAvailableHeight = realScreenHeight - topInset
1461
+ val newHeight = minOf(detentCalculator.getDetentHeight(detents[targetIndex]), maxAvailableHeight)
1462
+ val newWidth = getStateWidth()
765
1463
 
766
- override fun onInitializeAccessibilityNodeInfo(info: AccessibilityNodeInfo) {
767
- super.onInitializeAccessibilityNodeInfo(info)
1464
+ if (deferShrink && newWidth == lastStateWidth && newHeight < lastStateHeight) return
768
1465
 
769
- val testId = getTag(R.id.react_test_id) as String?
770
- if (testId != null) {
771
- info.viewIdResourceName = testId
772
- }
1466
+ setStateDimensions(newWidth, newHeight)
773
1467
  }
774
1468
 
775
- override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
776
- super.onSizeChanged(w, h, oldw, oldh)
1469
+ /**
1470
+ * Tracks the sheet's visible height during drag (and the post-release settle
1471
+ * animation) so the container resizes with the finger, like iOS. Clamped to the
1472
+ * smallest detent — dragging below it slides the sheet out without resizing.
1473
+ */
1474
+ private fun updateStateDimensionsForDrag(sheetTop: Int) {
1475
+ if (detents.isEmpty()) return
777
1476
 
778
- // Only proceed if size actually changed
779
- if (w == oldw && h == oldh) return
1477
+ val maxAvailableHeight = realScreenHeight - topInset
1478
+ val minHeight = minOf(
1479
+ detentCalculator.getDetentHeight(detents.first(), includeKeyboard = !hasKeyboardFloor),
1480
+ maxAvailableHeight
1481
+ )
1482
+ val newHeight = detentCalculator.getVisibleSheetHeight(sheetTop).coerceIn(minHeight, maxAvailableHeight)
780
1483
 
781
- // Update screen height based on new dimensions
782
- val oldMaxScreenHeight = maxScreenHeight
783
- maxScreenHeight = ScreenUtils.getScreenHeight(reactContext, edgeToEdgeEnabled)
1484
+ setStateDimensions(getStateWidth(), newHeight)
1485
+ }
1486
+
1487
+ private fun getStateWidth(): Int {
1488
+ val applyMaxWidth = maxContentWidth != null && !ScreenUtils.isPortraitPhone(reactContext)
1489
+ val effectiveMaxWidth = if (applyMaxWidth) maxContentWidth!! else DEFAULT_MAX_WIDTH.dpToPx().toInt()
1490
+ return minOf(screenWidth, effectiveMaxWidth)
1491
+ }
1492
+
1493
+ private fun setStateDimensions(width: Int, height: Int) {
1494
+ if (lastStateWidth == width && lastStateHeight == height) return
1495
+
1496
+ lastStateWidth = width
1497
+ lastStateHeight = height
1498
+ delegate?.viewControllerDidChangeSize(width, height)
1499
+ }
1500
+
1501
+ /**
1502
+ * Realtime (non-animated) counterpart of TrueSheetStackManager.updateParentTranslation,
1503
+ * driven by onSlide so the parent tracks this sheet's actual position.
1504
+ */
1505
+ private fun syncParentTranslation(sheetTop: Int) {
1506
+ val parent = parentSheetView ?: return
1507
+ val minSheetTop = detentCalculator.getSheetTopForDetentIndex(0)
1508
+ parent.syncTranslationForChild(sheetTop, minSheetTop)
1509
+ }
784
1510
 
785
- // Only handle rotation if sheet is presented and screen height actually changed
786
- if (isPresented && oldMaxScreenHeight != maxScreenHeight && oldMaxScreenHeight > 0) {
787
- // Recalculate sheet detents with new screen dimensions
788
- setupSheetDetents()
1511
+ fun translateSheet(translationY: Int, animated: Boolean = true, onEnd: (() -> Unit)? = null) {
1512
+ val sheet = sheetView ?: return
789
1513
 
790
- this.post {
791
- // Update footer position after rotation
792
- positionFooter()
1514
+ if (!animated) {
1515
+ sheet.animate().cancel()
1516
+ sheet.translationY = translationY.toFloat()
1517
+ emitChangePositionDelegate(sheet.top + translationY)
1518
+ onEnd?.invoke()
1519
+ return
1520
+ }
793
1521
 
794
- // Notify JS about position change after rotation settles
795
- val detentInfo = getDetentInfoForIndex(currentDetentIndex)
796
- delegate?.viewControllerDidChangePosition(detentInfo.index, detentInfo.position, transitioning = true)
1522
+ sheet.animate()
1523
+ .translationY(translationY.toFloat())
1524
+ .setDuration(TRANSLATE_ANIMATION_DURATION)
1525
+ .setUpdateListener {
1526
+ val effectiveTop = sheet.top + sheet.translationY.toInt()
1527
+ emitChangePositionDelegate(effectiveTop)
1528
+ }
1529
+ .withEndAction { onEnd?.invoke() }
1530
+ .start()
1531
+ }
1532
+
1533
+ private fun getDetentInfoWithValue(index: Int): Triple<Int, Float, Float> {
1534
+ val state = detentCalculator.getStateForDetentIndex(index)
1535
+ val detentIndex = detentCalculator.getDetentIndexForState(state) ?: 0
1536
+ val position = getPositionForDetentIndex(detentIndex)
1537
+ val detent = detentCalculator.getDetentValueForIndex(detentIndex)
1538
+ return Triple(detentIndex, position, detent)
1539
+ }
1540
+
1541
+ private fun getPositionForDetentIndex(index: Int): Float {
1542
+ if (index < 0 || index >= detents.size) return screenHeight.pxToDp()
1543
+
1544
+ sheetView?.let {
1545
+ val visibleSheetHeight = detentCalculator.getVisibleSheetHeight(it.top)
1546
+ if (visibleSheetHeight in 1..<realScreenHeight) {
1547
+ return detentCalculator.getPositionDp(visibleSheetHeight)
797
1548
  }
798
1549
  }
799
1550
 
800
- // Notify delegate about size change
801
- delegate?.viewControllerDidChangeSize(w, h)
1551
+ val detentHeight = detentCalculator.getDetentHeight(detents[index])
1552
+ return detentCalculator.getPositionDp(detentHeight)
802
1553
  }
803
1554
 
804
- override fun handleException(t: Throwable) {
805
- reactContext.reactApplicationContext.handleException(RuntimeException(t))
1555
+ // =============================================================================
1556
+ // MARK: - RootView Implementation
1557
+ // =============================================================================
1558
+
1559
+ override fun onInitializeAccessibilityNodeInfo(info: AccessibilityNodeInfo) {
1560
+ super.onInitializeAccessibilityNodeInfo(info)
1561
+ (getTag(R.id.react_test_id) as? String)?.let { info.viewIdResourceName = it }
1562
+ }
1563
+
1564
+ // =============================================================================
1565
+ // MARK: - RootView Touch Handling
1566
+ // =============================================================================
1567
+
1568
+ override fun dispatchTouchEvent(event: MotionEvent): Boolean {
1569
+ val footer = containerView?.footerView
1570
+ val action = event.actionMasked
1571
+
1572
+ if (footer != null && footer.isShown) {
1573
+ if (action == MotionEvent.ACTION_DOWN) {
1574
+ val loc = ScreenUtils.getScreenLocation(footer)
1575
+ val x = event.rawX.toInt()
1576
+ val y = event.rawY.toInt()
1577
+ footerOwnsTouchStream = x >= loc[0] &&
1578
+ x <= loc[0] + footer.width &&
1579
+ y >= loc[1] &&
1580
+ y <= loc[1] + footer.height
1581
+ }
1582
+
1583
+ if (footerOwnsTouchStream) {
1584
+ val loc = ScreenUtils.getScreenLocation(footer)
1585
+ val localEvent = MotionEvent.obtain(event)
1586
+ localEvent.setLocation(event.rawX - loc[0], event.rawY - loc[1])
1587
+ val handled = footer.dispatchTouchEvent(localEvent)
1588
+ localEvent.recycle()
1589
+
1590
+ if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL) {
1591
+ footerOwnsTouchStream = false
1592
+ }
1593
+ if (handled) return true
1594
+ }
1595
+ }
1596
+
1597
+ if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL) {
1598
+ footerOwnsTouchStream = false
1599
+ }
1600
+ return super.dispatchTouchEvent(event)
806
1601
  }
807
1602
 
808
1603
  override fun onInterceptTouchEvent(event: MotionEvent): Boolean {
809
- eventDispatcher?.let { eventDispatcher ->
810
- jSTouchDispatcher.handleTouchEvent(event, eventDispatcher, reactContext)
811
- jSPointerDispatcher?.handleMotionEvent(event, eventDispatcher, true)
1604
+ eventDispatcher?.let {
1605
+ if (touchDeduper.shouldDispatch(event)) {
1606
+ jsTouchDispatcher.handleTouchEvent(event, it, reactContext)
1607
+ }
1608
+ jsPointerDispatcher.handleMotionEvent(event, it, true)
812
1609
  }
813
1610
  return super.onInterceptTouchEvent(event)
814
1611
  }
815
1612
 
1613
+ override fun requestDisallowInterceptTouchEvent(disallowIntercept: Boolean) {
1614
+ // Mirrors ReactSurfaceView: keep receiving onInterceptTouchEvent so
1615
+ // jsTouchDispatcher sees the gesture end, but forward the request up the tree.
1616
+ parent?.requestDisallowInterceptTouchEvent(disallowIntercept)
1617
+ }
1618
+
816
1619
  override fun onTouchEvent(event: MotionEvent): Boolean {
817
- eventDispatcher?.let { eventDispatcher ->
818
- jSTouchDispatcher.handleTouchEvent(event, eventDispatcher, reactContext)
819
- jSPointerDispatcher?.handleMotionEvent(event, eventDispatcher, false)
1620
+ eventDispatcher?.let {
1621
+ if (touchDeduper.shouldDispatch(event)) {
1622
+ jsTouchDispatcher.handleTouchEvent(event, it, reactContext)
1623
+ }
1624
+ jsPointerDispatcher.handleMotionEvent(event, it, false)
820
1625
  }
821
1626
  super.onTouchEvent(event)
822
- // In case when there is no children interested in handling touch event, we return true from
823
- // the root view in order to receive subsequent events related to that gesture
824
1627
  return true
825
1628
  }
826
1629
 
827
1630
  override fun onInterceptHoverEvent(event: MotionEvent): Boolean {
828
- eventDispatcher?.let { jSPointerDispatcher?.handleMotionEvent(event, it, true) }
1631
+ eventDispatcher?.let { jsPointerDispatcher.handleMotionEvent(event, it, true) }
829
1632
  return super.onHoverEvent(event)
830
1633
  }
831
1634
 
832
1635
  override fun onHoverEvent(event: MotionEvent): Boolean {
833
- eventDispatcher?.let { jSPointerDispatcher?.handleMotionEvent(event, it, false) }
1636
+ eventDispatcher?.let { jsPointerDispatcher.handleMotionEvent(event, it, false) }
834
1637
  return super.onHoverEvent(event)
835
1638
  }
836
1639
 
837
- @OptIn(UnstableReactNativeAPI::class)
838
- @Suppress("DEPRECATION")
839
1640
  override fun onChildStartedNativeGesture(childView: View?, ev: MotionEvent) {
840
- eventDispatcher?.let { eventDispatcher ->
841
- jSTouchDispatcher.onChildStartedNativeGesture(ev, eventDispatcher, reactContext)
842
- jSPointerDispatcher?.onChildStartedNativeGesture(childView, ev, eventDispatcher)
1641
+ coordinatorLayout?.childDidClaimNativeGesture()
1642
+ eventDispatcher?.let {
1643
+ jsTouchDispatcher.onChildStartedNativeGesture(ev, it)
1644
+ jsPointerDispatcher.onChildStartedNativeGesture(childView, ev, it)
843
1645
  }
844
1646
  }
845
1647
 
846
1648
  override fun onChildEndedNativeGesture(childView: View, ev: MotionEvent) {
847
- eventDispatcher?.let { jSTouchDispatcher.onChildEndedNativeGesture(ev, it) }
848
- jSPointerDispatcher?.onChildEndedNativeGesture()
1649
+ eventDispatcher?.let { jsTouchDispatcher.onChildEndedNativeGesture(ev, it) }
1650
+ jsPointerDispatcher.onChildEndedNativeGesture()
849
1651
  }
850
1652
 
851
- override fun requestDisallowInterceptTouchEvent(disallowIntercept: Boolean) {
852
- // Allow the request to propagate to parent
853
- super.requestDisallowInterceptTouchEvent(disallowIntercept)
1653
+ override fun handleException(t: Throwable) {
1654
+ reactContext.reactApplicationContext.handleException(RuntimeException(t))
854
1655
  }
855
1656
  }