@onekeyfe/react-native-pager-view 3.0.157-alpha.257 → 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 +237 -43
  3. package/android/src/main/java/com/reactnativepagerview/NativeScrollerScrollAwayInsets.kt +15 -11
  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 +32 -52
  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 +267 -34
  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
@@ -19,11 +21,11 @@ import androidx.core.view.ViewCompat
19
21
  import androidx.recyclerview.widget.LinearLayoutManager
20
22
  import androidx.recyclerview.widget.RecyclerView
21
23
  import androidx.swiperefreshlayout.widget.CircularProgressDrawable
24
+ import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
22
25
  import androidx.viewpager2.widget.ViewPager2
23
26
  import com.facebook.react.R as ReactR
24
27
  import com.facebook.react.uimanager.events.NativeGestureUtil
25
28
  import com.facebook.react.views.scroll.ReactScrollView
26
- import com.facebook.react.views.swiperefresh.ReactSwipeRefreshLayout
27
29
  import com.margelo.nitro.nativelogger.OneKeyLog
28
30
  import java.lang.ref.WeakReference
29
31
  import java.util.WeakHashMap
@@ -120,10 +122,19 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
120
122
  private val restoredRecyclerKeys = WeakHashMap<RecyclerView, String>()
121
123
  private val originalScrollerPadding = WeakHashMap<ScrollView, ScrollerPadding>()
122
124
  private val restoredScrollerKeys = WeakHashMap<ScrollView, String>()
123
- private val scrollerRefreshIndicators = WeakHashMap<ScrollView, WeakReference<View>>()
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
124
132
  private val pageOffsets = HashMap<String, Int>()
125
133
  private var observedRecyclerView: RecyclerView? = null
126
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
127
138
  private var observedNativeScroller: ScrollView? = null
128
139
  private var observedNativeScrollerListener: ViewTreeObserver.OnScrollChangedListener? = null
129
140
  private var attachmentGeneration = 0
@@ -139,6 +150,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
139
150
  private var headerDownEvent: MotionEvent? = null
140
151
  private var headerHasHorizontalChild = false
141
152
  private var forwardedScrollable: View? = null
153
+ private var pendingRefreshPull: WeakReference<SwipeRefreshLayout>? = null
142
154
  private var nativeGestureStarted = false
143
155
  private var pressCancelled = false
144
156
  private val pageContentLayoutListener = ViewTreeObserver.OnPreDrawListener {
@@ -314,6 +326,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
314
326
  }
315
327
 
316
328
  fun removeReactChild(child: View) {
329
+ releaseRefreshForeground()
317
330
  val index = logicalChildren.indexOf(child)
318
331
  if (index < 0) return
319
332
  logicalChildren.removeAt(index)
@@ -331,6 +344,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
331
344
  findExplicitNativeScrollerInView(child) ?: findVerticalScrollableView(child)) {
332
345
  is RecyclerView -> restoreRecyclerInsets(scrollable)
333
346
  is ScrollView -> restoreNativeScrollerInsets(scrollable)
347
+ is NativeScrollerViewport -> restoreOwnedScrollerInsets(scrollable)
334
348
  }
335
349
  adapter.removePage(child)
336
350
  log("page-detach slot=${index - PAGE_SLOT_OFFSET} nativePages=${adapter.itemCount}")
@@ -339,6 +353,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
339
353
  }
340
354
 
