@onekeyfe/react-native-pager-view 3.0.119 → 3.0.120

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.
@@ -226,6 +226,7 @@ dependencies {
226
226
  implementation "com.facebook.react:react-android"
227
227
  implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
228
228
  implementation 'androidx.viewpager2:viewpager2:1.1.0'
229
+ implementation project(":onekeyfe_react-native-native-logger")
229
230
  }
230
231
 
231
232
  if (isNewArchitectureEnabled()) {
@@ -2,9 +2,13 @@ package com.reactnativepagerview
2
2
 
3
3
  import android.content.Context
4
4
  import android.graphics.Rect
5
+ import android.os.SystemClock
6
+ import android.view.MotionEvent
5
7
  import android.view.View
8
+ import android.view.ViewConfiguration
6
9
  import android.view.ViewGroup
7
10
  import android.view.ViewTreeObserver
11
+ import android.widget.HorizontalScrollView
8
12
  // OneKey patch: FrameLayout is inherited through NestedScrollableHost.
9
13
  // import android.widget.FrameLayout
10
14
  import androidx.core.view.NestedScrollingParent3
@@ -13,6 +17,8 @@ import androidx.core.view.ViewCompat
13
17
  import androidx.recyclerview.widget.LinearLayoutManager
14
18
  import androidx.recyclerview.widget.RecyclerView
15
19
  import androidx.viewpager2.widget.ViewPager2
20
+ import com.facebook.react.uimanager.events.NativeGestureUtil
21
+ import com.margelo.nitro.nativelogger.OneKeyLog
16
22
  import java.util.WeakHashMap
17
23
  import kotlin.math.max
18
24
  import kotlin.math.min
@@ -72,6 +78,13 @@ internal class CollapsiblePagerAdapter : RecyclerView.Adapter<ViewPagerViewHolde
72
78
  // OneKey patch: Match the standard pager's nested horizontal gesture host.
73
79
  // Original: class CollapsiblePagerHost(context: Context) : FrameLayout(context), NestedScrollingParent3 {
74
80
  class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), NestedScrollingParent3 {
81
+ private enum class HeaderGestureOwner {
82
+ NONE,
83
+ LIST,
84
+ HORIZONTAL_CHILD,
85
+ HEADER_GUARD,
86
+ }
87
+
75
88
  private data class RecyclerPadding(
76
89
  var left: Int,
77
90
  var top: Int,
@@ -83,6 +96,8 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
83
96
 
84
97
  val pager = ViewPager2(context)
85
98
  internal val adapter = CollapsiblePagerAdapter()
99
+ private val nativeTabBarView = CollapsiblePagerNativeTabBarView(context)
100
+ private val nativeSubHeaderView = CollapsiblePagerNativeSubHeaderView(context)
86
101
  private val logicalChildren = ArrayList<View>()
87
102
  private val nestedScrollingParentHelper = NestedScrollingParentHelper(this)
88
103
  private val originalRecyclerPadding = WeakHashMap<RecyclerView, RecyclerPadding>()
@@ -91,8 +106,20 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
91
106
  private var observedRecyclerView: RecyclerView? = null
92
107
  private var observedScrollListener: RecyclerView.OnScrollListener? = null
93
108
  private var attachmentGeneration = 0
109
+ private val touchSlopPx = ViewConfiguration.get(context).scaledTouchSlop
110
+ private val hostIdentity = System.identityHashCode(this)
94
111
  private var headerView: View? = null
95
112
  private var stickyHeaderView: View? = null
113
+ private var headerTouchActive = false
114
+ private var headerTouchRegion = "none"
115
+ private var headerGestureOwner = HeaderGestureOwner.NONE
116
+ private var headerDownX = 0f
117
+ private var headerDownY = 0f
118
+ private var headerDownEvent: MotionEvent? = null
119
+ private var headerHasHorizontalChild = false
120
+ private var forwardedRecycler: RecyclerView? = null
121
+ private var nativeGestureStarted = false
122
+ private var pressCancelled = false
96
123
  private val pageContentLayoutListener = ViewTreeObserver.OnPreDrawListener {
97
124
  if (width > 0 && height > 0 && isShown) layoutPagerIfRequested()
98
125
  // Fabric can mount a retained list without another Android layout pass.
@@ -128,6 +155,22 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
128
155
  var pageKeys: List<String> = emptyList()
129
156
  var retainedPages: String = "[]"
130
157
  var onHeaderOffsetChanged: (() -> Unit)? = null
158
+ var nativeSmoothHeaderScrollEnabled = false
159
+ var onNativeTabPress: ((Int, String) -> Unit)? = null
160
+ var onNativeSubHeaderPress: ((Int, String) -> Unit)? = null
161
+
162
+ var nativeTabBarHeight = 44.0
163
+ var nativeTabBarContentPaddingHorizontal = 20.0
164
+ var nativeTabBarItemSpacing = 8.0
165
+ var nativeTabBarFontSize = 16.0
166
+ var nativeTabBarFontFamily: String? = null
167
+ var nativeTabBarBackgroundColor = android.graphics.Color.TRANSPARENT
168
+ var nativeTabBarActiveTextColor = android.graphics.Color.BLACK
169
+ var nativeTabBarInactiveTextColor = android.graphics.Color.GRAY
170
+ var nativeTabBarIndicatorColor = android.graphics.Color.BLACK
171
+ var nativeTabBarIndicatorHeight = 2.0
172
+ var nativeTabBarIndicatorBottom = 0.0
173
+ var nativeSubHeaderSelectedBackgroundColor = android.graphics.Color.TRANSPARENT
131
174
 
132
175
  init {
133
176
  isSaveEnabled = false
@@ -139,6 +182,88 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
139
182
  pager,
140
183
  LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT),
141
184
  )
185
+ nativeTabBarView.onItemPress = { index, key -> onNativeTabPress?.invoke(index, key) }
186
+ nativeSubHeaderView.onItemPress = { index, key -> onNativeSubHeaderPress?.invoke(index, key) }
187
+ super.addView(nativeTabBarView)
188
+ super.addView(nativeSubHeaderView)
189
+ applyNativeHeaderStyle()
190
+ log("host-init generation=$attachmentGeneration")
191
+ }
192
+
193
+ private fun log(message: String) {
194
+ OneKeyLog.debug("CollapsiblePager", "host=$hostIdentity $message")
195
+ }
196
+
197
+ private fun outerPagerIndex(): Int {
198
+ var ancestor = parent as? View
199
+ while (ancestor != null) {
200
+ if (ancestor is ViewPager2) return ancestor.currentItem
201
+ ancestor = ancestor.parent as? View
202
+ }
203
+ return -1
204
+ }
205
+
206
+ fun updateNativeTabBarItems(value: String?) {
207
+ nativeTabBarView.updateItemsJSON(value)
208
+ bringNativeHeadersToFront()
209
+ updateHeaderLayout()
210
+ }
211
+
212
+ fun updateNativeSubHeader(value: String?) {
213
+ nativeSubHeaderView.updateConfigJSON(value)
214
+ applyNativeHeaderStyle()
215
+ bringNativeHeadersToFront()
216
+ updateHeaderLayout()
217
+ }
218
+
219
+ fun updateNativeTabProgress(position: Int, offset: Float) {
220
+ nativeTabBarView.setProgress(position + offset)
221
+ }
222
+
223
+ fun logPagerState(reason: String) {
224
+ log(
225
+ "pager-state reason=$reason inner=$selectedPage outer=${outerPagerIndex()} " +
226
+ "nativePages=${adapter.itemCount} attachedPages=${attachedPageCount()} " +
227
+ "headerOffset=$headerOffsetPx retained=$retainedPages",
228
+ )
229
+ }
230
+
231
+ fun setPagerLayoutDirection(layoutDirection: Int) {
232
+ pager.layoutDirection = layoutDirection
233
+ nativeTabBarView.layoutDirection = layoutDirection
234
+ nativeSubHeaderView.layoutDirection = layoutDirection
235
+ nativeSubHeaderView.requestLayout()
236
+ }
237
+
238
+ fun applyNativeHeaderStyle() {
239
+ nativeTabBarView.updateStyle(
240
+ nativeTabBarHeight,
241
+ nativeTabBarContentPaddingHorizontal,
242
+ nativeTabBarItemSpacing,
243
+ nativeTabBarFontSize,
244
+ nativeTabBarFontFamily,
245
+ nativeTabBarIndicatorHeight,
246
+ nativeTabBarIndicatorBottom,
247
+ )
248
+ nativeTabBarView.updateColors(
249
+ nativeTabBarBackgroundColor,
250
+ nativeTabBarActiveTextColor,
251
+ nativeTabBarInactiveTextColor,
252
+ nativeTabBarIndicatorColor,
253
+ )
254
+ nativeSubHeaderView.updateColors(
255
+ nativeTabBarBackgroundColor,
256
+ nativeTabBarActiveTextColor,
257
+ nativeTabBarInactiveTextColor,
258
+ nativeSubHeaderSelectedBackgroundColor,
259
+ nativeTabBarFontFamily,
260
+ )
261
+ updateHeaderLayout()
262
+ }
263
+
264
+ private fun bringNativeHeadersToFront() {
265
+ nativeTabBarView.bringToFront()
266
+ nativeSubHeaderView.bringToFront()
142
267
  }
143
268
 
144
269
  fun addReactChild(child: View, index: Int) {
@@ -160,7 +285,11 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
160
285
  applyPendingInitialPage()
161
286
  headerView?.bringToFront()
162
287
  stickyHeaderView?.bringToFront()
288
+ bringNativeHeadersToFront()
163
289
  requestLayout()
290
+ if (safeIndex >= PAGE_SLOT_OFFSET) {
291
+ log("page-attach slot=${safeIndex - PAGE_SLOT_OFFSET} nativePages=${adapter.itemCount}")
292
+ }
164
293
  }
165
294
 
166
295
  fun removeReactChild(child: View) {
@@ -179,6 +308,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
179
308
  else -> {
180
309
  findVerticalRecyclerView(child)?.let(::restoreRecyclerInsets)
181
310
  adapter.removePage(child)
311
+ log("page-detach slot=${index - PAGE_SLOT_OFFSET} nativePages=${adapter.itemCount}")
182
312
  }
183
313
  }
184
314
  }
