@onekeyfe/react-native-pager-view 3.0.157-alpha.256 → 3.0.157-alpha.258

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/README.md +51 -4
  2. package/android/src/main/java/com/reactnativepagerview/CollapsiblePagerHost.kt +254 -8
  3. package/android/src/main/java/com/reactnativepagerview/NativeScrollerScrollAwayInsets.kt +24 -0
  4. package/android/src/main/java/com/reactnativepagerview/NativeScrollerView.kt +573 -0
  5. package/android/src/main/java/com/reactnativepagerview/NativeScrollerViewManager.kt +130 -0
  6. package/android/src/main/java/com/reactnativepagerview/PagerViewViewPackage.kt +1 -1
  7. package/android/src/main/jni/CMakeLists.txt +19 -0
  8. package/android/src/main/jni/pagerview.h +11 -0
  9. package/android/src/main/res/values/native_scroll_coordinator_ids.xml +1 -0
  10. package/android/src/test/java/com/reactnativepagerview/NativeScrollerScrollAwayInsetsTest.kt +38 -0
  11. package/common/cpp/react/renderer/components/pagerview/RNCNativeScrollerComponentDescriptor.h +8 -0
  12. package/common/cpp/react/renderer/components/pagerview/RNCNativeScrollerShadowNode.cpp +15 -0
  13. package/common/cpp/react/renderer/components/pagerview/RNCNativeScrollerShadowNode.h +23 -0
  14. package/common/cpp/react/renderer/components/pagerview/RNCNativeScrollerState.h +33 -0
  15. package/docs/SPEC.md +268 -1
  16. package/ios/RNCCollapsiblePagerViewComponentView.mm +187 -1
  17. package/ios/RNCNativeScrollerComponentView.h +17 -0
  18. package/ios/RNCNativeScrollerComponentView.mm +479 -0
  19. package/ios/tests/RNCCollapsiblePagerPressCancellationTests.mm +346 -0
  20. package/ios/tests/RNCCollapsiblePagerPressCancellationWiringTests.mm +109 -0
  21. package/ios/tests/RNCNativeScrollerTests.mm +252 -0
  22. package/lib/module/CollapsiblePagerView.js +1 -1
  23. package/lib/module/NativeScroller.js +171 -11
  24. package/lib/module/NativeScrollerNativeComponent.ts +101 -0
  25. package/lib/typescript/src/CollapsiblePagerView.d.ts +2 -3
  26. package/lib/typescript/src/NativeScroller.d.ts +15 -7
  27. package/lib/typescript/src/NativeScrollerNativeComponent.d.ts +79 -0
  28. package/package.json +6 -3
  29. package/react-native-pager-view.podspec +2 -1
  30. package/react-native.config.js +15 -0
  31. package/src/CollapsiblePagerView.tsx +4 -4
  32. package/src/NativeScroller.tsx +291 -14
  33. package/src/NativeScrollerNativeComponent.ts +101 -0
package/README.md CHANGED
@@ -68,12 +68,59 @@ Web scroll child can opt in by exposing a DOM element with
68
68
  movement, so React is not updated on each vertical scroll frame.
69
69
 
70
70
  On iOS and Android, `nativeTabBar` renders the tab bar natively and
71
- `onNativeTabPress` reports presses. A press animates to the pressed page by
72
- default. Set `nativeTabPressAnimationEnabled={false}` to jump straight there,
73
- so pressing a distant tab does not scroll through every page in between,
74
- including unmounted pages that would show blank.
71
+ `onNativeTabPress` reports presses. By default, adjacent tab presses animate and
72
+ non-adjacent presses jump directly to the target without passing intermediate
73
+ pages. Set `nativeTabPressAnimationEnabled={true}` to always animate, or `false`
74
+ to always jump. Swipe gestures and imperative page commands are unchanged.
75
75
 
76
76
  Thank you again to Callstack and everyone who contributes to
77
77
  `react-native-pager-view` 💙
78
78
 
79
79
  The native ancestor-scroll contribution protocol is specified in [docs/SPEC.md](docs/SPEC.md).
