@onekeyfe/react-native-pager-view 3.0.169 → 3.0.171

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.
@@ -123,6 +123,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
123
123
  private val originalScrollerPadding = WeakHashMap<ScrollView, ScrollerPadding>()
124
124
  private val restoredScrollerKeys = WeakHashMap<ScrollView, String>()
125
125
  private val ownedScrollerKeys = WeakHashMap<NativeScrollerViewport, String>()
126
+ private val ownedScrollerRestoreRequests = HashMap<String, Pair<WeakReference<NativeScrollerViewport>, Int?>>()
126
127
  private var foregroundRefreshIndicator: WeakReference<ImageView>? = null
127
128
  private var refreshForegroundWasVisible = false
128
129
  private val refreshForegroundMatrix = Matrix()
@@ -130,13 +131,23 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
130
131
  private val refreshForegroundAncestors = ArrayList<View>()
131
132
  private var refreshForegroundAncestorAlpha = 1f
132
133
  private val pageOffsets = HashMap<String, Int>()
134
+ private val pageHeaderOffsets = HashMap<String, Int>()
135
+ private var transitionHeaderOffsets: Map<String, Int>? = null
136
+ private var transitionPageOffsets: Map<String, Int>? = null
137
+ private var transitionSourcePage = 0
138
+ private var transitionTargetPage: Int? = null
139
+ private var syntheticPageRestore = false
140
+ private var resumeTransitionSource: Int? = null
141
+ private val pendingPageRestores = HashSet<String>()
133
142
  private var observedRecyclerView: RecyclerView? = null
143
+ private var observedRecyclerKey: String? = null
134
144
  private var observedScrollListener: RecyclerView.OnScrollListener? = null
135
145
  private var observedOwnedScroller: NativeScrollerViewport? = null
136
146
  private var observedOwnedScrollerKey: String? = null
137
147
  private var observedOwnedScrollerListener: ViewTreeObserver.OnScrollChangedListener? = null
138
148
  private var observedOwnedScrollerTreeObserver: ViewTreeObserver? = null
139
149
  private var observedNativeScroller: ScrollView? = null
150
+ private var observedNativeScrollerKey: String? = null
140
151
  private var observedNativeScrollerListener: ViewTreeObserver.OnScrollChangedListener? = null
141
152
  private var observedNativeScrollerTreeObserver: ViewTreeObserver? = null
142
153
  private var observedPageRemoved = false
@@ -230,6 +241,19 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
230
241
  OneKeyLog.debug("CollapsiblePager", "host=$hostIdentity $message")
231
242
  }
232
243
 
