@onekeyfe/react-native-pager-view 3.0.172 → 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
@@ -132,6 +133,26 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
132
133
  private var refreshForegroundAncestorAlpha = 1f
133
134
  private val pageOffsets = HashMap<String, Int>()
134
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>>()
135
156
  private var transitionHeaderOffsets: Map<String, Int>? = null
136
157
  private var transitionPageOffsets: Map<String, Int>? = null
137
158
  private var transitionSourcePage = 0
@@ -152,6 +173,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
152
173
  return PageRestoreRequest(view, pageIndex, attachmentGeneration).also {
153
174
  pageRestoreRequests[key] = it
154
175
  pendingPageRestores.add(key)
176
+ offsetDiag("request-register", view, key, pageIndex, it)
155
177
  }
156
178
  }
157
179
 
@@ -163,9 +185,11 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
163
185
  private fun clearPageRestoreForView(key: String, view: View, request: PageRestoreRequest? = null) {
164
186
  if (request != null && pageRestoreRequests[key] !== request) return
165
187
  if (pageRestoreRequests[key]?.view?.get() !== view) return
188
+ offsetDiag("request-clear-before", view, key, request = request ?: pageRestoreRequests[key])
166
189
  pageRestoreRequests.remove(key)
167
190
  pendingPageRestores.remove(key)
168
191
  ownedScrollerRestoreRequests.remove(key)
192
+ offsetDiag("request-clear-after", view, key, request = request)
169
193
  }
170
194
  private var observedRecyclerView: RecyclerView? = null
171
195
  private var observedRecyclerKey: String? = null
@@ -201,8 +225,14 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
201
225
  // Apply its header insets before that list first draws.
202
226
  prepareAdjacentPages()
203
227
  attachPrimaryScrollObserver()
228
+ finishPagerTopIfSettled()
204
229
  // OneKey patch: a filtered short page can reduce the existing collapse range.
205
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
+ }
206
236
  true
207
237
  }
208
238
 
@@ -238,6 +268,8 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
238
268
  ownedScrollerRestoreRequests.remove(key)
239
269
  }
240
270
  }
271
+ recyclerReadingAnchors.keys.retainAll(value.toSet())
272
+ displayRecyclerAnchors.keys.retainAll(value.toSet())
241
273
  field = value
242
274
  }
243
275
  var retainedPages: String = "[]"
@@ -282,16 +314,48 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
282
314
  }
283
315
 
284
316
  private var offsetDiagSequence = 0L
317
+ private val offsetDiagNextDraw = WeakHashMap<View, Pair<String, Int>>()
285
318
 
286
- private fun offsetDiag(phase: String, view: View? = null, key: String = currentPageKey()) {
287
- if ((context.applicationInfo.flags and android.content.pm.ApplicationInfo.FLAG_DEBUGGABLE) == 0) return
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
288
334
  val safeKey = if (key in setOf("portfolio", "perps", "defi", "nft", "history")) key else "other"
289
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)
290
343
  OneKeyLog.debug("CollapsiblePager", "[HOME-OFFSET-DIAG] phase=$phase seq=${++offsetDiagSequence} " +
291
344
  "wallMs=${System.currentTimeMillis()} monoMs=${SystemClock.uptimeMillis()} host=$hostIdentity " +
292
- "generation=$attachmentGeneration page=$selectedPage key=$safeKey header=$headerOffsetPx headerHeight=$headerHeightPx " +
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} " +
293
350
  "view=${view?.let { System.identityHashCode(it) }} tag=${view?.id} observed=${observed?.let { System.identityHashCode(it) }} " +
294
- "offset=${view?.let(::verticalScrollOffset)} cache=${pageOffsets[key]} boundsHeight=${view?.height}")
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}")
295
359
  }
296
360
 
