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

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