@onekeyfe/react-native-pager-view 6.7.0-alpha.344 → 6.7.0-alpha.354

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -66,6 +66,11 @@ 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.
69
74
 
70
75
  On Web, `WebCollapsiblePagerView` is an explicit root export of the existing Web
71
76
  pager. It measures Header and sticky content when their heights are omitted.
@@ -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,136 @@
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 com.facebook.react.uimanager.PointerEvents
16
+ import com.facebook.react.views.view.ReactViewGroup
17
+ import java.util.concurrent.atomic.AtomicInteger
18
+ import kotlin.math.roundToInt
19
+
20
+ class CollapsiblePagerHitTestActivity : Activity()
21
+
22
+ class CollapsiblePagerTabReservationTest : InstrumentationTestCase() {
23
+ fun testTabViewportToolbarHitAndResetInBothDirections() {
24
+ val context = instrumentation.targetContext
25
+ assertTrue(context.applicationInfo.flags and ApplicationInfo.FLAG_SUPPORTS_RTL != 0)
26
+ val activity = instrumentation.startActivitySync(
27
+ Intent(context, CollapsiblePagerHitTestActivity::class.java)
28
+ .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
29
+ ) as CollapsiblePagerHitTestActivity
30
+ val toolbarPresses = AtomicInteger()
31
+ val tabPresses = AtomicInteger()
32
+ try {
33
+ instrumentation.runOnMainSync {
34
+ val density = context.resources.displayMetrics.density
35
+ fun dp(value: Int) = (value * density).roundToInt()
36
+ val width = dp(320)
37
+ val height = dp(500)
38
+ val reserved = dp(64)
39
+ val tabHeight = dp(44)
40
+ val host = CollapsiblePagerHost(context)
41
+ activity.setContentView(host)
42
+ assertTrue(host.isAttachedToWindow)
43
+ val sticky = ReactViewGroup(context).apply { pointerEvents = PointerEvents.BOX_NONE }
44
+ val fade = View(context).apply { setBackgroundColor(Color.RED) }
45
+ val toolbar = View(context).apply {
46
+ setBackgroundColor(Color.BLUE)
47
+ isClickable = true
48
+ }
49
+ sticky.addView(fade)
50
+ sticky.addView(toolbar)
51
+ host.addReactChild(View(context), 0)
52
+ host.addReactChild(sticky, 1)
53
+ host.headerHeightPx = 0
54
+ host.stickyHeaderHeightPx = tabHeight
55
+ host.nativeTabBarBackgroundColor = Color.GREEN
56
+ host.applyNativeHeaderStyle()
57
+ host.updateNativeTabBarItems(
58
+ """[{"key":"a","title":"Overview"},{"key":"b","title":"Ethereum"},""" +
59
+ """{"key":"c","title":"BNB Chain"},{"key":"d","title":"Polygon"}]""",
60
+ )
61
+ val tab = (0 until host.childCount).map(host::getChildAt)
62
+ .first { it.javaClass.simpleName == "CollapsiblePagerNativeTabBarView" } as ViewGroup
63
+ val row = tab.getChildAt(0) as HorizontalScrollView
64
+ toolbar.setOnClickListener { toolbarPresses.incrementAndGet() }
65
+ host.onNativeTabPress = { _, _ -> tabPresses.incrementAndGet() }
66
+
67
+ fun layout() {
68
+ host.measure(View.MeasureSpec.makeMeasureSpec(width, View.MeasureSpec.EXACTLY),
69
+ View.MeasureSpec.makeMeasureSpec(height, View.MeasureSpec.EXACTLY))
70
+ host.layout(0, 0, width, height)
71
+ }
72
+ fun tap(x: Int) {
73
+ val time = android.os.SystemClock.uptimeMillis()
74
+ val down = MotionEvent.obtain(time, time, MotionEvent.ACTION_DOWN,
75
+ x.toFloat(), (tabHeight / 2).toFloat(), 0)
76
+ val up = MotionEvent.obtain(time, time + 20, MotionEvent.ACTION_UP,
77
+ x.toFloat(), (tabHeight / 2).toFloat(), 0)
78
+ host.dispatchTouchEvent(down)
79
+ host.dispatchTouchEvent(up)
80
+ down.recycle()
81
+ up.recycle()
82
+ }
83
+ fun colorAt(x: Int): Int {
84
+ val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
85
+ host.draw(Canvas(bitmap))
86
+ val color = bitmap.getPixel(x, tabHeight / 2)
87
+ bitmap.recycle()
88
+ return color
89
+ }
90
+
91
+ host.nativeTabBarTrailingReservedWidth = 64.0
92
+ layout()
93
+ fade.layout(width - reserved - dp(20), 0, width - reserved, tabHeight)
94
+ toolbar.layout(width - reserved, 0, width, tabHeight)
95
+ assertEquals(width - reserved, tab.width)
96
+ assertEquals(0, tab.left)
97
+ assertEquals(width, sticky.width)
98
+ assertEquals(Color.RED, colorAt(width - reserved - dp(10)))
99
+ tap(width - dp(10))
100
+ tap(dp(28))
101
+ host.updateNativeTabProgress(3, 0f)
102
+ val content = row.getChildAt(0) as ViewGroup
103
+ val lastTab = (0 until content.childCount).map(content::getChildAt)
104
+ .filterIsInstance<TextView>().last()
105
+ assertTrue(lastTab.left >= row.scrollX - dp(1))
106
+ assertTrue(lastTab.right <= row.scrollX + row.width + dp(1))
107
+
108
+ host.setPagerLayoutDirection(View.LAYOUT_DIRECTION_RTL)
109
+ host.nativeTabBarTrailingReservedWidth = 64.0
110
+ layout()
111
+ assertEquals(View.LAYOUT_DIRECTION_RTL, tab.layoutDirection)
112
+ toolbar.layout(0, 0, reserved, tabHeight)
113
+ fade.layout(reserved, 0, reserved + dp(20), tabHeight)
114
+ assertEquals(reserved, tab.left)
115
+ assertEquals(width, tab.right)
116
+ assertEquals(Color.RED, colorAt(reserved + dp(10)))
117
+ tap(dp(10))
118
+
119
+ host.nativeTabBarTrailingReservedWidth = 500.0
120
+ layout()
121
+ assertEquals(0, tab.width)
122
+ host.nativeTabBarTrailingReservedWidth = 0.0
123
+ layout()
124
+ assertEquals(0, tab.left)
125
+ assertEquals(width, tab.width)
126
+ assertTrue(host.indexOfChild(sticky) < host.indexOfChild(tab))
127
+ }
128
+ instrumentation.waitForIdleSync()
129
+ assertEquals(2, toolbarPresses.get())
130
+ assertEquals(1, tabPresses.get())
131
+ } finally {
132
+ instrumentation.runOnMainSync { activity.finish() }
133
+ instrumentation.waitForIdleSync()
134
+ }
135
+ }
136
+ }
@@ -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,6 +293,20 @@ 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 nativeTabBarTrailingReservedWidth = 0.0
297
+ set(value) {
298
+ field = if (value.isFinite() && value >= 0.0) value else 0.0
299
+ bringNativeHeadersToFront()
300
+ requestLayout()
301
+ }
302
+
303
+ private fun nativeTabReservationPx(width: Int): Int {
304
+ val reserved = nativeTabBarTrailingReservedWidth
305
+ val density = resources.displayMetrics.density
306
+ return if (reserved.isFinite() && reserved > 0.0 && width > 0 && density > 0f) {
307
+ (reserved.coerceAtMost(width / density.toDouble()) * density).roundToInt()
308
+ } else 0
309
+ }
296
310
  var nativeTabBarContentPaddingHorizontal = 20.0
297
311
  var nativeTabBarItemSpacing = 8.0
298
312
  var nativeTabBarFontSize = 16.0
@@ -442,6 +456,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
442
456
  private fun bringNativeHeadersToFront() {
443
457
  nativeTabBarView.bringToFront()
444
458
  nativeSubHeaderView.bringToFront()
459
+ if (nativeTabBarTrailingReservedWidth > 0.0) stickyHeaderView?.bringToFront()
445
460
  }
446
461
 