297
361
  private fun outerPagerIndex(): Int {
@@ -430,7 +494,17 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
430
494
  detachPrimaryScrollObserver()
431
495
  pageRestoreRequests.clear()
432
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()
433
505
  ownedScrollerRestoreRequests.clear()
506
+ recyclerReadingAnchors.clear()
507
+ transitionSourceRecyclerAnchor = null
434
508
  // Weak keys may disappear after size is read; Collection.toArray tolerates this.
435
509
  for (recycler in originalRecyclerPadding.keys.toTypedArray()) {
436
510
  restoreRecyclerInsets(recycler)
@@ -454,30 +528,28 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
454
528
  fun reactChildAt(index: Int) = logicalChildren[index]
455
529
 
456
530
  internal fun beginPageTransition(target: Int? = null) {
531
+ cancelPagerTop()
457
532
  if (!nativeSmoothHeaderScrollEnabled || headerHeightPx <= 0) return
458
533
  if (transitionHeaderOffsets == null) {
534
+ transitionSourceDisplayBody = primaryScrollableForPage(selectedPage)?.let(::verticalScrollOffset) ?: 0
535
+ transitionSourceRecyclerAnchor = (primaryScrollableForPage(selectedPage) as? RecyclerView)?.let(::captureRecyclerAnchor)
459
536
  pageHeaderOffsets[currentPageKey()] = headerOffsetPx
460
537
  detachPrimaryScrollObserver()
461
538
  transitionSourcePage = selectedPage
462
539
  transitionHeaderOffsets = pageHeaderOffsets.toMap()
463
540
  transitionPageOffsets = pageOffsets.toMap()
464
541
  }
465
- if (target != null) transitionTargetPage = target
542
+ if (target != null) {
543
+ transitionTargetPage = target
544
+ alignPageBody(target)
545
+ }
546
+ offsetDiag("transition-begin-source=$transitionSourcePage", primaryScrollableForPage(selectedPage), pageKey(target ?: selectedPage), target ?: selectedPage)
466
547
  }
467
548
 
468
549
  internal fun onPagerScrolled(position: Int, offset: Float) {
469
- val headers = transitionHeaderOffsets ?: return
470
- val target = transitionTargetPage
471
- val first = if (target != null) transitionSourcePage else position
472
- val second = target ?: (position + 1).coerceAtMost(adapter.itemCount - 1)
473
- val fraction = if (target != null && target != first) {
474
- ((position + offset - first) / (target - first)).coerceIn(0f, 1f)
475
- } else offset
476
- val firstH = headers[pageKey(first)] ?: 0
477
- val secondH = headers[pageKey(second)] ?: 0
478
- syntheticPageRestore = true
479
- setHeaderOffset((firstH + (secondH - firstH) * fraction).roundToInt())
480
- syntheticPageRestore = false
550
+ if (transitionHeaderOffsets == null) return
551
+ alignPageBody(position)
552
+ if (position + 1 < adapter.itemCount) alignPageBody(position + 1)
481
553
  updateNativeTabProgress(position, offset)
482
554
  }
483
555
 
@@ -495,18 +567,30 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
495
567
  releaseRefreshForeground()
496
568
  selectedPage = position.coerceIn(0, adapter.itemCount - 1)
497
569
  syntheticPageRestore = true
498
- setHeaderOffset(headers[currentPageKey()] ?: 0)
570
+ setHeaderOffset(headers[pageKey(transitionSourcePage)] ?: headerOffsetPx)
499
571
  transitionHeaderOffsets = null
500
572
  transitionPageOffsets = null
501
573
  transitionTargetPage = null
502
574
  syntheticPageRestore = false
503
- pageHeaderOffsets[currentPageKey()] = headerOffsetPx
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)
504
588
  prepareAdjacentPages()
505
589
  attachPrimaryScrollObserver()
506
590
  }
507
591
 
508
592
  fun selectPage(position: Int) {
509
- offsetDiag("select-begin-target=$position", observedOwnedScroller ?: observedNativeScroller ?: observedRecyclerView)
593
+ offsetDiag("select-begin", observedOwnedScroller ?: observedNativeScroller ?: observedRecyclerView, pageKey(position), position)
510
594
  pendingRefreshPull = null
511
595
  if (adapter.itemCount == 0) return
512
596
  if (nativeSmoothHeaderScrollEnabled && headerHeightPx > 0) {
@@ -650,7 +734,287 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
650
734
  }
651
735
  }