244
+ private var offsetDiagSequence = 0L
245
+
246
+ private fun offsetDiag(phase: String, view: View? = null, key: String = currentPageKey()) {
247
+ if ((context.applicationInfo.flags and android.content.pm.ApplicationInfo.FLAG_DEBUGGABLE) == 0) return
248
+ val safeKey = if (key in setOf("portfolio", "perps", "defi", "nft", "history")) key else "other"
249
+ val observed = observedOwnedScroller ?: observedNativeScroller ?: observedRecyclerView
250
+ OneKeyLog.debug("CollapsiblePager", "[HOME-OFFSET-DIAG] phase=$phase seq=${++offsetDiagSequence} " +
251
+ "wallMs=${System.currentTimeMillis()} monoMs=${SystemClock.uptimeMillis()} host=$hostIdentity " +
252
+ "generation=$attachmentGeneration page=$selectedPage key=$safeKey header=$headerOffsetPx headerHeight=$headerHeightPx " +
253
+ "view=${view?.let { System.identityHashCode(it) }} tag=${view?.id} observed=${observed?.let { System.identityHashCode(it) }} " +
254
+ "offset=${view?.let(::verticalScrollOffset)} cache=${pageOffsets[key]} boundsHeight=${view?.height}")
255
+ }
256
+
233
257
  private fun outerPagerIndex(): Int {
234
258
  var ancestor = parent as? View
235
259
  while (ancestor != null) {
@@ -386,16 +410,80 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
386
410
 
387
411
  fun reactChildAt(index: Int) = logicalChildren[index]
388
412
 
413
+ internal fun beginPageTransition(target: Int? = null) {
414
+ if (!nativeSmoothHeaderScrollEnabled || headerHeightPx <= 0) return
415
+ if (transitionHeaderOffsets == null) {
416
+ pageHeaderOffsets[currentPageKey()] = headerOffsetPx
417
+ detachPrimaryScrollObserver()
418
+ transitionSourcePage = selectedPage
419
+ transitionHeaderOffsets = pageHeaderOffsets.toMap()
420
+ transitionPageOffsets = pageOffsets.toMap()
421
+ }
422
+ if (target != null) transitionTargetPage = target
423
+ }
424
+
425
+ internal fun onPagerScrolled(position: Int, offset: Float) {
426
+ val headers = transitionHeaderOffsets ?: return
427
+ val target = transitionTargetPage
428
+ val first = if (target != null) transitionSourcePage else position
429
+ val second = target ?: (position + 1).coerceAtMost(adapter.itemCount - 1)
430
+ val fraction = if (target != null && target != first) {
431
+ ((position + offset - first) / (target - first)).coerceIn(0f, 1f)
432
+ } else offset
433
+ val firstH = headers[pageKey(first)] ?: 0
434
+ val secondH = headers[pageKey(second)] ?: 0
435
+ syntheticPageRestore = true
436
+ setHeaderOffset((firstH + (secondH - firstH) * fraction).roundToInt())
437
+ syntheticPageRestore = false
438
+ updateNativeTabProgress(position, offset)
439
+ }
440
+
441
+ internal fun onPagerScrollStateChanged(state: Int) {
442
+ if (state == ViewPager2.SCROLL_STATE_DRAGGING || state == ViewPager2.SCROLL_STATE_SETTLING) {
443
+ beginPageTransition()
444
+ } else if (state == ViewPager2.SCROLL_STATE_IDLE && transitionHeaderOffsets != null) {
445
+ finishPageTransition(pager.currentItem)
446
+ }
447
+ }
448
+
449
+ private fun finishPageTransition(position: Int) {
450
+ val headers = transitionHeaderOffsets ?: return
451
+ detachPrimaryScrollObserver(recordOffset = false)
452
+ releaseRefreshForeground()
453
+ selectedPage = position.coerceIn(0, adapter.itemCount - 1)
454
+ syntheticPageRestore = true
455
+ setHeaderOffset(headers[currentPageKey()] ?: 0)
456
+ transitionHeaderOffsets = null
457
+ transitionPageOffsets = null
458
+ transitionTargetPage = null
459
+ syntheticPageRestore = false
460
+ pageHeaderOffsets[currentPageKey()] = headerOffsetPx
461
+ prepareAdjacentPages()
462
+ attachPrimaryScrollObserver()
463
+ }
464
+
389
465
  fun selectPage(position: Int) {
466
+ offsetDiag("select-begin-target=$position", observedOwnedScroller ?: observedNativeScroller ?: observedRecyclerView)
390
467
  pendingRefreshPull = null
391
468
  if (adapter.itemCount == 0) return
469
+ if (nativeSmoothHeaderScrollEnabled && headerHeightPx > 0) {
470
+ if (transitionHeaderOffsets != null) return
471
+ beginPageTransition(position)
472
+ finishPageTransition(position)
473
+ return
474
+ }
392
475
  detachPrimaryScrollObserver()
393
476
  releaseRefreshForeground()
394
477
  selectedPage = position.coerceIn(0, adapter.itemCount - 1)
395
478
  val savedOffset = pageOffsets[currentPageKey()]
396
479
  ?: primaryScrollableForPage(selectedPage)?.let(::verticalScrollOffset)
397
480
  ?: 0
398
- if (savedOffset > 0) setHeaderOffset(headerHeightPx)
481
+ offsetDiag("select-offset=$savedOffset-source=${if (pageOffsets[currentPageKey()] != null) "saved" else "live"}")
482
+ if (savedOffset > 0) {
483
+ offsetDiag("select-force-collapse-before")
484
+ setHeaderOffset(headerHeightPx)
485
+ offsetDiag("select-force-collapse-after")
486
+ }
399
487
  postForCurrentAttachment {
400
488
  prepareAdjacentPages()
401
489
  attachPrimaryScrollObserver()
@@ -537,9 +625,14 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
537
625
 
538
626
  private fun setHeaderOffset(offset: Int) {
539
627
  // val next = offset.coerceIn(0, headerHeightPx)
540
- val next = offset.coerceIn(0, maximumHeaderOffset())
628
+ val pending = nativeSmoothHeaderScrollEnabled && currentPageKey() in pendingPageRestores
629
+ val maximum = if (transitionHeaderOffsets != null || pending) headerHeightPx else maximumHeaderOffset()
630
+ val next = offset.coerceIn(0, maximum)
541
631
  if (headerOffsetPx == next) return
542
632
  headerOffsetPx = next
633
+ if (nativeSmoothHeaderScrollEnabled && transitionHeaderOffsets == null && !syntheticPageRestore && !pending) {
634
+ pageHeaderOffsets[currentPageKey()] = next
635
+ }
543
636
  applyHeaderOffset()
544
637
  }
545
638
 
@@ -656,13 +749,17 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
656
749
  }
657
750
 
658
751
  private fun attachPrimaryScrollObserver() {
752
+ if (transitionHeaderOffsets != null) return
659
753
  if (selectedPage !in 0 until adapter.itemCount) return
660
754
  val previous = observedRecyclerView ?: observedOwnedScroller ?: observedNativeScroller
661
755
  if (observedPageRemoved || previous != null && !isDescendant(previous, adapter.pageAt(selectedPage))) {
756
+ offsetDiag("primary-replacement-before-removed=$observedPageRemoved", previous)
662
757
  observedPageRemoved = false
663
- detachPrimaryScrollObserver()
758
+ detachPrimaryScrollObserver(recordOffset = !nativeSmoothHeaderScrollEnabled)
664
759
  // A replacement scroller starts at the beginning while retaining header collapse.
665
- pageOffsets[currentPageKey()] = 0
760
+ offsetDiag("primary-replacement-cache-before", previous)
761
+ if (!nativeSmoothHeaderScrollEnabled) pageOffsets[currentPageKey()] = 0
762
+ offsetDiag("primary-replacement-cache-after", previous)
666
763
  }
667
764
  when (val scrollable = primaryScrollableForPage(selectedPage)) {
668
765
  is RecyclerView -> attachRecyclerObserver(scrollable)
@@ -672,24 +769,33 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
672
769
  }
673
770
 
674
771
  private fun attachRecyclerObserver(recycler: RecyclerView) {
675
- if (observedRecyclerView === recycler) {
772
+ if (observedRecyclerView === recycler && observedRecyclerKey == currentPageKey()) {
676
773
  applyRecyclerInsets(recycler, selectedPage)
677
774
  publishConsumedOffset(recycler)
678
775
  return
679
776
  }
680
777
 
681
778
  detachPrimaryScrollObserver()
779
+ offsetDiag("recycler-attach-before", recycler)
682
780
  observedRecyclerView = recycler
781
+ observedRecyclerKey = currentPageKey()
683
782
  applyRecyclerInsets(recycler, selectedPage)
684
783
  publishConsumedOffset(recycler)
784
+ val key = currentPageKey()
685
785
  val listener = object : RecyclerView.OnScrollListener() {
686
786
  override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
787
+ if (observedScrollListener !== this || observedRecyclerKey != key ||
788
+ key != currentPageKey() || !isAttachedToWindow ||
789
+ transitionHeaderOffsets != null || syntheticPageRestore || key in pendingPageRestores) return
687
790
  val contentOffset = recyclerView.computeVerticalScrollOffset()
688
- pageOffsets[currentPageKey()] = contentOffset
791
+ if (pageOffsets[currentPageKey()] != contentOffset) offsetDiag("recycler-cache-before-new=$contentOffset", recyclerView)
792
+ pageOffsets[key] = contentOffset
689
793
  // Nested touch scrolling moves list content only after the header has
690
794
  // collapsed. Programmatic list jumps bypass those parent callbacks.
691
795
  if (contentOffset > 0 && headerOffsetPx < headerHeightPx) {
796
+ offsetDiag("recycler-positive-collapse-before", recyclerView)
692
797
  setHeaderOffset(headerHeightPx)
798
+ offsetDiag("recycler-positive-collapse-after", recyclerView)
693
799
  }
694
800
  }
695
801
  }
@@ -705,7 +811,11 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
705
811
  val recycler = observedRecyclerView
706
812
  val listener = observedScrollListener
707
813
  if (recycler != null) {
708
- if (recordOffset) pageOffsets[currentPageKey()] = recycler.computeVerticalScrollOffset()
814
+ offsetDiag("recycler-detach-save-before-record=$recordOffset", recycler)
815
+ if (recordOffset && transitionPageOffsets == null) observedRecyclerKey?.let {
816
+ if (it !in pendingPageRestores) pageOffsets[it] = recycler.computeVerticalScrollOffset()
817
+ }
818
+ offsetDiag("recycler-detach-save-after", recycler)
709
819
  publishConsumedOffset(recycler, remove = true)
710
820
  }
711
821
  if (recycler != null && listener != null) recycler.removeOnScrollListener(listener)
@@ -716,28 +826,37 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
716
826
  )
717
827
  }
718
828
  observedRecyclerView = null
829
+ observedRecyclerKey = null
719
830
  observedScrollListener = null
720
831
  }
721
832
 
722
833
  private fun attachNativeScrollerObserver(scroller: ScrollView) {
723
- if (observedNativeScroller === scroller) {
834
+ if (observedNativeScroller === scroller && observedNativeScrollerKey == currentPageKey()) {
724
835
  applyNativeScrollerInsets(scroller, selectedPage)
725
836
  return
726
837
  }
727
838
 
728
839
  detachPrimaryScrollObserver()
840
+ offsetDiag("scrollview-attach-before", scroller)
729
841
  observedNativeScroller = scroller
842
+ observedNativeScrollerKey = currentPageKey()
730
843
  applyNativeScrollerInsets(scroller, selectedPage)
844
+ val key = currentPageKey()
731
845
  val listener = object : ViewTreeObserver.OnScrollChangedListener {
732
846
  override fun onScrollChanged() {
733
847
  if (observedNativeScrollerListener !== this) {
734
848
  removeScrollChangedListener(null, scroller, this)
735
849
  return
736
850
  }
851
+ if (key != currentPageKey() || !isAttachedToWindow ||
852
+ transitionHeaderOffsets != null || syntheticPageRestore || key in pendingPageRestores) return
737
853
  val contentOffset = scroller.scrollY
738
- pageOffsets[currentPageKey()] = contentOffset
854
+ if (pageOffsets[currentPageKey()] != contentOffset) offsetDiag("scrollview-cache-before-new=$contentOffset", scroller)
855
+ pageOffsets[key] = contentOffset
739
856
  if (contentOffset > 0 && headerOffsetPx < headerHeightPx) {
857
+ offsetDiag("scrollview-positive-collapse-before", scroller)
740
858
  setHeaderOffset(headerHeightPx)
859
+ offsetDiag("scrollview-positive-collapse-after", scroller)
741
860
  }
742
861
  }
743
862
  }
@@ -754,7 +873,11 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
754
873
  private fun detachNativeScrollerObserver(recordOffset: Boolean) {
755
874
  val scroller = observedNativeScroller
756
875
  val listener = observedNativeScrollerListener
757
- if (scroller != null && recordOffset) pageOffsets[currentPageKey()] = scroller.scrollY
876
+ offsetDiag("scrollview-detach-save-before-record=$recordOffset", scroller)
877
+ if (scroller != null && recordOffset && transitionPageOffsets == null) observedNativeScrollerKey?.let {
878
+ if (it !in pendingPageRestores) pageOffsets[it] = scroller.scrollY
879
+ }
880
+ offsetDiag("scrollview-detach-save-after", scroller)
758
881
  if (scroller != null && listener != null) {
759
882
  removeScrollChangedListener(observedNativeScrollerTreeObserver, scroller, listener)
760
883
  }
@@ -765,6 +888,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
765
888
  )
766
889
  }
767
890
  observedNativeScroller = null
891
+ observedNativeScrollerKey = null
768
892
  observedNativeScrollerListener = null
769
893
  observedNativeScrollerTreeObserver = null
770
894
  }
@@ -793,9 +917,23 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
793
917
  val key = pageKey(pageIndex)
794
918
  if (ownedScrollerKeys[scroller] == key) return
795
919
  ownedScrollerKeys[scroller] = key
920
+ if (nativeSmoothHeaderScrollEnabled) {
921
+ pendingPageRestores.add(key)
922
+ ownedScrollerRestoreRequests[key] = WeakReference(scroller) to null
923
+ }
924
+ val generation = attachmentGeneration
796
925
  postForCurrentAttachment {
797
- if (ownedScrollerKeys[scroller] == key && scroller.isAttachedToWindow) {
798
- scroller.host.restoreScrollOffset(pageOffsets[key] ?: 0)
926
+ if (ownedScrollerKeys[scroller] == key && generation == attachmentGeneration &&
927
+ scroller.isAttachedToWindow && (!nativeSmoothHeaderScrollEnabled ||
928
+ (key in pendingPageRestores && ownedScrollerRestoreRequests[key]?.first?.get() === scroller))) {
929
+ offsetDiag("owned-restore-before", scroller, key)
930
+ val saved = transitionPageOffsets?.get(key) ?: pageOffsets[key] ?: 0
931
+ scroller.host.restoreScrollOffset(saved)
932
+ if (nativeSmoothHeaderScrollEnabled) {
933
+ ownedScrollerRestoreRequests[key] = WeakReference(scroller) to generation
934
+ recordOwnedScrollerOffset(scroller, key)
935
+ }
936
+ offsetDiag("owned-restore-after", scroller, key)
799
937
  }
800
938
  }
801
939
  }
@@ -803,7 +941,9 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
803
941
  private fun restoreOwnedScrollerInsets(scroller: NativeScrollerViewport) {
804
942
  scroller.stopMotion()
805
943
  scroller.host.cancelPendingScrollRestore()
806
- ownedScrollerKeys.remove(scroller)
944
+ ownedScrollerKeys.remove(scroller)?.let { key ->
945
+ if (ownedScrollerRestoreRequests[key]?.first?.get() === scroller) ownedScrollerRestoreRequests.remove(key)
946
+ }
807
947
  publishConsumedOffset(scroller, remove = true)
808
948
  scroller.host.setPagerInset(this, null)
809
949
  }
@@ -811,9 +951,11 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
811
951
  private fun attachOwnedScrollerObserver(scroller: NativeScrollerViewport) {
812
952
  if (observedOwnedScroller === scroller && observedOwnedScrollerKey == currentPageKey()) {
813
953
  applyOwnedScrollerInsets(scroller, selectedPage)
954
+ if (nativeSmoothHeaderScrollEnabled) recordOwnedScrollerOffset(scroller, currentPageKey())
814
955
  return
815
956
  }
816
957
  detachPrimaryScrollObserver()
958
+ offsetDiag("owned-attach-before", scroller)
817
959
  observedOwnedScroller = scroller
818
960
  observedOwnedScrollerKey = currentPageKey()
819
961
  val key = currentPageKey()
@@ -824,9 +966,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
824
966
  removeScrollChangedListener(null, scroller, this)
825
967
  return
826
968
  }
827
- val contentOffset = scroller.scrollY
828
- pageOffsets[key] = contentOffset
829
- if (contentOffset > 0 && headerOffsetPx < headerHeightPx) setHeaderOffset(headerHeightPx)
969
+ recordOwnedScrollerOffset(scroller, key)
830
970
  }
831
971
  }
