@onekeyfe/react-native-pager-view 6.7.0-2 → 6.7.0-20

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 (42) 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 +90 -21
  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/test/java/com/reactnativepagerview/NativeScrollerScrollAwayInsetsTest.kt +40 -0
  10. package/docs/SPEC.md +253 -10
  11. package/ios/RNCCollapsiblePagerViewComponentView.mm +614 -42
  12. package/ios/RNCNativeScrollerComponentView.h +2 -0
  13. package/ios/RNCNativeScrollerComponentView.mm +42 -0
  14. package/ios/tests/RNCCollapsiblePagerFirstFrameTests.mm +393 -0
  15. package/ios/tests/RNCCollapsiblePagerHitTestTests.mm +338 -0
  16. package/ios/tests/RNCCollapsiblePagerPressCancellationTests.mm +74 -9
  17. package/ios/tests/RNCCollapsiblePagerRefreshControlTests.mm +50 -0
  18. package/lib/module/CollapsiblePagerView.js +32 -7
  19. package/lib/module/CollapsiblePagerView.web.js +439 -39
  20. package/lib/module/CollapsiblePagerViewNativeComponent.ts +13 -0
  21. package/lib/module/NativeScroller.web.js +16 -3
  22. package/lib/module/WebCollapsiblePagerPage.js +182 -0
  23. package/lib/module/index.js +2 -0
  24. package/lib/module/index.native.js +9 -0
  25. package/lib/module/index.web.js +2 -0
  26. package/lib/typescript/src/CollapsiblePagerView.d.ts +10 -3
  27. package/lib/typescript/src/CollapsiblePagerView.web.d.ts +66 -3
  28. package/lib/typescript/src/CollapsiblePagerViewNativeComponent.d.ts +5 -0
  29. package/lib/typescript/src/WebCollapsiblePagerPage.d.ts +42 -0
  30. package/lib/typescript/src/index.d.ts +4 -0
  31. package/lib/typescript/src/index.native.d.ts +13 -0
  32. package/lib/typescript/src/index.web.d.ts +4 -0
  33. package/package.json +6 -4
  34. package/react-native-pager-view.podspec +1 -0
  35. package/src/CollapsiblePagerView.tsx +99 -8
  36. package/src/CollapsiblePagerView.web.tsx +657 -51
  37. package/src/CollapsiblePagerViewNativeComponent.ts +13 -0
  38. package/src/NativeScroller.web.tsx +24 -2
  39. package/src/WebCollapsiblePagerPage.tsx +258 -0
  40. package/src/index.native.tsx +30 -0
  41. package/src/index.tsx +4 -0
  42. 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) {
@@ -689,7 +709,16 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
689
709
 
690
710
  private fun layoutPagerIfRequested() {
691
711
  val recycler = pager.getChildAt(0) as? RecyclerView ?: return
692
- if (!recycler.isLayoutRequested || recycler.isComputingLayout || recycler.width <= 0 || recycler.height <= 0) return
712
+ if (
713
+ !shouldDrainCollapsiblePagerLayout(
714
+ recycler.isLayoutRequested,
715
+ recycler.isLayoutSuppressed,
716
+ recycler.isComputingLayout,
717
+ recycler.width,
718
+ recycler.height,
719
+ adapter.itemCount,
720
+ )
721
+ ) return
693
722
  // OneKey patch: a non-animated page command queues RecyclerView layout, but RN
694
723
  // can stop that request after navigation. Drain it before showing the page.
695
724
  recycler.measure(
@@ -712,6 +741,13 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
712
741
  layout(left, top, right, bottom)
713
742
  }
714
743
 
744
+ private fun isPagerRecyclerLayoutSuppressed(): Boolean =
745
+ shouldSuppressCollapsiblePagerRecyclerLayout(
746
+ (pager.getChildAt(0) as? RecyclerView)?.isLayoutSuppressed == true,
747
+ adapter.itemCount,
748
+ attachedPageCount(),
749
+ )
750
+
715
751
  override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
716
752
  val width = MeasureSpec.getSize(widthMeasureSpec)
717
753
  val height = MeasureSpec.getSize(heightMeasureSpec)
@@ -722,10 +758,12 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
722
758
  return
723
759
  }
724
760
  val pagerHeight = height + headerHeightPx
725
- pager.measure(
726
- MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
727
- MeasureSpec.makeMeasureSpec(pagerHeight, MeasureSpec.EXACTLY),
728
- )
761
+ if (!isPagerRecyclerLayoutSuppressed()) {
762
+ pager.measure(
763
+ MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
764
+ MeasureSpec.makeMeasureSpec(pagerHeight, MeasureSpec.EXACTLY),
765
+ )
766
+ }
729
767
  headerView?.measure(
730
768
  MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
731
769
  MeasureSpec.makeMeasureSpec(headerHeightPx, MeasureSpec.EXACTLY),
@@ -737,8 +775,9 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
737
775
  val nativeTabHeight = if (nativeTabBarView.visibility == View.VISIBLE) {
738
776
  nativeTabBarView.preferredHeightPx()
739
777
  } else 0
778
+ val tabReservedWidth = nativeTabReservationPx(width)
740
779
  nativeTabBarView.measure(
741
- MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
780
+ MeasureSpec.makeMeasureSpec(width - tabReservedWidth, MeasureSpec.EXACTLY),
742
781
  MeasureSpec.makeMeasureSpec(nativeTabHeight, MeasureSpec.EXACTLY),
743
782
  )
744
783
  val nativeSubHeaderHeight = if (nativeSubHeaderView.visibility == View.VISIBLE) {
@@ -757,7 +796,9 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
757
796
  // RN ancestors allow overflow; keep translated header content inside this viewport.
758
797
  clipBounds = Rect(0, 0, width, height)
759
798
  if (width <= 0 || height <= 0) return
760
- pager.layout(0, 0, width, height + headerHeightPx)
799
+ if (!isPagerRecyclerLayoutSuppressed()) {
800
+ pager.layout(0, 0, width, height + headerHeightPx)
801
+ }
761
802
  headerView?.layout(0, 0, width, headerHeightPx)
762
803
  stickyHeaderView?.layout(
763
804
  0,
@@ -766,7 +807,12 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
766
807
  headerHeightPx + stickyHeaderHeightPx,
767
808
  )
768
809
  val nativeTabHeight = nativeTabBarView.measuredHeight
769
- nativeTabBarView.layout(0, headerHeightPx, width, headerHeightPx + nativeTabHeight)
810
+ val tabReservedWidth = nativeTabReservationPx(width)
811
+ val tabLeft = if (nativeTabBarView.layoutDirection == View.LAYOUT_DIRECTION_RTL) {
812
+ tabReservedWidth
813
+ } else 0
814
+ nativeTabBarView.layout(tabLeft, headerHeightPx, tabLeft + width - tabReservedWidth,
815
+ headerHeightPx + nativeTabHeight)
770
816
  nativeSubHeaderView.layout(
771
817
  0,
772
818
  headerHeightPx + nativeTabHeight,
@@ -828,7 +874,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
828
874
  }
829
875
  if (!reached()) {
830
876
  if (!isAttachedToWindow || !view.isAttachedToWindow || width <= 0 || height <= 0 ||
831
- view.width <= 0 || view.height <= 0 || isInLayout || view.isInLayout || view.isComputingLayout ||
877
+ view.width <= 0 || view.height <= 0 || isInLayout || view.isInLayout || view.isLayoutSuppressed || view.isComputingLayout ||
832
878
  pageKey(index) != key || primaryScrollableForPage(index) !== view || !isCurrentPageRestore(key, request)) return false
833
879
  val previousSynthetic = syntheticPageRestore
834
880
  syntheticPageRestore = true
@@ -987,13 +1033,33 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
987
1033
  if (!settled) return
988
1034
  pageOffsets[request.key] = 0
989
1035
  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)
1036
+ setHeaderOffset(request.logicalOffset.coerceIn(0, headerHeightPx))
1037
+ offsetDiag("pager-top-complete-offset=${request.logicalOffset}-token=${request.token}", view, request.key)
992
1038
  cancelPagerTop()
993
1039
  }
994
1040
 
995
1041
  internal fun scrollToTop(target: Int, animated: Boolean) {
996
- if (target !in 0..1 || pager.scrollState != ViewPager2.SCROLL_STATE_IDLE || transitionHeaderOffsets != null ||
1042
+ if (target !in 0..1) return
1043
+ scrollHeaderToOffset(if (target == 1) headerHeightPx else 0, animated)
1044
+ }
1045
+
1046
+ internal fun scrollHeaderBy(delta: Double) {
1047
+ if (!delta.isFinite()) return
1048
+ val density = resources.displayMetrics.density.toDouble()
1049
+ val maxDip = headerHeightPx / density
1050
+ val deltaPx = (delta.coerceIn(-maxDip, maxDip) * density).roundToInt()
1051
+ scrollHeaderToOffset((headerOffsetPx.toLong() + deltaPx).coerceIn(0, headerHeightPx.toLong()).toInt(), false)
1052
+ }
1053
+
1054
+ internal fun scrollHeaderToOffset(offset: Double) {
1055
+ if (!offset.isFinite()) return
1056
+ val density = resources.displayMetrics.density.toDouble()
1057
+ val targetPx = (offset.coerceIn(0.0, headerHeightPx / density) * density).roundToInt()
1058
+ scrollHeaderToOffset(targetPx, false)
1059
+ }
1060
+
1061
+ private fun scrollHeaderToOffset(logicalOffset: Int, animated: Boolean) {
1062
+ if (pager.scrollState != ViewPager2.SCROLL_STATE_IDLE || transitionHeaderOffsets != null ||
997
1063
  syntheticPageRestore || selectedPage !in 0 until adapter.itemCount || !isAttachedToWindow) return
998
1064
  val view = observedRecyclerView ?: observedOwnedScroller ?: observedNativeScroller ?: return
999
1065
  val key = currentPageKey()
@@ -1020,11 +1086,12 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
1020
1086
  if (view is RecyclerView) deferredRecyclerGeometry.remove(view)
1021
1087
  if (view is ScrollView) deferredScrollerGeometry.remove(view)
1022
1088
  pageOffsets[key] = 0
1023
- val request = PagerTopRequest(view, key, selectedPage, attachmentGeneration, target, ++pagerTopToken)
1089
+ val targetOffset = logicalOffset.coerceIn(0, headerHeightPx)
1090
+ val request = PagerTopRequest(view, key, selectedPage, attachmentGeneration, targetOffset, ++pagerTopToken)
1024
1091
  pagerTopRequest = request
1025
1092
  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)
1093
+ setHeaderOffset(targetOffset)
1094
+ offsetDiag("pager-top-begin-offset=$targetOffset-animated=$animated-token=${request.token}", view, key)
1028
1095
  dispatchingPagerTop = true
1029
1096
  try {
1030
1097
  when (view) {
@@ -1033,7 +1100,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
1033
1100
  if (animated && !recyclerIsAtBodyTop(view)) view.smoothScrollToPosition(0)
1034
1101
  else if (manager != null && manager.orientation == RecyclerView.VERTICAL) {
1035
1102
  manager.scrollToPositionWithOffset(0, 0)
1036
- if (view.width > 0 && view.height > 0 && !isInLayout && !view.isInLayout && !view.isComputingLayout) {
1103
+ if (view.width > 0 && view.height > 0 && !isInLayout && !view.isInLayout && !view.isLayoutSuppressed && !view.isComputingLayout) {
1037
1104
  view.measure(MeasureSpec.makeMeasureSpec(view.width, MeasureSpec.EXACTLY), MeasureSpec.makeMeasureSpec(view.height, MeasureSpec.EXACTLY))
1038
1105
  view.layout(view.left, view.top, view.right, view.bottom)
1039
1106
  }
@@ -1638,7 +1705,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
1638
1705
  if (anchorOffset != null) manager?.scrollToPositionWithOffset(first, anchorOffset)
1639
1706
  // React Native can stop this nested requestLayout at its React-owned parent.
1640
1707
  // Commit the changed inset before drawing instead of waiting for a data update.
1641
- if (recycler.width > 0 && recycler.height > 0 && !recycler.isComputingLayout) {
1708
+ if (recycler.width > 0 && recycler.height > 0 && !recycler.isLayoutSuppressed && !recycler.isComputingLayout) {
1642
1709
  recycler.forceLayout()
1643
1710
  recycler.measure(
1644
1711
  MeasureSpec.makeMeasureSpec(recycler.width, MeasureSpec.EXACTLY),
@@ -2012,14 +2079,16 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
2012
2079
  }
2013
2080
  }
2014
2081
 
2015
- private fun headerRegionAt(y: Float): String? {
2082
+ private fun headerRegionAt(x: Float, y: Float): String? {
2016
2083
  val headerBottom = headerHeightPx - headerOffsetPx
2017
2084
  val stickyBottom = headerHeightPx + stickyHeaderHeightPx - headerOffsetPx
2018
2085
  return when {
2019
2086
  y < 0 || y >= stickyBottom -> null
2020
2087
  y < headerBottom -> "header"
2021
2088
  y < headerBottom + nativeTabBarView.measuredHeight &&
2022
- nativeTabBarView.visibility == View.VISIBLE -> "primary-tab"
2089
+ nativeTabBarView.visibility == View.VISIBLE ->
2090
+ if (x >= nativeTabBarView.left && x < nativeTabBarView.right) "primary-tab"
2091
+ else "sticky-controls"
2023
2092
  y < headerBottom + nativeTabBarView.measuredHeight + nativeSubHeaderView.measuredHeight &&
2024
2093
  nativeSubHeaderView.visibility == View.VISIBLE -> "secondary-header"
2025
2094
  else -> "sticky-controls"
@@ -2128,7 +2197,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
2128
2197
  pressCancelled = false
2129
2198
  nativeGestureStarted = false
2130
2199
  nestedNativeGestureStarted = false
2131
- headerTouchRegion = headerRegionAt(event.y) ?: "none"
2200
+ headerTouchRegion = headerRegionAt(event.x, event.y) ?: "none"
2132
2201
  headerTouchActive = headerTouchRegion != "none"
2133
2202
  if (!headerTouchActive) return super.dispatchTouchEvent(event)
2134
2203
  headerDownX = event.x