80
+
81
+ ## NativeScroller compatibility
82
+
83
+ The independent iOS/Android `NativeScroller` has passing native builds and
84
+ focused simulator checks. The complete acceptance matrix remains open; see
85
+ [docs/SPEC.md](docs/SPEC.md) for the verified cases and limits. It is a
86
+ non-virtualized vertical scroller with one internal React content View. Fabric/Yoga lays out React children and content padding; size
87
+ changes are sent to native through layout props, not a JavaScript scroll loop.
88
+ Shared Fabric native state tracks local scroll displacement and ancestor header
89
+ travel separately, without per-frame JavaScript coordination. Descendant hit
90
+ testing and `measureInWindow` use that state; device acceptance must compare the
91
+ reported coordinates with visible React content after scrolling and page return.
92
+ The automatic flexGrow minimum uses a native-reported usable content height,
93
+ with static pager reservations accounted for once. This internal size event is
94
+ not public API and does not run on scroll frames. Before it arrives the automatic
95
+ minimum is zero; a larger explicit numeric minHeight remains authoritative.
96
+ Refresh insets and content-dependent bottom fill do not change this minimum.
97
+ Web continues using its existing ScrollView implementation.
98
+
99
+ The supported ScrollView subset is `contentContainerStyle` (including padding,
100
+ height, numeric minHeight and flexGrow), `onContentSizeChange`, `scrollEnabled`,
101
+ `nestedScrollEnabled`, indicators, `bounces`, `alwaysBounceVertical`,
102
+ `contentInset`, keyboard dismissal/persist-tap modes, `scrollEventThrottle`, and
103
+ the scroll/drag/momentum callbacks. Standard View props remain available.
104
+ `decelerationRate` applies on iOS; Android uses AndroidX's native fling physics.
105
+ `bounces`/`alwaysBounceVertical` and refresh tint/title are iOS controls; Android
106
+ uses refresh colors/background/size and does not expose a bounce policy here.
107
+ Tap-to-dismiss uses RN 0.86's JavaScript TextInput registry to preserve taps on
108
+ another input and child-handled presses; keyboard behavior requires device checks.
109
+ Horizontal scrolling, sticky rows, snapping, zoom, automatic keyboard insets and
110
+ the full ScrollView responder API are not provided.
111
+
112
+ Pass a standard `<RefreshControl refreshing={value} onRefresh={callback} />` as
113
+ `refreshControl`. Its enabled, tint/colors, background, offset, title/title color
114
+ and size props map to the component's native refresh controller; the RN refresh
115
+ element is not mounted. Refreshing remains controlled even if the callback does
116
+ not change the value. Arbitrary custom refresh components are not supported.
117
+
118
+ `NativeScrollerRef` exposes `scrollTo`, `scrollToEnd`, `flashScrollIndicators`,
119
+ host measurement/focus methods, `getNativeScrollRef` and `getScrollableNode`.
120
+ `pagerScrollKey` remains the stable primary-scroller identity. Native command
121
+ events use the ScrollView event shape; UIKit/AndroidX continue owning gestures
122
+ and momentum. Animated commands whose clamped target differs from the current
123
+ position emit a momentum begin/end pair; a no-op emits no new pair. A new command,
124
+ new touch, content-root replacement or detach ends the previous active sequence.
125
+ The focused JS tests verify wrapper routing, not native scrolling,
126
+ Yoga rendering, keyboard interaction or refresh placement on a device.
@@ -1,6 +1,8 @@
1
1
  package com.reactnativepagerview
2
2
 
3
3
  import android.content.Context
4
+ import android.graphics.Canvas
5
+ import android.graphics.Matrix
4
6
  import android.graphics.Rect
5
7
  import android.os.SystemClock
6
8
  import android.view.MotionEvent
@@ -9,6 +11,7 @@ import android.view.ViewConfiguration
9
11
  import android.view.ViewGroup
10
12
  import android.view.ViewTreeObserver
11
13
  import android.widget.HorizontalScrollView
14
+ import android.widget.ImageView
12
15
  import android.widget.ScrollView
13
16
  // OneKey patch: FrameLayout is inherited through NestedScrollableHost.
14
17
  // import android.widget.FrameLayout
@@ -17,11 +20,14 @@ import androidx.core.view.NestedScrollingParentHelper
17
20
  import androidx.core.view.ViewCompat
18
21
  import androidx.recyclerview.widget.LinearLayoutManager
19
22
  import androidx.recyclerview.widget.RecyclerView
23
+ import androidx.swiperefreshlayout.widget.CircularProgressDrawable
24
+ import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
20
25
  import androidx.viewpager2.widget.ViewPager2
21
26
  import com.facebook.react.R as ReactR
22
27
  import com.facebook.react.uimanager.events.NativeGestureUtil
23
28
  import com.facebook.react.views.scroll.ReactScrollView
24
29
  import com.margelo.nitro.nativelogger.OneKeyLog
30
+ import java.lang.ref.WeakReference
25
31
  import java.util.WeakHashMap