341
355
  fun removeAllReactChildren() {
356
+ releaseRefreshForeground()
342
357
  detachPrimaryScrollObserver()
343
358
  for (recycler in originalRecyclerPadding.keys.toList()) {
344
359
  restoreRecyclerInsets(recycler)
@@ -346,6 +361,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
346
361
  for (scroller in originalScrollerPadding.keys.toList()) {
347
362
  restoreNativeScrollerInsets(scroller)
348
363
  }
364
+ for (scroller in ownedScrollerKeys.keys.toList()) restoreOwnedScrollerInsets(scroller)
349
365
  headerView?.let { view -> super.removeView(view) }
350
366
  stickyHeaderView?.let { view -> super.removeView(view) }
351
367
  headerView = null
@@ -361,8 +377,10 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
361
377
  fun reactChildAt(index: Int) = logicalChildren[index]
362
378
 
363
379
  fun selectPage(position: Int) {
380
+ pendingRefreshPull = null
364
381
  if (adapter.itemCount == 0) return
365
382
  detachPrimaryScrollObserver()
383
+ releaseRefreshForeground()
366
384
  selectedPage = position.coerceIn(0, adapter.itemCount - 1)
367
385
  val savedOffset = pageOffsets[currentPageKey()]
368
386
  ?: primaryScrollableForPage(selectedPage)?.let(::verticalScrollOffset)
@@ -396,7 +414,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
396
414
  fun attachedPageCount(): Int = (pager.getChildAt(0) as? RecyclerView)?.childCount ?: 0
397
415
 
398
416
  fun observedScrollableCount(): Int =
399
- originalRecyclerPadding.size + originalScrollerPadding.size
417
+ originalRecyclerPadding.size + originalScrollerPadding.size + ownedScrollerKeys.size
400
418
 
401
419
  private fun layoutPagerIfRequested() {
402
420
  val recycler = pager.getChildAt(0) as? RecyclerView ?: return
@@ -493,6 +511,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
493
511
 
494
512
  private fun maximumHeaderOffset(): Int {
495
513
  val scrollable = observedRecyclerView
514
+ ?: observedOwnedScroller
496
515
  ?: observedNativeScroller
497
516
  ?: primaryScrollableForPage(selectedPage)
498
517
  ?: return headerHeightPx
@@ -514,7 +533,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
514
533
  applyHeaderOffset()
515
534
  }
516
535
 
517
- private fun publishConsumedOffset(recycler: RecyclerView, remove: Boolean = false) {
536
+ private fun publishConsumedOffset(recycler: View, remove: Boolean = false) {
518
537
  val key = R.id.onekey_native_scroll_coordinator_consumed_offsets
519
538
  val existing = recycler.getTag(key) as? NativeScrollCoordinatorContributions
520
539
  val contributions = existing ?: if (remove) return else NativeScrollCoordinatorContributions().also {
@@ -535,6 +554,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
535
554
  nativeTabBarView.translationY = -offset
536
555
  nativeSubHeaderView.translationY = -offset
537
556
  observedRecyclerView?.let { publishConsumedOffset(it) }
557
+ observedOwnedScroller?.let { publishConsumedOffset(it) }
538
558
  }
539
559
 
540
560
  private fun pageKey(index: Int): String = pageKeys.getOrNull(index) ?: "page-$index"
@@ -561,6 +581,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
561
581
  }
562
582
 
563
583
  private fun findExplicitNativeScrollerInView(view: View): View? {
584
+ if (view is NativeScrollerView) return view.viewport
564
585
  if (view !== this && view is CollapsiblePagerHost) {
565
586
  return view.currentPrimaryScrollableForParent()
566
587
  }
@@ -577,6 +598,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
577
598
  }
578
599
 
579
600
  private fun findVerticalScrollableView(view: View): View? {
601
+ if (view is NativeScrollerView) return view.viewport
580
602
  if (view !== this && view is CollapsiblePagerHost) {
581
603
  return view.currentPrimaryScrollableForParent()
582
604
  }
@@ -605,29 +627,26 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
605
627
  private fun verticalScrollOffset(view: View): Int = when (view) {
606
628
  is RecyclerView -> view.computeVerticalScrollOffset()
607
629
  is ScrollView -> view.scrollY
630
+ is NativeScrollerViewport -> view.scrollY
608
631
  else -> 0
609
632
  }
610
633
 
611
634
  private fun prepareAdjacentPages() {
612
- // Retained pages and replaced controls may leave the attached view tree.
613
- for ((scroller, indicator) in scrollerRefreshIndicators.toMap()) {
614
- if (nativeScrollerRefreshIndicator(scroller) !== indicator.get()) {
615
- restoreNativeScrollerRefreshIndicator(scroller)
616
- }
617
- }
618
635
  val first = max(0, selectedPage - 1)
619
636
  val last = min(adapter.itemCount - 1, selectedPage + 1)
620
637
  for (index in first..last) {
621
638
  when (val scrollable = primaryScrollableForPage(index)) {
622
639
  is RecyclerView -> applyRecyclerInsets(scrollable, index)
623
640
  is ScrollView -> applyNativeScrollerInsets(scrollable, index)
641
+ is NativeScrollerViewport -> applyOwnedScrollerInsets(scrollable, index)
624
642
  }
625
643
  }
644
+ updateRefreshForeground()
626
645
  }
627
646
 
628
647
  private fun attachPrimaryScrollObserver() {
629
648
  if (selectedPage !in 0 until adapter.itemCount) return
630
- val previous = observedRecyclerView ?: observedNativeScroller
649
+ val previous = observedRecyclerView ?: observedOwnedScroller ?: observedNativeScroller
631
650
  if (previous != null && !isDescendant(previous, adapter.pageAt(selectedPage))) {
632
651
  detachPrimaryScrollObserver()
633
652
  // A replacement scroller starts at the beginning while retaining header collapse.
@@ -636,6 +655,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
636
655
  when (val scrollable = primaryScrollableForPage(selectedPage)) {
637
656
  is RecyclerView -> attachRecyclerObserver(scrollable)
638
657
  is ScrollView -> attachNativeScrollerObserver(scrollable)
658
+ is NativeScrollerViewport -> attachOwnedScrollerObserver(scrollable)
639
659
  }
640
660
  }
641
661
 
@@ -729,10 +749,63 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
729
749
  }
730
750
 
731
751
  private fun detachPrimaryScrollObserver() {
752
+ detachOwnedScrollerObserver()
732
753
  detachRecyclerObserver()
733
754
  detachNativeScrollerObserver()
734
755
  }
735
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
+
736
809
  private fun applyRecyclerInsets(recycler: RecyclerView, pageIndex: Int) {
737
810
  val original = originalRecyclerPadding.getOrPut(recycler) {
738
811
  RecyclerPadding(
@@ -805,7 +878,6 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
805
878
  updateScrollerPaddingOwnership(scroller, original)
806
879
  val topInset = headerHeightPx + stickyHeaderHeightPx
807
880
  scroller.clipToPadding = false
808
- applyNativeScrollerRefreshIndicator(scroller, topInset)
809
881
  if (scroller is ReactScrollView) {
810
882
  // Fabric owns the content child's layout, so ScrollView padding alone
811
883
  // cannot move it. RN's native ScrollAway API also updates Fabric culling.
@@ -908,7 +980,6 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
908
980
  }
909
981
 
910
982
  private fun restoreNativeScrollerInsets(scroller: ScrollView) {
911
- restoreNativeScrollerRefreshIndicator(scroller)
912
983
  val original = originalScrollerPadding.remove(scroller) ?: return
913
984
  updateScrollerPaddingOwnership(scroller, original)
914
985
  restoredScrollerKeys.remove(scroller)
@@ -923,12 +994,14 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
923
994
  scroller.setPadding(original.left, original.top, original.right, original.bottom)
924
995
  }
925
996
 
926
- private fun nativeScrollerRefreshIndicator(scroller: ScrollView): View? {
927
- if (!nativeSmoothHeaderScrollEnabled) return null
928
- var ancestor = scroller.parent
929
- while (ancestor != null && ancestor !== this) ancestor = ancestor.parent
930
- if (ancestor !== this) return null
931
- val refresh = scroller.parent as? ReactSwipeRefreshLayout ?: return null
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? {
932
1005
  for (index in 0 until refresh.childCount) {
933
1006
  val child = refresh.getChildAt(index)
934
1007
  if (child is ImageView && child.drawable is CircularProgressDrawable) return child
@@ -936,29 +1009,119 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
936
1009
  return null
937
1010
  }
938
1011
 
939
- private fun applyNativeScrollerRefreshIndicator(scroller: ScrollView, inset: Int) {
940
- val indicator = nativeScrollerRefreshIndicator(scroller)
941
- if (scrollerRefreshIndicators[scroller]?.get() !== indicator) {
942
- restoreNativeScrollerRefreshIndicator(scroller)
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
943
1040
  }
944
- if (indicator == null) return
945
- if (scrollerRefreshIndicators[scroller]?.get() !== indicator) {
946
- scrollerRefreshIndicators[scroller] = WeakReference(indicator)
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
+ }
947
1057
  }
948
- val translation = refreshIndicatorTranslations.getOrPut(indicator) {
949
- NativeScrollerRefreshTranslation(indicator.translationY)
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()
950
1085
  }
951
- // Moving only the drawing preserves RN's offsets and AndroidX's active pull/refresh animation.
952
- // setProgressViewOffset would reset that animation and clear isRefreshing.
953
- val next = translation.update(indicator.translationY, this, inset.toFloat())
954
- if (indicator.translationY != next) indicator.translationY = next
955
1086
  }
956
1087
 
957
- private fun restoreNativeScrollerRefreshIndicator(scroller: ScrollView) {
958
- val indicator = scrollerRefreshIndicators.remove(scroller)?.get() ?: return
959
- val translation = refreshIndicatorTranslations[indicator] ?: return
960
- indicator.translationY = translation.update(indicator.translationY, this, null)
961
- if (translation.isEmpty) refreshIndicatorTranslations.remove(indicator)
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
+ }
962
1125
  }
963
1126
 
964
1127
  private fun headerRegionAt(y: Float): String? {
@@ -1185,10 +1348,12 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
1185
1348
  axes and ViewCompat.SCROLL_AXIS_VERTICAL != 0 && isCurrentPageTarget(target)
1186
1349
 
1187
1350
  override fun onNestedScrollAccepted(child: View, target: View, axes: Int, type: Int) {
1351
+ if (type == ViewCompat.TYPE_TOUCH) pendingRefreshPull = null
1188
1352
  nestedScrollingParentHelper.onNestedScrollAccepted(child, target, axes, type)
1189
1353
  }
1190
1354
 
1191
1355
  override fun onStopNestedScroll(target: View, type: Int) {
1356
+ if (pendingRefreshPull?.get() === target) pendingRefreshPull = null
1192
1357
  nestedScrollingParentHelper.onStopNestedScroll(target, type)
1193
1358
  onHeaderOffsetChanged?.invoke()
1194
1359
  }
@@ -1203,10 +1368,18 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
1203
1368
  val used = min(dy, maximumOffset - headerOffsetPx)
1204
1369
  setHeaderOffset(headerOffsetPx + used)
1205
1370
  consumed[1] += used
1206
- } else if (dy < 0 && headerOffsetPx > 0 && !target.canScrollVertically(-1)) {
1207
- val used = max(dy, -headerOffsetPx)
1208
- setHeaderOffset(headerOffsetPx + used)
1209
- 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
+ }
1210
1383
  }
1211
1384
  }
1212
1385
 
@@ -1219,10 +1392,14 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
1219
1392
  type: Int,
1220
1393
  consumed: IntArray,
1221
1394
  ) {
1222
- if (!isCurrentPageTarget(target) || dyUnconsumed >= 0 || headerOffsetPx <= 0) return
1395
+ if (!isCurrentPageTarget(target) || dyUnconsumed >= 0) return
1223
1396
  val used = max(dyUnconsumed, -headerOffsetPx)
1224
1397
  setHeaderOffset(headerOffsetPx + used)
1225
1398
  consumed[1] += used
1399
+ if (type == ViewCompat.TYPE_TOUCH && dyUnconsumed < used &&
1400
+ target is SwipeRefreshLayout && target.isEnabled) {
1401
+ pendingRefreshPull = WeakReference(target)
1402
+ }
1226
1403
  }
1227
1404
 
1228
1405
  override fun onNestedScroll(
@@ -1244,6 +1421,20 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
1244
1421
  )
1245
1422
  }
1246
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
+
1247
1438
  override fun getNestedScrollAxes() = nestedScrollingParentHelper.nestedScrollAxes
1248
1439
 
1249
1440
  override fun onAttachedToWindow() {
@@ -1259,6 +1450,8 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
1259
1450
  }
1260
1451
 
1261
1452
  override fun onDetachedFromWindow() {
1453
+ pendingRefreshPull = null
1454
+ releaseRefreshForeground()
1262
1455
  attachmentGeneration += 1
1263
1456
  headerDownEvent?.recycle()
1264
1457
  headerDownEvent = null
@@ -1275,6 +1468,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
1275
1468
  for (scroller in originalScrollerPadding.keys.toList()) {
1276
1469
  restoreNativeScrollerInsets(scroller)
1277
1470
  }
1471
+ for (scroller in ownedScrollerKeys.keys.toList()) restoreOwnedScrollerInsets(scroller)
1278
1472
  log(
1279
1473
  "host-detach generation=$attachmentGeneration inner=$selectedPage " +
1280
1474
  "outer=${outerPagerIndex()} nativePages=${adapter.itemCount}",
@@ -1283,7 +1477,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
1283
1477
  }
1284
1478
 
1285
1479
  companion object {
1286
- private val refreshIndicatorTranslations = WeakHashMap<View, NativeScrollerRefreshTranslation>()
1480
+ private val refreshIndicatorAlphas = WeakHashMap<ImageView, NativeRefreshForegroundAlpha>()
1287
1481
  const val NATIVE_SCROLLER_ID_PREFIX = "rnc-collapsible-pager-native-scroller:"
1288
1482
  const val PAGE_SLOT_OFFSET = 2
1289
1483
  }
@@ -18,20 +18,24 @@ internal fun nativeScrollerPaddingWasResetByScrollAway(
18
18
  ): Boolean = appliedInset > 0 && left == 0 && top == 0 && right == 0 &&
19
19
  bottom == scrollAwayTop + scrollAwayBottom
20
20
 
21
- internal class NativeScrollerRefreshTranslation(initialTranslation: Float) {
22
- private val contributions = WeakHashMap<Any, Float>()
23
- private var baseline = initialTranslation
24
- private var lastApplied = initialTranslation
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
25
29
 
26
30
  val isEmpty: Boolean
27
- get() = contributions.isEmpty()
31
+ get() = owners.isEmpty()
32
+ val drawingAlpha: Float
33
+ get() = baseline
28
34
 
29
- fun update(currentTranslation: Float, owner: Any, inset: Float?): Float {
30
- // Native refresh layout owns top/scale, while an external translation is
31
- // an absolute baseline rather than another copy of our previous inset.
32
- if (currentTranslation != lastApplied) baseline = currentTranslation
33
- if (inset == null) contributions.remove(owner) else contributions[owner] = inset
34
- lastApplied = baseline + contributions.values.sum()
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
35
39
  return lastApplied
36
40
  }
37
41
  }