@@ -195,6 +325,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
195
325
  logicalChildren.clear()
196
326
  adapter.clear()
197
327
  hasAppliedInitialPage = false
328
+ log("pages-clear")
198
329
  }
199
330
 
200
331
  fun reactChildCount() = logicalChildren.size
@@ -285,6 +416,20 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
285
416
  MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
286
417
  MeasureSpec.makeMeasureSpec(stickyHeaderHeightPx, MeasureSpec.EXACTLY),
287
418
  )
419
+ val nativeTabHeight = if (nativeTabBarView.visibility == View.VISIBLE) {
420
+ nativeTabBarView.preferredHeightPx()
421
+ } else 0
422
+ nativeTabBarView.measure(
423
+ MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
424
+ MeasureSpec.makeMeasureSpec(nativeTabHeight, MeasureSpec.EXACTLY),
425
+ )
426
+ val nativeSubHeaderHeight = if (nativeSubHeaderView.visibility == View.VISIBLE) {
427
+ nativeSubHeaderView.preferredHeightPx()
428
+ } else 0
429
+ nativeSubHeaderView.measure(
430
+ MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
431
+ MeasureSpec.makeMeasureSpec(nativeSubHeaderHeight, MeasureSpec.EXACTLY),
432
+ )
288
433
  setMeasuredDimension(width, height)
289
434
  }
290
435
 
@@ -302,6 +447,14 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
302
447
  width,
303
448
  headerHeightPx + stickyHeaderHeightPx,
304
449
  )
450
+ val nativeTabHeight = nativeTabBarView.measuredHeight
451
+ nativeTabBarView.layout(0, headerHeightPx, width, headerHeightPx + nativeTabHeight)
452
+ nativeSubHeaderView.layout(
453
+ 0,
454
+ headerHeightPx + nativeTabHeight,
455
+ width,
456
+ headerHeightPx + nativeTabHeight + nativeSubHeaderView.measuredHeight,
457
+ )
305
458
  applyHeaderOffset()
306
459
  postForCurrentAttachment {
307
460
  prepareAdjacentPages()
@@ -333,6 +486,8 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
333
486
  pager.translationY = -offset
334
487
  headerView?.translationY = -offset
335
488
  stickyHeaderView?.translationY = -offset
489
+ nativeTabBarView.translationY = -offset
490
+ nativeSubHeaderView.translationY = -offset
336
491
  }
337
492
 
338
493
  private fun pageKey(index: Int): String = pageKeys.getOrNull(index) ?: "page-$index"
@@ -408,7 +563,10 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
408
563
  }
409
564
  observedScrollListener = listener
410
565
  recycler.addOnScrollListener(listener)
411
-
566
+ log(
567
+ "list-observer-attach inner=$selectedPage key=${currentPageKey()} " +
568
+ "recycler=${System.identityHashCode(recycler)}",
569
+ )
412
570
  }
413
571
 
414
572
  private fun detachRecyclerObserver() {
@@ -416,6 +574,12 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
416
574
  val recycler = observedRecyclerView
417
575
  val listener = observedScrollListener
418
576
  if (recycler != null && listener != null) recycler.removeOnScrollListener(listener)
577
+ if (recycler != null) {
578
+ log(
579
+ "list-observer-detach inner=$selectedPage key=${currentPageKey()} " +
580
+ "recycler=${System.identityHashCode(recycler)}",
581
+ )
582
+ }
419
583
  observedRecyclerView = null
420
584
  observedScrollListener = null
421
585
  }
@@ -513,6 +677,195 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
513
677
  recycler.setPadding(original.left, original.top, original.right, original.bottom)
514
678
  }
515
679
 