832
972
  observedOwnedScrollerListener = listener
@@ -835,11 +975,37 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
835
975
  }
836
976
  }
837
977
 
978
+ private fun recordOwnedScrollerOffset(scroller: NativeScrollerViewport, key: String) {
979
+ if (ownedScrollerKeys[scroller] != key || !isAttachedToWindow || !scroller.isAttachedToWindow) return
980
+ if (key in pendingPageRestores) {
981
+ // A false native pending flag before our posted request executes is not cancellation.
982
+ val request = ownedScrollerRestoreRequests[key]
983
+ if (request == null || request.first.get() !== scroller || request.second != attachmentGeneration ||
984
+ scroller.width <= 0 || scroller.height <= 0 || scroller.host.hasPendingScrollRestore()) return
985
+ pendingPageRestores.remove(key)
986
+ ownedScrollerRestoreRequests.remove(key)
987
+ }
988
+ if (observedOwnedScroller !== scroller || observedOwnedScrollerKey != key ||
989
+ key != currentPageKey() || transitionHeaderOffsets != null || syntheticPageRestore) return
990
+ val contentOffset = scroller.scrollY
991
+ if (pageOffsets[key] != contentOffset) offsetDiag("owned-cache-before-new=$contentOffset", scroller, key)
992
+ pageOffsets[key] = contentOffset
993
+ if (contentOffset > 0 && headerOffsetPx < headerHeightPx) {
994
+ offsetDiag("owned-positive-collapse-before", scroller, key)
995
+ setHeaderOffset(headerHeightPx)
996
+ offsetDiag("owned-positive-collapse-after", scroller, key)
997
+ }
998
+ }
999
+
838
1000
  private fun detachOwnedScrollerObserver(recordOffset: Boolean) {
839
1001
  val scroller = observedOwnedScroller ?: return
840
1002
  scroller.stopMotion()
841
1003
  publishConsumedOffset(scroller, remove = true)
842
- if (recordOffset) observedOwnedScrollerKey?.let { pageOffsets[it] = scroller.scrollY }
1004
+ offsetDiag("owned-detach-save-before-record=$recordOffset", scroller)
1005
+ if (recordOffset && transitionPageOffsets == null) observedOwnedScrollerKey?.let {
1006
+ if (it !in pendingPageRestores) pageOffsets[it] = scroller.scrollY
1007
+ }
1008
+ offsetDiag("owned-detach-save-after", scroller)
843
1009
  observedOwnedScrollerListener?.let {
844
1010
  removeScrollChangedListener(observedOwnedScrollerTreeObserver, scroller, it)
845
1011
  }
@@ -893,6 +1059,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
893
1059
  val key = pageKey(pageIndex)
894
1060
  if (restoredRecyclerKeys[recycler] != key) {
895
1061
  restoredRecyclerKeys[recycler] = key
1062
+ if (nativeSmoothHeaderScrollEnabled) pendingPageRestores.add(key)
896
1063
  val generation = attachmentGeneration
897
1064
  recycler.post {
898
1065
  if (!isAttachedToWindow || generation != attachmentGeneration ||
@@ -901,9 +1068,16 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
901
1068
  if (restoredRecyclerKeys[recycler] == key) restoredRecyclerKeys.remove(recycler)
902
1069
  return@post
903
1070
  }
904
- val saved = pageOffsets[key] ?: 0
1071
+ val saved = transitionPageOffsets?.get(key) ?: pageOffsets[key] ?: 0
905
1072
  val current = recycler.computeVerticalScrollOffset()
1073
+ offsetDiag("recycler-restore-before-saved=$saved", recycler, key)
1074
+ if (nativeSmoothHeaderScrollEnabled && (recycler.height <= 0 || recycler.childCount == 0)) {
1075
+ restoredRecyclerKeys.remove(recycler)
1076
+ return@post
1077
+ }
906
1078
  if (saved != current) recycler.scrollBy(0, saved - current)
1079
+ pendingPageRestores.remove(key)
1080
+ offsetDiag("recycler-restore-after", recycler, key)
907
1081
  }
908
1082
  }
909
1083
  }
