@onekeyfe/react-native-pager-view 3.0.158 → 3.0.160

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 (42) hide show
  1. package/README.md +57 -4
  2. package/android/src/main/java/com/reactnativepagerview/CollapsiblePagerHost.kt +379 -31
  3. package/android/src/main/java/com/reactnativepagerview/NativeScrollCoordinatorContributions.kt +15 -0
  4. package/android/src/main/java/com/reactnativepagerview/NativeScrollerPendingRestore.kt +26 -0
  5. package/android/src/main/java/com/reactnativepagerview/NativeScrollerScrollAwayInsets.kt +41 -0
  6. package/android/src/main/java/com/reactnativepagerview/NativeScrollerView.kt +600 -0
  7. package/android/src/main/java/com/reactnativepagerview/NativeScrollerViewManager.kt +145 -0
  8. package/android/src/main/java/com/reactnativepagerview/PagerViewViewPackage.kt +1 -1
  9. package/android/src/main/jni/CMakeLists.txt +19 -0
  10. package/android/src/main/jni/pagerview.h +11 -0
  11. package/android/src/main/res/values/native_scroll_coordinator_ids.xml +5 -0
  12. package/android/src/test/java/com/reactnativepagerview/NativeScrollCoordinatorContributionsTest.kt +26 -0
  13. package/android/src/test/java/com/reactnativepagerview/NativeScrollerPendingRestoreTest.kt +31 -0
  14. package/android/src/test/java/com/reactnativepagerview/NativeScrollerScrollAwayInsetsTest.kt +75 -0
  15. package/common/cpp/react/renderer/components/pagerview/RNCNativeScrollerComponentDescriptor.h +8 -0
  16. package/common/cpp/react/renderer/components/pagerview/RNCNativeScrollerShadowNode.cpp +15 -0
  17. package/common/cpp/react/renderer/components/pagerview/RNCNativeScrollerShadowNode.h +23 -0
  18. package/common/cpp/react/renderer/components/pagerview/RNCNativeScrollerState.h +33 -0
  19. package/docs/SPEC.md +450 -0
  20. package/ios/RNCCollapsiblePagerReleasedStatePolicy.h +8 -0
  21. package/ios/RNCCollapsiblePagerReleasedStatePolicy.m +13 -0
  22. package/ios/RNCCollapsiblePagerViewComponentView.mm +414 -1
  23. package/ios/RNCNativeScrollerComponentView.h +17 -0
  24. package/ios/RNCNativeScrollerComponentView.mm +479 -0
  25. package/ios/tests/RNCCollapsiblePagerHitTestTests.mm +80 -0
  26. package/ios/tests/RNCCollapsiblePagerPressCancellationTests.mm +788 -0
  27. package/ios/tests/RNCCollapsiblePagerPressCancellationWiringTests.mm +109 -0
  28. package/ios/tests/RNCCollapsiblePagerRefreshControlTests.mm +218 -0
  29. package/ios/tests/RNCCollapsiblePagerReleasedStatePolicyTests.mm +23 -0
  30. package/ios/tests/RNCNativeScrollerTests.mm +252 -0
  31. package/lib/module/CollapsiblePagerView.js +10 -1
  32. package/lib/module/NativeScroller.js +171 -11
  33. package/lib/module/NativeScrollerNativeComponent.ts +101 -0
  34. package/lib/typescript/src/CollapsiblePagerView.d.ts +3 -3
  35. package/lib/typescript/src/NativeScroller.d.ts +15 -7
  36. package/lib/typescript/src/NativeScrollerNativeComponent.d.ts +79 -0
  37. package/package.json +8 -4
  38. package/react-native-pager-view.podspec +2 -1
  39. package/react-native.config.js +15 -0
  40. package/src/CollapsiblePagerView.tsx +14 -4
  41. package/src/NativeScroller.tsx +291 -14
  42. 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
@@ -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,10 +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
28
+ import com.facebook.react.views.scroll.ReactScrollView
23
29
  import com.margelo.nitro.nativelogger.OneKeyLog
30
+ import java.lang.ref.WeakReference
24
31
  import java.util.WeakHashMap
25
32
  import kotlin.math.max
26
33
  import kotlin.math.min
@@ -115,11 +122,24 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
115
122
  private val restoredRecyclerKeys = WeakHashMap<RecyclerView, String>()
116
123
  private val originalScrollerPadding = WeakHashMap<ScrollView, ScrollerPadding>()
