@onekeyfe/react-native-pager-view 6.7.0-2 → 6.7.0-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 (43) hide show
  1. package/README.md +14 -0
  2. package/android/src/androidTest/AndroidManifest.xml +7 -0
  3. package/android/src/androidTest/java/com/reactnativepagerview/CollapsiblePagerTabReservationTest.kt +306 -0
  4. package/android/src/main/java/com/reactnativepagerview/CollapsiblePagerHost.kt +130 -30
  5. package/android/src/main/java/com/reactnativepagerview/CollapsiblePagerNativeHeaders.kt +72 -7
  6. package/android/src/main/java/com/reactnativepagerview/CollapsiblePagerViewManager.kt +27 -0
  7. package/android/src/main/java/com/reactnativepagerview/NativeScrollerScrollAwayInsets.kt +19 -0
  8. package/android/src/main/java/com/reactnativepagerview/NativeScrollerView.kt +50 -6
  9. package/android/src/main/java/com/reactnativepagerview/NestedScrollableHost.kt +8 -2
  10. package/android/src/test/java/com/reactnativepagerview/NativeScrollerScrollAwayInsetsTest.kt +40 -0
  11. package/docs/SPEC.md +257 -10
  12. package/ios/RNCCollapsiblePagerViewComponentView.mm +614 -42
  13. package/ios/RNCNativeScrollerComponentView.h +2 -0
  14. package/ios/RNCNativeScrollerComponentView.mm +42 -0
  15. package/ios/tests/RNCCollapsiblePagerFirstFrameTests.mm +403 -1
  16. package/ios/tests/RNCCollapsiblePagerHitTestTests.mm +338 -0
  17. package/ios/tests/RNCCollapsiblePagerPressCancellationTests.mm +74 -9
  18. package/ios/tests/RNCCollapsiblePagerRefreshControlTests.mm +50 -0
  19. package/lib/module/CollapsiblePagerView.js +32 -7
  20. package/lib/module/CollapsiblePagerView.web.js +439 -39
  21. package/lib/module/CollapsiblePagerViewNativeComponent.ts +13 -0
  22. package/lib/module/NativeScroller.web.js +16 -3
  23. package/lib/module/WebCollapsiblePagerPage.js +182 -0
  24. package/lib/module/index.js +2 -0
  25. package/lib/module/index.native.js +9 -0
  26. package/lib/module/index.web.js +2 -0
  27. package/lib/typescript/src/CollapsiblePagerView.d.ts +10 -3
  28. package/lib/typescript/src/CollapsiblePagerView.web.d.ts +66 -3
  29. package/lib/typescript/src/CollapsiblePagerViewNativeComponent.d.ts +5 -0
  30. package/lib/typescript/src/WebCollapsiblePagerPage.d.ts +42 -0
  31. package/lib/typescript/src/index.d.ts +4 -0
  32. package/lib/typescript/src/index.native.d.ts +13 -0
  33. package/lib/typescript/src/index.web.d.ts +4 -0
  34. package/package.json +6 -4
  35. package/react-native-pager-view.podspec +1 -0
  36. package/src/CollapsiblePagerView.tsx +99 -8
  37. package/src/CollapsiblePagerView.web.tsx +657 -51
  38. package/src/CollapsiblePagerViewNativeComponent.ts +13 -0
  39. package/src/NativeScroller.web.tsx +24 -2
  40. package/src/WebCollapsiblePagerPage.tsx +258 -0
  41. package/src/index.native.tsx +30 -0
  42. package/src/index.tsx +4 -0
  43. package/src/index.web.tsx +4 -0
package/README.md CHANGED
@@ -66,6 +66,20 @@ replacing its delegate. Android coordinates nested scroll with the active
66
66
  Web scroll child can opt in by exposing a DOM element with
67
67
  `data-collapsible-pager-scroll`. CSS scroll timelines drive header/sticky
68
68
  movement, so React is not updated on each vertical scroll frame.
69
+ `pagerRef.current?.scrollHeaderBy(delta)` immediately moves the active page's
70
+ collapsible header by a finite logical distance, clamped to its measured range.
71
+ `scrollHeaderToOffset(offset)` targets an absolute collapse position, so a
72
+ keyboard's own scroll adjustment cannot add a second relative movement.
73
+ It is useful for revealing a focused field above a keyboard.
74
+
75
+ On Web, `WebCollapsiblePagerView` is an explicit root export of the existing Web
76
+ pager. It measures Header and sticky content when their heights are omitted.
77
+ `isActive` describes route visibility; the pager preserves a pending scroll
78
+ target across a short or temporarily hidden page and retries when real content
79
+ geometry becomes ready. `useWebCollapsiblePagerPage()` exposes the selected
80
+ page's scroll owner, visible metrics, content registration and pending target
81
+ to descendants. These Web-only APIs require a DOM when mounted. Native callers
82
+ continue to use `CollapsiblePagerView`.
69
83
 
70
84
  On iOS and Android, `nativeTabBar` renders the tab bar natively and
71
85
  `onNativeTabPress` reports presses. By default, adjacent tab presses animate and
