@onekeyfe/react-native-pager-view 3.0.173 → 3.0.174

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.
@@ -29,6 +29,7 @@ import com.facebook.react.views.scroll.ReactScrollView
29
29
  import com.margelo.nitro.nativelogger.OneKeyLog
30
30
  import java.lang.ref.WeakReference
31
31
  import java.util.WeakHashMap
32
+ import java.util.function.BiConsumer
32
33
  import kotlin.math.max
33
34
  import kotlin.math.min
34
35
  import kotlin.math.roundToInt
@@ -123,6 +124,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
123
124
  private val originalScrollerPadding = WeakHashMap<ScrollView, ScrollerPadding>()
124
125
  private val restoredScrollerKeys = WeakHashMap<ScrollView, String>()
125
126
  private val ownedScrollerKeys = WeakHashMap<NativeScrollerViewport, String>()
127
+ private val ownedScrollerRestoreRequests = HashMap<String, Pair<WeakReference<NativeScrollerViewport>, Int?>>()
126
128
  private var foregroundRefreshIndicator: WeakReference<ImageView>? = null
127
129
  private var refreshForegroundWasVisible = false
128
130
  private val refreshForegroundMatrix = Matrix()
@@ -130,13 +132,74 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
130
132
  private val refreshForegroundAncestors = ArrayList<View>()
131
133
  private var refreshForegroundAncestorAlpha = 1f
132
134
  private val pageOffsets = HashMap<String, Int>()
135
+ private val pageHeaderOffsets = HashMap<String, Int>()
136
+ private class RecyclerReadingAnchor(view: RecyclerView, val adapter: RecyclerView.Adapter<*>, val position: Int, val top: Int) {
137
+ val view = WeakReference(view)
138
+ }
139
+ private val recyclerReadingAnchors = HashMap<String, RecyclerReadingAnchor>()
140
+ private val displayRecyclerAnchors = HashMap<String, RecyclerReadingAnchor>()
141
+ private var transitionSourceRecyclerAnchor: RecyclerReadingAnchor? = null
142
+ private val commandBodyKeys = HashSet<String>()
143
+ private var explicitTopCommand: Pair<String, PageRestoreRequest>? = null
144
+ private class PagerTopRequest(view: View, val key: String, val pageIndex: Int, val generation: Int, val target: Int, val token: Int) {
145
+ val view = WeakReference(view)
146
+ }
147
+ private var pagerTopRequest: PagerTopRequest? = null
148
+ private var pagerTopToken = 0
149
+ private var dispatchingPagerTop = false
150
+ private val suspendedBodyKeys = HashSet<String>()
151
+ private val alignedBodyKeys = HashSet<String>()
152
+ private val transitionAlignedViews = WeakHashMap<View, String>()
153
+ private var transitionSourceDisplayBody = 0
154
+ private val displayBodyOverrides = HashMap<String, Int>()
155
+ private val deferredRecyclerGeometry = WeakHashMap<RecyclerView, Triple<Int, Int, Int>>()
156
+ private var transitionHeaderOffsets: Map<String, Int>? = null
157
+ private var transitionPageOffsets: Map<String, Int>? = null
158
+ private var transitionSourcePage = 0
159
+ private var transitionTargetPage: Int? = null
160
+ private var syntheticPageRestore = false
161
+ private var resumeTransitionSource: Int? = null
162
+ private val pendingPageRestores = HashSet<String>()
163
+ private class PageRestoreRequest(view: View, val pageIndex: Int, val generation: Int) {
164
+ val view = WeakReference(view)
165
+ }
166
+ private val pageRestoreRequests = HashMap<String, PageRestoreRequest>()
167
+
168
+ private fun registerPageRestore(key: String, view: View, pageIndex: Int): PageRestoreRequest? {
169
+ if (!nativeSmoothHeaderScrollEnabled) return null
170
+ val previous = pageRestoreRequests[key]?.view?.get()
171
+ if (previous is NativeScrollerViewport && previous !== view) previous.host.cancelPendingScrollRestore()
172
+ ownedScrollerRestoreRequests.remove(key)
173
+ return PageRestoreRequest(view, pageIndex, attachmentGeneration).also {
174
+ pageRestoreRequests[key] = it
175
+ pendingPageRestores.add(key)
176
+ offsetDiag("request-register", view, key, pageIndex, it)
177
+ }
178
+ }
179
+
180
+ private fun isCurrentPageRestore(key: String, request: PageRestoreRequest?): Boolean =
181
+ !nativeSmoothHeaderScrollEnabled || (request != null && pageRestoreRequests[key] === request &&
182
+ key in pendingPageRestores && request.generation == attachmentGeneration &&
183
+ pageKey(request.pageIndex) == key)
184
+
185
+ private fun clearPageRestoreForView(key: String, view: View, request: PageRestoreRequest? = null) {
186
+ if (request != null && pageRestoreRequests[key] !== request) return
187
+ if (pageRestoreRequests[key]?.view?.get() !== view) return
188
+ offsetDiag("request-clear-before", view, key, request = request ?: pageRestoreRequests[key])
189
+ pageRestoreRequests.remove(key)
190
+ pendingPageRestores.remove(key)
191
+ ownedScrollerRestoreRequests.remove(key)
192
+ offsetDiag("request-clear-after", view, key, request = request)
193
+ }
133
194
  private var observedRecyclerView: RecyclerView? = null
195
+ private var observedRecyclerKey: String? = null
134
196
  private var observedScrollListener: RecyclerView.OnScrollListener? = null
135
197
  private var observedOwnedScroller: NativeScrollerViewport? = null
136
198
  private var observedOwnedScrollerKey: String? = null
137
199
  private var observedOwnedScrollerListener: ViewTreeObserver.OnScrollChangedListener? = null
138
200
  private var observedOwnedScrollerTreeObserver: ViewTreeObserver? = null
139
201
  private var observedNativeScroller: ScrollView? = null
202
+ private var observedNativeScrollerKey: String? = null
140
203
  private var observedNativeScrollerListener: ViewTreeObserver.OnScrollChangedListener? = null
141
204
  private var observedNativeScrollerTreeObserver: ViewTreeObserver? = null
142
205
  private var observedPageRemoved = false
@@ -162,8 +225,14 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
162
225
  // Apply its header insets before that list first draws.
163
226
  prepareAdjacentPages()
164
227
  attachPrimaryScrollObserver()
228
+ finishPagerTopIfSettled()
165
229
  // OneKey patch: a filtered short page can reduce the existing collapse range.
166
230
  setHeaderOffset(headerOffsetPx)
231
+ if (offsetDiagEnabled() && offsetDiagNextDraw.isNotEmpty()) {
232
+ val samples = offsetDiagNextDraw.entries.map { it.key to it.value }
233
+ offsetDiagNextDraw.clear()
234
+ samples.forEach { (view, owner) -> offsetDiag("restore-next-predraw", view, owner.first, owner.second) }
235
+ }
167
236
  true
168
237
  }
169
238
 
@@ -189,6 +258,20 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
189
258
  var headerOffsetPx = 0
190
259
  private set
191
260
  var pageKeys: List<String> = emptyList()
261
+ set(value) {
262
+ if (field == value) return
263
+ pageRestoreRequests.entries.toList().forEach { (key, request) ->
264
+ if ((value.getOrNull(request.pageIndex) ?: "page-${request.pageIndex}") != key) {
265
+ (request.view.get() as? NativeScrollerViewport)?.host?.cancelPendingScrollRestore()
266
+ pageRestoreRequests.remove(key)
267
+ pendingPageRestores.remove(key)
268
+ ownedScrollerRestoreRequests.remove(key)
269
+ }
270
+ }
271
+ recyclerReadingAnchors.keys.retainAll(value.toSet())
272
+ displayRecyclerAnchors.keys.retainAll(value.toSet())
273
+ field = value
274
+ }
192
275
  var retainedPages: String = "[]"
193
276
  var onHeaderOffsetChanged: (() -> Unit)? = null
194
277
  var nativeSmoothHeaderScrollEnabled = false
@@ -230,6 +313,51 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
230
313
  OneKeyLog.debug("CollapsiblePager", "host=$hostIdentity $message")
231
314
  }
232
315
 
