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

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 (205) hide show
  1. package/README.md +25 -4
  2. package/android/build.gradle +22 -1
  3. package/android/src/main/java/com/lodev09/truesheet/TrueSheetContainerView.kt +50 -16
  4. package/android/src/main/java/com/lodev09/truesheet/TrueSheetContentView.kt +303 -184
  5. package/android/src/main/java/com/lodev09/truesheet/TrueSheetContentViewManager.kt +5 -1
  6. package/android/src/main/java/com/lodev09/truesheet/TrueSheetFooterView.kt +21 -110
  7. package/android/src/main/java/com/lodev09/truesheet/TrueSheetOverlayView.kt +116 -0
  8. package/android/src/main/java/com/lodev09/truesheet/TrueSheetOverlayViewManager.kt +45 -0
  9. package/android/src/main/java/com/lodev09/truesheet/TrueSheetPackage.kt +2 -1
  10. package/android/src/main/java/com/lodev09/truesheet/TrueSheetStateUpdater.kt +18 -0
  11. package/android/src/main/java/com/lodev09/truesheet/TrueSheetView.kt +124 -50
  12. package/android/src/main/java/com/lodev09/truesheet/TrueSheetViewController.kt +369 -163
  13. package/android/src/main/java/com/lodev09/truesheet/TrueSheetViewManager.kt +59 -12
  14. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetBottomSheetBehavior.kt +126 -0
  15. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetBottomSheetView.kt +22 -9
  16. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetCoordinatorLayout.kt +84 -5
  17. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetDetentCalculator.kt +104 -39
  18. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetDimView.kt +3 -0
  19. package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetOverlayRootView.kt +137 -0
  20. package/android/src/main/java/com/lodev09/truesheet/events/TrueSheetLifecycleEvents.kt +15 -0
  21. package/android/src/main/java/com/lodev09/truesheet/utils/KeyboardUtils.kt +15 -2
  22. package/android/src/main/java/com/lodev09/truesheet/utils/ScreenUtils.kt +0 -12
  23. package/android/src/main/java/com/lodev09/truesheet/utils/ViewUtils.kt +19 -7
  24. package/android/src/main/jni/TrueSheetSpec.h +1 -0
  25. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewComponentDescriptor.h +1 -9
  26. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewShadowNode.cpp +110 -30
  27. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewShadowNode.h +30 -7
  28. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewState.cpp +2 -2
  29. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewState.h +9 -8
  30. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetFooterViewComponentDescriptor.h +3 -5
  31. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetFooterViewShadowNode.cpp +9 -3
  32. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetFooterViewShadowNode.h +3 -6
  33. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetFooterViewState.cpp +1 -1
  34. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetFooterViewState.h +4 -6
  35. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetInsets.cpp +1 -1
  36. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetInsets.h +1 -1
  37. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetLayoutUtils.h +43 -0
  38. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetOverlayViewComponentDescriptor.h +21 -0
  39. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetOverlayViewShadowNode.cpp +17 -0
  40. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetOverlayViewShadowNode.h +33 -0
  41. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetViewComponentDescriptor.h +3 -5
  42. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetViewShadowNode.cpp +7 -40
  43. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetViewShadowNode.h +3 -7
  44. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetViewState.cpp +3 -5
  45. package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetViewState.h +4 -5
  46. package/ios/TrueSheetContainerView.h +27 -7
  47. package/ios/TrueSheetContainerView.mm +31 -11
  48. package/ios/TrueSheetContentView.h +43 -15
  49. package/ios/TrueSheetContentView.mm +211 -189
  50. package/ios/TrueSheetFooterView.h +6 -0
  51. package/ios/TrueSheetFooterView.mm +93 -17
  52. package/ios/TrueSheetModule.h +2 -7
  53. package/ios/TrueSheetModule.mm +7 -16
  54. package/ios/TrueSheetOverlayView.h +32 -0
  55. package/ios/TrueSheetOverlayView.mm +161 -0
  56. package/ios/TrueSheetView.mm +275 -56
  57. package/ios/TrueSheetViewController.h +31 -3
  58. package/ios/TrueSheetViewController.mm +393 -94
  59. package/ios/core/TrueSheetBlurView.h +0 -2
  60. package/ios/core/TrueSheetBlurView.mm +3 -36
  61. package/ios/core/TrueSheetDetentCalculator.h +7 -1
  62. package/ios/core/TrueSheetDetentCalculator.mm +27 -4
  63. package/ios/core/TrueSheetGrabberView.mm +13 -3
  64. package/ios/core/TrueSheetKeyboardObserver.mm +2 -0
  65. package/ios/core/TrueSheetOverlayContainerView.h +31 -0
  66. package/ios/core/TrueSheetOverlayContainerView.mm +35 -0
  67. package/ios/events/TrueSheetLifecycleEvents.h +2 -0
  68. package/ios/events/TrueSheetLifecycleEvents.mm +8 -0
  69. package/ios/tvos/TrueSheetStubs.mm +12 -0
  70. package/ios/utils/UIView+ScrollEdgeInteraction.h +1 -0
  71. package/ios/utils/UIView+ScrollEdgeInteraction.mm +24 -2
  72. package/lib/module/TrueSheet.js +175 -39
  73. package/lib/module/TrueSheet.js.map +1 -1
  74. package/lib/module/TrueSheet.web.js +220 -243
  75. package/lib/module/TrueSheet.web.js.map +1 -1
  76. package/lib/module/TrueSheetBackground.js +15 -0
  77. package/lib/module/TrueSheetBackground.js.map +1 -0
  78. package/lib/module/TrueSheetOverlay.js +30 -0
  79. package/lib/module/TrueSheetOverlay.js.map +1 -0
  80. package/lib/module/TrueSheetOverlay.web.js +35 -0
  81. package/lib/module/TrueSheetOverlay.web.js.map +1 -0
  82. package/lib/module/TrueSheetPeek.web.js +20 -15
  83. package/lib/module/TrueSheetPeek.web.js.map +1 -1
  84. package/lib/module/detentBackgrounds.js +21 -0
  85. package/lib/module/detentBackgrounds.js.map +1 -0
  86. package/lib/module/fabric/TrueSheetContentViewNativeComponent.ts +1 -1
  87. package/lib/module/fabric/TrueSheetOverlayViewNativeComponent.ts +12 -0
  88. package/lib/module/fabric/TrueSheetViewNativeComponent.ts +15 -10
  89. package/lib/module/index.js +1 -0
  90. package/lib/module/index.js.map +1 -1
  91. package/lib/module/mocks/index.js +18 -2
  92. package/lib/module/mocks/index.js.map +1 -1
  93. package/lib/module/mocks/navigationExpoRouter.js.map +1 -1
  94. package/lib/module/navigation/base-types.js +2 -0
  95. package/lib/module/navigation/base-types.js.map +1 -0
  96. package/lib/module/navigation/createTrueSheetRouter.js +0 -9
  97. package/lib/module/navigation/createTrueSheetRouter.js.map +1 -1
  98. package/lib/module/navigation/expo-router/base-types.js +2 -0
  99. package/lib/module/navigation/expo-router/base-types.js.map +1 -0
  100. package/lib/module/navigation/expo-router/index.js +10 -7
  101. package/lib/module/navigation/expo-router/index.js.map +1 -1
  102. package/lib/module/navigation/expo-router/types.js +4 -0
  103. package/lib/module/navigation/expo-router/types.js.map +1 -0
  104. package/lib/module/navigation/index.js.map +1 -1
  105. package/lib/module/navigation/screen/useSheetScreenState.js +1 -0
  106. package/lib/module/navigation/screen/useSheetScreenState.js.map +1 -1
  107. package/lib/module/web/constants.js +1 -1
  108. package/lib/module/web/constants.js.map +1 -1
  109. package/lib/module/web/layout.js +168 -0
  110. package/lib/module/web/layout.js.map +1 -0
  111. package/lib/module/web/vaul/constants.js +9 -0
  112. package/lib/module/web/vaul/constants.js.map +1 -1
  113. package/lib/module/web/vaul/context.js.map +1 -1
  114. package/lib/module/web/vaul/helpers.js +1 -0
  115. package/lib/module/web/vaul/helpers.js.map +1 -1
  116. package/lib/module/web/vaul/index.js +47 -7
  117. package/lib/module/web/vaul/index.js.map +1 -1
  118. package/lib/module/web/vaul/use-snap-points.js +21 -10
  119. package/lib/module/web/vaul/use-snap-points.js.map +1 -1
  120. package/lib/typescript/src/TrueSheet.d.ts +6 -0
  121. package/lib/typescript/src/TrueSheet.d.ts.map +1 -1
  122. package/lib/typescript/src/TrueSheet.types.d.ts +157 -34
  123. package/lib/typescript/src/TrueSheet.types.d.ts.map +1 -1
  124. package/lib/typescript/src/TrueSheet.web.d.ts.map +1 -1
  125. package/lib/typescript/src/TrueSheetBackground.d.ts +3 -0
  126. package/lib/typescript/src/TrueSheetBackground.d.ts.map +1 -0
  127. package/lib/typescript/src/TrueSheetOverlay.d.ts +8 -0
  128. package/lib/typescript/src/TrueSheetOverlay.d.ts.map +1 -0
  129. package/lib/typescript/src/TrueSheetOverlay.web.d.ts +8 -0
  130. package/lib/typescript/src/TrueSheetOverlay.web.d.ts.map +1 -0
  131. package/lib/typescript/src/TrueSheetPeek.web.d.ts +8 -4
  132. package/lib/typescript/src/TrueSheetPeek.web.d.ts.map +1 -1
  133. package/lib/typescript/src/detentBackgrounds.d.ts +10 -0
  134. package/lib/typescript/src/detentBackgrounds.d.ts.map +1 -0
  135. package/lib/typescript/src/fabric/TrueSheetOverlayViewNativeComponent.d.ts +6 -0
  136. package/lib/typescript/src/fabric/TrueSheetOverlayViewNativeComponent.d.ts.map +1 -0
  137. package/lib/typescript/src/fabric/TrueSheetViewNativeComponent.d.ts +12 -7
  138. package/lib/typescript/src/fabric/TrueSheetViewNativeComponent.d.ts.map +1 -1
  139. package/lib/typescript/src/index.d.ts +1 -0
  140. package/lib/typescript/src/index.d.ts.map +1 -1
  141. package/lib/typescript/src/mocks/index.d.ts +4 -0
  142. package/lib/typescript/src/mocks/index.d.ts.map +1 -1
  143. package/lib/typescript/src/mocks/navigationExpoRouter.d.ts +3 -3
  144. package/lib/typescript/src/mocks/navigationExpoRouter.d.ts.map +1 -1
  145. package/lib/typescript/src/navigation/base-types.d.ts +9 -0
  146. package/lib/typescript/src/navigation/base-types.d.ts.map +1 -0
  147. package/lib/typescript/src/navigation/createTrueSheetRouter.d.ts +2 -13
  148. package/lib/typescript/src/navigation/createTrueSheetRouter.d.ts.map +1 -1
  149. package/lib/typescript/src/navigation/expo-router/base-types.d.ts +8 -0
  150. package/lib/typescript/src/navigation/expo-router/base-types.d.ts.map +1 -0
  151. package/lib/typescript/src/navigation/expo-router/index.d.ts +8 -6
  152. package/lib/typescript/src/navigation/expo-router/index.d.ts.map +1 -1
  153. package/lib/typescript/src/navigation/expo-router/types.d.ts +44 -0
  154. package/lib/typescript/src/navigation/expo-router/types.d.ts.map +1 -0
  155. package/lib/typescript/src/navigation/index.d.ts +3 -4
  156. package/lib/typescript/src/navigation/index.d.ts.map +1 -1
  157. package/lib/typescript/src/navigation/screen/useSheetScreenState.d.ts +2 -1
  158. package/lib/typescript/src/navigation/screen/useSheetScreenState.d.ts.map +1 -1
  159. package/lib/typescript/src/navigation/types.d.ts +41 -5
  160. package/lib/typescript/src/navigation/types.d.ts.map +1 -1
  161. package/lib/typescript/src/web/constants.d.ts +1 -1
  162. package/lib/typescript/src/web/constants.d.ts.map +1 -1
  163. package/lib/typescript/src/web/layout.d.ts +19 -0
  164. package/lib/typescript/src/web/layout.d.ts.map +1 -0
  165. package/lib/typescript/src/web/vaul/constants.d.ts +3 -0
  166. package/lib/typescript/src/web/vaul/constants.d.ts.map +1 -1
  167. package/lib/typescript/src/web/vaul/context.d.ts +1 -0
  168. package/lib/typescript/src/web/vaul/context.d.ts.map +1 -1
  169. package/lib/typescript/src/web/vaul/helpers.d.ts +1 -0
  170. package/lib/typescript/src/web/vaul/helpers.d.ts.map +1 -1
  171. package/lib/typescript/src/web/vaul/index.d.ts +13 -1
  172. package/lib/typescript/src/web/vaul/index.d.ts.map +1 -1
  173. package/lib/typescript/src/web/vaul/use-snap-points.d.ts +2 -1
  174. package/lib/typescript/src/web/vaul/use-snap-points.d.ts.map +1 -1
  175. package/package.json +17 -8
  176. package/react-native.config.js +1 -0
  177. package/src/TrueSheet.tsx +208 -48
  178. package/src/TrueSheet.types.ts +166 -35
  179. package/src/TrueSheet.web.tsx +272 -290
  180. package/src/TrueSheetBackground.tsx +17 -0
  181. package/src/TrueSheetOverlay.tsx +24 -0
  182. package/src/TrueSheetOverlay.web.tsx +33 -0
  183. package/src/TrueSheetPeek.web.tsx +25 -23
  184. package/src/detentBackgrounds.ts +24 -0
  185. package/src/fabric/TrueSheetContentViewNativeComponent.ts +1 -1
  186. package/src/fabric/TrueSheetOverlayViewNativeComponent.ts +12 -0
  187. package/src/fabric/TrueSheetViewNativeComponent.ts +15 -10
  188. package/src/index.ts +1 -0
  189. package/src/mocks/index.ts +17 -2
  190. package/src/mocks/navigationExpoRouter.ts +6 -5
  191. package/src/navigation/base-types.ts +20 -0
  192. package/src/navigation/createTrueSheetRouter.ts +5 -33
  193. package/src/navigation/expo-router/base-types.ts +18 -0
  194. package/src/navigation/expo-router/index.ts +28 -30
  195. package/src/navigation/expo-router/types.ts +116 -0
  196. package/src/navigation/index.ts +6 -7
  197. package/src/navigation/screen/useSheetScreenState.ts +2 -0
  198. package/src/navigation/types.ts +75 -22
  199. package/src/web/constants.ts +1 -1
  200. package/src/web/layout.ts +196 -0
  201. package/src/web/vaul/constants.ts +9 -0
  202. package/src/web/vaul/context.ts +1 -0
  203. package/src/web/vaul/helpers.ts +5 -0
  204. package/src/web/vaul/index.tsx +75 -5
  205. package/src/web/vaul/use-snap-points.ts +32 -15
