@onekeyfe/react-native-pager-view 3.0.157-alpha.257 → 3.0.157-alpha.262

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 (35) hide show
  1. package/README.md +55 -4
  2. package/android/src/main/java/com/reactnativepagerview/CollapsiblePagerHost.kt +295 -59
  3. package/android/src/main/java/com/reactnativepagerview/NativeScrollerPendingRestore.kt +26 -0
  4. package/android/src/main/java/com/reactnativepagerview/NativeScrollerScrollAwayInsets.kt +15 -11
  5. package/android/src/main/java/com/reactnativepagerview/NativeScrollerView.kt +600 -0
  6. package/android/src/main/java/com/reactnativepagerview/NativeScrollerViewManager.kt +145 -0
  7. package/android/src/main/java/com/reactnativepagerview/PagerViewViewPackage.kt +1 -1
  8. package/android/src/main/jni/CMakeLists.txt +19 -0
  9. package/android/src/main/jni/pagerview.h +11 -0
  10. package/android/src/main/res/values/native_scroll_coordinator_ids.xml +1 -0
  11. package/android/src/test/java/com/reactnativepagerview/NativeScrollerPendingRestoreTest.kt +31 -0
  12. package/android/src/test/java/com/reactnativepagerview/NativeScrollerScrollAwayInsetsTest.kt +32 -52
  13. package/common/cpp/react/renderer/components/pagerview/RNCNativeScrollerComponentDescriptor.h +8 -0
  14. package/common/cpp/react/renderer/components/pagerview/RNCNativeScrollerShadowNode.cpp +15 -0
  15. package/common/cpp/react/renderer/components/pagerview/RNCNativeScrollerShadowNode.h +23 -0
  16. package/common/cpp/react/renderer/components/pagerview/RNCNativeScrollerState.h +33 -0
  17. package/docs/SPEC.md +282 -34
  18. package/ios/RNCCollapsiblePagerViewComponentView.mm +187 -1
  19. package/ios/RNCNativeScrollerComponentView.h +17 -0
  20. package/ios/RNCNativeScrollerComponentView.mm +479 -0
  21. package/ios/tests/RNCCollapsiblePagerPressCancellationTests.mm +759 -0
  22. package/ios/tests/RNCCollapsiblePagerPressCancellationWiringTests.mm +109 -0
  23. package/ios/tests/RNCNativeScrollerTests.mm +252 -0
  24. package/lib/module/CollapsiblePagerView.js +10 -1
  25. package/lib/module/NativeScroller.js +171 -11
  26. package/lib/module/NativeScrollerNativeComponent.ts +101 -0
  27. package/lib/typescript/src/CollapsiblePagerView.d.ts +3 -3
  28. package/lib/typescript/src/NativeScroller.d.ts +15 -7
  29. package/lib/typescript/src/NativeScrollerNativeComponent.d.ts +79 -0
  30. package/package.json +7 -4
  31. package/react-native-pager-view.podspec +2 -1
  32. package/react-native.config.js +15 -0
  33. package/src/CollapsiblePagerView.tsx +14 -4
  34. package/src/NativeScroller.tsx +291 -14
  35. package/src/NativeScrollerNativeComponent.ts +101 -0
@@ -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,12 +122,24 @@ 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
138
+ private var observedOwnedScrollerTreeObserver: ViewTreeObserver? = null
127
139
  private var observedNativeScroller: ScrollView? = null
128
140
  private var observedNativeScrollerListener: ViewTreeObserver.OnScrollChangedListener? = null
141
+ private var observedNativeScrollerTreeObserver: ViewTreeObserver? = null
142
+ private var observedPageRemoved = false
129
143
  private var attachmentGeneration = 0
130
144
  private val touchSlopPx = ViewConfiguration.get(context).scaledTouchSlop
131
145
  private val hostIdentity = System.identityHashCode(this)
@@ -139,6 +153,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
139
153
  private var headerDownEvent: MotionEvent? = null
140
154
  private var headerHasHorizontalChild = false
141
155
  private var forwardedScrollable: View? = null
156
+ private var pendingRefreshPull: WeakReference<SwipeRefreshLayout>? = null
142
157
  private var nativeGestureStarted = false
143
158
  private var pressCancelled = false
144
159
  private val pageContentLayoutListener = ViewTreeObserver.OnPreDrawListener {
@@ -314,6 +329,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
314
329
  }
315
330
 