316
+ private var offsetDiagSequence = 0L
317
+ private val offsetDiagNextDraw = WeakHashMap<View, Pair<String, Int>>()
318
+
319
+ private fun offsetDiagEnabled(): Boolean =
320
+ (context.applicationInfo.flags and android.content.pm.ApplicationInfo.FLAG_DEBUGGABLE) != 0
321
+
322
+ private fun offsetDiagAfterDraw(view: View, key: String, pageIndex: Int) {
323
+ if (offsetDiagEnabled()) offsetDiagNextDraw[view] = key to pageIndex
324
+ }
325
+
326
+ private fun offsetDiag(
327
+ phase: String,
328
+ view: View? = null,
329
+ key: String = currentPageKey(),
330
+ pageIndex: Int = pageKeys.indexOf(key),
331
+ request: PageRestoreRequest? = pageRestoreRequests[key],
332
+ ) {
333
+ if (!offsetDiagEnabled()) return
334
+ val safeKey = if (key in setOf("portfolio", "perps", "defi", "nft", "history")) key else "other"
335
+ val observed = observedOwnedScroller ?: observedNativeScroller ?: observedRecyclerView
336
+ val observedKey = observedOwnedScrollerKey ?: observedNativeScrollerKey ?: observedRecyclerKey
337
+ val recycler = view as? RecyclerView
338
+ val manager = recycler?.layoutManager as? LinearLayoutManager
339
+ val first = manager?.findFirstVisibleItemPosition()
340
+ val last = manager?.findLastVisibleItemPosition()
341
+ val anchor = first?.takeIf { it != RecyclerView.NO_POSITION }?.let { manager?.findViewByPosition(it) }
342
+ val child = (view as? ViewGroup)?.getChildAt(0)
343
+ OneKeyLog.debug("CollapsiblePager", "[HOME-OFFSET-DIAG] phase=$phase seq=${++offsetDiagSequence} " +
344
+ "wallMs=${System.currentTimeMillis()} monoMs=${SystemClock.uptimeMillis()} host=$hostIdentity " +
345
+ "generation=$attachmentGeneration selectedPage=$selectedPage pagerPage=${pager.currentItem} targetPage=$pageIndex key=$safeKey " +
346
+ "ownerPage=${pageKeys.indexOf(observedKey)} header=$headerOffsetPx headerHeight=$headerHeightPx stickyHeight=$stickyHeaderHeightPx " +
347
+ "transition=${transitionHeaderOffsets != null} synthetic=$syntheticPageRestore pending=${key in pendingPageRestores} " +
348
+ "requestId=${request?.let { System.identityHashCode(it) }} requestGeneration=${request?.generation} " +
349
+ "requestPage=${request?.pageIndex} requestCurrent=${request != null && pageRestoreRequests[key] === request} " +
350
+ "view=${view?.let { System.identityHashCode(it) }} tag=${view?.id} observed=${observed?.let { System.identityHashCode(it) }} " +
351
+ "attached=${view?.isAttachedToWindow} estimatedOffset=${view?.let(::verticalScrollOffset)} " +
352
+ "cachePresent=${pageOffsets.containsKey(key)} cache=${pageOffsets[key]} snapshot=${transitionPageOffsets?.get(key)} " +
353
+ "boundsHeight=${view?.height} measuredHeight=${view?.measuredHeight} childHeight=${child?.height} childTop=${child?.top} " +
354
+ "paddingTop=${view?.paddingTop} paddingBottom=${view?.paddingBottom} childCount=${(view as? ViewGroup)?.childCount} " +
355
+ "itemCount=${recycler?.adapter?.itemCount} first=$first last=$last anchorTag=${anchor?.id} " +
356
+ "anchorTop=${anchor?.let { manager?.getDecoratedTop(it) }} anchorRelative=${anchor?.let { manager?.getDecoratedTop(it)?.minus(recycler?.paddingTop ?: 0) }} " +
357
+ "range=${recycler?.computeVerticalScrollRange()} extent=${recycler?.computeVerticalScrollExtent()} " +
358
+ "canUp=${view?.canScrollVertically(-1)} canDown=${view?.canScrollVertically(1)} scrollState=${recycler?.scrollState} computing=${recycler?.isComputingLayout}")
359
+ }
360
+
233
361
  private fun outerPagerIndex(): Int {
234
362
  var ancestor = parent as? View
235
363
  while (ancestor != null) {
@@ -364,6 +492,19 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
364
492
  fun removeAllReactChildren() {
365
493
  releaseRefreshForeground()
366
494
  detachPrimaryScrollObserver()
495
+ pageRestoreRequests.clear()
496
+ pendingPageRestores.clear()
497
+ commandBodyKeys.clear()
498
+ explicitTopCommand = null
499
+ suspendedBodyKeys.clear()
500
+ alignedBodyKeys.clear()
501
+ transitionAlignedViews.clear()
502
+ deferredRecyclerGeometry.clear()
503
+ displayBodyOverrides.clear()
504
+ displayRecyclerAnchors.clear()
505
+ ownedScrollerRestoreRequests.clear()
506
+ recyclerReadingAnchors.clear()
507
+ transitionSourceRecyclerAnchor = null
367
508
  // Weak keys may disappear after size is read; Collection.toArray tolerates this.
368
509
  for (recycler in originalRecyclerPadding.keys.toTypedArray()) {
369
510
  restoreRecyclerInsets(recycler)
@@ -386,16 +527,90 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
386
527
 
387
528
  fun reactChildAt(index: Int) = logicalChildren[index]
388
529
 
530
+ internal fun beginPageTransition(target: Int? = null) {
531
+ cancelPagerTop()
532
+ if (!nativeSmoothHeaderScrollEnabled || headerHeightPx <= 0) return
533
+ if (transitionHeaderOffsets == null) {
534
+ transitionSourceDisplayBody = primaryScrollableForPage(selectedPage)?.let(::verticalScrollOffset) ?: 0
535
+ transitionSourceRecyclerAnchor = (primaryScrollableForPage(selectedPage) as? RecyclerView)?.let(::captureRecyclerAnchor)
536
+ pageHeaderOffsets[currentPageKey()] = headerOffsetPx
537
+ detachPrimaryScrollObserver()
538
+ transitionSourcePage = selectedPage
539
+ transitionHeaderOffsets = pageHeaderOffsets.toMap()
540
+ transitionPageOffsets = pageOffsets.toMap()
541
+ }
542
+ if (target != null) {
543
+ transitionTargetPage = target
544
+ alignPageBody(target)
545
+ }
546
+ offsetDiag("transition-begin-source=$transitionSourcePage", primaryScrollableForPage(selectedPage), pageKey(target ?: selectedPage), target ?: selectedPage)
547
+ }
548
+
549
+ internal fun onPagerScrolled(position: Int, offset: Float) {
550
+ if (transitionHeaderOffsets == null) return
551
+ alignPageBody(position)
552
+ if (position + 1 < adapter.itemCount) alignPageBody(position + 1)
553
+ updateNativeTabProgress(position, offset)
554
+ }
555
+
556
+ internal fun onPagerScrollStateChanged(state: Int) {
557
+ if (state == ViewPager2.SCROLL_STATE_DRAGGING || state == ViewPager2.SCROLL_STATE_SETTLING) {
558
+ beginPageTransition()
559
+ } else if (state == ViewPager2.SCROLL_STATE_IDLE && transitionHeaderOffsets != null) {
560
+ finishPageTransition(pager.currentItem)
561
+ }
562
+ }
563
+
564
+ private fun finishPageTransition(position: Int) {
565
+ val headers = transitionHeaderOffsets ?: return
566
+ detachPrimaryScrollObserver(recordOffset = false)
567
+ releaseRefreshForeground()
568
+ selectedPage = position.coerceIn(0, adapter.itemCount - 1)
569
+ syntheticPageRestore = true
570
+ setHeaderOffset(headers[pageKey(transitionSourcePage)] ?: headerOffsetPx)
571
+ transitionHeaderOffsets = null
572
+ transitionPageOffsets = null
573
+ transitionTargetPage = null
574
+ syntheticPageRestore = false
575
+ transitionAlignedViews.clear()
576
+ if (selectedPage == transitionSourcePage) {
577
+ displayBodyOverrides[currentPageKey()] = transitionSourceDisplayBody
578
+ transitionSourceRecyclerAnchor?.let { displayRecyclerAnchors[currentPageKey()] = it }
579
+ } else {
580
+ displayBodyOverrides.remove(currentPageKey())
581
+ displayRecyclerAnchors.remove(currentPageKey())
582
+ }
583
+ transitionSourceRecyclerAnchor = null
584
+ if (headerOffsetPx == headerHeightPx) suspendedBodyKeys.remove(currentPageKey())
585
+ else if ((pageOffsets[currentPageKey()] ?: 0) > 0) suspendedBodyKeys.add(currentPageKey())
586
+ alignPageBody(selectedPage, force = true)
587
+ offsetDiag("transition-finish", primaryScrollableForPage(selectedPage), currentPageKey(), selectedPage)
588
+ prepareAdjacentPages()
589
+ attachPrimaryScrollObserver()
590
+ }
591
+
389
592
  fun selectPage(position: Int) {
593
+ offsetDiag("select-begin", observedOwnedScroller ?: observedNativeScroller ?: observedRecyclerView, pageKey(position), position)
390
594
  pendingRefreshPull = null
391
595
  if (adapter.itemCount == 0) return
596
+ if (nativeSmoothHeaderScrollEnabled && headerHeightPx > 0) {
597
+ if (transitionHeaderOffsets != null) return
598
+ beginPageTransition(position)
599
+ finishPageTransition(position)
600
+ return
601
+ }
392
602
  detachPrimaryScrollObserver()
393
603
  releaseRefreshForeground()
394
604
  selectedPage = position.coerceIn(0, adapter.itemCount - 1)
395
605
  val savedOffset = pageOffsets[currentPageKey()]
396
606
  ?: primaryScrollableForPage(selectedPage)?.let(::verticalScrollOffset)
397
607
  ?: 0
398
- if (savedOffset > 0) setHeaderOffset(headerHeightPx)
608
+ offsetDiag("select-offset=$savedOffset-source=${if (pageOffsets[currentPageKey()] != null) "saved" else "live"}")
609
+ if (savedOffset > 0) {
610
+ offsetDiag("select-force-collapse-before")
611
+ setHeaderOffset(headerHeightPx)
612
+ offsetDiag("select-force-collapse-after")
613
+ }
399
614
  postForCurrentAttachment {
400
615
  prepareAdjacentPages()
401
616
  attachPrimaryScrollObserver()
@@ -519,7 +734,287 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
519
734
  }
520
735
  }
521
736
 
737
+ private fun restorationBodyOffset(key: String): Int {
738
+ val saved = if (transitionPageOffsets != null && key == pageKey(transitionSourcePage)) transitionSourceDisplayBody
739
+ else displayBodyOverrides[key] ?: transitionPageOffsets?.get(key) ?: pageOffsets[key] ?: 0
740
+ return if (nativeSmoothHeaderScrollEnabled && headerOffsetPx < headerHeightPx) 0 else saved
741
+ }
742
+
743
+ private fun captureRecyclerAnchor(view: RecyclerView): RecyclerReadingAnchor? {
744
+ val manager = view.layoutManager as? LinearLayoutManager ?: return null
745
+ if (manager.orientation != RecyclerView.VERTICAL) return null
746
+ val position = manager.findFirstVisibleItemPosition()
747
+ val child = manager.findViewByPosition(position) ?: return null
748
+ val adapter = view.adapter ?: return null
749
+ return RecyclerReadingAnchor(view, adapter, position, manager.getDecoratedTop(child) - view.paddingTop)
750
+ }
751
+
752
+ private fun saveRecyclerReadingAnchor(key: String, view: RecyclerView) {
753
+ captureRecyclerAnchor(view)?.let { recyclerReadingAnchors[key] = it }
754
+ }
755
+
756
+ private fun restoreRecyclerAnchor(view: RecyclerView, key: String, index: Int, request: PageRestoreRequest?): Boolean {
757
+ if (!nativeSmoothHeaderScrollEnabled || pageKey(index) != key ||
758
+ primaryScrollableForPage(index) !== view || !isCurrentPageRestore(key, request) ||
759
+ !isAttachedToWindow || !view.isAttachedToWindow || view.width <= 0 || view.height <= 0 ||
760
+ isInLayout || view.isInLayout || view.isComputingLayout) return false
761
+ val manager = view.layoutManager as? LinearLayoutManager
762
+ val reading = recyclerReadingAnchors[key]?.takeIf { it.view.get() === view && it.adapter === view.adapter }
763
+ val display = (if (transitionPageOffsets != null && index == transitionSourcePage) transitionSourceRecyclerAnchor
764
+ else displayRecyclerAnchors[key])?.takeIf { it.view.get() === view && it.adapter === view.adapter }
765
+ if (manager == null || manager.orientation != RecyclerView.VERTICAL ||
766
+ (headerOffsetPx == headerHeightPx && reading == null && display == null && restorationBodyOffset(key) > 0)) {
767
+ val saved = restorationBodyOffset(key)
768
+ val current = view.computeVerticalScrollOffset()
769
+ val previousSynthetic = syntheticPageRestore
770
+ syntheticPageRestore = true
771
+ try { if (saved != current) view.scrollBy(0, saved - current) }
772
+ finally { syntheticPageRestore = previousSynthetic }
773
+ return saved <= view.computeVerticalScrollOffset() || view.canScrollVertically(1)
774
+ }
775
+ val target = if (headerOffsetPx < headerHeightPx) null else display ?: reading
776
+ val position = target?.position ?: 0
777
+ val top = target?.top ?: 0
778
+ if (position !in 0 until (view.adapter?.itemCount ?: 0)) return false
779
+ fun reached(): Boolean {
780
+ val actual = captureRecyclerAnchor(view) ?: return false
781
+ return actual.position == position && actual.top == top
782
+ }
783
+ if (!reached()) {
784
+ if (!isAttachedToWindow || !view.isAttachedToWindow || width <= 0 || height <= 0 ||
785
+ view.width <= 0 || view.height <= 0 || isInLayout || view.isInLayout || view.isComputingLayout ||
786
+ pageKey(index) != key || primaryScrollableForPage(index) !== view || !isCurrentPageRestore(key, request)) return false
787
+ val previousSynthetic = syntheticPageRestore
788
+ syntheticPageRestore = true
789
+ try {
790
+ manager.scrollToPositionWithOffset(position, top)
791
+ // Use the existing inset commit boundary once, before the target's first frame.
792
+ view.forceLayout()
793
+ view.measure(MeasureSpec.makeMeasureSpec(view.width, MeasureSpec.EXACTLY), MeasureSpec.makeMeasureSpec(view.height, MeasureSpec.EXACTLY))
794
+ view.layout(view.left, view.top, view.right, view.bottom)
795
+ } finally {
796
+ syntheticPageRestore = previousSynthetic
797
+ }
798
+ offsetDiag("recycler-anchor-restore-position=$position-top=$top", view, key, index, request)
799
+ }
800
+ val readingStillUnreachable = display != null && reading != null &&
801
+ (reading.position != position || reading.top != top) && !view.canScrollVertically(1)
802
+ return reached() && !readingStillUnreachable
803
+ }
804
+
805
+ private fun alignPageBody(index: Int, force: Boolean = false) {
806
+ if (!nativeSmoothHeaderScrollEnabled || index !in 0 until adapter.itemCount) return
807
+ val view = primaryScrollableForPage(index) ?: return
808
+ val key = pageKey(index)
809
+ if (!force && transitionAlignedViews[view] == key) return
810
+ transitionAlignedViews[view] = key
811
+ if (transitionPageOffsets != null && index != transitionSourcePage) {
812
+ displayBodyOverrides.remove(key)
813
+ displayRecyclerAnchors.remove(key)
814
+ }
815
+ alignedBodyKeys.add(key)
816
+ if (headerOffsetPx < headerHeightPx && (pageOffsets[key] ?: 0) > 0) suspendedBodyKeys.add(key)
817
+ val previousSynthetic = syntheticPageRestore
818
+ syntheticPageRestore = true
819
+ when (view) {
820
+ is NativeScrollerViewport -> {
821
+ ownedScrollerKeys.remove(view)
822
+ applyOwnedScrollerInsets(view, index)
823
+ view.host.restoreScrollOffset(restorationBodyOffset(key))
824
+ }
825
+ is RecyclerView -> {
826
+ restoredRecyclerKeys.remove(view)
827
+ applyRecyclerInsets(view, index)
828
+ restoreRecyclerAnchor(view, key, index, pageRestoreRequests[key])
829
+ }
830
+ is ScrollView -> {
831
+ restoredScrollerKeys.remove(view)
832
+ applyNativeScrollerInsets(view, index)
833
+ if (view.height > 0 && view.childCount > 0) view.scrollTo(view.scrollX, restorationBodyOffset(key))
834
+ }
835
+ }
836
+ syntheticPageRestore = previousSynthetic
837
+ }
838
+
839
+ private fun takeOverBodyForVerticalConsumption() {
840
+ cancelPagerTop()
841
+ if (!nativeSmoothHeaderScrollEnabled || transitionHeaderOffsets != null || syntheticPageRestore) return
842
+ val key = currentPageKey()
843
+ commandBodyKeys.remove(key)
844
+ explicitTopCommand = null
845
+ suspendedBodyKeys.remove(key)
846
+ alignedBodyKeys.remove(key)
847
+ displayBodyOverrides.remove(key)
848
+ displayRecyclerAnchors.remove(key)
849
+ pageOffsets[key] = primaryScrollableForPage(selectedPage)?.let(::verticalScrollOffset) ?: 0
850
+ (primaryScrollableForPage(selectedPage) as? RecyclerView)?.let { saveRecyclerReadingAnchor(key, it) }
851
+ }
852
+
853
+ internal fun acceptObservedScrollCommand(view: View, explicitTop: Boolean, phase: Int = 0) {
854
+ if (dispatchingPagerTop && matchesPagerTop(view)) return
855
+ if (phase == 0 && view === (observedRecyclerView ?: observedOwnedScroller ?: observedNativeScroller)) cancelPagerTop()
856
+ if (phase != 0 && matchesPagerTop(view)) { finishPagerTopIfSettled(); return }
857
+ val owner = observedRecyclerView ?: observedOwnedScroller ?: observedNativeScroller
858
+ if (!nativeSmoothHeaderScrollEnabled || view !== owner || transitionHeaderOffsets != null || syntheticPageRestore ||
859
+ !isAttachedToWindow || !view.isAttachedToWindow) return
860
+ val key = currentPageKey()
861
+ val observedKey = observedRecyclerKey ?: observedOwnedScrollerKey ?: observedNativeScrollerKey
862
+ if (observedKey != key) return
863
+ if (phase == 1) { commandBodyKeys.add(key); return }
864
+ if (phase == 2) {
865
+ if (!commandBodyKeys.remove(key)) return
866
+ val completingExplicitTop = hasExplicitTopCommand(view, key)
867
+ val manager = (view as? RecyclerView)?.layoutManager as? LinearLayoutManager
868
+ val first = manager?.findViewByPosition(0)
869
+ val atExplicitTop = completingExplicitTop && manager != null && manager.orientation == RecyclerView.VERTICAL &&
870
+ manager.findFirstVisibleItemPosition() == 0 && first != null &&
871
+ manager.getDecoratedTop(first) - view.paddingTop == 0
872
+ explicitTopCommand = null
873
+ pageOffsets[key] = if (atExplicitTop) 0 else verticalScrollOffset(view)
874
+ if (view is RecyclerView) saveRecyclerReadingAnchor(key, view)
875
+ if (atExplicitTop) setHeaderOffset(0)
876
+ else if ((pageOffsets[key] ?: 0) > 0) setHeaderOffset(headerHeightPx)
877
+ offsetDiag("command-result-complete", view, key)
878
+ return
879
+ }
880
+ commandBodyKeys.remove(key)
881
+ explicitTopCommand = if (explicitTop && view is RecyclerView) {
882
+ key to PageRestoreRequest(view, selectedPage, attachmentGeneration)
883
+ } else null
884
+ pageRestoreRequests.remove(key)
885
+ pendingPageRestores.remove(key)
886
+ ownedScrollerRestoreRequests.remove(key)
887
+ if (view is RecyclerView) deferredRecyclerGeometry.remove(view)
888
+ (view as? NativeScrollerViewport)?.host?.cancelPendingScrollRestore()
889
+ suspendedBodyKeys.remove(key)
890
+ alignedBodyKeys.remove(key)
891
+ displayBodyOverrides.remove(key)
892
+ displayRecyclerAnchors.remove(key)
893
+ if (view is RecyclerView) saveRecyclerReadingAnchor(key, view)
894
+ pageOffsets[key] = if (explicitTop) 0 else verticalScrollOffset(view)
895
+ if (explicitTop) setHeaderOffset(0)
896
+ offsetDiag("command-body-takeover-top=$explicitTop", view, key)
897
+ }
898
+
899
+ private fun matchesPagerTop(view: View): Boolean {
900
+ val request = pagerTopRequest ?: return false
901
+ val owner = observedRecyclerView ?: observedOwnedScroller ?: observedNativeScroller
902
+ val observedKey = observedRecyclerKey ?: observedOwnedScrollerKey ?: observedNativeScrollerKey
903
+ return request.view.get() === view && owner === view && observedKey == request.key &&
904
+ request.key == currentPageKey() && request.pageIndex == selectedPage &&
905
+ request.generation == attachmentGeneration && view.isAttachedToWindow && isAttachedToWindow
906
+ }
907
+
908
+ private fun cancelPagerTop() {
909
+ pagerTopRequest?.let { commandBodyKeys.remove(it.key) }
910
+ pagerTopRequest = null
911
+ }
912
+
913
+ private fun recyclerIsAtBodyTop(view: RecyclerView): Boolean {
914
+ val manager = view.layoutManager as? LinearLayoutManager ?: return !view.canScrollVertically(-1)
915
+ val first = manager.findViewByPosition(0) ?: return view.adapter?.itemCount == 0
916
+ return manager.orientation == RecyclerView.VERTICAL && manager.findFirstVisibleItemPosition() == 0 &&
917
+ manager.getDecoratedTop(first) - view.paddingTop == 0
918
+ }
919
+
920
+ private fun finishPagerTopIfSettled() {
921
+ val request = pagerTopRequest ?: return
922
+ val view = request.view.get() ?: run { cancelPagerTop(); return }
923
+ if (!matchesPagerTop(view)) { cancelPagerTop(); return }
924
+ if (dispatchingPagerTop) return
925
+ val settled = when (view) {
926
+ is RecyclerView -> !view.isComputingLayout && view.scrollState == RecyclerView.SCROLL_STATE_IDLE &&
927
+ view.layoutManager?.isSmoothScrolling != true && recyclerIsAtBodyTop(view)
928
+ is NativeScrollerViewport -> !view.isReadingMotionActive() && view.scrollY == 0
929
+ is ScrollView -> view.scrollY == 0
930
+ else -> false
931
+ }
932
+ if (!settled) return
933
+ pageOffsets[request.key] = 0
934
+ if (view is RecyclerView) saveRecyclerReadingAnchor(request.key, view)
935
+ setHeaderOffset(if (request.target == 1) headerHeightPx else 0)
936
+ offsetDiag("pager-top-complete-target=${request.target}-token=${request.token}", view, request.key)
937
+ cancelPagerTop()
938
+ }
939
+
940
+ internal fun scrollToTop(target: Int, animated: Boolean) {
941
+ if (target !in 0..1 || pager.scrollState != ViewPager2.SCROLL_STATE_IDLE || transitionHeaderOffsets != null ||
942
+ syntheticPageRestore || selectedPage !in 0 until adapter.itemCount || !isAttachedToWindow) return
943
+ val view = observedRecyclerView ?: observedOwnedScroller ?: observedNativeScroller ?: return
944
+ val key = currentPageKey()
945
+ val ownerKey = observedRecyclerKey ?: observedOwnedScrollerKey ?: observedNativeScrollerKey
946
+ if (ownerKey != key || !view.isAttachedToWindow || primaryScrollableForPage(selectedPage) !== view) return
947
+ // Finish old child notifications before installing the new Pager-owned target.
948
+ cancelPagerTop()
949
+ if (view is RecyclerView) {
950
+ (view.getTag(R.id.onekey_native_scroll_coordinator_command_cancel_listener) as? Runnable)?.run()
951
+ view.stopScroll()
952
+ } else if (view is NativeScrollerViewport) {
953
+ view.stopMotion()
954
+ view.host.cancelPendingScrollRestore()
955
+ }
956
+ explicitTopCommand = null
957
+ pageRestoreRequests.remove(key)
958
+ pendingPageRestores.remove(key)
959
+ ownedScrollerRestoreRequests.remove(key)
960
+ suspendedBodyKeys.remove(key)
961
+ alignedBodyKeys.remove(key)
962
+ displayBodyOverrides.remove(key)
963
+ displayRecyclerAnchors.remove(key)
964
+ recyclerReadingAnchors.remove(key)
965
+ if (view is RecyclerView) deferredRecyclerGeometry.remove(view)
966
+ pageOffsets[key] = 0
967
+ val request = PagerTopRequest(view, key, selectedPage, attachmentGeneration, target, ++pagerTopToken)
968
+ pagerTopRequest = request
969
+ commandBodyKeys.add(key)
970
+ setHeaderOffset(if (target == 1) headerHeightPx else 0)
971
+ offsetDiag("pager-top-begin-target=$target-animated=$animated-token=${request.token}", view, key)
972
+ dispatchingPagerTop = true
973
+ try {
974
+ when (view) {
975
+ is RecyclerView -> {
976
+ val manager = view.layoutManager as? LinearLayoutManager
977
+ if (animated && !recyclerIsAtBodyTop(view)) view.smoothScrollToPosition(0)
978
+ else if (manager != null && manager.orientation == RecyclerView.VERTICAL) {
979
+ manager.scrollToPositionWithOffset(0, 0)
980
+ if (view.width > 0 && view.height > 0 && !isInLayout && !view.isInLayout && !view.isComputingLayout) {
981
+ view.measure(MeasureSpec.makeMeasureSpec(view.width, MeasureSpec.EXACTLY), MeasureSpec.makeMeasureSpec(view.height, MeasureSpec.EXACTLY))
982
+ view.layout(view.left, view.top, view.right, view.bottom)
983
+ }
984
+ } else view.scrollToPosition(0)
985
+ }
986
+ is NativeScrollerViewport -> view.host.scrollToPosition(0, 0, animated)
987
+ is ScrollView -> if (animated) view.smoothScrollTo(0, 0) else view.scrollTo(0, 0)
988
+ }
989
+ } finally { dispatchingPagerTop = false }
990
+ finishPagerTopIfSettled()
991
+ }
992
+
993
+ private fun hasExplicitTopCommand(view: View, key: String): Boolean {
994
+ val command = explicitTopCommand ?: return false
995
+ val request = command.second
996
+ return view is RecyclerView && observedRecyclerView === view && observedRecyclerKey == key &&
997
+ key == currentPageKey() && command.first == key && request.view.get() === view &&
998
+ request.pageIndex == selectedPage && request.generation == attachmentGeneration
999
+ }
1000
+
1001
+ private fun publishScrollCommandListener(recycler: RecyclerView, remove: Boolean = false) {
1002
+ val tag = R.id.onekey_native_scroll_coordinator_command_listener
1003
+ @Suppress("UNCHECKED_CAST")
1004
+ val existing = recycler.getTag(tag) as? MutableMap<Any, BiConsumer<Boolean, Int>>
1005
+ val listeners = existing ?: if (remove) return else mutableMapOf<Any, BiConsumer<Boolean, Int>>().also { recycler.setTag(tag, it) }
1006
+ if (remove) listeners.remove(this)
1007
+ else {
1008
+ val generation = attachmentGeneration
1009
+ val key = currentPageKey()
1010
+ listeners[this] = BiConsumer { explicitTop, phase ->
1011
+ if (generation == attachmentGeneration && key == currentPageKey()) acceptObservedScrollCommand(recycler, explicitTop, phase)
1012
+ }
1013
+ }
1014
+ }
1015
+
522
1016
  private fun maximumHeaderOffset(): Int {
1017
+ if (nativeSmoothHeaderScrollEnabled) return headerHeightPx
523
1018
  val scrollable = observedRecyclerView
524
1019
  ?: observedOwnedScroller
525
1020
  ?: observedNativeScroller
@@ -537,9 +1032,14 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
537
1032
 
538
1033
  private fun setHeaderOffset(offset: Int) {
539
1034
  // val next = offset.coerceIn(0, headerHeightPx)
540
- val next = offset.coerceIn(0, maximumHeaderOffset())
1035
+ val pending = nativeSmoothHeaderScrollEnabled && currentPageKey() in pendingPageRestores
1036
+ val maximum = if (transitionHeaderOffsets != null || pending || pagerTopRequest != null) headerHeightPx else maximumHeaderOffset()
1037
+ val next = offset.coerceIn(0, maximum)
541
1038
  if (headerOffsetPx == next) return
542
1039
  headerOffsetPx = next
1040
+ if (nativeSmoothHeaderScrollEnabled && transitionHeaderOffsets == null && !syntheticPageRestore && !pending) {
1041
+ pageHeaderOffsets[currentPageKey()] = next
1042
+ }
543
1043
  applyHeaderOffset()
544
1044
  }
545
1045
 
@@ -656,13 +1156,20 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
656
1156
  }
657
1157
 
658
1158
  private fun attachPrimaryScrollObserver() {
1159
+ if (transitionHeaderOffsets != null) return
659
1160
  if (selectedPage !in 0 until adapter.itemCount) return
660
1161
  val previous = observedRecyclerView ?: observedOwnedScroller ?: observedNativeScroller
661
1162
  if (observedPageRemoved || previous != null && !isDescendant(previous, adapter.pageAt(selectedPage))) {
1163
+ offsetDiag("primary-replacement-before-removed=$observedPageRemoved", previous)
662
1164
  observedPageRemoved = false
663
- detachPrimaryScrollObserver()
1165
+ detachPrimaryScrollObserver(recordOffset = !nativeSmoothHeaderScrollEnabled)
664
1166
  // A replacement scroller starts at the beginning while retaining header collapse.
665
- pageOffsets[currentPageKey()] = 0
1167
+ offsetDiag("primary-replacement-cache-before", previous)
1168
+ if (!nativeSmoothHeaderScrollEnabled) {
1169
+ offsetDiag("cache-write-replacement-new=0", previous)
1170
+ pageOffsets[currentPageKey()] = 0
1171
+ }
1172
+ offsetDiag("primary-replacement-cache-after", previous)
666
1173
  }
667
1174
  when (val scrollable = primaryScrollableForPage(selectedPage)) {
668
1175
  is RecyclerView -> attachRecyclerObserver(scrollable)
@@ -672,24 +1179,45 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
672
1179
  }
673
1180
 
674
1181
  private fun attachRecyclerObserver(recycler: RecyclerView) {
675
- if (observedRecyclerView === recycler) {
1182
+ if (observedRecyclerView === recycler && observedRecyclerKey == currentPageKey()) {
676
1183
  applyRecyclerInsets(recycler, selectedPage)
677
1184
  publishConsumedOffset(recycler)
678
1185
  return
679
1186
  }
680
1187
 
681
1188
  detachPrimaryScrollObserver()
1189
+ offsetDiag("recycler-attach-before", recycler)
682
1190
  observedRecyclerView = recycler
1191
+ observedRecyclerKey = currentPageKey()
1192
+ publishScrollCommandListener(recycler)
683
1193
  applyRecyclerInsets(recycler, selectedPage)
684
1194
  publishConsumedOffset(recycler)
1195
+ val key = currentPageKey()
685
1196
  val listener = object : RecyclerView.OnScrollListener() {
1197
+ override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
1198
+ if (newState == RecyclerView.SCROLL_STATE_IDLE) finishPagerTopIfSettled()
1199
+ }
686
1200
  override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
1201
+ if (observedScrollListener !== this || observedRecyclerKey != key ||
1202
+ key != currentPageKey() || !isAttachedToWindow ||
1203
+ transitionHeaderOffsets != null || syntheticPageRestore || key in pendingPageRestores || key in alignedBodyKeys) {
1204
+ if (offsetDiagEnabled() && (dx != 0 || dy != 0 || pageOffsets[key] != recyclerView.computeVerticalScrollOffset())) {
1205
+ offsetDiag("recycler-observer-skip-dx=$dx-dy=$dy-listenerCurrent=${observedScrollListener === this}", recyclerView, key)
1206
+ }
1207
+ return
1208
+ }
1209
+ if (nativeSmoothHeaderScrollEnabled && key !in commandBodyKeys &&
1210
+ (dy == 0 || recyclerView.scrollState == RecyclerView.SCROLL_STATE_IDLE)) return
687
1211
  val contentOffset = recyclerView.computeVerticalScrollOffset()
688
- pageOffsets[currentPageKey()] = contentOffset
1212
+ if (pageOffsets[key] != contentOffset) offsetDiag("cache-write-recycler-observer-new=$contentOffset-dx=$dx-dy=$dy", recyclerView, key)
1213
+ pageOffsets[key] = contentOffset
1214
+ if (nativeSmoothHeaderScrollEnabled) saveRecyclerReadingAnchor(key, recyclerView)
689
1215
  // Nested touch scrolling moves list content only after the header has
690
1216
  // collapsed. Programmatic list jumps bypass those parent callbacks.
691
- if (contentOffset > 0 && headerOffsetPx < headerHeightPx) {
1217
+ if (contentOffset > 0 && headerOffsetPx < headerHeightPx && !hasExplicitTopCommand(recyclerView, key) && !matchesPagerTop(recyclerView)) {
1218
+ offsetDiag("recycler-positive-collapse-before", recyclerView)
692
1219
  setHeaderOffset(headerHeightPx)
1220
+ offsetDiag("recycler-positive-collapse-after", recyclerView)
693
1221
  }
694
1222
  }
695
1223
  }
@@ -702,11 +1230,21 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
702
1230
  }
703
1231
 
704
1232
  private fun detachRecyclerObserver(recordOffset: Boolean) {
1233
+ cancelPagerTop()
1234
+ explicitTopCommand = null
705
1235
  val recycler = observedRecyclerView
706
1236
  val listener = observedScrollListener
707
1237
  if (recycler != null) {
708
- if (recordOffset) pageOffsets[currentPageKey()] = recycler.computeVerticalScrollOffset()
1238
+ offsetDiag("recycler-detach-save-before-record=$recordOffset", recycler, observedRecyclerKey ?: currentPageKey())
1239
+ if (recordOffset && !nativeSmoothHeaderScrollEnabled && transitionPageOffsets == null) observedRecyclerKey?.let {
1240
+ if (it !in pendingPageRestores) {
1241
+ offsetDiag("cache-write-recycler-detach-new=${recycler.computeVerticalScrollOffset()}", recycler, it)
1242
+ pageOffsets[it] = recycler.computeVerticalScrollOffset()
1243
+ }
1244
+ }
1245
+ offsetDiag("recycler-detach-save-after", recycler, observedRecyclerKey ?: currentPageKey())
709
1246
  publishConsumedOffset(recycler, remove = true)
1247
+ publishScrollCommandListener(recycler, remove = true)
710
1248
  }
711
1249
  if (recycler != null && listener != null) recycler.removeOnScrollListener(listener)
712
1250
  if (recycler != null) {
@@ -716,28 +1254,38 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
716
1254
  )
717
1255
  }
718
1256
  observedRecyclerView = null
1257
+ observedRecyclerKey = null
719
1258
  observedScrollListener = null
720
1259
  }
721
1260
 
722
1261
  private fun attachNativeScrollerObserver(scroller: ScrollView) {
723
- if (observedNativeScroller === scroller) {
1262
+ if (observedNativeScroller === scroller && observedNativeScrollerKey == currentPageKey()) {
724
1263
  applyNativeScrollerInsets(scroller, selectedPage)
725
1264
  return
726
1265
  }
727
1266
 
728
1267
  detachPrimaryScrollObserver()
1268
+ offsetDiag("scrollview-attach-before", scroller)
729
1269
  observedNativeScroller = scroller
1270
+ observedNativeScrollerKey = currentPageKey()
730
1271
  applyNativeScrollerInsets(scroller, selectedPage)
1272
+ val key = currentPageKey()
731
1273
  val listener = object : ViewTreeObserver.OnScrollChangedListener {
732
1274
  override fun onScrollChanged() {
733
1275
  if (observedNativeScrollerListener !== this) {
734
1276
  removeScrollChangedListener(null, scroller, this)
735
1277
  return
736
1278
  }
1279
+ if (key != currentPageKey() || !isAttachedToWindow ||
1280
+ transitionHeaderOffsets != null || syntheticPageRestore || key in pendingPageRestores || key in alignedBodyKeys) return
1281
+ if (nativeSmoothHeaderScrollEnabled && nestedScrollingParentHelper.nestedScrollAxes == 0 && key !in commandBodyKeys) return
737
1282
  val contentOffset = scroller.scrollY
738
- pageOffsets[currentPageKey()] = contentOffset
739
- if (contentOffset > 0 && headerOffsetPx < headerHeightPx) {
1283
+ if (pageOffsets[key] != contentOffset) offsetDiag("cache-write-scrollview-observer-new=$contentOffset", scroller, key)
1284
+ pageOffsets[key] = contentOffset
1285
+ if (contentOffset > 0 && headerOffsetPx < headerHeightPx && !matchesPagerTop(scroller)) {
1286
+ offsetDiag("scrollview-positive-collapse-before", scroller)
740
1287
  setHeaderOffset(headerHeightPx)
1288
+ offsetDiag("scrollview-positive-collapse-after", scroller)
741
1289
  }
742
1290
  }
743
1291
  }
@@ -752,9 +1300,17 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
752
1300
  }
753
1301
 
754
1302
  private fun detachNativeScrollerObserver(recordOffset: Boolean) {
1303
+ cancelPagerTop()
755
1304
  val scroller = observedNativeScroller
756
1305
  val listener = observedNativeScrollerListener
757
- if (scroller != null && recordOffset) pageOffsets[currentPageKey()] = scroller.scrollY
1306
+ offsetDiag("scrollview-detach-save-before-record=$recordOffset", scroller, observedNativeScrollerKey ?: currentPageKey())
1307
+ if (scroller != null && recordOffset && !nativeSmoothHeaderScrollEnabled && transitionPageOffsets == null) observedNativeScrollerKey?.let {
1308
+ if (it !in pendingPageRestores) {
1309
+ offsetDiag("cache-write-scrollview-detach-new=${scroller.scrollY}", scroller, it)
1310
+ pageOffsets[it] = scroller.scrollY
1311
+ }
1312
+ }
1313
+ offsetDiag("scrollview-detach-save-after", scroller, observedNativeScrollerKey ?: currentPageKey())
758
1314
  if (scroller != null && listener != null) {
759
1315
  removeScrollChangedListener(observedNativeScrollerTreeObserver, scroller, listener)
760
1316
  }
@@ -765,6 +1321,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
765
1321
  )
766
1322
  }
767
1323
  observedNativeScroller = null
1324
+ observedNativeScrollerKey = null
768
1325
  observedNativeScrollerListener = null
769
1326
  observedNativeScrollerTreeObserver = null
770
1327
  }
@@ -793,9 +1350,24 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
793
1350
  val key = pageKey(pageIndex)
794
1351
  if (ownedScrollerKeys[scroller] == key) return
795
1352
  ownedScrollerKeys[scroller] = key
1353
+ val restoreRequest = registerPageRestore(key, scroller, pageIndex)
1354
+ if (nativeSmoothHeaderScrollEnabled) ownedScrollerRestoreRequests[key] = WeakReference(scroller) to null
1355
+ val generation = attachmentGeneration
1356
+ offsetDiag("owned-enqueue", scroller, key, pageIndex, restoreRequest)
796
1357
  postForCurrentAttachment {
797
- if (ownedScrollerKeys[scroller] == key && scroller.isAttachedToWindow) {
798
- scroller.host.restoreScrollOffset(pageOffsets[key] ?: 0)
1358
+ offsetDiag("owned-execute", scroller, key, pageIndex, restoreRequest)
1359
+ if (isCurrentPageRestore(key, restoreRequest) && ownedScrollerKeys[scroller] == key && generation == attachmentGeneration &&
1360
+ scroller.isAttachedToWindow && (!nativeSmoothHeaderScrollEnabled ||
1361
+ (key in pendingPageRestores && ownedScrollerRestoreRequests[key]?.first?.get() === scroller))) {
1362
+ offsetDiag("owned-restore-before", scroller, key, pageIndex, restoreRequest)
1363
+ val saved = restorationBodyOffset(key)
1364
+ scroller.host.restoreScrollOffset(saved)
1365
+ if (nativeSmoothHeaderScrollEnabled) {
1366
+ ownedScrollerRestoreRequests[key] = WeakReference(scroller) to generation
1367
+ recordOwnedScrollerOffset(scroller, key)
1368
+ }
1369
+ offsetDiag("owned-restore-after-saved=$saved", scroller, key, pageIndex, restoreRequest)
1370
+ offsetDiagAfterDraw(scroller, key, pageIndex)
799
1371
  }
800
1372
  }
801
1373
  }
@@ -803,7 +1375,10 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
803
1375
  private fun restoreOwnedScrollerInsets(scroller: NativeScrollerViewport) {
804
1376
  scroller.stopMotion()
805
1377
  scroller.host.cancelPendingScrollRestore()
806
- ownedScrollerKeys.remove(scroller)
1378
+ ownedScrollerKeys.remove(scroller)?.let { key ->
1379
+ clearPageRestoreForView(key, scroller)
1380
+ if (ownedScrollerRestoreRequests[key]?.first?.get() === scroller) ownedScrollerRestoreRequests.remove(key)
1381
+ }
807
1382
  publishConsumedOffset(scroller, remove = true)
808
1383
  scroller.host.setPagerInset(this, null)
809
1384
  }
@@ -811,9 +1386,11 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
811
1386
  private fun attachOwnedScrollerObserver(scroller: NativeScrollerViewport) {
812
1387
  if (observedOwnedScroller === scroller && observedOwnedScrollerKey == currentPageKey()) {
813
1388
  applyOwnedScrollerInsets(scroller, selectedPage)
1389
+ if (nativeSmoothHeaderScrollEnabled) recordOwnedScrollerOffset(scroller, currentPageKey())
814
1390
  return
815
1391
  }
816
1392
  detachPrimaryScrollObserver()
1393
+ offsetDiag("owned-attach-before", scroller)
817
1394
  observedOwnedScroller = scroller
818
1395
  observedOwnedScrollerKey = currentPageKey()
819
1396
  val key = currentPageKey()
@@ -824,9 +1401,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
824
1401
  removeScrollChangedListener(null, scroller, this)
825
1402
  return
826
1403
  }
827
- val contentOffset = scroller.scrollY
828
- pageOffsets[key] = contentOffset
829
- if (contentOffset > 0 && headerOffsetPx < headerHeightPx) setHeaderOffset(headerHeightPx)
1404
+ recordOwnedScrollerOffset(scroller, key)
830
1405
  }
831
1406
  }
832
1407
  observedOwnedScrollerListener = listener
@@ -835,11 +1410,41 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
835
1410
  }
836
1411
  }