@@ -1,26 +1,50 @@
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
17
+ import com.lodev09.truesheet.utils.ScreenUtils
19
18
  import com.lodev09.truesheet.utils.isDescendantOf
20
- import com.lodev09.truesheet.utils.smoothScrollBy
21
19
  import com.lodev09.truesheet.utils.smoothScrollTo
22
20
 
23
- data class ScrollableOptions(val keyboardScrollOffset: Float = 0f, val scrollingExpandsSheet: Boolean = true)
21
+ /**
22
+ * What the sheet insets the bottom of the scroll content for — the safe area
23
+ * while it can scroll, and/or an absolute footer floating over it.
24
+ */
25
+ enum class TrueSheetContentInsetAdjustment(val safeArea: Boolean, val footer: Boolean) {
26
+ AUTOMATIC(safeArea = true, footer = true),
27
+ SAFE_AREA(safeArea = true, footer = false),
28
+ FOOTER(safeArea = false, footer = true),
29
+ NEVER(safeArea = false, footer = false);
30
+
31
+ companion object {
32
+ fun fromString(value: String?): TrueSheetContentInsetAdjustment =
33
+ when (value) {
34
+ "safe-area" -> SAFE_AREA
35
+ "footer" -> FOOTER
36
+ "never" -> NEVER
37
+ else -> AUTOMATIC
38
+ }
39
+ }
40
+ }
41
+
42
+ data class ScrollableOptions(
43
+ val keyboardScrollOffset: Float = 0f,
44
+ val keyboardOffset: Float = 0f,
45
+ val scrollingExpandsSheet: Boolean = true,
46
+ val contentInsetAdjustment: TrueSheetContentInsetAdjustment = TrueSheetContentInsetAdjustment.AUTOMATIC
47
+ )
24
48
 