316
331
  fun removeReactChild(child: View) {
332
+ releaseRefreshForeground()
317
333
  val index = logicalChildren.indexOf(child)
318
334
  if (index < 0) return
319
335
  logicalChildren.removeAt(index)
@@ -327,10 +343,17 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
327
343
  stickyHeaderView = null
328
344
  }
329
345
  else -> {
346
+ val observed = observedRecyclerView ?: observedOwnedScroller ?: observedNativeScroller
347
+ if (observed != null && isDescendant(observed, child)) {
348
+ // The page detaches only later; its scroller must not keep writing this key.
349
+ detachPrimaryScrollObserver(recordOffset = false)
350
+ observedPageRemoved = true
351
+ }
330
352
  when (val scrollable =
331
353
  findExplicitNativeScrollerInView(child) ?: findVerticalScrollableView(child)) {
332
354
  is RecyclerView -> restoreRecyclerInsets(scrollable)
333
355
  is ScrollView -> restoreNativeScrollerInsets(scrollable)
356
+ is NativeScrollerViewport -> restoreOwnedScrollerInsets(scrollable)
334
357
  }
335
358
  adapter.removePage(child)
336
359
  log("page-detach slot=${index - PAGE_SLOT_OFFSET} nativePages=${adapter.itemCount}")
@@ -339,6 +362,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
339
362
  }
340
363
 