837
1412
 
1413
+ private fun recordOwnedScrollerOffset(scroller: NativeScrollerViewport, key: String) {
1414
+ if (ownedScrollerKeys[scroller] != key || !isAttachedToWindow || !scroller.isAttachedToWindow) return
1415
+ if (key in pendingPageRestores) {
1416
+ // A false native pending flag before our posted request executes is not cancellation.
1417
+ val request = ownedScrollerRestoreRequests[key]
1418
+ if (request == null || request.first.get() !== scroller || request.second != attachmentGeneration ||
1419
+ scroller.width <= 0 || scroller.height <= 0 || scroller.host.hasPendingScrollRestore()) return
1420
+ clearPageRestoreForView(key, scroller)
1421
+ }
1422
+ if (observedOwnedScroller !== scroller || observedOwnedScrollerKey != key ||
1423
+ key != currentPageKey() || transitionHeaderOffsets != null || syntheticPageRestore || key in alignedBodyKeys) return
1424
+ if (nativeSmoothHeaderScrollEnabled && !scroller.isReadingMotionActive() && key !in commandBodyKeys) return
1425
+ val contentOffset = scroller.scrollY
1426
+ if (pageOffsets[key] != contentOffset) offsetDiag("cache-write-owned-observer-new=$contentOffset", scroller, key)
1427
+ pageOffsets[key] = contentOffset
1428
+ if (contentOffset > 0 && headerOffsetPx < headerHeightPx && !matchesPagerTop(scroller)) {
1429
+ offsetDiag("owned-positive-collapse-before", scroller, key)
1430
+ setHeaderOffset(headerHeightPx)
1431
+ offsetDiag("owned-positive-collapse-after", scroller, key)
1432
+ }
1433
+ }
1434
+
838
1435
  private fun detachOwnedScrollerObserver(recordOffset: Boolean) {
1436
+ cancelPagerTop()
839
1437
  val scroller = observedOwnedScroller ?: return
840
1438
  scroller.stopMotion()
841
1439
  publishConsumedOffset(scroller, remove = true)
842
- if (recordOffset) observedOwnedScrollerKey?.let { pageOffsets[it] = scroller.scrollY }
1440
+ offsetDiag("owned-detach-save-before-record=$recordOffset", scroller, observedOwnedScrollerKey ?: currentPageKey())
1441
+ if (recordOffset && !nativeSmoothHeaderScrollEnabled && transitionPageOffsets == null) observedOwnedScrollerKey?.let {
1442
+ if (it !in pendingPageRestores) {
1443
+ offsetDiag("cache-write-owned-detach-new=${scroller.scrollY}", scroller, it)
1444
+ pageOffsets[it] = scroller.scrollY
1445
+ }
1446
+ }
1447
+ offsetDiag("owned-detach-save-after", scroller, observedOwnedScrollerKey ?: currentPageKey())
843
1448
  observedOwnedScrollerListener?.let {
844
1449
  removeScrollChangedListener(observedOwnedScrollerTreeObserver, scroller, it)
845
1450
  }
@@ -876,6 +1481,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
876
1481
  val anchorOffset = if (first != RecyclerView.NO_POSITION) {
877
1482
  manager?.findViewByPosition(first)?.let { manager.getDecoratedTop(it) - recycler.paddingTop }
878
1483
  } else null
1484
+ offsetDiag("recycler-inset-before-nextTop=$top-anchorPosition=$first-anchorOffset=$anchorOffset", recycler, pageKey(pageIndex), pageIndex)
879
1485
  recycler.setPadding(original.left, top, original.right, bottom)
880
1486
  if (anchorOffset != null) manager?.scrollToPositionWithOffset(first, anchorOffset)
881
1487
  // React Native can stop this nested requestLayout at its React-owned parent.
@@ -889,21 +1495,67 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
889
1495
  recycler.layout(recycler.left, recycler.top, recycler.right, recycler.bottom)
890
1496
  }