652
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
+
653
1016
  private fun maximumHeaderOffset(): Int {
1017
+ if (nativeSmoothHeaderScrollEnabled) return headerHeightPx
654
1018
  val scrollable = observedRecyclerView
655
1019
  ?: observedOwnedScroller
656
1020
  ?: observedNativeScroller
@@ -669,7 +1033,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
669
1033
  private fun setHeaderOffset(offset: Int) {
670
1034
  // val next = offset.coerceIn(0, headerHeightPx)
671
1035
  val pending = nativeSmoothHeaderScrollEnabled && currentPageKey() in pendingPageRestores
672
- val maximum = if (transitionHeaderOffsets != null || pending) headerHeightPx else maximumHeaderOffset()
1036
+ val maximum = if (transitionHeaderOffsets != null || pending || pagerTopRequest != null) headerHeightPx else maximumHeaderOffset()
673
1037
  val next = offset.coerceIn(0, maximum)
674
1038
  if (headerOffsetPx == next) return
675
1039
  headerOffsetPx = next
@@ -801,7 +1165,10 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
801
1165
  detachPrimaryScrollObserver(recordOffset = !nativeSmoothHeaderScrollEnabled)
802
1166
  // A replacement scroller starts at the beginning while retaining header collapse.
803
1167
  offsetDiag("primary-replacement-cache-before", previous)
804
- if (!nativeSmoothHeaderScrollEnabled) pageOffsets[currentPageKey()] = 0
1168
+ if (!nativeSmoothHeaderScrollEnabled) {
1169
+ offsetDiag("cache-write-replacement-new=0", previous)
1170
+ pageOffsets[currentPageKey()] = 0
1171
+ }
805
1172
  offsetDiag("primary-replacement-cache-after", previous)
806
1173
  }
807
1174
  when (val scrollable = primaryScrollableForPage(selectedPage)) {
@@ -822,20 +1189,32 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
822
1189
  offsetDiag("recycler-attach-before", recycler)
823
1190
  observedRecyclerView = recycler
824
1191
  observedRecyclerKey = currentPageKey()
1192
+ publishScrollCommandListener(recycler)
825
1193
  applyRecyclerInsets(recycler, selectedPage)
826
1194
  publishConsumedOffset(recycler)
827
1195
  val key = currentPageKey()
828
1196
  val listener = object : RecyclerView.OnScrollListener() {
1197
+ override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
1198
+ if (newState == RecyclerView.SCROLL_STATE_IDLE) finishPagerTopIfSettled()
1199
+ }
829
1200
  override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
830
1201
  if (observedScrollListener !== this || observedRecyclerKey != key ||
831
1202
  key != currentPageKey() || !isAttachedToWindow ||
832
- transitionHeaderOffsets != null || syntheticPageRestore || key in pendingPageRestores) return
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
833
1211
  val contentOffset = recyclerView.computeVerticalScrollOffset()
834
- if (pageOffsets[currentPageKey()] != contentOffset) offsetDiag("recycler-cache-before-new=$contentOffset", recyclerView)
1212
+ if (pageOffsets[key] != contentOffset) offsetDiag("cache-write-recycler-observer-new=$contentOffset-dx=$dx-dy=$dy", recyclerView, key)
835
1213
  pageOffsets[key] = contentOffset
1214
+ if (nativeSmoothHeaderScrollEnabled) saveRecyclerReadingAnchor(key, recyclerView)
836
1215
  // Nested touch scrolling moves list content only after the header has
837
1216
  // collapsed. Programmatic list jumps bypass those parent callbacks.
838
- if (contentOffset > 0 && headerOffsetPx < headerHeightPx) {
1217
+ if (contentOffset > 0 && headerOffsetPx < headerHeightPx && !hasExplicitTopCommand(recyclerView, key) && !matchesPagerTop(recyclerView)) {
839
1218
  offsetDiag("recycler-positive-collapse-before", recyclerView)
840
1219
  setHeaderOffset(headerHeightPx)
841
1220
  offsetDiag("recycler-positive-collapse-after", recyclerView)
@@ -851,15 +1230,21 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
851
1230
  }
852
1231
 
853
1232
  private fun detachRecyclerObserver(recordOffset: Boolean) {
1233
+ cancelPagerTop()
1234
+ explicitTopCommand = null
854
1235
  val recycler = observedRecyclerView
855
1236
  val listener = observedScrollListener
856
1237
  if (recycler != null) {
857
- offsetDiag("recycler-detach-save-before-record=$recordOffset", recycler)
858
- if (recordOffset && transitionPageOffsets == null) observedRecyclerKey?.let {
859
- if (it !in pendingPageRestores) pageOffsets[it] = 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
+ }
860
1244
  }
861
- offsetDiag("recycler-detach-save-after", recycler)
1245
+ offsetDiag("recycler-detach-save-after", recycler, observedRecyclerKey ?: currentPageKey())
862
1246
  publishConsumedOffset(recycler, remove = true)
1247
+ publishScrollCommandListener(recycler, remove = true)
863
1248
  }
864
1249
  if (recycler != null && listener != null) recycler.removeOnScrollListener(listener)
865
1250
  if (recycler != null) {
@@ -892,11 +1277,12 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
892
1277
  return
893
1278
  }
894
1279
  if (key != currentPageKey() || !isAttachedToWindow ||
895
- transitionHeaderOffsets != null || syntheticPageRestore || key in pendingPageRestores) return
1280
+ transitionHeaderOffsets != null || syntheticPageRestore || key in pendingPageRestores || key in alignedBodyKeys) return
1281
+ if (nativeSmoothHeaderScrollEnabled && nestedScrollingParentHelper.nestedScrollAxes == 0 && key !in commandBodyKeys) return
896
1282
  val contentOffset = scroller.scrollY
897
- if (pageOffsets[currentPageKey()] != contentOffset) offsetDiag("scrollview-cache-before-new=$contentOffset", scroller)
1283
+ if (pageOffsets[key] != contentOffset) offsetDiag("cache-write-scrollview-observer-new=$contentOffset", scroller, key)
898
1284
  pageOffsets[key] = contentOffset
899
- if (contentOffset > 0 && headerOffsetPx < headerHeightPx) {
1285
+ if (contentOffset > 0 && headerOffsetPx < headerHeightPx && !matchesPagerTop(scroller)) {
900
1286
  offsetDiag("scrollview-positive-collapse-before", scroller)
901
1287
  setHeaderOffset(headerHeightPx)
902
1288
  offsetDiag("scrollview-positive-collapse-after", scroller)
@@ -914,13 +1300,17 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
914
1300
  }
915
1301
 
916
1302
  private fun detachNativeScrollerObserver(recordOffset: Boolean) {
1303
+ cancelPagerTop()
917
1304
  val scroller = observedNativeScroller
918
1305
  val listener = observedNativeScrollerListener
919
- offsetDiag("scrollview-detach-save-before-record=$recordOffset", scroller)
920
- if (scroller != null && recordOffset && transitionPageOffsets == null) observedNativeScrollerKey?.let {
921
- if (it !in pendingPageRestores) pageOffsets[it] = 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
+ }
922
1312
  }
923
- offsetDiag("scrollview-detach-save-after", scroller)
1313
+ offsetDiag("scrollview-detach-save-after", scroller, observedNativeScrollerKey ?: currentPageKey())
924
1314
  if (scroller != null && listener != null) {
925
1315
  removeScrollChangedListener(observedNativeScrollerTreeObserver, scroller, listener)
926
1316
  }
@@ -963,18 +1353,21 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
963
1353
  val restoreRequest = registerPageRestore(key, scroller, pageIndex)
964
1354
  if (nativeSmoothHeaderScrollEnabled) ownedScrollerRestoreRequests[key] = WeakReference(scroller) to null
965
1355
  val generation = attachmentGeneration
1356
+ offsetDiag("owned-enqueue", scroller, key, pageIndex, restoreRequest)
966
1357
  postForCurrentAttachment {
1358
+ offsetDiag("owned-execute", scroller, key, pageIndex, restoreRequest)
967
1359
  if (isCurrentPageRestore(key, restoreRequest) && ownedScrollerKeys[scroller] == key && generation == attachmentGeneration &&
968
1360
  scroller.isAttachedToWindow && (!nativeSmoothHeaderScrollEnabled ||
969
1361
  (key in pendingPageRestores && ownedScrollerRestoreRequests[key]?.first?.get() === scroller))) {
970
- offsetDiag("owned-restore-before", scroller, key)
971
- val saved = transitionPageOffsets?.get(key) ?: pageOffsets[key] ?: 0
1362
+ offsetDiag("owned-restore-before", scroller, key, pageIndex, restoreRequest)
1363
+ val saved = restorationBodyOffset(key)
972
1364
  scroller.host.restoreScrollOffset(saved)
973
1365
  if (nativeSmoothHeaderScrollEnabled) {
974
1366
  ownedScrollerRestoreRequests[key] = WeakReference(scroller) to generation
975
1367
  recordOwnedScrollerOffset(scroller, key)
976
1368
  }
977
- offsetDiag("owned-restore-after", scroller, key)
1369
+ offsetDiag("owned-restore-after-saved=$saved", scroller, key, pageIndex, restoreRequest)
1370
+ offsetDiagAfterDraw(scroller, key, pageIndex)
978
1371
  }
979
1372
  }
980
1373
  }
@@ -1027,11 +1420,12 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
1027
1420
  clearPageRestoreForView(key, scroller)
1028
1421
  }
1029
1422
  if (observedOwnedScroller !== scroller || observedOwnedScrollerKey != key ||
1030
- key != currentPageKey() || transitionHeaderOffsets != null || syntheticPageRestore) return
1423
+ key != currentPageKey() || transitionHeaderOffsets != null || syntheticPageRestore || key in alignedBodyKeys) return
1424
+ if (nativeSmoothHeaderScrollEnabled && !scroller.isReadingMotionActive() && key !in commandBodyKeys) return
1031
1425
  val contentOffset = scroller.scrollY