117
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
118
132
  private val pageOffsets = HashMap<String, Int>()
119
133
  private var observedRecyclerView: RecyclerView? = null
120
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
121
139
  private var observedNativeScroller: ScrollView? = null
122
140
  private var observedNativeScrollerListener: ViewTreeObserver.OnScrollChangedListener? = null
141
+ private var observedNativeScrollerTreeObserver: ViewTreeObserver? = null
142
+ private var observedPageRemoved = false
123
143
  private var attachmentGeneration = 0
124
144
  private val touchSlopPx = ViewConfiguration.get(context).scaledTouchSlop
125
145
  private val hostIdentity = System.identityHashCode(this)
@@ -133,6 +153,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
133
153
  private var headerDownEvent: MotionEvent? = null
134
154
  private var headerHasHorizontalChild = false
135
155
  private var forwardedScrollable: View? = null
156
+ private var pendingRefreshPull: WeakReference<SwipeRefreshLayout>? = null
136
157
  private var nativeGestureStarted = false
137
158
  private var pressCancelled = false
138
159
  private val pageContentLayoutListener = ViewTreeObserver.OnPreDrawListener {
@@ -308,6 +329,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
308
329
  }
309
330
 
310
331
  fun removeReactChild(child: View) {
332
+ releaseRefreshForeground()
311
333
  val index = logicalChildren.indexOf(child)
312
334
  if (index < 0) return
313
335
  logicalChildren.removeAt(index)
@@ -321,10 +343,17 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
321
343
  stickyHeaderView = null
322
344
  }
323
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
+ }
324
352
  when (val scrollable =
325
353
  findExplicitNativeScrollerInView(child) ?: findVerticalScrollableView(child)) {
326
354
  is RecyclerView -> restoreRecyclerInsets(scrollable)
327
355
  is ScrollView -> restoreNativeScrollerInsets(scrollable)
356
+ is NativeScrollerViewport -> restoreOwnedScrollerInsets(scrollable)
328
357
  }
329
358
  adapter.removePage(child)
330
359
  log("page-detach slot=${index - PAGE_SLOT_OFFSET} nativePages=${adapter.itemCount}")
@@ -333,6 +362,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
333
362
  }
334
363
 