680
+ private fun headerRegionAt(y: Float): String? {
681
+ val headerBottom = headerHeightPx - headerOffsetPx
682
+ val stickyBottom = headerHeightPx + stickyHeaderHeightPx - headerOffsetPx
683
+ return when {
684
+ y < 0 || y >= stickyBottom -> null
685
+ y < headerBottom -> "header"
686
+ y < headerBottom + nativeTabBarView.measuredHeight &&
687
+ nativeTabBarView.visibility == View.VISIBLE -> "primary-tab"
688
+ y < headerBottom + nativeTabBarView.measuredHeight + nativeSubHeaderView.measuredHeight &&
689
+ nativeSubHeaderView.visibility == View.VISIBLE -> "secondary-header"
690
+ else -> "sticky-controls"
691
+ }
692
+ }
693
+
694
+ private fun deepestChildAt(group: ViewGroup, x: Float, y: Float): View {
695
+ for (index in group.childCount - 1 downTo 0) {
696
+ val child = group.getChildAt(index)
697
+ if (child.visibility != View.VISIBLE || child.alpha <= 0f) continue
698
+ val localX = x + group.scrollX - child.left - child.translationX
699
+ val localY = y + group.scrollY - child.top - child.translationY
700
+ if (localX < 0 || localY < 0 || localX >= child.width || localY >= child.height) continue
701
+ return if (child is ViewGroup) deepestChildAt(child, localX, localY) else child
702
+ }
703
+ return group
704
+ }
705
+
706
+ private fun hasHorizontalScrollOwner(x: Float, y: Float): Boolean {
707
+ var target: View? = deepestChildAt(this, x, y)
708
+ while (target != null && target !== this) {
709
+ if (
710
+ target is HorizontalScrollView ||
711
+ target.canScrollHorizontally(-1) ||
712
+ target.canScrollHorizontally(1) ||
713
+ (target is RecyclerView &&
714
+ (target.layoutManager as? LinearLayoutManager)?.orientation == RecyclerView.HORIZONTAL)
715
+ ) {
716
+ return true
717
+ }
718
+ target = target.parent as? View
719
+ }
720
+ return false
721
+ }
722
+
723
+ private fun cancelHeaderPress(event: MotionEvent, owner: String) {
724
+ if (pressCancelled) return
725
+ pressCancelled = true
726
+ val cancel = MotionEvent.obtain(event).apply { action = MotionEvent.ACTION_CANCEL }
727
+ super.dispatchTouchEvent(cancel)
728
+ cancel.recycle()
729
+ if (!nativeGestureStarted) {
730
+ NativeGestureUtil.notifyNativeGestureStarted(this, event)
731
+ nativeGestureStarted = true
732
+ }
733
+ log(
734
+ "press-cancel owner=$owner region=$headerTouchRegion " +
735
+ "inner=$selectedPage outer=${outerPagerIndex()}",
736
+ )
737
+ }
738
+
739
+ private fun dispatchToRecycler(recycler: RecyclerView, event: MotionEvent): Boolean {
740
+ val hostLocation = IntArray(2)
741
+ val recyclerLocation = IntArray(2)
742
+ getLocationOnScreen(hostLocation)
743
+ recycler.getLocationOnScreen(recyclerLocation)
744
+ val copy = MotionEvent.obtain(event)
745
+ copy.offsetLocation(
746
+ (hostLocation[0] - recyclerLocation[0]).toFloat(),
747
+ (hostLocation[1] - recyclerLocation[1]).toFloat(),
748
+ )
749
+ val handled = recycler.dispatchTouchEvent(copy)
750
+ copy.recycle()
751
+ return handled
752
+ }
753
+
754
+ private fun beginForwardingToRecycler(event: MotionEvent): Boolean {
755
+ val recycler = recyclerViewForPage(selectedPage) ?: return false
756
+ forwardedRecycler = recycler
757
+ headerDownEvent?.let { down -> dispatchToRecycler(recycler, down) }
758
+ return dispatchToRecycler(recycler, event)
759
+ }
760
+
761
+ private fun finishHeaderGesture(event: MotionEvent) {
762
+ val owner = headerGestureOwner.name.lowercase()
763
+ if (nativeGestureStarted) {
764
+ NativeGestureUtil.notifyNativeGestureEnded(this, event)
765
+ nativeGestureStarted = false
766
+ }
767
+ log(
768
+ "gesture-end action=${event.actionMasked} owner=$owner region=$headerTouchRegion " +
769
+ "inner=$selectedPage outer=${outerPagerIndex()} durationMs=" +
770
+ "${SystemClock.uptimeMillis() - event.downTime}",
771
+ )
772
+ headerDownEvent?.recycle()
773
+ headerDownEvent = null
774
+ headerTouchActive = false
775
+ headerTouchRegion = "none"
776
+ headerGestureOwner = HeaderGestureOwner.NONE
777
+ headerHasHorizontalChild = false
778
+ forwardedRecycler = null
779
+ pressCancelled = false
780
+ }
781
+
782
+ override fun dispatchTouchEvent(e: MotionEvent): Boolean {
783
+ val event = e
784
+ if (!nativeSmoothHeaderScrollEnabled) return super.dispatchTouchEvent(event)
785
+
786
+ when (event.actionMasked) {
787
+ MotionEvent.ACTION_DOWN -> {
788
+ headerDownEvent?.recycle()
789
+ headerDownEvent = null
790
+ headerGestureOwner = HeaderGestureOwner.NONE
791
+ forwardedRecycler = null
792
+ pressCancelled = false
793
+ nativeGestureStarted = false
794
+ headerTouchRegion = headerRegionAt(event.y) ?: "none"
795
+ headerTouchActive = headerTouchRegion != "none"
796
+ if (!headerTouchActive) return super.dispatchTouchEvent(event)
797
+ headerDownX = event.x
798
+ headerDownY = event.y
799
+ headerDownEvent = MotionEvent.obtain(event)
800
+ headerHasHorizontalChild = hasHorizontalScrollOwner(event.x, event.y)
801
+ parent.requestDisallowInterceptTouchEvent(true)
802
+ log(
803
+ "gesture-begin region=$headerTouchRegion owner=pending " +
804
+ "horizontalChild=$headerHasHorizontalChild inner=$selectedPage " +
805
+ "outer=${outerPagerIndex()} headerOffset=$headerOffsetPx",
806
+ )
807
+ return super.dispatchTouchEvent(event)
808
+ }
809
+
810
+ MotionEvent.ACTION_MOVE -> {
811
+ if (!headerTouchActive) return super.dispatchTouchEvent(event)
812
+ if (headerGestureOwner == HeaderGestureOwner.NONE) {
813
+ val dx = event.x - headerDownX
814
+ val dy = event.y - headerDownY
815
+ if (kotlin.math.abs(dx) <= touchSlopPx && kotlin.math.abs(dy) <= touchSlopPx) {
816
+ return super.dispatchTouchEvent(event)
817
+ }
818
+ headerGestureOwner = when {
819
+ kotlin.math.abs(dy) > kotlin.math.abs(dx) -> HeaderGestureOwner.LIST
820
+ headerHasHorizontalChild -> HeaderGestureOwner.HORIZONTAL_CHILD
821
+ else -> HeaderGestureOwner.HEADER_GUARD
822
+ }
823
+ log(
824
+ "direction-lock owner=${headerGestureOwner.name.lowercase()} " +
825
+ "region=$headerTouchRegion dx=${dx.roundToInt()} dy=${dy.roundToInt()} " +
826
+ "inner=$selectedPage outer=${outerPagerIndex()}",
827
+ )
828
+ }
829
+ parent.requestDisallowInterceptTouchEvent(true)
830
+ return when (headerGestureOwner) {
831
+ HeaderGestureOwner.LIST -> {
832
+ cancelHeaderPress(event, "list")
833
+ val recycler = forwardedRecycler
834
+ if (recycler != null) {
835
+ dispatchToRecycler(recycler, event)
836
+ } else if (beginForwardingToRecycler(event)) {
837
+ true
838
+ } else {
839
+ headerGestureOwner = HeaderGestureOwner.HEADER_GUARD
840
+ log("gesture-list-unavailable region=$headerTouchRegion inner=$selectedPage")
841
+ true
842
+ }
843
+ }
844
+ HeaderGestureOwner.HORIZONTAL_CHILD -> super.dispatchTouchEvent(event)
845
+ HeaderGestureOwner.HEADER_GUARD -> {
846
+ cancelHeaderPress(event, "header-guard")
847
+ true
848
+ }
849
+ HeaderGestureOwner.NONE -> super.dispatchTouchEvent(event)
850
+ }
851
+ }
852
+
853
+ MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
854
+ if (!headerTouchActive) return super.dispatchTouchEvent(event)
855
+ val handled = when (headerGestureOwner) {
856
+ HeaderGestureOwner.LIST -> forwardedRecycler?.let { recycler ->
857
+ dispatchToRecycler(recycler, event)
858
+ } ?: true
859
+ HeaderGestureOwner.HEADER_GUARD -> true
860
+ else -> super.dispatchTouchEvent(event)
861
+ }
862
+ finishHeaderGesture(event)
863
+ return handled
864
+ }
865
+ }
866
+ return super.dispatchTouchEvent(event)
867
+ }
868
+
516
869
  private fun isCurrentPageTarget(target: View): Boolean {
517
870
  if (selectedPage !in 0 until adapter.itemCount) return false
518
871
  return isDescendant(target, adapter.pageAt(selectedPage))
@@ -612,15 +965,29 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
612
965
  attachmentGeneration += 1
613
966
  restoredRecyclerKeys.clear()
614
967
  viewTreeObserver.addOnPreDrawListener(pageContentLayoutListener)
968
+ log(
969
+ "host-attach generation=$attachmentGeneration inner=$selectedPage " +
970
+ "outer=${outerPagerIndex()} nativePages=${adapter.itemCount}",
971
+ )
615
972
  }
616
973
 
617
974
  override fun onDetachedFromWindow() {
618
975
  attachmentGeneration += 1
976
+ headerDownEvent?.recycle()
977
+ headerDownEvent = null
978
+ headerTouchActive = false
979
+ forwardedRecycler = null
980
+ pressCancelled = false
981
+ nativeGestureStarted = false
619
982
  viewTreeObserver.removeOnPreDrawListener(pageContentLayoutListener)
620
983
  detachRecyclerObserver()
621
984
  for (recycler in originalRecyclerPadding.keys.toList()) {
622
985
  restoreRecyclerInsets(recycler)
623
986
  }
987
+ log(
988
+ "host-detach generation=$attachmentGeneration inner=$selectedPage " +
989
+ "outer=${outerPagerIndex()} nativePages=${adapter.itemCount}",
990
+ )
624
991
  super.onDetachedFromWindow()
625
992
  }
626
993
 