341
364
  fun removeAllReactChildren() {
365
+ releaseRefreshForeground()
342
366
  detachPrimaryScrollObserver()
343
367
  for (recycler in originalRecyclerPadding.keys.toList()) {
344
368
  restoreRecyclerInsets(recycler)
@@ -346,6 +370,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
346
370
  for (scroller in originalScrollerPadding.keys.toList()) {
347
371
  restoreNativeScrollerInsets(scroller)
348
372
  }
373
+ for (scroller in ownedScrollerKeys.keys.toList()) restoreOwnedScrollerInsets(scroller)
349
374
  headerView?.let { view -> super.removeView(view) }
350
375
  stickyHeaderView?.let { view -> super.removeView(view) }
351
376
  headerView = null
@@ -361,8 +386,10 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
361
386
  fun reactChildAt(index: Int) = logicalChildren[index]
362
387
 
363
388
  fun selectPage(position: Int) {
389
+ pendingRefreshPull = null
364
390
  if (adapter.itemCount == 0) return
365
391
  detachPrimaryScrollObserver()
392
+ releaseRefreshForeground()
366
393
  selectedPage = position.coerceIn(0, adapter.itemCount - 1)
367
394
  val savedOffset = pageOffsets[currentPageKey()]
368
395
  ?: primaryScrollableForPage(selectedPage)?.let(::verticalScrollOffset)
@@ -396,7 +423,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
396
423
  fun attachedPageCount(): Int = (pager.getChildAt(0) as? RecyclerView)?.childCount ?: 0
397
424
 
398
425
  fun observedScrollableCount(): Int =
399
- originalRecyclerPadding.size + originalScrollerPadding.size
426
+ originalRecyclerPadding.size + originalScrollerPadding.size + ownedScrollerKeys.size
400
427
 
401
428
  private fun layoutPagerIfRequested() {
402
429
  val recycler = pager.getChildAt(0) as? RecyclerView ?: return
@@ -493,6 +520,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
493
520
 
494
521
  private fun maximumHeaderOffset(): Int {
495
522
  val scrollable = observedRecyclerView
523
+ ?: observedOwnedScroller
496
524
  ?: observedNativeScroller
497
525
  ?: primaryScrollableForPage(selectedPage)
498
526
  ?: return headerHeightPx
@@ -514,7 +542,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
514
542
  applyHeaderOffset()
515
543
  }
516
544
 
517
- private fun publishConsumedOffset(recycler: RecyclerView, remove: Boolean = false) {
545
+ private fun publishConsumedOffset(recycler: View, remove: Boolean = false) {
518
546
  val key = R.id.onekey_native_scroll_coordinator_consumed_offsets
519
547
  val existing = recycler.getTag(key) as? NativeScrollCoordinatorContributions
520
548
  val contributions = existing ?: if (remove) return else NativeScrollCoordinatorContributions().also {
@@ -535,6 +563,8 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
535
563
  nativeTabBarView.translationY = -offset
536
564
  nativeSubHeaderView.translationY = -offset
537
565
  observedRecyclerView?.let { publishConsumedOffset(it) }
566
+ // Adjacent owned pages are translated too; unchanged values are not republished.
567
+ for (scroller in ownedScrollerKeys.keys) publishConsumedOffset(scroller)
538
568
  }
539
569
 
540
570
  private fun pageKey(index: Int): String = pageKeys.getOrNull(index) ?: "page-$index"
@@ -561,6 +591,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
561
591
  }
562
592
 
563
593
  private fun findExplicitNativeScrollerInView(view: View): View? {
594
+ if (view is NativeScrollerView) return view.viewport
564
595
  if (view !== this && view is CollapsiblePagerHost) {
565
596
  return view.currentPrimaryScrollableForParent()
566
597
  }
@@ -577,6 +608,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
577
608
  }
578
609
 
579
610
  private fun findVerticalScrollableView(view: View): View? {
611
+ if (view is NativeScrollerView) return view.viewport
580
612
  if (view !== this && view is CollapsiblePagerHost) {
581
613
  return view.currentPrimaryScrollableForParent()
582
614
  }
@@ -605,30 +637,28 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
605
637
  private fun verticalScrollOffset(view: View): Int = when (view) {
606
638
  is RecyclerView -> view.computeVerticalScrollOffset()
607
639
  is ScrollView -> view.scrollY
640
+ is NativeScrollerViewport -> view.scrollY
608
641
  else -> 0
609
642
  }
610
643
 
611
644
  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
645
  val first = max(0, selectedPage - 1)
619
646
  val last = min(adapter.itemCount - 1, selectedPage + 1)
620
647
  for (index in first..last) {
621
648
  when (val scrollable = primaryScrollableForPage(index)) {
622
649
  is RecyclerView -> applyRecyclerInsets(scrollable, index)
623
650
  is ScrollView -> applyNativeScrollerInsets(scrollable, index)
651
+ is NativeScrollerViewport -> applyOwnedScrollerInsets(scrollable, index)
624
652
  }
625
653
  }
654
+ updateRefreshForeground()
626
655
  }
627
656
 
628
657
  private fun attachPrimaryScrollObserver() {
629
658
  if (selectedPage !in 0 until adapter.itemCount) return
630
- val previous = observedRecyclerView ?: observedNativeScroller
631
- if (previous != null && !isDescendant(previous, adapter.pageAt(selectedPage))) {
659
+ val previous = observedRecyclerView ?: observedOwnedScroller ?: observedNativeScroller
660
+ if (observedPageRemoved || previous != null && !isDescendant(previous, adapter.pageAt(selectedPage))) {
661
+ observedPageRemoved = false
632
662
  detachPrimaryScrollObserver()
633
663
  // A replacement scroller starts at the beginning while retaining header collapse.
634
664
  pageOffsets[currentPageKey()] = 0
@@ -636,6 +666,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
636
666
  when (val scrollable = primaryScrollableForPage(selectedPage)) {
637
667
  is RecyclerView -> attachRecyclerObserver(scrollable)
638
668
  is ScrollView -> attachNativeScrollerObserver(scrollable)
669
+ is NativeScrollerViewport -> attachOwnedScrollerObserver(scrollable)
639
670
  }
640
671
  }
641
672
 
@@ -669,11 +700,11 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
669
700
  )
670
701
  }
671
702
 
672
- private fun detachRecyclerObserver() {
703
+ private fun detachRecyclerObserver(recordOffset: Boolean) {
673
704
  val recycler = observedRecyclerView
674
705
  val listener = observedScrollListener
675
706
  if (recycler != null) {
676
- pageOffsets[currentPageKey()] = recycler.computeVerticalScrollOffset()
707
+ if (recordOffset) pageOffsets[currentPageKey()] = recycler.computeVerticalScrollOffset()
677
708
  publishConsumedOffset(recycler, remove = true)
678
709
  }
679
710
  if (recycler != null && listener != null) recycler.removeOnScrollListener(listener)
@@ -696,27 +727,35 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
696
727
  detachPrimaryScrollObserver()
697
728
  observedNativeScroller = scroller
698
729
  applyNativeScrollerInsets(scroller, selectedPage)
699
- val listener = ViewTreeObserver.OnScrollChangedListener {
700
- val contentOffset = scroller.scrollY
701
- pageOffsets[currentPageKey()] = contentOffset
702
- if (contentOffset > 0 && headerOffsetPx < headerHeightPx) {
703
- setHeaderOffset(headerHeightPx)
730
+ val listener = object : ViewTreeObserver.OnScrollChangedListener {
731
+ override fun onScrollChanged() {
732
+ if (observedNativeScrollerListener !== this) {
733
+ removeScrollChangedListener(null, scroller, this)
734
+ return
735
+ }
736
+ val contentOffset = scroller.scrollY
737
+ pageOffsets[currentPageKey()] = contentOffset
738
+ if (contentOffset > 0 && headerOffsetPx < headerHeightPx) {
739
+ setHeaderOffset(headerHeightPx)
740
+ }
704
741
  }
705
742
  }
706
743
  observedNativeScrollerListener = listener
707
- scroller.viewTreeObserver.addOnScrollChangedListener(listener)
744
+ observedNativeScrollerTreeObserver = scroller.viewTreeObserver.also {
745
+ it.addOnScrollChangedListener(listener)
746
+ }
708
747
  log(
709
748
  "scroller-observer-attach inner=$selectedPage key=${currentPageKey()} " +
710
749
  "scroller=${System.identityHashCode(scroller)}",
711
750
  )
712
751
  }
713
752
 
714
- private fun detachNativeScrollerObserver() {
753
+ private fun detachNativeScrollerObserver(recordOffset: Boolean) {
715
754
  val scroller = observedNativeScroller
716
755
  val listener = observedNativeScrollerListener
717
- if (scroller != null) pageOffsets[currentPageKey()] = scroller.scrollY
718
- if (scroller != null && listener != null && scroller.viewTreeObserver.isAlive) {
719
- scroller.viewTreeObserver.removeOnScrollChangedListener(listener)
756
+ if (scroller != null && recordOffset) pageOffsets[currentPageKey()] = scroller.scrollY
757
+ if (scroller != null && listener != null) {
758
+ removeScrollChangedListener(observedNativeScrollerTreeObserver, scroller, listener)
720
759
  }
721
760
  if (scroller != null) {
722
761
  log(
@@ -726,11 +765,87 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
726
765
  }
727
766
  observedNativeScroller = null
728
767
  observedNativeScrollerListener = null
768
+ observedNativeScrollerTreeObserver = null
729
769
  }
730
770
 
731
- private fun detachPrimaryScrollObserver() {
732
- detachRecyclerObserver()
733
- detachNativeScrollerObserver()
771
+ private fun detachPrimaryScrollObserver(recordOffset: Boolean = true) {
772
+ detachOwnedScrollerObserver(recordOffset)
773
+ detachRecyclerObserver(recordOffset)
774
+ detachNativeScrollerObserver(recordOffset)
775
+ }
776
+
777
+ private fun removeScrollChangedListener(
778
+ registered: ViewTreeObserver?,
779
+ scroller: View,
780
+ listener: ViewTreeObserver.OnScrollChangedListener,
781
+ ) {
782
+ // A detached scroller returns a new floating observer, while the listener is
783
+ // still registered on the window observer. Try every instance that can own it.
784
+ for (observer in arrayOf(registered, scroller.viewTreeObserver, viewTreeObserver)) {
785
+ if (observer != null && observer.isAlive) observer.removeOnScrollChangedListener(listener)
786
+ }
787
+ }
788
+
789
+ private fun applyOwnedScrollerInsets(scroller: NativeScrollerViewport, pageIndex: Int) {
790
+ scroller.host.setPagerInset(this, headerHeightPx + stickyHeaderHeightPx)
791
+ publishConsumedOffset(scroller)
792
+ val key = pageKey(pageIndex)
793
+ if (ownedScrollerKeys[scroller] == key) return
794
+ ownedScrollerKeys[scroller] = key
795
+ postForCurrentAttachment {
796
+ if (ownedScrollerKeys[scroller] == key && scroller.isAttachedToWindow) {
797
+ scroller.host.restoreScrollOffset(pageOffsets[key] ?: 0)
798
+ }
799
+ }
800
+ }
801
+
802
+ private fun restoreOwnedScrollerInsets(scroller: NativeScrollerViewport) {
803
+ scroller.stopMotion()
804
+ scroller.host.cancelPendingScrollRestore()
805
+ ownedScrollerKeys.remove(scroller)
806
+ publishConsumedOffset(scroller, remove = true)
807
+ scroller.host.setPagerInset(this, null)
808
+ }
809
+
810
+ private fun attachOwnedScrollerObserver(scroller: NativeScrollerViewport) {
811
+ if (observedOwnedScroller === scroller && observedOwnedScrollerKey == currentPageKey()) {
812
+ applyOwnedScrollerInsets(scroller, selectedPage)
813
+ return
814
+ }
815
+ detachPrimaryScrollObserver()
816
+ observedOwnedScroller = scroller
817
+ observedOwnedScrollerKey = currentPageKey()
818
+ val key = currentPageKey()
819
+ applyOwnedScrollerInsets(scroller, selectedPage)
820
+ val listener = object : ViewTreeObserver.OnScrollChangedListener {
821
+ override fun onScrollChanged() {
822
+ if (observedOwnedScrollerListener !== this) {
823
+ removeScrollChangedListener(null, scroller, this)
824
+ return
825
+ }
826
+ val contentOffset = scroller.scrollY
827
+ pageOffsets[key] = contentOffset
828
+ if (contentOffset > 0 && headerOffsetPx < headerHeightPx) setHeaderOffset(headerHeightPx)
829
+ }
830
+ }
831
+ observedOwnedScrollerListener = listener
832
+ observedOwnedScrollerTreeObserver = scroller.viewTreeObserver.also {
833
+ it.addOnScrollChangedListener(listener)
834
+ }
835
+ }
836
+
837
+ private fun detachOwnedScrollerObserver(recordOffset: Boolean) {
838
+ val scroller = observedOwnedScroller ?: return
839
+ scroller.stopMotion()
840
+ publishConsumedOffset(scroller, remove = true)
841
+ if (recordOffset) observedOwnedScrollerKey?.let { pageOffsets[it] = scroller.scrollY }
842
+ observedOwnedScrollerListener?.let {
843
+ removeScrollChangedListener(observedOwnedScrollerTreeObserver, scroller, it)
844
+ }
845
+ observedOwnedScroller = null
846
+ observedOwnedScrollerKey = null
847
+ observedOwnedScrollerListener = null
848
+ observedOwnedScrollerTreeObserver = null
734
849
  }
735
850
 
736
851
  private fun applyRecyclerInsets(recycler: RecyclerView, pageIndex: Int) {
@@ -805,7 +920,6 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
805
920
  updateScrollerPaddingOwnership(scroller, original)
806
921
  val topInset = headerHeightPx + stickyHeaderHeightPx
807
922
  scroller.clipToPadding = false
808
- applyNativeScrollerRefreshIndicator(scroller, topInset)
809
923
  if (scroller is ReactScrollView) {
810
924
  // Fabric owns the content child's layout, so ScrollView padding alone
811
925
  // cannot move it. RN's native ScrollAway API also updates Fabric culling.
@@ -908,7 +1022,6 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
908
1022
  }
909
1023
 
910
1024
  private fun restoreNativeScrollerInsets(scroller: ScrollView) {
911
- restoreNativeScrollerRefreshIndicator(scroller)
912
1025
  val original = originalScrollerPadding.remove(scroller) ?: return
913
1026
  updateScrollerPaddingOwnership(scroller, original)
914
1027
  restoredScrollerKeys.remove(scroller)
@@ -923,12 +1036,14 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
923
1036
  scroller.setPadding(original.left, original.top, original.right, original.bottom)
924
1037
  }
925
1038
 
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
1039
+ private fun refreshLayoutFor(scrollable: View): SwipeRefreshLayout? {
1040
+ if (scrollable !is ScrollView && scrollable !is RecyclerView && scrollable !is NativeScrollerViewport) return null
1041
+ var ancestor = scrollable.parent
1042
+ while (ancestor != null && ancestor !is SwipeRefreshLayout) ancestor = ancestor.parent
1043
+ return ancestor as? SwipeRefreshLayout
1044
+ }
1045
+
1046
+ private fun refreshIndicatorFor(refresh: SwipeRefreshLayout): ImageView? {
932
1047
  for (index in 0 until refresh.childCount) {
933
1048
  val child = refresh.getChildAt(index)
934
1049
  if (child is ImageView && child.drawable is CircularProgressDrawable) return child
@@ -936,29 +1051,119 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
936
1051
  return null
937
1052
  }
938
1053
 
939
- private fun applyNativeScrollerRefreshIndicator(scroller: ScrollView, inset: Int) {
940
- val indicator = nativeScrollerRefreshIndicator(scroller)
941
- if (scrollerRefreshIndicators[scroller]?.get() !== indicator) {
942
- restoreNativeScrollerRefreshIndicator(scroller)
1054
+ private fun refreshForegroundOwner(scrollable: View): CollapsiblePagerHost? {
1055
+ var owner: CollapsiblePagerHost? = null
1056
+ var ancestor = scrollable.parent
1057
+ while (ancestor != null) {
1058
+ if (ancestor is CollapsiblePagerHost && ancestor.nativeSmoothHeaderScrollEnabled &&
1059
+ ancestor.isAttachedToWindow && ancestor.isShown &&
1060
+ ancestor.primaryScrollableForPage(ancestor.selectedPage) === scrollable) {
1061
+ owner = ancestor
1062
+ }
1063
+ ancestor = ancestor.parent
1064
+ }
1065
+ return owner
1066
+ }
1067
+
1068
+ private fun updateRefreshForeground() {
1069
+ val scrollable = primaryScrollableForPage(selectedPage)
1070
+ val refresh = scrollable?.takeIf { refreshForegroundOwner(it) === this }?.let(::refreshLayoutFor)
1071
+ val indicator = refresh?.let(::refreshIndicatorFor)
1072
+ if (foregroundRefreshIndicator?.get() !== indicator) releaseRefreshForeground()
1073
+ if (indicator != null) {
1074
+ if (foregroundRefreshIndicator?.get() !== indicator) {
1075
+ foregroundRefreshIndicator = WeakReference(indicator)
1076
+ }
1077
+ val state = refreshIndicatorAlphas.getOrPut(indicator) {
1078
+ NativeRefreshForegroundAlpha(indicator.alpha)
1079
+ }
1080
+ val alpha = state.update(indicator.alpha, this, acquire = true)
1081
+ if (indicator.alpha != alpha) indicator.alpha = alpha
943
1082
  }
944
- if (indicator == null) return
945
- if (scrollerRefreshIndicators[scroller]?.get() !== indicator) {
946
- scrollerRefreshIndicators[scroller] = WeakReference(indicator)
1083
+ val visible = indicator?.isShown == true &&
1084
+ (refreshIndicatorAlphas[indicator]?.drawingAlpha ?: 0f) > 0f
1085
+ // The foreground lies outside the original child's invalidation rectangle.
1086
+ // Repaint once on disappearance as well as throughout the native animation.
1087
+ if (visible || refreshForegroundWasVisible) postInvalidateOnAnimation()
1088
+ refreshForegroundWasVisible = visible
1089
+ }
1090
+
1091
+ private fun releaseRefreshForeground() {
1092
+ val indicator = foregroundRefreshIndicator?.get()
1093
+ foregroundRefreshIndicator = null
1094
+ if (indicator != null) {
1095
+ refreshIndicatorAlphas[indicator]?.let { state ->
1096
+ indicator.alpha = state.update(indicator.alpha, this, acquire = false)
1097
+ if (state.isEmpty) refreshIndicatorAlphas.remove(indicator)
1098
+ }
947
1099
  }
948
- val translation = refreshIndicatorTranslations.getOrPut(indicator) {
949
- NativeScrollerRefreshTranslation(indicator.translationY)
1100
+ if (indicator != null || refreshForegroundWasVisible) invalidate()
1101
+ refreshForegroundWasVisible = false
1102
+ }
1103
+
1104
+ private fun refreshMatrixInHost(refresh: View): Boolean {
1105
+ refreshForegroundMatrix.reset()
1106
+ refreshForegroundAncestorAlpha = 1f
1107
+ refreshForegroundAncestors.clear()
1108
+ try {
1109
+ var current: View? = refresh
1110
+ while (current != null && current !== this) {
1111
+ refreshForegroundAncestors.add(current)
1112
+ current = current.parent as? View
1113
+ }
1114
+ if (current !== this) return false
1115
+ for (index in refreshForegroundAncestors.lastIndex downTo 0) {
1116
+ val child = refreshForegroundAncestors[index]
1117
+ val parent = child.parent as? View ?: return false
1118
+ refreshForegroundMatrix.preTranslate(
1119
+ (child.left - parent.scrollX).toFloat(), (child.top - parent.scrollY).toFloat(),
1120
+ )
1121
+ refreshForegroundMatrix.preConcat(child.matrix)
1122
+ refreshForegroundAncestorAlpha *= child.alpha
1123
+ }
1124
+ return true
1125
+ } finally {
1126
+ refreshForegroundAncestors.clear()
950
1127
  }
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
1128
  }
956
1129
 
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)
1130
+ override fun dispatchDraw(canvas: Canvas) {
1131
+ // The original parent advances native refresh animations and their listeners.
1132
+ super.dispatchDraw(canvas)
1133
+ val indicator = foregroundRefreshIndicator?.get() ?: return
1134
+ val scrollable = primaryScrollableForPage(selectedPage) ?: return
1135
+ if (refreshForegroundOwner(scrollable) !== this) return
1136
+ val refresh = refreshLayoutFor(scrollable) ?: return
1137
+ if (refreshIndicatorFor(refresh) !== indicator || !indicator.isShown ||
1138
+ indicator.width <= 0 || indicator.height <= 0 || !refreshMatrixInHost(refresh)) return
1139
+ val alpha = (refreshIndicatorAlphas[indicator]?.drawingAlpha ?: return) *
1140
+ refreshForegroundAncestorAlpha
1141
+ if (alpha <= 0f) return
1142
+ refreshForegroundOrigin[0] = 0f
1143
+ refreshForegroundOrigin[1] = 0f
1144
+ refreshForegroundMatrix.mapPoints(refreshForegroundOrigin)
1145
+ val appliedInset = (refresh.getTag(R.id.onekey_native_scroll_coordinator_refresh_inset) as? Number)
1146
+ ?.toFloat() ?: 0f
1147
+ val save = canvas.save()
1148
+ try {
1149
+ // Pin the refresh container's origin to this Host top while retaining its
1150
+ // horizontal page position, native scale/pivot, and caller translation.
1151
+ canvas.translate(0f, -refreshForegroundOrigin[1])
1152
+ canvas.concat(refreshForegroundMatrix)
1153
+ canvas.translate(indicator.left.toFloat(), nativeRefreshForegroundTop(indicator.top, appliedInset))
1154
+ canvas.concat(indicator.matrix)
1155
+ if (alpha < 1f) {
1156
+ canvas.saveLayerAlpha(
1157
+ 0f, 0f, indicator.width.toFloat(), indicator.height.toFloat(),
1158
+ (alpha * 255f).roundToInt().coerceIn(0, 255),
1159
+ )
1160
+ }
1161
+ // Direct draw deliberately bypasses the alpha used to hide the original
1162
+ // RenderNode. Do not advance Animation.getTransformation a second time.
1163
+ indicator.draw(canvas)
1164
+ } finally {
1165
+ canvas.restoreToCount(save)
1166
+ }
962
1167
  }
963
1168
 
964
1169
  private fun headerRegionAt(y: Float): String? {
@@ -1185,10 +1390,12 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
1185
1390
  axes and ViewCompat.SCROLL_AXIS_VERTICAL != 0 && isCurrentPageTarget(target)
1186
1391
 
1187
1392
  override fun onNestedScrollAccepted(child: View, target: View, axes: Int, type: Int) {
1393
+ if (type == ViewCompat.TYPE_TOUCH) pendingRefreshPull = null
1188
1394
  nestedScrollingParentHelper.onNestedScrollAccepted(child, target, axes, type)
1189
1395
  }
1190
1396
 
1191
1397
  override fun onStopNestedScroll(target: View, type: Int) {
1398
+ if (pendingRefreshPull?.get() === target) pendingRefreshPull = null
1192
1399
  nestedScrollingParentHelper.onStopNestedScroll(target, type)
1193
1400
  onHeaderOffsetChanged?.invoke()
1194
1401
  }
@@ -1203,10 +1410,18 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
1203
1410
  val used = min(dy, maximumOffset - headerOffsetPx)
1204
1411
  setHeaderOffset(headerOffsetPx + used)
1205
1412
  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
1413
+ } else if (dy < 0 && headerOffsetPx > 0) {
1414
+ // Refresh wrappers forward nested scrolls but do not scroll themselves.
1415
+ val canScrollUp = if (target is SwipeRefreshLayout) {
1416
+ target.canChildScrollUp()
1417
+ } else {
1418
+ target.canScrollVertically(-1)
1419
+ }
1420
+ if (!canScrollUp) {
1421
+ val used = max(dy, -headerOffsetPx)
1422
+ setHeaderOffset(headerOffsetPx + used)
1423
+ consumed[1] += used
1424
+ }
1210
1425
  }
1211
1426
  }
1212
1427
 
@@ -1219,10 +1434,14 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
1219
1434
  type: Int,
1220
1435
  consumed: IntArray,
1221
1436
  ) {
1222
- if (!isCurrentPageTarget(target) || dyUnconsumed >= 0 || headerOffsetPx <= 0) return
1437
+ if (!isCurrentPageTarget(target) || dyUnconsumed >= 0) return
1223
1438
  val used = max(dyUnconsumed, -headerOffsetPx)
1224
1439
  setHeaderOffset(headerOffsetPx + used)
1225
1440
  consumed[1] += used
1441
+ if (type == ViewCompat.TYPE_TOUCH && dyUnconsumed < used &&
1442
+ target is SwipeRefreshLayout && target.isEnabled) {
1443
+ pendingRefreshPull = WeakReference(target)
1444
+ }
1226
1445
  }
1227
1446
 
1228
1447
  override fun onNestedScroll(
@@ -1244,6 +1463,20 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
1244
1463
  )
1245
1464
  }
1246
1465
 
1466
+ override fun onNestedPreFling(target: View, velocityX: Float, velocityY: Float): Boolean {
1467
+ val refresh = pendingRefreshPull?.get()
1468
+ val scrollable = primaryScrollableForPage(selectedPage)
1469
+ if (nativeSmoothHeaderScrollEnabled && refresh != null && refresh === target && isCurrentPageTarget(target) &&
1470
+ headerOffsetPx == 0 && scrollable != null && !scrollable.canScrollVertically(-1) &&
1471
+ refresh.isEnabled && !refresh.isRefreshing &&
1472
+ refreshIndicatorFor(refresh)?.visibility == View.VISIBLE) {
1473
+ // A small release reversal must settle the current refresh pull, not fling
1474
+ // content upward and make its first positive offset collapse the header.
1475
+ return true
1476
+ }
1477
+ return super.onNestedPreFling(target, velocityX, velocityY)
1478
+ }
1479
+
1247
1480
  override fun getNestedScrollAxes() = nestedScrollingParentHelper.nestedScrollAxes
1248
1481
 
1249
1482
  override fun onAttachedToWindow() {
@@ -1259,6 +1492,8 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
1259
1492
  }
1260
1493
 
1261
1494
  override fun onDetachedFromWindow() {
1495
+ pendingRefreshPull = null
1496
+ releaseRefreshForeground()
1262
1497
  attachmentGeneration += 1
1263
1498
  headerDownEvent?.recycle()
1264
1499
  headerDownEvent = null
@@ -1275,6 +1510,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
1275
1510
  for (scroller in originalScrollerPadding.keys.toList()) {
1276
1511
  restoreNativeScrollerInsets(scroller)
1277
1512
  }
1513
+ for (scroller in ownedScrollerKeys.keys.toList()) restoreOwnedScrollerInsets(scroller)
1278
1514
  log(
1279
1515
  "host-detach generation=$attachmentGeneration inner=$selectedPage " +
1280
1516
  "outer=${outerPagerIndex()} nativePages=${adapter.itemCount}",
@@ -1283,7 +1519,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
1283
1519
  }
1284
1520
 
1285
1521
  companion object {
1286
- private val refreshIndicatorTranslations = WeakHashMap<View, NativeScrollerRefreshTranslation>()
1522
+ private val refreshIndicatorAlphas = WeakHashMap<ImageView, NativeRefreshForegroundAlpha>()
1287
1523
  const val NATIVE_SCROLLER_ID_PREFIX = "rnc-collapsible-pager-native-scroller:"
1288
1524
  const val PAGE_SLOT_OFFSET = 2
1289
1525
  }
@@ -0,0 +1,26 @@
1
+ package com.reactnativepagerview
2
+
3
+ /**
4
+ * A pager-owned offset restoration that NestedScrollView clamped because the
5
+ * React content height had not reached native yet. It is reapplied on content
6
+ * layout until reached or cancelled by user/command/content replacement.
7
+ */
8
+ internal class NativeScrollerPendingRestore {
9
+ var target: Int? = null
10
+ private set
11
+
12
+ fun request(target: Int, reached: Int) {
13
+ this.target = if (reached == target) null else target
14
+ }
15
+
16
+ /** The offset to reapply after a content layout pass. */
17
+ fun anchor(current: Int): Int = target ?: current
18
+
19
+ fun settle(reached: Int) {
20
+ if (target == reached) target = null
21
+ }
22
+
23
+ fun cancel() {
24
+ target = null
25
+ }
26
+ }