335
364
  fun removeAllReactChildren() {
365
+ releaseRefreshForeground()
336
366
  detachPrimaryScrollObserver()
337
367
  for (recycler in originalRecyclerPadding.keys.toList()) {
338
368
  restoreRecyclerInsets(recycler)
@@ -340,6 +370,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
340
370
  for (scroller in originalScrollerPadding.keys.toList()) {
341
371
  restoreNativeScrollerInsets(scroller)
342
372
  }
373
+ for (scroller in ownedScrollerKeys.keys.toList()) restoreOwnedScrollerInsets(scroller)
343
374
  headerView?.let { view -> super.removeView(view) }
344
375
  stickyHeaderView?.let { view -> super.removeView(view) }
345
376
  headerView = null
@@ -355,8 +386,10 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
355
386
  fun reactChildAt(index: Int) = logicalChildren[index]
356
387
 
357
388
  fun selectPage(position: Int) {
389
+ pendingRefreshPull = null
358
390
  if (adapter.itemCount == 0) return
359
391
  detachPrimaryScrollObserver()
392
+ releaseRefreshForeground()
360
393
  selectedPage = position.coerceIn(0, adapter.itemCount - 1)
361
394
  val savedOffset = pageOffsets[currentPageKey()]
362
395
  ?: primaryScrollableForPage(selectedPage)?.let(::verticalScrollOffset)
@@ -390,7 +423,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
390
423
  fun attachedPageCount(): Int = (pager.getChildAt(0) as? RecyclerView)?.childCount ?: 0
391
424
 
392
425
  fun observedScrollableCount(): Int =
393
- originalRecyclerPadding.size + originalScrollerPadding.size
426
+ originalRecyclerPadding.size + originalScrollerPadding.size + ownedScrollerKeys.size
394
427
 
395
428
  private fun layoutPagerIfRequested() {
396
429
  val recycler = pager.getChildAt(0) as? RecyclerView ?: return
@@ -487,6 +520,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
487
520
 
488
521
  private fun maximumHeaderOffset(): Int {
489
522
  val scrollable = observedRecyclerView
523
+ ?: observedOwnedScroller
490
524
  ?: observedNativeScroller
491
525
  ?: primaryScrollableForPage(selectedPage)
492
526
  ?: return headerHeightPx
@@ -508,6 +542,19 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
508
542
  applyHeaderOffset()
509
543
  }
510
544
 
545
+ private fun publishConsumedOffset(recycler: View, remove: Boolean = false) {
546
+ val key = R.id.onekey_native_scroll_coordinator_consumed_offsets
547
+ val existing = recycler.getTag(key) as? NativeScrollCoordinatorContributions
548
+ val contributions = existing ?: if (remove) return else NativeScrollCoordinatorContributions().also {
549
+ recycler.setTag(key, it)
550
+ }
551
+ val changed = if (remove) contributions.clearOwner(this) else contributions.update(this, headerOffsetPx)
552
+ if (changed) {
553
+ (recycler.getTag(R.id.onekey_native_scroll_coordinator_offset_listener) as? Runnable)?.run()
554
+ }
555
+ if (contributions.isEmpty()) recycler.setTag(key, null)
556
+ }
557
+
511
558
  private fun applyHeaderOffset() {
512
559
  val offset = headerOffsetPx.toFloat()
513
560
  pager.translationY = -offset
@@ -515,6 +562,9 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
515
562
  stickyHeaderView?.translationY = -offset
516
563
  nativeTabBarView.translationY = -offset
517
564
  nativeSubHeaderView.translationY = -offset
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)
518
568
  }
519
569
 
520
570
  private fun pageKey(index: Int): String = pageKeys.getOrNull(index) ?: "page-$index"
@@ -541,6 +591,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
541
591
  }
542
592
 
543
593
  private fun findExplicitNativeScrollerInView(view: View): View? {
594
+ if (view is NativeScrollerView) return view.viewport
544
595
  if (view !== this && view is CollapsiblePagerHost) {
545
596
  return view.currentPrimaryScrollableForParent()
546
597
  }
@@ -557,6 +608,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
557
608
  }
558
609
 
559
610
  private fun findVerticalScrollableView(view: View): View? {
611
+ if (view is NativeScrollerView) return view.viewport
560
612
  if (view !== this && view is CollapsiblePagerHost) {
561
613
  return view.currentPrimaryScrollableForParent()
562
614
  }
@@ -585,6 +637,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
585
637
  private fun verticalScrollOffset(view: View): Int = when (view) {
586
638
  is RecyclerView -> view.computeVerticalScrollOffset()
587
639
  is ScrollView -> view.scrollY
640
+ is NativeScrollerViewport -> view.scrollY
588
641
  else -> 0
589
642
  }
590
643
 
@@ -595,14 +648,17 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
595
648
  when (val scrollable = primaryScrollableForPage(index)) {
596
649
  is RecyclerView -> applyRecyclerInsets(scrollable, index)
597
650
  is ScrollView -> applyNativeScrollerInsets(scrollable, index)
651
+ is NativeScrollerViewport -> applyOwnedScrollerInsets(scrollable, index)
598
652
  }
599
653
  }
654
+ updateRefreshForeground()
600
655
  }
601
656
 
602
657
  private fun attachPrimaryScrollObserver() {
603
658
  if (selectedPage !in 0 until adapter.itemCount) return
604
- val previous = observedRecyclerView ?: observedNativeScroller
605
- 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
606
662
  detachPrimaryScrollObserver()
607
663
  // A replacement scroller starts at the beginning while retaining header collapse.
608
664
  pageOffsets[currentPageKey()] = 0
@@ -610,18 +666,21 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
610
666
  when (val scrollable = primaryScrollableForPage(selectedPage)) {
611
667
  is RecyclerView -> attachRecyclerObserver(scrollable)
612
668
  is ScrollView -> attachNativeScrollerObserver(scrollable)
669
+ is NativeScrollerViewport -> attachOwnedScrollerObserver(scrollable)
613
670
  }
614
671
  }
615
672
 
616
673
  private fun attachRecyclerObserver(recycler: RecyclerView) {
617
674
  if (observedRecyclerView === recycler) {
618
675
  applyRecyclerInsets(recycler, selectedPage)
676
+ publishConsumedOffset(recycler)
619
677
  return
620
678
  }
621
679
 
622
680
  detachPrimaryScrollObserver()
623
681
  observedRecyclerView = recycler
624
682
  applyRecyclerInsets(recycler, selectedPage)
683
+ publishConsumedOffset(recycler)
625
684
  val listener = object : RecyclerView.OnScrollListener() {
626
685
  override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
627
686
  val contentOffset = recyclerView.computeVerticalScrollOffset()
@@ -641,11 +700,12 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
641
700
  )
642
701
  }
643
702
 
644
- private fun detachRecyclerObserver() {
703
+ private fun detachRecyclerObserver(recordOffset: Boolean) {
645
704
  val recycler = observedRecyclerView
646
705
  val listener = observedScrollListener
647
706
  if (recycler != null) {
648
- pageOffsets[currentPageKey()] = recycler.computeVerticalScrollOffset()
707
+ if (recordOffset) pageOffsets[currentPageKey()] = recycler.computeVerticalScrollOffset()
708
+ publishConsumedOffset(recycler, remove = true)
649
709
  }
650
710
  if (recycler != null && listener != null) recycler.removeOnScrollListener(listener)
651
711
  if (recycler != null) {
@@ -667,27 +727,35 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
667
727
  detachPrimaryScrollObserver()
668
728
  observedNativeScroller = scroller
669
729
  applyNativeScrollerInsets(scroller, selectedPage)
670
- val listener = ViewTreeObserver.OnScrollChangedListener {
671
- val contentOffset = scroller.scrollY
672
- pageOffsets[currentPageKey()] = contentOffset
673
- if (contentOffset > 0 && headerOffsetPx < headerHeightPx) {
674
- 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
+ }
675
741
  }
676
742
  }
677
743
  observedNativeScrollerListener = listener
678
- scroller.viewTreeObserver.addOnScrollChangedListener(listener)
744
+ observedNativeScrollerTreeObserver = scroller.viewTreeObserver.also {
745
+ it.addOnScrollChangedListener(listener)
746
+ }
679
747
  log(
680
748
  "scroller-observer-attach inner=$selectedPage key=${currentPageKey()} " +
681
749
  "scroller=${System.identityHashCode(scroller)}",
682
750
  )
683
751
  }
684
752
 
685
- private fun detachNativeScrollerObserver() {
753
+ private fun detachNativeScrollerObserver(recordOffset: Boolean) {
686
754
  val scroller = observedNativeScroller
687
755
  val listener = observedNativeScrollerListener
688
- if (scroller != null) pageOffsets[currentPageKey()] = scroller.scrollY
689
- if (scroller != null && listener != null && scroller.viewTreeObserver.isAlive) {
690
- scroller.viewTreeObserver.removeOnScrollChangedListener(listener)
756
+ if (scroller != null && recordOffset) pageOffsets[currentPageKey()] = scroller.scrollY
757
+ if (scroller != null && listener != null) {
758
+ removeScrollChangedListener(observedNativeScrollerTreeObserver, scroller, listener)
691
759
  }
692
760
  if (scroller != null) {
693
761
  log(
@@ -697,11 +765,87 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
697
765
  }
698
766
  observedNativeScroller = null
699
767
  observedNativeScrollerListener = null
768
+ observedNativeScrollerTreeObserver = null
700
769
  }
701
770
 
702
- private fun detachPrimaryScrollObserver() {
703
- detachRecyclerObserver()
704
- 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
705
849
  }
706
850
 
707
851
  private fun applyRecyclerInsets(recycler: RecyclerView, pageIndex: Int) {
@@ -775,11 +919,29 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
775
919
  }
776
920
  updateScrollerPaddingOwnership(scroller, original)
777
921
  val topInset = headerHeightPx + stickyHeaderHeightPx
778
- val top = original.top + topInset
779
922
  scroller.clipToPadding = false
780
- if (scroller.paddingLeft != original.left || scroller.paddingTop != top ||
781
- scroller.paddingRight != original.right || scroller.paddingBottom != original.bottom) {
782
- scroller.setPadding(original.left, top, original.right, original.bottom)
923
+ if (scroller is ReactScrollView) {
924
+ // Fabric owns the content child's layout, so ScrollView padding alone
925
+ // cannot move it. RN's native ScrollAway API also updates Fabric culling.
926
+ val state = scroller.reactScrollViewScrollState
927
+ val scrollAwayTop = nativeScrollerScrollAwayTop(
928
+ state.scrollAwayPaddingTop, original.appliedTopInset, topInset,
929
+ )
930
+ if (state.scrollAwayPaddingTop != scrollAwayTop ||
931
+ scroller.getChildAt(0)?.translationY != scrollAwayTop.toFloat()) {
932
+ scroller.setScrollAwayPaddingEnabledUnstable(scrollAwayTop, state.scrollAwayPaddingBottom)
933
+ }
934
+ val bottom = original.bottom + topInset
935
+ if (scroller.paddingLeft != original.left || scroller.paddingTop != original.top ||
936
+ scroller.paddingRight != original.right || scroller.paddingBottom != bottom) {
937
+ scroller.setPadding(original.left, original.top, original.right, bottom)
938
+ }
939
+ } else {
940
+ val top = original.top + topInset
941
+ if (scroller.paddingLeft != original.left || scroller.paddingTop != top ||
942
+ scroller.paddingRight != original.right || scroller.paddingBottom != original.bottom) {
943
+ scroller.setPadding(original.left, top, original.right, original.bottom)
944
+ }
783
945
  }
784
946
  original.appliedTopInset = topInset
785
947
 
@@ -804,13 +966,29 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
804
966
  scroller: ScrollView,
805
967
  original: ScrollerPadding,
806
968
  ) {
807
- val expectedTop = original.top + original.appliedTopInset
969
+ val isReactScroller = scroller is ReactScrollView
970
+ if (scroller is ReactScrollView) {
971
+ val state = scroller.reactScrollViewScrollState
972
+ if (nativeScrollerPaddingWasResetByScrollAway(
973
+ original.appliedTopInset,
974
+ scroller.paddingLeft, scroller.paddingTop, scroller.paddingRight, scroller.paddingBottom,
975
+ state.scrollAwayPaddingTop, state.scrollAwayPaddingBottom,
976
+ )) {
977
+ // RN may replay ScrollAway state before this pass. Its setter clears
978
+ // caller padding; that native reset must not become our new baseline.
979
+ return
980
+ }
981
+ }
982
+ val ownedTop = if (isReactScroller) 0 else original.appliedTopInset
983
+ val ownedBottom = if (isReactScroller) original.appliedTopInset else 0
808
984
  if (scroller.paddingLeft != original.left) original.left = scroller.paddingLeft
809
- if (scroller.paddingTop != expectedTop) {
810
- original.top = (scroller.paddingTop - original.appliedTopInset).coerceAtLeast(0)
985
+ if (scroller.paddingTop != original.top + ownedTop) {
986
+ original.top = (scroller.paddingTop - ownedTop).coerceAtLeast(0)
811
987
  }
812
988
  if (scroller.paddingRight != original.right) original.right = scroller.paddingRight
813
- if (scroller.paddingBottom != original.bottom) original.bottom = scroller.paddingBottom
989
+ if (scroller.paddingBottom != original.bottom + ownedBottom) {
990
+ original.bottom = (scroller.paddingBottom - ownedBottom).coerceAtLeast(0)
991
+ }
814
992
  if (scroller.clipToPadding) original.clipToPadding = true
815
993
  }
816
994
 
@@ -835,6 +1013,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
835
1013
  }
836
1014
 
837
1015
  private fun restoreRecyclerInsets(recycler: RecyclerView) {
1016
+ publishConsumedOffset(recycler, remove = true)
838
1017
  val original = originalRecyclerPadding.remove(recycler) ?: return
839
1018
  updateRecyclerPaddingOwnership(recycler, original)
840
1019
  restoredRecyclerKeys.remove(recycler)
@@ -846,10 +1025,147 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
846
1025
  val original = originalScrollerPadding.remove(scroller) ?: return
847
1026
  updateScrollerPaddingOwnership(scroller, original)
848
1027
  restoredScrollerKeys.remove(scroller)
1028
+ if (scroller is ReactScrollView) {
1029
+ val state = scroller.reactScrollViewScrollState
1030
+ scroller.setScrollAwayPaddingEnabledUnstable(
1031
+ nativeScrollerScrollAwayTop(state.scrollAwayPaddingTop, original.appliedTopInset, 0),
1032
+ state.scrollAwayPaddingBottom,
1033
+ )
1034
+ }
849
1035
  scroller.clipToPadding = original.clipToPadding
850
1036
  scroller.setPadding(original.left, original.top, original.right, original.bottom)
851
1037
  }
852
1038
 
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? {
1047
+ for (index in 0 until refresh.childCount) {
1048
+ val child = refresh.getChildAt(index)
1049
+ if (child is ImageView && child.drawable is CircularProgressDrawable) return child
1050
+ }
1051
+ return null
1052
+ }
1053
+
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
1082
+ }
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
+ }
1099
+ }
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()
1127
+ }
1128
+ }
1129
+
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
+ }
1167
+ }
1168
+
853
1169
  private fun headerRegionAt(y: Float): String? {
854
1170
  val headerBottom = headerHeightPx - headerOffsetPx
855
1171
  val stickyBottom = headerHeightPx + stickyHeaderHeightPx - headerOffsetPx
@@ -1074,10 +1390,12 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
1074
1390
  axes and ViewCompat.SCROLL_AXIS_VERTICAL != 0 && isCurrentPageTarget(target)
1075
1391
 
1076
1392
  override fun onNestedScrollAccepted(child: View, target: View, axes: Int, type: Int) {
1393
+ if (type == ViewCompat.TYPE_TOUCH) pendingRefreshPull = null
1077
1394
  nestedScrollingParentHelper.onNestedScrollAccepted(child, target, axes, type)
1078
1395
  }
1079
1396
 
1080
1397
  override fun onStopNestedScroll(target: View, type: Int) {
1398
+ if (pendingRefreshPull?.get() === target) pendingRefreshPull = null
1081
1399
  nestedScrollingParentHelper.onStopNestedScroll(target, type)
1082
1400
  onHeaderOffsetChanged?.invoke()
1083
1401
  }
@@ -1092,10 +1410,18 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
1092
1410
  val used = min(dy, maximumOffset - headerOffsetPx)
1093
1411
  setHeaderOffset(headerOffsetPx + used)
1094
1412
  consumed[1] += used
1095
- } else if (dy < 0 && headerOffsetPx > 0 && !target.canScrollVertically(-1)) {
1096
- val used = max(dy, -headerOffsetPx)
1097
- setHeaderOffset(headerOffsetPx + used)
1098
- 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
+ }
1099
1425
  }