@@ -0,0 +1,503 @@
1
+ package com.reactnativepagerview
2
+
3
+ import android.content.Context
4
+ import android.graphics.Color
5
+ import android.graphics.Typeface
6
+ import android.graphics.drawable.GradientDrawable
7
+ import android.text.TextUtils
8
+ import android.util.TypedValue
9
+ import android.view.Gravity
10
+ import android.view.MotionEvent
11
+ import android.view.View
12
+ import android.view.ViewGroup
13
+ import android.widget.FrameLayout
14
+ import android.widget.HorizontalScrollView
15
+ import android.widget.TextView
16
+ import androidx.core.graphics.ColorUtils
17
+ import org.json.JSONArray
18
+ import org.json.JSONObject
19
+ import kotlin.math.ceil
20
+ import kotlin.math.floor
21
+ import kotlin.math.max
22
+ import kotlin.math.min
23
+ import kotlin.math.roundToInt
24
+
25
+ internal data class CollapsiblePagerNativeHeaderItem(
26
+ val key: String,
27
+ val title: String,
28
+ val accessibilityLabel: String,
29
+ val testID: String?,
30
+ )
31
+
32
+ private fun parseHeaderItems(value: String?): List<CollapsiblePagerNativeHeaderItem> {
33
+ if (value.isNullOrEmpty()) return emptyList()
34
+ return runCatching {
35
+ val array = JSONArray(value)
36
+ List(array.length()) { index ->
37
+ val item = array.optJSONObject(index) ?: JSONObject()
38
+ val title = item.optString("title")
39
+ CollapsiblePagerNativeHeaderItem(
40
+ key = item.optString("key"),
41
+ title = title,
42
+ accessibilityLabel = item.optString("accessibilityLabel", title),
43
+ testID = item.optString("testID").takeIf(String::isNotEmpty),
44
+ )
45
+ }
46
+ }.getOrDefault(emptyList())
47
+ }
48
+
49
+ private class CollapsiblePagerHorizontalItemsView(
50
+ context: Context,
51
+ private val showsProgressIndicator: Boolean,
52
+ ) : HorizontalScrollView(context) {
53
+ private val density = resources.displayMetrics.density
54
+ private val content = FrameLayout(context)
55
+ private val indicator = View(context)
56
+ private val buttons = ArrayList<TextView>()
57
+ private val itemFrames = ArrayList<IntArray>()
58
+ private var items: List<CollapsiblePagerNativeHeaderItem> = emptyList()
59
+ private var selectedKey = ""
60
+ private var rowHeightPx = dp(if (showsProgressIndicator) 44.0 else 42.0)
61
+ private var contentPaddingPx = dp(20.0)
62
+ private var itemSpacingPx = dp(8.0)
63
+ private var fontSize = if (showsProgressIndicator) 16.0 else 14.0
64
+ private var fontFamily: String? = null
65
+ private var activeTextColor = Color.BLACK
66
+ private var inactiveTextColor = Color.GRAY
67
+ private var selectedBackgroundColor = Color.TRANSPARENT
68
+ private var indicatorColor = Color.BLACK
69
+ private var indicatorHeightPx = dp(2.0)
70
+ private var indicatorBottomPx = 0
71
+ private var progress = 0f
72
+ private var centerSelectionWhenIdle = true
73
+ private var isTouchDragging = false
74
+
75
+ var onItemPress: ((Int, String) -> Unit)? = null
76
+
77
+ init {
78
+ isHorizontalScrollBarEnabled = false
79
+ isVerticalScrollBarEnabled = false
80
+ isFillViewport = true
81
+ overScrollMode = OVER_SCROLL_NEVER
82
+ clipToPadding = false
83
+ content.addView(indicator)
84
+ addView(
85
+ content,
86
+ LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.MATCH_PARENT),
87
+ )
88
+ }
89
+
90
+ private fun dp(value: Double): Int = (value * density).roundToInt()
91
+
92
+ fun updateItemsJSON(value: String?) {
93
+ val next = parseHeaderItems(value)
94
+ if (items == next) return
95
+ items = next
96
+ buttons.forEach(content::removeView)
97
+ buttons.clear()
98
+ itemFrames.clear()
99
+ items.forEachIndexed { index, item ->
100
+ val button = TextView(context).apply {
101
+ text = item.title
102
+ contentDescription = item.accessibilityLabel
103
+ setTag(com.facebook.react.R.id.react_test_id, item.testID)
104
+ gravity = Gravity.CENTER
105
+ isSingleLine = true
106
+ ellipsize = TextUtils.TruncateAt.END
107
+ isClickable = true
108
+ isFocusable = true
109
+ setPadding(dp(8.0), 0, dp(8.0), 0)
110
+ setOnClickListener { onItemPress?.invoke(index, item.key) }
111
+ }
112
+ buttons.add(button)
113
+ content.addView(button)
114
+ }
115
+ visibility = if (items.isEmpty()) GONE else VISIBLE
116
+ centerSelectionWhenIdle = true
117
+ requestLayout()
118
+ }
119
+
120
+ fun updateSelectedKey(value: String) {
121
+ if (selectedKey == value) return
122
+ selectedKey = value
123
+ centerSelectionWhenIdle = true
124
+ updatePresentation()
125
+ }
126
+
127
+ fun updateStyle(
128
+ rowHeight: Double,
129
+ contentPadding: Double,
130
+ itemSpacing: Double,
131
+ fontSize: Double,
132
+ fontFamily: String?,
133
+ indicatorHeight: Double = 0.0,
134
+ indicatorBottom: Double = 0.0,
135
+ ) {
136
+ rowHeightPx = max(1, dp(rowHeight))
137
+ contentPaddingPx = max(0, dp(contentPadding))
138
+ itemSpacingPx = max(0, dp(itemSpacing))
139
+ this.fontSize = max(1.0, fontSize)
140
+ this.fontFamily = fontFamily
141
+ if (showsProgressIndicator) {
142
+ indicatorHeightPx = max(0, dp(indicatorHeight))
143
+ indicatorBottomPx = max(0, dp(indicatorBottom))
144
+ }
145
+ applyTypeface()
146
+ requestLayout()
147
+ }
148
+
149
+ fun updateColors(
150
+ backgroundColor: Int,
151
+ activeTextColor: Int,
152
+ inactiveTextColor: Int,
153
+ selectedBackgroundColor: Int,
154
+ indicatorColor: Int,
155
+ ) {
156
+ setBackgroundColor(backgroundColor)
157
+ content.setBackgroundColor(backgroundColor)
158
+ this.activeTextColor = activeTextColor
159
+ this.inactiveTextColor = inactiveTextColor
160
+ this.selectedBackgroundColor = selectedBackgroundColor
161
+ this.indicatorColor = indicatorColor
162
+ indicator.setBackgroundColor(indicatorColor)
163
+ updatePresentation()
164
+ }
165
+
166
+ fun setProgress(value: Float) {
167
+ if (!showsProgressIndicator || items.isEmpty()) return
168
+ val next = value.coerceIn(0f, (items.size - 1).toFloat())
169
+ if (kotlin.math.abs(progress - next) >= 0.001f) centerSelectionWhenIdle = true
170
+ progress = next
171
+ updatePresentation()
172
+ }
173
+
174
+ private fun applyTypeface() {
175
+ val typeface = Typeface.create(fontFamily ?: "sans-serif-medium", Typeface.NORMAL)
176
+ buttons.forEach { button ->
177
+ button.typeface = typeface
178
+ button.setTextSize(TypedValue.COMPLEX_UNIT_SP, fontSize.toFloat())
179
+ }
180
+ }
181
+
182
+ private fun selectedBackground(selected: Boolean) = GradientDrawable().apply {
183
+ shape = GradientDrawable.RECTANGLE
184
+ setColor(if (selected) selectedBackgroundColor else Color.TRANSPARENT)
185
+ cornerRadius = dp(10.0).toFloat()
186
+ }
187
+
188
+ override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
189
+ val width = MeasureSpec.getSize(widthMeasureSpec)
190
+ val height = when (MeasureSpec.getMode(heightMeasureSpec)) {
191
+ MeasureSpec.EXACTLY -> MeasureSpec.getSize(heightMeasureSpec)
192
+ else -> rowHeightPx
193
+ }
194
+ applyTypeface()
195
+ itemFrames.clear()
196
+ var x = contentPaddingPx
197
+ buttons.forEachIndexed { index, button ->
198
+ val textWidth = ceil(button.paint.measureText(button.text.toString()).toDouble()).toInt()
199
+ val buttonWidth = max(dp(44.0), textWidth + dp(if (showsProgressIndicator) 16.0 else 20.0))
200
+ val indicatorWidth = textWidth
201
+ val indicatorX = x + (buttonWidth - indicatorWidth) / 2
202
+ itemFrames.add(intArrayOf(x, buttonWidth, indicatorX, indicatorWidth))
203
+ val itemHeight = if (showsProgressIndicator) height else min(dp(32.0), height)
204
+ val top = if (showsProgressIndicator) 0 else max(0, (height - itemHeight) / 2)
205
+ button.layoutParams = FrameLayout.LayoutParams(buttonWidth, itemHeight).apply {
206
+ leftMargin = x
207
+ topMargin = top
208
+ }
209
+ x += buttonWidth
210
+ if (index < buttons.lastIndex) x += itemSpacingPx
211
+ }
212
+ val contentWidth = max(width, x + contentPaddingPx)
213
+ content.layoutParams = LayoutParams(contentWidth, height)
214
+ content.measure(
215
+ MeasureSpec.makeMeasureSpec(contentWidth, MeasureSpec.EXACTLY),
216
+ MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY),
217
+ )
218
+ setMeasuredDimension(width, height)
219
+ updatePresentation()
220
+ }
221
+
222
+ override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
223
+ super.onLayout(changed, left, top, right, bottom)
224
+ updatePresentation()
225
+ }
226
+
227
+ override fun onTouchEvent(event: MotionEvent): Boolean {
228
+ when (event.actionMasked) {
229
+ MotionEvent.ACTION_DOWN -> isTouchDragging = true
230
+ MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
231
+ isTouchDragging = false
232
+ post(::updatePresentation)
233
+ }
234
+ }
235
+ return super.onTouchEvent(event)
236
+ }
237
+
238
+ private fun updatePresentation() {
239
+ if (buttons.isEmpty() || itemFrames.size != buttons.size) return
240
+ val selectedIndex: Int
241
+ if (showsProgressIndicator) {
242
+ val clamped = progress.coerceIn(0f, buttons.lastIndex.toFloat())
243
+ val lower = floor(clamped).toInt()
244
+ val upper = min(lower + 1, buttons.lastIndex)
245
+ val fraction = clamped - lower
246
+ val from = itemFrames[lower]
247
+ val to = itemFrames[upper]
248
+ val indicatorX = (from[2] + (to[2] - from[2]) * fraction).roundToInt()
249
+ val indicatorWidth = (from[3] + (to[3] - from[3]) * fraction).roundToInt()
250
+ indicator.layoutParams = FrameLayout.LayoutParams(indicatorWidth, indicatorHeightPx).apply {
251
+ leftMargin = indicatorX
252
+ topMargin = max(0, measuredHeight - indicatorBottomPx - indicatorHeightPx)
253
+ }
254
+ indicator.visibility = if (indicatorHeightPx > 0) VISIBLE else GONE
255
+ indicator.background = GradientDrawable().apply {
256
+ shape = GradientDrawable.RECTANGLE
257
+ setColor(indicatorColor)
258
+ cornerRadius = indicatorHeightPx / 2f
259
+ }
260
+ selectedIndex = clamped.roundToInt()
261
+ buttons.forEachIndexed { index, button ->
262
+ val emphasis = 1f - min(1f, kotlin.math.abs(clamped - index))
263
+ button.setTextColor(ColorUtils.blendARGB(inactiveTextColor, activeTextColor, emphasis))
264
+ button.background = null
265
+ button.isSelected = index == selectedIndex
266
+ }
267
+ } else {
268
+ selectedIndex = items.indexOfFirst { item -> item.key == selectedKey }
269
+ indicator.visibility = GONE
270
+ buttons.forEachIndexed { index, button ->
271
+ val selected = index == selectedIndex
272
+ button.setTextColor(if (selected) activeTextColor else inactiveTextColor)
273
+ button.background = selectedBackground(selected)
274
+ button.isSelected = selected
275
+ }
276
+ }
277
+
278
+ if (centerSelectionWhenIdle && !isTouchDragging && width > 0 && selectedIndex in itemFrames.indices) {
279
+ val frame = itemFrames[selectedIndex]
280
+ val desired = (frame[0] + frame[1] / 2 - width / 2)
281
+ .coerceIn(0, max(0, content.measuredWidth - width))
282
+ scrollTo(desired, 0)
283
+ centerSelectionWhenIdle = false
284
+ }
285
+ }
286
+ }
287
+
288
+ internal class CollapsiblePagerNativeTabBarView(context: Context) : FrameLayout(context) {
289
+ private val row = CollapsiblePagerHorizontalItemsView(context, true)
290
+ private var heightPx = 0
291
+
292
+ var onItemPress: ((Int, String) -> Unit)?
293
+ get() = row.onItemPress
294
+ set(value) {
295
+ row.onItemPress = value
296
+ }
297
+
298
+ init {
299
+ addView(row, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT))
300
+ visibility = GONE
301
+ }
302
+
303
+ fun updateItemsJSON(value: String?) {
304
+ row.updateItemsJSON(value)
305
+ visibility = row.visibility
306
+ }
307
+
308
+ fun updateStyle(
309
+ height: Double,
310
+ contentPadding: Double,
311
+ itemSpacing: Double,
312
+ fontSize: Double,
313
+ fontFamily: String?,
314
+ indicatorHeight: Double,
315
+ indicatorBottom: Double,
316
+ ) {
317
+ heightPx = max(1, (height * resources.displayMetrics.density).roundToInt())
318
+ row.updateStyle(
319
+ height,
320
+ contentPadding,
321
+ itemSpacing,
322
+ fontSize,
323
+ fontFamily,
324
+ indicatorHeight,
325
+ indicatorBottom,
326
+ )
327
+ requestLayout()
328
+ }
329
+
330
+ fun updateColors(
331
+ backgroundColor: Int,
332
+ activeTextColor: Int,
333
+ inactiveTextColor: Int,
334
+ indicatorColor: Int,
335
+ ) = row.updateColors(
336
+ backgroundColor,
337
+ activeTextColor,
338
+ inactiveTextColor,
339
+ Color.TRANSPARENT,
340
+ indicatorColor,
341
+ )
342
+
343
+ fun setProgress(progress: Float) = row.setProgress(progress)
344
+
345
+ fun preferredHeightPx(): Int = heightPx
346
+ }
347
+
348
+ internal class CollapsiblePagerNativeSubHeaderView(context: Context) : ViewGroup(context) {
349
+ private val density = resources.displayMetrics.density
350
+ private val tabs = CollapsiblePagerHorizontalItemsView(context, false)
351
+ private val columns = FrameLayout(context)
352
+ private val leading = TextView(context)
353
+ private val middle = TextView(context)
354
+ private val trailing = TextView(context)
355
+ private var configuredHeightPx = dp(74.0)
356
+ private var tabsHeightPx = dp(42.0)
357
+ private var contentPaddingPx = dp(20.0)
358
+ private var itemSpacing = 8.0
359
+ private var itemFontSize = 14.0
360
+ private var columnFontSize = 12.0
361
+ private var trailingColumnWidthPx = dp(80.0)
362
+ private var columnGapPx = dp(8.0)
363
+ private var backgroundColorValue = Color.TRANSPARENT
364
+ private var activeTextColor = Color.BLACK
365
+ private var inactiveTextColor = Color.GRAY
366
+ private var selectedBackgroundColor = Color.TRANSPARENT
367
+ private var fontFamily: String? = null
368
+
369
+ var onItemPress: ((Int, String) -> Unit)?
370
+ get() = tabs.onItemPress
371
+ set(value) {
372
+ tabs.onItemPress = value
373
+ }
374
+
375
+ init {
376
+ addView(tabs)
377
+ addView(columns)
378
+ columns.addView(leading)
379
+ columns.addView(middle)
380
+ columns.addView(trailing)
381
+ listOf(leading, middle, trailing).forEach { label ->
382
+ label.gravity = Gravity.CENTER_VERTICAL
383
+ label.isSingleLine = true
384
+ label.ellipsize = TextUtils.TruncateAt.END
385
+ }
386
+ visibility = GONE
387
+ }
388
+
389
+ private fun dp(value: Double): Int = (value * density).roundToInt()
390
+
391
+ fun updateConfigJSON(value: String?) {
392
+ val config = runCatching { JSONObject(value ?: "{}") }.getOrDefault(JSONObject())
393
+ val style = config.optJSONObject("style") ?: JSONObject()
394
+ val columnsConfig = config.optJSONObject("columns") ?: JSONObject()
395
+ val itemArray = config.optJSONArray("items") ?: JSONArray()
396
+ tabs.updateItemsJSON(itemArray.toString())
397
+ tabs.updateSelectedKey(config.optString("selectedKey"))
398
+ configuredHeightPx = max(1, dp(style.optDouble("height", 74.0)))
399
+ tabsHeightPx = max(0, dp(style.optDouble("tabsHeight", 42.0)))
400
+ contentPaddingPx = max(0, dp(style.optDouble("contentPaddingHorizontal", 20.0)))
401
+ itemSpacing = max(0.0, style.optDouble("itemSpacing", 8.0))
402
+ itemFontSize = max(1.0, style.optDouble("fontSize", 14.0))
403
+ columnFontSize = max(1.0, style.optDouble("columnFontSize", 12.0))
404
+ trailingColumnWidthPx = max(0, dp(style.optDouble("trailingColumnWidth", 80.0)))
405
+ columnGapPx = max(0, dp(style.optDouble("columnGap", 8.0)))
406
+ leading.text = columnsConfig.optString("leading")
407
+ middle.text = columnsConfig.optString("middle")
408
+ trailing.text = columnsConfig.optString("trailing")
409
+ visibility = if (itemArray.length() == 0) GONE else VISIBLE
410
+ applyStyle()
411
+ requestLayout()
412
+ }
413
+
414
+ fun updateColors(
415
+ backgroundColor: Int,
416
+ activeTextColor: Int,
417
+ inactiveTextColor: Int,
418
+ selectedBackgroundColor: Int,
419
+ fontFamily: String?,
420
+ ) {
421
+ backgroundColorValue = backgroundColor
422
+ this.activeTextColor = activeTextColor
423
+ this.inactiveTextColor = inactiveTextColor
424
+ this.selectedBackgroundColor = selectedBackgroundColor
425
+ this.fontFamily = fontFamily
426
+ applyStyle()
427
+ }
428
+
429
+ private fun applyStyle() {
430
+ setBackgroundColor(backgroundColorValue)
431
+ columns.setBackgroundColor(backgroundColorValue)
432
+ tabs.updateStyle(
433
+ tabsHeightPx / density.toDouble(),
434
+ contentPaddingPx / density.toDouble(),
435
+ itemSpacing,
436
+ itemFontSize,
437
+ fontFamily,
438
+ )
439
+ tabs.updateColors(
440
+ backgroundColorValue,
441
+ activeTextColor,
442
+ inactiveTextColor,
443
+ selectedBackgroundColor,
444
+ activeTextColor,
445
+ )
446
+ val typeface = Typeface.create(fontFamily ?: "sans-serif-medium", Typeface.NORMAL)
447
+ listOf(leading, middle, trailing).forEach { label ->
448
+ label.typeface = typeface
449
+ label.setTextSize(TypedValue.COMPLEX_UNIT_SP, columnFontSize.toFloat())
450
+ label.setTextColor(inactiveTextColor)
451
+ }
452
+ }
453
+
454
+ fun preferredHeightPx(): Int = configuredHeightPx
455
+
456
+ override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
457
+ val width = MeasureSpec.getSize(widthMeasureSpec)
458
+ val height = MeasureSpec.getSize(heightMeasureSpec)
459
+ val actualTabsHeight = min(height, tabsHeightPx)
460
+ tabs.measure(
461
+ MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
462
+ MeasureSpec.makeMeasureSpec(actualTabsHeight, MeasureSpec.EXACTLY),
463
+ )
464
+ val columnsHeight = max(0, height - actualTabsHeight)
465
+ columns.measure(
466
+ MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
467
+ MeasureSpec.makeMeasureSpec(columnsHeight, MeasureSpec.EXACTLY),
468
+ )
469
+ setMeasuredDimension(width, height)
470
+ }
471
+
472
+ override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
473
+ val width = right - left
474
+ val height = bottom - top
475
+ val actualTabsHeight = min(height, tabsHeightPx)
476
+ tabs.layout(0, 0, width, actualTabsHeight)
477
+ columns.layout(0, actualTabsHeight, width, height)
478
+ val columnsHeight = height - actualTabsHeight
479
+ val half = width / 2
480
+ val isRtl = layoutDirection == View.LAYOUT_DIRECTION_RTL
481
+ if (!isRtl) {
482
+ leading.gravity = Gravity.START or Gravity.CENTER_VERTICAL
483
+ middle.gravity = Gravity.END or Gravity.CENTER_VERTICAL
484
+ trailing.gravity = Gravity.END or Gravity.CENTER_VERTICAL
485
+ val trailingX = width - contentPaddingPx - trailingColumnWidthPx
486
+ leading.layout(contentPaddingPx, 0, half, columnsHeight)
487
+ middle.layout(half, 0, max(half, trailingX - columnGapPx), columnsHeight)
488
+ trailing.layout(trailingX, 0, width - contentPaddingPx, columnsHeight)
489
+ } else {
490
+ leading.gravity = Gravity.END or Gravity.CENTER_VERTICAL
491
+ middle.gravity = Gravity.START or Gravity.CENTER_VERTICAL
492
+ trailing.gravity = Gravity.START or Gravity.CENTER_VERTICAL
493
+ leading.layout(half, 0, width - contentPaddingPx, columnsHeight)
494
+ middle.layout(
495
+ contentPaddingPx + trailingColumnWidthPx + columnGapPx,
496
+ 0,
497
+ half,
498
+ columnsHeight,
499
+ )
500
+ trailing.layout(contentPaddingPx, 0, contentPaddingPx + trailingColumnWidthPx, columnsHeight)
501
+ }
502
+ }
503
+ }
@@ -2,6 +2,7 @@ package com.reactnativepagerview
2
2
 