@@ -949,6 +1123,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
949
1123
  val key = pageKey(pageIndex)
950
1124
  if (restoredScrollerKeys[scroller] != key) {
951
1125
  restoredScrollerKeys[scroller] = key
1126
+ if (nativeSmoothHeaderScrollEnabled) pendingPageRestores.add(key)
952
1127
  val generation = attachmentGeneration
953
1128
  scroller.post {
954
1129
  if (!isAttachedToWindow || generation != attachmentGeneration ||
@@ -957,8 +1132,16 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
957
1132
  if (restoredScrollerKeys[scroller] == key) restoredScrollerKeys.remove(scroller)
958
1133
  return@post
959
1134
  }
960
- val saved = pageOffsets[key] ?: 0
1135
+ val saved = transitionPageOffsets?.get(key) ?: pageOffsets[key] ?: 0
1136
+ offsetDiag("scrollview-restore-before-saved=$saved", scroller, key)
1137
+ if (nativeSmoothHeaderScrollEnabled &&
1138
+ (scroller.height <= 0 || (scroller.getChildAt(0)?.height ?: 0) <= 0)) {
1139
+ restoredScrollerKeys.remove(scroller)
1140
+ return@post
1141
+ }
961
1142
  if (scroller.scrollY != saved) scroller.scrollTo(scroller.scrollX, saved)
1143
+ pendingPageRestores.remove(key)
1144
+ offsetDiag("scrollview-restore-after", scroller, key)
962
1145
  }
963
1146
  }