1032
- if (pageOffsets[key] != contentOffset) offsetDiag("owned-cache-before-new=$contentOffset", scroller, key)
1426
+ if (pageOffsets[key] != contentOffset) offsetDiag("cache-write-owned-observer-new=$contentOffset", scroller, key)
1033
1427
  pageOffsets[key] = contentOffset
1034
- if (contentOffset > 0 && headerOffsetPx < headerHeightPx) {
1428
+ if (contentOffset > 0 && headerOffsetPx < headerHeightPx && !matchesPagerTop(scroller)) {
1035
1429
  offsetDiag("owned-positive-collapse-before", scroller, key)
1036
1430
  setHeaderOffset(headerHeightPx)
1037
1431
  offsetDiag("owned-positive-collapse-after", scroller, key)
@@ -1039,14 +1433,18 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
1039
1433
  }
1040
1434
 
1041
1435
  private fun detachOwnedScrollerObserver(recordOffset: Boolean) {
1436
+ cancelPagerTop()
1042
1437
  val scroller = observedOwnedScroller ?: return
1043
1438
  scroller.stopMotion()
1044
1439
  publishConsumedOffset(scroller, remove = true)
1045
- offsetDiag("owned-detach-save-before-record=$recordOffset", scroller)
1046
- if (recordOffset && transitionPageOffsets == null) observedOwnedScrollerKey?.let {
1047
- if (it !in pendingPageRestores) 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
+ }
1048
1446
  }
1049
- offsetDiag("owned-detach-save-after", scroller)
1447
+ offsetDiag("owned-detach-save-after", scroller, observedOwnedScrollerKey ?: currentPageKey())
1050
1448
  observedOwnedScrollerListener?.let {
1051
1449
  removeScrollChangedListener(observedOwnedScrollerTreeObserver, scroller, it)
1052
1450
  }
@@ -1083,6 +1481,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
1083
1481
  val anchorOffset = if (first != RecyclerView.NO_POSITION) {
1084
1482
  manager?.findViewByPosition(first)?.let { manager.getDecoratedTop(it) - recycler.paddingTop }
1085
1483
  } else null
1484
+ offsetDiag("recycler-inset-before-nextTop=$top-anchorPosition=$first-anchorOffset=$anchorOffset", recycler, pageKey(pageIndex), pageIndex)
1086
1485
  recycler.setPadding(original.left, top, original.right, bottom)
1087
1486
  if (anchorOffset != null) manager?.scrollToPositionWithOffset(first, anchorOffset)
1088
1487
  // React Native can stop this nested requestLayout at its React-owned parent.
@@ -1096,15 +1495,33 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
1096
1495
  recycler.layout(recycler.left, recycler.top, recycler.right, recycler.bottom)
1097
1496
  }
