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

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