3
3
  import android.view.View
4
4
  import android.view.ViewGroup
5
+ import android.graphics.Color
5
6
  import androidx.viewpager2.widget.ViewPager2
6
7
  import com.facebook.react.bridge.ReadableArray
7
8
  import com.facebook.react.common.MapBuilder
@@ -18,6 +19,7 @@ import com.reactnativepagerview.event.CollapsibleStateChangedEvent
18
19
  import com.reactnativepagerview.event.PageScrollEvent
19
20
  import com.reactnativepagerview.event.PageScrollStateChangedEvent
20
21
  import com.reactnativepagerview.event.PageSelectedEvent
22
+ import com.reactnativepagerview.event.NativeHeaderPressEvent
21
23
  import org.json.JSONArray
22
24
  import kotlin.math.roundToInt
23
25
 
@@ -41,13 +43,16 @@ class CollapsiblePagerViewManager : ViewGroupManager<CollapsiblePagerHost>(),
41
43
  )
42
44
  host.pager.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() {
43
45
  override fun onPageScrolled(position: Int, offset: Float, offsetPixels: Int) {
46
+ host.updateNativeTabProgress(position, offset)
44
47
  dispatch(reactContext, host, PageScrollEvent(host.id, position, offset))
45
48
  }
46
49
 
47
50
  override fun onPageSelected(position: Int) {
48
51
  host.selectPage(position)
52
+ host.updateNativeTabProgress(position, 0f)
49
53
  dispatch(reactContext, host, PageSelectedEvent(host.id, position))
50
54
  dispatchDiagnostics(reactContext, host, "page-selected")
55
+ host.logPagerState("page-selected")
51
56
  }
52
57
 
53
58
  override fun onPageScrollStateChanged(state: Int) {
@@ -60,9 +65,34 @@ class CollapsiblePagerViewManager : ViewGroupManager<CollapsiblePagerHost>(),
60
65
  dispatch(reactContext, host, PageScrollStateChangedEvent(host.id, value))
61
66
  if (state == ViewPager2.SCROLL_STATE_IDLE) {
62
67
  dispatchDiagnostics(reactContext, host, "pager-idle")
68
+ host.logPagerState("idle")
63
69
  }
64
70
  }
65
71
  })
