@lodev09/react-native-true-sheet 4.0.0-beta.3 → 4.0.0-beta.5
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.
- package/README.md +21 -2
- package/android/src/main/java/com/lodev09/truesheet/TrueSheetContainerView.kt +24 -7
- package/android/src/main/java/com/lodev09/truesheet/TrueSheetContentView.kt +195 -174
- package/android/src/main/java/com/lodev09/truesheet/TrueSheetContentViewManager.kt +5 -1
- package/android/src/main/java/com/lodev09/truesheet/TrueSheetFooterView.kt +6 -17
- package/android/src/main/java/com/lodev09/truesheet/TrueSheetView.kt +50 -8
- package/android/src/main/java/com/lodev09/truesheet/TrueSheetViewController.kt +101 -18
- package/android/src/main/java/com/lodev09/truesheet/TrueSheetViewManager.kt +24 -1
- package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetCoordinatorLayout.kt +3 -3
- package/android/src/main/java/com/lodev09/truesheet/core/TrueSheetDetentCalculator.kt +14 -24
- package/android/src/main/java/com/lodev09/truesheet/utils/ViewUtils.kt +0 -7
- package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewComponentDescriptor.h +0 -8
- package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewShadowNode.cpp +54 -29
- package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewShadowNode.h +6 -1
- package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewState.cpp +1 -1
- package/common/cpp/react/renderer/components/TrueSheetSpec/TrueSheetContentViewState.h +6 -4
- package/ios/TrueSheetContainerView.h +9 -7
- package/ios/TrueSheetContainerView.mm +6 -7
- package/ios/TrueSheetContentView.h +28 -14
- package/ios/TrueSheetContentView.mm +122 -179
- package/ios/TrueSheetFooterView.mm +17 -0
- package/ios/TrueSheetView.mm +13 -10
- package/ios/TrueSheetViewController.mm +35 -27
- package/ios/utils/UIView+ScrollEdgeInteraction.h +1 -0
- package/ios/utils/UIView+ScrollEdgeInteraction.mm +24 -2
- package/lib/module/TrueSheet.js +47 -14
- package/lib/module/TrueSheet.js.map +1 -1
- package/lib/module/TrueSheet.web.js +26 -24
- package/lib/module/TrueSheet.web.js.map +1 -1
- package/lib/module/fabric/TrueSheetContentViewNativeComponent.ts +1 -1
- package/lib/module/fabric/TrueSheetViewNativeComponent.ts +4 -0
- package/lib/typescript/src/TrueSheet.d.ts +2 -0
- package/lib/typescript/src/TrueSheet.d.ts.map +1 -1
- package/lib/typescript/src/TrueSheet.types.d.ts +64 -1
- package/lib/typescript/src/TrueSheet.types.d.ts.map +1 -1
- package/lib/typescript/src/TrueSheet.web.d.ts.map +1 -1
- package/lib/typescript/src/fabric/TrueSheetViewNativeComponent.d.ts +3 -0
- package/lib/typescript/src/fabric/TrueSheetViewNativeComponent.d.ts.map +1 -1
- package/lib/typescript/src/navigation/types.d.ts +1 -1
- package/lib/typescript/src/navigation/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/TrueSheet.tsx +48 -13
- package/src/TrueSheet.types.ts +68 -1
- package/src/TrueSheet.web.tsx +39 -24
- package/src/fabric/TrueSheetContentViewNativeComponent.ts +1 -1
- package/src/fabric/TrueSheetViewNativeComponent.ts +4 -0
- package/src/navigation/types.ts +1 -0
package/README.md
CHANGED
|
@@ -22,6 +22,23 @@ The true native bottom sheet experience for your React Native Apps. 💩
|
|
|
22
22
|
* 🧭 **React Navigation** - Built-in [sheet navigator](https://sheet.lodev09.com/guides/navigation) for seamless navigation integration
|
|
23
23
|
* 🌐 **Web Support** - Full [web support](https://sheet.lodev09.com/guides/web) out of the box
|
|
24
24
|
|
|
25
|
+
## 🎉 What's New in v4
|
|
26
|
+
|
|
27
|
+
> [!TIP]
|
|
28
|
+
> ✨ **v4 is in beta!** It rewrites the layout engine — the sheet now lays out synchronously per detent, so the container is sized to the sheet's visible height and tracks it in realtime while dragging.
|
|
29
|
+
|
|
30
|
+
* 📏 **Synchronous layout** - Content resizes in the same frame as the sheet, no more lag
|
|
31
|
+
* 📐 **`auto` detent with scrollables** - The sheet now sizes itself to a `ScrollView`/`FlatList`'s content height
|
|
32
|
+
* 📜 **`scrollableRef`** - Pass a `ScrollView`/`FlatList` ref to wire nested scrolling, keyboard insets, and `auto` detent sizing
|
|
33
|
+
* 🦶 **Relative footers** - Footers take up space below the content by default
|
|
34
|
+
* 🧭 **Expo Router** - First-class support via the `Sheet` layout from `/navigation/expo-router`
|
|
35
|
+
|
|
36
|
+
```sh
|
|
37
|
+
npx expo install @lodev09/react-native-true-sheet@beta
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
See the [Migration Guide](docs/docs/migration.mdx) to upgrade. **v3 is still supported** for React Native 0.81+ and remains the `latest` release on npm.
|
|
41
|
+
|
|
25
42
|
## Installation
|
|
26
43
|
|
|
27
44
|
> [!IMPORTANT]
|
|
@@ -30,7 +47,7 @@ The true native bottom sheet experience for your React Native Apps. 💩
|
|
|
30
47
|
|
|
31
48
|
### Prerequisites
|
|
32
49
|
|
|
33
|
-
- React Native 0.82+
|
|
50
|
+
- React Native 0.82+ (0.81+ on v3)
|
|
34
51
|
- New Architecture enabled
|
|
35
52
|
- Xcode 26.1+
|
|
36
53
|
|
|
@@ -55,6 +72,8 @@ yarn add @lodev09/react-native-true-sheet
|
|
|
55
72
|
cd ios && pod install
|
|
56
73
|
```
|
|
57
74
|
|
|
75
|
+
These install v3. For v4, append `@beta` — see [🎉 What's New in v4](#-whats-new-in-v4).
|
|
76
|
+
|
|
58
77
|
## Documentation
|
|
59
78
|
|
|
60
79
|
- [Example](example)
|
|
@@ -63,7 +82,7 @@ cd ios && pod install
|
|
|
63
82
|
- [React Navigation](https://sheet.lodev09.com/guides/navigation)
|
|
64
83
|
- [Troubleshooting](https://sheet.lodev09.com/troubleshooting)
|
|
65
84
|
- [Testing with Jest](https://sheet.lodev09.com/guides/jest)
|
|
66
|
-
- [Migrating to v3](https://sheet.lodev09.com/migration)
|
|
85
|
+
- [Migrating to v3](https://sheet.lodev09.com/migration-v3)
|
|
67
86
|
|
|
68
87
|
## Usage
|
|
69
88
|
|
|
@@ -61,8 +61,6 @@ class TrueSheetContainerView(reactContext: ThemedReactContext) :
|
|
|
61
61
|
return bottom
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
-
var insetAdjustment: TrueSheetInsetAdjustment = TrueSheetInsetAdjustment.AUTOMATIC
|
|
65
|
-
var scrollViewBottomInset: Int = 0
|
|
66
64
|
var absoluteFooter: Boolean = false
|
|
67
65
|
|
|
68
66
|
/**
|
|
@@ -80,10 +78,21 @@ class TrueSheetContainerView(reactContext: ThemedReactContext) :
|
|
|
80
78
|
contentView?.scrollableOptions = value
|
|
81
79
|
}
|
|
82
80
|
|
|
83
|
-
|
|
81
|
+
// React tag of the user-provided scrollable within the content (see the scrollableRef prop)
|
|
82
|
+
var scrollableHandle: Int = -1
|
|
84
83
|
set(value) {
|
|
85
84
|
field = value
|
|
86
|
-
contentView?.
|
|
85
|
+
contentView?.scrollableHandle = value
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Bottom safe-area inset the plugged scrollable applies as content padding
|
|
90
|
+
* while it can scroll (see ScrollableOptions.contentInsetAdjustment)
|
|
91
|
+
*/
|
|
92
|
+
var scrollableBottomInset: Int = 0
|
|
93
|
+
set(value) {
|
|
94
|
+
field = value
|
|
95
|
+
contentView?.bottomInset = value
|
|
87
96
|
}
|
|
88
97
|
|
|
89
98
|
override val eventDispatcher: EventDispatcher?
|
|
@@ -96,8 +105,7 @@ class TrueSheetContainerView(reactContext: ThemedReactContext) :
|
|
|
96
105
|
}
|
|
97
106
|
|
|
98
107
|
fun setupScrollable() {
|
|
99
|
-
|
|
100
|
-
contentView?.setupScrollable(bottomInset)
|
|
108
|
+
contentView?.setupScrollable()
|
|
101
109
|
}
|
|
102
110
|
|
|
103
111
|
fun setupKeyboardHandler() {
|
|
@@ -115,9 +123,13 @@ class TrueSheetContainerView(reactContext: ThemedReactContext) :
|
|
|
115
123
|
is TrueSheetContentView -> {
|
|
116
124
|
child.delegate = this
|
|
117
125
|
child.scrollableOptions = scrollableOptions
|
|
118
|
-
child.
|
|
126
|
+
child.scrollableHandle = scrollableHandle
|
|
127
|
+
child.bottomInset = scrollableBottomInset
|
|
119
128
|
contentView = child
|
|
120
129
|
|
|
130
|
+
// State lands before the view is attached, so pull the current value
|
|
131
|
+
contentHeight = child.naturalHeight
|
|
132
|
+
|
|
121
133
|
// Children mount bottom-up, so the content subtree is complete here.
|
|
122
134
|
// Late-mounted peek views attach themselves instead (see TrueSheetPeekView).
|
|
123
135
|
findPeekView(child)?.let { attachPeekView(it) }
|
|
@@ -205,6 +217,9 @@ class TrueSheetContainerView(reactContext: ThemedReactContext) :
|
|
|
205
217
|
override val footerKeyboardOcclusion: Int
|
|
206
218
|
get() = if (absoluteFooter) footerView?.keyboardOcclusionHeight ?: 0 else -(footerView?.height ?: 0)
|
|
207
219
|
|
|
220
|
+
override val hasRelativeFooter: Boolean
|
|
221
|
+
get() = footerView != null && !absoluteFooter
|
|
222
|
+
|
|
208
223
|
override fun contentViewDidScroll() {
|
|
209
224
|
delegate?.containerViewContentDidScroll()
|
|
210
225
|
}
|
|
@@ -221,6 +236,8 @@ class TrueSheetContainerView(reactContext: ThemedReactContext) :
|
|
|
221
236
|
override fun footerViewDidChangeSize(width: Int, height: Int) {
|
|
222
237
|
footerHeight = height
|
|
223
238
|
delegate?.containerViewFooterDidChangeSize(width, height)
|
|
239
|
+
// A relative footer takes over the bottom inset — drop the scrollable's
|
|
240
|
+
contentView?.updateContentInset()
|
|
224
241
|
}
|
|
225
242
|
|
|
226
243
|
override fun peekViewDidChangeSize(width: Int, height: Int) {
|
|
@@ -1,26 +1,27 @@
|
|
|
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
|
|
8
9
|
import android.widget.EditText
|
|
9
|
-
import android.widget.ScrollView
|
|
10
|
-
import androidx.core.widget.NestedScrollView
|
|
11
10
|
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
|
12
|
-
import com.facebook.react.bridge.WritableNativeMap
|
|
13
11
|
import com.facebook.react.uimanager.PixelUtil.dpToPx
|
|
14
|
-
import com.facebook.react.uimanager.StateWrapper
|
|
15
12
|
import com.facebook.react.uimanager.ThemedReactContext
|
|
16
13
|
import com.facebook.react.views.view.ReactViewGroup
|
|
17
14
|
import com.lodev09.truesheet.core.TrueSheetKeyboardObserver
|
|
18
15
|
import com.lodev09.truesheet.core.TrueSheetKeyboardObserverDelegate
|
|
19
16
|
import com.lodev09.truesheet.utils.isDescendantOf
|
|
20
|
-
import com.lodev09.truesheet.utils.smoothScrollBy
|
|
21
17
|
import com.lodev09.truesheet.utils.smoothScrollTo
|
|
22
18
|
|
|
23
|
-
data class ScrollableOptions(
|
|
19
|
+
data class ScrollableOptions(
|
|
20
|
+
val keyboardScrollOffset: Float = 0f,
|
|
21
|
+
val keyboardOffset: Float = 0f,
|
|
22
|
+
val scrollingExpandsSheet: Boolean = true,
|
|
23
|
+
val contentInsetAdjustment: Boolean = true
|
|
24
|
+
)
|
|
24
25
|
|
|
25
26
|
/**
|
|
26
27
|
* Delegate interface for content view size changes
|
|
@@ -32,10 +33,19 @@ interface TrueSheetContentViewDelegate {
|
|
|
32
33
|
|
|
33
34
|
/**
|
|
34
35
|
* Keyboard occlusion adjustment below the content — positive for an absolute
|
|
35
|
-
* footer
|
|
36
|
-
* negative for a relative footer sitting behind the keyboard shielding
|
|
36
|
+
* footer floating over the viewport's bottom edge (extends the caret reveal),
|
|
37
|
+
* negative for a relative footer sitting behind the keyboard shielding that
|
|
38
|
+
* much of its overlap (reduces the keyboard padding).
|
|
37
39
|
*/
|
|
38
40
|
val footerKeyboardOcclusion: Int
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* A relative footer sits below the content and absorbs the bottom safe-area
|
|
44
|
+
* inset — the scrollable doesn't reach the sheet's bottom edge, so no
|
|
45
|
+
* content inset is applied (mirrors iOS, where UIKit resolves this
|
|
46
|
+
* geometrically via the scroll view's safe area).
|
|
47
|
+
*/
|
|
48
|
+
val hasRelativeFooter: Boolean
|
|
39
49
|
}
|
|
40
50
|
|
|
41
51
|
/**
|
|
@@ -45,44 +55,43 @@ interface TrueSheetContentViewDelegate {
|
|
|
45
55
|
@SuppressLint("ViewConstructor")
|
|
46
56
|
class TrueSheetContentView(private val reactContext: ThemedReactContext) : ReactViewGroup(reactContext) {
|
|
47
57
|
var delegate: TrueSheetContentViewDelegate? = null
|
|
48
|
-
var stateWrapper: StateWrapper? = null
|
|
49
58
|
|
|
50
|
-
private var
|
|
51
|
-
private var lastHeight = 0
|
|
52
|
-
|
|
53
|
-
private var pinnedScrollView: ViewGroup? = null
|
|
54
|
-
private var observedScrollChild: View? = null
|
|
59
|
+
private var detectedScrollView: ViewGroup? = null
|
|
55
60
|
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
|
-
}
|
|
68
61
|
|
|
69
62
|
/**
|
|
70
|
-
* Content height
|
|
71
|
-
*
|
|
72
|
-
* Falls back to the view height
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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 padding — 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,20 +101,40 @@ 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
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
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
|
|
114
|
+
var bottomInset: Int = 0
|
|
103
115
|
set(value) {
|
|
104
116
|
if (field == value) return
|
|
105
117
|
field = value
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
118
|
+
updateContentInset()
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
private var baseBottomInset = 0
|
|
122
|
+
private var keyboardBottomInset = 0
|
|
123
|
+
|
|
124
|
+
private val scrollableLayoutListener =
|
|
125
|
+
View.OnLayoutChangeListener { _, _, top, _, bottom, _, oldTop, _, oldBottom ->
|
|
126
|
+
if (bottom - top != oldBottom - oldTop) updateContentInset()
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* React tag of the user-provided scrollable (see the `scrollableRef` prop).
|
|
131
|
+
* Setting a new handle clears the currently resolved ScrollView.
|
|
132
|
+
*/
|
|
133
|
+
var scrollableHandle: Int = -1
|
|
134
|
+
set(value) {
|
|
135
|
+
if (field == value) return
|
|
136
|
+
field = value
|
|
137
|
+
clearScrollable()
|
|
109
138
|
}
|
|
110
139
|
|
|
111
140
|
override fun addView(child: View?, index: Int) {
|
|
@@ -119,98 +148,41 @@ class TrueSheetContentView(private val reactContext: ThemedReactContext) : React
|
|
|
119
148
|
}
|
|
120
149
|
|
|
121
150
|
private fun checkScrollViewChanged() {
|
|
122
|
-
if (
|
|
151
|
+
if (detectedScrollView == null || detectedScrollView?.isDescendantOf(this) == false) {
|
|
123
152
|
delegate?.contentViewScrollViewDidChange()
|
|
124
153
|
}
|
|
125
154
|
}
|
|
126
155
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
}
|
|
131
|
-
|
|
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
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
reportSizeNow()
|
|
149
|
-
}
|
|
150
|
-
|
|
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
|
-
}
|
|
158
|
-
}
|
|
159
|
-
|
|
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)
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
|
|
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) {
|
|
156
|
+
fun setupScrollable() {
|
|
157
|
+
// Check if the detected scroll view is still valid (still in view hierarchy)
|
|
158
|
+
if (detectedScrollView != null && detectedScrollView?.isDescendantOf(this) == false) {
|
|
179
159
|
clearScrollable()
|
|
180
160
|
}
|
|
181
161
|
|
|
182
|
-
|
|
183
|
-
if (pinnedScrollView != null && this.bottomInset == bottomInset) {
|
|
162
|
+
if (detectedScrollView != null) {
|
|
184
163
|
return
|
|
185
164
|
}
|
|
186
165
|
|
|
187
|
-
val scrollView = findScrollView(
|
|
166
|
+
val scrollView = findScrollView() ?: return
|
|
188
167
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
originalScrollViewPaddingBottom = scrollView.paddingBottom
|
|
192
|
-
pinnedScrollView = scrollView
|
|
168
|
+
originalScrollViewPaddingBottom = scrollView.paddingBottom
|
|
169
|
+
detectedScrollView = scrollView
|
|
193
170
|
|
|
194
|
-
|
|
195
|
-
|
|
171
|
+
scrollView.isNestedScrollingEnabled = true
|
|
172
|
+
(scrollView.parent as? SwipeRefreshLayout)?.isNestedScrollingEnabled = false
|
|
196
173
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
}
|
|
174
|
+
scrollView.setOnScrollChangeListener { _, _, scrollY, _, oldScrollY ->
|
|
175
|
+
if (scrollY != oldScrollY) {
|
|
176
|
+
delegate?.contentViewDidScroll()
|
|
201
177
|
}
|
|
202
|
-
|
|
203
|
-
scrollView.getChildAt(0)?.let { child ->
|
|
204
|
-
observedScrollChild = child
|
|
205
|
-
child.addOnLayoutChangeListener(scrollChildLayoutListener)
|
|
206
|
-
}
|
|
207
|
-
setScrollableBounded(hasAutoDetent)
|
|
208
|
-
reportSizeIfChanged()
|
|
209
178
|
}
|
|
210
179
|
|
|
211
|
-
|
|
180
|
+
// Track viewport and content size changes — the content inset only applies
|
|
181
|
+
// while the content can scroll (mirrors iOS's automatic behavior)
|
|
182
|
+
scrollView.addOnLayoutChangeListener(scrollableLayoutListener)
|
|
183
|
+
scrollView.getChildAt(0)?.addOnLayoutChangeListener(scrollableLayoutListener)
|
|
212
184
|
|
|
213
|
-
|
|
185
|
+
updateContentInset()
|
|
214
186
|
|
|
215
187
|
// If keyboard is currently showing, re-apply the keyboard inset to the new ScrollView
|
|
216
188
|
val keyboardHeight = keyboardObserver?.currentHeight ?: 0
|
|
@@ -219,8 +191,38 @@ class TrueSheetContentView(private val reactContext: ThemedReactContext) : React
|
|
|
219
191
|
}
|
|
220
192
|
}
|
|
221
193
|
|
|
194
|
+
/**
|
|
195
|
+
* Re-applies the base content inset — the bottom safe-area inset while the
|
|
196
|
+
* content can scroll, no relative footer absorbs it, and
|
|
197
|
+
* `contentInsetAdjustmentBehavior` isn't disabled.
|
|
198
|
+
*/
|
|
199
|
+
fun updateContentInset() {
|
|
200
|
+
val scrollView = detectedScrollView ?: return
|
|
201
|
+
|
|
202
|
+
val enabled = scrollableOptions?.contentInsetAdjustment ?: true
|
|
203
|
+
baseBottomInset = if (enabled && delegate?.hasRelativeFooter != true && canScrollContent(scrollView)) {
|
|
204
|
+
bottomInset
|
|
205
|
+
} else {
|
|
206
|
+
0
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
applyBottomPadding()
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Measured against the original padding so the applied inset doesn't feed
|
|
213
|
+
// back into the check
|
|
214
|
+
private fun canScrollContent(scrollView: ViewGroup): Boolean {
|
|
215
|
+
val child = scrollView.getChildAt(0) ?: return false
|
|
216
|
+
return child.height > scrollView.height - scrollView.paddingTop - originalScrollViewPaddingBottom
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// The keyboard covers the safe-area region, so the larger inset wins over a sum
|
|
220
|
+
private fun applyBottomPadding() {
|
|
221
|
+
setScrollViewPaddingBottom(originalScrollViewPaddingBottom + maxOf(baseBottomInset, keyboardBottomInset))
|
|
222
|
+
}
|
|
223
|
+
|
|
222
224
|
private fun setScrollViewPaddingBottom(paddingBottom: Int) {
|
|
223
|
-
val scrollView =
|
|
225
|
+
val scrollView = detectedScrollView ?: return
|
|
224
226
|
scrollView.clipToPadding = false
|
|
225
227
|
scrollView.setPadding(
|
|
226
228
|
scrollView.paddingLeft,
|
|
@@ -231,39 +233,27 @@ class TrueSheetContentView(private val reactContext: ThemedReactContext) : React
|
|
|
231
233
|
}
|
|
232
234
|
|
|
233
235
|
fun clearScrollable() {
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
(
|
|
236
|
+
detectedScrollView?.setOnScrollChangeListener(null as View.OnScrollChangeListener?)
|
|
237
|
+
detectedScrollView?.removeOnLayoutChangeListener(scrollableLayoutListener)
|
|
238
|
+
detectedScrollView?.getChildAt(0)?.removeOnLayoutChangeListener(scrollableLayoutListener)
|
|
239
|
+
detectedScrollView?.isNestedScrollingEnabled = false
|
|
240
|
+
(detectedScrollView?.parent as? SwipeRefreshLayout)?.isNestedScrollingEnabled = true
|
|
237
241
|
setScrollViewPaddingBottom(originalScrollViewPaddingBottom)
|
|
238
|
-
|
|
239
|
-
observedScrollChild = null
|
|
240
|
-
setScrollableBounded(false)
|
|
241
|
-
pinnedScrollView = null
|
|
242
|
+
detectedScrollView = null
|
|
242
243
|
originalScrollViewPaddingBottom = 0
|
|
243
|
-
|
|
244
|
-
|
|
244
|
+
baseBottomInset = 0
|
|
245
|
+
keyboardBottomInset = 0
|
|
246
|
+
appliedKeyboardOffset = 0
|
|
245
247
|
}
|
|
246
248
|
|
|
249
|
+
/**
|
|
250
|
+
* Resolves the user-provided `scrollableHandle` within the content subtree —
|
|
251
|
+
* React tags are view ids on Android.
|
|
252
|
+
*/
|
|
247
253
|
fun findScrollView(): ViewGroup? {
|
|
248
|
-
if (
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
private fun findScrollView(view: View): ViewGroup? {
|
|
253
|
-
if (view is ScrollView || view is NestedScrollView) {
|
|
254
|
-
return view as ViewGroup
|
|
255
|
-
}
|
|
256
|
-
|
|
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
|
-
}
|
|
265
|
-
|
|
266
|
-
return null
|
|
254
|
+
if (detectedScrollView != null) return detectedScrollView
|
|
255
|
+
if (scrollableHandle <= 0) return null
|
|
256
|
+
return findViewById<View>(scrollableHandle) as? ViewGroup
|
|
267
257
|
}
|
|
268
258
|
|
|
269
259
|
// ==================== Keyboard Handling ====================
|
|
@@ -273,8 +263,19 @@ class TrueSheetContentView(private val reactContext: ThemedReactContext) : React
|
|
|
273
263
|
|
|
274
264
|
keyboardObserver = TrueSheetKeyboardObserver(this, reactContext).apply {
|
|
275
265
|
delegate = object : TrueSheetKeyboardObserverDelegate {
|
|
276
|
-
|
|
266
|
+
// Drive the inset per keyboard animation frame so the content tracks
|
|
267
|
+
// the keyboard edge instead of jumping to the final inset up front.
|
|
268
|
+
// On hide, the shrinking inset gradually clamps the scroll position
|
|
269
|
+
// back in sync with the keyboard.
|
|
270
|
+
override fun keyboardDidChangeHeight(height: Int) {
|
|
277
271
|
updateScrollViewInsetForKeyboard(height)
|
|
272
|
+
|
|
273
|
+
// Follow the caret frame-by-frame — only API 30+ delivers
|
|
274
|
+
// incremental heights; legacy fires once after the keyboard is
|
|
275
|
+
// already up, where keyboardDidShow's smooth reveal takes over
|
|
276
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && !isHiding) {
|
|
277
|
+
scrollToFocusedInput(smooth = false)
|
|
278
|
+
}
|
|
278
279
|
}
|
|
279
280
|
|
|
280
281
|
override fun keyboardDidShow(height: Int) {
|
|
@@ -284,7 +285,6 @@ class TrueSheetContentView(private val reactContext: ThemedReactContext) : React
|
|
|
284
285
|
|
|
285
286
|
override fun keyboardWillHide() {
|
|
286
287
|
detachCaretListener()
|
|
287
|
-
updateScrollViewInsetForKeyboard(0)
|
|
288
288
|
}
|
|
289
289
|
|
|
290
290
|
override fun focusDidChange(newFocus: View) {
|
|
@@ -329,30 +329,39 @@ class TrueSheetContentView(private val reactContext: ThemedReactContext) : React
|
|
|
329
329
|
}
|
|
330
330
|
|
|
331
331
|
private fun updateScrollViewInsetForKeyboard(keyboardHeight: Int) {
|
|
332
|
-
val scrollView =
|
|
333
|
-
|
|
334
|
-
//
|
|
335
|
-
//
|
|
336
|
-
//
|
|
337
|
-
//
|
|
338
|
-
|
|
339
|
-
maxOf(0, keyboardHeight + (delegate?.footerKeyboardOcclusion ?: 0))
|
|
332
|
+
val scrollView = detectedScrollView ?: return
|
|
333
|
+
|
|
334
|
+
// A relative footer stays behind the keyboard below the content, so its
|
|
335
|
+
// height shields that much of the keyboard's overlap. An absolute footer
|
|
336
|
+
// floats within the viewport — its clearance is the content padding's job
|
|
337
|
+
// (the caret reveal accounts for it, see scrollToFocusedInput).
|
|
338
|
+
keyboardBottomInset = if (keyboardHeight > 0) {
|
|
339
|
+
val baseInset = maxOf(0, keyboardHeight + minOf(0, delegate?.footerKeyboardOcclusion ?: 0))
|
|
340
|
+
val adjustedInset = maxOf(0, baseInset + keyboardOffset.toInt())
|
|
341
|
+
appliedKeyboardOffset = adjustedInset - baseInset
|
|
342
|
+
adjustedInset
|
|
340
343
|
} else {
|
|
341
|
-
|
|
344
|
+
appliedKeyboardOffset = 0
|
|
345
|
+
0
|
|
342
346
|
}
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
scrollView.post { nudgeScrollView() }
|
|
347
|
+
applyBottomPadding()
|
|
348
|
+
clampScrollPosition()
|
|
346
349
|
}
|
|
347
350
|
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
351
|
+
// Padding changes don't re-clamp the scroll position until the next layout
|
|
352
|
+
// pass — clamp synchronously so the content follows the shrinking inset on
|
|
353
|
+
// every keyboard frame instead of snapping afterwards
|
|
354
|
+
private fun clampScrollPosition() {
|
|
355
|
+
val scrollView = detectedScrollView ?: return
|
|
356
|
+
val child = scrollView.getChildAt(0) ?: return
|
|
357
|
+
val maxScrollY = maxOf(0, child.height - (scrollView.height - scrollView.paddingTop - scrollView.paddingBottom))
|
|
358
|
+
if (scrollView.scrollY > maxScrollY) {
|
|
359
|
+
scrollView.scrollTo(scrollView.scrollX, maxScrollY)
|
|
360
|
+
}
|
|
352
361
|
}
|
|
353
362
|
|
|
354
|
-
private fun scrollToFocusedInput() {
|
|
355
|
-
val scrollView =
|
|
363
|
+
private fun scrollToFocusedInput(smooth: Boolean = true) {
|
|
364
|
+
val scrollView = detectedScrollView ?: findScrollView() ?: return
|
|
356
365
|
val focusedView = findFocus() ?: return
|
|
357
366
|
|
|
358
367
|
val focusedLocation = IntArray(2)
|
|
@@ -379,17 +388,29 @@ class TrueSheetContentView(private val reactContext: ThemedReactContext) : React
|
|
|
379
388
|
caretBottom = viewTop + focusedView.height
|
|
380
389
|
}
|
|
381
390
|
|
|
391
|
+
// An absolute footer floats over the viewport's bottom edge — extend the
|
|
392
|
+
// caret target so it clears the footer, not just the keyboard.
|
|
393
|
+
val footerOcclusion = maxOf(0, delegate?.footerKeyboardOcclusion ?: 0)
|
|
394
|
+
|
|
382
395
|
val offset = keyboardScrollOffset.toInt()
|
|
383
|
-
val visibleHeight = scrollView.height - scrollView.paddingBottom
|
|
396
|
+
val visibleHeight = scrollView.height - scrollView.paddingBottom + appliedKeyboardOffset
|
|
384
397
|
val visibleTop = scrollView.scrollY
|
|
385
398
|
val visibleBottom = scrollView.scrollY + visibleHeight
|
|
386
399
|
|
|
387
|
-
when {
|
|
388
|
-
caretBottom + offset > visibleBottom ->
|
|
389
|
-
|
|
400
|
+
val targetY = when {
|
|
401
|
+
caretBottom + offset + footerOcclusion > visibleBottom ->
|
|
402
|
+
caretBottom + offset + footerOcclusion - visibleHeight
|
|
390
403
|
|
|
391
404
|
caretTop - offset < visibleTop ->
|
|
392
|
-
|
|
405
|
+
(caretTop - offset).coerceAtLeast(0)
|
|
406
|
+
|
|
407
|
+
else -> return
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
if (smooth) {
|
|
411
|
+
scrollView.smoothScrollTo(0, targetY)
|
|
412
|
+
} else {
|
|
413
|
+
scrollView.scrollTo(0, targetY)
|
|
393
414
|
}
|
|
394
415
|
}
|
|
395
416
|
|
|
@@ -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
|
-
|
|
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
|
|