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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (221) hide show
  1. package/README.md +25 -4
  2. package/android/build.gradle +22 -1
  3. package/android/src/main/java/com/lodev09/truesheet/TrueSheetContainerView.kt +24 -7
  4. package/android/src/main/java/com/lodev09/truesheet/TrueSheetContentView.kt +211 -182
  5. package/android/src/main/java/com/lodev09/truesheet/TrueSheetContentViewManager.kt +5 -1
  6. package/android/src/main/java/com/lodev09/truesheet/TrueSheetFooterView.kt +14 -19
  7. package/android/src/main/java/com/lodev09/truesheet/TrueSheetOverlayView.kt +106 -0
  8. package/android/src/main/java/com/lodev09/truesheet/TrueSheetOverlayViewManager.kt +45 -0
  9. package/android/src/main/java/com/lodev09/truesheet/TrueSheetPackage.kt +2 -1
  10. package/android/src/main/java/com/lodev09/truesheet/TrueSheetStateUpdater.kt +18 -0
  11. package/android/src/main/java/com/lodev09/truesheet/TrueSheetView.kt +82 -39
  12. package/android/src/main/java/com/lodev09/truesheet/TrueSheetViewController.kt +275 -88
  13. package/android/src/main/java/com/lodev09/truesheet/TrueSheetViewManager.kt +26 -1
  14. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetCoordinatorLayout.kt +75 -3
  15. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetDetentCalculator.kt +56 -34
  16. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetOverlayRootView.kt +137 -0
  17. package/android/src/main/java/com/lodev09/truesheet/events/TrueSheetLifecycleEvents.kt +15 -0
  18. package/android/src/main/java/com/lodev09/truesheet/utils/KeyboardUtils.kt +1 -1
  19. package/android/src/main/java/com/lodev09/truesheet/utils/TouchEventDeduper.kt +31 -0
  20. package/android/src/main/java/com/lodev09/truesheet/utils/ViewUtils.kt +19 -7
  21. package/android/src/main/jni/TrueSheetSpec.h +1 -0
  22. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewComponentDescriptor.h +0 -8
  23. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewShadowNode.cpp +54 -29
  24. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewShadowNode.h +6 -1
  25. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewState.cpp +1 -1
  26. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewState.h +6 -4
  27. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetFooterViewShadowNode.cpp +7 -0
  28. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetLayoutUtils.h +46 -0
  29. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetOverlayViewComponentDescriptor.h +21 -0
  30. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetOverlayViewShadowNode.cpp +17 -0
  31. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetOverlayViewShadowNode.h +36 -0
  32. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetViewShadowNode.cpp +4 -35
  33. package/ios/TrueSheetContainerView.h +14 -7
  34. package/ios/TrueSheetContainerView.mm +10 -7
  35. package/ios/TrueSheetContentView.h +31 -15
  36. package/ios/TrueSheetContentView.mm +133 -179
  37. package/ios/TrueSheetFooterView.h +6 -0
  38. package/ios/TrueSheetFooterView.mm +48 -2
  39. package/ios/TrueSheetModule.h +2 -7
  40. package/ios/TrueSheetModule.mm +7 -16
  41. package/ios/TrueSheetOverlayView.h +32 -0
  42. package/ios/TrueSheetOverlayView.mm +161 -0
  43. package/ios/TrueSheetView.mm +171 -29
  44. package/ios/TrueSheetViewController.h +10 -0
  45. package/ios/TrueSheetViewController.mm +256 -74
  46. package/ios/core/TrueSheetGrabberView.mm +13 -3
  47. package/ios/core/TrueSheetOverlayContainerView.h +31 -0
  48. package/ios/core/TrueSheetOverlayContainerView.mm +35 -0
  49. package/ios/events/TrueSheetLifecycleEvents.h +2 -0
  50. package/ios/events/TrueSheetLifecycleEvents.mm +8 -0
  51. package/ios/tvos/TrueSheetStubs.mm +12 -0
  52. package/ios/utils/UIView+ScrollEdgeInteraction.h +1 -0
  53. package/ios/utils/UIView+ScrollEdgeInteraction.mm +24 -2
  54. package/lib/module/TrueSheet.js +149 -34
  55. package/lib/module/TrueSheet.js.map +1 -1
  56. package/lib/module/TrueSheet.web.js +122 -53
  57. package/lib/module/TrueSheet.web.js.map +1 -1
  58. package/lib/module/TrueSheetOverlay.js +30 -0
  59. package/lib/module/TrueSheetOverlay.js.map +1 -0
  60. package/lib/module/TrueSheetOverlay.web.js +35 -0
  61. package/lib/module/TrueSheetOverlay.web.js.map +1 -0
  62. package/lib/module/TrueSheetPeek.web.js +3 -3
  63. package/lib/module/TrueSheetPeek.web.js.map +1 -1
  64. package/lib/module/fabric/TrueSheetContentViewNativeComponent.ts +1 -1
  65. package/lib/module/fabric/TrueSheetOverlayViewNativeComponent.ts +12 -0
  66. package/lib/module/fabric/TrueSheetViewNativeComponent.ts +5 -0
  67. package/lib/module/index.js +1 -0
  68. package/lib/module/index.js.map +1 -1
  69. package/lib/module/mocks/index.js +10 -0
  70. package/lib/module/mocks/index.js.map +1 -1
  71. package/lib/module/mocks/navigation.js +31 -21
  72. package/lib/module/mocks/navigation.js.map +1 -1
  73. package/lib/module/mocks/navigationExpoRouter.js +21 -0
  74. package/lib/module/mocks/navigationExpoRouter.js.map +1 -0
  75. package/lib/module/mocks/reanimated.js +1 -1
  76. package/lib/module/mocks/reanimated.js.map +1 -1
  77. package/lib/module/navigation/{TrueSheetView.js → TrueSheetNavigatorContent.js} +14 -12
  78. package/lib/module/navigation/TrueSheetNavigatorContent.js.map +1 -0
  79. package/lib/module/navigation/actions.js +50 -0
  80. package/lib/module/navigation/actions.js.map +1 -0
  81. package/lib/module/navigation/base-types.js +2 -0
  82. package/lib/module/navigation/base-types.js.map +1 -0
  83. package/lib/module/navigation/{TrueSheetRouter.js → createTrueSheetRouter.js} +11 -24
  84. package/lib/module/navigation/createTrueSheetRouter.js.map +1 -0
  85. package/lib/module/navigation/expo-router/base-types.js +2 -0
  86. package/lib/module/navigation/expo-router/base-types.js.map +1 -0
  87. package/lib/module/navigation/expo-router/index.js +50 -0
  88. package/lib/module/navigation/expo-router/index.js.map +1 -0
  89. package/lib/module/navigation/expo-router/types.js +4 -0
  90. package/lib/module/navigation/expo-router/types.js.map +1 -0
  91. package/lib/module/navigation/index.js +43 -2
  92. package/lib/module/navigation/index.js.map +1 -1
  93. package/lib/module/navigation/navigator.js +10 -0
  94. package/lib/module/navigation/navigator.js.map +1 -0
  95. package/lib/module/navigation/screen/ReanimatedTrueSheetScreen.js +2 -2
  96. package/lib/module/navigation/screen/ReanimatedTrueSheetScreen.js.map +1 -1
  97. package/lib/module/navigation/screen/TrueSheetScreen.js +2 -2
  98. package/lib/module/navigation/screen/TrueSheetScreen.js.map +1 -1
  99. package/lib/module/navigation/screen/useSheetScreenState.js +5 -4
  100. package/lib/module/navigation/screen/useSheetScreenState.js.map +1 -1
  101. package/lib/module/navigation/useTrueSheetNavigation.js +1 -1
  102. package/lib/module/navigation/useTrueSheetNavigation.js.map +1 -1
  103. package/lib/module/web/dom.js +10 -0
  104. package/lib/module/web/dom.js.map +1 -0
  105. package/lib/module/web/vaul/constants.js +4 -0
  106. package/lib/module/web/vaul/constants.js.map +1 -1
  107. package/lib/module/web/vaul/context.js.map +1 -1
  108. package/lib/module/web/vaul/helpers.js +10 -4
  109. package/lib/module/web/vaul/helpers.js.map +1 -1
  110. package/lib/module/web/vaul/index.js +35 -2
  111. package/lib/module/web/vaul/index.js.map +1 -1
  112. package/lib/typescript/src/TrueSheet.d.ts +6 -0
  113. package/lib/typescript/src/TrueSheet.d.ts.map +1 -1
  114. package/lib/typescript/src/TrueSheet.types.d.ts +72 -1
  115. package/lib/typescript/src/TrueSheet.types.d.ts.map +1 -1
  116. package/lib/typescript/src/TrueSheet.web.d.ts.map +1 -1
  117. package/lib/typescript/src/TrueSheetOverlay.d.ts +8 -0
  118. package/lib/typescript/src/TrueSheetOverlay.d.ts.map +1 -0
  119. package/lib/typescript/src/TrueSheetOverlay.web.d.ts +8 -0
  120. package/lib/typescript/src/TrueSheetOverlay.web.d.ts.map +1 -0
  121. package/lib/typescript/src/TrueSheetPeek.web.d.ts.map +1 -1
  122. package/lib/typescript/src/fabric/TrueSheetOverlayViewNativeComponent.d.ts +6 -0
  123. package/lib/typescript/src/fabric/TrueSheetOverlayViewNativeComponent.d.ts.map +1 -0
  124. package/lib/typescript/src/fabric/TrueSheetViewNativeComponent.d.ts +4 -0
  125. package/lib/typescript/src/fabric/TrueSheetViewNativeComponent.d.ts.map +1 -1
  126. package/lib/typescript/src/index.d.ts +1 -0
  127. package/lib/typescript/src/index.d.ts.map +1 -1
  128. package/lib/typescript/src/mocks/index.d.ts +4 -0
  129. package/lib/typescript/src/mocks/index.d.ts.map +1 -1
  130. package/lib/typescript/src/mocks/navigation.d.ts +5 -1
  131. package/lib/typescript/src/mocks/navigation.d.ts.map +1 -1
  132. package/lib/typescript/src/mocks/navigationExpoRouter.d.ts +22 -0
  133. package/lib/typescript/src/mocks/navigationExpoRouter.d.ts.map +1 -0
  134. package/lib/typescript/src/mocks/reanimated.d.ts.map +1 -1
  135. package/lib/typescript/src/navigation/TrueSheetNavigatorContent.d.ts +5 -0
  136. package/lib/typescript/src/navigation/TrueSheetNavigatorContent.d.ts.map +1 -0
  137. package/lib/typescript/src/navigation/actions.d.ts +71 -0
  138. package/lib/typescript/src/navigation/actions.d.ts.map +1 -0
  139. package/lib/typescript/src/navigation/base-types.d.ts +9 -0
  140. package/lib/typescript/src/navigation/base-types.d.ts.map +1 -0
  141. package/lib/typescript/src/navigation/createTrueSheetRouter.d.ts +3 -0
  142. package/lib/typescript/src/navigation/createTrueSheetRouter.d.ts.map +1 -0
  143. package/lib/typescript/src/navigation/expo-router/base-types.d.ts +8 -0
  144. package/lib/typescript/src/navigation/expo-router/base-types.d.ts.map +1 -0
  145. package/lib/typescript/src/navigation/expo-router/index.d.ts +37 -0
  146. package/lib/typescript/src/navigation/expo-router/index.d.ts.map +1 -0
  147. package/lib/typescript/src/navigation/expo-router/types.d.ts +44 -0
  148. package/lib/typescript/src/navigation/expo-router/types.d.ts.map +1 -0
  149. package/lib/typescript/src/navigation/index.d.ts +42 -3
  150. package/lib/typescript/src/navigation/index.d.ts.map +1 -1
  151. package/lib/typescript/src/navigation/navigator.d.ts +7 -0
  152. package/lib/typescript/src/navigation/navigator.d.ts.map +1 -0
  153. package/lib/typescript/src/navigation/screen/ReanimatedTrueSheetScreen.d.ts +1 -1
  154. package/lib/typescript/src/navigation/screen/ReanimatedTrueSheetScreen.d.ts.map +1 -1
  155. package/lib/typescript/src/navigation/screen/TrueSheetScreen.d.ts +1 -1
  156. package/lib/typescript/src/navigation/screen/TrueSheetScreen.d.ts.map +1 -1
  157. package/lib/typescript/src/navigation/screen/types.d.ts +3 -4
  158. package/lib/typescript/src/navigation/screen/types.d.ts.map +1 -1
  159. package/lib/typescript/src/navigation/screen/useSheetScreenState.d.ts +5 -6
  160. package/lib/typescript/src/navigation/screen/useSheetScreenState.d.ts.map +1 -1
  161. package/lib/typescript/src/navigation/types.d.ts +65 -5
  162. package/lib/typescript/src/navigation/types.d.ts.map +1 -1
  163. package/lib/typescript/src/navigation/useTrueSheetNavigation.d.ts +1 -1
  164. package/lib/typescript/src/navigation/useTrueSheetNavigation.d.ts.map +1 -1
  165. package/lib/typescript/src/web/dom.d.ts +7 -0
  166. package/lib/typescript/src/web/dom.d.ts.map +1 -0
  167. package/lib/typescript/src/web/vaul/constants.d.ts +1 -0
  168. package/lib/typescript/src/web/vaul/constants.d.ts.map +1 -1
  169. package/lib/typescript/src/web/vaul/context.d.ts +1 -0
  170. package/lib/typescript/src/web/vaul/context.d.ts.map +1 -1
  171. package/lib/typescript/src/web/vaul/helpers.d.ts.map +1 -1
  172. package/lib/typescript/src/web/vaul/index.d.ts +7 -1
  173. package/lib/typescript/src/web/vaul/index.d.ts.map +1 -1
  174. package/package.json +32 -9
  175. package/react-native.config.js +1 -0
  176. package/src/TrueSheet.tsx +184 -46
  177. package/src/TrueSheet.types.ts +77 -1
  178. package/src/TrueSheet.web.tsx +135 -64
  179. package/src/TrueSheetOverlay.tsx +24 -0
  180. package/src/TrueSheetOverlay.web.tsx +33 -0
  181. package/src/TrueSheetPeek.web.tsx +4 -3
  182. package/src/fabric/TrueSheetContentViewNativeComponent.ts +1 -1
  183. package/src/fabric/TrueSheetOverlayViewNativeComponent.ts +12 -0
  184. package/src/fabric/TrueSheetViewNativeComponent.ts +5 -0
  185. package/src/index.ts +1 -0
  186. package/src/mocks/index.ts +7 -0
  187. package/src/mocks/navigation.ts +32 -25
  188. package/src/mocks/navigationExpoRouter.ts +41 -0
  189. package/src/mocks/reanimated.ts +8 -9
  190. package/src/navigation/{TrueSheetView.tsx → TrueSheetNavigatorContent.tsx} +24 -17
  191. package/src/navigation/actions.ts +78 -0
  192. package/src/navigation/base-types.ts +20 -0
  193. package/src/navigation/createTrueSheetRouter.ts +194 -0
  194. package/src/navigation/expo-router/base-types.ts +18 -0
  195. package/src/navigation/expo-router/index.ts +90 -0
  196. package/src/navigation/expo-router/types.ts +116 -0
  197. package/src/navigation/index.ts +68 -2
  198. package/src/navigation/navigator.ts +18 -0
  199. package/src/navigation/screen/ReanimatedTrueSheetScreen.tsx +2 -2
  200. package/src/navigation/screen/TrueSheetScreen.tsx +2 -2
  201. package/src/navigation/screen/types.ts +4 -6
  202. package/src/navigation/screen/useSheetScreenState.ts +10 -15
  203. package/src/navigation/types.ts +99 -16
  204. package/src/navigation/useTrueSheetNavigation.ts +1 -1
  205. package/src/web/dom.ts +9 -0
  206. package/src/web/vaul/constants.ts +4 -0
  207. package/src/web/vaul/context.ts +1 -0
  208. package/src/web/vaul/helpers.ts +13 -6
  209. package/src/web/vaul/index.tsx +46 -1
  210. package/lib/module/navigation/TrueSheetRouter.js.map +0 -1
  211. package/lib/module/navigation/TrueSheetView.js.map +0 -1
  212. package/lib/module/navigation/createTrueSheetNavigator.js +0 -63
  213. package/lib/module/navigation/createTrueSheetNavigator.js.map +0 -1
  214. package/lib/typescript/src/navigation/TrueSheetRouter.d.ts +0 -57
  215. package/lib/typescript/src/navigation/TrueSheetRouter.d.ts.map +0 -1
  216. package/lib/typescript/src/navigation/TrueSheetView.d.ts +0 -10
  217. package/lib/typescript/src/navigation/TrueSheetView.d.ts.map +0 -1
  218. package/lib/typescript/src/navigation/createTrueSheetNavigator.d.ts +0 -35
  219. package/lib/typescript/src/navigation/createTrueSheetNavigator.d.ts.map +0 -1
  220. package/src/navigation/TrueSheetRouter.ts +0 -234
  221. package/src/navigation/createTrueSheetNavigator.tsx +0 -93