72
+ host.onNativeTabPress = { position, key ->
73
+ dispatch(
74
+ reactContext,
75
+ host,
76
+ NativeHeaderPressEvent(
77
+ host.id,
78
+ position,
79
+ key,
80
+ NativeHeaderPressEvent.TAB_EVENT_NAME,
81
+ ),
82
+ )
83
+ }
84
+ host.onNativeSubHeaderPress = { position, key ->
85
+ dispatch(
86
+ reactContext,
87
+ host,
88
+ NativeHeaderPressEvent(
89
+ host.id,
90
+ position,
91
+ key,
92
+ NativeHeaderPressEvent.SUB_HEADER_EVENT_NAME,
93
+ ),
94
+ )
95
+ }
66
96
  host.onHeaderOffsetChanged = {
67
97
  if (!host.pager.isFakeDragging && host.pager.scrollState == ViewPager2.SCROLL_STATE_IDLE) {
68
98
  dispatchDiagnostics(reactContext, host, "vertical-idle")
@@ -123,11 +153,11 @@ class CollapsiblePagerViewManager : ViewGroupManager<CollapsiblePagerHost>(),
123
153
 
124
154
  @ReactProp(name = "layoutDirection")
125
155
  override fun setLayoutDirection(view: CollapsiblePagerHost?, value: String?) {
126
- view?.pager?.layoutDirection = if (value == "rtl") {
156
+ view?.setPagerLayoutDirection(if (value == "rtl") {
127
157
  View.LAYOUT_DIRECTION_RTL
128
158
  } else {
129
159
  View.LAYOUT_DIRECTION_LTR
130
- }
160
+ })
131
161
  }
132
162
 
133
163
  @ReactProp(name = "initialPage", defaultInt = 0)
@@ -151,11 +181,12 @@ class CollapsiblePagerViewManager : ViewGroupManager<CollapsiblePagerHost>(),
151
181
  // OneKey patch: CollapsiblePagerHost always coordinates nested pagers on Android.
152
182
  }
153
183
 
154
- // The smooth shared-header ownership is implemented on iOS first.
155
184
  override fun setNativeSmoothHeaderScrollEnabled(
156
185
  view: CollapsiblePagerHost?,
157
186
  value: Boolean,
158
- ) = Unit
187
+ ) {
188
+ view?.nativeSmoothHeaderScrollEnabled = value
189
+ }
159
190
 