891
1497
  }
1498
+ if (original.appliedTopInset != topInset) offsetDiag("recycler-inset-after-old=${original.appliedTopInset}-new=$topInset", recycler, pageKey(pageIndex), pageIndex)
892
1499
  original.appliedTopInset = topInset
893
1500
  val key = pageKey(pageIndex)
1501
+ val geometry = Triple(recycler.computeVerticalScrollRange(), recycler.computeVerticalScrollExtent(), recycler.childCount)
1502
+ deferredRecyclerGeometry[recycler]?.let { deferred ->
1503
+ if (geometry != deferred) {
1504
+ restoredRecyclerKeys.remove(recycler)
1505
+ deferredRecyclerGeometry.remove(recycler)
1506
+ if (nativeSmoothHeaderScrollEnabled && headerOffsetPx == headerHeightPx) {
1507
+ displayBodyOverrides.remove(key)
1508
+ displayRecyclerAnchors.remove(key)
1509
+ }
1510
+ }
1511
+ }
894
1512
  if (restoredRecyclerKeys[recycler] != key) {
895
1513
  restoredRecyclerKeys[recycler] = key
1514
+ val restoreRequest = registerPageRestore(key, recycler, pageIndex)
896
1515
  val generation = attachmentGeneration
1516
+ offsetDiag("recycler-enqueue", recycler, key, pageIndex, restoreRequest)
1517
+ if (nativeSmoothHeaderScrollEnabled && recycler.height > 0 && recycler.childCount > 0) {
1518
+ // Consume an available reading anchor before this pre-draw reaches the first frame.
1519
+ restoreRecyclerAnchor(recycler, key, pageIndex, restoreRequest)
1520
+ }
897
1521
  recycler.post {
1522
+ offsetDiag("recycler-execute", recycler, key, pageIndex, restoreRequest)
1523
+ if (!isCurrentPageRestore(key, restoreRequest)) {
1524
+ offsetDiag("recycler-post-skip-cancelled", recycler, key, pageIndex, restoreRequest)
1525
+ return@post
1526
+ }
898
1527
  if (!isAttachedToWindow || generation != attachmentGeneration ||
899
1528
  restoredRecyclerKeys[recycler] != key ||
900
1529
  originalRecyclerPadding[recycler] !== original) {
901
1530
  if (restoredRecyclerKeys[recycler] == key) restoredRecyclerKeys.remove(recycler)
1531
+ clearPageRestoreForView(key, recycler, restoreRequest)
902
1532
  return@post
903
1533
  }
904
- val saved = pageOffsets[key] ?: 0
1534
+ val saved = restorationBodyOffset(key)
905
1535
  val current = recycler.computeVerticalScrollOffset()
906
- if (saved != current) recycler.scrollBy(0, saved - current)
1536
+ offsetDiag("recycler-restore-before-saved=$saved-current=${current}-requestedDelta=${saved - current}", recycler, key, pageIndex, restoreRequest)
1537
+ if (nativeSmoothHeaderScrollEnabled && (recycler.height <= 0 || recycler.childCount == 0)) {
1538
+ restoredRecyclerKeys.remove(recycler)
1539
+ if (pageRestoreRequests[key] === restoreRequest) pageRestoreRequests.remove(key)
1540
+ return@post
1541
+ }
1542
+ if (nativeSmoothHeaderScrollEnabled) {
1543
+ if (!restoreRecyclerAnchor(recycler, key, pageIndex, restoreRequest)) {
1544
+ if (isInLayout || recycler.isInLayout || recycler.isComputingLayout) {
1545
+ restoredRecyclerKeys.remove(recycler)
1546
+ if (pageRestoreRequests[key] === restoreRequest) pageRestoreRequests.remove(key)
1547
+ return@post
1548
+ }
1549
+ deferredRecyclerGeometry[recycler] = Triple(recycler.computeVerticalScrollRange(), recycler.computeVerticalScrollExtent(), recycler.childCount)
1550
+ offsetDiag("recycler-anchor-pending-saved=$saved", recycler, key, pageIndex, restoreRequest)
1551
+ return@post
1552
+ }
1553
+ } else if (saved != current) recycler.scrollBy(0, saved - current)
1554
+ deferredRecyclerGeometry.remove(recycler)
1555
+ if (nativeSmoothHeaderScrollEnabled) clearPageRestoreForView(key, recycler, restoreRequest)
1556
+ else pendingPageRestores.remove(key)
1557
+ offsetDiag("recycler-restore-after-saved=$saved", recycler, key, pageIndex, restoreRequest)
1558
+ offsetDiagAfterDraw(recycler, key, pageIndex)
907
1559
  }
908
1560
  }