@@ -1,26 +1,28 @@
1
1
  package com.lodev09.truesheet
2
2
 
3
3
  import android.annotation.SuppressLint
4
+ import android.os.Build
4
5
  import android.text.Editable
5
6
  import android.text.TextWatcher
6
7
  import android.view.View
7
8
  import android.view.ViewGroup
9
+ import android.view.ViewTreeObserver
8
10
  import android.widget.EditText
9
- import android.widget.ScrollView
10
- import androidx.core.widget.NestedScrollView
11
11
  import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
12
- import com.facebook.react.bridge.WritableNativeMap
13
12
  import com.facebook.react.uimanager.PixelUtil.dpToPx
14
- import com.facebook.react.uimanager.StateWrapper
15
13
  import com.facebook.react.uimanager.ThemedReactContext
16
14
  import com.facebook.react.views.view.ReactViewGroup
17
15
  import com.lodev09.truesheet.core.TrueSheetKeyboardObserver
18
16
  import com.lodev09.truesheet.core.TrueSheetKeyboardObserverDelegate
19
17
  import com.lodev09.truesheet.utils.isDescendantOf
20
- import com.lodev09.truesheet.utils.smoothScrollBy
21
18
  import com.lodev09.truesheet.utils.smoothScrollTo
22
19
 