25
49
  /**
26
50
  * Delegate interface for content view size changes
@@ -32,10 +56,34 @@ interface TrueSheetContentViewDelegate {
32
56
 
33
57
  /**
34
58
  * 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.
59
+ * footer floating over the viewport's bottom edge (extends the caret reveal),
60
+ * negative for a relative footer sitting behind the keyboard shielding that
61
+ * much of its overlap (reduces the keyboard padding).
37
62
  */
38
63
  val footerKeyboardOcclusion: Int
64
+
65
+ /**
66
+ * Layout height of an absolute footer floating over the content, including
67
+ * its absorbed safe-area inset — 0 for a relative footer.
68
+ */
69
+ val absoluteFooterHeight: Int
70
+
71
+ // Includes the controller's translation during sheet transitions.
72
+ val absoluteFooterTop: Int
73
+
74
+ /**
75
+ * false when an absolute footer stays behind the keyboard instead of rising
76
+ * above it.
77
+ */
78
+ val footerAvoidsKeyboard: Boolean
79
+
80
+ /**
81
+ * A relative footer sits below the content and absorbs the bottom safe-area
82
+ * inset — the scrollable doesn't reach the sheet's bottom edge, so no
83
+ * content inset is applied (mirrors iOS, where UIKit resolves this
84
+ * geometrically via the scroll view's safe area).
85
+ */
86
+ val hasRelativeFooter: Boolean
39
87
  }