@@ -0,0 +1,7 @@
1
+ <manifest xmlns:android="http://schemas.android.com/apk/res/android">
2
+ <application android:supportsRtl="true">
3
+ <activity
4
+ android:name="com.reactnativepagerview.CollapsiblePagerHitTestActivity"
5
+ android:exported="false" />
6
+ </application>
7
+ </manifest>
@@ -0,0 +1,306 @@
1
+ package com.reactnativepagerview
2
+
3
+ import android.app.Activity
4
+ import android.content.Intent
5
+ import android.content.pm.ApplicationInfo
6
+ import android.graphics.Bitmap
7
+ import android.graphics.Canvas
8
+ import android.graphics.Color
9
+ import android.test.InstrumentationTestCase
10
+ import android.view.MotionEvent
11
+ import android.view.View
12
+ import android.view.ViewGroup
13
+ import android.widget.HorizontalScrollView
14
+ import android.widget.TextView
15
+ import androidx.recyclerview.widget.RecyclerView
16
+ import androidx.viewpager2.widget.ViewPager2
17
+ import com.facebook.react.uimanager.PointerEvents
18
+ import com.facebook.react.views.view.ReactViewGroup
19
+ import java.util.concurrent.atomic.AtomicInteger
20
+ import kotlin.math.roundToInt
21
+
22
+ class CollapsiblePagerHitTestActivity : Activity()
23
+
24
+ class CollapsiblePagerTabReservationTest : InstrumentationTestCase() {
25
+ fun testSegmentedNativeTabsTrackFractionalProgressAndReset() {
26
+ val context = instrumentation.targetContext
27
+ assertTrue(context.applicationInfo.flags and ApplicationInfo.FLAG_SUPPORTS_RTL != 0)
28
+ val activity = instrumentation.startActivitySync(
29
+ Intent(context, CollapsiblePagerHitTestActivity::class.java)
30
+ .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
31
+ ) as CollapsiblePagerHitTestActivity
32
+ val density = context.resources.displayMetrics.density
33
+ fun dp(value: Int) = (value * density).roundToInt()
34
+ lateinit var tab: CollapsiblePagerNativeTabBarView
35
+ lateinit var row: HorizontalScrollView
36
+ lateinit var content: ViewGroup
37
+ lateinit var track: View
38
+ lateinit var thumb: View
39
+ lateinit var buttons: List<TextView>
40
+ fun layout(width: Int = 320, height: Int = 56) {
41
+ tab.measure(View.MeasureSpec.makeMeasureSpec(dp(width), View.MeasureSpec.EXACTLY),
42
+ View.MeasureSpec.makeMeasureSpec(dp(height), View.MeasureSpec.EXACTLY))
43
+ tab.layout(0, 0, dp(width), dp(height))
44
+ }
45
+ try {
46
+ instrumentation.runOnMainSync {
47
+ tab = CollapsiblePagerNativeTabBarView(context)
48
+ activity.setContentView(tab)
49
+ val presses = mutableListOf<String>()
50
+ tab.onItemPress = { _, key -> presses.add(key) }
51
+ tab.updateItemsJSON("""[{"key":"recent","title":"Recents"},""" +
52
+ """{"key":"account","title":"Accounts"},""" +
53
+ """{"key":"book","title":"Address Book"}]""")
54
+ tab.updateStyle(56.0, "segmented", 20.0, 12.0, 0.0, 14.0, null, 2.0, 0.0)
55
+ tab.updateColors(Color.LTGRAY, Color.BLACK, Color.GRAY, Color.WHITE)
56
+ layout()
57
+ row = tab.getChildAt(0) as HorizontalScrollView
58
+ content = row.getChildAt(0) as ViewGroup
59
+ track = content.getChildAt(0)
60
+ thumb = content.getChildAt(1)
61
+ buttons = (0 until content.childCount).map(content::getChildAt).filterIsInstance<TextView>()
62
+ assertEquals(3, buttons.size)
63
+ assertEquals(dp(20), track.left)
64
+ assertTrue(kotlin.math.abs(dp(32) - track.height) <= 1)
65
+ assertEquals(0, row.scrollX)
66
+ assertTrue(kotlin.math.abs(buttons[0].width - buttons[1].width) <= 1)
67
+ tab.setProgress(0f)
68
+ val firstX = thumb.left
69
+ tab.setProgress(1f)
70
+ val secondX = thumb.left
71
+ tab.setProgress(0.25f)
72
+ assertTrue(kotlin.math.abs(thumb.left - (firstX + (secondX - firstX) * 0.25f)) <= 1)
73
+ tab.setProgress(0.5f)
74
+ assertTrue(kotlin.math.abs(thumb.left - (firstX + secondX) / 2f) <= 1)
75
+ tab.setProgress(0f)
76
+ assertEquals(firstX, thumb.left)
77
+ buttons[1].performClick()
78
+ assertEquals(listOf("account"), presses)
79
+
80
+ tab.layoutDirection = View.LAYOUT_DIRECTION_RTL
81
+ }
82
+ instrumentation.waitForIdleSync()
83
+ instrumentation.runOnMainSync {
84
+ layout()
85
+ assertEquals(View.LAYOUT_DIRECTION_RTL, row.layoutDirection)
86
+ val rtlFirst = buttons[0].layoutParams as android.widget.FrameLayout.LayoutParams
87
+ val rtlSecond = buttons[1].layoutParams as android.widget.FrameLayout.LayoutParams
88
+ assertTrue(rtlFirst.leftMargin > rtlSecond.leftMargin)
89
+ val rtlGeometry = "rowDir=${row.layoutDirection} contentDir=${content.layoutDirection} " +
90
+ buttons.mapIndexed { index, button ->
91
+ val params = button.layoutParams as android.widget.FrameLayout.LayoutParams
92
+ "$index:${button.left}-${button.right} margin=${params.leftMargin}/${params.rightMargin} gravity=${params.gravity}"
93
+ }.joinToString(" ")
94
+ assertTrue(rtlGeometry, buttons[0].left >= buttons[1].right)
95
+ assertEquals(dp(20), track.left)
96
+ tab.setProgress(0f)
97
+ val rtlFirstX = thumb.left
98
+ tab.setProgress(1f)
99
+ val rtlSecondX = thumb.left
100
+ assertTrue(rtlFirstX > rtlSecondX)
101
+ tab.setProgress(0.5f)
102
+ assertTrue(kotlin.math.abs(thumb.left - (rtlFirstX + rtlSecondX) / 2f) <= 1)
103
+ tab.layoutDirection = View.LAYOUT_DIRECTION_LTR
104
+ }
105
+ instrumentation.waitForIdleSync()
106
+ instrumentation.runOnMainSync {
107
+ layout()
108
+ assertTrue(buttons[0].right <= buttons[1].left)
109
+ tab.updateItemsJSON("""[{"key":"recent","title":"Recents (8)"},""" +
110
+ """{"key":"account","title":"Accounts"}]""")
111
+ layout()
112
+ val twoButtons = (0 until content.childCount).map(content::getChildAt).filterIsInstance<TextView>()
113
+ assertEquals(2, twoButtons.size)
114
+ assertTrue(kotlin.math.abs(twoButtons[0].width - track.width / 2) <= 1)
115
+ layout(width = 30)
116
+ assertEquals(dp(15), track.left)
117
+ assertEquals(0, track.width)
118
+ tab.updateStyle(56.0, "segmented", 20.0, 40.0, 0.0, 14.0, null, 2.0, 0.0)
119
+ layout(width = 30)
120
+ assertTrue(kotlin.math.abs(dp(28) - track.top) <= 1)
121
+ assertEquals(0, track.height)
122
+ assertEquals(View.GONE, thumb.visibility)
123
+ tab.updateItemsJSON("[]")
124
+ assertEquals(View.GONE, tab.visibility)
125
+ tab.updateItemsJSON("""[{"key":"recent","title":"Recents"}]""")
126
+ tab.updateStyle(44.0, "underline", 20.0, 0.0, 8.0, 16.0, null, 2.0, 0.0)
127
+ layout(height = 44)
128
+ assertEquals(View.GONE, track.visibility)
129
+ assertEquals(dp(2), thumb.height)
130
+ }
131
+ } finally {
132
+ instrumentation.runOnMainSync { activity.finish() }
133
+ }
134
+ }
135
+
136
+ fun testTabViewportToolbarHitAndResetInBothDirections() {
137
+ val context = instrumentation.targetContext
138
+ assertTrue(context.applicationInfo.flags and ApplicationInfo.FLAG_SUPPORTS_RTL != 0)
139
+ val activity = instrumentation.startActivitySync(
140
+ Intent(context, CollapsiblePagerHitTestActivity::class.java)
141
+ .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
142
+ ) as CollapsiblePagerHitTestActivity
143
+ val toolbarPresses = AtomicInteger()
144
+ val tabPresses = AtomicInteger()
145
+ try {
146
+ instrumentation.runOnMainSync {
147
+ val density = context.resources.displayMetrics.density
148
+ fun dp(value: Int) = (value * density).roundToInt()
149
+ val width = dp(320)
150
+ val height = dp(500)
151
+ val reserved = dp(64)
152
+ val tabHeight = dp(44)
153
+ val host = CollapsiblePagerHost(context)
154
+ activity.setContentView(host)
155
+ assertTrue(host.isAttachedToWindow)
156
+ val sticky = ReactViewGroup(context).apply { pointerEvents = PointerEvents.BOX_NONE }
157
+ val fade = View(context).apply { setBackgroundColor(Color.RED) }
158
+ val toolbar = View(context).apply {
159
+ setBackgroundColor(Color.BLUE)
160
+ isClickable = true
161
+ }
162
+ sticky.addView(fade)
163
+ sticky.addView(toolbar)
164
+ host.addReactChild(View(context), 0)
165
+ host.addReactChild(sticky, 1)
166
+ host.headerHeightPx = 0
167
+ host.stickyHeaderHeightPx = tabHeight
168
+ host.nativeTabBarBackgroundColor = Color.GREEN
169
+ host.applyNativeHeaderStyle()
170
+ host.updateNativeTabBarItems(
171
+ """[{"key":"a","title":"Overview"},{"key":"b","title":"Ethereum"},""" +
172
+ """{"key":"c","title":"BNB Chain"},{"key":"d","title":"Polygon"}]""",
173
+ )
174
+ val tab = (0 until host.childCount).map(host::getChildAt)
175
+ .first { it.javaClass.simpleName == "CollapsiblePagerNativeTabBarView" } as ViewGroup
176
+ val row = tab.getChildAt(0) as HorizontalScrollView
177
+ toolbar.setOnClickListener { toolbarPresses.incrementAndGet() }
178
+ host.onNativeTabPress = { _, _ -> tabPresses.incrementAndGet() }
179
+
180
+ fun layout() {
181
+ host.measure(View.MeasureSpec.makeMeasureSpec(width, View.MeasureSpec.EXACTLY),
182
+ View.MeasureSpec.makeMeasureSpec(height, View.MeasureSpec.EXACTLY))
183
+ host.layout(0, 0, width, height)
184
+ }
185
+ fun tap(x: Int) {
186
+ val time = android.os.SystemClock.uptimeMillis()
187
+ val down = MotionEvent.obtain(time, time, MotionEvent.ACTION_DOWN,
188
+ x.toFloat(), (tabHeight / 2).toFloat(), 0)
189
+ val up = MotionEvent.obtain(time, time + 20, MotionEvent.ACTION_UP,
190
+ x.toFloat(), (tabHeight / 2).toFloat(), 0)
191
+ host.dispatchTouchEvent(down)
192
+ host.dispatchTouchEvent(up)
193
+ down.recycle()
194
+ up.recycle()
195
+ }
196
+ fun colorAt(x: Int): Int {
197
+ val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
198
+ host.draw(Canvas(bitmap))
199
+ val color = bitmap.getPixel(x, tabHeight / 2)
200
+ bitmap.recycle()
201
+ return color
202
+ }
203
+
204
+ host.nativeTabBarTrailingReservedWidth = 64.0
205
+ layout()
206
+ fade.layout(width - reserved - dp(20), 0, width - reserved, tabHeight)
207
+ toolbar.layout(width - reserved, 0, width, tabHeight)
208
+ assertEquals(width - reserved, tab.width)
209
+ assertEquals(0, tab.left)
210
+ assertEquals(width, sticky.width)
211
+ assertEquals(Color.RED, colorAt(width - reserved - dp(10)))
212
+ tap(width - dp(10))
213
+ tap(dp(28))
214
+ host.updateNativeTabProgress(3, 0f)
215
+ val content = row.getChildAt(0) as ViewGroup
216
+ val lastTab = (0 until content.childCount).map(content::getChildAt)
217
+ .filterIsInstance<TextView>().last()
218
+ assertTrue(lastTab.left >= row.scrollX - dp(1))
219
+ assertTrue(lastTab.right <= row.scrollX + row.width + dp(1))
220
+
221
+ host.setPagerLayoutDirection(View.LAYOUT_DIRECTION_RTL)
222
+ host.nativeTabBarTrailingReservedWidth = 64.0
223
+ layout()
224
+ assertEquals(View.LAYOUT_DIRECTION_RTL, tab.layoutDirection)
225
+ toolbar.layout(0, 0, reserved, tabHeight)
226
+ fade.layout(reserved, 0, reserved + dp(20), tabHeight)
227
+ assertEquals(reserved, tab.left)
228
+ assertEquals(width, tab.right)
229
+ assertEquals(Color.RED, colorAt(reserved + dp(10)))
230
+ tap(dp(10))
231
+
232
+ host.nativeTabBarTrailingReservedWidth = 500.0
233
+ layout()
234
+ assertEquals(0, tab.width)
235
+ host.nativeTabBarTrailingReservedWidth = 0.0
236
+ layout()
237
+ assertEquals(0, tab.left)
238
+ assertEquals(width, tab.width)
239
+ assertTrue(host.indexOfChild(sticky) < host.indexOfChild(tab))
240
+ }
241
+ instrumentation.waitForIdleSync()
242
+ assertEquals(2, toolbarPresses.get())
243
+ assertEquals(1, tabPresses.get())
244
+ } finally {
245
+ instrumentation.runOnMainSync { activity.finish() }
246
+ instrumentation.waitForIdleSync()
247
+ }
248
+ }
249
+
250
+ fun testUnmountWithTransitioningPageHoldersSkipsEmptyRecyclerLayout() {
251
+ val context = instrumentation.targetContext
252
+ val activity = instrumentation.startActivitySync(
253
+ Intent(context, CollapsiblePagerHitTestActivity::class.java)
254
+ .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
255
+ ) as CollapsiblePagerHitTestActivity
256
+ try {
257
+ instrumentation.runOnMainSync {
258
+ val density = context.resources.displayMetrics.density
259
+ fun dp(value: Int) = (value * density).roundToInt()
260
+ val width = dp(320)
261
+ val height = dp(500)
262
+ val host = CollapsiblePagerHost(context)
263
+ activity.setContentView(host)
264
+ val header = View(context)
265
+ val sticky = View(context)
266
+ val page0 = View(context)
267
+ val page1 = View(context)
268
+ host.addReactChild(header, 0)
269
+ host.addReactChild(sticky, 1)
270
+ host.addReactChild(page0, 2)
271
+ host.addReactChild(page1, 3)
272
+ host.measure(
273
+ View.MeasureSpec.makeMeasureSpec(width, View.MeasureSpec.EXACTLY),
274
+ View.MeasureSpec.makeMeasureSpec(height, View.MeasureSpec.EXACTLY),
275
+ )
276
+ host.layout(0, 0, width, height)
277
+ val pager = (0 until host.childCount).map(host::getChildAt).filterIsInstance<ViewPager2>().first()
278
+ val recycler = pager.getChildAt(0) as RecyclerView
279
+ assertNull(recycler.itemAnimator)
280
+ assertTrue(host.attachedPageCount() > 0)
281
+
282
+ val transitioningHolders = (0 until recycler.childCount).map(recycler::getChildAt)
283
+ transitioningHolders.forEach { recycler.startViewTransition(it) }
284
+
285
+ host.removeReactChild(page1)
286
+ host.removeReactChild(page0)
287
+ host.removeReactChild(sticky)
288
+ host.removeReactChild(header)
289
+
290
+ host.viewTreeObserver.dispatchOnPreDraw()
291
+ host.measure(
292
+ View.MeasureSpec.makeMeasureSpec(width, View.MeasureSpec.EXACTLY),
293
+ View.MeasureSpec.makeMeasureSpec(height, View.MeasureSpec.EXACTLY),
294
+ )
295
+ host.layout(0, 0, width, height)
296
+ assertTrue(host.attachedPageCount() > 0)
297
+
298
+ transitioningHolders.forEach { recycler.endViewTransition(it) }
299
+ }
300
+ instrumentation.waitForIdleSync()
301
+ } finally {
302
+ instrumentation.runOnMainSync { activity.finish() }
303
+ instrumentation.waitForIdleSync()
304
+ }
305
+ }
306
+ }
@@ -142,7 +142,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
142
142
  private var transitionSourceRecyclerAnchor: RecyclerReadingAnchor? = null