1100
1426
  }
1101
1427
 
@@ -1108,10 +1434,14 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
1108
1434
  type: Int,
1109
1435
  consumed: IntArray,
1110
1436
  ) {
1111
- if (!isCurrentPageTarget(target) || dyUnconsumed >= 0 || headerOffsetPx <= 0) return
1437
+ if (!isCurrentPageTarget(target) || dyUnconsumed >= 0) return
1112
1438
  val used = max(dyUnconsumed, -headerOffsetPx)
1113
1439
  setHeaderOffset(headerOffsetPx + used)
1114
1440
  consumed[1] += used
1441
+ if (type == ViewCompat.TYPE_TOUCH && dyUnconsumed < used &&
1442
+ target is SwipeRefreshLayout && target.isEnabled) {
1443
+ pendingRefreshPull = WeakReference(target)
1444
+ }
1115
1445
  }
1116
1446
 
1117
1447
  override fun onNestedScroll(
@@ -1133,6 +1463,20 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
1133
1463
  )
1134
1464
  }
1135
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
+
1136
1480
  override fun getNestedScrollAxes() = nestedScrollingParentHelper.nestedScrollAxes
1137
1481
 
1138
1482
  override fun onAttachedToWindow() {
@@ -1148,6 +1492,8 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
1148
1492
  }
