@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.
Files changed (29) hide show
  1. package/README.md +40 -0
  2. package/android/src/main/java/com/reactnativepagerview/CollapsiblePagerHost.kt +630 -0
  3. package/android/src/main/java/com/reactnativepagerview/CollapsiblePagerViewManager.kt +222 -0
  4. package/android/src/main/java/com/reactnativepagerview/NestedScrollableHost.kt +3 -1
  5. package/android/src/main/java/com/reactnativepagerview/PagerViewViewPackage.kt +1 -1
  6. package/android/src/main/java/com/reactnativepagerview/event/CollapsibleStateChangedEvent.kt +39 -0
  7. package/ios/RNCCollapsiblePagerViewComponentView.h +9 -0
  8. package/ios/RNCCollapsiblePagerViewComponentView.mm +991 -0
  9. package/ios/RNCPagerViewComponentView.mm +4 -2
  10. package/lib/module/CollapsiblePagerView.js +167 -0
  11. package/lib/module/CollapsiblePagerView.web.js +478 -0
  12. package/lib/module/CollapsiblePagerViewNativeComponent.ts +87 -0
  13. package/lib/module/PagerView.web.js +177 -0
  14. package/lib/module/index.js +2 -1
  15. package/lib/module/index.web.js +6 -0
  16. package/lib/module/usePagerView.js +1 -1
  17. package/lib/typescript/src/CollapsiblePagerView.d.ts +58 -0
  18. package/lib/typescript/src/CollapsiblePagerView.web.d.ts +105 -0
  19. package/lib/typescript/src/CollapsiblePagerViewNativeComponent.d.ts +51 -0
  20. package/lib/typescript/src/PagerView.web.d.ts +55 -0
  21. package/lib/typescript/src/index.d.ts +1 -0
  22. package/lib/typescript/src/index.web.d.ts +5 -0
  23. package/package.json +5 -2
  24. package/src/CollapsiblePagerView.tsx +293 -0
  25. package/src/CollapsiblePagerView.web.tsx +710 -0
  26. package/src/CollapsiblePagerViewNativeComponent.ts +87 -0
  27. package/src/PagerView.web.tsx +280 -0
  28. package/src/index.tsx +1 -0
  29. 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
+ }