23
- data class ScrollableOptions(val keyboardScrollOffset: Float = 0f, val scrollingExpandsSheet: Boolean = true)
20
+ data class ScrollableOptions(
21
+ val keyboardScrollOffset: Float = 0f,
22
+ val keyboardOffset: Float = 0f,
23
+ val scrollingExpandsSheet: Boolean = true,
24
+ val contentInsetAdjustment: Boolean = true
25
+ )
24
26
 
25
27
  /**
26
28
  * Delegate interface for content view size changes
@@ -32,10 +34,19 @@ interface TrueSheetContentViewDelegate {
32
34
 
33
35
  /**
34
36
  * Keyboard occlusion adjustment below the content — positive for an absolute
35
- * footer risen above the keyboard covering the content's bottom edge,
36
- * negative for a relative footer sitting behind the keyboard shielding it.
37
+ * footer floating over the viewport's bottom edge (extends the caret reveal),
38
+ * negative for a relative footer sitting behind the keyboard shielding that
39
+ * much of its overlap (reduces the keyboard padding).
37
40
  */
38
41
  val footerKeyboardOcclusion: Int
42
+
43
+ /**
44
+ * A relative footer sits below the content and absorbs the bottom safe-area
45
+ * inset — the scrollable doesn't reach the sheet's bottom edge, so no
46
+ * content inset is applied (mirrors iOS, where UIKit resolves this
47
+ * geometrically via the scroll view's safe area).
48
+ */
49
+ val hasRelativeFooter: Boolean
39
50
  }