1149
1493
 
1150
1494
  override fun onDetachedFromWindow() {
1495
+ pendingRefreshPull = null
1496
+ releaseRefreshForeground()
1151
1497
  attachmentGeneration += 1
1152
1498
  headerDownEvent?.recycle()
1153
1499
  headerDownEvent = null
@@ -1164,6 +1510,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
1164
1510
  for (scroller in originalScrollerPadding.keys.toList()) {
1165
1511
  restoreNativeScrollerInsets(scroller)
1166
1512
  }
1513
+ for (scroller in ownedScrollerKeys.keys.toList()) restoreOwnedScrollerInsets(scroller)
1167
1514
  log(
1168
1515
  "host-detach generation=$attachmentGeneration inner=$selectedPage " +
1169
1516
  "outer=${outerPagerIndex()} nativePages=${adapter.itemCount}",
@@ -1172,6 +1519,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
1172
1519
  }
1173
1520
 
1174
1521
  companion object {
1522
+ private val refreshIndicatorAlphas = WeakHashMap<ImageView, NativeRefreshForegroundAlpha>()
1175
1523
  const val NATIVE_SCROLLER_ID_PREFIX = "rnc-collapsible-pager-native-scroller:"
1176
1524
  const val PAGE_SLOT_OFFSET = 2
1177
1525
  }