909
1561
  }
@@ -949,16 +1601,35 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
949
1601
  val key = pageKey(pageIndex)
950
1602
  if (restoredScrollerKeys[scroller] != key) {
951
1603
  restoredScrollerKeys[scroller] = key
1604
+ val restoreRequest = registerPageRestore(key, scroller, pageIndex)
952
1605
  val generation = attachmentGeneration
1606
+ offsetDiag("scrollview-enqueue", scroller, key, pageIndex, restoreRequest)
953
1607
  scroller.post {
1608
+ offsetDiag("scrollview-execute", scroller, key, pageIndex, restoreRequest)
1609
+ if (!isCurrentPageRestore(key, restoreRequest)) {
1610
+ offsetDiag("scrollview-post-skip-cancelled", scroller, key, pageIndex, restoreRequest)
1611
+ return@post
1612
+ }
954
1613
  if (!isAttachedToWindow || generation != attachmentGeneration ||
955
1614
  restoredScrollerKeys[scroller] != key ||
956
1615
  originalScrollerPadding[scroller] !== original) {
957
1616
  if (restoredScrollerKeys[scroller] == key) restoredScrollerKeys.remove(scroller)
1617
+ clearPageRestoreForView(key, scroller, restoreRequest)
1618
+ return@post
1619
+ }
1620
+ val saved = restorationBodyOffset(key)
1621
+ offsetDiag("scrollview-restore-before-saved=$saved-current=${scroller.scrollY}-requestedDelta=${saved - scroller.scrollY}", scroller, key, pageIndex, restoreRequest)
1622
+ if (nativeSmoothHeaderScrollEnabled &&
1623
+ (scroller.height <= 0 || (scroller.getChildAt(0)?.height ?: 0) <= 0)) {
1624
+ restoredScrollerKeys.remove(scroller)
1625
+ if (pageRestoreRequests[key] === restoreRequest) pageRestoreRequests.remove(key)
958
1626
  return@post
959
1627
  }
960
- val saved = pageOffsets[key] ?: 0
961
1628
  if (scroller.scrollY != saved) scroller.scrollTo(scroller.scrollX, saved)
1629
+ if (nativeSmoothHeaderScrollEnabled) clearPageRestoreForView(key, scroller, restoreRequest)
1630
+ else pendingPageRestores.remove(key)
1631
+ offsetDiag("scrollview-restore-after-saved=$saved", scroller, key, pageIndex, restoreRequest)
1632
+ offsetDiagAfterDraw(scroller, key, pageIndex)
962
1633
  }
963
1634
  }
