@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.
- package/android/src/main/java/com/reactnativepagerview/CollapsiblePagerHost.kt +222 -19
- package/android/src/main/java/com/reactnativepagerview/CollapsiblePagerViewManager.kt +8 -0
- package/android/src/main/java/com/reactnativepagerview/NativeScrollerView.kt +2 -0
- package/android/src/test/java/com/reactnativepagerview/NativeScrollerPendingRestoreTest.kt +11 -0
- package/docs/SPEC.md +42 -3
- package/ios/RNCCollapsiblePagerViewComponentView.mm +257 -28
- package/ios/RNCNativeScrollerComponentView.mm +34 -0
- package/ios/tests/RNCCollapsiblePagerFirstFrameTests.mm +173 -0
- package/ios/tests/RNCCollapsiblePagerPressCancellationTests.mm +31 -0
- package/package.json +2 -2
|
@@ -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 (
|
|
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
|
|
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
|
-
|
|
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()]
|
|
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
|
-
|
|
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()]
|
|
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
|
-
|
|
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 &&
|
|
798
|
-
scroller.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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.
|