143
143
  private val commandBodyKeys = HashSet<String>()
144
144
  private var explicitTopCommand: Pair<String, PageRestoreRequest>? = null
145
- private class PagerTopRequest(view: View, val key: String, val pageIndex: Int, val generation: Int, val target: Int, val token: Int) {
145
+ private class PagerTopRequest(view: View, val key: String, val pageIndex: Int, val generation: Int, val logicalOffset: Int, val token: Int) {
146
146
  val view = WeakReference(view)
147
147
  }
148
148
  private var pagerTopRequest: PagerTopRequest? = null
@@ -293,7 +293,23 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
293
293
  var onNativeSubHeaderPress: ((Int, String) -> Unit)? = null
294
294
 
295
295
  var nativeTabBarHeight = 44.0
296
+ var nativeTabBarVariant = "underline"
297
+ var nativeTabBarTrailingReservedWidth = 0.0
298
+ set(value) {
299
+ field = if (value.isFinite() && value >= 0.0) value else 0.0
300
+ bringNativeHeadersToFront()
301
+ requestLayout()
302
+ }
303
+
304
+ private fun nativeTabReservationPx(width: Int): Int {
305
+ val reserved = nativeTabBarTrailingReservedWidth
306
+ val density = resources.displayMetrics.density
307
+ return if (reserved.isFinite() && reserved > 0.0 && width > 0 && density > 0f) {
308
+ (reserved.coerceAtMost(width / density.toDouble()) * density).roundToInt()
309
+ } else 0
310
+ }
296
311
  var nativeTabBarContentPaddingHorizontal = 20.0
312
+ var nativeTabBarContentPaddingVertical = 0.0
297
313
  var nativeTabBarItemSpacing = 8.0
298
314
  var nativeTabBarFontSize = 16.0
299
315
  var nativeTabBarFontFamily: String? = null
@@ -311,6 +327,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
311
327
  pager.id = View.generateViewId()
312
328
  pager.adapter = adapter
313
329
  pager.isSaveEnabled = false
330
+ (pager.getChildAt(0) as? RecyclerView)?.itemAnimator = null
314
331
  super.addView(
315
332
  pager,
316
333
  LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT),
@@ -416,7 +433,9 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
416
433
  fun applyNativeHeaderStyle() {
417
434
  nativeTabBarView.updateStyle(
418
435
  nativeTabBarHeight,
436
+ nativeTabBarVariant,
419
437
  nativeTabBarContentPaddingHorizontal,
438
+ nativeTabBarContentPaddingVertical,
420
439
  nativeTabBarItemSpacing,
421
440
  nativeTabBarFontSize,
422
441
  nativeTabBarFontFamily,
@@ -442,6 +461,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
442
461
  private fun bringNativeHeadersToFront() {
443
462
  nativeTabBarView.bringToFront()
444
463
  nativeSubHeaderView.bringToFront()
464
+ if (nativeTabBarTrailingReservedWidth > 0.0) stickyHeaderView?.bringToFront()
445
465
  }
446
466
 
447
467
  fun addReactChild(child: View, index: Int) {
@@ -571,9 +591,29 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
571
591
  cancelPagerTop()
572
592
  if (adapter.itemCount == 0 || !nativeSmoothHeaderScrollEnabled || headerHeightPx <= 0) return
573
593
  if (transitionHeaderOffsets == null) {
574
- transitionSourceDisplayBody = primaryScrollableForPage(selectedPage)?.let(::verticalScrollOffset) ?: 0
575
- transitionSourceRecyclerAnchor = (primaryScrollableForPage(selectedPage) as? RecyclerView)?.let(::captureRecyclerAnchor)
576
- pageHeaderOffsets[currentPageKey()] = headerOffsetPx
594
+ val sourceKey = currentPageKey()
595
+ val sourceView = primaryScrollableForPage(selectedPage)
596
+ val topInset = headerHeightPx + stickyHeaderHeightPx
597
+ val sourceRestored = sourceKey !in pendingPageRestores && when (sourceView) {
598
+ is RecyclerView -> restoredRecyclerKeys[sourceView] == sourceKey &&
599
+ originalRecyclerPadding[sourceView]?.appliedTopInset == topInset
600
+ is ScrollView -> restoredScrollerKeys[sourceView] == sourceKey &&
601
+ originalScrollerPadding[sourceView]?.appliedTopInset == topInset
602
+ is NativeScrollerViewport -> ownedScrollerKeys[sourceView] == sourceKey
603
+ else -> false
604
+ }
605
+ transitionSourceDisplayBody = if (sourceRestored && sourceView != null) {
606
+ verticalScrollOffset(sourceView)
607
+ } else {
608
+ displayBodyOverrides[sourceKey] ?: pageOffsets[sourceKey] ?: 0
609
+ }
610
+ transitionSourceRecyclerAnchor = if (sourceRestored && sourceView is RecyclerView) {
611
+ captureRecyclerAnchor(sourceView)
612
+ } else {
613
+ (displayRecyclerAnchors[sourceKey] ?: recyclerReadingAnchors[sourceKey])
614
+ ?.takeIf { sourceView !is RecyclerView || (it.view.get() === sourceView && it.adapter === sourceView.adapter) }
615
+ }
616
+ pageHeaderOffsets[sourceKey] = headerOffsetPx
577
617
  detachPrimaryScrollObserver()
578
618
  transitionSourcePage = selectedPage
579
619
  transitionHeaderOffsets = pageHeaderOffsets.toMap()
@@ -689,7 +729,16 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
689
729
 
690
730
  private fun layoutPagerIfRequested() {
691
731
  val recycler = pager.getChildAt(0) as? RecyclerView ?: return
692
- if (!recycler.isLayoutRequested || recycler.isComputingLayout || recycler.width <= 0 || recycler.height <= 0) return
732
+ if (
733
+ !shouldDrainCollapsiblePagerLayout(
734
+ recycler.isLayoutRequested,
735
+ recycler.isLayoutSuppressed,
736
+ recycler.isComputingLayout,
737
+ recycler.width,
738
+ recycler.height,
739
+ adapter.itemCount,
740
+ )
741
+ ) return
693
742
  // OneKey patch: a non-animated page command queues RecyclerView layout, but RN
694
743
  // can stop that request after navigation. Drain it before showing the page.
695
744
  recycler.measure(
@@ -712,6 +761,13 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
712
761
  layout(left, top, right, bottom)
713
762
  }
714
763
 
764
+ private fun isPagerRecyclerLayoutSuppressed(): Boolean =
765
+ shouldSuppressCollapsiblePagerRecyclerLayout(
766
+ (pager.getChildAt(0) as? RecyclerView)?.isLayoutSuppressed == true,
767
+ adapter.itemCount,
768
+ attachedPageCount(),
769
+ )
770
+
715
771
  override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
716
772
  val width = MeasureSpec.getSize(widthMeasureSpec)
717
773
  val height = MeasureSpec.getSize(heightMeasureSpec)
@@ -722,10 +778,12 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
722
778
  return
723
779
  }
724
780
  val pagerHeight = height + headerHeightPx
725
- pager.measure(
726
- MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
727
- MeasureSpec.makeMeasureSpec(pagerHeight, MeasureSpec.EXACTLY),
728
- )
781
+ if (!isPagerRecyclerLayoutSuppressed()) {
782
+ pager.measure(
783
+ MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
784
+ MeasureSpec.makeMeasureSpec(pagerHeight, MeasureSpec.EXACTLY),
785
+ )
786
+ }
729
787
  headerView?.measure(
730
788
  MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
731
789
  MeasureSpec.makeMeasureSpec(headerHeightPx, MeasureSpec.EXACTLY),
@@ -737,8 +795,9 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
737
795
  val nativeTabHeight = if (nativeTabBarView.visibility == View.VISIBLE) {
738
796
  nativeTabBarView.preferredHeightPx()
739
797
  } else 0
798
+ val tabReservedWidth = nativeTabReservationPx(width)
740
799
  nativeTabBarView.measure(
741
- MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
800
+ MeasureSpec.makeMeasureSpec(width - tabReservedWidth, MeasureSpec.EXACTLY),
742
801
  MeasureSpec.makeMeasureSpec(nativeTabHeight, MeasureSpec.EXACTLY),
743
802
  )
744
803
  val nativeSubHeaderHeight = if (nativeSubHeaderView.visibility == View.VISIBLE) {
@@ -757,7 +816,9 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
757
816
  // RN ancestors allow overflow; keep translated header content inside this viewport.
758
817
  clipBounds = Rect(0, 0, width, height)
759
818
  if (width <= 0 || height <= 0) return
760
- pager.layout(0, 0, width, height + headerHeightPx)
819
+ if (!isPagerRecyclerLayoutSuppressed()) {
820
+ pager.layout(0, 0, width, height + headerHeightPx)
821
+ }
761
822
  headerView?.layout(0, 0, width, headerHeightPx)
762
823
  stickyHeaderView?.layout(
763
824
  0,
@@ -766,7 +827,12 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
766
827
  headerHeightPx + stickyHeaderHeightPx,
767
828
  )
768
829
  val nativeTabHeight = nativeTabBarView.measuredHeight
769
- nativeTabBarView.layout(0, headerHeightPx, width, headerHeightPx + nativeTabHeight)
830
+ val tabReservedWidth = nativeTabReservationPx(width)
831
+ val tabLeft = if (nativeTabBarView.layoutDirection == View.LAYOUT_DIRECTION_RTL) {
832
+ tabReservedWidth
833
+ } else 0
834
+ nativeTabBarView.layout(tabLeft, headerHeightPx, tabLeft + width - tabReservedWidth,
835
+ headerHeightPx + nativeTabHeight)
770
836
  nativeSubHeaderView.layout(
771
837
  0,
772
838
  headerHeightPx + nativeTabHeight,
@@ -828,7 +894,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
828
894
  }
829
895
  if (!reached()) {
830
896
  if (!isAttachedToWindow || !view.isAttachedToWindow || width <= 0 || height <= 0 ||
831
- view.width <= 0 || view.height <= 0 || isInLayout || view.isInLayout || view.isComputingLayout ||
897
+ view.width <= 0 || view.height <= 0 || isInLayout || view.isInLayout || view.isLayoutSuppressed || view.isComputingLayout ||
832
898
  pageKey(index) != key || primaryScrollableForPage(index) !== view || !isCurrentPageRestore(key, request)) return false
833
899
  val previousSynthetic = syntheticPageRestore
834
900
  syntheticPageRestore = true
@@ -987,13 +1053,33 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
987
1053
  if (!settled) return
988
1054
  pageOffsets[request.key] = 0
989
1055
  if (view is RecyclerView) saveRecyclerReadingAnchor(request.key, view)
990
- setHeaderOffset(if (request.target == 1) headerHeightPx else 0)
991
- offsetDiag("pager-top-complete-target=${request.target}-token=${request.token}", view, request.key)
1056
+ setHeaderOffset(request.logicalOffset.coerceIn(0, headerHeightPx))
1057
+ offsetDiag("pager-top-complete-offset=${request.logicalOffset}-token=${request.token}", view, request.key)
992
1058
  cancelPagerTop()
993
1059
  }
994
1060
 
995
1061
  internal fun scrollToTop(target: Int, animated: Boolean) {
996
- if (target !in 0..1 || pager.scrollState != ViewPager2.SCROLL_STATE_IDLE || transitionHeaderOffsets != null ||
1062
+ if (target !in 0..1) return
1063
+ scrollHeaderToOffset(if (target == 1) headerHeightPx else 0, animated)
1064
+ }
1065
+
1066
+ internal fun scrollHeaderBy(delta: Double) {
1067
+ if (!delta.isFinite()) return
1068
+ val density = resources.displayMetrics.density.toDouble()
1069
+ val maxDip = headerHeightPx / density
1070
+ val deltaPx = (delta.coerceIn(-maxDip, maxDip) * density).roundToInt()
1071
+ scrollHeaderToOffset((headerOffsetPx.toLong() + deltaPx).coerceIn(0, headerHeightPx.toLong()).toInt(), false)
1072
+ }
1073
+
1074
+ internal fun scrollHeaderToOffset(offset: Double) {
1075
+ if (!offset.isFinite()) return
1076
+ val density = resources.displayMetrics.density.toDouble()
1077
+ val targetPx = (offset.coerceIn(0.0, headerHeightPx / density) * density).roundToInt()
1078
+ scrollHeaderToOffset(targetPx, false)
1079
+ }
1080
+
1081
+ private fun scrollHeaderToOffset(logicalOffset: Int, animated: Boolean) {
1082
+ if (pager.scrollState != ViewPager2.SCROLL_STATE_IDLE || transitionHeaderOffsets != null ||
997
1083
  syntheticPageRestore || selectedPage !in 0 until adapter.itemCount || !isAttachedToWindow) return
998
1084
  val view = observedRecyclerView ?: observedOwnedScroller ?: observedNativeScroller ?: return
999
1085
  val key = currentPageKey()
@@ -1020,11 +1106,12 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
1020
1106
  if (view is RecyclerView) deferredRecyclerGeometry.remove(view)
1021
1107
  if (view is ScrollView) deferredScrollerGeometry.remove(view)
1022
1108
  pageOffsets[key] = 0
1023
- val request = PagerTopRequest(view, key, selectedPage, attachmentGeneration, target, ++pagerTopToken)
1109
+ val targetOffset = logicalOffset.coerceIn(0, headerHeightPx)
1110
+ val request = PagerTopRequest(view, key, selectedPage, attachmentGeneration, targetOffset, ++pagerTopToken)
1024
1111
  pagerTopRequest = request
1025
1112
  commandBodyKeys.add(key)
1026
- setHeaderOffset(if (target == 1) headerHeightPx else 0)
1027
- offsetDiag("pager-top-begin-target=$target-animated=$animated-token=${request.token}", view, key)
1113
+ setHeaderOffset(targetOffset)
1114
+ offsetDiag("pager-top-begin-offset=$targetOffset-animated=$animated-token=${request.token}", view, key)
1028
1115
  dispatchingPagerTop = true
1029
1116
  try {
1030
1117
  when (view) {
@@ -1033,7 +1120,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
1033
1120
  if (animated && !recyclerIsAtBodyTop(view)) view.smoothScrollToPosition(0)
1034
1121
  else if (manager != null && manager.orientation == RecyclerView.VERTICAL) {
1035
1122
  manager.scrollToPositionWithOffset(0, 0)
1036
- if (view.width > 0 && view.height > 0 && !isInLayout && !view.isInLayout && !view.isComputingLayout) {
1123
+ if (view.width > 0 && view.height > 0 && !isInLayout && !view.isInLayout && !view.isLayoutSuppressed && !view.isComputingLayout) {
1037
1124
  view.measure(MeasureSpec.makeMeasureSpec(view.width, MeasureSpec.EXACTLY), MeasureSpec.makeMeasureSpec(view.height, MeasureSpec.EXACTLY))
1038
1125
  view.layout(view.left, view.top, view.right, view.bottom)
1039
1126
  }
@@ -1617,6 +1704,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
1617
1704
  )
1618
1705
  }
1619
1706
  updateRecyclerPaddingOwnership(recycler, original)
1707
+ val key = pageKey(pageIndex)
1620
1708
  val topInset = headerHeightPx + stickyHeaderHeightPx
1621
1709
  recycler.clipToPadding = false
1622
1710
  val top = original.top + topInset
@@ -1628,17 +1716,28 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
1628
1716
  // OneKey patch: LinearLayoutManager otherwise anchors children at their old
1629
1717
  // screen coordinates when a page changes the sticky header height.
1630
1718
  val manager = recycler.layoutManager as? LinearLayoutManager
1631
- val first = manager?.takeIf { it.orientation == RecyclerView.VERTICAL }
1632
- ?.findFirstVisibleItemPosition() ?: RecyclerView.NO_POSITION
1633
- val anchorOffset = if (first != RecyclerView.NO_POSITION) {
1719
+ val savedAnchor = if (nativeSmoothHeaderScrollEnabled && original.appliedTopInset == 0) {
1720
+ if (headerOffsetPx < headerHeightPx) {
1721
+ recycler.adapter?.let { RecyclerReadingAnchor(recycler, it, 0, 0) }
1722
+ } else {
1723
+ (if (transitionPageOffsets != null && pageIndex == transitionSourcePage) transitionSourceRecyclerAnchor
1724
+ else displayRecyclerAnchors[key] ?: recyclerReadingAnchors[key])
1725
+ ?.takeIf { it.view.get() === recycler && it.adapter === recycler.adapter }
1726
+ }
1727
+ } else null
1728
+ val first = savedAnchor?.position
1729
+ ?: manager?.takeIf { it.orientation == RecyclerView.VERTICAL }
1730
+ ?.findFirstVisibleItemPosition()
1731
+ ?: RecyclerView.NO_POSITION
1732
+ val anchorOffset = savedAnchor?.top ?: if (first != RecyclerView.NO_POSITION) {
1634
1733
  manager?.findViewByPosition(first)?.let { manager.getDecoratedTop(it) - recycler.paddingTop }
1635
1734
  } else null
1636
- offsetDiag("recycler-inset-before-nextTop=$top-anchorPosition=$first-anchorOffset=$anchorOffset", recycler, pageKey(pageIndex), pageIndex)
1735
+ offsetDiag("recycler-inset-before-nextTop=$top-anchorPosition=$first-anchorOffset=$anchorOffset", recycler, key, pageIndex)
1637
1736
  recycler.setPadding(original.left, top, original.right, bottom)
1638
1737
  if (anchorOffset != null) manager?.scrollToPositionWithOffset(first, anchorOffset)
1639
1738
  // React Native can stop this nested requestLayout at its React-owned parent.
1640
1739
  // Commit the changed inset before drawing instead of waiting for a data update.
1641
- if (recycler.width > 0 && recycler.height > 0 && !recycler.isComputingLayout) {
1740
+ if (recycler.width > 0 && recycler.height > 0 && !recycler.isLayoutSuppressed && !recycler.isComputingLayout) {
1642
1741
  recycler.forceLayout()
1643
1742
  recycler.measure(
1644
1743
  MeasureSpec.makeMeasureSpec(recycler.width, MeasureSpec.EXACTLY),
@@ -1647,9 +1746,8 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
1647
1746
  recycler.layout(recycler.left, recycler.top, recycler.right, recycler.bottom)
1648
1747
  }
1649
1748
  }
1650
- if (original.appliedTopInset != topInset) offsetDiag("recycler-inset-after-old=${original.appliedTopInset}-new=$topInset", recycler, pageKey(pageIndex), pageIndex)
1749
+ if (original.appliedTopInset != topInset) offsetDiag("recycler-inset-after-old=${original.appliedTopInset}-new=$topInset", recycler, key, pageIndex)
1651
1750
  original.appliedTopInset = topInset
1652
- val key = pageKey(pageIndex)
1653
1751
  val geometry = Triple(recycler.computeVerticalScrollRange(), recycler.computeVerticalScrollExtent(), recycler.childCount)
1654
1752
  deferredRecyclerGeometry[recycler]?.let { deferred ->
1655
1753
  if (geometry != deferred) {
@@ -2012,14 +2110,16 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
2012
2110
  }
2013
2111
  }
2014
2112
 
2015
- private fun headerRegionAt(y: Float): String? {
2113
+ private fun headerRegionAt(x: Float, y: Float): String? {
2016
2114
  val headerBottom = headerHeightPx - headerOffsetPx
2017
2115
  val stickyBottom = headerHeightPx + stickyHeaderHeightPx - headerOffsetPx
2018
2116
  return when {
2019
2117
  y < 0 || y >= stickyBottom -> null
2020
2118
  y < headerBottom -> "header"
2021
2119
  y < headerBottom + nativeTabBarView.measuredHeight &&
2022
- nativeTabBarView.visibility == View.VISIBLE -> "primary-tab"
2120
+ nativeTabBarView.visibility == View.VISIBLE ->
2121
+ if (x >= nativeTabBarView.left && x < nativeTabBarView.right) "primary-tab"
2122
+ else "sticky-controls"
2023
2123
  y < headerBottom + nativeTabBarView.measuredHeight + nativeSubHeaderView.measuredHeight &&
2024
2124
  nativeSubHeaderView.visibility == View.VISIBLE -> "secondary-header"
2025
2125
  else -> "sticky-controls"
@@ -2128,7 +2228,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
2128
2228
  pressCancelled = false
2129
2229
  nativeGestureStarted = false
2130
2230
  nestedNativeGestureStarted = false
2131
- headerTouchRegion = headerRegionAt(event.y) ?: "none"
2231
+ headerTouchRegion = headerRegionAt(event.x, event.y) ?: "none"
2132
2232
  headerTouchActive = headerTouchRegion != "none"
2133
2233
  if (!headerTouchActive) return super.dispatchTouchEvent(event)
2134
2234
  headerDownX = event.x