@onekeyfe/react-native-pager-view 3.0.114 → 3.0.116
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 +40 -0
- package/android/src/main/java/com/reactnativepagerview/CollapsiblePagerHost.kt +630 -0
- package/android/src/main/java/com/reactnativepagerview/CollapsiblePagerViewManager.kt +222 -0
- package/android/src/main/java/com/reactnativepagerview/NestedScrollableHost.kt +3 -1
- package/android/src/main/java/com/reactnativepagerview/PagerViewViewPackage.kt +1 -1
- package/android/src/main/java/com/reactnativepagerview/event/CollapsibleStateChangedEvent.kt +39 -0
- package/ios/RNCCollapsiblePagerViewComponentView.h +9 -0
- package/ios/RNCCollapsiblePagerViewComponentView.mm +991 -0
- package/ios/RNCPagerViewComponentView.mm +4 -2
- package/lib/module/CollapsiblePagerView.js +167 -0
- package/lib/module/CollapsiblePagerView.web.js +478 -0
- package/lib/module/CollapsiblePagerViewNativeComponent.ts +87 -0
- package/lib/module/PagerView.web.js +177 -0
- package/lib/module/index.js +2 -1
- package/lib/module/index.web.js +6 -0
- package/lib/module/usePagerView.js +1 -1
- package/lib/typescript/src/CollapsiblePagerView.d.ts +58 -0
- package/lib/typescript/src/CollapsiblePagerView.web.d.ts +105 -0
- package/lib/typescript/src/CollapsiblePagerViewNativeComponent.d.ts +51 -0
- package/lib/typescript/src/PagerView.web.d.ts +55 -0
- package/lib/typescript/src/index.d.ts +1 -0
- package/lib/typescript/src/index.web.d.ts +5 -0
- package/package.json +5 -2
- package/src/CollapsiblePagerView.tsx +293 -0
- package/src/CollapsiblePagerView.web.tsx +710 -0
- package/src/CollapsiblePagerViewNativeComponent.ts +87 -0
- package/src/PagerView.web.tsx +280 -0
- package/src/index.tsx +1 -0
- package/src/index.web.tsx +12 -0
package/README.md
CHANGED
|
@@ -27,5 +27,45 @@ development and delivery stable.
|
|
|
27
27
|
- If you are looking for original behavior and full documentation, please refer
|
|
28
28
|
to the upstream repository.
|
|
29
29
|
|
|
30
|
+
## Collapsible pager
|
|
31
|
+
|
|
32
|
+
`CollapsiblePagerView` is an opt-in container. The default `PagerView` export
|
|
33
|
+
keeps its existing page lifetime and native implementation.
|
|
34
|
+
|
|
35
|
+
```tsx
|
|
36
|
+
<CollapsiblePagerView
|
|
37
|
+
header={<Header />}
|
|
38
|
+
stickyHeader={<Tabs />}
|
|
39
|
+
headerHeight={measuredHeaderHeight}
|
|
40
|
+
stickyHeaderHeight={measuredTabsHeight}
|
|
41
|
+
pageRetentionDistance={1}
|
|
42
|
+
onMountedPagesChanged={({ mountedPages }) => {
|
|
43
|
+
// Selected page plus one neighbour on each side by default.
|
|
44
|
+
}}
|
|
45
|
+
onCollapsibleStateChanged={({ nativeEvent }) => {
|
|
46
|
+
// Settled diagnostics: headerOffset, native/attached page counts, etc.
|
|
47
|
+
}}
|
|
48
|
+
>
|
|
49
|
+
{pages.map((page) => (
|
|
50
|
+
<View key={page.key}>{page.content}</View>
|
|
51
|
+
))}
|
|
52
|
+
</CollapsiblePagerView>
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Page keys must remain stable. The selected page and configured neighbours stay
|
|
56
|
+
mounted for preheating; distant page slots remain in the pager while their
|
|
57
|
+
React/native heavy subtrees are unmounted. iOS and Android retain a per-key
|
|
58
|
+
scroll offset and preserve the shared header collapse when pages change. A
|
|
59
|
+
NativeList can additionally restore its own stable item-key anchor when it is
|
|
60
|
+
remounted; the pager does not replace that list-level anchor protocol or its
|
|
61
|
+
cell reuse.
|
|
62
|
+
|
|
63
|
+
On iOS the component observes the current `UIScrollView` content offset without
|
|
64
|
+
replacing its delegate. Android coordinates nested scroll with the active
|
|
65
|
+
`RecyclerView`. Web uses the active NativeList viewport automatically; another
|
|
66
|
+
Web scroll child can opt in by exposing a DOM element with
|
|
67
|
+
`data-collapsible-pager-scroll`. CSS scroll timelines drive header/sticky
|
|
68
|
+
movement, so React is not updated on each vertical scroll frame.
|
|
69
|
+
|
|
30
70
|
Thank you again to Callstack and everyone who contributes to
|
|
31
71
|
`react-native-pager-view` 💙
|
|
@@ -0,0 +1,630 @@
|
|
|
1
|
+
package com.reactnativepagerview
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.graphics.Rect
|
|
5
|
+
import android.view.View
|
|
6
|
+
import android.view.ViewGroup
|
|
7
|
+
import android.view.ViewTreeObserver
|
|
8
|
+
// OneKey patch: FrameLayout is inherited through NestedScrollableHost.
|
|
9
|
+
// import android.widget.FrameLayout
|
|
10
|
+
import androidx.core.view.NestedScrollingParent3
|
|
11
|
+
import androidx.core.view.NestedScrollingParentHelper
|
|
12
|
+
import androidx.core.view.ViewCompat
|
|
13
|
+
import androidx.recyclerview.widget.LinearLayoutManager
|
|
14
|
+
import androidx.recyclerview.widget.RecyclerView
|
|
15
|
+
import androidx.viewpager2.widget.ViewPager2
|
|
16
|
+
import java.util.WeakHashMap
|
|
17
|
+
import kotlin.math.max
|
|
18
|
+
import kotlin.math.min
|
|
19
|
+
import kotlin.math.roundToInt
|
|
20
|
+
|
|
21
|
+
internal class CollapsiblePagerAdapter : RecyclerView.Adapter<ViewPagerViewHolder>() {
|
|
22
|
+
private val pages = ArrayList<View>()
|
|
23
|
+
|
|
24
|
+
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
|
|
25
|
+
ViewPagerViewHolder.create(parent)
|
|
26
|
+
|
|
27
|
+
override fun onBindViewHolder(holder: ViewPagerViewHolder, position: Int) {
|
|
28
|
+
val page = pages[position]
|
|
29
|
+
val container = holder.container
|
|
30
|
+
container.removeAllViews()
|
|
31
|
+
(page.parent as? ViewGroup)?.removeView(page)
|
|
32
|
+
container.addView(page)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
override fun onViewRecycled(holder: ViewPagerViewHolder) {
|
|
36
|
+
holder.container.removeAllViews()
|
|
37
|
+
super.onViewRecycled(holder)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
override fun getItemCount() = pages.size
|
|
41
|
+
|
|
42
|
+
fun addPage(page: View, index: Int) {
|
|
43
|
+
val safeIndex = index.coerceIn(0, pages.size)
|
|
44
|
+
pages.add(safeIndex, page)
|
|
45
|
+
notifyItemInserted(safeIndex)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
fun removePage(page: View) {
|
|
49
|
+
val index = pages.indexOf(page)
|
|
50
|
+
if (index >= 0) {
|
|
51
|
+
pages.removeAt(index)
|
|
52
|
+
notifyItemRemoved(index)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
fun pageAt(index: Int) = pages[index]
|
|
57
|
+
|
|
58
|
+
fun clear() {
|
|
59
|
+
val count = pages.size
|
|
60
|
+
pages.forEach { page -> (page.parent as? ViewGroup)?.removeView(page) }
|
|
61
|
+
pages.clear()
|
|
62
|
+
if (count > 0) notifyItemRangeRemoved(0, count)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Native vertical coordinator for the optional collapsible PagerView variant.
|
|
68
|
+
* It consumes only the header part of vertical nested scroll. The active page's
|
|
69
|
+
* vertical scroll view remains the content scroll source and horizontal gestures
|
|
70
|
+
* remain owned by ViewPager2.
|
|
71
|
+
*/
|
|
72
|
+
// OneKey patch: Match the standard pager's nested horizontal gesture host.
|
|
73
|
+
// Original: class CollapsiblePagerHost(context: Context) : FrameLayout(context), NestedScrollingParent3 {
|
|
74
|
+
class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), NestedScrollingParent3 {
|
|
75
|
+
private data class RecyclerPadding(
|
|
76
|
+
var left: Int,
|
|
77
|
+
var top: Int,
|
|
78
|
+
var right: Int,
|
|
79
|
+
var bottom: Int,
|
|
80
|
+
var clipToPadding: Boolean,
|
|
81
|
+
var appliedTopInset: Int = 0,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
val pager = ViewPager2(context)
|
|
85
|
+
internal val adapter = CollapsiblePagerAdapter()
|
|
86
|
+
private val logicalChildren = ArrayList<View>()
|
|
87
|
+
private val nestedScrollingParentHelper = NestedScrollingParentHelper(this)
|
|
88
|
+
private val originalRecyclerPadding = WeakHashMap<RecyclerView, RecyclerPadding>()
|
|
89
|
+
private val restoredRecyclerKeys = WeakHashMap<RecyclerView, String>()
|
|
90
|
+
private val pageOffsets = HashMap<String, Int>()
|
|
91
|
+
private var observedRecyclerView: RecyclerView? = null
|
|
92
|
+
private var observedScrollListener: RecyclerView.OnScrollListener? = null
|
|
93
|
+
private var attachmentGeneration = 0
|
|
94
|
+
private var headerView: View? = null
|
|
95
|
+
private var stickyHeaderView: View? = null
|
|
96
|
+
private val pageContentLayoutListener = ViewTreeObserver.OnPreDrawListener {
|
|
97
|
+
if (width > 0 && height > 0 && isShown) layoutPagerIfRequested()
|
|
98
|
+
// Fabric can mount a retained list without another Android layout pass.
|
|
99
|
+
// Apply its header insets before that list first draws.
|
|
100
|
+
prepareAdjacentPages()
|
|
101
|
+
attachRecyclerObserver()
|
|
102
|
+
// OneKey patch: a filtered short page can reduce the existing collapse range.
|
|
103
|
+
setHeaderOffset(headerOffsetPx)
|
|
104
|
+
true
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
var selectedPage = 0
|
|
108
|
+
private set
|
|
109
|
+
var pendingInitialPage = 0
|
|
110
|
+
private var hasAppliedInitialPage = false
|
|
111
|
+
var headerHeightPx = 0
|
|
112
|
+
set(value) {
|
|
113
|
+
if (field == value) return
|
|
114
|
+
field = max(0, value)
|
|
115
|
+
headerOffsetPx = min(headerOffsetPx, field)
|
|
116
|
+
reapplyRecyclerInsets()
|
|
117
|
+
updateHeaderLayout()
|
|
118
|
+
}
|
|
119
|
+
var stickyHeaderHeightPx = 0
|
|
120
|
+
set(value) {
|
|
121
|
+
if (field == value) return
|
|
122
|
+
field = max(0, value)
|
|
123
|
+
reapplyRecyclerInsets()
|
|
124
|
+
updateHeaderLayout()
|
|
125
|
+
}
|
|
126
|
+
var headerOffsetPx = 0
|
|
127
|
+
private set
|
|
128
|
+
var pageKeys: List<String> = emptyList()
|
|
129
|
+
var retainedPages: String = "[]"
|
|
130
|
+
var onHeaderOffsetChanged: (() -> Unit)? = null
|
|
131
|
+
|
|
132
|
+
init {
|
|
133
|
+
isSaveEnabled = false
|
|
134
|
+
clipChildren = true
|
|
135
|
+
pager.id = View.generateViewId()
|
|
136
|
+
pager.adapter = adapter
|
|
137
|
+
pager.isSaveEnabled = false
|
|
138
|
+
super.addView(
|
|
139
|
+
pager,
|
|
140
|
+
LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT),
|
|
141
|
+
)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
fun addReactChild(child: View, index: Int) {
|
|
145
|
+
val safeIndex = index.coerceIn(0, logicalChildren.size)
|
|
146
|
+
logicalChildren.add(safeIndex, child)
|
|
147
|
+
when (safeIndex) {
|
|
148
|
+
0 -> {
|
|
149
|
+
headerView = child
|
|
150
|
+
(child.parent as? ViewGroup)?.removeView(child)
|
|
151
|
+
super.addView(child)
|
|
152
|
+
}
|
|
153
|
+
1 -> {
|
|
154
|
+
stickyHeaderView = child
|
|
155
|
+
(child.parent as? ViewGroup)?.removeView(child)
|
|
156
|
+
super.addView(child)
|
|
157
|
+
}
|
|
158
|
+
else -> adapter.addPage(child, safeIndex - PAGE_SLOT_OFFSET)
|
|
159
|
+
}
|
|
160
|
+
applyPendingInitialPage()
|
|
161
|
+
headerView?.bringToFront()
|
|
162
|
+
stickyHeaderView?.bringToFront()
|
|
163
|
+
requestLayout()
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
fun removeReactChild(child: View) {
|
|
167
|
+
val index = logicalChildren.indexOf(child)
|
|
168
|
+
if (index < 0) return
|
|
169
|
+
logicalChildren.removeAt(index)
|
|
170
|
+
when (index) {
|
|
171
|
+
0 -> {
|
|
172
|
+
super.removeView(child)
|
|
173
|
+
headerView = null
|
|
174
|
+
}
|
|
175
|
+
1 -> {
|
|
176
|
+
super.removeView(child)
|
|
177
|
+
stickyHeaderView = null
|
|
178
|
+
}
|
|
179
|
+
else -> {
|
|
180
|
+
findVerticalRecyclerView(child)?.let(::restoreRecyclerInsets)
|
|
181
|
+
adapter.removePage(child)
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
fun removeAllReactChildren() {
|
|
187
|
+
detachRecyclerObserver()
|
|
188
|
+
for (recycler in originalRecyclerPadding.keys.toList()) {
|
|
189
|
+
restoreRecyclerInsets(recycler)
|
|
190
|
+
}
|
|
191
|
+
headerView?.let { view -> super.removeView(view) }
|
|
192
|
+
stickyHeaderView?.let { view -> super.removeView(view) }
|
|
193
|
+
headerView = null
|
|
194
|
+
stickyHeaderView = null
|
|
195
|
+
logicalChildren.clear()
|
|
196
|
+
adapter.clear()
|
|
197
|
+
hasAppliedInitialPage = false
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
fun reactChildCount() = logicalChildren.size
|
|
201
|
+
|
|
202
|
+
fun reactChildAt(index: Int) = logicalChildren[index]
|
|
203
|
+
|
|
204
|
+
fun selectPage(position: Int) {
|
|
205
|
+
if (adapter.itemCount == 0) return
|
|
206
|
+
detachRecyclerObserver()
|
|
207
|
+
selectedPage = position.coerceIn(0, adapter.itemCount - 1)
|
|
208
|
+
val savedOffset = pageOffsets[currentPageKey()]
|
|
209
|
+
?: recyclerViewForPage(selectedPage)?.computeVerticalScrollOffset()
|
|
210
|
+
?: 0
|
|
211
|
+
if (savedOffset > 0) setHeaderOffset(headerHeightPx)
|
|
212
|
+
postForCurrentAttachment {
|
|
213
|
+
prepareAdjacentPages()
|
|
214
|
+
attachRecyclerObserver()
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
private fun postForCurrentAttachment(block: () -> Unit) {
|
|
219
|
+
val generation = attachmentGeneration
|
|
220
|
+
post {
|
|
221
|
+
if (isAttachedToWindow && generation == attachmentGeneration) block()
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
fun applyPendingInitialPage() {
|
|
226
|
+
// OneKey patch: Fabric can resend initialPage while retained pages change.
|
|
227
|
+
// Reapplying it interrupts the current gesture and can leave ViewPager2 settling.
|
|
228
|
+
if (hasAppliedInitialPage) return
|
|
229
|
+
if (pendingInitialPage !in 0 until adapter.itemCount) return
|
|
230
|
+
hasAppliedInitialPage = true
|
|
231
|
+
if (pager.currentItem != pendingInitialPage) {
|
|
232
|
+
pager.setCurrentItem(pendingInitialPage, false)
|
|
233
|
+
}
|
|
234
|
+
selectPage(pendingInitialPage)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
fun attachedPageCount(): Int = (pager.getChildAt(0) as? RecyclerView)?.childCount ?: 0
|
|
238
|
+
|
|
239
|
+
fun observedScrollableCount(): Int = originalRecyclerPadding.size
|
|
240
|
+
|
|
241
|
+
private fun layoutPagerIfRequested() {
|
|
242
|
+
val recycler = pager.getChildAt(0) as? RecyclerView ?: return
|
|
243
|
+
if (!recycler.isLayoutRequested || recycler.isComputingLayout || recycler.width <= 0 || recycler.height <= 0) return
|
|
244
|
+
// OneKey patch: a non-animated page command queues RecyclerView layout, but RN
|
|
245
|
+
// can stop that request after navigation. Drain it before showing the page.
|
|
246
|
+
recycler.measure(
|
|
247
|
+
MeasureSpec.makeMeasureSpec(recycler.width, MeasureSpec.EXACTLY),
|
|
248
|
+
MeasureSpec.makeMeasureSpec(recycler.height, MeasureSpec.EXACTLY),
|
|
249
|
+
)
|
|
250
|
+
recycler.layout(recycler.left, recycler.top, recycler.right, recycler.bottom)
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
private fun updateHeaderLayout() {
|
|
254
|
+
requestLayout()
|
|
255
|
+
if (width <= 0 || height <= 0) return
|
|
256
|
+
// OneKey patch: React Native ancestors can stop requestLayout after header props change.
|
|
257
|
+
// Commit the custom native slots at their existing bounds before the next draw.
|
|
258
|
+
forceLayout()
|
|
259
|
+
measure(
|
|
260
|
+
MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
|
|
261
|
+
MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY),
|
|
262
|
+
)
|
|
263
|
+
layout(left, top, right, bottom)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
|
|
267
|
+
val width = MeasureSpec.getSize(widthMeasureSpec)
|
|
268
|
+
val height = MeasureSpec.getSize(heightMeasureSpec)
|
|
269
|
+
// OneKey patch: a detached Fabric page has a zero viewport. Measuring ViewPager2
|
|
270
|
+
// at width zero spuriously selects page zero and replaces the retained JS pages.
|
|
271
|
+
if (width <= 0 || height <= 0) {
|
|
272
|
+
setMeasuredDimension(width, height)
|
|
273
|
+
return
|
|
274
|
+
}
|
|
275
|
+
val pagerHeight = height + headerHeightPx
|
|
276
|
+
pager.measure(
|
|
277
|
+
MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
|
|
278
|
+
MeasureSpec.makeMeasureSpec(pagerHeight, MeasureSpec.EXACTLY),
|
|
279
|
+
)
|
|
280
|
+
headerView?.measure(
|
|
281
|
+
MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
|
|
282
|
+
MeasureSpec.makeMeasureSpec(headerHeightPx, MeasureSpec.EXACTLY),
|
|
283
|
+
)
|
|
284
|
+
stickyHeaderView?.measure(
|
|
285
|
+
MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
|
|
286
|
+
MeasureSpec.makeMeasureSpec(stickyHeaderHeightPx, MeasureSpec.EXACTLY),
|
|
287
|
+
)
|
|
288
|
+
setMeasuredDimension(width, height)
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
|
|
292
|
+
val width = right - left
|
|
293
|
+
val height = bottom - top
|
|
294
|
+
// RN ancestors allow overflow; keep translated header content inside this viewport.
|
|
295
|
+
clipBounds = Rect(0, 0, width, height)
|
|
296
|
+
if (width <= 0 || height <= 0) return
|
|
297
|
+
pager.layout(0, 0, width, height + headerHeightPx)
|
|
298
|
+
headerView?.layout(0, 0, width, headerHeightPx)
|
|
299
|
+
stickyHeaderView?.layout(
|
|
300
|
+
0,
|
|
301
|
+
headerHeightPx,
|
|
302
|
+
width,
|
|
303
|
+
headerHeightPx + stickyHeaderHeightPx,
|
|
304
|
+
)
|
|
305
|
+
applyHeaderOffset()
|
|
306
|
+
postForCurrentAttachment {
|
|
307
|
+
prepareAdjacentPages()
|
|
308
|
+
attachRecyclerObserver()
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
private fun maximumHeaderOffset(): Int {
|
|
313
|
+
val recycler = observedRecyclerView ?: return headerHeightPx
|
|
314
|
+
if (recycler.childCount == 0 || recycler.canScrollVertically(-1) || recycler.canScrollVertically(1)) {
|
|
315
|
+
return headerHeightPx
|
|
316
|
+
}
|
|
317
|
+
// OneKey patch: short pages use the original rounded-DIP minimum content height.
|
|
318
|
+
val density = resources.displayMetrics.density
|
|
319
|
+
val contentHeight = ((height / density).roundToInt() + (headerHeightPx / density).roundToInt()) * density
|
|
320
|
+
return (contentHeight.roundToInt() - height).coerceIn(0, headerHeightPx)
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
private fun setHeaderOffset(offset: Int) {
|
|
324
|
+
// val next = offset.coerceIn(0, headerHeightPx)
|
|
325
|
+
val next = offset.coerceIn(0, maximumHeaderOffset())
|
|
326
|
+
if (headerOffsetPx == next) return
|
|
327
|
+
headerOffsetPx = next
|
|
328
|
+
applyHeaderOffset()
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
private fun applyHeaderOffset() {
|
|
332
|
+
val offset = headerOffsetPx.toFloat()
|
|
333
|
+
pager.translationY = -offset
|
|
334
|
+
headerView?.translationY = -offset
|
|
335
|
+
stickyHeaderView?.translationY = -offset
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
private fun pageKey(index: Int): String = pageKeys.getOrNull(index) ?: "page-$index"
|
|
339
|
+
|
|
340
|
+
private fun currentPageKey(): String = pageKey(selectedPage)
|
|
341
|
+
|
|
342
|
+
private fun isDescendant(view: View, ancestor: View): Boolean {
|
|
343
|
+
var current: View? = view
|
|
344
|
+
while (current != null) {
|
|
345
|
+
if (current === ancestor) return true
|
|
346
|
+
current = current.parent as? View
|
|
347
|
+
}
|
|
348
|
+
return false
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
private fun findVerticalRecyclerView(view: View): RecyclerView? {
|
|
352
|
+
if (view is RecyclerView) {
|
|
353
|
+
val manager = view.layoutManager
|
|
354
|
+
if (manager !is LinearLayoutManager || manager.orientation == RecyclerView.VERTICAL) {
|
|
355
|
+
return view
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
if (view is ViewGroup) {
|
|
359
|
+
for (index in 0 until view.childCount) {
|
|
360
|
+
findVerticalRecyclerView(view.getChildAt(index))?.let { return it }
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
return null
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
private fun recyclerViewForPage(index: Int): RecyclerView? {
|
|
367
|
+
if (index !in 0 until adapter.itemCount) return null
|
|
368
|
+
return findVerticalRecyclerView(adapter.pageAt(index))
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
private fun prepareAdjacentPages() {
|
|
372
|
+
val first = max(0, selectedPage - 1)
|
|
373
|
+
val last = min(adapter.itemCount - 1, selectedPage + 1)
|
|
374
|
+
for (index in first..last) {
|
|
375
|
+
recyclerViewForPage(index)?.let { recycler ->
|
|
376
|
+
applyRecyclerInsets(recycler, index)
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
private fun attachRecyclerObserver() {
|
|
382
|
+
if (selectedPage !in 0 until adapter.itemCount) return
|
|
383
|
+
val previous = observedRecyclerView
|
|
384
|
+
if (previous != null && !isDescendant(previous, adapter.pageAt(selectedPage))) {
|
|
385
|
+
detachRecyclerObserver()
|
|
386
|
+
// A replacement list starts at its first row, retaining only header collapse.
|
|
387
|
+
pageOffsets[currentPageKey()] = 0
|
|
388
|
+
}
|
|
389
|
+
val recycler = recyclerViewForPage(selectedPage) ?: return
|
|
390
|
+
if (observedRecyclerView === recycler) {
|
|
391
|
+
applyRecyclerInsets(recycler, selectedPage)
|
|
392
|
+
return
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
detachRecyclerObserver()
|
|
396
|
+
observedRecyclerView = recycler
|
|
397
|
+
applyRecyclerInsets(recycler, selectedPage)
|
|
398
|
+
val listener = object : RecyclerView.OnScrollListener() {
|
|
399
|
+
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
|
|
400
|
+
val contentOffset = recyclerView.computeVerticalScrollOffset()
|
|
401
|
+
pageOffsets[currentPageKey()] = contentOffset
|
|
402
|
+
// Nested touch scrolling moves list content only after the header has
|
|
403
|
+
// collapsed. Programmatic list jumps bypass those parent callbacks.
|
|
404
|
+
if (contentOffset > 0 && headerOffsetPx < headerHeightPx) {
|
|
405
|
+
setHeaderOffset(headerHeightPx)
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
observedScrollListener = listener
|
|
410
|
+
recycler.addOnScrollListener(listener)
|
|
411
|
+
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
private fun detachRecyclerObserver() {
|
|
415
|
+
storeObservedPageOffset()
|
|
416
|
+
val recycler = observedRecyclerView
|
|
417
|
+
val listener = observedScrollListener
|
|
418
|
+
if (recycler != null && listener != null) recycler.removeOnScrollListener(listener)
|
|
419
|
+
observedRecyclerView = null
|
|
420
|
+
observedScrollListener = null
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
private fun storeObservedPageOffset() {
|
|
424
|
+
observedRecyclerView?.let { recycler ->
|
|
425
|
+
pageOffsets[currentPageKey()] = recycler.computeVerticalScrollOffset()
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
private fun applyRecyclerInsets(recycler: RecyclerView, pageIndex: Int) {
|
|
430
|
+
val original = originalRecyclerPadding.getOrPut(recycler) {
|
|
431
|
+
RecyclerPadding(
|
|
432
|
+
recycler.paddingLeft,
|
|
433
|
+
recycler.paddingTop,
|
|
434
|
+
recycler.paddingRight,
|
|
435
|
+
recycler.paddingBottom,
|
|
436
|
+
recycler.clipToPadding,
|
|
437
|
+
)
|
|
438
|
+
}
|
|
439
|
+
updateRecyclerPaddingOwnership(recycler, original)
|
|
440
|
+
val topInset = headerHeightPx + stickyHeaderHeightPx
|
|
441
|
+
recycler.clipToPadding = false
|
|
442
|
+
val top = original.top + topInset
|
|
443
|
+
// The taller pager already accounts for the translated header. Adding it
|
|
444
|
+
// again leaves a full header-sized blank footer at the end of long lists.
|
|
445
|
+
val bottom = original.bottom
|
|
446
|
+
if (recycler.paddingLeft != original.left || recycler.paddingTop != top ||
|
|
447
|
+
recycler.paddingRight != original.right || recycler.paddingBottom != bottom) {
|
|
448
|
+
// OneKey patch: LinearLayoutManager otherwise anchors children at their old
|
|
449
|
+
// screen coordinates when a page changes the sticky header height.
|
|
450
|
+
val manager = recycler.layoutManager as? LinearLayoutManager
|
|
451
|
+
val first = manager?.takeIf { it.orientation == RecyclerView.VERTICAL }
|
|
452
|
+
?.findFirstVisibleItemPosition() ?: RecyclerView.NO_POSITION
|
|
453
|
+
val anchorOffset = if (first != RecyclerView.NO_POSITION) {
|
|
454
|
+
manager?.findViewByPosition(first)?.let { manager.getDecoratedTop(it) - recycler.paddingTop }
|
|
455
|
+
} else null
|
|
456
|
+
recycler.setPadding(original.left, top, original.right, bottom)
|
|
457
|
+
if (anchorOffset != null) manager?.scrollToPositionWithOffset(first, anchorOffset)
|
|
458
|
+
// React Native can stop this nested requestLayout at its React-owned parent.
|
|
459
|
+
// Commit the changed inset before drawing instead of waiting for a data update.
|
|
460
|
+
if (recycler.width > 0 && recycler.height > 0 && !recycler.isComputingLayout) {
|
|
461
|
+
recycler.forceLayout()
|
|
462
|
+
recycler.measure(
|
|
463
|
+
MeasureSpec.makeMeasureSpec(recycler.width, MeasureSpec.EXACTLY),
|
|
464
|
+
MeasureSpec.makeMeasureSpec(recycler.height, MeasureSpec.EXACTLY),
|
|
465
|
+
)
|
|
466
|
+
recycler.layout(recycler.left, recycler.top, recycler.right, recycler.bottom)
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
original.appliedTopInset = topInset
|
|
470
|
+
val key = pageKey(pageIndex)
|
|
471
|
+
if (restoredRecyclerKeys[recycler] != key) {
|
|
472
|
+
restoredRecyclerKeys[recycler] = key
|
|
473
|
+
val generation = attachmentGeneration
|
|
474
|
+
recycler.post {
|
|
475
|
+
if (!isAttachedToWindow || generation != attachmentGeneration ||
|
|
476
|
+
restoredRecyclerKeys[recycler] != key ||
|
|
477
|
+
originalRecyclerPadding[recycler] !== original) {
|
|
478
|
+
if (restoredRecyclerKeys[recycler] == key) restoredRecyclerKeys.remove(recycler)
|
|
479
|
+
return@post
|
|
480
|
+
}
|
|
481
|
+
val saved = pageOffsets[key] ?: 0
|
|
482
|
+
val current = recycler.computeVerticalScrollOffset()
|
|
483
|
+
if (saved != current) recycler.scrollBy(0, saved - current)
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
private fun updateRecyclerPaddingOwnership(
|
|
489
|
+
recycler: RecyclerView,
|
|
490
|
+
original: RecyclerPadding,
|
|
491
|
+
) {
|
|
492
|
+
val expectedTop = original.top + original.appliedTopInset
|
|
493
|
+
if (recycler.paddingLeft != original.left) original.left = recycler.paddingLeft
|
|
494
|
+
if (recycler.paddingTop != expectedTop) {
|
|
495
|
+
// A React-owned list can replace its content padding while it remains
|
|
496
|
+
// attached. Remove our inset before retaining that value as the new base.
|
|
497
|
+
original.top = (recycler.paddingTop - original.appliedTopInset).coerceAtLeast(0)
|
|
498
|
+
}
|
|
499
|
+
if (recycler.paddingRight != original.right) original.right = recycler.paddingRight
|
|
500
|
+
if (recycler.paddingBottom != original.bottom) original.bottom = recycler.paddingBottom
|
|
501
|
+
if (recycler.clipToPadding) original.clipToPadding = true
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
private fun reapplyRecyclerInsets() {
|
|
505
|
+
prepareAdjacentPages()
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
private fun restoreRecyclerInsets(recycler: RecyclerView) {
|
|
509
|
+
val original = originalRecyclerPadding.remove(recycler) ?: return
|
|
510
|
+
updateRecyclerPaddingOwnership(recycler, original)
|
|
511
|
+
restoredRecyclerKeys.remove(recycler)
|
|
512
|
+
recycler.clipToPadding = original.clipToPadding
|
|
513
|
+
recycler.setPadding(original.left, original.top, original.right, original.bottom)
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
private fun isCurrentPageTarget(target: View): Boolean {
|
|
517
|
+
if (selectedPage !in 0 until adapter.itemCount) return false
|
|
518
|
+
return isDescendant(target, adapter.pageAt(selectedPage))
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// RN ScrollView uses the platform nested-scroll callbacks; RecyclerView
|
|
522
|
+
// uses the AndroidX variants with an explicit input type.
|
|
523
|
+
override fun onStartNestedScroll(child: View, target: View, axes: Int): Boolean =
|
|
524
|
+
onStartNestedScroll(child, target, axes, ViewCompat.TYPE_TOUCH)
|
|
525
|
+
|
|
526
|
+
override fun onNestedScrollAccepted(child: View, target: View, axes: Int) =
|
|
527
|
+
onNestedScrollAccepted(child, target, axes, ViewCompat.TYPE_TOUCH)
|
|
528
|
+
|
|
529
|
+
override fun onStopNestedScroll(target: View) =
|
|
530
|
+
onStopNestedScroll(target, ViewCompat.TYPE_TOUCH)
|
|
531
|
+
|
|
532
|
+
override fun onNestedPreScroll(target: View, dx: Int, dy: Int, consumed: IntArray) =
|
|
533
|
+
onNestedPreScroll(target, dx, dy, consumed, ViewCompat.TYPE_TOUCH)
|
|
534
|
+
|
|
535
|
+
override fun onNestedScroll(
|
|
536
|
+
target: View,
|
|
537
|
+
dxConsumed: Int,
|
|
538
|
+
dyConsumed: Int,
|
|
539
|
+
dxUnconsumed: Int,
|
|
540
|
+
dyUnconsumed: Int,
|
|
541
|
+
) = onNestedScroll(
|
|
542
|
+
target, dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed, ViewCompat.TYPE_TOUCH,
|
|
543
|
+
)
|
|
544
|
+
|
|
545
|
+
override fun onStartNestedScroll(child: View, target: View, axes: Int, type: Int): Boolean =
|
|
546
|
+
axes and ViewCompat.SCROLL_AXIS_VERTICAL != 0 && isCurrentPageTarget(target)
|
|
547
|
+
|
|
548
|
+
override fun onNestedScrollAccepted(child: View, target: View, axes: Int, type: Int) {
|
|
549
|
+
nestedScrollingParentHelper.onNestedScrollAccepted(child, target, axes, type)
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
override fun onStopNestedScroll(target: View, type: Int) {
|
|
553
|
+
nestedScrollingParentHelper.onStopNestedScroll(target, type)
|
|
554
|
+
onHeaderOffsetChanged?.invoke()
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
override fun onNestedPreScroll(target: View, dx: Int, dy: Int, consumed: IntArray, type: Int) {
|
|
558
|
+
if (!isCurrentPageTarget(target)) return
|
|
559
|
+
// OneKey patch: consume only the distance the short-page header can actually move.
|
|
560
|
+
val maximumOffset = maximumHeaderOffset()
|
|
561
|
+
// if (dy > 0 && headerOffsetPx < headerHeightPx) {
|
|
562
|
+
// val used = min(dy, headerHeightPx - headerOffsetPx)
|
|
563
|
+
if (dy > 0 && headerOffsetPx < maximumOffset) {
|
|
564
|
+
val used = min(dy, maximumOffset - headerOffsetPx)
|
|
565
|
+
setHeaderOffset(headerOffsetPx + used)
|
|
566
|
+
consumed[1] += used
|
|
567
|
+
} else if (dy < 0 && headerOffsetPx > 0 && !target.canScrollVertically(-1)) {
|
|
568
|
+
val used = max(dy, -headerOffsetPx)
|
|
569
|
+
setHeaderOffset(headerOffsetPx + used)
|
|
570
|
+
consumed[1] += used
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
override fun onNestedScroll(
|
|
575
|
+
target: View,
|
|
576
|
+
dxConsumed: Int,
|
|
577
|
+
dyConsumed: Int,
|
|
578
|
+
dxUnconsumed: Int,
|
|
579
|
+
dyUnconsumed: Int,
|
|
580
|
+
type: Int,
|
|
581
|
+
consumed: IntArray,
|
|
582
|
+
) {
|
|
583
|
+
if (!isCurrentPageTarget(target) || dyUnconsumed >= 0 || headerOffsetPx <= 0) return
|
|
584
|
+
val used = max(dyUnconsumed, -headerOffsetPx)
|
|
585
|
+
setHeaderOffset(headerOffsetPx + used)
|
|
586
|
+
consumed[1] += used
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
override fun onNestedScroll(
|
|
590
|
+
target: View,
|
|
591
|
+
dxConsumed: Int,
|
|
592
|
+
dyConsumed: Int,
|
|
593
|
+
dxUnconsumed: Int,
|
|
594
|
+
dyUnconsumed: Int,
|
|
595
|
+
type: Int,
|
|
596
|
+
) {
|
|
597
|
+
onNestedScroll(
|
|
598
|
+
target,
|
|
599
|
+
dxConsumed,
|
|
600
|
+
dyConsumed,
|
|
601
|
+
dxUnconsumed,
|
|
602
|
+
dyUnconsumed,
|
|
603
|
+
type,
|
|
604
|
+
IntArray(2),
|
|
605
|
+
)
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
override fun getNestedScrollAxes() = nestedScrollingParentHelper.nestedScrollAxes
|
|
609
|
+
|
|
610
|
+
override fun onAttachedToWindow() {
|
|
611
|
+
super.onAttachedToWindow()
|
|
612
|
+
attachmentGeneration += 1
|
|
613
|
+
restoredRecyclerKeys.clear()
|
|
614
|
+
viewTreeObserver.addOnPreDrawListener(pageContentLayoutListener)
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
override fun onDetachedFromWindow() {
|
|
618
|
+
attachmentGeneration += 1
|
|
619
|
+
viewTreeObserver.removeOnPreDrawListener(pageContentLayoutListener)
|
|
620
|
+
detachRecyclerObserver()
|
|
621
|
+
for (recycler in originalRecyclerPadding.keys.toList()) {
|
|
622
|
+
restoreRecyclerInsets(recycler)
|
|
623
|
+
}
|
|
624
|
+
super.onDetachedFromWindow()
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
companion object {
|
|
628
|
+
const val PAGE_SLOT_OFFSET = 2
|
|
629
|
+
}
|
|
630
|
+
}
|