964
1147
  }
@@ -1399,6 +1582,10 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
1399
1582
  override fun onNestedScrollAccepted(child: View, target: View, axes: Int, type: Int) {
1400
1583
  if (type == ViewCompat.TYPE_TOUCH) pendingRefreshPull = null
1401
1584
  nestedScrollingParentHelper.onNestedScrollAccepted(child, target, axes, type)
1585
+ if (type == ViewCompat.TYPE_TOUCH) {
1586
+ pendingPageRestores.remove(currentPageKey())
1587
+ ownedScrollerRestoreRequests.remove(currentPageKey())
1588
+ }
1402
1589
  }
1403
1590
 
1404
1591
  override fun onStopNestedScroll(target: View, type: Int) {
@@ -1492,6 +1679,13 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
1492
1679
  restoredRecyclerKeys.clear()
1493
1680
  restoredScrollerKeys.clear()
1494
1681
  viewTreeObserver.addOnPreDrawListener(pageContentLayoutListener)
1682
+ resumeTransitionSource?.let { source ->
1683
+ resumeTransitionSource = null
1684
+ if (source in 0 until adapter.itemCount) {
1685
+ pager.setCurrentItem(source, false)
1686
+ selectPage(source)
1687
+ }
1688
+ }
1495
1689
  log(
1496
1690
  "host-attach generation=$attachmentGeneration inner=$selectedPage " +
1497
1691
  "outer=${outerPagerIndex()} nativePages=${adapter.itemCount}",
@@ -1511,6 +1705,15 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
1511
1705
  nestedNativeGestureStarted = false
1512
1706
  viewTreeObserver.removeOnPreDrawListener(pageContentLayoutListener)
1513
1707
  detachPrimaryScrollObserver()
1708
+ transitionHeaderOffsets?.let { headers ->
1709
+ resumeTransitionSource = transitionSourcePage
1710
+ syntheticPageRestore = true
1711
+ setHeaderOffset(headers[pageKey(transitionSourcePage)] ?: 0)
1712
+ syntheticPageRestore = false
1713
+ }
1714
+ transitionHeaderOffsets = null
1715
+ transitionPageOffsets = null
1716
+ transitionTargetPage = null
1514
1717
  // Snapshot live keys before restoration removes their padding records.
1515
1718
  for (recycler in originalRecyclerPadding.keys.toTypedArray()) {
1516
1719
  restoreRecyclerInsets(recycler)
@@ -43,6 +43,7 @@ class CollapsiblePagerViewManager : ViewGroupManager<CollapsiblePagerHost>(),
43
43
  )
44
44
  host.pager.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() {
45
45
  override fun onPageScrolled(position: Int, offset: Float, offsetPixels: Int) {
46
+ host.onPagerScrolled(position, offset)
46
47
  dispatch(reactContext, host, PageScrollEvent(host.id, position, offset))
47
48
  }
48
49
 
@@ -55,6 +56,7 @@ class CollapsiblePagerViewManager : ViewGroupManager<CollapsiblePagerHost>(),
55
56
  }
56
57
 
57
58
  override fun onPageScrollStateChanged(state: Int) {
59
+ host.onPagerScrollStateChanged(state)
58
60
  val value = when (state) {
59
61
  ViewPager2.SCROLL_STATE_IDLE -> "idle"
60
62
  ViewPager2.SCROLL_STATE_DRAGGING -> "dragging"
@@ -324,13 +326,19 @@ class CollapsiblePagerViewManager : ViewGroupManager<CollapsiblePagerHost>(),
324
326
 
325
327
  override fun setPage(view: CollapsiblePagerHost?, selectedPage: Int) {
326
328
  if (view != null && selectedPage in 0 until view.adapter.itemCount) {
329
+ view.beginPageTransition(selectedPage)
327
330
  view.pager.setCurrentItem(selectedPage, true)
331
+ if (view.pager.scrollState == ViewPager2.SCROLL_STATE_IDLE) {
332
+ view.onPagerScrollStateChanged(ViewPager2.SCROLL_STATE_IDLE)
333
+ }
328
334
  }
329
335
  }
330
336
 
331
337
  override fun setPageWithoutAnimation(view: CollapsiblePagerHost?, selectedPage: Int) {
332
338
  if (view != null && selectedPage in 0 until view.adapter.itemCount) {
339
+ view.beginPageTransition(selectedPage)
333
340
  view.pager.setCurrentItem(selectedPage, false)
341
+ view.onPagerScrollStateChanged(ViewPager2.SCROLL_STATE_IDLE)
334
342
  }
335
343
  }
336
344
 
@@ -255,6 +255,8 @@ class NativeScrollerView(context: Context) : FrameLayout(context) {
255
255
 
256
256
  internal fun cancelPendingScrollRestore() = pendingRestore.cancel()
257
257
 
258
+ internal fun hasPendingScrollRestore(): Boolean = pendingRestore.target != null
259
+
258
260
  internal fun setRefreshOffset(value: Int) {
259
261
  refreshOffset = value
260
262
  applyRefreshOffset()
@@ -27,5 +27,16 @@ fun main() {
27
27
  restore.cancel()
28
28
  check(restore.target == null)
29
29
  check(restore.anchor(120) == 120)
30
+ // Cancellation clears the old target without requiring the reached offset to change.
31
+ restore.request(target = 640, reached = 120)
32
+ check(restore.anchor(120) == 640)
33
+ restore.cancel()
34
+ check(restore.target == null)
35
+ check(restore.anchor(120) == 120)
36
+ // A fresh request owns its target after cancellation.
37
+ restore.request(target = 320, reached = 120)
38
+ check(restore.target == 320)
39
+ restore.settle(reached = 320)
40
+ check(restore.target == null)
30
41
  println("Native scroller pending restore: clamp retry, partial growth, settle and cancel passed")
31
42
  }
package/docs/SPEC.md CHANGED
@@ -3,6 +3,42 @@
3
3
  Existing PagerView and CollapsiblePagerView APIs are documented in README.md.
4
4
  The sections below define native coordination boundaries without new React props.
5
5
 
6
+ ## Smooth per-page screen restoration
7
+
8
+ When nativeSmoothHeaderScrollEnabled is enabled with a collapsible header, each
9
+ page owns its header collapse and body offset. For a settled position outside
10
+ pull overscroll, Q = H + L, H = min(Q, C), and L = max(Q - C, 0), where C is
11
+ the header-collapse distance. These are semantic coordinates, not new props.
12
+ An unvisited page starts expanded
13
+ at body start. Leaving a page preserves its expanded, partial or collapsed header
14
+ and its body position; tap, swipe and programmatic selection restore the same
15
+ complete screen state. Legacy mode and pagers without a collapsible header retain
16
+ their existing behavior.
17
+
18
+ Horizontal progress interpolates the shared header between immutable source and
19
+ target snapshots. Native body geometry follows that header without obscured rows
20
+ or blank gaps. Temporary transition offsets MUST NOT overwrite page snapshots.
21
+ Non-adjacent commands use the original source and destination, not intermediate
22
+ pages, as the header interpolation endpoints. Cancellation restores the source
23
+ and all temporarily compensated pages before observation resumes.
24
+
25
+ A scroll observer owns one page identity and generation independently of mutable
26
+ selection. Detach, replacement and late notifications MUST NOT save another
27
+ page's offset. Zero layout and unresolved replacement identity preserve pending
28
+ restoration until an actual usable layout; settled content shrink still clamps to
29
+ the legal range. This state is native UI-thread state, not persistent wallet data
30
+ or per-frame JavaScript synchronization. Vertical scrolling, refresh, snapping
31
+ and repeated-current-page selection retain their existing contracts.
32
+
33
+ Android owned NativeScroller restoration keeps its intent while a posted request
34
+ has not executed or native content is still too short to reach the saved offset.
35
+ Only the same viewport, page key and attachment generation may reconcile an
36
+ issued request. Once native restoration settles or is cancelled by touch, an
37
+ imperative command or content replacement, the host resumes recording its live
38
+ body offset. Existing pre-draw observation also reconciles a same-position command
39
+ that emits no scroll notification. Zero-sized geometry retains the pending intent;
40
+ no timeout infers that delayed content is permanently short.
41
+
6
42
  ## Independent NativeScroller migration
7
43
 
8
44
  Status: implemented in source. Both native builds pass. Android focused emulator
@@ -254,9 +290,11 @@ start a refresh gesture. Non-smooth refresh direction behavior remains unchanged
254
290
  iOS nested horizontal ownership MUST include enabled nonpaging horizontal-only
255
291
  ScrollViews, with content wider than bounds and no vertical content overflow.
256
292
  Both pan holding and failure dependencies MUST use the same eligibility. A
257
- failure dependency on the Pager guard MUST apply only to the horizontal pan;
293
+ failure dependency on the Pager guard MUST apply only to the horizontal pan,
294
+ both on the root Pager ScrollView and on eligible nested horizontal ScrollViews;
258
295
  independent non-pan recognizers, including stationary native long presses, MUST
259
- remain able to recognize before finger-up while Pager intent is pending. A
296
+ remain able to deliver their target actions before finger-up while Pager intent
297
+ is pending. A
260
298
  second touch in the pending Pager guard fails recognition; an already active
261
299
  Pager guard cancels and restores each held pan's original minimum touch count.
262
300
 
@@ -452,7 +490,8 @@ When restoring a retained page's cached vertical offset, the pager limits only
452
490
  the upper bound to the current content height, viewport and applied insets. A
453
491
  cache captured before rows were removed MUST NOT restore an offset below the new
454
492
  content bottom, including when removal occurs during a page transition. Valid
455
- deep offsets and the existing lower-bound/header alignment rules are unchanged.
493
+ deep offsets and pull-overscroll behavior are preserved; smooth-header selection
494
+ follows the per-page reading-position contract above.
456
495
  This check runs only on explicit restoration, not on scroll frames; UIKit pull
457
496
  overscroll and refresh settlement retain their existing ownership. An unmeasured
458
497
  zero-height viewport defers this upper-bound limit to a later restoration.