1098
1497
  }
1498
+ if (original.appliedTopInset != topInset) offsetDiag("recycler-inset-after-old=${original.appliedTopInset}-new=$topInset", recycler, pageKey(pageIndex), pageIndex)
1099
1499
  original.appliedTopInset = topInset
1100
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
+ }
1101
1512
  if (restoredRecyclerKeys[recycler] != key) {
1102
1513
  restoredRecyclerKeys[recycler] = key
1103
1514
  val restoreRequest = registerPageRestore(key, recycler, pageIndex)
1104
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
+ }
1105
1521
  recycler.post {
1522
+ offsetDiag("recycler-execute", recycler, key, pageIndex, restoreRequest)
1106
1523
  if (!isCurrentPageRestore(key, restoreRequest)) {
1107
- offsetDiag("post-skip-cancelled-page=$pageIndex", recycler, key)
1524
+ offsetDiag("recycler-post-skip-cancelled", recycler, key, pageIndex, restoreRequest)
1108
1525
  return@post
1109
1526
  }
1110
1527
  if (!isAttachedToWindow || generation != attachmentGeneration ||
@@ -1114,18 +1531,31 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
1114
1531
  clearPageRestoreForView(key, recycler, restoreRequest)
1115
1532
  return@post
1116
1533
  }
1117
- val saved = transitionPageOffsets?.get(key) ?: pageOffsets[key] ?: 0
1534
+ val saved = restorationBodyOffset(key)
1118
1535
  val current = recycler.computeVerticalScrollOffset()
1119
- offsetDiag("recycler-restore-before-saved=$saved", recycler, key)
1536
+ offsetDiag("recycler-restore-before-saved=$saved-current=${current}-requestedDelta=${saved - current}", recycler, key, pageIndex, restoreRequest)
1120
1537
  if (nativeSmoothHeaderScrollEnabled && (recycler.height <= 0 || recycler.childCount == 0)) {
1121
1538
  restoredRecyclerKeys.remove(recycler)
1122
1539
  if (pageRestoreRequests[key] === restoreRequest) pageRestoreRequests.remove(key)
1123
1540
  return@post
1124
1541
  }
1125
- if (saved != current) recycler.scrollBy(0, saved - current)
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)
1126
1555
  if (nativeSmoothHeaderScrollEnabled) clearPageRestoreForView(key, recycler, restoreRequest)