447
462
  fun addReactChild(child: View, index: Int) {
@@ -689,7 +704,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
689
704
 
690
705
  private fun layoutPagerIfRequested() {
691
706
  val recycler = pager.getChildAt(0) as? RecyclerView ?: return
692
- if (!recycler.isLayoutRequested || recycler.isComputingLayout || recycler.width <= 0 || recycler.height <= 0) return
707
+ if (!recycler.isLayoutRequested || recycler.isLayoutSuppressed || recycler.isComputingLayout || recycler.width <= 0 || recycler.height <= 0) return
693
708
  // OneKey patch: a non-animated page command queues RecyclerView layout, but RN
694
709
  // can stop that request after navigation. Drain it before showing the page.
695
710
  recycler.measure(
@@ -712,6 +727,9 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
712
727
  layout(left, top, right, bottom)
713
728
  }
714
729
 
730
+ private fun isPagerRecyclerLayoutSuppressed(): Boolean =
731
+ (pager.getChildAt(0) as? RecyclerView)?.isLayoutSuppressed == true
732
+
715
733
  override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
716
734
  val width = MeasureSpec.getSize(widthMeasureSpec)
717
735
  val height = MeasureSpec.getSize(heightMeasureSpec)
@@ -722,10 +740,12 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
722
740
  return
723
741
  }
724
742
  val pagerHeight = height + headerHeightPx
725
- pager.measure(
726
- MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
727
- MeasureSpec.makeMeasureSpec(pagerHeight, MeasureSpec.EXACTLY),
728
- )
743
+ if (!isPagerRecyclerLayoutSuppressed()) {
744
+ pager.measure(
745
+ MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
746
+ MeasureSpec.makeMeasureSpec(pagerHeight, MeasureSpec.EXACTLY),
747
+ )
748
+ }
729
749
  headerView?.measure(
730
750
  MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
731
751
  MeasureSpec.makeMeasureSpec(headerHeightPx, MeasureSpec.EXACTLY),
@@ -737,8 +757,9 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
737
757
  val nativeTabHeight = if (nativeTabBarView.visibility == View.VISIBLE) {
738
758
  nativeTabBarView.preferredHeightPx()
739
759
  } else 0
760
+ val tabReservedWidth = nativeTabReservationPx(width)
740
761
  nativeTabBarView.measure(
741
- MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
762
+ MeasureSpec.makeMeasureSpec(width - tabReservedWidth, MeasureSpec.EXACTLY),
742
763
  MeasureSpec.makeMeasureSpec(nativeTabHeight, MeasureSpec.EXACTLY),
743
764
  )
744
765
  val nativeSubHeaderHeight = if (nativeSubHeaderView.visibility == View.VISIBLE) {
@@ -757,7 +778,9 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
757
778
  // RN ancestors allow overflow; keep translated header content inside this viewport.
758
779
  clipBounds = Rect(0, 0, width, height)
759
780
  if (width <= 0 || height <= 0) return
760
- pager.layout(0, 0, width, height + headerHeightPx)
781
+ if (!isPagerRecyclerLayoutSuppressed()) {
782
+ pager.layout(0, 0, width, height + headerHeightPx)
783
+ }
761
784
  headerView?.layout(0, 0, width, headerHeightPx)
762
785
  stickyHeaderView?.layout(
763
786
  0,
@@ -766,7 +789,12 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
766
789
  headerHeightPx + stickyHeaderHeightPx,
767
790
  )
768
791
  val nativeTabHeight = nativeTabBarView.measuredHeight
769
- nativeTabBarView.layout(0, headerHeightPx, width, headerHeightPx + nativeTabHeight)
792
+ val tabReservedWidth = nativeTabReservationPx(width)
793
+ val tabLeft = if (nativeTabBarView.layoutDirection == View.LAYOUT_DIRECTION_RTL) {
794
+ tabReservedWidth
795
+ } else 0
796
+ nativeTabBarView.layout(tabLeft, headerHeightPx, tabLeft + width - tabReservedWidth,
797
+ headerHeightPx + nativeTabHeight)
770
798
  nativeSubHeaderView.layout(
771
799
  0,
772
800
  headerHeightPx + nativeTabHeight,
@@ -828,7 +856,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
828
856
  }
829
857
  if (!reached()) {
830
858
  if (!isAttachedToWindow || !view.isAttachedToWindow || width <= 0 || height <= 0 ||
831
- view.width <= 0 || view.height <= 0 || isInLayout || view.isInLayout || view.isComputingLayout ||
859
+ view.width <= 0 || view.height <= 0 || isInLayout || view.isInLayout || view.isLayoutSuppressed || view.isComputingLayout ||
832
860
  pageKey(index) != key || primaryScrollableForPage(index) !== view || !isCurrentPageRestore(key, request)) return false
833
861
  val previousSynthetic = syntheticPageRestore
834
862
  syntheticPageRestore = true
@@ -987,13 +1015,33 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
987
1015
  if (!settled) return
988
1016
  pageOffsets[request.key] = 0
989
1017
  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)
1018
+ setHeaderOffset(request.logicalOffset.coerceIn(0, headerHeightPx))
1019
+ offsetDiag("pager-top-complete-offset=${request.logicalOffset}-token=${request.token}", view, request.key)
992
1020
  cancelPagerTop()
993
1021
  }
994
1022
 
995
1023
  internal fun scrollToTop(target: Int, animated: Boolean) {
996
- if (target !in 0..1 || pager.scrollState != ViewPager2.SCROLL_STATE_IDLE || transitionHeaderOffsets != null ||
1024
+ if (target !in 0..1) return
1025
+ scrollHeaderToOffset(if (target == 1) headerHeightPx else 0, animated)
1026
+ }
1027
+
1028
+ internal fun scrollHeaderBy(delta: Double) {
1029
+ if (!delta.isFinite()) return
1030
+ val density = resources.displayMetrics.density.toDouble()
1031
+ val maxDip = headerHeightPx / density
1032
+ val deltaPx = (delta.coerceIn(-maxDip, maxDip) * density).roundToInt()
1033
+ scrollHeaderToOffset((headerOffsetPx.toLong() + deltaPx).coerceIn(0, headerHeightPx.toLong()).toInt(), false)
1034
+ }
1035
+
1036
+ internal fun scrollHeaderToOffset(offset: Double) {
1037
+ if (!offset.isFinite()) return
1038
+ val density = resources.displayMetrics.density.toDouble()
1039
+ val targetPx = (offset.coerceIn(0.0, headerHeightPx / density) * density).roundToInt()
1040
+ scrollHeaderToOffset(targetPx, false)
1041
+ }
1042
+
1043
+ private fun scrollHeaderToOffset(logicalOffset: Int, animated: Boolean) {
1044
+ if (pager.scrollState != ViewPager2.SCROLL_STATE_IDLE || transitionHeaderOffsets != null ||
997
1045
  syntheticPageRestore || selectedPage !in 0 until adapter.itemCount || !isAttachedToWindow) return
998
1046
  val view = observedRecyclerView ?: observedOwnedScroller ?: observedNativeScroller ?: return
999
1047
  val key = currentPageKey()
@@ -1020,11 +1068,12 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
1020
1068
  if (view is RecyclerView) deferredRecyclerGeometry.remove(view)
1021
1069
  if (view is ScrollView) deferredScrollerGeometry.remove(view)
1022
1070
  pageOffsets[key] = 0
1023
- val request = PagerTopRequest(view, key, selectedPage, attachmentGeneration, target, ++pagerTopToken)
1071
+ val targetOffset = logicalOffset.coerceIn(0, headerHeightPx)
1072
+ val request = PagerTopRequest(view, key, selectedPage, attachmentGeneration, targetOffset, ++pagerTopToken)
1024
1073
  pagerTopRequest = request
1025
1074
  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)
1075
+ setHeaderOffset(targetOffset)
1076
+ offsetDiag("pager-top-begin-offset=$targetOffset-animated=$animated-token=${request.token}", view, key)
1028
1077
  dispatchingPagerTop = true
1029
1078
  try {
1030
1079
  when (view) {
@@ -1033,7 +1082,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
1033
1082
  if (animated && !recyclerIsAtBodyTop(view)) view.smoothScrollToPosition(0)
1034
1083
  else if (manager != null && manager.orientation == RecyclerView.VERTICAL) {
1035
1084
  manager.scrollToPositionWithOffset(0, 0)
1036
- if (view.width > 0 && view.height > 0 && !isInLayout && !view.isInLayout && !view.isComputingLayout) {
1085
+ if (view.width > 0 && view.height > 0 && !isInLayout && !view.isInLayout && !view.isLayoutSuppressed && !view.isComputingLayout) {
1037
1086
  view.measure(MeasureSpec.makeMeasureSpec(view.width, MeasureSpec.EXACTLY), MeasureSpec.makeMeasureSpec(view.height, MeasureSpec.EXACTLY))
1038
1087
  view.layout(view.left, view.top, view.right, view.bottom)
1039
1088
  }
@@ -1638,7 +1687,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
1638
1687
  if (anchorOffset != null) manager?.scrollToPositionWithOffset(first, anchorOffset)
1639
1688
  // React Native can stop this nested requestLayout at its React-owned parent.
1640
1689
  // Commit the changed inset before drawing instead of waiting for a data update.
1641
- if (recycler.width > 0 && recycler.height > 0 && !recycler.isComputingLayout) {
1690
+ if (recycler.width > 0 && recycler.height > 0 && !recycler.isLayoutSuppressed && !recycler.isComputingLayout) {
1642
1691
  recycler.forceLayout()
1643
1692
  recycler.measure(
1644
1693
  MeasureSpec.makeMeasureSpec(recycler.width, MeasureSpec.EXACTLY),
@@ -2012,14 +2061,16 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
2012
2061
  }
2013
2062
  }
2014
2063
 
2015
- private fun headerRegionAt(y: Float): String? {
2064
+ private fun headerRegionAt(x: Float, y: Float): String? {
2016
2065
  val headerBottom = headerHeightPx - headerOffsetPx
2017
2066
  val stickyBottom = headerHeightPx + stickyHeaderHeightPx - headerOffsetPx
2018
2067
  return when {
2019
2068
  y < 0 || y >= stickyBottom -> null
2020
2069
  y < headerBottom -> "header"
2021
2070
  y < headerBottom + nativeTabBarView.measuredHeight &&
2022
- nativeTabBarView.visibility == View.VISIBLE -> "primary-tab"
2071
+ nativeTabBarView.visibility == View.VISIBLE ->
2072
+ if (x >= nativeTabBarView.left && x < nativeTabBarView.right) "primary-tab"
2073
+ else "sticky-controls"
2023
2074
  y < headerBottom + nativeTabBarView.measuredHeight + nativeSubHeaderView.measuredHeight &&
2024
2075
  nativeSubHeaderView.visibility == View.VISIBLE -> "secondary-header"
2025
2076
  else -> "sticky-controls"
@@ -2128,7 +2179,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
2128
2179
  pressCancelled = false
2129
2180
  nativeGestureStarted = false
2130
2181
  nestedNativeGestureStarted = false
2131
- headerTouchRegion = headerRegionAt(event.y) ?: "none"
2182
+ headerTouchRegion = headerRegionAt(event.x, event.y) ?: "none"
2132
2183
  headerTouchActive = headerTouchRegion != "none"
2133
2184
  if (!headerTouchActive) return super.dispatchTouchEvent(event)
2134
2185
  headerDownX = event.x
@@ -239,6 +239,11 @@ class CollapsiblePagerViewManager : ViewGroupManager<CollapsiblePagerHost>(),
239
239
  view.applyNativeHeaderStyle()
240
240
  }
241
241
 
242
+ @ReactProp(name = "nativeTabBarTrailingReservedWidth", defaultDouble = 0.0)
243
+ override fun setNativeTabBarTrailingReservedWidth(view: CollapsiblePagerHost?, value: Double) {
244
+ view?.nativeTabBarTrailingReservedWidth = value
245
+ }
246
+
242
247
  @ReactProp(name = "nativeTabBarContentPaddingHorizontal")
243
248
  override fun setNativeTabBarContentPaddingHorizontal(
244
249
  view: CollapsiblePagerHost?,
@@ -343,6 +348,14 @@ class CollapsiblePagerViewManager : ViewGroupManager<CollapsiblePagerHost>(),
343
348
  view?.scrollToTop(target, animated)
344
349
  }
345
350
 
351
+ override fun scrollHeaderBy(view: CollapsiblePagerHost?, delta: Double) {
352
+ view?.scrollHeaderBy(delta)
353
+ }
354
+
355
+ override fun scrollHeaderToOffset(view: CollapsiblePagerHost?, offset: Double) {
356
+ view?.scrollHeaderToOffset(offset)
357
+ }
358
+
346
359
  override fun setPage(view: CollapsiblePagerHost?, selectedPage: Int) {
347
360
  if (view != null && selectedPage in 0 until view.adapter.itemCount) {
348
361
  view.beginPageTransition(selectedPage)
@@ -37,26 +37,50 @@ class NativeScrollerView(context: Context) : FrameLayout(context) {
37
37
  setMeasuredDimension(MeasureSpec.getSize(widthSpec), MeasureSpec.getSize(heightSpec))
38
38
  }
39
39
  override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) = Unit
40
+
41
+ override fun startViewTransition(view: View) {
42
+ super.startViewTransition(view)
43
+ if (view === reactChild) transitioningReactChild = view
44
+ }
45
+
46
+ override fun endViewTransition(view: View) {
47
+ super.endViewTransition(view)
48
+ if (transitioningReactChild === view) transitioningReactChild = null
49
+ if (retainedReactChild === view) {
50
+ clearRetainedGeometry()
51
+ if (isAttachedToWindow) scheduleLayout()
52
+ }
53
+ }
40
54
  }
41
55
  private val contentFrame = object : FrameLayout(context) {
42
56
  override fun onMeasure(widthSpec: Int, heightSpec: Int) {
43
57
  setMeasuredDimension(
44
- max(MeasureSpec.getSize(widthSpec), contentWidthPx + insetLeft + insetRight),
45
- contentHeightPx + contentTop + insetBottom,
58
+ max(MeasureSpec.getSize(widthSpec), effectiveContentWidthPx + insetLeft + insetRight),
59
+ effectiveContentHeightPx + contentTop + insetBottom,
46
60
  )
47
61
  }
48
62
 
49
63
  override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
50
64
  // Only the native origin layer moves. Fabric owns its React child's layout/transform.
65
+ val contentWidth = effectiveContentWidthPx
66
+ val contentHeight = effectiveContentHeightPx
51
67
  contentOriginFrame.measure(
52
- MeasureSpec.makeMeasureSpec(contentWidthPx, MeasureSpec.EXACTLY),
53
- MeasureSpec.makeMeasureSpec(contentHeightPx, MeasureSpec.EXACTLY),
68
+ MeasureSpec.makeMeasureSpec(contentWidth, MeasureSpec.EXACTLY),
69
+ MeasureSpec.makeMeasureSpec(contentHeight, MeasureSpec.EXACTLY),
54
70
  )
55
- contentOriginFrame.layout(insetLeft, contentTop, insetLeft + contentWidthPx, contentTop + contentHeightPx)
71
+ contentOriginFrame.layout(insetLeft, contentTop, insetLeft + contentWidth, contentTop + contentHeight)
56
72
  }
57
73
  }
58
74
  private val pagerInsets = WeakHashMap<View, Int>()
59
75
  private var reactChild: View? = null
76
+ private var transitioningReactChild: View? = null
77
+ private var retainedReactChild: View? = null
78
+ private var retainedContentWidthPx = 0
79
+ private var retainedContentHeightPx = 0
80
+ private val effectiveContentWidthPx: Int
81
+ get() = if (reactChild == null && retainedReactChild != null) retainedContentWidthPx else contentWidthPx
82
+ private val effectiveContentHeightPx: Int
83
+ get() = if (reactChild == null && retainedReactChild != null) retainedContentHeightPx else contentHeightPx
60
84
  private var fabricState: StateWrapper? = null
61
85
  private var lastOriginX: Int? = null
62
86
  private var lastOriginY: Int? = null
@@ -172,6 +196,8 @@ class NativeScrollerView(context: Context) : FrameLayout(context) {
172
196
  internal fun addReactChild(child: View, index: Int) {
173
197
  require(index == 0 && reactChild == null) { "NativeScroller requires one React content view" }
174
198
  viewport.stopMotion()
199
+ clearRetainedGeometry()
200
+ transitioningReactChild = null
175
201
  reactChild = child
176
202
  contentOriginFrame.addView(child)
177
203
  scheduleLayout()
@@ -180,13 +206,29 @@ class NativeScrollerView(context: Context) : FrameLayout(context) {
180
206
  internal fun removeReactChild() {
181
207
  viewport.stopMotion()
182
208
  pendingRestore.cancel()
183
- reactChild?.let(contentOriginFrame::removeView)
209
+ reactChild?.let { child ->
210
+ clearRetainedGeometry()
211
+ // Fabric removes the child before its disappearing view finishes drawing.
212
+ if (transitioningReactChild === child &&
213
+ contentOriginFrame.width > 0 && contentOriginFrame.height > 0) {
214
+ retainedReactChild = child
215
+ retainedContentWidthPx = contentOriginFrame.width
216
+ retainedContentHeightPx = contentOriginFrame.height
217
+ }
218
+ contentOriginFrame.removeView(child)
219
+ }
184
220
  reactChild = null
185
221
  contentWidthPx = 0
186
222
  contentHeightPx = 0
187
223
  scheduleLayout()
188
224
  }
189
225
 
226
+ private fun clearRetainedGeometry() {
227
+ retainedReactChild = null
228
+ retainedContentWidthPx = 0
229
+ retainedContentHeightPx = 0
230
+ }
231
+
190
232
  internal fun reactChildCount() = if (reactChild == null) 0 else 1
191
233
  internal fun reactChildAt(index: Int): View {
192
234
  require(index == 0)
@@ -381,6 +423,8 @@ class NativeScrollerView(context: Context) : FrameLayout(context) {
381
423
  lastContentViewportHeight = null
382
424
  viewport.stopMotion()
383
425
  viewport.setTag(R.id.onekey_native_scroll_coordinator_offset_listener, null)
426
+ transitioningReactChild = null
427
+ clearRetainedGeometry()
384
428
  super.onDetachedFromWindow()
385
429
  }
386
430
 
package/docs/SPEC.md CHANGED
@@ -74,7 +74,7 @@ the shared host before their old scroll-edge interaction is released.
74
74
  The measured overlay height is excluded from the collapsible header distance:
75
75
  `header` remains collapsible and `stickyHeader` pins immediately below the
76
76
  overlay. Selection, cancellation, reuse and disposal rebind or release the
77
- interaction and restore any prior scroll-edge style. Native scroll/refresh
77
+ interaction and restore any prior scroll-edge style. The existing owner-scoped native sticky-height callback also reaches NativeList hosts, so their React container slots clip below the same fixed viewport region and release it when the owner detaches. Native scroll/refresh
78
78
  coordination continues on the main thread without per-frame JavaScript work.
79
79
  Refresh retains the pager-owned `overlay + header + sticky` top inset but offsets
80
80
  the indicator by `header + sticky`, so its spinner begins below the fixed overlay.
@@ -171,6 +171,24 @@ intent. Real vertical consumption, a newer valid command, or owner detachment
171
171
  releases the target. Existing NativeList and NativeScroller public coordinates
172
172
  are unchanged. The same page-top/sticky-top destinations are available without smooth-header mode.
173
173
 
174
+ ## Current-page partial header command
175
+
176
+ `scrollHeaderBy(delta: number)` moves the current page's shared collapsible
177
+ header from its displayed offset C to `clamp(C + delta, 0, D)`.
178
+ `scrollHeaderToOffset(offset: number)` moves it to `clamp(offset, 0, D)`
179
+ regardless of intervening native keyboard or user scrolling. D is the latest
180
+ measured collapsible height. Inputs use logical points on iOS and Android and
181
+ CSS pixels on Web. Non-finite values are ignored. The header and current body move immediately
182
+ so a focused input becomes visible before the next keyboard frame. A valid command
183
+ clears only the current page's body reading position B; it never changes
184
+ another page's memory or adds permanent content/insets. It requires the same
185
+ stable attached current owner as the page-top commands and is a safe no-op
186
+ during horizontal transitions, before mount, or after disposal. A newer
187
+ command or real user vertical consumption takes ownership from an older one.
188
+ Dynamic header measurements clamp any in-flight target to the latest D.
189
+ This command allows a focused input inside the shared header to move above a
190
+ keyboard without replacing the pager's sole vertical scroll owner.
191
+
174
192
  ## Independent NativeScroller migration
175
193
 
176
194
  Status: implemented in source. Both native builds pass. Android focused emulator
@@ -333,6 +351,32 @@ warnings were observed. Keyboard, multi-touch, physical-device performance and
333
351
  the full lifecycle/interaction matrix remain unverified. These are local source
334
352
  builds; the currently installed npm version does not yet contain this migration.
335
353
 
354
+ ## Android removal-transition geometry retention
355
+
356
+ Status: implemented locally; unreleased. On the Android UI thread, during a
357
+ screen removal transition,
358
+ NativeList and CollapsiblePager layout entry points respect the RecyclerView
359
+ suppression state established by the screen-transition owner. The owner restores
360
+ the original state when its last transition ownership ends. This adds no public
361
+ API and does not change Web or iOS behavior.
362
+
363
+ If Fabric removes the current NativeScroller React child while its origin-frame
364
+ transition remains active, Android retains the already displayed native extent
365
+ for drawing. The current child and raw content dimensions still clear normally;
366
+ only native content measurement and layout use the retained effective extent.
367
+ The matching transition end or detach clears the private child and extent state
368
+ idempotently. Adding a new child cancels prior retention, and a late end for the
369
+ old child cannot alter the new content. Ordinary removal, size events and scroll
370
+ commands keep their existing semantics.
371
+
372
+ Local arm64 DevShell acceptance covered Base, CXO and Overview exit content,
373
+ normal scrolling, RecyclerView ownership and suppression cleanup for the
374
+ observed CXO exit, and NativeScroller raw-zero content dimensions with a
375
+ nonzero displayed origin followed by detach cleanup. Nested-owner and cancel
376
+ paths, Overview rebound/cancel, natural resize and new-child runtime cases,
377
+ and the full performance matrix remain unverified. SDK publication and
378
+ consumer version alignment are separate steps.
379
+
336
380
  ## iOS vertical-scroll press cancellation
337
381
 
338
382
  Status: implemented in source. A source-identical `node_modules` build was
@@ -378,7 +422,7 @@ content, the shared header, and touches beginning on an inner horizontal banner.
378
422
  Normal taps MUST remain functional. Press-retention behavior in application
379
423
  controls belongs to the consuming responder implementation; the Pager package
380
424
  does not contain or publish Tamagui helpers. The native application tab bar has
381
- a separate contract in react-native-tab-view/docs/SPEC.md.
425
+ a separate contract in react-native-bottom-tabs/docs/SPEC.md.
382
426
 
383
427
  The current native implementation confirms horizontal intent after at least
384
428
  32 points on iOS or 32 dp on Android, with horizontal displacement exceeding
@@ -472,6 +516,28 @@ Status: implemented; focused native-wrapper tests cover both directions,
472
516
  explicit overrides and in-flight/superseded command targets. Home applies this policy to its JS tab bar, with iOS and
473
517
  Android simulator recordings verifying distant jumps and adjacent transitions.
474
518
 
519
+ ## Native tab trailing control reservation
520
+
521
+ Status: implemented locally, unreleased. The focused Android host test passed
522
+ (1/1); iOS hit tests had 8 passes and 1 existing skip. iOS companion-app UI
523
+ acceptance remains unverified.
524
+ `nativeTabBar.style.trailingReservedWidth` is an optional logical trailing
525
+ reservation for a sibling React control in the full-width sticky header. It
526
+ defaults to zero. JavaScript normalizes non-finite or negative input to zero;
527
+ native also clamps the reservation to the actual tab viewport width. On Android
528
+ and iOS, only the native tab viewport shrinks from `W` to `W - R`: its frame
529
+ begins at zero in LTR and at `R` in RTL. The sticky React header, its height,
530
+ and page content keep their original full-width geometry. Touches in the
531
+ reserved region reach the sibling control, while native tab hit testing and
532
+ last-tab visibility use the reduced viewport. Changing the reservation updates
533
+ that viewport; removing it or recycling the pager restores the default zero.
534
+ Web does not render the native tab and ignores this style field. For a positive
535
+ reservation, the box-none sticky React control draws above the native tab so
536
+ its inward fade and toolbar hit area remain visible; zero preserves the old
537
+ native-tab foreground order. Acceptance requires focused native layout and hit
538
+ testing for zero, nonzero, reset, and RTL reservations; type forwarding alone
539
+ is insufficient.
540
+
475
541
  ## Android refresh foreground coordination
476
542
 
477
543
  Status: implemented; Android Home simulator placement verified on 2026-09-28.
@@ -18,6 +18,7 @@
18
18
 
19
19
  // OneKey patch: deduplicate structural mutation parents before scanning the page.
20
20
  #include <unordered_set>
21
+ #include <cmath>
21
22
 
22
23
  using namespace facebook::react;
23
24
 
@@ -1471,6 +1472,7 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
1471
1472
  - (void)liftSharedHeadersForPagerTransition:(NSString *)reason;
1472
1473
  - (void)restoreSharedHeadersToContainer;
1473
1474
  - (void)layoutSharedHeaders;
1475
+ - (void)bringReservedStickyControlToFront;
1474
1476
  - (void)restorePinnedStickyHeader;
1475
1477
  - (void)updatePinnedStickyHeader;
1476
1478
  - (UIScrollView *)currentVerticalScrollViewForParentPager;
@@ -1558,6 +1560,7 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
1558
1560
  NSUInteger _pendingTopInsetReboundGeneration;
1559
1561
  NSTimer *_topInsetReboundTimer;
1560
1562
  CGFloat _nativeTabBarHeight;
1563
+ CGFloat _nativeTabBarTrailingReservedWidth;
1561
1564
  CGFloat _nativeSubHeaderHeight;
1562
1565
  CGFloat _headerOffset;
1563
1566
  BOOL _scrollEnabled;
@@ -2479,6 +2482,7 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
2479
2482
  }
2480
2483
  [_nativeTabBarView updateAncestorGesturePrecedence];
2481
2484
  [_nativeSubHeaderView updateAncestorGesturePrecedence];
2485
+ [self bringReservedStickyControlToFront];
2482
2486
  }
2483
2487
 
2484
2488
  - (void)restoreSharedHeadersToContainer
@@ -2584,10 +2588,11 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
2584
2588
  CGFloat nativeTabBarVisibleHeight = _nativeTabBarView.hidden
2585
2589
  ? 0
2586
2590
  : MIN(_stickyHeaderHeight, _nativeTabBarHeight);
2591
+ CGFloat tabReservedWidth = MIN(width, MAX(0, _nativeTabBarTrailingReservedWidth));
2587
2592
  _nativeTabBarView.frame = CGRectMake(
2588
- 0,
2593
+ self.isLtrLayout ? 0 : tabReservedWidth,
2589
2594
  stickyChildY,
2590
- width,
2595
+ width - tabReservedWidth,
2591
2596
  nativeTabBarVisibleHeight
2592
2597
  );
2593
2598
  _nativeSubHeaderView.frame = CGRectMake(
@@ -2601,9 +2606,26 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
2601
2606
  );
2602
2607
  _topOverlayHostView.frame = CGRectMake(0, 0, width, self.effectiveTopOverlayHeight);
2603
2608
  _topOverlayView.frame = _topOverlayHostView.bounds;
2609
+ [self bringReservedStickyControlToFront];
2604
2610
  [self applyHeaderOffset];
2605
2611
  }
2606
2612
 
2613
+ - (void)bringReservedStickyControlToFront
2614
+ {
2615
+ if (_stickyHeaderView != nil &&
2616
+ _stickyHeaderView.superview == _nativeTabBarView.superview) {
2617
+ if (_nativeTabBarTrailingReservedWidth > 0) {
2618
+ [_stickyHeaderView.superview bringSubviewToFront:_stickyHeaderView];
2619
+ } else {
2620
+ [_nativeTabBarView.superview bringSubviewToFront:_nativeTabBarView];
2621
+ if (!_nativeSubHeaderView.hidden &&
2622
+ _nativeSubHeaderView.superview == _nativeTabBarView.superview) {
2623
+ [_nativeSubHeaderView.superview bringSubviewToFront:_nativeSubHeaderView];
2624
+ }
2625
+ }
2626
+ }
2627
+ }
2628
+
2607
2629
  - (void)updatePinnedStickyHeader
2608
2630
  {
2609
2631
  BOOL attachedToList = _nativeSmoothHeaderScrollEnabled &&
@@ -2650,8 +2672,10 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
2650
2672
  CGFloat childY = shouldPin ? 0 : _headerHeight;
2651
2673
  CGFloat width = CGRectGetWidth(self.bounds);
2652
2674
  CGFloat tabHeight = _nativeTabBarView.hidden ? 0 : MIN(_stickyHeaderHeight, _nativeTabBarHeight);
2675
+ CGFloat tabReservedWidth = MIN(width, MAX(0, _nativeTabBarTrailingReservedWidth));
2653
2676
  _stickyHeaderView.frame = CGRectMake(0, childY, width, _stickyHeaderHeight);
2654
- _nativeTabBarView.frame = CGRectMake(0, childY, width, tabHeight);
2677
+ _nativeTabBarView.frame = CGRectMake(self.isLtrLayout ? 0 : tabReservedWidth,
2678
+ childY, width - tabReservedWidth, tabHeight);
2655
2679
  _nativeSubHeaderView.frame = CGRectMake(0, childY + tabHeight, width,
2656
2680
  MIN(MAX(0, _stickyHeaderHeight - tabHeight), _nativeSubHeaderHeight));
2657
2681
  _pinnedStickyHostView.hidden = !shouldPin;
@@ -2671,6 +2695,7 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
2671
2695
  }
2672
2696
  [_nativeTabBarView updateAncestorGesturePrecedence];
2673
2697
  [_nativeSubHeaderView updateAncestorGesturePrecedence];
2698
+ [self bringReservedStickyControlToFront];
2674
2699
  [self updateTopScrollEdgeForScrollView:_observedScrollView];
2675
2700
  [self logHomeVisualGeometry:shouldPin ? @"pin" : @"unpin"
2676
2701
  scrollView:_observedScrollView];
@@ -3050,6 +3075,7 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
3050
3075
  if (_stickyHeaderView != nil) [_sharedHeaderHostView bringSubviewToFront:_stickyHeaderView];
3051
3076
  if (!_nativeTabBarView.hidden) [_sharedHeaderHostView bringSubviewToFront:_nativeTabBarView];
3052
3077
  if (!_nativeSubHeaderView.hidden) [_sharedHeaderHostView bringSubviewToFront:_nativeSubHeaderView];
3078
+ [self bringReservedStickyControlToFront];
3053
3079
  [_containerView bringSubviewToFront:_sharedHeaderHostView];
3054
3080
  if (_stickyHeaderIsPinned) {
3055
3081
  [_pinnedStickyHostView.superview bringSubviewToFront:_pinnedStickyHostView];
@@ -3262,6 +3288,7 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
3262
3288
  _homeVisualLastLayoutLog = 0;
3263
3289
  _homeVisualLastGeometrySignature = nil;
3264
3290
  _nativeTabBarHeight = 44;
3291
+ _nativeTabBarTrailingReservedWidth = 0;
3265
3292
  _nativeSubHeaderHeight = 74;
3266
3293
  _headerOffset = 0;
3267
3294
  _currentLogicalOffset = 0;
@@ -3343,6 +3370,13 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
3343
3370
  _layoutDirection = RCTNSStringFromString(toString(newViewProps.layoutDirection));
3344
3371
  [_nativeTabBarView setLeftToRight:self.isLtrLayout];
3345
3372
  [_nativeSubHeaderView setLeftToRight:self.isLtrLayout];
3373
+ [self setNeedsLayout];
3374
+ }
3375
+ if (_needsPropsReapply ||
3376
+ oldViewProps.nativeTabBarTrailingReservedWidth != newViewProps.nativeTabBarTrailingReservedWidth) {
3377
+ double reserved = newViewProps.nativeTabBarTrailingReservedWidth;
3378
+ _nativeTabBarTrailingReservedWidth = std::isfinite(reserved) && reserved >= 0 ? reserved : 0;
3379
+ [self setNeedsLayout];
3346
3380
  }
3347
3381
  BOOL insetsChanged = NO;
3348
3382
  if (_needsPropsReapply || oldViewProps.headerHeight != newViewProps.headerHeight) {
@@ -3474,6 +3508,7 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
3474
3508
  if (!_nativeSubHeaderView.hidden) {
3475
3509
  [_nativeSubHeaderView.superview bringSubviewToFront:_nativeSubHeaderView];
3476
3510
  }
3511
+ [self bringReservedStickyControlToFront];
3477
3512
  [_sharedHeaderHostView.superview bringSubviewToFront:_sharedHeaderHostView];
3478
3513
  if (_stickyHeaderIsPinned) {
3479
3514
  [_pinnedStickyHostView.superview bringSubviewToFront:_pinnedStickyHostView];
@@ -4438,8 +4473,9 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
4438
4473
 
4439
4474
  - (void)updateNativeScrollerViewport:(UIScrollView *)scrollView stickyHeight:(CGFloat)height
4440
4475
  {
4441
- if ([scrollView.superview isKindOfClass:RNCNativeScrollerComponentView.class]) {
4442
- [(RNCNativeScrollerComponentView *)scrollView.superview setPagerStickyHeight:height forOwner:self];
4476
+ id host = scrollView.superview;
4477
+ if ([host respondsToSelector:@selector(setPagerStickyHeight:forOwner:)]) {
4478
+ [host setPagerStickyHeight:height forOwner:self];
4443
4479
  }
4444
4480
  }
4445
4481
 
@@ -5233,6 +5269,13 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
5233
5269
  {
5234
5270
  UIScrollView *scrollView = _pagerTopScrollView;
5235
5271
  if (_dispatchingPagerTop || ![self matchesPagerTop:scrollView]) return;
5272
+ CGFloat clampedTarget = MIN(MAX(_pagerTopLogicalTarget, 0), _headerHeight);
5273
+ if (clampedTarget != _pagerTopLogicalTarget) {
5274
+ _pagerTopLogicalTarget = clampedTarget;
5275
+ CGFloat physicalTarget = RNCLimitRestoredScrollOffset(clampedTarget - scrollView.contentInset.top,
5276
+ scrollView.contentSize.height, scrollView.bounds.size.height, scrollView.contentInset.top, scrollView.contentInset.bottom);
5277
+ [scrollView setContentOffset:CGPointMake(scrollView.contentOffset.x, physicalTarget) animated:NO];
5278
+ }
5236
5279
  CGFloat logical = scrollView.contentOffset.y + scrollView.contentInset.top;
5237
5280
  if (fabs(logical - _pagerTopLogicalTarget) > 0.5) return;
5238
5281
  _pageOffsets[_pagerTopPageKey] = @(_nativeSmoothHeaderScrollEnabled ? 0 : _pagerTopLogicalTarget);
@@ -5244,11 +5287,29 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
5244
5287
  }
5245
5288
 
5246
5289
  - (void)scrollToTop:(NSInteger)target animated:(BOOL)animated
5290
+ {
5291
+ if (target != 0 && target != 1) return;
5292
+ [self scrollHeaderToOffset:(target == 1 ? _headerHeight : 0) animated:animated];
5293
+ }
5294
+
5295
+ - (void)scrollHeaderBy:(double)delta
5296
+ {
5297
+ if (!std::isfinite(delta)) return;
5298
+ [self scrollHeaderToOffset:MIN(MAX(_headerOffset + delta, 0), _headerHeight) animated:NO];
5299
+ }
5300
+
5301
+ - (void)scrollHeaderToOffset:(double)offset
5302
+ {
5303
+ if (!std::isfinite(offset)) return;
5304
+ [self scrollHeaderToOffset:MIN(MAX(offset, 0), _headerHeight) animated:NO];
5305
+ }
5306
+
5307
+ - (void)scrollHeaderToOffset:(CGFloat)logicalTarget animated:(BOOL)animated
5247
5308
  {
5248
5309
  [self cancelTopInsetReboundCheck];
5249
5310
  UIScrollView *scrollView = _observedScrollView;
5250
5311
  NSString *key = _observedPageKey;
5251
- if ((target != 0 && target != 1) || _isBeingRecycled || _transitioning || _isPagerDragging ||
5312
+ if (_isBeingRecycled || _transitioning || _isPagerDragging ||
5252
5313
  _transitionPageOffsets != nil || _syntheticPageRestore || scrollView == nil || scrollView.window == nil ||
5253
5314
  key == nil || ![key isEqualToString:self.currentPageKey] || _observedPageIndex != _currentIndex ||
5254
5315
  _observedPageGeneration != _generation || self.window == nil) return;
@@ -5264,7 +5325,7 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
5264
5325
  [_alignedBodyKeys removeObject:key];
5265
5326
  [_suspendedBodyKeys removeObject:key];
5266
5327
  [_displayBodyOverrides removeObjectForKey:key];
5267
- CGFloat logicalTarget = target == 1 ? _headerHeight : 0;
5328
+ logicalTarget = MIN(MAX(logicalTarget, 0), _headerHeight);
5268
5329
  _pageOffsets[key] = @(_nativeSmoothHeaderScrollEnabled ? 0 : logicalTarget);
5269
5330
  _pagerTopScrollView = scrollView;
5270
5331
  _pagerTopPageKey = [key copy];
@@ -2,13 +2,22 @@
2
2
 
3
3
  #import "RNCCollapsiblePagerViewComponentView.h"
4
4
  #import "RNCPagerViewComponentView.h"
5
+ #import <React/RCTViewComponentView.h>
5
6
  #import <react/renderer/components/pagerview/Props.h>
7
+ #import <react/renderer/components/view/ViewProps.h>
6
8
 
7
9
  using namespace facebook::react;
8
10
 
9
11
  @interface RNCCollapsiblePagerViewComponentView (ScrollEdgeTesting)
10
12
  - (void)updateTopScrollEdgeForScrollView:(UIScrollView *)scrollView;
11
13
  - (void)updateScrollEdgeContentMasks;
14
+ - (void)layoutSharedHeaders;
15
+ @end
16
+
17
+ @interface UIView (NativeTabReservationTesting)
18
+ - (void)updateItemsJSON:(NSString *)itemsJSON;
19
+ - (void)setProgress:(CGFloat)progress;
20
+ - (void)setLeftToRight:(BOOL)leftToRight;
12
21
  @end
13
22
 
14
23
  @interface RNCCollapsiblePagerHitTestTests : XCTestCase
@@ -56,6 +65,84 @@ using namespace facebook::react;
56
65
  XCTAssertEqual([self.root hitTest:CGPointMake(20, 80) withEvent:nil], self.toolbar);
57
66
  }
58
67
 
68
+ - (void)testNativeTabReservationKeepsReactToolbarAboveTabsAndResetsInRTL
69
+ {
70
+ UIView *sharedHost = [self.pager valueForKey:@"sharedHeaderHostView"];
71
+ UIView *tabBar = [self.pager valueForKey:@"nativeTabBarView"];
72
+ [tabBar updateItemsJSON:@"[{\"key\":\"a\",\"title\":\"Overview\"},"
73
+ @"{\"key\":\"b\",\"title\":\"Ethereum\"},"
74
+ @"{\"key\":\"c\",\"title\":\"BNB Chain\"},"
75
+ @"{\"key\":\"d\",\"title\":\"Polygon\"}]"];
76
+ RCTViewComponentView *sticky = [RCTViewComponentView new];
77
+ auto stickyProps = std::make_shared<ViewProps>();
78
+ stickyProps->pointerEvents = PointerEventsMode::BoxNone;
79
+ [sticky updateProps:stickyProps oldProps:nullptr];
80
+ UIButton *trailingControl = [[UIButton alloc] initWithFrame:CGRectMake(250, 0, 70, 44)];
81
+ [sticky addSubview:trailingControl];
82
+ [sharedHost addSubview:sticky];
83
+ [sharedHost addSubview:tabBar];
84
+ [self.pager setValue:sticky forKey:@"stickyHeaderView"];
85
+ [self.pager setValue:@0 forKey:@"headerHeight"];
86
+ [self.pager setValue:@44 forKey:@"stickyHeaderHeight"];
87
+ tabBar.hidden = NO;
88
+
89
+ [self.pager setValue:@64 forKey:@"nativeTabBarTrailingReservedWidth"];
90
+ [self.pager layoutSharedHeaders];
91
+ XCTAssertEqualWithAccuracy(CGRectGetWidth(sticky.frame), 320, 0.001);
92
+ XCTAssertEqualWithAccuracy(CGRectGetWidth(tabBar.frame), 256, 0.001);
93
+ XCTAssertEqualWithAccuracy(CGRectGetMinX(tabBar.frame), 0, 0.001);
94
+ XCTAssertEqual(sharedHost.subviews.lastObject, sticky);
95
+ XCTAssertEqual([sharedHost hitTest:CGPointMake(300, 22) withEvent:nil], trailingControl);
96
+ XCTAssertTrue([tabBar pointInside:CGPointMake(250, 22) withEvent:nil]);
97
+ [tabBar layoutIfNeeded];
98
+ [tabBar setProgress:3];
99
+ UIScrollView *tabScroll = (UIScrollView *)tabBar.subviews.firstObject;
100
+ NSArray<UIButton *> *buttons = [tabScroll.subviews filteredArrayUsingPredicate:
101
+ [NSPredicate predicateWithBlock:^BOOL(UIView *view, NSDictionary *bindings) {
102
+ return [view isKindOfClass:UIButton.class];
103
+ }]];
104
+ UIButton *lastTab = buttons.lastObject;
105
+ XCTAssertNotNil(lastTab);
106
+ XCTAssertGreaterThanOrEqual(CGRectGetMinX(lastTab.frame), tabScroll.contentOffset.x - 1);
107
+ XCTAssertLessThanOrEqual(CGRectGetMaxX(lastTab.frame),
108
+ tabScroll.contentOffset.x + CGRectGetWidth(tabScroll.bounds) + 1);
109
+
110
+ [self.pager setValue:@"rtl" forKey:@"layoutDirection"];
111
+ [tabBar setLeftToRight:NO];
112
+ trailingControl.frame = CGRectMake(0, 0, 70, 44);
113
+ [self.pager layoutSharedHeaders];
114
+ XCTAssertEqualWithAccuracy(CGRectGetMinX(tabBar.frame), 64, 0.001);
115
+ XCTAssertEqualWithAccuracy(CGRectGetMaxX(tabBar.frame), 320, 0.001);
116
+ XCTAssertEqual([sharedHost hitTest:CGPointMake(20, 22) withEvent:nil], trailingControl);
117
+
118
+ [self.pager setValue:@500 forKey:@"nativeTabBarTrailingReservedWidth"];
119
+ [self.pager layoutSharedHeaders];
120
+ XCTAssertEqualWithAccuracy(CGRectGetMinX(tabBar.frame), 320, 0.001);
121
+ XCTAssertEqualWithAccuracy(CGRectGetWidth(tabBar.frame), 0, 0.001);
122
+
123
+ [self.pager setValue:@0 forKey:@"nativeTabBarTrailingReservedWidth"];
124
+ [self.pager layoutSharedHeaders];
125
+ XCTAssertEqualWithAccuracy(CGRectGetMinX(tabBar.frame), 0, 0.001);
126
+ XCTAssertEqualWithAccuracy(CGRectGetWidth(tabBar.frame), 320, 0.001);
127
+ XCTAssertEqual(sharedHost.subviews.lastObject, tabBar);
128
+ }
129
+
130
+ - (void)testNativeTabReservationPropsRestoreDefaultOnRemoval
131
+ {
132
+ auto reservedProps = std::make_shared<RNCCollapsiblePagerViewProps>();
133
+ reservedProps->nativeSmoothHeaderScrollEnabled = true;
134
+ reservedProps->nativeTabBarTrailingReservedWidth = 64;
135
+ [self.pager updateProps:reservedProps oldProps:nullptr];
136
+ XCTAssertEqualWithAccuracy(
137
+ [[self.pager valueForKey:@"nativeTabBarTrailingReservedWidth"] doubleValue], 64, 0.001);
138
+
139
+ auto defaultProps = std::make_shared<RNCCollapsiblePagerViewProps>();
140
+ defaultProps->nativeSmoothHeaderScrollEnabled = true;
141
+ [self.pager updateProps:defaultProps oldProps:reservedProps];
142
+ XCTAssertEqualWithAccuracy(
143
+ [[self.pager valueForKey:@"nativeTabBarTrailingReservedWidth"] doubleValue], 0, 0.001);
144
+ }
145
+
59
146
  - (void)testPinnedChildOutsideHeaderHostStillReceivesViewportTouches
60
147
  {
61
148
  XCTAssertEqual([self.pager hitTest:CGPointMake(20, 90) withEvent:nil], self.sticky);
@@ -82,6 +82,12 @@ export class CollapsiblePagerView extends React.PureComponent {
82
82
  scrollToPageTop = options => {
83
83
  if (this.nativeRef) CollapsiblePagerViewNativeCommands.scrollToTop(this.nativeRef, 0, options?.animated ?? true);
84
84
  };
85
+ scrollHeaderBy = delta => {
86
+ if (this.nativeRef && Number.isFinite(delta)) CollapsiblePagerViewNativeCommands.scrollHeaderBy(this.nativeRef, delta);
87
+ };
88
+ scrollHeaderToOffset = offset => {
89
+ if (this.nativeRef && Number.isFinite(offset)) CollapsiblePagerViewNativeCommands.scrollHeaderToOffset(this.nativeRef, offset);
90
+ };
85
91
  scrollToStickyTop = options => {
86
92
  if (this.nativeRef) CollapsiblePagerViewNativeCommands.scrollToTop(this.nativeRef, 1, options?.animated ?? true);
87
93
  };
@@ -96,7 +102,7 @@ export class CollapsiblePagerView extends React.PureComponent {
96
102
  nativeTabBarEnabled() {
97
103
  return (Platform.OS === "ios" || Platform.OS === "android") && !!this.props.nativeTabBar?.items.length;
98
104
  }
99
- dispatchNativeTabPageCommand(position, animated) {
105
+ dispatchNativeTabPageCommand(position, animated, expectedKey) {
100
106
  const commandId = ++this.nativeTabCommandId;
101
107
  this.nativeTabCommandQueuedDuringScroll = this.nativePagerScrollState !== "idle";
102
108
  this.latestNativeTabTarget = position;
@@ -104,7 +110,7 @@ export class CollapsiblePagerView extends React.PureComponent {
104
110
  transientRetainedPages: state.transientRetainedPages.includes(position) ? state.transientRetainedPages : [...state.transientRetainedPages, position]
105
111
  }), () => {
106
112
  requestAnimationFrame(() => {
107
- if (commandId !== this.nativeTabCommandId || !this.nativeRef) {
113
+ if (commandId !== this.nativeTabCommandId || !this.nativeRef || expectedKey !== undefined && (!this.nativeTabBarEnabled() || this.props.nativeTabBar?.items[position]?.key !== expectedKey)) {
108
114
  return;
109
115
  }
110
116
  this.dispatchedNativeTabTarget = position;
@@ -186,10 +192,13 @@ export class CollapsiblePagerView extends React.PureComponent {
186
192
  };
187
193
  onNativeTabPress = event => {
188
194
  const position = pageIndex(event.nativeEvent.position);
189
- if (position !== null && position >= 0 && position < this.pages().length && (position !== this.state.selectedPage || this.latestNativeTabTarget !== null)) {
195
+ if (!this.nativeTabBarEnabled() || position === null || this.props.nativeTabBar?.items[position]?.key !== event.nativeEvent.key) {
196
+ return;
197
+ }
198
+ if (position >= 0 && position < this.pages().length && (position !== this.state.selectedPage || this.latestNativeTabTarget !== null)) {
190
199
  // Selection state lags an in-flight transition until native reports it.
191
200
  const currentPage = this.dispatchedNativeTabTarget ?? this.state.selectedPage;
192
- this.dispatchNativeTabPageCommand(position, this.props.nativeTabPressAnimationEnabled ?? Math.abs(position - currentPage) <= 1);
201
+ this.dispatchNativeTabPageCommand(position, this.props.nativeTabPressAnimationEnabled ?? Math.abs(position - currentPage) <= 1, event.nativeEvent.key);
193
202
  }
194
203
  this.props.onNativeTabPress?.(event);
195
204
  };
@@ -233,6 +242,8 @@ export class CollapsiblePagerView extends React.PureComponent {
233
242
  const deducedLayoutDirection = !layoutDirection || layoutDirection === "locale" ? I18nManager.isRTL ? "rtl" : "ltr" : layoutDirection;
234
243
  const nativeTabBarEnabled = (Platform.OS === "ios" || Platform.OS === "android") && !!nativeTabBar?.items.length;
235
244
  const nativeTabBarStyle = nativeTabBar?.style;
245
+ const requestedTrailingReservedWidth = nativeTabBarStyle?.trailingReservedWidth;
246
+ const nativeTabBarTrailingReservedWidth = typeof requestedTrailingReservedWidth === "number" && Number.isFinite(requestedTrailingReservedWidth) && requestedTrailingReservedWidth >= 0 ? requestedTrailingReservedWidth : 0;
236
247
  const nativeSubHeaderEnabled = (Platform.OS === "ios" || Platform.OS === "android") && !!nativeSubHeader?.items.length;
237
248
  const nativeSubHeaderStyle = nativeSubHeader?.style;
238
249
  const nativeSubHeaderConfig = nativeSubHeaderEnabled ? JSON.stringify({
@@ -263,6 +274,7 @@ export class CollapsiblePagerView extends React.PureComponent {
263
274
  onPageScrollStateChanged: this.onPageScrollStateChanged,
264
275
  nativeTabBarItems: nativeTabBarEnabled ? JSON.stringify(nativeTabBar.items) : undefined,
265
276
  nativeTabBarHeight: nativeTabBarEnabled ? nativeTabBarStyle?.height ?? 44 : undefined,
277
+ nativeTabBarTrailingReservedWidth: nativeTabBarEnabled ? nativeTabBarTrailingReservedWidth : 0,
266
278
  nativeTabBarContentPaddingHorizontal: nativeTabBarEnabled ? nativeTabBarStyle?.contentPaddingHorizontal ?? 20 : undefined,
267
279
  nativeTabBarItemSpacing: nativeTabBarEnabled ? nativeTabBarStyle?.itemSpacing ?? 8 : undefined,
268
280
  nativeTabBarFontSize: nativeTabBarEnabled ? nativeTabBarStyle?.fontSize ?? 16 : undefined,
@@ -335,6 +335,15 @@ export class CollapsiblePagerView extends React.PureComponent {
335
335
  scrollToPageTop = options => {
336
336
  this.scrollCurrentPageToTop(0, options?.animated ?? true);
337
337
  };
338
+ scrollHeaderBy = delta => {
339
+ if (!Number.isFinite(delta)) return;
340
+ const offset = Math.min(this.headerHeight, Math.max(0, this.sharedHeaderOffset + delta));
341
+ this.scrollCurrentPageToTop(offset, false);
342
+ };
343
+ scrollHeaderToOffset = offset => {
344
+ if (!Number.isFinite(offset)) return;
345
+ this.scrollCurrentPageToTop(Math.min(this.headerHeight, Math.max(0, offset)), false);
346
+ };
338
347
  scrollToStickyTop = options => {
339
348
  this.scrollCurrentPageToTop(this.headerHeight, options?.animated ?? true);
340
349
  };
@@ -386,13 +395,13 @@ export class CollapsiblePagerView extends React.PureComponent {
386
395
  this.pendingTopFrame = null;
387
396
  if (!element.isConnected || this.horizontalTouch || this.transitionTimer !== null || this.pageScrollElement(this.state.selectedPage) !== element || this.pages().map(pageKey)[this.state.selectedPage] !== key) return;
388
397
  this.pageOffsets.set(key, 0);
389
- this.sharedHeaderOffset = offset;
398
+ this.sharedHeaderOffset = Math.min(this.headerHeight, Math.max(0, offset));
390
399
  this.bodyAlignment = {
391
400
  element,
392
401
  key
393
402
  };
394
403
  scrollElementTo(element, {
395
- top: offset,
404
+ top: this.sharedHeaderOffset,
396
405
  behavior: animated ? "smooth" : "instant"
397
406
  });
398
407
  this.syncFallbackHeader();
@@ -67,6 +67,7 @@ export interface NativeProps extends ViewProps {
67
67
  retainedPages?: string;
68
68
  nativeTabBarItems?: string;
69
69
  nativeTabBarHeight?: Double;
70
+ nativeTabBarTrailingReservedWidth?: WithDefault<Double, 0>;
70
71
  nativeTabBarContentPaddingHorizontal?: Double;
71
72
  nativeTabBarItemSpacing?: Double;
72
73
  nativeTabBarFontSize?: Double;
@@ -91,6 +92,14 @@ export interface NativeProps extends ViewProps {
91
92
  type CollapsiblePagerViewNativeType = HostComponent<NativeProps>;
92
93
 
93
94
  export interface NativeCommands {
95
+ scrollHeaderToOffset: (
96
+ viewRef: React.ElementRef<CollapsiblePagerViewNativeType>,
97
+ offset: Double
98
+ ) => void;
99
+ scrollHeaderBy: (
100
+ viewRef: React.ElementRef<CollapsiblePagerViewNativeType>,
101
+ delta: Double
102
+ ) => void;
94
103
  scrollToTop: (
95
104
  viewRef: React.ElementRef<CollapsiblePagerViewNativeType>,
96
105
  target: Int32,
@@ -112,6 +121,8 @@ export interface NativeCommands {
112
121
 
113
122
  export const Commands: NativeCommands = codegenNativeCommands<NativeCommands>({
114
123
  supportedCommands: [
124
+ "scrollHeaderToOffset",
125
+ "scrollHeaderBy",
115
126
  "scrollToTop",
116
127
  "setPage",
117
128
  "setPageWithoutAnimation",
@@ -17,6 +17,7 @@ export type CollapsiblePagerNativeTabBarItem = Readonly<{
17
17
  }>;
18
18
  export type CollapsiblePagerNativeTabBarStyle = Readonly<{
19
19
  height?: number;
20
+ trailingReservedWidth?: number;
20
21
  contentPaddingHorizontal?: number;
21
22
  itemSpacing?: number;
22
23
  fontSize?: number;
@@ -56,7 +57,7 @@ export type CollapsiblePagerNativeSubHeaderConfig = Readonly<{
56
57
  }>;
57
58
  export type CollapsiblePagerViewOnNativeTabPressEvent = ReactNative.NativeSyntheticEvent<OnNativeTabPressEventData>;
58
59
  export type CollapsiblePagerViewOnNativeSubHeaderPressEvent = ReactNative.NativeSyntheticEvent<OnNativeTabPressEventData>;
59
- export interface CollapsiblePagerViewProps extends Omit<NativeProps, "children" | "headerHeight" | "stickyHeaderHeight" | "topScrollEdgeEffect" | "topOverlayHeight" | "externalTopOverlayTag" | "pageKeys" | "retainedPages" | "nativeTabBarItems" | "nativeTabBarHeight" | "nativeTabBarContentPaddingHorizontal" | "nativeTabBarItemSpacing" | "nativeTabBarFontSize" | "nativeTabBarFontFamily" | "nativeTabBarBackgroundColor" | "nativeTabBarActiveTextColor" | "nativeTabBarInactiveTextColor" | "nativeTabBarIndicatorColor" | "nativeTabBarIndicatorHeight" | "nativeTabBarIndicatorBottom" | "nativeSubHeaderConfig" | "nativeSubHeaderSelectedBackgroundColor" | "onNativeTabPress" | "onNativeSubHeaderPress" | "layoutDirection"> {
60
+ export interface CollapsiblePagerViewProps extends Omit<NativeProps, "children" | "headerHeight" | "stickyHeaderHeight" | "topScrollEdgeEffect" | "topOverlayHeight" | "externalTopOverlayTag" | "pageKeys" | "retainedPages" | "nativeTabBarItems" | "nativeTabBarHeight" | "nativeTabBarTrailingReservedWidth" | "nativeTabBarContentPaddingHorizontal" | "nativeTabBarItemSpacing" | "nativeTabBarFontSize" | "nativeTabBarFontFamily" | "nativeTabBarBackgroundColor" | "nativeTabBarActiveTextColor" | "nativeTabBarInactiveTextColor" | "nativeTabBarIndicatorColor" | "nativeTabBarIndicatorHeight" | "nativeTabBarIndicatorBottom" | "nativeSubHeaderConfig" | "nativeSubHeaderSelectedBackgroundColor" | "onNativeTabPress" | "onNativeSubHeaderPress" | "layoutDirection"> {
60
61
  /** Collapsible content rendered above the sticky bar. */
61
62
  header: React.ReactNode;
62
63
  /** Bar which remains pinned after the header has collapsed. */
@@ -128,6 +129,8 @@ export declare class CollapsiblePagerView extends React.PureComponent<Collapsibl
128
129
  scrollToPageTop: (options?: Readonly<{
129
130
  animated?: boolean;
130
131
  }>) => void;
132
+ scrollHeaderBy: (delta: number) => void;
133
+ scrollHeaderToOffset: (offset: number) => void;
131
134
  scrollToStickyTop: (options?: Readonly<{
132
135
  animated?: boolean;
133
136
  }>) => void;
@@ -132,6 +132,8 @@ export declare class CollapsiblePagerView extends React.PureComponent<Collapsibl
132
132
  scrollToPageTop: (options?: Readonly<{
133
133
  animated?: boolean;
134
134
  }>) => void;
135
+ scrollHeaderBy: (delta: number) => void;
136
+ scrollHeaderToOffset: (offset: number) => void;
135
137
  scrollToStickyTop: (options?: Readonly<{
136
138
  animated?: boolean;
137
139
  }>) => void;
@@ -50,6 +50,7 @@ export interface NativeProps extends ViewProps {
50
50
  retainedPages?: string;
51
51
  nativeTabBarItems?: string;
52
52
  nativeTabBarHeight?: Double;
53
+ nativeTabBarTrailingReservedWidth?: WithDefault<Double, 0>;
53
54
  nativeTabBarContentPaddingHorizontal?: Double;
54
55
  nativeTabBarItemSpacing?: Double;
55
56
  nativeTabBarFontSize?: Double;
@@ -72,6 +73,8 @@ export interface NativeProps extends ViewProps {
72
73
  }
73
74
  type CollapsiblePagerViewNativeType = HostComponent<NativeProps>;
74
75
  export interface NativeCommands {
76
+ scrollHeaderToOffset: (viewRef: React.ElementRef<CollapsiblePagerViewNativeType>, offset: Double) => void;
77
+ scrollHeaderBy: (viewRef: React.ElementRef<CollapsiblePagerViewNativeType>, delta: Double) => void;
75
78
  scrollToTop: (viewRef: React.ElementRef<CollapsiblePagerViewNativeType>, target: Int32, animated: boolean) => void;
76
79
  setPage: (viewRef: React.ElementRef<CollapsiblePagerViewNativeType>, selectedPage: Int32) => void;
77
80
  setPageWithoutAnimation: (viewRef: React.ElementRef<CollapsiblePagerViewNativeType>, selectedPage: Int32) => void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onekeyfe/react-native-pager-view",
3
- "version": "6.7.0-alpha.344",
3
+ "version": "6.7.0-alpha.354",
4
4
  "description": "React Native wrapper for Android and iOS ViewPager",
5
5
  "source": "./src/index.tsx",
6
6
  "main": "./lib/module/index.js",
@@ -73,7 +73,7 @@
73
73
  "typescript": "^5.9.2"
74
74
  },
75
75
  "peerDependencies": {
76
- "@onekeyfe/react-native-native-logger": "6.7.0-alpha.344",
76
+ "@onekeyfe/react-native-native-logger": "6.7.0-alpha.354",
77
77
  "react": "*",
78
78
  "react-native": "*"
79
79
  },
@@ -33,6 +33,7 @@ export type CollapsiblePagerNativeTabBarItem = Readonly<{
33
33
 
34
34
  export type CollapsiblePagerNativeTabBarStyle = Readonly<{
35
35
  height?: number;
36
+ trailingReservedWidth?: number;
36
37
  contentPaddingHorizontal?: number;
37
38
  itemSpacing?: number;
38
39
  fontSize?: number;
@@ -94,6 +95,7 @@ export interface CollapsiblePagerViewProps
94
95
  | "retainedPages"
95
96
  | "nativeTabBarItems"
96
97
  | "nativeTabBarHeight"
98
+ | "nativeTabBarTrailingReservedWidth"
97
99
  | "nativeTabBarContentPaddingHorizontal"
98
100
  | "nativeTabBarItemSpacing"
99
101
  | "nativeTabBarFontSize"
@@ -290,6 +292,22 @@ export class CollapsiblePagerView extends React.PureComponent<
290
292
  );
291
293
  };
292
294
 
295
+ public scrollHeaderBy = (delta: number) => {
296
+ if (this.nativeRef && Number.isFinite(delta))
297
+ CollapsiblePagerViewNativeCommands.scrollHeaderBy(
298
+ this.nativeRef,
299
+ delta
300
+ );
301
+ };
302
+
303
+ public scrollHeaderToOffset = (offset: number) => {
304
+ if (this.nativeRef && Number.isFinite(offset))
305
+ CollapsiblePagerViewNativeCommands.scrollHeaderToOffset(
306
+ this.nativeRef,
307
+ offset
308
+ );
309
+ };
310
+
293
311
  public scrollToStickyTop = (options?: Readonly<{ animated?: boolean }>) => {
294
312
  if (this.nativeRef)
295
313
  CollapsiblePagerViewNativeCommands.scrollToTop(
@@ -319,7 +337,11 @@ export class CollapsiblePagerView extends React.PureComponent<
319
337
  );
320
338
  }
321
339
 
322
- private dispatchNativeTabPageCommand(position: number, animated: boolean) {
340
+ private dispatchNativeTabPageCommand(
341
+ position: number,
342
+ animated: boolean,
343
+ expectedKey?: string
344
+ ) {
323
345
  const commandId = ++this.nativeTabCommandId;
324
346
  this.nativeTabCommandQueuedDuringScroll =
325
347
  this.nativePagerScrollState !== "idle";
@@ -332,7 +354,13 @@ export class CollapsiblePagerView extends React.PureComponent<
332
354
  }),
333
355
  () => {
334
356
  requestAnimationFrame(() => {
335
- if (commandId !== this.nativeTabCommandId || !this.nativeRef) {
357
+ if (
358
+ commandId !== this.nativeTabCommandId ||
359
+ !this.nativeRef ||
360
+ (expectedKey !== undefined &&
361
+ (!this.nativeTabBarEnabled() ||
362
+ this.props.nativeTabBar?.items[position]?.key !== expectedKey))
363
+ ) {
336
364
  return;
337
365
  }
338
366
  this.dispatchedNativeTabTarget = position;
@@ -458,7 +486,13 @@ export class CollapsiblePagerView extends React.PureComponent<
458
486
  ) => {
459
487
  const position = pageIndex(event.nativeEvent.position);
460
488
  if (
461
- position !== null &&
489
+ !this.nativeTabBarEnabled() ||
490
+ position === null ||
491
+ this.props.nativeTabBar?.items[position]?.key !== event.nativeEvent.key
492
+ ) {
493
+ return;
494
+ }
495
+ if (
462
496
  position >= 0 &&
463
497
  position < this.pages().length &&
464
498
  (position !== this.state.selectedPage ||
@@ -470,7 +504,8 @@ export class CollapsiblePagerView extends React.PureComponent<
470
504
  this.dispatchNativeTabPageCommand(
471
505
  position,
472
506
  this.props.nativeTabPressAnimationEnabled ??
473
- Math.abs(position - currentPage) <= 1
507
+ Math.abs(position - currentPage) <= 1,
508
+ event.nativeEvent.key
474
509
  );
475
510
  }
476
511
  this.props.onNativeTabPress?.(event);
@@ -533,6 +568,14 @@ export class CollapsiblePagerView extends React.PureComponent<
533
568
  (Platform.OS === "ios" || Platform.OS === "android") &&
534
569
  !!nativeTabBar?.items.length;
535
570
  const nativeTabBarStyle = nativeTabBar?.style;
571
+ const requestedTrailingReservedWidth =
572
+ nativeTabBarStyle?.trailingReservedWidth;
573
+ const nativeTabBarTrailingReservedWidth =
574
+ typeof requestedTrailingReservedWidth === "number" &&
575
+ Number.isFinite(requestedTrailingReservedWidth) &&
576
+ requestedTrailingReservedWidth >= 0
577
+ ? requestedTrailingReservedWidth
578
+ : 0;
536
579
  const nativeSubHeaderEnabled =
537
580
  (Platform.OS === "ios" || Platform.OS === "android") &&
538
581
  !!nativeSubHeader?.items.length;
@@ -586,6 +629,9 @@ export class CollapsiblePagerView extends React.PureComponent<
586
629
  nativeTabBarHeight={
587
630
  nativeTabBarEnabled ? nativeTabBarStyle?.height ?? 44 : undefined
588
631
  }
632
+ nativeTabBarTrailingReservedWidth={
633
+ nativeTabBarEnabled ? nativeTabBarTrailingReservedWidth : 0
634
+ }
589
635
  nativeTabBarContentPaddingHorizontal={
590
636
  nativeTabBarEnabled
591
637
  ? nativeTabBarStyle?.contentPaddingHorizontal ?? 20
@@ -580,6 +580,23 @@ export class CollapsiblePagerView extends React.PureComponent<
580
580
  this.scrollCurrentPageToTop(0, options?.animated ?? true);
581
581
  };
582
582
 
583
+ public scrollHeaderBy = (delta: number) => {
584
+ if (!Number.isFinite(delta)) return;
585
+ const offset = Math.min(
586
+ this.headerHeight,
587
+ Math.max(0, this.sharedHeaderOffset + delta)
588
+ );
589
+ this.scrollCurrentPageToTop(offset, false);
590
+ };
591
+
592
+ public scrollHeaderToOffset = (offset: number) => {
593
+ if (!Number.isFinite(offset)) return;
594
+ this.scrollCurrentPageToTop(
595
+ Math.min(this.headerHeight, Math.max(0, offset)),
596
+ false
597
+ );
598
+ };
599
+
583
600
  public scrollToStickyTop = (options?: Readonly<{ animated?: boolean }>) => {
584
601
  this.scrollCurrentPageToTop(
585
602
  this.headerHeight,
@@ -674,10 +691,10 @@ export class CollapsiblePagerView extends React.PureComponent<
674
691
  )
675
692
  return;
676
693
  this.pageOffsets.set(key, 0);
677
- this.sharedHeaderOffset = offset;
694
+ this.sharedHeaderOffset = Math.min(this.headerHeight, Math.max(0, offset));
678
695
  this.bodyAlignment = { element, key };
679
696
  scrollElementTo(element, {
680
- top: offset,
697
+ top: this.sharedHeaderOffset,
681
698
  behavior: animated ? "smooth" : "instant",
682
699
  });
683
700
  this.syncFallbackHeader();
@@ -67,6 +67,7 @@ export interface NativeProps extends ViewProps {
67
67
  retainedPages?: string;
68
68
  nativeTabBarItems?: string;
69
69
  nativeTabBarHeight?: Double;
70
+ nativeTabBarTrailingReservedWidth?: WithDefault<Double, 0>;
70
71
  nativeTabBarContentPaddingHorizontal?: Double;
71
72
  nativeTabBarItemSpacing?: Double;
72
73
  nativeTabBarFontSize?: Double;
@@ -91,6 +92,14 @@ export interface NativeProps extends ViewProps {
91
92
  type CollapsiblePagerViewNativeType = HostComponent<NativeProps>;
92
93
 
93
94
  export interface NativeCommands {
95
+ scrollHeaderToOffset: (
96
+ viewRef: React.ElementRef<CollapsiblePagerViewNativeType>,
97
+ offset: Double
98
+ ) => void;
99
+ scrollHeaderBy: (
100
+ viewRef: React.ElementRef<CollapsiblePagerViewNativeType>,
101
+ delta: Double
102
+ ) => void;
94
103
  scrollToTop: (
95
104
  viewRef: React.ElementRef<CollapsiblePagerViewNativeType>,
96
105
  target: Int32,
@@ -112,6 +121,8 @@ export interface NativeCommands {
112
121
 
113
122
  export const Commands: NativeCommands = codegenNativeCommands<NativeCommands>({
114
123
  supportedCommands: [
124
+ "scrollHeaderToOffset",
125
+ "scrollHeaderBy",
115
126
  "scrollToTop",
116
127
  "setPage",
117
128
  "setPageWithoutAnimation",