40
88
 
41
89
  /**
@@ -45,44 +93,42 @@ interface TrueSheetContentViewDelegate {
45
93
  @SuppressLint("ViewConstructor")
46
94
  class TrueSheetContentView(private val reactContext: ThemedReactContext) : ReactViewGroup(reactContext) {
47
95
  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
- }
96
+
97
+ private var detectedScrollView: ViewGroup? = null
68
98
 
69
99
  /**
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.
100
+ * Content height measured unconstrained by the shadow node — the height the
101
+ * content wants regardless of container bounds (see
102
+ * TrueSheetContentViewShadowNode). Falls back to the view height before the
103
+ * first state update.
73
104
  */
74
105
  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
- }
106
+ get() = if (lastNaturalHeight > 0) lastNaturalHeight else height
107
+
108
+ private var lastNaturalHeight = 0
109
+
110
+ fun updateNaturalHeight(heightDp: Double) {
111
+ val heightPx = heightDp.toFloat().dpToPx().toInt()
112
+ if (heightPx == lastNaturalHeight) return
113
+ lastNaturalHeight = heightPx
114
+ delegate?.contentViewDidChangeSize(width, heightPx)
115
+ }
84
116
 
85
117
  private var keyboardScrollOffset: Float = 0f
118
+
119
+ /**
120
+ * Adjustment added to the keyboard bottom inset applied to the detected
121
+ * scrollable — negative values reduce the inset (e.g. to cancel out safe-area
122
+ * padding already baked into the content's paddingBottom).
123
+ */
124
+ private var keyboardOffset: Float = 0f
125
+
126
+ /**
127
+ * How much of keyboardOffset actually landed in the applied inset — the
128
+ * caret reveal compensates by this so it still targets the real keyboard edge.
129
+ */
130
+ private var appliedKeyboardOffset = 0
131
+
86
132
  private var keyboardObserver: TrueSheetKeyboardObserver? = null