1127
1556
  else pendingPageRestores.remove(key)
1128
- offsetDiag("recycler-restore-after", recycler, key)
1557
+ offsetDiag("recycler-restore-after-saved=$saved", recycler, key, pageIndex, restoreRequest)
1558
+ offsetDiagAfterDraw(recycler, key, pageIndex)
1129
1559
  }
1130
1560
  }
1131
1561
  }
@@ -1173,9 +1603,11 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
1173
1603
  restoredScrollerKeys[scroller] = key
1174
1604
  val restoreRequest = registerPageRestore(key, scroller, pageIndex)
1175
1605
  val generation = attachmentGeneration
1606
+ offsetDiag("scrollview-enqueue", scroller, key, pageIndex, restoreRequest)
1176
1607
  scroller.post {
1608
+ offsetDiag("scrollview-execute", scroller, key, pageIndex, restoreRequest)
1177
1609
  if (!isCurrentPageRestore(key, restoreRequest)) {
1178
- offsetDiag("post-skip-cancelled-page=$pageIndex", scroller, key)
1610
+ offsetDiag("scrollview-post-skip-cancelled", scroller, key, pageIndex, restoreRequest)
1179
1611
  return@post
1180
1612
  }
1181
1613
  if (!isAttachedToWindow || generation != attachmentGeneration ||
@@ -1185,8 +1617,8 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
1185
1617
  clearPageRestoreForView(key, scroller, restoreRequest)
1186
1618
  return@post
1187
1619
  }
1188
- val saved = transitionPageOffsets?.get(key) ?: pageOffsets[key] ?: 0
1189
- offsetDiag("scrollview-restore-before-saved=$saved", scroller, key)
1620
+ val saved = restorationBodyOffset(key)
1621
+ offsetDiag("scrollview-restore-before-saved=$saved-current=${scroller.scrollY}-requestedDelta=${saved - scroller.scrollY}", scroller, key, pageIndex, restoreRequest)
1190
1622
  if (nativeSmoothHeaderScrollEnabled &&
1191
1623
  (scroller.height <= 0 || (scroller.getChildAt(0)?.height ?: 0) <= 0)) {
1192
1624
  restoredScrollerKeys.remove(scroller)
@@ -1196,7 +1628,8 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
1196
1628
  if (scroller.scrollY != saved) scroller.scrollTo(scroller.scrollX, saved)
1197
1629
  if (nativeSmoothHeaderScrollEnabled) clearPageRestoreForView(key, scroller, restoreRequest)
1198
1630
  else pendingPageRestores.remove(key)
1199
- offsetDiag("scrollview-restore-after", scroller, key)
1631
+ offsetDiag("scrollview-restore-after-saved=$saved", scroller, key, pageIndex, restoreRequest)
1632
+ offsetDiagAfterDraw(scroller, key, pageIndex)
1200
1633
  }
1201
1634
  }