26
32
  import kotlin.math.max
27
33
  import kotlin.math.min
@@ -116,9 +122,19 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
116
122
  private val restoredRecyclerKeys = WeakHashMap<RecyclerView, String>()
117
123
  private val originalScrollerPadding = WeakHashMap<ScrollView, ScrollerPadding>()
118
124
  private val restoredScrollerKeys = WeakHashMap<ScrollView, String>()
125
+ private val ownedScrollerKeys = WeakHashMap<NativeScrollerViewport, String>()
126
+ private var foregroundRefreshIndicator: WeakReference<ImageView>? = null
127
+ private var refreshForegroundWasVisible = false
128
+ private val refreshForegroundMatrix = Matrix()
129
+ private val refreshForegroundOrigin = FloatArray(2)
130
+ private val refreshForegroundAncestors = ArrayList<View>()
131
+ private var refreshForegroundAncestorAlpha = 1f
119
132
  private val pageOffsets = HashMap<String, Int>()
120
133
  private var observedRecyclerView: RecyclerView? = null
121
134
  private var observedScrollListener: RecyclerView.OnScrollListener? = null
135
+ private var observedOwnedScroller: NativeScrollerViewport? = null
136
+ private var observedOwnedScrollerKey: String? = null
137
+ private var observedOwnedScrollerListener: ViewTreeObserver.OnScrollChangedListener? = null
122
138
  private var observedNativeScroller: ScrollView? = null
123
139
  private var observedNativeScrollerListener: ViewTreeObserver.OnScrollChangedListener? = null
124
140
  private var attachmentGeneration = 0
@@ -134,6 +150,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
134
150
  private var headerDownEvent: MotionEvent? = null
135
151
  private var headerHasHorizontalChild = false
136
152
  private var forwardedScrollable: View? = null
153
+ private var pendingRefreshPull: WeakReference<SwipeRefreshLayout>? = null
137
154
  private var nativeGestureStarted = false
138
155
  private var pressCancelled = false
139
156
  private val pageContentLayoutListener = ViewTreeObserver.OnPreDrawListener {
@@ -309,6 +326,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
309
326
  }
310
327
 
311
328
  fun removeReactChild(child: View) {
329
+ releaseRefreshForeground()
312
330
  val index = logicalChildren.indexOf(child)
313
331
  if (index < 0) return
314
332
  logicalChildren.removeAt(index)
@@ -326,6 +344,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
326
344
  findExplicitNativeScrollerInView(child) ?: findVerticalScrollableView(child)) {
327
345
  is RecyclerView -> restoreRecyclerInsets(scrollable)
328
346
  is ScrollView -> restoreNativeScrollerInsets(scrollable)
347
+ is NativeScrollerViewport -> restoreOwnedScrollerInsets(scrollable)
329
348
  }
330
349
  adapter.removePage(child)
331
350
  log("page-detach slot=${index - PAGE_SLOT_OFFSET} nativePages=${adapter.itemCount}")
@@ -334,6 +353,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
334
353
  }
335
354
 