40
51
 
41
52
  /**
@@ -45,44 +56,42 @@ interface TrueSheetContentViewDelegate {
45
56
  @SuppressLint("ViewConstructor")
46
57
  class TrueSheetContentView(private val reactContext: ThemedReactContext) : ReactViewGroup(reactContext) {
47
58
  var delegate: TrueSheetContentViewDelegate? = null
48
- var stateWrapper: StateWrapper? = null
49
-
50
- private var lastWidth = 0
51
- private var lastHeight = 0
52
-
53
- private var pinnedScrollView: ViewGroup? = null
54
- private var observedScrollChild: View? = null
55
- private var originalScrollViewPaddingBottom: Int = 0
56
- private var bottomInset: Int = 0
57
- private var scrollableBounded = false
58
- private var isReportPending = false
59
-
60
- // Content growth is invisible to layout once the viewport is bounded, so track
61
- // the scroll content size directly to keep the auto detent height in sync.
62
- private val scrollChildLayoutListener =
63
- View.OnLayoutChangeListener { _, _, top, _, bottom, _, oldTop, _, oldBottom ->
64
- if (bottom - top != oldBottom - oldTop) {
65
- reportSizeIfChanged()
66
- }
67
- }
59
+
60
+ private var detectedScrollView: ViewGroup? = null
68
61
 
69
62
  /**
70
- * Content height with the pinned ScrollView's viewport replaced by its content
71
- * size — the height the content wants regardless of container bounds.
72
- * Falls back to the view height when no ScrollView is pinned.
63
+ * Content height measured unconstrained by the shadow node the height the
64
+ * content wants regardless of container bounds (see
65
+ * TrueSheetContentViewShadowNode). Falls back to the view height before the
66
+ * first state update.
73
67
  */
74
68
  val naturalHeight: Int
75
- get() {
76
- var naturalHeight = height
77
- pinnedScrollView?.let { scrollView ->
78
- scrollView.getChildAt(0)?.let { child ->
79
- naturalHeight += child.height - scrollView.height
80
- }
81
- }
82
- return maxOf(0, naturalHeight)
83
- }
69
+ get() = if (lastNaturalHeight > 0) lastNaturalHeight else height
70
+
71
+ private var lastNaturalHeight = 0
72
+
73
+ fun updateNaturalHeight(heightDp: Double) {
74
+ val heightPx = heightDp.toFloat().dpToPx().toInt()
75
+ if (heightPx == lastNaturalHeight) return
76
+ lastNaturalHeight = heightPx
77
+ delegate?.contentViewDidChangeSize(width, heightPx)
78
+ }
84
79
 
85
80
  private var keyboardScrollOffset: Float = 0f
81
+
82
+ /**
83
+ * Adjustment added to the keyboard bottom inset applied to the detected
84
+ * scrollable — negative values reduce the inset (e.g. to cancel out safe-area
85
+ * padding already baked into the content's paddingBottom).
86
+ */
87
+ private var keyboardOffset: Float = 0f
88
+
89
+ /**
90
+ * How much of keyboardOffset actually landed in the applied inset — the
91
+ * caret reveal compensates by this so it still targets the real keyboard edge.
92
+ */
93
+ private var appliedKeyboardOffset = 0
94
+
86
95
  private var keyboardObserver: TrueSheetKeyboardObserver? = null
87
96
 
88
97
  private var watchedEditText: EditText? = null
@@ -92,125 +101,103 @@ class TrueSheetContentView(private val reactContext: ThemedReactContext) : React
92
101
  set(value) {
93
102
  field = value
94
103
  keyboardScrollOffset = value?.keyboardScrollOffset?.dpToPx() ?: 0f
104
+ keyboardOffset = value?.keyboardOffset?.dpToPx() ?: 0f
105
+ updateContentInset()
95
106
  }