1202
1635
  }
@@ -1638,9 +2071,13 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
1638
2071
  if (type == ViewCompat.TYPE_TOUCH) pendingRefreshPull = null
1639
2072
  nestedScrollingParentHelper.onNestedScrollAccepted(child, target, axes, type)
1640
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) }
1641
2077
  pageRestoreRequests.remove(currentPageKey())
1642
2078
  pendingPageRestores.remove(currentPageKey())
1643
2079
  ownedScrollerRestoreRequests.remove(currentPageKey())
2080
+ offsetDiag("touch-cancel-after", target, request = request)
1644
2081
  }
1645
2082
  }
1646
2083
 
@@ -1658,6 +2095,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
1658
2095
  // val used = min(dy, headerHeightPx - headerOffsetPx)
1659
2096
  if (dy > 0 && headerOffsetPx < maximumOffset) {
1660
2097
  val used = min(dy, maximumOffset - headerOffsetPx)
2098
+ if (type == ViewCompat.TYPE_TOUCH && used != 0) takeOverBodyForVerticalConsumption()
1661
2099
  setHeaderOffset(headerOffsetPx + used)
1662
2100
  consumed[1] += used
1663
2101
  } else if (dy < 0 && headerOffsetPx > 0) {
@@ -1669,6 +2107,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
1669
2107
  }
1670
2108
  if (!canScrollUp) {
1671
2109
  val used = max(dy, -headerOffsetPx)
2110
+ if (type == ViewCompat.TYPE_TOUCH && used != 0) takeOverBodyForVerticalConsumption()
1672
2111
  setHeaderOffset(headerOffsetPx + used)
1673
2112
  consumed[1] += used
1674
2113
  }
@@ -1684,8 +2123,11 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
1684
2123
  type: Int,
1685
2124
  consumed: IntArray,
1686
2125
  ) {
1687
- 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
1688
2129
  val used = max(dyUnconsumed, -headerOffsetPx)
2130
+ if (type == ViewCompat.TYPE_TOUCH && used != 0) takeOverBodyForVerticalConsumption()
1689
2131
  setHeaderOffset(headerOffsetPx + used)
1690
2132
  consumed[1] += used
1691
2133
  if (type == ViewCompat.TYPE_TOUCH && dyUnconsumed < used &&
@@ -1763,6 +2205,14 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
1763
2205
  detachPrimaryScrollObserver()
1764
2206
  pageRestoreRequests.clear()
1765
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()
1766
2216
  ownedScrollerRestoreRequests.clear()
1767
2217
  transitionHeaderOffsets?.let { headers ->
1768
2218
  resumeTransitionSource = transitionSourcePage