87
133
 
88
134
  private var watchedEditText: EditText? = null
@@ -92,178 +138,207 @@ class TrueSheetContentView(private val reactContext: ThemedReactContext) : React
92
138
  set(value) {
93
139
  field = value
94
140
  keyboardScrollOffset = value?.keyboardScrollOffset?.dpToPx() ?: 0f
141
+ keyboardOffset = value?.keyboardOffset?.dpToPx() ?: 0f
142
+ updateContentInset()
95
143
  }
96
144
 
97
145
  /**
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.
146
+ * Bottom safe-area inset applied to the scrollable's content padding while
147
+ * the content can scroll — Android counterpart of iOS's
148
+ * `contentInsetAdjustmentBehavior="automatic"`. Already zero when the
149
+ * sheet's `insetAdjustment` is `'never'`.
101
150
  */
102
- var hasAutoDetent = false
151
+ var bottomInset: Int = 0
103
152
  set(value) {
104
153
  if (field == value) return
105
154
  field = value
106
- if (pinnedScrollView != null) {
107
- setScrollableBounded(value)
108
- }
155
+ updateContentInset()
109
156
  }
110
157
 
111
- override fun addView(child: View?, index: Int) {
112
- super.addView(child, index)
113
- checkScrollViewChanged()
158
+ private var baseBottomInset = 0
159
+ private var keyboardBottomInset = 0
160
+
161
+ /**
162
+ * Height of the absolute footer the scroll content is padded by (see
163
+ * ScrollableOptions.contentInsetAdjustment), applied on top of the
164
+ * safe-area or keyboard inset.
165
+ */
166
+ private var footerBottomInset = 0
167
+
168
+ private val scrollableLayoutListener = ViewTreeObserver.OnGlobalLayoutListener {
169
+ updateContentInset()
114
170
  }
115
171
 
116
- override fun removeViewAt(index: Int) {
117
- super.removeViewAt(index)
118
- checkScrollViewChanged()
172
+ private val scrollContentLayoutListener = View.OnLayoutChangeListener { _, _, _, _, _, _, _, _, _ ->
173
+ updateContentInset()
119
174
  }
120
175
 
121
- private fun checkScrollViewChanged() {
122
- if (pinnedScrollView == null || pinnedScrollView?.isDescendantOf(this) == false) {
123
- delegate?.contentViewScrollViewDidChange()
124
- }
176
+ override fun onAttachedToWindow() {
177
+ super.onAttachedToWindow()
178
+ viewTreeObserver.addOnGlobalLayoutListener(scrollableLayoutListener)
125
179
  }
126
180
 
127
- override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
128
- super.onSizeChanged(w, h, oldw, oldh)
129
- reportSizeIfChanged()
181
+ override fun onDetachedFromWindow() {
182
+ viewTreeObserver.removeOnGlobalLayoutListener(scrollableLayoutListener)
183
+ super.onDetachedFromWindow()
130
184
  }
131
185
 
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
186
+ /**
187
+ * React tag of the user-provided scrollable (see the `scrollableRef` prop).
188
+ * Setting a new handle clears the currently resolved ScrollView.
189
+ */
190
+ var scrollableHandle: Int = -1
191
+ set(value) {
192
+ if (field == value) return
193
+ field = value
194
+ clearScrollable()
146
195
  }
147
196
 
148
- reportSizeNow()
197
+ override fun addView(child: View?, index: Int) {
198
+ super.addView(child, index)
199
+ checkScrollViewChanged()
149
200
  }
150
201
 
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
- }
202
+ override fun removeViewAt(index: Int) {
203
+ super.removeViewAt(index)
204
+ checkScrollViewChanged()
158
205
  }
159
206
 
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)
207
+ private fun checkScrollViewChanged() {
208
+ if (detectedScrollView == null || detectedScrollView?.isDescendantOf(this) == false) {
209
+ delegate?.contentViewScrollViewDidChange()
173
210
  }
174
211
  }
175
212
 
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) {
213
+ fun setupScrollable() {
214
+ // Check if the detected scroll view is still valid (still in view hierarchy)
215
+ if (detectedScrollView != null && detectedScrollView?.isDescendantOf(this) == false) {
179
216
  clearScrollable()
180
217
  }
181
218
 
182
- // Already set up with same inset and valid scroll view
183
- if (pinnedScrollView != null && this.bottomInset == bottomInset) {
219
+ if (detectedScrollView != null) {
184
220
  return
185
221
  }
186
222
 
187
- val scrollView = findScrollView(this) ?: return
223
+ val scrollView = findScrollView() ?: return
188
224
 
189
- // Only capture originals on first pin
190
- if (pinnedScrollView == null) {
191
- originalScrollViewPaddingBottom = scrollView.paddingBottom
192
- pinnedScrollView = scrollView
225
+ detectedScrollView = scrollView
193
226
 
194
- scrollView.isNestedScrollingEnabled = true
195
- (scrollView.parent as? SwipeRefreshLayout)?.isNestedScrollingEnabled = false
227
+ scrollView.isNestedScrollingEnabled = true
228
+ (scrollView.parent as? SwipeRefreshLayout)?.isNestedScrollingEnabled = false
196
229
 
197
- scrollView.setOnScrollChangeListener { _, _, scrollY, _, oldScrollY ->
198
- if (scrollY != oldScrollY) {
199
- delegate?.contentViewDidScroll()
200
- }
230
+ scrollView.setOnScrollChangeListener { _, _, scrollY, _, oldScrollY ->
231
+ if (scrollY != oldScrollY) {
232
+ delegate?.contentViewDidScroll()
201
233
  }
234
+ }
202
235
 
203
- scrollView.getChildAt(0)?.let { child ->
204
- observedScrollChild = child
205
- child.addOnLayoutChangeListener(scrollChildLayoutListener)
206
- }
207
- setScrollableBounded(hasAutoDetent)
208
- reportSizeIfChanged()
236
+ // Restore the inset before ReactScrollView's listener clamps the offset.
237
+ scrollView.getChildAt(0)?.let { child ->
238
+ if (scrollView is View.OnLayoutChangeListener) child.removeOnLayoutChangeListener(scrollView)
239
+ child.addOnLayoutChangeListener(scrollContentLayoutListener)
240
+ if (scrollView is View.OnLayoutChangeListener) child.addOnLayoutChangeListener(scrollView)
209
241
  }
210
242
 
211
- this.bottomInset = bottomInset
243
+ updateContentInset()
244
+ }
212
245
 
213
- setScrollViewPaddingBottom(originalScrollViewPaddingBottom + bottomInset)
246
+ /**
247
+ * Re-applies the base content inset — the bottom safe-area inset while the
248
+ * content can scroll, no relative footer absorbs it, and
249
+ * `contentInsetAdjustment` includes the safe area.
250
+ */
251
+ fun updateContentInset() {
252
+ val scrollView = detectedScrollView ?: return
214
253
 
215
- // If keyboard is currently showing, re-apply the keyboard inset to the new ScrollView
216
- val keyboardHeight = keyboardObserver?.currentHeight ?: 0
217
- if (keyboardHeight > 0) {
218
- updateScrollViewInsetForKeyboard(keyboardHeight)
254
+ val enabled = scrollableOptions?.contentInsetAdjustment?.safeArea ?: true
255
+ baseBottomInset = if (enabled && delegate?.hasRelativeFooter != true && canScrollContent(scrollView)) {
256
+ bottomInset
257
+ } else {
258
+ 0
219
259
  }
220
- }
221
260
 
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
- )
261
+ updateScrollViewInsetForKeyboard(keyboardObserver?.currentHeight ?: 0)
231
262
  }
232
263
 
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()
264
+ // Fabric's measured height excludes the native inset, avoiding feedback.
265
+ private fun canScrollContent(scrollView: ViewGroup): Boolean {
266
+ val child = scrollView.getChildAt(0) ?: return false
267
+ return child.measuredHeight > scrollView.height - scrollView.paddingTop - scrollView.paddingBottom
245
268
  }
246
269
 
247
- fun findScrollView(): ViewGroup? {
248
- if (pinnedScrollView != null) return pinnedScrollView
249
- return findScrollView(this as View)
270
+ // The keyboard covers the safe-area region, so the larger inset wins over a sum
271
+ private fun applyBottomInset() {
272
+ val safeAreaInset = maxOf(baseBottomInset, keyboardBottomInset)
273
+ footerBottomInset = footerInsetAbove(safeAreaInset)
274
+ setScrollContentInset(safeAreaInset + footerBottomInset)
250
275
  }
251
276
 
252
- private fun findScrollView(view: View): ViewGroup? {
253
- if (view is ScrollView || view is NestedScrollView) {
254
- return view as ViewGroup
277
+ // A pinned footer uses its positioned top so overlap follows the sheet's
278
+ // animation, even before the container reaches its final height. A rising
279
+ // footer uses its layout top because the keyboard inset carries its rise.
280
+ // The safe-area inset baked into the footer is already
281
+ // covered by the safe-area or keyboard inset when one applies — only the
282
+ // footer's occlusion above it stacks on top. A pinned footer adds only the
283
+ // overlap not already covered by the safe-area or keyboard inset.
284
+ private fun footerInsetAbove(safeAreaInset: Int): Int {
285
+ if (scrollableOptions?.contentInsetAdjustment?.footer == false) return 0
286
+ val scrollView = detectedScrollView ?: return 0
287
+ val container = parent as? View ?: return 0
288
+ val footerHeight = delegate?.absoluteFooterHeight ?: 0
289
+ if (footerHeight <= 0) return 0
290
+
291
+ var scrollBottom = scrollView.bottom
292
+ var view: View = scrollView.parent as? View ?: return 0
293
+ while (view !== container) {
294
+ scrollBottom += view.top
295
+ view = view.parent as? View ?: return 0
255
296
  }
256
297
 
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
- }
298
+ val footerTop = if (delegate?.footerAvoidsKeyboard == false) {
299
+ delegate?.absoluteFooterTop ?: return 0
300
+ } else {
301
+ container.height - footerHeight
302
+ }
303
+ var inset = scrollBottom - footerTop
304
+ if (delegate?.footerAvoidsKeyboard == false) {
305
+ inset -= safeAreaInset
306
+ } else if (safeAreaInset > 0) {
307
+ inset -= footerHeight - maxOf(0, delegate?.footerKeyboardOcclusion ?: 0)
264
308
  }
309
+ return maxOf(0, inset)
310
+ }
265
311
 
266
- return null
312
+ private fun setScrollContentInset(inset: Int) {
313
+ val scrollView = detectedScrollView ?: return
314
+ val child = scrollView.getChildAt(0) ?: return
315
+ scrollView.clipToPadding = false
316
+ // ScrollView's canScrollVertically ignores bottom padding. Extend the
317
+ // content bounds instead, keeping Fabric's measured height as the baseline.
318
+ child.bottom = child.top + child.measuredHeight + inset
319
+ }
320
+
321
+ fun clearScrollable() {
322
+ detectedScrollView?.setOnScrollChangeListener(null as View.OnScrollChangeListener?)
323
+ detectedScrollView?.getChildAt(0)?.removeOnLayoutChangeListener(scrollContentLayoutListener)
324
+ detectedScrollView?.isNestedScrollingEnabled = false
325
+ (detectedScrollView?.parent as? SwipeRefreshLayout)?.isNestedScrollingEnabled = true
326
+ setScrollContentInset(0)
327
+ detectedScrollView = null
328
+ baseBottomInset = 0
329
+ keyboardBottomInset = 0
330
+ footerBottomInset = 0
331
+ appliedKeyboardOffset = 0
332
+ }
333
+
334
+ /**
335
+ * Resolves the user-provided `scrollableHandle` within the content subtree —
336
+ * React tags are view ids on Android.
337
+ */
338
+ fun findScrollView(): ViewGroup? {
339
+ if (detectedScrollView != null) return detectedScrollView
340
+ if (scrollableHandle <= 0) return null
341
+ return findViewById<View>(scrollableHandle) as? ViewGroup
267
342
  }
268
343
 
269
344
  // ==================== Keyboard Handling ====================
@@ -273,8 +348,19 @@ class TrueSheetContentView(private val reactContext: ThemedReactContext) : React
273
348
 
274
349
  keyboardObserver = TrueSheetKeyboardObserver(this, reactContext).apply {
275
350
  delegate = object : TrueSheetKeyboardObserverDelegate {
276
- override fun keyboardWillShow(height: Int) {
351
+ // Drive the inset per keyboard animation frame so the content tracks
352
+ // the keyboard edge instead of jumping to the final inset up front.
353
+ // On hide, the shrinking inset gradually clamps the scroll position
354
+ // back in sync with the keyboard.
355
+ override fun keyboardDidChangeHeight(height: Int) {
277
356
  updateScrollViewInsetForKeyboard(height)
357
+
358
+ // Follow the caret frame-by-frame — only API 30+ delivers
359
+ // incremental heights; legacy fires once after the keyboard is
360
+ // already up, where keyboardDidShow's smooth reveal takes over
361
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && !isHiding) {
362
+ scrollToFocusedInput(smooth = false)
363
+ }
278
364
  }
279
365
 
280
366
  override fun keyboardDidShow(height: Int) {
@@ -284,7 +370,6 @@ class TrueSheetContentView(private val reactContext: ThemedReactContext) : React
284
370
 
285
371
  override fun keyboardWillHide() {
286
372
  detachCaretListener()
287
- updateScrollViewInsetForKeyboard(0)
288
373
  }
289
374
 
290
375
  override fun focusDidChange(newFocus: View) {
@@ -329,31 +414,46 @@ class TrueSheetContentView(private val reactContext: ThemedReactContext) : React
329
414
  }
330
415
 
331
416
  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))
417
+ val scrollView = detectedScrollView ?: return
418
+
419
+ // Short or nested scroll views can end above the sheet's bottom edge.
420
+ // Their position also accounts for space occupied by a relative footer.
421
+ keyboardBottomInset = if (keyboardHeight > 0) {
422
+ val location = IntArray(2)
423
+ scrollView.getLocationInWindow(location)
424
+ val keyboardTop = ScreenUtils.getRealScreenHeight(reactContext) - keyboardHeight
425
+ val baseInset = maxOf(0, location[1] + scrollView.height - keyboardTop)
426
+ val adjustedInset = maxOf(0, baseInset + keyboardOffset.toInt())
427
+ appliedKeyboardOffset = adjustedInset - baseInset
428
+ if (delegate?.footerAvoidsKeyboard == false) {
429
+ appliedKeyboardOffset += footerInsetAbove(maxOf(baseBottomInset, adjustedInset)) -
430
+ footerInsetAbove(maxOf(baseBottomInset, baseInset))
431
+ }
432
+ adjustedInset
340
433
  } else {
341
- bottomInset
434
+ appliedKeyboardOffset = 0
435
+ 0
342
436
  }
343
- setScrollViewPaddingBottom(originalScrollViewPaddingBottom + totalBottomInset)
344
-
345
- scrollView.post { nudgeScrollView() }
437
+ applyBottomInset()
438
+ clampScrollPosition()
346
439
  }
347
440
 
348
- private fun nudgeScrollView() {
349
- val scrollView = pinnedScrollView ?: return
350
- scrollView.smoothScrollBy(0, 1)
351
- scrollView.smoothScrollBy(0, -1)
441
+ // Content bounds changes don't re-clamp the scroll position until the next layout
442
+ // pass — clamp synchronously so the content follows the shrinking inset on
443
+ // every keyboard frame instead of snapping afterwards
444
+ private fun clampScrollPosition() {
445
+ val scrollView = detectedScrollView ?: return
446
+ val child = scrollView.getChildAt(0) ?: return
447
+ val maxScrollY = maxOf(0, child.height - (scrollView.height - scrollView.paddingTop - scrollView.paddingBottom))
448
+ if (scrollView.scrollY > maxScrollY) {
449
+ scrollView.scrollTo(scrollView.scrollX, maxScrollY)
450
+ }
352
451
  }
353
452
 
354
- private fun scrollToFocusedInput() {
355
- val scrollView = pinnedScrollView ?: findScrollView() ?: return
453
+ private fun scrollToFocusedInput(smooth: Boolean = true) {
454
+ val scrollView = detectedScrollView ?: findScrollView() ?: return
356
455
  val focusedView = findFocus() ?: return
456
+ if (!focusedView.isDescendantOf(scrollView)) return
357
457
 
358
458
  val focusedLocation = IntArray(2)
359
459
  val scrollViewLocation = IntArray(2)
@@ -379,17 +479,36 @@ class TrueSheetContentView(private val reactContext: ThemedReactContext) : React
379
479
  caretBottom = viewTop + focusedView.height
380
480
  }
381
481
 
482
+ // An absolute footer floats over the viewport's bottom edge — extend the
483
+ // caret target so it clears the footer, not just the keyboard. Not needed
484
+ // while the footer inset is applied (the visible range already ends above
485
+ // it) or when the footer stays behind the keyboard.
486
+ val footerOcclusion = if (footerBottomInset > 0 || delegate?.footerAvoidsKeyboard == false) {
487
+ 0
488
+ } else {
489
+ maxOf(0, delegate?.footerKeyboardOcclusion ?: 0)
490
+ }
491
+
382
492
  val offset = keyboardScrollOffset.toInt()
383
- val visibleHeight = scrollView.height - scrollView.paddingBottom
493
+ val appliedInset = maxOf(baseBottomInset, keyboardBottomInset) + footerBottomInset
494
+ val visibleHeight = scrollView.height - scrollView.paddingBottom - appliedInset + appliedKeyboardOffset
384
495
  val visibleTop = scrollView.scrollY
385
496
  val visibleBottom = scrollView.scrollY + visibleHeight
386
497
 
387
- when {
388
- caretBottom + offset > visibleBottom ->
389
- scrollView.smoothScrollTo(0, caretBottom + offset - visibleHeight)
498
+ val targetY = when {
499
+ caretBottom + offset + footerOcclusion > visibleBottom ->
500
+ caretBottom + offset + footerOcclusion - visibleHeight
390
501
 
391
502
  caretTop - offset < visibleTop ->
392
- scrollView.smoothScrollTo(0, (caretTop - offset).coerceAtLeast(0))
503
+ (caretTop - offset).coerceAtLeast(0)
504
+
505
+ else -> return
506
+ }
507
+
508
+ if (smooth) {
509
+ scrollView.smoothScrollTo(0, targetY)
510
+ } else {
511
+ scrollView.scrollTo(0, targetY)
393
512
  }
394
513
  }
395
514
 
@@ -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