96
107
 
97
108
  /**
98
- * Whether the sheet has an `auto` detent. Deriving the sheet height from the
99
- * scroll content is circular with natural layout, so the pinned ScrollView's
100
- * viewport is force-bounded to the container only in this case.
109
+ * Bottom safe-area inset applied to the scrollable's content padding while
110
+ * the content can scroll Android counterpart of iOS's
111
+ * `contentInsetAdjustmentBehavior="automatic"`. Already zero when the
112
+ * sheet's `insetAdjustment` is `'never'`.
101
113
  */
102
- var hasAutoDetent = false
114
+ var bottomInset: Int = 0
103
115
  set(value) {
104
116
  if (field == value) return
105
117
  field = value
106
- if (pinnedScrollView != null) {
107
- setScrollableBounded(value)
108
- }
118
+ updateContentInset()
109
119
  }
110
120
 
111
- override fun addView(child: View?, index: Int) {
112
- super.addView(child, index)
113
- checkScrollViewChanged()
121
+ private var baseBottomInset = 0
122
+ private var keyboardBottomInset = 0
123
+
124
+ private val scrollableLayoutListener = ViewTreeObserver.OnGlobalLayoutListener {
125
+ updateContentInset()
126
+ clampScrollPosition()
114
127
  }
115
128
 
116
- override fun removeViewAt(index: Int) {
117
- super.removeViewAt(index)
118
- checkScrollViewChanged()
129
+ private val scrollContentLayoutListener = View.OnLayoutChangeListener { _, _, _, _, _, _, _, _, _ ->
130
+ updateContentInset()
119
131
  }
120
132
 
121
- private fun checkScrollViewChanged() {
122
- if (pinnedScrollView == null || pinnedScrollView?.isDescendantOf(this) == false) {
123
- delegate?.contentViewScrollViewDidChange()
124
- }
133
+ override fun onAttachedToWindow() {
134
+ super.onAttachedToWindow()
135
+ viewTreeObserver.addOnGlobalLayoutListener(scrollableLayoutListener)
125
136
  }
126
137
 
127
- override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
128
- super.onSizeChanged(w, h, oldw, oldh)
129
- reportSizeIfChanged()
138
+ override fun onDetachedFromWindow() {
139
+ viewTreeObserver.removeOnGlobalLayoutListener(scrollableLayoutListener)
140
+ super.onDetachedFromWindow()
130
141
  }
131
142
 
132
- private fun reportSizeIfChanged() {
133
- // naturalHeight mixes sizes from different views; mid-layout they're
134
- // momentarily inconsistent (parent resizes before its children), which
135
- // feeds back into the auto detent and oscillates. Coalesce to the next
136
- // frame so sizes are settled before measuring.
137
- if (pinnedScrollView != null) {
138
- if (isReportPending) return
139
- isReportPending = true
140
-
141
- post {
142
- isReportPending = false
143
- reportSizeNow()
144
- }
145
- return
143
+ /**
144
+ * React tag of the user-provided scrollable (see the `scrollableRef` prop).
145
+ * Setting a new handle clears the currently resolved ScrollView.
146
+ */
147
+ var scrollableHandle: Int = -1
148
+ set(value) {
149
+ if (field == value) return
150
+ field = value
151
+ clearScrollable()
146
152
  }
147
153
 
148
- reportSizeNow()
154
+ override fun addView(child: View?, index: Int) {
155
+ super.addView(child, index)
156
+ checkScrollViewChanged()
149
157
  }
150
158
 
151
- private fun reportSizeNow() {
152
- val newHeight = naturalHeight
153
- if (width != lastWidth || newHeight != lastHeight) {
154
- lastWidth = width
155
- lastHeight = newHeight
156
- delegate?.contentViewDidChangeSize(width, newHeight)
157
- }
159
+ override fun removeViewAt(index: Int) {
160
+ super.removeViewAt(index)
161
+ checkScrollViewChanged()
158
162
  }
159
163
 
160
- /**
161
- * Tells the shadow node to fill the container (flexGrow/flexShrink) so the
162
- * pinned ScrollView's viewport is bounded to the visible space. Only applied
163
- * for auto detents — otherwise content lays out naturally like a regular view.
164
- */
165
- private fun setScrollableBounded(bounded: Boolean) {
166
- if (scrollableBounded == bounded) return
167
- scrollableBounded = bounded
168
-
169
- stateWrapper?.let {
170
- val newState = WritableNativeMap()
171
- newState.putBoolean("scrollableBounded", bounded)
172
- it.updateState(newState)
164
+ private fun checkScrollViewChanged() {
165
+ if (detectedScrollView == null || detectedScrollView?.isDescendantOf(this) == false) {
166
+ delegate?.contentViewScrollViewDidChange()
173
167
  }
174
168
  }
175
169
 
176
- fun setupScrollable(bottomInset: Int) {
177
- // Check if pinned scroll view is still valid (still in view hierarchy)
178
- if (pinnedScrollView != null && pinnedScrollView?.isDescendantOf(this) == false) {
170
+ fun setupScrollable() {
171
+ // Check if the detected scroll view is still valid (still in view hierarchy)
172
+ if (detectedScrollView != null && detectedScrollView?.isDescendantOf(this) == false) {
179
173
  clearScrollable()
180
174
  }
181
175
 
182
- // Already set up with same inset and valid scroll view
183
- if (pinnedScrollView != null && this.bottomInset == bottomInset) {
176
+ if (detectedScrollView != null) {
184
177
  return
185
178
  }
186
179
 
187
- val scrollView = findScrollView(this) ?: return
180
+ val scrollView = findScrollView() ?: return
188
181
 
189
- // Only capture originals on first pin
190
- if (pinnedScrollView == null) {
191
- originalScrollViewPaddingBottom = scrollView.paddingBottom
192
- pinnedScrollView = scrollView
182
+ detectedScrollView = scrollView
193
183
 
194
- scrollView.isNestedScrollingEnabled = true
195
- (scrollView.parent as? SwipeRefreshLayout)?.isNestedScrollingEnabled = false
184
+ scrollView.isNestedScrollingEnabled = true
185
+ (scrollView.parent as? SwipeRefreshLayout)?.isNestedScrollingEnabled = false
196
186
 
197
- scrollView.setOnScrollChangeListener { _, _, scrollY, _, oldScrollY ->
198
- if (scrollY != oldScrollY) {
199
- delegate?.contentViewDidScroll()
200
- }
187
+ scrollView.setOnScrollChangeListener { _, _, scrollY, _, oldScrollY ->
188
+ if (scrollY != oldScrollY) {
189
+ delegate?.contentViewDidScroll()
201
190
  }
202
-
203
- scrollView.getChildAt(0)?.let { child ->
204
- observedScrollChild = child
205
- child.addOnLayoutChangeListener(scrollChildLayoutListener)
206
- }
207
- setScrollableBounded(hasAutoDetent)
208
- reportSizeIfChanged()
209
191
  }
210
192
 
211
- this.bottomInset = bottomInset
193
+ // Restore the inset before ReactScrollView's listener clamps the offset.
194
+ scrollView.getChildAt(0)?.let { child ->
195
+ if (scrollView is View.OnLayoutChangeListener) child.removeOnLayoutChangeListener(scrollView)
196
+ child.addOnLayoutChangeListener(scrollContentLayoutListener)
197
+ if (scrollView is View.OnLayoutChangeListener) child.addOnLayoutChangeListener(scrollView)
198
+ }
212
199
 
213
- setScrollViewPaddingBottom(originalScrollViewPaddingBottom + bottomInset)
200
+ updateContentInset()
214
201
 
215
202
  // If keyboard is currently showing, re-apply the keyboard inset to the new ScrollView
216
203
  val keyboardHeight = keyboardObserver?.currentHeight ?: 0
@@ -219,51 +206,64 @@ class TrueSheetContentView(private val reactContext: ThemedReactContext) : React
219
206
  }
220
207
  }
221
208
 
222
- private fun setScrollViewPaddingBottom(paddingBottom: Int) {
223
- val scrollView = pinnedScrollView ?: return
224
- scrollView.clipToPadding = false
225
- scrollView.setPadding(
226
- scrollView.paddingLeft,
227
- scrollView.paddingTop,
228
- scrollView.paddingRight,
229
- paddingBottom
230
- )
209
+ /**
210
+ * Re-applies the base content inset — the bottom safe-area inset while the
211
+ * content can scroll, no relative footer absorbs it, and
212
+ * `contentInsetAdjustmentBehavior` isn't disabled.
213
+ */
214
+ fun updateContentInset() {
215
+ val scrollView = detectedScrollView ?: return
216
+
217
+ val enabled = scrollableOptions?.contentInsetAdjustment ?: true
218
+ baseBottomInset = if (enabled && delegate?.hasRelativeFooter != true && canScrollContent(scrollView)) {
219
+ bottomInset
220
+ } else {
221
+ 0
222
+ }
223
+
224
+ applyBottomInset()
231
225
  }
232
226
 
233
- fun clearScrollable() {
234
- pinnedScrollView?.setOnScrollChangeListener(null as View.OnScrollChangeListener?)
235
- pinnedScrollView?.isNestedScrollingEnabled = false
236
- (pinnedScrollView?.parent as? SwipeRefreshLayout)?.isNestedScrollingEnabled = true
237
- setScrollViewPaddingBottom(originalScrollViewPaddingBottom)
238
- observedScrollChild?.removeOnLayoutChangeListener(scrollChildLayoutListener)
239
- observedScrollChild = null
240
- setScrollableBounded(false)
241
- pinnedScrollView = null
242
- originalScrollViewPaddingBottom = 0
243
- bottomInset = 0
244
- reportSizeIfChanged()
227
+ // Fabric's measured height excludes the native inset, avoiding feedback.
228
+ private fun canScrollContent(scrollView: ViewGroup): Boolean {
229
+ val child = scrollView.getChildAt(0) ?: return false
230
+ return child.measuredHeight > scrollView.height - scrollView.paddingTop - scrollView.paddingBottom
245
231
  }
246
232
 
247
- fun findScrollView(): ViewGroup? {
248
- if (pinnedScrollView != null) return pinnedScrollView
249
- return findScrollView(this as View)
233
+ // The keyboard covers the safe-area region, so the larger inset wins over a sum
234
+ private fun applyBottomInset() {
235
+ setScrollContentInset(maxOf(baseBottomInset, keyboardBottomInset))
250
236
  }
251
237
 
252
- private fun findScrollView(view: View): ViewGroup? {
253
- if (view is ScrollView || view is NestedScrollView) {
254
- return view as ViewGroup
255
- }
238
+ private fun setScrollContentInset(inset: Int) {
239
+ val scrollView = detectedScrollView ?: return
240
+ val child = scrollView.getChildAt(0) ?: return
241
+ scrollView.clipToPadding = false
242
+ // ScrollView's canScrollVertically ignores bottom padding. Extend the
243
+ // content bounds instead, keeping Fabric's measured height as the baseline.
244
+ child.bottom = child.top + child.measuredHeight + inset
245
+ }
256
246
 
257
- if (view is ViewGroup) {
258
- for (i in 0 until view.childCount) {
259
- val scrollView = findScrollView(view.getChildAt(i))
260
- if (scrollView != null) {
261
- return scrollView
262
- }
263
- }
264
- }
247
+ fun clearScrollable() {
248
+ detectedScrollView?.setOnScrollChangeListener(null as View.OnScrollChangeListener?)
249
+ detectedScrollView?.getChildAt(0)?.removeOnLayoutChangeListener(scrollContentLayoutListener)
250
+ detectedScrollView?.isNestedScrollingEnabled = false
251
+ (detectedScrollView?.parent as? SwipeRefreshLayout)?.isNestedScrollingEnabled = true
252
+ setScrollContentInset(0)
253
+ detectedScrollView = null
254
+ baseBottomInset = 0
255
+ keyboardBottomInset = 0
256
+ appliedKeyboardOffset = 0
257
+ }
265
258
 
266
- return null
259
+ /**
260
+ * Resolves the user-provided `scrollableHandle` within the content subtree —
261
+ * React tags are view ids on Android.
262
+ */
263
+ fun findScrollView(): ViewGroup? {
264
+ if (detectedScrollView != null) return detectedScrollView
265
+ if (scrollableHandle <= 0) return null
266
+ return findViewById<View>(scrollableHandle) as? ViewGroup
267
267
  }
268
268
 
269
269
  // ==================== Keyboard Handling ====================
@@ -273,8 +273,19 @@ class TrueSheetContentView(private val reactContext: ThemedReactContext) : React
273
273
 
274
274
  keyboardObserver = TrueSheetKeyboardObserver(this, reactContext).apply {
275
275
  delegate = object : TrueSheetKeyboardObserverDelegate {
276
- override fun keyboardWillShow(height: Int) {
276
+ // Drive the inset per keyboard animation frame so the content tracks
277
+ // the keyboard edge instead of jumping to the final inset up front.
278
+ // On hide, the shrinking inset gradually clamps the scroll position
279
+ // back in sync with the keyboard.
280
+ override fun keyboardDidChangeHeight(height: Int) {
277
281
  updateScrollViewInsetForKeyboard(height)
282
+
283
+ // Follow the caret frame-by-frame — only API 30+ delivers
284
+ // incremental heights; legacy fires once after the keyboard is
285
+ // already up, where keyboardDidShow's smooth reveal takes over
286
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && !isHiding) {
287
+ scrollToFocusedInput(smooth = false)
288
+ }
278
289
  }
279
290
 
280
291
  override fun keyboardDidShow(height: Int) {
@@ -284,7 +295,6 @@ class TrueSheetContentView(private val reactContext: ThemedReactContext) : React
284
295
 
285
296
  override fun keyboardWillHide() {
286
297
  detachCaretListener()
287
- updateScrollViewInsetForKeyboard(0)
288
298
  }
289
299
 
290
300
  override fun focusDidChange(newFocus: View) {
@@ -329,30 +339,37 @@ class TrueSheetContentView(private val reactContext: ThemedReactContext) : React
329
339
  }
330
340
 
331
341
  private fun updateScrollViewInsetForKeyboard(keyboardHeight: Int) {
332
- val scrollView = pinnedScrollView ?: return
333
-
334
- // An absolute footer rises above the keyboard and covers the content's
335
- // bottom edge — include it so the caret clears the footer, not just the
336
- // keyboard. A relative footer stays behind the keyboard below the content,
337
- // so its height shields that much of the keyboard's overlap.
338
- val totalBottomInset = if (keyboardHeight > 0) {
339
- maxOf(0, keyboardHeight + (delegate?.footerKeyboardOcclusion ?: 0))
342
+ // A relative footer stays behind the keyboard below the content, so its
343
+ // height shields that much of the keyboard's overlap. An absolute footer
344
+ // floats within the viewport its clearance is the content padding's job
345
+ // (the caret reveal accounts for it, see scrollToFocusedInput).
346
+ keyboardBottomInset = if (keyboardHeight > 0) {
347
+ val baseInset = maxOf(0, keyboardHeight + minOf(0, delegate?.footerKeyboardOcclusion ?: 0))
348
+ val adjustedInset = maxOf(0, baseInset + keyboardOffset.toInt())
349
+ appliedKeyboardOffset = adjustedInset - baseInset
350
+ adjustedInset
340
351
  } else {
341
- bottomInset
352
+ appliedKeyboardOffset = 0
353
+ 0
342
354
  }
343
- setScrollViewPaddingBottom(originalScrollViewPaddingBottom + totalBottomInset)
344
-
345
- scrollView.post { nudgeScrollView() }
355
+ applyBottomInset()
356
+ clampScrollPosition()
346
357
  }
347
358
 
348
- private fun nudgeScrollView() {
349
- val scrollView = pinnedScrollView ?: return
350
- scrollView.smoothScrollBy(0, 1)
351
- scrollView.smoothScrollBy(0, -1)
359
+ // Content bounds changes don't re-clamp the scroll position until the next layout
360
+ // pass clamp synchronously so the content follows the shrinking inset on
361
+ // every keyboard frame instead of snapping afterwards
362
+ private fun clampScrollPosition() {
363
+ val scrollView = detectedScrollView ?: return
364
+ val child = scrollView.getChildAt(0) ?: return
365
+ val maxScrollY = maxOf(0, child.height - (scrollView.height - scrollView.paddingTop - scrollView.paddingBottom))
366
+ if (scrollView.scrollY > maxScrollY) {
367
+ scrollView.scrollTo(scrollView.scrollX, maxScrollY)
368
+ }
352
369
  }
353
370
 
354
- private fun scrollToFocusedInput() {
355
- val scrollView = pinnedScrollView ?: findScrollView() ?: return
371
+ private fun scrollToFocusedInput(smooth: Boolean = true) {
372
+ val scrollView = detectedScrollView ?: findScrollView() ?: return
356
373
  val focusedView = findFocus() ?: return
357
374
 
358
375
  val focusedLocation = IntArray(2)
@@ -379,17 +396,29 @@ class TrueSheetContentView(private val reactContext: ThemedReactContext) : React
379
396
  caretBottom = viewTop + focusedView.height
380
397
  }
381
398
 
399
+ // An absolute footer floats over the viewport's bottom edge — extend the
400
+ // caret target so it clears the footer, not just the keyboard.
401
+ val footerOcclusion = maxOf(0, delegate?.footerKeyboardOcclusion ?: 0)
402
+
382
403
  val offset = keyboardScrollOffset.toInt()
383
- val visibleHeight = scrollView.height - scrollView.paddingBottom
404
+ val visibleHeight = scrollView.height - scrollView.paddingBottom - maxOf(baseBottomInset, keyboardBottomInset) + appliedKeyboardOffset
384
405
  val visibleTop = scrollView.scrollY
385
406
  val visibleBottom = scrollView.scrollY + visibleHeight
386
407
 
387
- when {
388
- caretBottom + offset > visibleBottom ->
389
- scrollView.smoothScrollTo(0, caretBottom + offset - visibleHeight)
408
+ val targetY = when {
409
+ caretBottom + offset + footerOcclusion > visibleBottom ->
410
+ caretBottom + offset + footerOcclusion - visibleHeight
390
411
 
391
412
  caretTop - offset < visibleTop ->
392
- scrollView.smoothScrollTo(0, (caretTop - offset).coerceAtLeast(0))
413
+ (caretTop - offset).coerceAtLeast(0)
414
+
415
+ else -> return
416
+ }
417
+
418
+ if (smooth) {
419
+ scrollView.smoothScrollTo(0, targetY)
420
+ } else {
421
+ scrollView.scrollTo(0, targetY)
393
422
  }
394
423
  }
395
424
 
@@ -20,7 +20,11 @@ class TrueSheetContentViewManager : ViewGroupManager<TrueSheetContentView>() {
20
20
  override fun createViewInstance(reactContext: ThemedReactContext): TrueSheetContentView = TrueSheetContentView(reactContext)
21
21
 
22
22
  override fun updateState(view: TrueSheetContentView, props: ReactStylesDiffMap?, stateWrapper: StateWrapper?): Any? {
23
- view.stateWrapper = stateWrapper
23
+ stateWrapper?.stateData?.let { data ->
24
+ if (data.hasKey("naturalHeight")) {
25
+ view.updateNaturalHeight(data.getDouble("naturalHeight"))
26
+ }
27
+ }
24
28
  return null
25
29
  }
26
30
 
@@ -15,6 +15,7 @@ import com.facebook.react.uimanager.ThemedReactContext
15
15
  import com.facebook.react.uimanager.events.EventDispatcher
16
16
  import com.facebook.react.views.view.ReactViewGroup
17
17
  import com.lodev09.truesheet.core.TrueSheetCoordinatorLayout
18
+ import com.lodev09.truesheet.utils.TouchEventDeduper
18
19
 
19
20
  /**
20
21
  * Delegate interface for footer view size changes and event dispatching
@@ -44,25 +45,17 @@ class TrueSheetFooterView(private val reactContext: ThemedReactContext) :
44
45
 
45
46
  private var lastWidth = 0
46
47
  private var lastHeight = 0
47
- private var bottomInset = 0
48
48
 
49
- /**
50
- * Skips the inset while the keyboard is open — an absolute footer rises
51
- * above the keyboard, so the inset would leave a gap.
52
- */
53
- var keyboardVisible = false
54
- set(value) {
55
- if (field == value) return
56
- field = value
57
- pushBottomInsetState()
58
- }
49
+ var bottomInset = 0
50
+ private set
59
51
 
60
52
  /**
61
53
  * Height the footer occupies above the keyboard — its layout height minus
62
- * the safe-area inset it drops while the keyboard is open.
54
+ * the safe-area inset, which rides below the keyboard's top edge while the
55
+ * keyboard is open (see positionFooter).
63
56
  */
64
57
  val keyboardOcclusionHeight: Int
65
- get() = maxOf(0, height - if (keyboardVisible) 0 else bottomInset)
58
+ get() = maxOf(0, height - bottomInset)
66
59
 
67
60
  /**
68
61
  * Tells the shadow node to pad the footer's bottom edge with the sheet's
@@ -72,12 +65,9 @@ class TrueSheetFooterView(private val reactContext: ThemedReactContext) :
72
65
  fun setBottomInset(inset: Int) {
73
66
  if (bottomInset == inset) return
74
67
  bottomInset = inset
75
- pushBottomInsetState()
76
- }
77
68
 
78
- private fun pushBottomInsetState() {
79
69
  val sw = stateWrapper ?: return
80
- val insetDp = (if (keyboardVisible) 0 else bottomInset).toFloat().pxToDp()
70
+ val insetDp = bottomInset.toFloat().pxToDp()
81
71
 
82
72
  // Synchronous update — the footer must be padded before detents are
83
73
  // configured, otherwise the auto detent is set up an inset short
@@ -91,6 +81,7 @@ class TrueSheetFooterView(private val reactContext: ThemedReactContext) :
91
81
 
92
82
  private val jsTouchDispatcher = JSTouchDispatcher(this)
93
83
  private var jsPointerDispatcher: JSPointerDispatcher? = null
84
+ private val touchDeduper = TouchEventDeduper()
94
85
 
95
86
  init {
96
87
  jsPointerDispatcher = JSPointerDispatcher(this)
@@ -110,7 +101,9 @@ class TrueSheetFooterView(private val reactContext: ThemedReactContext) :
110
101
 
111
102
  override fun onInterceptTouchEvent(event: MotionEvent): Boolean {
112
103
  eventDispatcher?.let { dispatcher ->
113
- jsTouchDispatcher.handleTouchEvent(event, dispatcher, reactContext)
104
+ if (touchDeduper.shouldDispatch(event)) {
105
+ jsTouchDispatcher.handleTouchEvent(event, dispatcher, reactContext)
106
+ }
114
107
  jsPointerDispatcher?.handleMotionEvent(event, dispatcher, true)
115
108
  }
116
109
  return super.onInterceptTouchEvent(event)
@@ -128,7 +121,9 @@ class TrueSheetFooterView(private val reactContext: ThemedReactContext) :
128
121
  }
129
122
 
130
123
  eventDispatcher?.let { dispatcher ->
131
- jsTouchDispatcher.handleTouchEvent(event, dispatcher, reactContext)
124
+ if (touchDeduper.shouldDispatch(event)) {
125
+ jsTouchDispatcher.handleTouchEvent(event, dispatcher, reactContext)
126
+ }
132
127
  jsPointerDispatcher?.handleMotionEvent(event, dispatcher, false)
133
128
  }
134
129
  super.onTouchEvent(event)