160
191
  // OneKey patch: round Yoga header dimensions to the nearest physical pixel.
161
192
  @ReactProp(name = "headerHeight", defaultInt = 0)
@@ -179,41 +210,91 @@ class CollapsiblePagerViewManager : ViewGroupManager<CollapsiblePagerHost>(),
179
210
  view?.retainedPages = value ?: "[]"
180
211
  }
181
212
 
182
- // The optional native tab bar is implemented on iOS first. These no-op
183
- // setters preserve the shared Fabric contract without changing Android UI.
184
- override fun setNativeTabBarItems(view: CollapsiblePagerHost?, value: String?) = Unit
213
+ override fun setNativeTabBarItems(view: CollapsiblePagerHost?, value: String?) {
214
+ view?.updateNativeTabBarItems(value)
215
+ }
185
216
 
186
- override fun setNativeTabBarHeight(view: CollapsiblePagerHost?, value: Double) = Unit
217
+ override fun setNativeTabBarHeight(view: CollapsiblePagerHost?, value: Double) {
218
+ view ?: return
219
+ view.nativeTabBarHeight = value
220
+ view.applyNativeHeaderStyle()
221
+ }
187
222
 
188
223
  override fun setNativeTabBarContentPaddingHorizontal(
189
224
  view: CollapsiblePagerHost?,
190
225
  value: Double,
191
- ) = Unit
226
+ ) {
227
+ view ?: return
228
+ view.nativeTabBarContentPaddingHorizontal = value
229
+ view.applyNativeHeaderStyle()
230
+ }
192
231
 
193
- override fun setNativeTabBarItemSpacing(view: CollapsiblePagerHost?, value: Double) = Unit
232
+ override fun setNativeTabBarItemSpacing(view: CollapsiblePagerHost?, value: Double) {
233
+ view ?: return
234
+ view.nativeTabBarItemSpacing = value
235
+ view.applyNativeHeaderStyle()
236
+ }
194
237
 
195
- override fun setNativeTabBarFontSize(view: CollapsiblePagerHost?, value: Double) = Unit
238
+ override fun setNativeTabBarFontSize(view: CollapsiblePagerHost?, value: Double) {
239
+ view ?: return
240
+ view.nativeTabBarFontSize = value
241
+ view.applyNativeHeaderStyle()
242
+ }
196
243
 
197
- override fun setNativeTabBarFontFamily(view: CollapsiblePagerHost?, value: String?) = Unit
244
+ override fun setNativeTabBarFontFamily(view: CollapsiblePagerHost?, value: String?) {
245
+ view ?: return
246
+ view.nativeTabBarFontFamily = value
247
+ view.applyNativeHeaderStyle()
248
+ }
198
249
 
199
- override fun setNativeTabBarBackgroundColor(view: CollapsiblePagerHost?, value: Int?) = Unit
250
+ override fun setNativeTabBarBackgroundColor(view: CollapsiblePagerHost?, value: Int?) {
251
+ view ?: return
252
+ view.nativeTabBarBackgroundColor = value ?: Color.TRANSPARENT
253
+ view.applyNativeHeaderStyle()
254
+ }
200
255
 
201
- override fun setNativeTabBarActiveTextColor(view: CollapsiblePagerHost?, value: Int?) = Unit
256
+ override fun setNativeTabBarActiveTextColor(view: CollapsiblePagerHost?, value: Int?) {
257
+ view ?: return
258
+ view.nativeTabBarActiveTextColor = value ?: Color.BLACK
259
+ view.applyNativeHeaderStyle()
260
+ }
202
261
 
203
- override fun setNativeTabBarInactiveTextColor(view: CollapsiblePagerHost?, value: Int?) = Unit
262
+ override fun setNativeTabBarInactiveTextColor(view: CollapsiblePagerHost?, value: Int?) {
263
+ view ?: return
264
+ view.nativeTabBarInactiveTextColor = value ?: Color.GRAY
265
+ view.applyNativeHeaderStyle()
266
+ }
204
267
 
205
- override fun setNativeTabBarIndicatorColor(view: CollapsiblePagerHost?, value: Int?) = Unit
268
+ override fun setNativeTabBarIndicatorColor(view: CollapsiblePagerHost?, value: Int?) {
269
+ view ?: return
270
+ view.nativeTabBarIndicatorColor = value ?: view.nativeTabBarActiveTextColor
271
+ view.applyNativeHeaderStyle()
272
+ }
206
273
 
207
- override fun setNativeTabBarIndicatorHeight(view: CollapsiblePagerHost?, value: Double) = Unit
274
+ override fun setNativeTabBarIndicatorHeight(view: CollapsiblePagerHost?, value: Double) {
275
+ view ?: return
276
+ view.nativeTabBarIndicatorHeight = value
277
+ view.applyNativeHeaderStyle()
278
+ }
208
279
 
209
- override fun setNativeTabBarIndicatorBottom(view: CollapsiblePagerHost?, value: Double) = Unit
280
+ override fun setNativeTabBarIndicatorBottom(view: CollapsiblePagerHost?, value: Double) {
281
+ view ?: return
282
+ view.nativeTabBarIndicatorBottom = value
283
+ view.applyNativeHeaderStyle()
284
+ }
210
285
 
211
- override fun setNativeSubHeaderConfig(view: CollapsiblePagerHost?, value: String?) = Unit
286
+ override fun setNativeSubHeaderConfig(view: CollapsiblePagerHost?, value: String?) {
287
+ view?.updateNativeSubHeader(value)
288
+ }
212
289
 
213
290
  override fun setNativeSubHeaderSelectedBackgroundColor(
214
291
  view: CollapsiblePagerHost?,
215
292
  value: Int?,
216
- ) = Unit
293
+ ) {
294
+ view ?: return
295
+ view.nativeSubHeaderSelectedBackgroundColor = value ?: Color.TRANSPARENT
296
+ view.applyNativeHeaderStyle()
297
+ }
217
298
 
218
299
  private fun parseStringArray(value: String?): List<String> {
219
300
  if (value.isNullOrEmpty()) return emptyList()
@@ -255,6 +336,14 @@ class CollapsiblePagerViewManager : ViewGroupManager<CollapsiblePagerHost>(),
255
336
  CollapsibleStateChangedEvent.EVENT_NAME,
256
337
  MapBuilder.of("registrationName", "onCollapsibleStateChanged"),
257
338
  )
339
+ .put(
340
+ NativeHeaderPressEvent.TAB_EVENT_NAME,
341
+ MapBuilder.of("registrationName", "onNativeTabPress"),
342
+ )
343
+ .put(
344
+ NativeHeaderPressEvent.SUB_HEADER_EVENT_NAME,
345
+ MapBuilder.of("registrationName", "onNativeSubHeaderPress"),
346
+ )
258
347
  .build()
259
348
  .toMutableMap()
260
349
 
