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

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,247 @@
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 testSegmentedNativeTabsTrackFractionalProgressAndReset() {
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 density = context.resources.displayMetrics.density
31
+ fun dp(value: Int) = (value * density).roundToInt()
32
+ lateinit var tab: CollapsiblePagerNativeTabBarView
33
+ lateinit var row: HorizontalScrollView
34
+ lateinit var content: ViewGroup
35
+ lateinit var track: View
36
+ lateinit var thumb: View
37
+ lateinit var buttons: List<TextView>
38
+ fun layout(width: Int = 320, height: Int = 56) {
39
+ tab.measure(View.MeasureSpec.makeMeasureSpec(dp(width), View.MeasureSpec.EXACTLY),
40
+ View.MeasureSpec.makeMeasureSpec(dp(height), View.MeasureSpec.EXACTLY))
41
+ tab.layout(0, 0, dp(width), dp(height))
42
+ }
43
+ try {
44
+ instrumentation.runOnMainSync {
45
+ tab = CollapsiblePagerNativeTabBarView(context)
46
+ activity.setContentView(tab)
47
+ val presses = mutableListOf<String>()
48
+ tab.onItemPress = { _, key -> presses.add(key) }
49
+ tab.updateItemsJSON("""[{"key":"recent","title":"Recents"},""" +
50
+ """{"key":"account","title":"Accounts"},""" +
51
+ """{"key":"book","title":"Address Book"}]""")
52
+ tab.updateStyle(56.0, "segmented", 20.0, 12.0, 0.0, 14.0, null, 2.0, 0.0)
53
+ tab.updateColors(Color.LTGRAY, Color.BLACK, Color.GRAY, Color.WHITE)
54
+ layout()
55
+ row = tab.getChildAt(0) as HorizontalScrollView
56
+ content = row.getChildAt(0) as ViewGroup
57
+ track = content.getChildAt(0)
58
+ thumb = content.getChildAt(1)
59
+ buttons = (0 until content.childCount).map(content::getChildAt).filterIsInstance<TextView>()
60
+ assertEquals(3, buttons.size)
61
+ assertEquals(dp(20), track.left)
62
+ assertTrue(kotlin.math.abs(dp(32) - track.height) <= 1)
63
+ assertEquals(0, row.scrollX)
64
+ assertTrue(kotlin.math.abs(buttons[0].width - buttons[1].width) <= 1)
65
+ tab.setProgress(0f)
66
+ val firstX = thumb.left
67
+ tab.setProgress(1f)
68
+ val secondX = thumb.left
69
+ tab.setProgress(0.25f)
70
+ assertTrue(kotlin.math.abs(thumb.left - (firstX + (secondX - firstX) * 0.25f)) <= 1)
71
+ tab.setProgress(0.5f)
72
+ assertTrue(kotlin.math.abs(thumb.left - (firstX + secondX) / 2f) <= 1)
73
+ tab.setProgress(0f)
74
+ assertEquals(firstX, thumb.left)
75
+ buttons[1].performClick()
76
+ assertEquals(listOf("account"), presses)
77
+
78
+ tab.layoutDirection = View.LAYOUT_DIRECTION_RTL
79
+ }
80
+ instrumentation.waitForIdleSync()
81
+ instrumentation.runOnMainSync {
82
+ layout()
83
+ assertEquals(View.LAYOUT_DIRECTION_RTL, row.layoutDirection)
84
+ val rtlFirst = buttons[0].layoutParams as android.widget.FrameLayout.LayoutParams
85
+ val rtlSecond = buttons[1].layoutParams as android.widget.FrameLayout.LayoutParams
86
+ assertTrue(rtlFirst.leftMargin > rtlSecond.leftMargin)
87
+ val rtlGeometry = "rowDir=${row.layoutDirection} contentDir=${content.layoutDirection} " +
88
+ buttons.mapIndexed { index, button ->
89
+ val params = button.layoutParams as android.widget.FrameLayout.LayoutParams
90
+ "$index:${button.left}-${button.right} margin=${params.leftMargin}/${params.rightMargin} gravity=${params.gravity}"
91
+ }.joinToString(" ")
92
+ assertTrue(rtlGeometry, buttons[0].left >= buttons[1].right)
93
+ assertEquals(dp(20), track.left)
94
+ tab.setProgress(0f)
95
+ val rtlFirstX = thumb.left
96
+ tab.setProgress(1f)
97
+ val rtlSecondX = thumb.left
98
+ assertTrue(rtlFirstX > rtlSecondX)
99
+ tab.setProgress(0.5f)
100
+ assertTrue(kotlin.math.abs(thumb.left - (rtlFirstX + rtlSecondX) / 2f) <= 1)
101
+ tab.layoutDirection = View.LAYOUT_DIRECTION_LTR
102
+ }
103
+ instrumentation.waitForIdleSync()
104
+ instrumentation.runOnMainSync {
105
+ layout()
106
+ assertTrue(buttons[0].right <= buttons[1].left)
107
+ tab.updateItemsJSON("""[{"key":"recent","title":"Recents (8)"},""" +
108
+ """{"key":"account","title":"Accounts"}]""")
109
+ layout()
110
+ val twoButtons = (0 until content.childCount).map(content::getChildAt).filterIsInstance<TextView>()
111
+ assertEquals(2, twoButtons.size)
112
+ assertTrue(kotlin.math.abs(twoButtons[0].width - track.width / 2) <= 1)
113
+ layout(width = 30)
114
+ assertEquals(dp(15), track.left)
115
+ assertEquals(0, track.width)
116
+ tab.updateStyle(56.0, "segmented", 20.0, 40.0, 0.0, 14.0, null, 2.0, 0.0)
117
+ layout(width = 30)
118
+ assertTrue(kotlin.math.abs(dp(28) - track.top) <= 1)
119
+ assertEquals(0, track.height)
120
+ assertEquals(View.GONE, thumb.visibility)
121
+ tab.updateItemsJSON("[]")
122
+ assertEquals(View.GONE, tab.visibility)
123
+ tab.updateItemsJSON("""[{"key":"recent","title":"Recents"}]""")
124
+ tab.updateStyle(44.0, "underline", 20.0, 0.0, 8.0, 16.0, null, 2.0, 0.0)
125
+ layout(height = 44)
126
+ assertEquals(View.GONE, track.visibility)
127
+ assertEquals(dp(2), thumb.height)
128
+ }
129
+ } finally {
130
+ instrumentation.runOnMainSync { activity.finish() }
131
+ }
132
+ }
133
+
134
+ fun testTabViewportToolbarHitAndResetInBothDirections() {
135
+ val context = instrumentation.targetContext
136
+ assertTrue(context.applicationInfo.flags and ApplicationInfo.FLAG_SUPPORTS_RTL != 0)
137
+ val activity = instrumentation.startActivitySync(
138
+ Intent(context, CollapsiblePagerHitTestActivity::class.java)
139
+ .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
140
+ ) as CollapsiblePagerHitTestActivity
141
+ val toolbarPresses = AtomicInteger()
142
+ val tabPresses = AtomicInteger()
143
+ try {
144
+ instrumentation.runOnMainSync {
145
+ val density = context.resources.displayMetrics.density
146
+ fun dp(value: Int) = (value * density).roundToInt()
147
+ val width = dp(320)
148
+ val height = dp(500)
149
+ val reserved = dp(64)
150
+ val tabHeight = dp(44)
151
+ val host = CollapsiblePagerHost(context)
152
+ activity.setContentView(host)
153
+ assertTrue(host.isAttachedToWindow)
154
+ val sticky = ReactViewGroup(context).apply { pointerEvents = PointerEvents.BOX_NONE }
155
+ val fade = View(context).apply { setBackgroundColor(Color.RED) }
156
+ val toolbar = View(context).apply {
157
+ setBackgroundColor(Color.BLUE)
158
+ isClickable = true
159
+ }
160
+ sticky.addView(fade)
161
+ sticky.addView(toolbar)
162
+ host.addReactChild(View(context), 0)
163
+ host.addReactChild(sticky, 1)
164
+ host.headerHeightPx = 0
165
+ host.stickyHeaderHeightPx = tabHeight
166
+ host.nativeTabBarBackgroundColor = Color.GREEN
167
+ host.applyNativeHeaderStyle()
168
+ host.updateNativeTabBarItems(
169
+ """[{"key":"a","title":"Overview"},{"key":"b","title":"Ethereum"},""" +
170
+ """{"key":"c","title":"BNB Chain"},{"key":"d","title":"Polygon"}]""",
171
+ )
172
+ val tab = (0 until host.childCount).map(host::getChildAt)
173
+ .first { it.javaClass.simpleName == "CollapsiblePagerNativeTabBarView" } as ViewGroup
174
+ val row = tab.getChildAt(0) as HorizontalScrollView
175
+ toolbar.setOnClickListener { toolbarPresses.incrementAndGet() }
176
+ host.onNativeTabPress = { _, _ -> tabPresses.incrementAndGet() }
177
+
178
+ fun layout() {
179
+ host.measure(View.MeasureSpec.makeMeasureSpec(width, View.MeasureSpec.EXACTLY),
180
+ View.MeasureSpec.makeMeasureSpec(height, View.MeasureSpec.EXACTLY))
181
+ host.layout(0, 0, width, height)
182
+ }
183
+ fun tap(x: Int) {
184
+ val time = android.os.SystemClock.uptimeMillis()
185
+ val down = MotionEvent.obtain(time, time, MotionEvent.ACTION_DOWN,
186
+ x.toFloat(), (tabHeight / 2).toFloat(), 0)
187
+ val up = MotionEvent.obtain(time, time + 20, MotionEvent.ACTION_UP,
188
+ x.toFloat(), (tabHeight / 2).toFloat(), 0)
189
+ host.dispatchTouchEvent(down)
190
+ host.dispatchTouchEvent(up)
191
+ down.recycle()
192
+ up.recycle()
193
+ }
194
+ fun colorAt(x: Int): Int {
195
+ val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
196
+ host.draw(Canvas(bitmap))
197
+ val color = bitmap.getPixel(x, tabHeight / 2)
198
+ bitmap.recycle()
199
+ return color
200
+ }
201
+
202
+ host.nativeTabBarTrailingReservedWidth = 64.0
203
+ layout()
204
+ fade.layout(width - reserved - dp(20), 0, width - reserved, tabHeight)
205
+ toolbar.layout(width - reserved, 0, width, tabHeight)
206
+ assertEquals(width - reserved, tab.width)
207
+ assertEquals(0, tab.left)
208
+ assertEquals(width, sticky.width)
209
+ assertEquals(Color.RED, colorAt(width - reserved - dp(10)))
210
+ tap(width - dp(10))
211
+ tap(dp(28))
212
+ host.updateNativeTabProgress(3, 0f)
213
+ val content = row.getChildAt(0) as ViewGroup
214
+ val lastTab = (0 until content.childCount).map(content::getChildAt)
215
+ .filterIsInstance<TextView>().last()
216
+ assertTrue(lastTab.left >= row.scrollX - dp(1))
217
+ assertTrue(lastTab.right <= row.scrollX + row.width + dp(1))
218
+
219
+ host.setPagerLayoutDirection(View.LAYOUT_DIRECTION_RTL)
220
+ host.nativeTabBarTrailingReservedWidth = 64.0
221
+ layout()
222
+ assertEquals(View.LAYOUT_DIRECTION_RTL, tab.layoutDirection)
223
+ toolbar.layout(0, 0, reserved, tabHeight)
224
+ fade.layout(reserved, 0, reserved + dp(20), tabHeight)
225
+ assertEquals(reserved, tab.left)
226
+ assertEquals(width, tab.right)
227
+ assertEquals(Color.RED, colorAt(reserved + dp(10)))
228
+ tap(dp(10))
229
+
230
+ host.nativeTabBarTrailingReservedWidth = 500.0
231
+ layout()
232
+ assertEquals(0, tab.width)
233
+ host.nativeTabBarTrailingReservedWidth = 0.0
234
+ layout()
235
+ assertEquals(0, tab.left)
236
+ assertEquals(width, tab.width)
237
+ assertTrue(host.indexOfChild(sticky) < host.indexOfChild(tab))
238
+ }
239
+ instrumentation.waitForIdleSync()
240
+ assertEquals(2, toolbarPresses.get())
241
+ assertEquals(1, tabPresses.get())
242
+ } finally {
243
+ instrumentation.runOnMainSync { activity.finish() }
244
+ instrumentation.waitForIdleSync()
245
+ }
246
+ }
247
+ }
@@ -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
@@ -416,7 +432,9 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
416
432
  fun applyNativeHeaderStyle() {
417
433
  nativeTabBarView.updateStyle(
418
434
  nativeTabBarHeight,
435
+ nativeTabBarVariant,
419
436
  nativeTabBarContentPaddingHorizontal,
437
+ nativeTabBarContentPaddingVertical,
420
438
  nativeTabBarItemSpacing,
421
439
  nativeTabBarFontSize,
422
440
  nativeTabBarFontFamily,
@@ -442,6 +460,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
442
460
  private fun bringNativeHeadersToFront() {
443
461
  nativeTabBarView.bringToFront()
444
462
  nativeSubHeaderView.bringToFront()
463
+ if (nativeTabBarTrailingReservedWidth > 0.0) stickyHeaderView?.bringToFront()
445
464
  }
446
465
 
447
466
  fun addReactChild(child: View, index: Int) {
@@ -689,7 +708,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
689
708
 
690
709
  private fun layoutPagerIfRequested() {
691
710
  val recycler = pager.getChildAt(0) as? RecyclerView ?: return
692
- if (!recycler.isLayoutRequested || recycler.isComputingLayout || recycler.width <= 0 || recycler.height <= 0) return
711
+ if (!recycler.isLayoutRequested || recycler.isLayoutSuppressed || recycler.isComputingLayout || recycler.width <= 0 || recycler.height <= 0) return
693
712
  // OneKey patch: a non-animated page command queues RecyclerView layout, but RN
694
713
  // can stop that request after navigation. Drain it before showing the page.
695
714
  recycler.measure(
@@ -712,6 +731,9 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
712
731
  layout(left, top, right, bottom)
713
732
  }
714
733
 
734
+ private fun isPagerRecyclerLayoutSuppressed(): Boolean =
735
+ (pager.getChildAt(0) as? RecyclerView)?.isLayoutSuppressed == true
736
+
715
737
  override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
716
738
  val width = MeasureSpec.getSize(widthMeasureSpec)
717
739
  val height = MeasureSpec.getSize(heightMeasureSpec)
@@ -722,10 +744,12 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
722
744
  return
723
745
  }
724
746
  val pagerHeight = height + headerHeightPx
725
- pager.measure(
726
- MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
727
- MeasureSpec.makeMeasureSpec(pagerHeight, MeasureSpec.EXACTLY),
728
- )
747
+ if (!isPagerRecyclerLayoutSuppressed()) {
748
+ pager.measure(
749
+ MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
750
+ MeasureSpec.makeMeasureSpec(pagerHeight, MeasureSpec.EXACTLY),
751
+ )
752
+ }
729
753
  headerView?.measure(
730
754
  MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
731
755
  MeasureSpec.makeMeasureSpec(headerHeightPx, MeasureSpec.EXACTLY),
@@ -737,8 +761,9 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
737
761
  val nativeTabHeight = if (nativeTabBarView.visibility == View.VISIBLE) {
738
762
  nativeTabBarView.preferredHeightPx()
739
763
  } else 0
764
+ val tabReservedWidth = nativeTabReservationPx(width)
740
765
  nativeTabBarView.measure(
741
- MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
766
+ MeasureSpec.makeMeasureSpec(width - tabReservedWidth, MeasureSpec.EXACTLY),
742
767
  MeasureSpec.makeMeasureSpec(nativeTabHeight, MeasureSpec.EXACTLY),
743
768
  )
744
769
  val nativeSubHeaderHeight = if (nativeSubHeaderView.visibility == View.VISIBLE) {
@@ -757,7 +782,9 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
757
782
  // RN ancestors allow overflow; keep translated header content inside this viewport.
758
783
  clipBounds = Rect(0, 0, width, height)
759
784
  if (width <= 0 || height <= 0) return
760
- pager.layout(0, 0, width, height + headerHeightPx)
785
+ if (!isPagerRecyclerLayoutSuppressed()) {
786
+ pager.layout(0, 0, width, height + headerHeightPx)
787
+ }
761
788
  headerView?.layout(0, 0, width, headerHeightPx)
762
789
  stickyHeaderView?.layout(
763
790
  0,
@@ -766,7 +793,12 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
766
793
  headerHeightPx + stickyHeaderHeightPx,
767
794
  )
768
795
  val nativeTabHeight = nativeTabBarView.measuredHeight
769
- nativeTabBarView.layout(0, headerHeightPx, width, headerHeightPx + nativeTabHeight)
796
+ val tabReservedWidth = nativeTabReservationPx(width)
797
+ val tabLeft = if (nativeTabBarView.layoutDirection == View.LAYOUT_DIRECTION_RTL) {
798
+ tabReservedWidth
799
+ } else 0
800
+ nativeTabBarView.layout(tabLeft, headerHeightPx, tabLeft + width - tabReservedWidth,
801
+ headerHeightPx + nativeTabHeight)
770
802
  nativeSubHeaderView.layout(
771
803
  0,
772
804
  headerHeightPx + nativeTabHeight,
@@ -828,7 +860,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
828
860
  }
829
861
  if (!reached()) {
830
862
  if (!isAttachedToWindow || !view.isAttachedToWindow || width <= 0 || height <= 0 ||
831
- view.width <= 0 || view.height <= 0 || isInLayout || view.isInLayout || view.isComputingLayout ||
863
+ view.width <= 0 || view.height <= 0 || isInLayout || view.isInLayout || view.isLayoutSuppressed || view.isComputingLayout ||
832
864
  pageKey(index) != key || primaryScrollableForPage(index) !== view || !isCurrentPageRestore(key, request)) return false
833
865
  val previousSynthetic = syntheticPageRestore
834
866
  syntheticPageRestore = true
@@ -987,13 +1019,33 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
987
1019
  if (!settled) return
988
1020
  pageOffsets[request.key] = 0
989
1021
  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)
1022
+ setHeaderOffset(request.logicalOffset.coerceIn(0, headerHeightPx))
1023
+ offsetDiag("pager-top-complete-offset=${request.logicalOffset}-token=${request.token}", view, request.key)
992
1024
  cancelPagerTop()
993
1025
  }
994
1026
 
995
1027
  internal fun scrollToTop(target: Int, animated: Boolean) {
996
- if (target !in 0..1 || pager.scrollState != ViewPager2.SCROLL_STATE_IDLE || transitionHeaderOffsets != null ||
1028
+ if (target !in 0..1) return
1029
+ scrollHeaderToOffset(if (target == 1) headerHeightPx else 0, animated)
1030
+ }
1031
+
1032
+ internal fun scrollHeaderBy(delta: Double) {
1033
+ if (!delta.isFinite()) return
1034
+ val density = resources.displayMetrics.density.toDouble()
1035
+ val maxDip = headerHeightPx / density
1036
+ val deltaPx = (delta.coerceIn(-maxDip, maxDip) * density).roundToInt()
1037
+ scrollHeaderToOffset((headerOffsetPx.toLong() + deltaPx).coerceIn(0, headerHeightPx.toLong()).toInt(), false)
1038
+ }
1039
+
1040
+ internal fun scrollHeaderToOffset(offset: Double) {
1041
+ if (!offset.isFinite()) return
1042
+ val density = resources.displayMetrics.density.toDouble()
1043
+ val targetPx = (offset.coerceIn(0.0, headerHeightPx / density) * density).roundToInt()
1044
+ scrollHeaderToOffset(targetPx, false)
1045
+ }
1046
+
1047
+ private fun scrollHeaderToOffset(logicalOffset: Int, animated: Boolean) {
1048
+ if (pager.scrollState != ViewPager2.SCROLL_STATE_IDLE || transitionHeaderOffsets != null ||
997
1049
  syntheticPageRestore || selectedPage !in 0 until adapter.itemCount || !isAttachedToWindow) return
998
1050
  val view = observedRecyclerView ?: observedOwnedScroller ?: observedNativeScroller ?: return
999
1051
  val key = currentPageKey()
@@ -1020,11 +1072,12 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
1020
1072
  if (view is RecyclerView) deferredRecyclerGeometry.remove(view)
1021
1073
  if (view is ScrollView) deferredScrollerGeometry.remove(view)
1022
1074
  pageOffsets[key] = 0
1023
- val request = PagerTopRequest(view, key, selectedPage, attachmentGeneration, target, ++pagerTopToken)
1075
+ val targetOffset = logicalOffset.coerceIn(0, headerHeightPx)
1076
+ val request = PagerTopRequest(view, key, selectedPage, attachmentGeneration, targetOffset, ++pagerTopToken)
1024
1077
  pagerTopRequest = request
1025
1078
  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)
1079
+ setHeaderOffset(targetOffset)
1080
+ offsetDiag("pager-top-begin-offset=$targetOffset-animated=$animated-token=${request.token}", view, key)
1028
1081
  dispatchingPagerTop = true
1029
1082
  try {
1030
1083
  when (view) {
@@ -1033,7 +1086,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
1033
1086
  if (animated && !recyclerIsAtBodyTop(view)) view.smoothScrollToPosition(0)
1034
1087
  else if (manager != null && manager.orientation == RecyclerView.VERTICAL) {
1035
1088
  manager.scrollToPositionWithOffset(0, 0)
1036
- if (view.width > 0 && view.height > 0 && !isInLayout && !view.isInLayout && !view.isComputingLayout) {
1089
+ if (view.width > 0 && view.height > 0 && !isInLayout && !view.isInLayout && !view.isLayoutSuppressed && !view.isComputingLayout) {
1037
1090
  view.measure(MeasureSpec.makeMeasureSpec(view.width, MeasureSpec.EXACTLY), MeasureSpec.makeMeasureSpec(view.height, MeasureSpec.EXACTLY))
1038
1091
  view.layout(view.left, view.top, view.right, view.bottom)
1039
1092
  }
@@ -1638,7 +1691,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
1638
1691
  if (anchorOffset != null) manager?.scrollToPositionWithOffset(first, anchorOffset)
1639
1692
  // React Native can stop this nested requestLayout at its React-owned parent.
1640
1693
  // Commit the changed inset before drawing instead of waiting for a data update.
1641
- if (recycler.width > 0 && recycler.height > 0 && !recycler.isComputingLayout) {
1694
+ if (recycler.width > 0 && recycler.height > 0 && !recycler.isLayoutSuppressed && !recycler.isComputingLayout) {
1642
1695
  recycler.forceLayout()
1643
1696
  recycler.measure(
1644
1697
  MeasureSpec.makeMeasureSpec(recycler.width, MeasureSpec.EXACTLY),
@@ -2012,14 +2065,16 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
2012
2065
  }
2013
2066
  }
2014
2067
 
2015
- private fun headerRegionAt(y: Float): String? {
2068
+ private fun headerRegionAt(x: Float, y: Float): String? {
2016
2069
  val headerBottom = headerHeightPx - headerOffsetPx
2017
2070
  val stickyBottom = headerHeightPx + stickyHeaderHeightPx - headerOffsetPx
2018
2071
  return when {
2019
2072
  y < 0 || y >= stickyBottom -> null
2020
2073
  y < headerBottom -> "header"
2021
2074
  y < headerBottom + nativeTabBarView.measuredHeight &&
2022
- nativeTabBarView.visibility == View.VISIBLE -> "primary-tab"
2075
+ nativeTabBarView.visibility == View.VISIBLE ->
2076
+ if (x >= nativeTabBarView.left && x < nativeTabBarView.right) "primary-tab"
2077
+ else "sticky-controls"
2023
2078
  y < headerBottom + nativeTabBarView.measuredHeight + nativeSubHeaderView.measuredHeight &&
2024
2079
  nativeSubHeaderView.visibility == View.VISIBLE -> "secondary-header"
2025
2080
  else -> "sticky-controls"
@@ -2128,7 +2183,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
2128
2183
  pressCancelled = false
2129
2184
  nativeGestureStarted = false
2130
2185
  nestedNativeGestureStarted = false
2131
- headerTouchRegion = headerRegionAt(event.y) ?: "none"
2186
+ headerTouchRegion = headerRegionAt(event.x, event.y) ?: "none"
2132
2187
  headerTouchActive = headerTouchRegion != "none"
2133
2188
  if (!headerTouchActive) return super.dispatchTouchEvent(event)
2134
2189
  headerDownX = event.x
@@ -65,7 +65,11 @@ private class CollapsiblePagerHorizontalItemsView(
65
65
  ) : HorizontalScrollView(context) {
66
66
  private val density = resources.displayMetrics.density
67
67
  private val content = FrameLayout(context)
68
+ private val track = View(context)
68
69
  private val indicator = View(context)
70
+ private val trackBackground = GradientDrawable().apply {
71
+ shape = GradientDrawable.RECTANGLE
72
+ }
69
73
  private val indicatorBackground = GradientDrawable().apply {
70
74
  shape = GradientDrawable.RECTANGLE
71
75
  }
@@ -75,6 +79,8 @@ private class CollapsiblePagerHorizontalItemsView(
75
79
  private var selectedKey = ""
76
80
  private var rowHeightPx = dp(if (showsProgressIndicator) 44.0 else 42.0)
77
81
  private var contentPaddingPx = dp(20.0)
82
+ private var contentPaddingVerticalPx = 0
83
+ private var segmented = false
78
84
  private var itemSpacingPx = dp(8.0)
79
85
  private var fontSize = if (showsProgressIndicator) 16.0 else 14.0
80
86
  private var fontFamily: String? = null
@@ -96,7 +102,9 @@ private class CollapsiblePagerHorizontalItemsView(
96
102
  isFillViewport = true
97
103
  overScrollMode = OVER_SCROLL_NEVER
98
104
  clipToPadding = false
105
+ track.background = trackBackground
99
106
  indicator.background = indicatorBackground
107
+ content.addView(track, FrameLayout.LayoutParams(0, 0))
100
108
  content.addView(indicator, FrameLayout.LayoutParams(0, 0))
101
109
  addView(
102
110
  content,
@@ -149,17 +157,24 @@ private class CollapsiblePagerHorizontalItemsView(
149
157
  fontFamily: String?,
150
158
  indicatorHeight: Double = 0.0,
151
159
  indicatorBottom: Double = 0.0,
160
+ variant: String = "underline",
161
+ contentPaddingVertical: Double = 0.0,
152
162
  ) {
153
163
  rowHeightPx = max(1, dp(rowHeight))
154
164
  contentPaddingPx = max(0, dp(contentPadding))
165
+ contentPaddingVerticalPx = max(0, dp(contentPaddingVertical))
166
+ val wasSegmented = segmented
167
+ segmented = showsProgressIndicator && variant == "segmented"
155
168
  itemSpacingPx = max(0, dp(itemSpacing))
156
169
  this.fontSize = max(1.0, fontSize)
157
170
  this.fontFamily = fontFamily
158
171
  if (showsProgressIndicator) {
159
172
  indicatorHeightPx = max(0, dp(indicatorHeight))
160
173
  indicatorBottomPx = max(0, dp(indicatorBottom))
161
- indicatorBackground.cornerRadius = indicatorHeightPx / 2f
174
+ indicatorBackground.cornerRadius = if (segmented) dp(14.0).toFloat() else indicatorHeightPx / 2f
162
175
  }
176
+ track.visibility = if (segmented) VISIBLE else GONE
177
+ if (segmented && !wasSegmented) scrollTo(0, 0)
163
178
  applyTypeface()
164
179
  requestLayout()
165
180
  }
@@ -171,8 +186,9 @@ private class CollapsiblePagerHorizontalItemsView(
171
186
  selectedBackgroundColor: Int,
172
187
  indicatorColor: Int,
173
188
  ) {
174
- setBackgroundColor(backgroundColor)
175
- content.setBackgroundColor(backgroundColor)
189
+ setBackgroundColor(if (segmented) Color.TRANSPARENT else backgroundColor)
190
+ content.setBackgroundColor(if (segmented) Color.TRANSPARENT else backgroundColor)
191
+ trackBackground.setColor(backgroundColor)
176
192
  this.activeTextColor = activeTextColor
177
193
  this.inactiveTextColor = inactiveTextColor
178
194
  this.selectedBackgroundColor = selectedBackgroundColor
@@ -213,6 +229,46 @@ private class CollapsiblePagerHorizontalItemsView(
213
229
  }
214
230
  applyTypeface()
215
231
  itemFrames.clear()
232
+ if (segmented) {
233
+ val actualPaddingHorizontal = min(contentPaddingPx, width / 2)
234
+ val actualPaddingVertical = min(contentPaddingVerticalPx, height / 2)
235
+ val trackLeft = actualPaddingHorizontal
236
+ val trackRight = max(trackLeft, width - contentPaddingPx)
237
+ val trackTop = actualPaddingVertical
238
+ val trackBottom = max(trackTop, height - contentPaddingVerticalPx)
239
+ val trackWidth = trackRight - trackLeft
240
+ val trackHeight = trackBottom - trackTop
241
+ trackBackground.cornerRadius = trackHeight / 2f
242
+ indicatorBackground.cornerRadius = max(0, trackHeight - dp(4.0)) / 2f
243
+ track.layoutParams = FrameLayout.LayoutParams(trackWidth, trackHeight).apply {
244
+ gravity = Gravity.TOP or Gravity.LEFT
245
+ leftMargin = trackLeft
246
+ topMargin = trackTop
247
+ }
248
+ buttons.forEachIndexed { index, button ->
249
+ val count = buttons.size
250
+ val first = trackLeft + trackWidth * index / count
251
+ val last = trackLeft + trackWidth * (index + 1) / count
252
+ val start = if (layoutDirection == View.LAYOUT_DIRECTION_RTL) trackRight - (last - trackLeft) else first
253
+ val end = if (layoutDirection == View.LAYOUT_DIRECTION_RTL) trackRight - (first - trackLeft) else last
254
+ val buttonWidth = max(0, end - start)
255
+ val thumbInset = dp(2.0)
256
+ itemFrames.add(intArrayOf(start, buttonWidth, start + thumbInset, max(0, buttonWidth - 2 * thumbInset)))
257
+ button.layoutParams = FrameLayout.LayoutParams(buttonWidth, trackHeight).apply {
258
+ gravity = Gravity.TOP or Gravity.LEFT
259
+ leftMargin = start
260
+ topMargin = trackTop
261
+ }
262
+ }
263
+ content.layoutParams = LayoutParams(width, height)
264
+ content.measure(
265
+ MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
266
+ MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY),
267
+ )
268
+ setMeasuredDimension(width, height)
269
+ return
270
+ }
271
+ track.layoutParams = FrameLayout.LayoutParams(0, 0)
216
272
  var x = contentPaddingPx
217
273
  buttons.forEachIndexed { index, button ->
218
274
  val textWidth = ceil(button.paint.measureText(button.text.toString()).toDouble()).toInt()
@@ -260,6 +316,9 @@ private class CollapsiblePagerHorizontalItemsView(
260
316
  return super.onTouchEvent(event)
261
317
  }
262
318
 
319
+ override fun onInterceptTouchEvent(event: MotionEvent): Boolean =
320
+ if (segmented) false else super.onInterceptTouchEvent(event)
321
+
263
322
  private fun updatePresentation() {
264
323
  if (buttons.isEmpty() || itemFrames.size != buttons.size) return
265
324
  val selectedIndex: Int
@@ -275,14 +334,16 @@ private class CollapsiblePagerHorizontalItemsView(
275
334
  val interpolatedIndicatorWidth = from[3] + (to[3] - from[3]) * fraction
276
335
  val indicatorX = interpolatedIndicatorX.roundToInt()
277
336
  val indicatorWidth = interpolatedIndicatorWidth.roundToInt()
278
- val indicatorTop = max(0, measuredHeight - indicatorBottomPx - indicatorHeightPx)
337
+ val actualPaddingVertical = min(contentPaddingVerticalPx, measuredHeight / 2)
338
+ val indicatorHeight = if (segmented) max(0, measuredHeight - 2 * actualPaddingVertical - dp(4.0)) else indicatorHeightPx
339
+ val indicatorTop = if (segmented) actualPaddingVertical + dp(2.0) else max(0, measuredHeight - indicatorBottomPx - indicatorHeightPx)
279
340
  indicator.layout(
280
341
  indicatorX,
281
342
  indicatorTop,
282
343
  indicatorX + indicatorWidth,
283
- indicatorTop + indicatorHeightPx,
344
+ indicatorTop + indicatorHeight,
284
345
  )
285
- indicator.visibility = if (indicatorHeightPx > 0) VISIBLE else GONE
346
+ indicator.visibility = if (indicatorHeight > 0) VISIBLE else GONE
286
347
  scrollTargetCenterX = interpolatedIndicatorX + interpolatedIndicatorWidth / 2f
287
348
  selectedIndex = clamped.roundToInt()
288
349
  buttons.forEachIndexed { index, button ->
@@ -304,7 +365,7 @@ private class CollapsiblePagerHorizontalItemsView(
304
365
  }
305
366
  }
306
367
 
307
- if (centerSelectionWhenIdle && !isTouchDragging && width > 0 && selectedIndex in itemFrames.indices) {
368
+ if (centerSelectionWhenIdle && !segmented && !isTouchDragging && width > 0 && selectedIndex in itemFrames.indices) {
308
369
  val desired = (scrollTargetCenterX - width / 2f).roundToInt()
309
370
  .coerceIn(0, max(0, content.measuredWidth - width))
310
371
  scrollTo(desired, 0)
@@ -335,7 +396,9 @@ internal class CollapsiblePagerNativeTabBarView(context: Context) : FrameLayout(
335
396
 
336
397
  fun updateStyle(
337
398
  height: Double,
399
+ variant: String,
338
400
  contentPadding: Double,
401
+ contentPaddingVertical: Double,
339
402
  itemSpacing: Double,
340
403
  fontSize: Double,
341
404
  fontFamily: String?,
@@ -351,6 +414,8 @@ internal class CollapsiblePagerNativeTabBarView(context: Context) : FrameLayout(
351
414
  fontFamily,
352
415
  indicatorHeight,
353
416
  indicatorBottom,
417
+ variant,
418
+ contentPaddingVertical,
354
419
  )
355
420
  requestLayout()
356
421
  }