964
1635
  }
@@ -1017,7 +1688,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
1017
1688
  publishConsumedOffset(recycler, remove = true)
1018
1689
  val original = originalRecyclerPadding.remove(recycler) ?: return
1019
1690
  updateRecyclerPaddingOwnership(recycler, original)
1020
- restoredRecyclerKeys.remove(recycler)
1691
+ restoredRecyclerKeys.remove(recycler)?.let { clearPageRestoreForView(it, recycler) }
1021
1692
  recycler.clipToPadding = original.clipToPadding
1022
1693
  recycler.setPadding(original.left, original.top, original.right, original.bottom)
1023
1694
  }
@@ -1025,7 +1696,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
1025
1696
  private fun restoreNativeScrollerInsets(scroller: ScrollView) {
1026
1697
  val original = originalScrollerPadding.remove(scroller) ?: return
1027
1698
  updateScrollerPaddingOwnership(scroller, original)
1028
- restoredScrollerKeys.remove(scroller)
1699
+ restoredScrollerKeys.remove(scroller)?.let { clearPageRestoreForView(it, scroller) }
1029
1700
  if (scroller is ReactScrollView) {
1030
1701
  val state = scroller.reactScrollViewScrollState
1031
1702
  scroller.setScrollAwayPaddingEnabledUnstable(
@@ -1399,6 +2070,15 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
1399
2070
  override fun onNestedScrollAccepted(child: View, target: View, axes: Int, type: Int) {
1400
2071
  if (type == ViewCompat.TYPE_TOUCH) pendingRefreshPull = null
1401
2072
  nestedScrollingParentHelper.onNestedScrollAccepted(child, target, axes, type)
2073
+ if (type == ViewCompat.TYPE_TOUCH) {
2074
+ val request = pageRestoreRequests[currentPageKey()]
2075
+ offsetDiag("touch-cancel-before", target, request = request)
2076
+ observedRecyclerView?.let { deferredRecyclerGeometry.remove(it) }
2077
+ pageRestoreRequests.remove(currentPageKey())
2078
+ pendingPageRestores.remove(currentPageKey())
2079
+ ownedScrollerRestoreRequests.remove(currentPageKey())
2080
+ offsetDiag("touch-cancel-after", target, request = request)
2081
+ }
1402
2082
  }
1403
2083
 
1404
2084
  override fun onStopNestedScroll(target: View, type: Int) {
@@ -1415,6 +2095,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
1415
2095
  // val used = min(dy, headerHeightPx - headerOffsetPx)
1416
2096
  if (dy > 0 && headerOffsetPx < maximumOffset) {
1417
2097
  val used = min(dy, maximumOffset - headerOffsetPx)
2098
+ if (type == ViewCompat.TYPE_TOUCH && used != 0) takeOverBodyForVerticalConsumption()
1418
2099
  setHeaderOffset(headerOffsetPx + used)
1419
2100
  consumed[1] += used
1420
2101
  } else if (dy < 0 && headerOffsetPx > 0) {
@@ -1426,6 +2107,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
1426
2107
  }
1427
2108
  if (!canScrollUp) {
1428
2109
  val used = max(dy, -headerOffsetPx)
2110
+ if (type == ViewCompat.TYPE_TOUCH && used != 0) takeOverBodyForVerticalConsumption()
1429
2111
  setHeaderOffset(headerOffsetPx + used)
1430
2112
  consumed[1] += used
1431
2113
  }
@@ -1441,8 +2123,11 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
1441
2123
  type: Int,
1442
2124
  consumed: IntArray,
1443
2125
  ) {
1444
- if (!isCurrentPageTarget(target) || dyUnconsumed >= 0) return
2126
+ if (!isCurrentPageTarget(target)) return
2127
+ if (type == ViewCompat.TYPE_TOUCH && dyConsumed != 0) takeOverBodyForVerticalConsumption()
2128
+ if (dyUnconsumed >= 0) return
1445
2129
  val used = max(dyUnconsumed, -headerOffsetPx)
2130
+ if (type == ViewCompat.TYPE_TOUCH && used != 0) takeOverBodyForVerticalConsumption()
1446
2131
  setHeaderOffset(headerOffsetPx + used)
1447
2132
  consumed[1] += used
1448
2133
  if (type == ViewCompat.TYPE_TOUCH && dyUnconsumed < used &&
@@ -1492,6 +2177,13 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
1492
2177
  restoredRecyclerKeys.clear()
1493
2178
  restoredScrollerKeys.clear()
1494
2179
  viewTreeObserver.addOnPreDrawListener(pageContentLayoutListener)
2180
+ resumeTransitionSource?.let { source ->
2181
+ resumeTransitionSource = null
2182
+ if (source in 0 until adapter.itemCount) {
2183
+ pager.setCurrentItem(source, false)
2184
+ selectPage(source)
2185
+ }
2186
+ }
1495
2187
  log(
1496
2188
  "host-attach generation=$attachmentGeneration inner=$selectedPage " +
1497
2189
  "outer=${outerPagerIndex()} nativePages=${adapter.itemCount}",
@@ -1511,6 +2203,26 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
1511
2203
  nestedNativeGestureStarted = false
1512
2204
  viewTreeObserver.removeOnPreDrawListener(pageContentLayoutListener)
1513
2205
  detachPrimaryScrollObserver()
2206
+ pageRestoreRequests.clear()
2207
+ pendingPageRestores.clear()
2208
+ commandBodyKeys.clear()
2209
+ explicitTopCommand = null
2210
+ suspendedBodyKeys.clear()
2211
+ alignedBodyKeys.clear()
2212
+ transitionAlignedViews.clear()
2213
+ deferredRecyclerGeometry.clear()
2214
+ displayBodyOverrides.clear()
2215
+ displayRecyclerAnchors.clear()
2216
+ ownedScrollerRestoreRequests.clear()
2217
+ transitionHeaderOffsets?.let { headers ->
2218
+ resumeTransitionSource = transitionSourcePage
2219
+ syntheticPageRestore = true
2220
+ setHeaderOffset(headers[pageKey(transitionSourcePage)] ?: 0)
2221
+ syntheticPageRestore = false
2222
+ }
2223
+ transitionHeaderOffsets = null
2224
+ transitionPageOffsets = null
2225
+ transitionTargetPage = null
1514
2226
  // Snapshot live keys before restoration removes their padding records.
1515
2227
  for (recycler in originalRecyclerPadding.keys.toTypedArray()) {
1516
2228
  restoreRecyclerInsets(recycler)