336
355
  fun removeAllReactChildren() {
356
+ releaseRefreshForeground()
337
357
  detachPrimaryScrollObserver()
338
358
  for (recycler in originalRecyclerPadding.keys.toList()) {
339
359
  restoreRecyclerInsets(recycler)
@@ -341,6 +361,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
341
361
  for (scroller in originalScrollerPadding.keys.toList()) {
342
362
  restoreNativeScrollerInsets(scroller)
343
363
  }
364
+ for (scroller in ownedScrollerKeys.keys.toList()) restoreOwnedScrollerInsets(scroller)
344
365
  headerView?.let { view -> super.removeView(view) }
345
366
  stickyHeaderView?.let { view -> super.removeView(view) }
346
367
  headerView = null
@@ -356,8 +377,10 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
356
377
  fun reactChildAt(index: Int) = logicalChildren[index]
357
378
 
358
379
  fun selectPage(position: Int) {
380
+ pendingRefreshPull = null
359
381
  if (adapter.itemCount == 0) return
360
382
  detachPrimaryScrollObserver()
383
+ releaseRefreshForeground()
361
384
  selectedPage = position.coerceIn(0, adapter.itemCount - 1)
362
385
  val savedOffset = pageOffsets[currentPageKey()]
363
386
  ?: primaryScrollableForPage(selectedPage)?.let(::verticalScrollOffset)
@@ -391,7 +414,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
391
414
  fun attachedPageCount(): Int = (pager.getChildAt(0) as? RecyclerView)?.childCount ?: 0
392
415
 
393
416
  fun observedScrollableCount(): Int =
394
- originalRecyclerPadding.size + originalScrollerPadding.size
417
+ originalRecyclerPadding.size + originalScrollerPadding.size + ownedScrollerKeys.size
395
418
 
396
419
  private fun layoutPagerIfRequested() {
397
420
  val recycler = pager.getChildAt(0) as? RecyclerView ?: return
@@ -488,6 +511,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
488
511
 
489
512
  private fun maximumHeaderOffset(): Int {
490
513
  val scrollable = observedRecyclerView
514
+ ?: observedOwnedScroller
491
515
  ?: observedNativeScroller
492
516
  ?: primaryScrollableForPage(selectedPage)
493
517
  ?: return headerHeightPx
@@ -509,7 +533,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
509
533
  applyHeaderOffset()
510
534
  }
511
535
 
512
- private fun publishConsumedOffset(recycler: RecyclerView, remove: Boolean = false) {
536
+ private fun publishConsumedOffset(recycler: View, remove: Boolean = false) {
513
537
  val key = R.id.onekey_native_scroll_coordinator_consumed_offsets
514
538
  val existing = recycler.getTag(key) as? NativeScrollCoordinatorContributions
515
539
  val contributions = existing ?: if (remove) return else NativeScrollCoordinatorContributions().also {
@@ -530,6 +554,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
530
554
  nativeTabBarView.translationY = -offset
531
555
  nativeSubHeaderView.translationY = -offset
532
556
  observedRecyclerView?.let { publishConsumedOffset(it) }
557
+ observedOwnedScroller?.let { publishConsumedOffset(it) }
533
558
  }
534
559
 
535
560
  private fun pageKey(index: Int): String = pageKeys.getOrNull(index) ?: "page-$index"
@@ -556,6 +581,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
556
581
  }
557
582
 
558
583
  private fun findExplicitNativeScrollerInView(view: View): View? {
584
+ if (view is NativeScrollerView) return view.viewport
559
585
  if (view !== this && view is CollapsiblePagerHost) {
560
586
  return view.currentPrimaryScrollableForParent()
561
587
  }
@@ -572,6 +598,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
572
598
  }
573
599
 
574
600
  private fun findVerticalScrollableView(view: View): View? {
601
+ if (view is NativeScrollerView) return view.viewport
575
602
  if (view !== this && view is CollapsiblePagerHost) {
576
603
  return view.currentPrimaryScrollableForParent()
577
604
  }
@@ -600,6 +627,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
600
627
  private fun verticalScrollOffset(view: View): Int = when (view) {
601
628
  is RecyclerView -> view.computeVerticalScrollOffset()
602
629
  is ScrollView -> view.scrollY
630
+ is NativeScrollerViewport -> view.scrollY
603
631
  else -> 0
604
632
  }
605
633
 
@@ -610,13 +638,15 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
610
638
  when (val scrollable = primaryScrollableForPage(index)) {
611
639
  is RecyclerView -> applyRecyclerInsets(scrollable, index)
612
640
  is ScrollView -> applyNativeScrollerInsets(scrollable, index)
641
+ is NativeScrollerViewport -> applyOwnedScrollerInsets(scrollable, index)
613
642
  }
614
643
  }
644
+ updateRefreshForeground()
615
645
  }
616
646
 
617
647
  private fun attachPrimaryScrollObserver() {
618
648
  if (selectedPage !in 0 until adapter.itemCount) return
619
- val previous = observedRecyclerView ?: observedNativeScroller
649
+ val previous = observedRecyclerView ?: observedOwnedScroller ?: observedNativeScroller
620
650
  if (previous != null && !isDescendant(previous, adapter.pageAt(selectedPage))) {
621
651
  detachPrimaryScrollObserver()
622
652
  // A replacement scroller starts at the beginning while retaining header collapse.
@@ -625,6 +655,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
625
655
  when (val scrollable = primaryScrollableForPage(selectedPage)) {
626
656
  is RecyclerView -> attachRecyclerObserver(scrollable)
627
657
  is ScrollView -> attachNativeScrollerObserver(scrollable)
658
+ is NativeScrollerViewport -> attachOwnedScrollerObserver(scrollable)
628
659
  }
629
660
  }
630
661
 
@@ -718,10 +749,63 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
718
749
  }
719
750
 
720
751
  private fun detachPrimaryScrollObserver() {
752
+ detachOwnedScrollerObserver()
721
753
  detachRecyclerObserver()
722
754
  detachNativeScrollerObserver()
723
755
  }
724
756
 
757
+ private fun applyOwnedScrollerInsets(scroller: NativeScrollerViewport, pageIndex: Int) {
758
+ scroller.host.setPagerInset(this, headerHeightPx + stickyHeaderHeightPx)
759
+ publishConsumedOffset(scroller)
760
+ val key = pageKey(pageIndex)
761
+ if (ownedScrollerKeys[scroller] == key) return
762
+ ownedScrollerKeys[scroller] = key
763
+ postForCurrentAttachment {
764
+ if (ownedScrollerKeys[scroller] == key && scroller.isAttachedToWindow) {
765
+ scroller.scrollTo(0, pageOffsets[key] ?: 0)
766
+ }
767
+ }
768
+ }
769
+
770
+ private fun restoreOwnedScrollerInsets(scroller: NativeScrollerViewport) {
771
+ scroller.stopMotion()
772
+ ownedScrollerKeys.remove(scroller)
773
+ publishConsumedOffset(scroller, remove = true)
774
+ scroller.host.setPagerInset(this, null)
775
+ }
776
+
777
+ private fun attachOwnedScrollerObserver(scroller: NativeScrollerViewport) {
778
+ if (observedOwnedScroller === scroller && observedOwnedScrollerKey == currentPageKey()) {
779
+ applyOwnedScrollerInsets(scroller, selectedPage)
780
+ return
781
+ }
782
+ detachPrimaryScrollObserver()
783
+ observedOwnedScroller = scroller
784
+ observedOwnedScrollerKey = currentPageKey()
785
+ val key = currentPageKey()
786
+ applyOwnedScrollerInsets(scroller, selectedPage)
787
+ val listener = ViewTreeObserver.OnScrollChangedListener {
788
+ val contentOffset = scroller.scrollY
789
+ pageOffsets[key] = contentOffset
790
+ if (contentOffset > 0 && headerOffsetPx < headerHeightPx) setHeaderOffset(headerHeightPx)
791
+ }
792
+ observedOwnedScrollerListener = listener
793
+ scroller.viewTreeObserver.addOnScrollChangedListener(listener)
794
+ }
795
+
796
+ private fun detachOwnedScrollerObserver() {
797
+ val scroller = observedOwnedScroller ?: return
798
+ scroller.stopMotion()
799
+ publishConsumedOffset(scroller, remove = true)
800
+ observedOwnedScrollerKey?.let { pageOffsets[it] = scroller.scrollY }
801
+ observedOwnedScrollerListener?.let {
802
+ if (scroller.viewTreeObserver.isAlive) scroller.viewTreeObserver.removeOnScrollChangedListener(it)
803
+ }
804
+ observedOwnedScroller = null
805
+ observedOwnedScrollerKey = null
806
+ observedOwnedScrollerListener = null
807
+ }
808
+
725
809
  private fun applyRecyclerInsets(recycler: RecyclerView, pageIndex: Int) {
726
810
  val original = originalRecyclerPadding.getOrPut(recycler) {
727
811
  RecyclerPadding(
@@ -910,6 +994,136 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
910
994
  scroller.setPadding(original.left, original.top, original.right, original.bottom)
911
995
  }
912
996
 
997
+ private fun refreshLayoutFor(scrollable: View): SwipeRefreshLayout? {
998
+ if (scrollable !is ScrollView && scrollable !is RecyclerView && scrollable !is NativeScrollerViewport) return null
999
+ var ancestor = scrollable.parent
1000
+ while (ancestor != null && ancestor !is SwipeRefreshLayout) ancestor = ancestor.parent
1001
+ return ancestor as? SwipeRefreshLayout
1002
+ }
1003
+
1004
+ private fun refreshIndicatorFor(refresh: SwipeRefreshLayout): ImageView? {
1005
+ for (index in 0 until refresh.childCount) {
1006
+ val child = refresh.getChildAt(index)
1007
+ if (child is ImageView && child.drawable is CircularProgressDrawable) return child
1008
+ }
1009
+ return null
1010
+ }
1011
+
1012
+ private fun refreshForegroundOwner(scrollable: View): CollapsiblePagerHost? {
1013
+ var owner: CollapsiblePagerHost? = null
1014
+ var ancestor = scrollable.parent
1015
+ while (ancestor != null) {
1016
+ if (ancestor is CollapsiblePagerHost && ancestor.nativeSmoothHeaderScrollEnabled &&
1017
+ ancestor.isAttachedToWindow && ancestor.isShown &&
1018
+ ancestor.primaryScrollableForPage(ancestor.selectedPage) === scrollable) {
1019
+ owner = ancestor
1020
+ }
1021
+ ancestor = ancestor.parent
1022
+ }
1023
+ return owner
1024
+ }
1025
+
1026
+ private fun updateRefreshForeground() {
1027
+ val scrollable = primaryScrollableForPage(selectedPage)
1028
+ val refresh = scrollable?.takeIf { refreshForegroundOwner(it) === this }?.let(::refreshLayoutFor)
1029
+ val indicator = refresh?.let(::refreshIndicatorFor)
1030
+ if (foregroundRefreshIndicator?.get() !== indicator) releaseRefreshForeground()
1031
+ if (indicator != null) {
1032
+ if (foregroundRefreshIndicator?.get() !== indicator) {
1033
+ foregroundRefreshIndicator = WeakReference(indicator)
1034
+ }
1035
+ val state = refreshIndicatorAlphas.getOrPut(indicator) {
1036
+ NativeRefreshForegroundAlpha(indicator.alpha)
1037
+ }
1038
+ val alpha = state.update(indicator.alpha, this, acquire = true)
1039
+ if (indicator.alpha != alpha) indicator.alpha = alpha
1040
+ }
1041
+ val visible = indicator?.isShown == true &&
1042
+ (refreshIndicatorAlphas[indicator]?.drawingAlpha ?: 0f) > 0f
1043
+ // The foreground lies outside the original child's invalidation rectangle.
1044
+ // Repaint once on disappearance as well as throughout the native animation.
1045
+ if (visible || refreshForegroundWasVisible) postInvalidateOnAnimation()
1046
+ refreshForegroundWasVisible = visible
1047
+ }
1048
+
1049
+ private fun releaseRefreshForeground() {
1050
+ val indicator = foregroundRefreshIndicator?.get()
1051
+ foregroundRefreshIndicator = null
1052
+ if (indicator != null) {
1053
+ refreshIndicatorAlphas[indicator]?.let { state ->
1054
+ indicator.alpha = state.update(indicator.alpha, this, acquire = false)
1055
+ if (state.isEmpty) refreshIndicatorAlphas.remove(indicator)
1056
+ }
1057
+ }
1058
+ if (indicator != null || refreshForegroundWasVisible) invalidate()
1059
+ refreshForegroundWasVisible = false
1060
+ }
1061
+
1062
+ private fun refreshMatrixInHost(refresh: View): Boolean {
1063
+ refreshForegroundMatrix.reset()
1064
+ refreshForegroundAncestorAlpha = 1f
1065
+ refreshForegroundAncestors.clear()
1066
+ try {
1067
+ var current: View? = refresh
1068
+ while (current != null && current !== this) {
1069
+ refreshForegroundAncestors.add(current)
1070
+ current = current.parent as? View
1071
+ }
1072
+ if (current !== this) return false
1073
+ for (index in refreshForegroundAncestors.lastIndex downTo 0) {
1074
+ val child = refreshForegroundAncestors[index]
1075
+ val parent = child.parent as? View ?: return false
1076
+ refreshForegroundMatrix.preTranslate(
1077
+ (child.left - parent.scrollX).toFloat(), (child.top - parent.scrollY).toFloat(),
1078
+ )
1079
+ refreshForegroundMatrix.preConcat(child.matrix)
1080
+ refreshForegroundAncestorAlpha *= child.alpha
1081
+ }
1082
+ return true
1083
+ } finally {
1084
+ refreshForegroundAncestors.clear()
1085
+ }
1086
+ }
1087
+
1088
+ override fun dispatchDraw(canvas: Canvas) {
1089
+ // The original parent advances native refresh animations and their listeners.
1090
+ super.dispatchDraw(canvas)
1091
+ val indicator = foregroundRefreshIndicator?.get() ?: return
1092
+ val scrollable = primaryScrollableForPage(selectedPage) ?: return
1093
+ if (refreshForegroundOwner(scrollable) !== this) return
1094
+ val refresh = refreshLayoutFor(scrollable) ?: return
1095
+ if (refreshIndicatorFor(refresh) !== indicator || !indicator.isShown ||
1096
+ indicator.width <= 0 || indicator.height <= 0 || !refreshMatrixInHost(refresh)) return
1097
+ val alpha = (refreshIndicatorAlphas[indicator]?.drawingAlpha ?: return) *
1098
+ refreshForegroundAncestorAlpha
1099
+ if (alpha <= 0f) return
1100
+ refreshForegroundOrigin[0] = 0f
1101
+ refreshForegroundOrigin[1] = 0f
1102
+ refreshForegroundMatrix.mapPoints(refreshForegroundOrigin)
1103
+ val appliedInset = (refresh.getTag(R.id.onekey_native_scroll_coordinator_refresh_inset) as? Number)
1104
+ ?.toFloat() ?: 0f
1105
+ val save = canvas.save()
1106
+ try {
1107
+ // Pin the refresh container's origin to this Host top while retaining its
1108
+ // horizontal page position, native scale/pivot, and caller translation.
1109
+ canvas.translate(0f, -refreshForegroundOrigin[1])
1110
+ canvas.concat(refreshForegroundMatrix)
1111
+ canvas.translate(indicator.left.toFloat(), nativeRefreshForegroundTop(indicator.top, appliedInset))
1112
+ canvas.concat(indicator.matrix)
1113
+ if (alpha < 1f) {
1114
+ canvas.saveLayerAlpha(
1115
+ 0f, 0f, indicator.width.toFloat(), indicator.height.toFloat(),
1116
+ (alpha * 255f).roundToInt().coerceIn(0, 255),
1117
+ )
1118
+ }
1119
+ // Direct draw deliberately bypasses the alpha used to hide the original
1120
+ // RenderNode. Do not advance Animation.getTransformation a second time.
1121
+ indicator.draw(canvas)
1122
+ } finally {
1123
+ canvas.restoreToCount(save)
1124
+ }
1125
+ }
1126
+
913
1127
  private fun headerRegionAt(y: Float): String? {
914
1128
  val headerBottom = headerHeightPx - headerOffsetPx
915
1129
  val stickyBottom = headerHeightPx + stickyHeaderHeightPx - headerOffsetPx
@@ -1134,10 +1348,12 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
1134
1348
  axes and ViewCompat.SCROLL_AXIS_VERTICAL != 0 && isCurrentPageTarget(target)
1135
1349
 
1136
1350
  override fun onNestedScrollAccepted(child: View, target: View, axes: Int, type: Int) {
1351
+ if (type == ViewCompat.TYPE_TOUCH) pendingRefreshPull = null
1137
1352
  nestedScrollingParentHelper.onNestedScrollAccepted(child, target, axes, type)
1138
1353
  }
1139
1354
 
1140
1355
  override fun onStopNestedScroll(target: View, type: Int) {
1356
+ if (pendingRefreshPull?.get() === target) pendingRefreshPull = null
1141
1357
  nestedScrollingParentHelper.onStopNestedScroll(target, type)
1142
1358
  onHeaderOffsetChanged?.invoke()
1143
1359
  }
@@ -1152,10 +1368,18 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
1152
1368
  val used = min(dy, maximumOffset - headerOffsetPx)
1153
1369
  setHeaderOffset(headerOffsetPx + used)
1154
1370
  consumed[1] += used
1155
- } else if (dy < 0 && headerOffsetPx > 0 && !target.canScrollVertically(-1)) {
1156
- val used = max(dy, -headerOffsetPx)
1157
- setHeaderOffset(headerOffsetPx + used)
1158
- consumed[1] += used
1371
+ } else if (dy < 0 && headerOffsetPx > 0) {
1372
+ // Refresh wrappers forward nested scrolls but do not scroll themselves.
1373
+ val canScrollUp = if (target is SwipeRefreshLayout) {
1374
+ target.canChildScrollUp()
1375
+ } else {
1376
+ target.canScrollVertically(-1)
1377
+ }
1378
+ if (!canScrollUp) {
1379
+ val used = max(dy, -headerOffsetPx)
1380
+ setHeaderOffset(headerOffsetPx + used)
1381
+ consumed[1] += used
1382
+ }
1159
1383
  }
1160
1384
  }
1161
1385
 
@@ -1168,10 +1392,14 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
1168
1392
  type: Int,
1169
1393
  consumed: IntArray,
1170
1394
  ) {
1171
- if (!isCurrentPageTarget(target) || dyUnconsumed >= 0 || headerOffsetPx <= 0) return
1395
+ if (!isCurrentPageTarget(target) || dyUnconsumed >= 0) return
1172
1396
  val used = max(dyUnconsumed, -headerOffsetPx)
1173
1397
  setHeaderOffset(headerOffsetPx + used)
1174
1398
  consumed[1] += used
1399
+ if (type == ViewCompat.TYPE_TOUCH && dyUnconsumed < used &&
1400
+ target is SwipeRefreshLayout && target.isEnabled) {
1401
+ pendingRefreshPull = WeakReference(target)
1402
+ }
1175
1403
  }
1176
1404
 
1177
1405
  override fun onNestedScroll(
@@ -1193,6 +1421,20 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
1193
1421
  )
1194
1422
  }
1195
1423
 
1424
+ override fun onNestedPreFling(target: View, velocityX: Float, velocityY: Float): Boolean {
1425
+ val refresh = pendingRefreshPull?.get()
1426
+ val scrollable = primaryScrollableForPage(selectedPage)
1427
+ if (nativeSmoothHeaderScrollEnabled && refresh != null && refresh === target && isCurrentPageTarget(target) &&
1428
+ headerOffsetPx == 0 && scrollable != null && !scrollable.canScrollVertically(-1) &&
1429
+ refresh.isEnabled && !refresh.isRefreshing &&
1430
+ refreshIndicatorFor(refresh)?.visibility == View.VISIBLE) {
1431
+ // A small release reversal must settle the current refresh pull, not fling
1432
+ // content upward and make its first positive offset collapse the header.
1433
+ return true
1434
+ }
1435
+ return super.onNestedPreFling(target, velocityX, velocityY)
1436
+ }
1437
+
1196
1438
  override fun getNestedScrollAxes() = nestedScrollingParentHelper.nestedScrollAxes
1197
1439
 
1198
1440
  override fun onAttachedToWindow() {
@@ -1208,6 +1450,8 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
1208
1450
  }
1209
1451
 
1210
1452
  override fun onDetachedFromWindow() {
1453
+ pendingRefreshPull = null
1454
+ releaseRefreshForeground()
1211
1455
  attachmentGeneration += 1
1212
1456
  headerDownEvent?.recycle()
1213
1457
  headerDownEvent = null
@@ -1224,6 +1468,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
1224
1468
  for (scroller in originalScrollerPadding.keys.toList()) {
1225
1469
  restoreNativeScrollerInsets(scroller)
1226
1470
  }
1471
+ for (scroller in ownedScrollerKeys.keys.toList()) restoreOwnedScrollerInsets(scroller)
1227
1472
  log(
1228
1473
  "host-detach generation=$attachmentGeneration inner=$selectedPage " +
1229
1474
  "outer=${outerPagerIndex()} nativePages=${adapter.itemCount}",
@@ -1232,6 +1477,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
1232
1477
  }
1233
1478
 
1234
1479
  companion object {
1480
+ private val refreshIndicatorAlphas = WeakHashMap<ImageView, NativeRefreshForegroundAlpha>()
1235
1481
  const val NATIVE_SCROLLER_ID_PREFIX = "rnc-collapsible-pager-native-scroller:"
1236
1482
  const val PAGE_SLOT_OFFSET = 2
1237
1483
  }
@@ -1,5 +1,7 @@
1
1
  package com.reactnativepagerview
2
2
 
3
+ import java.util.WeakHashMap
4
+
3
5
  // Replace only this pager's contribution; another native owner may share the scroller.
4
6
  internal fun nativeScrollerScrollAwayTop(currentTop: Int, previousInset: Int, nextInset: Int): Int =
5
7
  (currentTop - previousInset).coerceAtLeast(0) + nextInset
@@ -15,3 +17,25 @@ internal fun nativeScrollerPaddingWasResetByScrollAway(
15
17
  scrollAwayBottom: Int,
16
18
  ): Boolean = appliedInset > 0 && left == 0 && top == 0 && right == 0 &&
17
19
  bottom == scrollAwayTop + scrollAwayBottom
20
+
21
+ // The refresh controller publishes its actual combined ancestor compensation.
22
+ internal fun nativeRefreshForegroundTop(indicatorTop: Int, appliedInset: Float): Float =
23
+ indicatorTop - appliedInset
24
+
25
+ internal class NativeRefreshForegroundAlpha(initialAlpha: Float) {
26
+ private val owners = WeakHashMap<Any, Unit>()
27
+ private var baseline = initialAlpha
28
+ private var lastApplied = initialAlpha
29
+
30
+ val isEmpty: Boolean
31
+ get() = owners.isEmpty()
32
+ val drawingAlpha: Float
33
+ get() = baseline
34
+
35
+ fun update(currentAlpha: Float, owner: Any, acquire: Boolean): Float {
36
+ if (currentAlpha != lastApplied) baseline = currentAlpha
37
+ if (acquire) owners[owner] = Unit else owners.remove(owner)
38
+ lastApplied = if (owners.isEmpty()) baseline else 0f
39
+ return lastApplied
40
+ }
41
+ }