@@ -0,0 +1,31 @@
1
+ package com.reactnativepagerview.event
2
+
3
+ import com.facebook.react.bridge.Arguments
4
+ import com.facebook.react.bridge.WritableMap
5
+ import com.facebook.react.uimanager.events.Event
6
+ import com.facebook.react.uimanager.events.RCTEventEmitter
7
+
8
+ class NativeHeaderPressEvent(
9
+ viewTag: Int,
10
+ private val position: Int,
11
+ private val key: String,
12
+ private val name: String,
13
+ ) : Event<NativeHeaderPressEvent>(viewTag) {
14
+ override fun getEventName() = name
15
+
16
+ override fun canCoalesce() = false
17
+
18
+ override fun dispatch(rctEventEmitter: RCTEventEmitter) {
19
+ rctEventEmitter.receiveEvent(viewTag, eventName, serializeEventData())
20
+ }
21
+
22
+ private fun serializeEventData(): WritableMap = Arguments.createMap().apply {
23
+ putInt("position", position)
24
+ putString("key", key)
25
+ }
26
+
27
+ companion object {
28
+ const val TAB_EVENT_NAME = "topNativeTabPress"
29
+ const val SUB_HEADER_EVENT_NAME = "topNativeSubHeaderPress"
30
+ }
31
+ }
@@ -73,7 +73,7 @@ export class CollapsiblePagerView extends React.PureComponent {
73
73
  return React.Children.toArray(this.props.children);
74
74
  }
75
75
  nativeTabBarEnabled() {
76
- return Platform.OS === "ios" && !!this.props.nativeTabBar?.items.length;
76
+ return (Platform.OS === "ios" || Platform.OS === "android") && !!this.props.nativeTabBar?.items.length;
77
77
  }
78
78
  dispatchNativeTabPageCommand(position, animated) {
79
79
  const commandId = ++this.nativeTabCommandId;
@@ -169,9 +169,9 @@ export class CollapsiblePagerView extends React.PureComponent {
169
169
  const pageKeys = pages.map(pageKey);
170
170
  const retained = new Set(this.mountedPages(pages.length));
171
171
  const deducedLayoutDirection = !layoutDirection || layoutDirection === "locale" ? I18nManager.isRTL ? "rtl" : "ltr" : layoutDirection;
172
- const nativeTabBarEnabled = Platform.OS === "ios" && !!nativeTabBar?.items.length;
172
+ const nativeTabBarEnabled = (Platform.OS === "ios" || Platform.OS === "android") && !!nativeTabBar?.items.length;
173
173
  const nativeTabBarStyle = nativeTabBar?.style;
174
- const nativeSubHeaderEnabled = Platform.OS === "ios" && !!nativeSubHeader?.items.length;
174
+ const nativeSubHeaderEnabled = (Platform.OS === "ios" || Platform.OS === "android") && !!nativeSubHeader?.items.length;
175
175
  const nativeSubHeaderStyle = nativeSubHeader?.style;
176
176
  const nativeSubHeaderConfig = nativeSubHeaderEnabled ? JSON.stringify({
177
177
  ...nativeSubHeader,
@@ -65,8 +65,8 @@ export interface CollapsiblePagerViewProps extends Omit<NativeProps, "children"
65
65
  /** Measured sticky bar height. */
66
66
  stickyHeaderHeight: number;
67
67
  /**
68
- * Lets the active iOS list own vertical gestures that begin on pager headers.
69
- * Defaults to false and currently has no effect on Android or Web.
68
+ * Lets the active native list own vertical gestures that begin on pager headers.
69
+ * Defaults to false and has no effect on Web.
70
70
  */
71
71
  nativeSmoothHeaderScrollEnabled?: boolean;
72
72
  /**
@@ -76,10 +76,10 @@ export interface CollapsiblePagerViewProps extends Omit<NativeProps, "children"
76
76
  */
77
77
  pageRetentionDistance?: number;
78
78
  layoutDirection?: "ltr" | "rtl" | "locale";
79
- /** Optional native tab bar. It is currently rendered on iOS only. */
79
+ /** Optional native tab bar for iOS and Android. */
80
80
  nativeTabBar?: CollapsiblePagerNativeTabBarConfig;
81
81
  onNativeTabPress?: (event: CollapsiblePagerViewOnNativeTabPressEvent) => void;
82
- /** Optional native secondary sticky header. It is currently rendered on iOS only. */
82
+ /** Optional native secondary sticky header for iOS and Android. */
83
83
  nativeSubHeader?: CollapsiblePagerNativeSubHeaderConfig;
84
84
  onNativeSubHeaderPress?: (event: CollapsiblePagerViewOnNativeSubHeaderPressEvent) => void;
85
85
  children?: React.ReactNode;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onekeyfe/react-native-pager-view",
3
- "version": "3.0.119",
3
+ "version": "3.0.120",
4
4
  "description": "React Native wrapper for Android and iOS ViewPager",
5
5
  "source": "./src/index.tsx",
6
6
  "main": "./lib/module/index.js",
@@ -39,7 +39,7 @@
39
39
  "prepare": "rm -rf lib && bob build",
40
40
  "typecheck": "tsc -b",
41
41
  "lint": "eslint \"**/*.{js,ts,tsx}\"",
42
- "test": "jest",
42
+ "test": "node --test src/__tests__/*.cjs",
43
43
  "release": "yarn prepare && npm whoami && npm publish --access public"
44
44
  },
45
45
  "keywords": [
@@ -67,6 +67,7 @@
67
67
  "typescript": "^5.9.2"
68
68
  },
69
69
  "peerDependencies": {
70
+ "@onekeyfe/react-native-native-logger": "3.0.120",
70
71
  "react": "*",
71
72
  "react-native": "*"
72
73
  },
@@ -115,8 +115,8 @@ export interface CollapsiblePagerViewProps
115
115
  /** Measured sticky bar height. */
116
116
  stickyHeaderHeight: number;
117
117
  /**
118
- * Lets the active iOS list own vertical gestures that begin on pager headers.
119
- * Defaults to false and currently has no effect on Android or Web.
118
+ * Lets the active native list own vertical gestures that begin on pager headers.
119
+ * Defaults to false and has no effect on Web.
120
120
  */
121
121
  nativeSmoothHeaderScrollEnabled?: boolean;
122
122
  /**
@@ -126,10 +126,10 @@ export interface CollapsiblePagerViewProps
126
126
  */
127
127
  pageRetentionDistance?: number;
128
128
  layoutDirection?: "ltr" | "rtl" | "locale";
129
- /** Optional native tab bar. It is currently rendered on iOS only. */
129
+ /** Optional native tab bar for iOS and Android. */
130
130
  nativeTabBar?: CollapsiblePagerNativeTabBarConfig;
131
131
  onNativeTabPress?: (event: CollapsiblePagerViewOnNativeTabPressEvent) => void;
132
- /** Optional native secondary sticky header. It is currently rendered on iOS only. */
132
+ /** Optional native secondary sticky header for iOS and Android. */
133
133
  nativeSubHeader?: CollapsiblePagerNativeSubHeaderConfig;
134
134
  onNativeSubHeaderPress?: (
135
135
  event: CollapsiblePagerViewOnNativeSubHeaderPressEvent
@@ -248,7 +248,10 @@ export class CollapsiblePagerView extends React.PureComponent<
248
248
  }
249
249
 
250
250
  private nativeTabBarEnabled() {
251
- return Platform.OS === "ios" && !!this.props.nativeTabBar?.items.length;
251
+ return (
252
+ (Platform.OS === "ios" || Platform.OS === "android") &&
253
+ !!this.props.nativeTabBar?.items.length
254
+ );
252
255
  }
253
256
 
254
257
  private dispatchNativeTabPageCommand(position: number, animated: boolean) {
@@ -393,10 +396,12 @@ export class CollapsiblePagerView extends React.PureComponent<
393
396
  : "ltr"
394
397
  : layoutDirection;
395
398
  const nativeTabBarEnabled =
396
- Platform.OS === "ios" && !!nativeTabBar?.items.length;
399
+ (Platform.OS === "ios" || Platform.OS === "android") &&
400
+ !!nativeTabBar?.items.length;
397
401
  const nativeTabBarStyle = nativeTabBar?.style;
398
402
  const nativeSubHeaderEnabled =
399
- Platform.OS === "ios" && !!nativeSubHeader?.items.length;
403
+ (Platform.OS === "ios" || Platform.OS === "android") &&
404
+ !!nativeSubHeader?.items.length;
400
405
  const nativeSubHeaderStyle = nativeSubHeader?.style;
401
406
  const nativeSubHeaderConfig = nativeSubHeaderEnabled
402
407
  ? JSON.stringify({