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

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,17 +132,90 @@ 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
143
206
  private var attachmentGeneration = 0
207
+ internal var headerSettledAtTop = false
208
+ private set
209
+ var onHeaderSettledStateChanged: (() -> Unit)? = null
210
+ private var headerSettledOwner: WeakReference<View>? = null
211
+ private var headerSettledKey: String? = null
212
+ private var headerSettledGeneration = -1
213
+ private var headerSettledGeometry: String? = null
214
+ private var headerSettledSince = 0L
215
+ private var headerSettledWasBusy = true
216
+ private var headerSettledCheck: Runnable? = null
217
+ private var headerCommandOwner: WeakReference<View>? = null
218
+
144
219
  private val touchSlopPx = ViewConfiguration.get(context).scaledTouchSlop
145
220
  private val hostIdentity = System.identityHashCode(this)
146
221
  private var headerView: View? = null
@@ -162,8 +237,15 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
162
237
  // Apply its header insets before that list first draws.
163
238
  prepareAdjacentPages()
164
239
  attachPrimaryScrollObserver()
240
+ finishPagerTopIfSettled()
165
241
  // OneKey patch: a filtered short page can reduce the existing collapse range.
166
242
  setHeaderOffset(headerOffsetPx)
243
+ updateHeaderSettledState()
244
+ if (offsetDiagEnabled() && offsetDiagNextDraw.isNotEmpty()) {
245
+ val samples = offsetDiagNextDraw.entries.map { it.key to it.value }
246
+ offsetDiagNextDraw.clear()
247
+ samples.forEach { (view, owner) -> offsetDiag("restore-next-predraw", view, owner.first, owner.second) }
248
+ }
167
249
  true
168
250
  }
169
251
 
@@ -189,6 +271,20 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
189
271
  var headerOffsetPx = 0
190
272
  private set
191
273
  var pageKeys: List<String> = emptyList()
274
+ set(value) {
275
+ if (field == value) return
276
+ pageRestoreRequests.entries.toList().forEach { (key, request) ->
277
+ if ((value.getOrNull(request.pageIndex) ?: "page-${request.pageIndex}") != key) {
278
+ (request.view.get() as? NativeScrollerViewport)?.host?.cancelPendingScrollRestore()
279
+ pageRestoreRequests.remove(key)
280
+ pendingPageRestores.remove(key)
281
+ ownedScrollerRestoreRequests.remove(key)
282
+ }
283
+ }
284
+ recyclerReadingAnchors.keys.retainAll(value.toSet())
285
+ displayRecyclerAnchors.keys.retainAll(value.toSet())
286
+ field = value
287
+ }
192
288
  var retainedPages: String = "[]"
193
289
  var onHeaderOffsetChanged: (() -> Unit)? = null
194
290
  var nativeSmoothHeaderScrollEnabled = false
@@ -230,6 +326,51 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
230
326
  OneKeyLog.debug("CollapsiblePager", "host=$hostIdentity $message")
231
327
  }
232
328
 
329
+ private var offsetDiagSequence = 0L
330
+ private val offsetDiagNextDraw = WeakHashMap<View, Pair<String, Int>>()
331
+
332
+ private fun offsetDiagEnabled(): Boolean =
333
+ (context.applicationInfo.flags and android.content.pm.ApplicationInfo.FLAG_DEBUGGABLE) != 0
334
+
335
+ private fun offsetDiagAfterDraw(view: View, key: String, pageIndex: Int) {
336
+ if (offsetDiagEnabled()) offsetDiagNextDraw[view] = key to pageIndex
337
+ }
338
+
339
+ private fun offsetDiag(
340
+ phase: String,
341
+ view: View? = null,
342
+ key: String = currentPageKey(),
343
+ pageIndex: Int = pageKeys.indexOf(key),
344
+ request: PageRestoreRequest? = pageRestoreRequests[key],
345
+ ) {
346
+ if (!offsetDiagEnabled()) return
347
+ val safeKey = if (key in setOf("portfolio", "perps", "defi", "nft", "history")) key else "other"
348
+ val observed = observedOwnedScroller ?: observedNativeScroller ?: observedRecyclerView
349
+ val observedKey = observedOwnedScrollerKey ?: observedNativeScrollerKey ?: observedRecyclerKey
350
+ val recycler = view as? RecyclerView
351
+ val manager = recycler?.layoutManager as? LinearLayoutManager
352
+ val first = manager?.findFirstVisibleItemPosition()
353
+ val last = manager?.findLastVisibleItemPosition()
354
+ val anchor = first?.takeIf { it != RecyclerView.NO_POSITION }?.let { manager?.findViewByPosition(it) }
355
+ val child = (view as? ViewGroup)?.getChildAt(0)
356
+ OneKeyLog.debug("CollapsiblePager", "[HOME-OFFSET-DIAG] phase=$phase seq=${++offsetDiagSequence} " +
357
+ "wallMs=${System.currentTimeMillis()} monoMs=${SystemClock.uptimeMillis()} host=$hostIdentity " +
358
+ "generation=$attachmentGeneration selectedPage=$selectedPage pagerPage=${pager.currentItem} targetPage=$pageIndex key=$safeKey " +
359
+ "ownerPage=${pageKeys.indexOf(observedKey)} header=$headerOffsetPx headerHeight=$headerHeightPx stickyHeight=$stickyHeaderHeightPx " +
360
+ "transition=${transitionHeaderOffsets != null} synthetic=$syntheticPageRestore pending=${key in pendingPageRestores} " +
361
+ "requestId=${request?.let { System.identityHashCode(it) }} requestGeneration=${request?.generation} " +
362
+ "requestPage=${request?.pageIndex} requestCurrent=${request != null && pageRestoreRequests[key] === request} " +
363
+ "view=${view?.let { System.identityHashCode(it) }} tag=${view?.id} observed=${observed?.let { System.identityHashCode(it) }} " +
364
+ "attached=${view?.isAttachedToWindow} estimatedOffset=${view?.let(::verticalScrollOffset)} " +
365
+ "cachePresent=${pageOffsets.containsKey(key)} cache=${pageOffsets[key]} snapshot=${transitionPageOffsets?.get(key)} " +
366
+ "boundsHeight=${view?.height} measuredHeight=${view?.measuredHeight} childHeight=${child?.height} childTop=${child?.top} " +
367
+ "paddingTop=${view?.paddingTop} paddingBottom=${view?.paddingBottom} childCount=${(view as? ViewGroup)?.childCount} " +
368
+ "itemCount=${recycler?.adapter?.itemCount} first=$first last=$last anchorTag=${anchor?.id} " +
369
+ "anchorTop=${anchor?.let { manager?.getDecoratedTop(it) }} anchorRelative=${anchor?.let { manager?.getDecoratedTop(it)?.minus(recycler?.paddingTop ?: 0) }} " +
370
+ "range=${recycler?.computeVerticalScrollRange()} extent=${recycler?.computeVerticalScrollExtent()} " +
371
+ "canUp=${view?.canScrollVertically(-1)} canDown=${view?.canScrollVertically(1)} scrollState=${recycler?.scrollState} computing=${recycler?.isComputingLayout}")
372
+ }
373
+
233
374
  private fun outerPagerIndex(): Int {
234
375
  var ancestor = parent as? View
235
376
  while (ancestor != null) {
@@ -364,6 +505,19 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
364
505
  fun removeAllReactChildren() {
365
506
  releaseRefreshForeground()
366
507
  detachPrimaryScrollObserver()
508
+ pageRestoreRequests.clear()
509
+ pendingPageRestores.clear()
510
+ commandBodyKeys.clear()
511
+ explicitTopCommand = null
512
+ suspendedBodyKeys.clear()
513
+ alignedBodyKeys.clear()
514
+ transitionAlignedViews.clear()
515
+ deferredRecyclerGeometry.clear()
516
+ displayBodyOverrides.clear()
517
+ displayRecyclerAnchors.clear()
518
+ ownedScrollerRestoreRequests.clear()
519
+ recyclerReadingAnchors.clear()
520
+ transitionSourceRecyclerAnchor = null
367
521
  // Weak keys may disappear after size is read; Collection.toArray tolerates this.
368
522
  for (recycler in originalRecyclerPadding.keys.toTypedArray()) {
369
523
  restoreRecyclerInsets(recycler)
@@ -386,16 +540,91 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
386
540
 
387
541
  fun reactChildAt(index: Int) = logicalChildren[index]
388
542
 
543
+ internal fun beginPageTransition(target: Int? = null) {
544
+ cancelPagerTop()
545
+ if (!nativeSmoothHeaderScrollEnabled || headerHeightPx <= 0) return
546
+ if (transitionHeaderOffsets == null) {
547
+ transitionSourceDisplayBody = primaryScrollableForPage(selectedPage)?.let(::verticalScrollOffset) ?: 0
548
+ transitionSourceRecyclerAnchor = (primaryScrollableForPage(selectedPage) as? RecyclerView)?.let(::captureRecyclerAnchor)
549
+ pageHeaderOffsets[currentPageKey()] = headerOffsetPx
550
+ detachPrimaryScrollObserver()
551
+ transitionSourcePage = selectedPage
552
+ transitionHeaderOffsets = pageHeaderOffsets.toMap()
553
+ transitionPageOffsets = pageOffsets.toMap()
554
+ }
555
+ if (target != null) {
556
+ transitionTargetPage = target
557
+ alignPageBody(target)
558
+ }
559
+ offsetDiag("transition-begin-source=$transitionSourcePage", primaryScrollableForPage(selectedPage), pageKey(target ?: selectedPage), target ?: selectedPage)
560
+ }
561
+
562
+ internal fun onPagerScrolled(position: Int, offset: Float) {
563
+ if (transitionHeaderOffsets == null) return
564
+ alignPageBody(position)
565
+ if (position + 1 < adapter.itemCount) alignPageBody(position + 1)
566
+ updateNativeTabProgress(position, offset)
567
+ }
568
+
569
+ internal fun onPagerScrollStateChanged(state: Int) {
570
+ updateHeaderSettledState(forceBusy = state != ViewPager2.SCROLL_STATE_IDLE)
571
+ if (state == ViewPager2.SCROLL_STATE_DRAGGING || state == ViewPager2.SCROLL_STATE_SETTLING) {
572
+ beginPageTransition()
573
+ } else if (state == ViewPager2.SCROLL_STATE_IDLE && transitionHeaderOffsets != null) {
574
+ finishPageTransition(pager.currentItem)
575
+ }
576
+ }
577
+
578
+ private fun finishPageTransition(position: Int) {
579
+ val headers = transitionHeaderOffsets ?: return
580
+ detachPrimaryScrollObserver(recordOffset = false)
581
+ releaseRefreshForeground()
582
+ selectedPage = position.coerceIn(0, adapter.itemCount - 1)
583
+ syntheticPageRestore = true
584
+ setHeaderOffset(headers[pageKey(transitionSourcePage)] ?: headerOffsetPx)
585
+ transitionHeaderOffsets = null
586
+ transitionPageOffsets = null
587
+ transitionTargetPage = null
588
+ syntheticPageRestore = false
589
+ transitionAlignedViews.clear()
590
+ if (selectedPage == transitionSourcePage) {
591
+ displayBodyOverrides[currentPageKey()] = transitionSourceDisplayBody
592
+ transitionSourceRecyclerAnchor?.let { displayRecyclerAnchors[currentPageKey()] = it }
593
+ } else {
594
+ displayBodyOverrides.remove(currentPageKey())
595
+ displayRecyclerAnchors.remove(currentPageKey())
596
+ }
597
+ transitionSourceRecyclerAnchor = null
598
+ if (headerOffsetPx == headerHeightPx) suspendedBodyKeys.remove(currentPageKey())
599
+ else if ((pageOffsets[currentPageKey()] ?: 0) > 0) suspendedBodyKeys.add(currentPageKey())
600
+ alignPageBody(selectedPage, force = true)
601
+ offsetDiag("transition-finish", primaryScrollableForPage(selectedPage), currentPageKey(), selectedPage)
602
+ prepareAdjacentPages()
603
+ attachPrimaryScrollObserver()
604
+ }
605
+
389
606
  fun selectPage(position: Int) {
607
+ offsetDiag("select-begin", observedOwnedScroller ?: observedNativeScroller ?: observedRecyclerView, pageKey(position), position)
390
608
  pendingRefreshPull = null
391
609
  if (adapter.itemCount == 0) return
610
+ if (nativeSmoothHeaderScrollEnabled && headerHeightPx > 0) {
611
+ if (transitionHeaderOffsets != null) return
612
+ beginPageTransition(position)
613
+ finishPageTransition(position)
614
+ return
615
+ }
392
616
  detachPrimaryScrollObserver()
393
617
  releaseRefreshForeground()
394
618
  selectedPage = position.coerceIn(0, adapter.itemCount - 1)
395
619
  val savedOffset = pageOffsets[currentPageKey()]
396
620
  ?: primaryScrollableForPage(selectedPage)?.let(::verticalScrollOffset)
397
621
  ?: 0
398
- if (savedOffset > 0) setHeaderOffset(headerHeightPx)
622
+ offsetDiag("select-offset=$savedOffset-source=${if (pageOffsets[currentPageKey()] != null) "saved" else "live"}")
623
+ if (savedOffset > 0) {
624
+ offsetDiag("select-force-collapse-before")
625
+ setHeaderOffset(headerHeightPx)
626
+ offsetDiag("select-force-collapse-after")
627
+ }
399
628
  postForCurrentAttachment {
400
629
  prepareAdjacentPages()
401
630
  attachPrimaryScrollObserver()
@@ -519,7 +748,295 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
519
748
  }
520
749
  }
521
750
 
751
+ private fun restorationBodyOffset(key: String): Int {
752
+ val saved = if (transitionPageOffsets != null && key == pageKey(transitionSourcePage)) transitionSourceDisplayBody
753
+ else displayBodyOverrides[key] ?: transitionPageOffsets?.get(key) ?: pageOffsets[key] ?: 0
754
+ return if (nativeSmoothHeaderScrollEnabled && headerOffsetPx < headerHeightPx) 0 else saved
755
+ }
756
+
757
+ private fun captureRecyclerAnchor(view: RecyclerView): RecyclerReadingAnchor? {
758
+ val manager = view.layoutManager as? LinearLayoutManager ?: return null
759
+ if (manager.orientation != RecyclerView.VERTICAL) return null
760
+ val position = manager.findFirstVisibleItemPosition()
761
+ val child = manager.findViewByPosition(position) ?: return null
762
+ val adapter = view.adapter ?: return null
763
+ return RecyclerReadingAnchor(view, adapter, position, manager.getDecoratedTop(child) - view.paddingTop)
764
+ }
765
+
766
+ private fun saveRecyclerReadingAnchor(key: String, view: RecyclerView) {
767
+ captureRecyclerAnchor(view)?.let { recyclerReadingAnchors[key] = it }
768
+ }
769
+
770
+ private fun restoreRecyclerAnchor(view: RecyclerView, key: String, index: Int, request: PageRestoreRequest?): Boolean {
771
+ if (!nativeSmoothHeaderScrollEnabled || pageKey(index) != key ||
772
+ primaryScrollableForPage(index) !== view || !isCurrentPageRestore(key, request) ||
773
+ !isAttachedToWindow || !view.isAttachedToWindow || view.width <= 0 || view.height <= 0 ||
774
+ isInLayout || view.isInLayout || view.isComputingLayout) return false
775
+ val manager = view.layoutManager as? LinearLayoutManager
776
+ val reading = recyclerReadingAnchors[key]?.takeIf { it.view.get() === view && it.adapter === view.adapter }
777
+ val display = (if (transitionPageOffsets != null && index == transitionSourcePage) transitionSourceRecyclerAnchor
778
+ else displayRecyclerAnchors[key])?.takeIf { it.view.get() === view && it.adapter === view.adapter }
779
+ if (manager == null || manager.orientation != RecyclerView.VERTICAL ||
780
+ (headerOffsetPx == headerHeightPx && reading == null && display == null && restorationBodyOffset(key) > 0)) {
781
+ val saved = restorationBodyOffset(key)
782
+ val current = view.computeVerticalScrollOffset()
783
+ val previousSynthetic = syntheticPageRestore
784
+ syntheticPageRestore = true
785
+ try { if (saved != current) view.scrollBy(0, saved - current) }
786
+ finally { syntheticPageRestore = previousSynthetic }
787
+ return saved <= view.computeVerticalScrollOffset() || view.canScrollVertically(1)
788
+ }
789
+ val target = if (headerOffsetPx < headerHeightPx) null else display ?: reading
790
+ val position = target?.position ?: 0
791
+ val top = target?.top ?: 0
792
+ if (position !in 0 until (view.adapter?.itemCount ?: 0)) return false
793
+ fun reached(): Boolean {
794
+ val actual = captureRecyclerAnchor(view) ?: return false
795
+ return actual.position == position && actual.top == top
796
+ }
797
+ if (!reached()) {
798
+ if (!isAttachedToWindow || !view.isAttachedToWindow || width <= 0 || height <= 0 ||
799
+ view.width <= 0 || view.height <= 0 || isInLayout || view.isInLayout || view.isComputingLayout ||
800
+ pageKey(index) != key || primaryScrollableForPage(index) !== view || !isCurrentPageRestore(key, request)) return false
801
+ val previousSynthetic = syntheticPageRestore
802
+ syntheticPageRestore = true
803
+ try {
804
+ manager.scrollToPositionWithOffset(position, top)
805
+ // Use the existing inset commit boundary once, before the target's first frame.
806
+ view.forceLayout()
807
+ view.measure(MeasureSpec.makeMeasureSpec(view.width, MeasureSpec.EXACTLY), MeasureSpec.makeMeasureSpec(view.height, MeasureSpec.EXACTLY))
808
+ view.layout(view.left, view.top, view.right, view.bottom)
809
+ } finally {
810
+ syntheticPageRestore = previousSynthetic
811
+ }
812
+ offsetDiag("recycler-anchor-restore-position=$position-top=$top", view, key, index, request)
813
+ }
814
+ val readingStillUnreachable = display != null && reading != null &&
815
+ (reading.position != position || reading.top != top) && !view.canScrollVertically(1)
816
+ return reached() && !readingStillUnreachable
817
+ }
818
+
819
+ private fun alignPageBody(index: Int, force: Boolean = false) {
820
+ if (!nativeSmoothHeaderScrollEnabled || index !in 0 until adapter.itemCount) return
821
+ val view = primaryScrollableForPage(index) ?: return
822
+ val key = pageKey(index)
823
+ if (!force && transitionAlignedViews[view] == key) return
824
+ transitionAlignedViews[view] = key
825
+ if (transitionPageOffsets != null && index != transitionSourcePage) {
826
+ displayBodyOverrides.remove(key)
827
+ displayRecyclerAnchors.remove(key)
828
+ }
829
+ alignedBodyKeys.add(key)
830
+ if (headerOffsetPx < headerHeightPx && (pageOffsets[key] ?: 0) > 0) suspendedBodyKeys.add(key)
831
+ val previousSynthetic = syntheticPageRestore
832
+ syntheticPageRestore = true
833
+ when (view) {
834
+ is NativeScrollerViewport -> {
835
+ ownedScrollerKeys.remove(view)
836
+ applyOwnedScrollerInsets(view, index)
837
+ view.host.restoreScrollOffset(restorationBodyOffset(key))
838
+ }
839
+ is RecyclerView -> {
840
+ restoredRecyclerKeys.remove(view)
841
+ applyRecyclerInsets(view, index)
842
+ restoreRecyclerAnchor(view, key, index, pageRestoreRequests[key])
843
+ }
844
+ is ScrollView -> {
845
+ restoredScrollerKeys.remove(view)
846
+ applyNativeScrollerInsets(view, index)
847
+ if (view.height > 0 && view.childCount > 0) view.scrollTo(view.scrollX, restorationBodyOffset(key))
848
+ }
849
+ }
850
+ syntheticPageRestore = previousSynthetic
851
+ }
852
+
853
+ private fun takeOverBodyForVerticalConsumption() {
854
+ cancelPagerTop()
855
+ if (!nativeSmoothHeaderScrollEnabled || transitionHeaderOffsets != null || syntheticPageRestore) return
856
+ val key = currentPageKey()
857
+ commandBodyKeys.remove(key)
858
+ explicitTopCommand = null
859
+ suspendedBodyKeys.remove(key)
860
+ alignedBodyKeys.remove(key)
861
+ displayBodyOverrides.remove(key)
862
+ displayRecyclerAnchors.remove(key)
863
+ pageOffsets[key] = primaryScrollableForPage(selectedPage)?.let(::verticalScrollOffset) ?: 0
864
+ (primaryScrollableForPage(selectedPage) as? RecyclerView)?.let { saveRecyclerReadingAnchor(key, it) }
865
+ }
866
+
867
+ internal fun acceptObservedScrollCommand(view: View, explicitTop: Boolean, phase: Int = 0) {
868
+ if (dispatchingPagerTop && matchesPagerTop(view)) return
869
+ if (phase == 0 && view === (observedRecyclerView ?: observedOwnedScroller ?: observedNativeScroller)) cancelPagerTop()
870
+ if (phase != 0 && matchesPagerTop(view)) { finishPagerTopIfSettled(); return }
871
+ val owner = observedRecyclerView ?: observedOwnedScroller ?: observedNativeScroller
872
+ val stateOwner = observedRecyclerView ?: observedOwnedScroller ?: observedNativeScroller
873
+ val stateKey = observedRecyclerKey ?: observedOwnedScrollerKey ?: observedNativeScrollerKey
874
+ if (view === stateOwner && stateKey == currentPageKey() && view.isAttachedToWindow && isAttachedToWindow && transitionHeaderOffsets == null && !syntheticPageRestore) {
875
+ updateHeaderSettledState()
876
+ if (phase == 1) headerCommandOwner = WeakReference(view)
877
+ else if (phase == 0 || phase == 2) headerCommandOwner = null
878
+ updateHeaderSettledState(forceBusy = phase != 2)
879
+ }
880
+ if (!nativeSmoothHeaderScrollEnabled || view !== owner || transitionHeaderOffsets != null || syntheticPageRestore ||
881
+ !isAttachedToWindow || !view.isAttachedToWindow) return
882
+ val key = currentPageKey()
883
+ val observedKey = observedRecyclerKey ?: observedOwnedScrollerKey ?: observedNativeScrollerKey
884
+ if (observedKey != key) return
885
+ if (phase == 1) { commandBodyKeys.add(key); return }
886
+ if (phase == 2) {
887
+ if (!commandBodyKeys.remove(key)) return
888
+ val completingExplicitTop = hasExplicitTopCommand(view, key)
889
+ val manager = (view as? RecyclerView)?.layoutManager as? LinearLayoutManager
890
+ val first = manager?.findViewByPosition(0)
891
+ val atExplicitTop = completingExplicitTop && manager != null && manager.orientation == RecyclerView.VERTICAL &&
892
+ manager.findFirstVisibleItemPosition() == 0 && first != null &&
893
+ manager.getDecoratedTop(first) - view.paddingTop == 0
894
+ explicitTopCommand = null
895
+ pageOffsets[key] = if (atExplicitTop) 0 else verticalScrollOffset(view)
896
+ if (view is RecyclerView) saveRecyclerReadingAnchor(key, view)
897
+ if (atExplicitTop) setHeaderOffset(0)
898
+ else if ((pageOffsets[key] ?: 0) > 0) setHeaderOffset(headerHeightPx)
899
+ offsetDiag("command-result-complete", view, key)
900
+ return
901
+ }
902
+ commandBodyKeys.remove(key)
903
+ explicitTopCommand = if (explicitTop && view is RecyclerView) {
904
+ key to PageRestoreRequest(view, selectedPage, attachmentGeneration)
905
+ } else null
906
+ pageRestoreRequests.remove(key)
907
+ pendingPageRestores.remove(key)
908
+ ownedScrollerRestoreRequests.remove(key)
909
+ if (view is RecyclerView) deferredRecyclerGeometry.remove(view)
910
+ (view as? NativeScrollerViewport)?.host?.cancelPendingScrollRestore()
911
+ suspendedBodyKeys.remove(key)
912
+ alignedBodyKeys.remove(key)
913
+ displayBodyOverrides.remove(key)
914
+ displayRecyclerAnchors.remove(key)
915
+ if (view is RecyclerView) saveRecyclerReadingAnchor(key, view)
916
+ pageOffsets[key] = if (explicitTop) 0 else verticalScrollOffset(view)
917
+ if (explicitTop) setHeaderOffset(0)
918
+ offsetDiag("command-body-takeover-top=$explicitTop", view, key)
919
+ }
920
+
921
+ private fun matchesPagerTop(view: View): Boolean {
922
+ val request = pagerTopRequest ?: return false
923
+ val owner = observedRecyclerView ?: observedOwnedScroller ?: observedNativeScroller
924
+ val observedKey = observedRecyclerKey ?: observedOwnedScrollerKey ?: observedNativeScrollerKey
925
+ return request.view.get() === view && owner === view && observedKey == request.key &&
926
+ request.key == currentPageKey() && request.pageIndex == selectedPage &&
927
+ request.generation == attachmentGeneration && view.isAttachedToWindow && isAttachedToWindow
928
+ }
929
+
930
+ private fun cancelPagerTop() {
931
+ pagerTopRequest?.let { commandBodyKeys.remove(it.key) }
932
+ pagerTopRequest = null
933
+ }
934
+
935
+ private fun recyclerIsAtBodyTop(view: RecyclerView): Boolean {
936
+ val manager = view.layoutManager as? LinearLayoutManager ?: return !view.canScrollVertically(-1)
937
+ val first = manager.findViewByPosition(0) ?: return view.adapter?.itemCount == 0
938
+ return manager.orientation == RecyclerView.VERTICAL && manager.findFirstVisibleItemPosition() == 0 &&
939
+ manager.getDecoratedTop(first) - view.paddingTop == 0
940
+ }
941
+
942
+ private fun finishPagerTopIfSettled() {
943
+ val request = pagerTopRequest ?: return
944
+ val view = request.view.get() ?: run { cancelPagerTop(); return }
945
+ if (!matchesPagerTop(view)) { cancelPagerTop(); return }
946
+ if (dispatchingPagerTop) return
947
+ val settled = when (view) {
948
+ is RecyclerView -> !view.isComputingLayout && view.scrollState == RecyclerView.SCROLL_STATE_IDLE &&
949
+ view.layoutManager?.isSmoothScrolling != true && recyclerIsAtBodyTop(view)
950
+ is NativeScrollerViewport -> !view.isReadingMotionActive() && view.scrollY == 0
951
+ is ScrollView -> view.scrollY == 0
952
+ else -> false
953
+ }
954
+ if (!settled) return
955
+ pageOffsets[request.key] = 0
956
+ if (view is RecyclerView) saveRecyclerReadingAnchor(request.key, view)
957
+ setHeaderOffset(if (request.target == 1) headerHeightPx else 0)
958
+ offsetDiag("pager-top-complete-target=${request.target}-token=${request.token}", view, request.key)
959
+ cancelPagerTop()
960
+ }
961
+
962
+ internal fun scrollToTop(target: Int, animated: Boolean) {
963
+ if (target !in 0..1 || pager.scrollState != ViewPager2.SCROLL_STATE_IDLE || transitionHeaderOffsets != null ||
964
+ syntheticPageRestore || selectedPage !in 0 until adapter.itemCount || !isAttachedToWindow) return
965
+ val view = observedRecyclerView ?: observedOwnedScroller ?: observedNativeScroller ?: return
966
+ val key = currentPageKey()
967
+ val ownerKey = observedRecyclerKey ?: observedOwnedScrollerKey ?: observedNativeScrollerKey
968
+ if (ownerKey != key || !view.isAttachedToWindow || primaryScrollableForPage(selectedPage) !== view) return
969
+ // Finish old child notifications before installing the new Pager-owned target.
970
+ cancelPagerTop()
971
+ if (view is RecyclerView) {
972
+ (view.getTag(R.id.onekey_native_scroll_coordinator_command_cancel_listener) as? Runnable)?.run()
973
+ view.stopScroll()
974
+ } else if (view is NativeScrollerViewport) {
975
+ view.stopMotion()
976
+ view.host.cancelPendingScrollRestore()
977
+ }
978
+ explicitTopCommand = null
979
+ pageRestoreRequests.remove(key)
980
+ pendingPageRestores.remove(key)
981
+ ownedScrollerRestoreRequests.remove(key)
982
+ suspendedBodyKeys.remove(key)
983
+ alignedBodyKeys.remove(key)
984
+ displayBodyOverrides.remove(key)
985
+ displayRecyclerAnchors.remove(key)
986
+ recyclerReadingAnchors.remove(key)
987
+ if (view is RecyclerView) deferredRecyclerGeometry.remove(view)
988
+ pageOffsets[key] = 0
989
+ val request = PagerTopRequest(view, key, selectedPage, attachmentGeneration, target, ++pagerTopToken)
990
+ pagerTopRequest = request
991
+ commandBodyKeys.add(key)
992
+ setHeaderOffset(if (target == 1) headerHeightPx else 0)
993
+ offsetDiag("pager-top-begin-target=$target-animated=$animated-token=${request.token}", view, key)
994
+ dispatchingPagerTop = true
995
+ try {
996
+ when (view) {
997
+ is RecyclerView -> {
998
+ val manager = view.layoutManager as? LinearLayoutManager
999
+ if (animated && !recyclerIsAtBodyTop(view)) view.smoothScrollToPosition(0)
1000
+ else if (manager != null && manager.orientation == RecyclerView.VERTICAL) {
1001
+ manager.scrollToPositionWithOffset(0, 0)
1002
+ if (view.width > 0 && view.height > 0 && !isInLayout && !view.isInLayout && !view.isComputingLayout) {
1003
+ view.measure(MeasureSpec.makeMeasureSpec(view.width, MeasureSpec.EXACTLY), MeasureSpec.makeMeasureSpec(view.height, MeasureSpec.EXACTLY))
1004
+ view.layout(view.left, view.top, view.right, view.bottom)
1005
+ }
1006
+ } else view.scrollToPosition(0)
1007
+ }
1008
+ is NativeScrollerViewport -> view.host.scrollToPosition(0, 0, animated)
1009
+ is ScrollView -> if (animated) view.smoothScrollTo(0, 0) else view.scrollTo(0, 0)
1010
+ }
1011
+ } finally { dispatchingPagerTop = false }
1012
+ finishPagerTopIfSettled()
1013
+ }
1014
+
1015
+ private fun hasExplicitTopCommand(view: View, key: String): Boolean {
1016
+ val command = explicitTopCommand ?: return false
1017
+ val request = command.second
1018
+ return view is RecyclerView && observedRecyclerView === view && observedRecyclerKey == key &&
1019
+ key == currentPageKey() && command.first == key && request.view.get() === view &&
1020
+ request.pageIndex == selectedPage && request.generation == attachmentGeneration
1021
+ }
1022
+
1023
+ private fun publishScrollCommandListener(recycler: RecyclerView, remove: Boolean = false) {
1024
+ val tag = R.id.onekey_native_scroll_coordinator_command_listener
1025
+ @Suppress("UNCHECKED_CAST")
1026
+ val existing = recycler.getTag(tag) as? MutableMap<Any, BiConsumer<Boolean, Int>>
1027
+ val listeners = existing ?: if (remove) return else mutableMapOf<Any, BiConsumer<Boolean, Int>>().also { recycler.setTag(tag, it) }
1028
+ if (remove) listeners.remove(this)
1029
+ else {
1030
+ val generation = attachmentGeneration
1031
+ val key = currentPageKey()
1032
+ listeners[this] = BiConsumer { explicitTop, phase ->
1033
+ if (generation == attachmentGeneration && key == currentPageKey()) acceptObservedScrollCommand(recycler, explicitTop, phase)
1034
+ }
1035
+ }
1036
+ }
1037
+
522
1038
  private fun maximumHeaderOffset(): Int {
1039
+ if (nativeSmoothHeaderScrollEnabled) return headerHeightPx
523
1040
  val scrollable = observedRecyclerView
524
1041
  ?: observedOwnedScroller
525
1042
  ?: observedNativeScroller
@@ -535,11 +1052,105 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
535
1052
  return (contentHeight.roundToInt() - height).coerceIn(0, headerHeightPx)
536
1053
  }
537
1054
 
1055
+ internal fun headerStatePageKey(): String = currentPageKey()
1056
+
1057
+ private fun publishHeaderSettledState(settled: Boolean, identityChanged: Boolean = false) {
1058
+ if (headerSettledAtTop == settled && !identityChanged) return
1059
+ headerSettledAtTop = settled
1060
+ log("header-settled-state page=$selectedPage generation=$attachmentGeneration settled=$settled")
1061
+ onHeaderSettledStateChanged?.invoke()
1062
+ }
1063
+
1064
+ private fun cancelHeaderSettledCheck() {
1065
+ headerSettledCheck?.let(::removeCallbacks)
1066
+ headerSettledCheck = null
1067
+ }
1068
+
1069
+ private fun updateHeaderSettledState(forceBusy: Boolean = false) {
1070
+ val owner = observedRecyclerView ?: observedOwnedScroller ?: observedNativeScroller
1071
+ val ownerKey = observedRecyclerKey ?: observedOwnedScrollerKey ?: observedNativeScrollerKey
1072
+ val valid = isAttachedToWindow && width > 0 && height > 0 && headerHeightPx > 0 &&
1073
+ headerView?.isAttachedToWindow == true && owner != null && owner.isAttachedToWindow &&
1074
+ owner.width > 0 && owner.height > 0 && ownerKey == currentPageKey() &&
1075
+ selectedPage in 0 until adapter.itemCount && isDescendant(owner, adapter.pageAt(selectedPage))
1076
+ val identityChanged = headerSettledOwner?.get() !== owner || headerSettledKey != currentPageKey() ||
1077
+ headerSettledGeneration != attachmentGeneration
1078
+ if (identityChanged) {
1079
+ cancelHeaderSettledCheck()
1080
+ headerCommandOwner = null
1081
+ headerSettledOwner = owner?.let(::WeakReference)
1082
+ headerSettledKey = currentPageKey()
1083
+ headerSettledGeneration = attachmentGeneration
1084
+ headerSettledGeometry = null
1085
+ headerSettledWasBusy = true
1086
+ publishHeaderSettledState(false, true)
1087
+ }
1088
+ if (!valid || owner == null || headerOffsetPx != 0) {
1089
+ cancelHeaderSettledCheck()
1090
+ headerSettledGeometry = null
1091
+ headerSettledWasBusy = true
1092
+ publishHeaderSettledState(false)
1093
+ return
1094
+ }
1095
+ val windowPoint = IntArray(2)
1096
+ owner.getLocationInWindow(windowPoint)
1097
+ val recycler = owner as? RecyclerView
1098
+ val manager = recycler?.layoutManager as? LinearLayoutManager
1099
+ val first = manager?.findFirstVisibleItemPosition() ?: -1
1100
+ val firstTop = manager?.let { layoutManager ->
1101
+ layoutManager.findViewByPosition(first)?.let(layoutManager::getDecoratedTop)
1102
+ } ?: 0
1103
+ val content = (owner as? ViewGroup)?.getChildAt(0)
1104
+ val geometry = listOf(headerOffsetPx, headerHeightPx, stickyHeaderHeightPx, width, height,
1105
+ owner.width, owner.height, owner.paddingTop, owner.paddingBottom, owner.scrollY,
1106
+ windowPoint[0], windowPoint[1], first, firstTop, recycler?.adapter?.itemCount ?: 0,
1107
+ content?.height ?: 0, headerView?.height ?: 0).joinToString(":")
1108
+ val busy = forceBusy || pager.isFakeDragging || pager.scrollState != ViewPager2.SCROLL_STATE_IDLE ||
1109
+ transitionHeaderOffsets != null || syntheticPageRestore || pagerTopRequest != null ||
1110
+ headerCommandOwner?.get() === owner ||
1111
+ nestedScrollingParentHelper.nestedScrollAxes and ViewCompat.SCROLL_AXIS_VERTICAL != 0 ||
1112
+ (recycler != null && (recycler.scrollState != RecyclerView.SCROLL_STATE_IDLE ||
1113
+ recycler.isComputingLayout || recycler.layoutManager?.isSmoothScrolling == true)) ||
1114
+ (owner is NativeScrollerViewport && owner.isReadingMotionActive())
1115
+ val changed = geometry != headerSettledGeometry
1116
+ val now = SystemClock.uptimeMillis()
1117
+ if (changed || busy || headerSettledWasBusy) {
1118
+ headerSettledSince = now
1119
+ headerSettledGeometry = geometry
1120
+ cancelHeaderSettledCheck()
1121
+ publishHeaderSettledState(false)
1122
+ }
1123
+ headerSettledWasBusy = busy
1124
+ if (!busy && now - headerSettledSince >= 150L) {
1125
+ cancelHeaderSettledCheck()
1126
+ publishHeaderSettledState(true)
1127
+ return
1128
+ }
1129
+ if (headerSettledCheck == null) {
1130
+ val weakOwner = WeakReference(owner)
1131
+ val key = currentPageKey()
1132
+ val generation = attachmentGeneration
1133
+ val check = Runnable {
1134
+ headerSettledCheck = null
1135
+ if (generation == attachmentGeneration && key == currentPageKey() && weakOwner.get() ===
1136
+ (observedRecyclerView ?: observedOwnedScroller ?: observedNativeScroller)) updateHeaderSettledState()
1137
+ }
1138
+ headerSettledCheck = check
1139
+ postDelayed(check, 150L)
1140
+ }
1141
+ }
1142
+
538
1143
  private fun setHeaderOffset(offset: Int) {
539
1144
  // val next = offset.coerceIn(0, headerHeightPx)
540
- val next = offset.coerceIn(0, maximumHeaderOffset())
1145
+ val pending = nativeSmoothHeaderScrollEnabled && currentPageKey() in pendingPageRestores
1146
+ val maximum = if (transitionHeaderOffsets != null || pending || pagerTopRequest != null) headerHeightPx else maximumHeaderOffset()
1147
+ val next = offset.coerceIn(0, maximum)
541
1148
  if (headerOffsetPx == next) return
542
1149
  headerOffsetPx = next
1150
+ updateHeaderSettledState(forceBusy = true)
1151
+ if (nativeSmoothHeaderScrollEnabled && transitionHeaderOffsets == null && !syntheticPageRestore && !pending) {
1152
+ pageHeaderOffsets[currentPageKey()] = next
1153
+ }
543
1154
  applyHeaderOffset()
544
1155
  }
545
1156
 
@@ -656,13 +1267,20 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
656
1267
  }
657
1268
 
658
1269
  private fun attachPrimaryScrollObserver() {
1270
+ if (transitionHeaderOffsets != null) return
659
1271
  if (selectedPage !in 0 until adapter.itemCount) return
660
1272
  val previous = observedRecyclerView ?: observedOwnedScroller ?: observedNativeScroller
661
1273
  if (observedPageRemoved || previous != null && !isDescendant(previous, adapter.pageAt(selectedPage))) {
1274
+ offsetDiag("primary-replacement-before-removed=$observedPageRemoved", previous)
662
1275
  observedPageRemoved = false
663
- detachPrimaryScrollObserver()
1276
+ detachPrimaryScrollObserver(recordOffset = !nativeSmoothHeaderScrollEnabled)
664
1277
  // A replacement scroller starts at the beginning while retaining header collapse.
665
- pageOffsets[currentPageKey()] = 0
1278
+ offsetDiag("primary-replacement-cache-before", previous)
1279
+ if (!nativeSmoothHeaderScrollEnabled) {
1280
+ offsetDiag("cache-write-replacement-new=0", previous)
1281
+ pageOffsets[currentPageKey()] = 0
1282
+ }
1283
+ offsetDiag("primary-replacement-cache-after", previous)
666
1284
  }
667
1285
  when (val scrollable = primaryScrollableForPage(selectedPage)) {
668
1286
  is RecyclerView -> attachRecyclerObserver(scrollable)
@@ -672,24 +1290,47 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
672
1290
  }
673
1291
 
674
1292
  private fun attachRecyclerObserver(recycler: RecyclerView) {
675
- if (observedRecyclerView === recycler) {
1293
+ if (observedRecyclerView === recycler && observedRecyclerKey == currentPageKey()) {
676
1294
  applyRecyclerInsets(recycler, selectedPage)
677
1295
  publishConsumedOffset(recycler)
678
1296
  return
679
1297
  }
680
1298
 
681
1299
  detachPrimaryScrollObserver()
1300
+ offsetDiag("recycler-attach-before", recycler)
682
1301
  observedRecyclerView = recycler
1302
+ observedRecyclerKey = currentPageKey()
1303
+ publishScrollCommandListener(recycler)
683
1304
  applyRecyclerInsets(recycler, selectedPage)
684
1305
  publishConsumedOffset(recycler)
1306
+ val key = currentPageKey()
685
1307
  val listener = object : RecyclerView.OnScrollListener() {
1308
+ override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
1309
+ if (observedRecyclerView === recyclerView) updateHeaderSettledState(forceBusy = newState != RecyclerView.SCROLL_STATE_IDLE)
1310
+ if (newState == RecyclerView.SCROLL_STATE_IDLE) finishPagerTopIfSettled()
1311
+ }
686
1312
  override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
1313
+ if (observedRecyclerView === recyclerView && (dx != 0 || dy != 0)) updateHeaderSettledState(forceBusy = true)
1314
+ if (observedScrollListener !== this || observedRecyclerKey != key ||
1315
+ key != currentPageKey() || !isAttachedToWindow ||
1316
+ transitionHeaderOffsets != null || syntheticPageRestore || key in pendingPageRestores || key in alignedBodyKeys) {
1317
+ if (offsetDiagEnabled() && (dx != 0 || dy != 0 || pageOffsets[key] != recyclerView.computeVerticalScrollOffset())) {
1318
+ offsetDiag("recycler-observer-skip-dx=$dx-dy=$dy-listenerCurrent=${observedScrollListener === this}", recyclerView, key)
1319
+ }
1320
+ return
1321
+ }
1322
+ if (nativeSmoothHeaderScrollEnabled && key !in commandBodyKeys &&
1323
+ (dy == 0 || recyclerView.scrollState == RecyclerView.SCROLL_STATE_IDLE)) return
687
1324
  val contentOffset = recyclerView.computeVerticalScrollOffset()
688
- pageOffsets[currentPageKey()] = contentOffset
1325
+ if (pageOffsets[key] != contentOffset) offsetDiag("cache-write-recycler-observer-new=$contentOffset-dx=$dx-dy=$dy", recyclerView, key)
1326
+ pageOffsets[key] = contentOffset
1327
+ if (nativeSmoothHeaderScrollEnabled) saveRecyclerReadingAnchor(key, recyclerView)
689
1328
  // Nested touch scrolling moves list content only after the header has
690
1329
  // collapsed. Programmatic list jumps bypass those parent callbacks.
691
- if (contentOffset > 0 && headerOffsetPx < headerHeightPx) {
1330
+ if (contentOffset > 0 && headerOffsetPx < headerHeightPx && !hasExplicitTopCommand(recyclerView, key) && !matchesPagerTop(recyclerView)) {
1331
+ offsetDiag("recycler-positive-collapse-before", recyclerView)
692
1332
  setHeaderOffset(headerHeightPx)
1333
+ offsetDiag("recycler-positive-collapse-after", recyclerView)
693
1334
  }
694
1335
  }
695
1336
  }
@@ -702,11 +1343,21 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
702
1343
  }
703
1344
 
704
1345
  private fun detachRecyclerObserver(recordOffset: Boolean) {
1346
+ cancelPagerTop()
1347
+ explicitTopCommand = null
705
1348
  val recycler = observedRecyclerView
706
1349
  val listener = observedScrollListener
707
1350
  if (recycler != null) {
708
- if (recordOffset) pageOffsets[currentPageKey()] = recycler.computeVerticalScrollOffset()
1351
+ offsetDiag("recycler-detach-save-before-record=$recordOffset", recycler, observedRecyclerKey ?: currentPageKey())
1352
+ if (recordOffset && !nativeSmoothHeaderScrollEnabled && transitionPageOffsets == null) observedRecyclerKey?.let {
1353
+ if (it !in pendingPageRestores) {
1354
+ offsetDiag("cache-write-recycler-detach-new=${recycler.computeVerticalScrollOffset()}", recycler, it)
1355
+ pageOffsets[it] = recycler.computeVerticalScrollOffset()
1356
+ }
1357
+ }
1358
+ offsetDiag("recycler-detach-save-after", recycler, observedRecyclerKey ?: currentPageKey())
709
1359
  publishConsumedOffset(recycler, remove = true)
1360
+ publishScrollCommandListener(recycler, remove = true)
710
1361
  }
711
1362
  if (recycler != null && listener != null) recycler.removeOnScrollListener(listener)
712
1363
  if (recycler != null) {
@@ -716,28 +1367,38 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
716
1367
  )
717
1368
  }
718
1369
  observedRecyclerView = null
1370
+ observedRecyclerKey = null
719
1371
  observedScrollListener = null
720
1372
  }
721
1373
 
722
1374
  private fun attachNativeScrollerObserver(scroller: ScrollView) {
723
- if (observedNativeScroller === scroller) {
1375
+ if (observedNativeScroller === scroller && observedNativeScrollerKey == currentPageKey()) {
724
1376
  applyNativeScrollerInsets(scroller, selectedPage)
725
1377
  return
726
1378
  }
727
1379
 
728
1380
  detachPrimaryScrollObserver()
1381
+ offsetDiag("scrollview-attach-before", scroller)
729
1382
  observedNativeScroller = scroller
1383
+ observedNativeScrollerKey = currentPageKey()
730
1384
  applyNativeScrollerInsets(scroller, selectedPage)
1385
+ val key = currentPageKey()
731
1386
  val listener = object : ViewTreeObserver.OnScrollChangedListener {
732
1387
  override fun onScrollChanged() {
733
1388
  if (observedNativeScrollerListener !== this) {
734
1389
  removeScrollChangedListener(null, scroller, this)
735
1390
  return
736
1391
  }
1392
+ if (key != currentPageKey() || !isAttachedToWindow ||
1393
+ transitionHeaderOffsets != null || syntheticPageRestore || key in pendingPageRestores || key in alignedBodyKeys) return
1394
+ if (nativeSmoothHeaderScrollEnabled && nestedScrollingParentHelper.nestedScrollAxes == 0 && key !in commandBodyKeys) return
737
1395
  val contentOffset = scroller.scrollY
738
- pageOffsets[currentPageKey()] = contentOffset
739
- if (contentOffset > 0 && headerOffsetPx < headerHeightPx) {
1396
+ if (pageOffsets[key] != contentOffset) offsetDiag("cache-write-scrollview-observer-new=$contentOffset", scroller, key)
1397
+ pageOffsets[key] = contentOffset
1398
+ if (contentOffset > 0 && headerOffsetPx < headerHeightPx && !matchesPagerTop(scroller)) {
1399
+ offsetDiag("scrollview-positive-collapse-before", scroller)
740
1400
  setHeaderOffset(headerHeightPx)
1401
+ offsetDiag("scrollview-positive-collapse-after", scroller)
741
1402
  }
742
1403
  }
743
1404
  }
@@ -752,9 +1413,17 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
752
1413
  }
753
1414
 
754
1415
  private fun detachNativeScrollerObserver(recordOffset: Boolean) {
1416
+ cancelPagerTop()
755
1417
  val scroller = observedNativeScroller
756
1418
  val listener = observedNativeScrollerListener
757
- if (scroller != null && recordOffset) pageOffsets[currentPageKey()] = scroller.scrollY
1419
+ offsetDiag("scrollview-detach-save-before-record=$recordOffset", scroller, observedNativeScrollerKey ?: currentPageKey())
1420
+ if (scroller != null && recordOffset && !nativeSmoothHeaderScrollEnabled && transitionPageOffsets == null) observedNativeScrollerKey?.let {
1421
+ if (it !in pendingPageRestores) {
1422
+ offsetDiag("cache-write-scrollview-detach-new=${scroller.scrollY}", scroller, it)
1423
+ pageOffsets[it] = scroller.scrollY
1424
+ }
1425
+ }
1426
+ offsetDiag("scrollview-detach-save-after", scroller, observedNativeScrollerKey ?: currentPageKey())
758
1427
  if (scroller != null && listener != null) {
759
1428
  removeScrollChangedListener(observedNativeScrollerTreeObserver, scroller, listener)
760
1429
  }
@@ -765,11 +1434,17 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
765
1434
  )
766
1435
  }
767
1436
  observedNativeScroller = null
1437
+ observedNativeScrollerKey = null
768
1438
  observedNativeScrollerListener = null
769
1439
  observedNativeScrollerTreeObserver = null
770
1440
  }
771
1441
 
772
1442
  private fun detachPrimaryScrollObserver(recordOffset: Boolean = true) {
1443
+ cancelHeaderSettledCheck()
1444
+ headerCommandOwner = null
1445
+ headerSettledGeometry = null
1446
+ headerSettledWasBusy = true
1447
+ publishHeaderSettledState(false)
773
1448
  detachOwnedScrollerObserver(recordOffset)
774
1449
  detachRecyclerObserver(recordOffset)
775
1450
  detachNativeScrollerObserver(recordOffset)
@@ -793,9 +1468,24 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
793
1468
  val key = pageKey(pageIndex)
794
1469
  if (ownedScrollerKeys[scroller] == key) return
795
1470
  ownedScrollerKeys[scroller] = key
1471
+ val restoreRequest = registerPageRestore(key, scroller, pageIndex)
1472
+ if (nativeSmoothHeaderScrollEnabled) ownedScrollerRestoreRequests[key] = WeakReference(scroller) to null
1473
+ val generation = attachmentGeneration
1474
+ offsetDiag("owned-enqueue", scroller, key, pageIndex, restoreRequest)
796
1475
  postForCurrentAttachment {
797
- if (ownedScrollerKeys[scroller] == key && scroller.isAttachedToWindow) {
798
- scroller.host.restoreScrollOffset(pageOffsets[key] ?: 0)
1476
+ offsetDiag("owned-execute", scroller, key, pageIndex, restoreRequest)
1477
+ if (isCurrentPageRestore(key, restoreRequest) && ownedScrollerKeys[scroller] == key && generation == attachmentGeneration &&
1478
+ scroller.isAttachedToWindow && (!nativeSmoothHeaderScrollEnabled ||
1479
+ (key in pendingPageRestores && ownedScrollerRestoreRequests[key]?.first?.get() === scroller))) {
1480
+ offsetDiag("owned-restore-before", scroller, key, pageIndex, restoreRequest)
1481
+ val saved = restorationBodyOffset(key)
1482
+ scroller.host.restoreScrollOffset(saved)
1483
+ if (nativeSmoothHeaderScrollEnabled) {
1484
+ ownedScrollerRestoreRequests[key] = WeakReference(scroller) to generation
1485
+ recordOwnedScrollerOffset(scroller, key)
1486
+ }
1487
+ offsetDiag("owned-restore-after-saved=$saved", scroller, key, pageIndex, restoreRequest)
1488
+ offsetDiagAfterDraw(scroller, key, pageIndex)
799
1489
  }
800
1490
  }
801
1491
  }
@@ -803,7 +1493,10 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
803
1493
  private fun restoreOwnedScrollerInsets(scroller: NativeScrollerViewport) {
804
1494
  scroller.stopMotion()
805
1495
  scroller.host.cancelPendingScrollRestore()
806
- ownedScrollerKeys.remove(scroller)
1496
+ ownedScrollerKeys.remove(scroller)?.let { key ->
1497
+ clearPageRestoreForView(key, scroller)
1498
+ if (ownedScrollerRestoreRequests[key]?.first?.get() === scroller) ownedScrollerRestoreRequests.remove(key)
1499
+ }
807
1500
  publishConsumedOffset(scroller, remove = true)
808
1501
  scroller.host.setPagerInset(this, null)
809
1502
  }
@@ -811,9 +1504,11 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
811
1504
  private fun attachOwnedScrollerObserver(scroller: NativeScrollerViewport) {
812
1505
  if (observedOwnedScroller === scroller && observedOwnedScrollerKey == currentPageKey()) {
813
1506
  applyOwnedScrollerInsets(scroller, selectedPage)
1507
+ if (nativeSmoothHeaderScrollEnabled) recordOwnedScrollerOffset(scroller, currentPageKey())
814
1508
  return
815
1509
  }
816
1510
  detachPrimaryScrollObserver()
1511
+ offsetDiag("owned-attach-before", scroller)
817
1512
  observedOwnedScroller = scroller
818
1513
  observedOwnedScrollerKey = currentPageKey()
819
1514
  val key = currentPageKey()
@@ -824,9 +1519,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
824
1519
  removeScrollChangedListener(null, scroller, this)
825
1520
  return
826
1521
  }
827
- val contentOffset = scroller.scrollY
828
- pageOffsets[key] = contentOffset
829
- if (contentOffset > 0 && headerOffsetPx < headerHeightPx) setHeaderOffset(headerHeightPx)
1522
+ recordOwnedScrollerOffset(scroller, key)
830
1523
  }
831
1524
  }
832
1525
  observedOwnedScrollerListener = listener
@@ -835,11 +1528,41 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
835
1528
  }
836
1529
  }
837
1530
 
1531
+ private fun recordOwnedScrollerOffset(scroller: NativeScrollerViewport, key: String) {
1532
+ if (ownedScrollerKeys[scroller] != key || !isAttachedToWindow || !scroller.isAttachedToWindow) return
1533
+ if (key in pendingPageRestores) {
1534
+ // A false native pending flag before our posted request executes is not cancellation.
1535
+ val request = ownedScrollerRestoreRequests[key]
1536
+ if (request == null || request.first.get() !== scroller || request.second != attachmentGeneration ||
1537
+ scroller.width <= 0 || scroller.height <= 0 || scroller.host.hasPendingScrollRestore()) return
1538
+ clearPageRestoreForView(key, scroller)
1539
+ }
1540
+ if (observedOwnedScroller !== scroller || observedOwnedScrollerKey != key ||
1541
+ key != currentPageKey() || transitionHeaderOffsets != null || syntheticPageRestore || key in alignedBodyKeys) return
1542
+ if (nativeSmoothHeaderScrollEnabled && !scroller.isReadingMotionActive() && key !in commandBodyKeys) return
1543
+ val contentOffset = scroller.scrollY
1544
+ if (pageOffsets[key] != contentOffset) offsetDiag("cache-write-owned-observer-new=$contentOffset", scroller, key)
1545
+ pageOffsets[key] = contentOffset
1546
+ if (contentOffset > 0 && headerOffsetPx < headerHeightPx && !matchesPagerTop(scroller)) {
1547
+ offsetDiag("owned-positive-collapse-before", scroller, key)
1548
+ setHeaderOffset(headerHeightPx)
1549
+ offsetDiag("owned-positive-collapse-after", scroller, key)
1550
+ }
1551
+ }
1552
+
838
1553
  private fun detachOwnedScrollerObserver(recordOffset: Boolean) {
1554
+ cancelPagerTop()
839
1555
  val scroller = observedOwnedScroller ?: return
840
1556
  scroller.stopMotion()
841
1557
  publishConsumedOffset(scroller, remove = true)
842
- if (recordOffset) observedOwnedScrollerKey?.let { pageOffsets[it] = scroller.scrollY }
1558
+ offsetDiag("owned-detach-save-before-record=$recordOffset", scroller, observedOwnedScrollerKey ?: currentPageKey())
1559
+ if (recordOffset && !nativeSmoothHeaderScrollEnabled && transitionPageOffsets == null) observedOwnedScrollerKey?.let {
1560
+ if (it !in pendingPageRestores) {
1561
+ offsetDiag("cache-write-owned-detach-new=${scroller.scrollY}", scroller, it)
1562
+ pageOffsets[it] = scroller.scrollY
1563
+ }
1564
+ }
1565
+ offsetDiag("owned-detach-save-after", scroller, observedOwnedScrollerKey ?: currentPageKey())
843
1566
  observedOwnedScrollerListener?.let {
844
1567
  removeScrollChangedListener(observedOwnedScrollerTreeObserver, scroller, it)
845
1568
  }
@@ -876,6 +1599,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
876
1599
  val anchorOffset = if (first != RecyclerView.NO_POSITION) {
877
1600
  manager?.findViewByPosition(first)?.let { manager.getDecoratedTop(it) - recycler.paddingTop }
878
1601
  } else null
1602
+ offsetDiag("recycler-inset-before-nextTop=$top-anchorPosition=$first-anchorOffset=$anchorOffset", recycler, pageKey(pageIndex), pageIndex)
879
1603
  recycler.setPadding(original.left, top, original.right, bottom)
880
1604
  if (anchorOffset != null) manager?.scrollToPositionWithOffset(first, anchorOffset)
881
1605
  // React Native can stop this nested requestLayout at its React-owned parent.
@@ -889,21 +1613,67 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
889
1613
  recycler.layout(recycler.left, recycler.top, recycler.right, recycler.bottom)
890
1614
  }
891
1615
  }
1616
+ if (original.appliedTopInset != topInset) offsetDiag("recycler-inset-after-old=${original.appliedTopInset}-new=$topInset", recycler, pageKey(pageIndex), pageIndex)
892
1617
  original.appliedTopInset = topInset
893
1618
  val key = pageKey(pageIndex)
1619
+ val geometry = Triple(recycler.computeVerticalScrollRange(), recycler.computeVerticalScrollExtent(), recycler.childCount)
1620
+ deferredRecyclerGeometry[recycler]?.let { deferred ->
1621
+ if (geometry != deferred) {
1622
+ restoredRecyclerKeys.remove(recycler)
1623
+ deferredRecyclerGeometry.remove(recycler)
1624
+ if (nativeSmoothHeaderScrollEnabled && headerOffsetPx == headerHeightPx) {
1625
+ displayBodyOverrides.remove(key)
1626
+ displayRecyclerAnchors.remove(key)
1627
+ }
1628
+ }
1629
+ }
894
1630
  if (restoredRecyclerKeys[recycler] != key) {
895
1631
  restoredRecyclerKeys[recycler] = key
1632
+ val restoreRequest = registerPageRestore(key, recycler, pageIndex)
896
1633
  val generation = attachmentGeneration
1634
+ offsetDiag("recycler-enqueue", recycler, key, pageIndex, restoreRequest)
1635
+ if (nativeSmoothHeaderScrollEnabled && recycler.height > 0 && recycler.childCount > 0) {
1636
+ // Consume an available reading anchor before this pre-draw reaches the first frame.
1637
+ restoreRecyclerAnchor(recycler, key, pageIndex, restoreRequest)
1638
+ }
897
1639
  recycler.post {
1640
+ offsetDiag("recycler-execute", recycler, key, pageIndex, restoreRequest)
1641
+ if (!isCurrentPageRestore(key, restoreRequest)) {
1642
+ offsetDiag("recycler-post-skip-cancelled", recycler, key, pageIndex, restoreRequest)
1643
+ return@post
1644
+ }
898
1645
  if (!isAttachedToWindow || generation != attachmentGeneration ||
899
1646
  restoredRecyclerKeys[recycler] != key ||
900
1647
  originalRecyclerPadding[recycler] !== original) {
901
1648
  if (restoredRecyclerKeys[recycler] == key) restoredRecyclerKeys.remove(recycler)
1649
+ clearPageRestoreForView(key, recycler, restoreRequest)
902
1650
  return@post
903
1651
  }
904
- val saved = pageOffsets[key] ?: 0
1652
+ val saved = restorationBodyOffset(key)
905
1653
  val current = recycler.computeVerticalScrollOffset()
906
- if (saved != current) recycler.scrollBy(0, saved - current)
1654
+ offsetDiag("recycler-restore-before-saved=$saved-current=${current}-requestedDelta=${saved - current}", recycler, key, pageIndex, restoreRequest)
1655
+ if (nativeSmoothHeaderScrollEnabled && (recycler.height <= 0 || recycler.childCount == 0)) {
1656
+ restoredRecyclerKeys.remove(recycler)
1657
+ if (pageRestoreRequests[key] === restoreRequest) pageRestoreRequests.remove(key)
1658
+ return@post
1659
+ }
1660
+ if (nativeSmoothHeaderScrollEnabled) {
1661
+ if (!restoreRecyclerAnchor(recycler, key, pageIndex, restoreRequest)) {
1662
+ if (isInLayout || recycler.isInLayout || recycler.isComputingLayout) {
1663
+ restoredRecyclerKeys.remove(recycler)
1664
+ if (pageRestoreRequests[key] === restoreRequest) pageRestoreRequests.remove(key)
1665
+ return@post
1666
+ }
1667
+ deferredRecyclerGeometry[recycler] = Triple(recycler.computeVerticalScrollRange(), recycler.computeVerticalScrollExtent(), recycler.childCount)
1668
+ offsetDiag("recycler-anchor-pending-saved=$saved", recycler, key, pageIndex, restoreRequest)
1669
+ return@post
1670
+ }
1671
+ } else if (saved != current) recycler.scrollBy(0, saved - current)
1672
+ deferredRecyclerGeometry.remove(recycler)
1673
+ if (nativeSmoothHeaderScrollEnabled) clearPageRestoreForView(key, recycler, restoreRequest)
1674
+ else pendingPageRestores.remove(key)
1675
+ offsetDiag("recycler-restore-after-saved=$saved", recycler, key, pageIndex, restoreRequest)
1676
+ offsetDiagAfterDraw(recycler, key, pageIndex)
907
1677
  }
908
1678
  }
909
1679
  }
@@ -949,16 +1719,35 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
949
1719
  val key = pageKey(pageIndex)
950
1720
  if (restoredScrollerKeys[scroller] != key) {
951
1721
  restoredScrollerKeys[scroller] = key
1722
+ val restoreRequest = registerPageRestore(key, scroller, pageIndex)
952
1723
  val generation = attachmentGeneration
1724
+ offsetDiag("scrollview-enqueue", scroller, key, pageIndex, restoreRequest)
953
1725
  scroller.post {
1726
+ offsetDiag("scrollview-execute", scroller, key, pageIndex, restoreRequest)
1727
+ if (!isCurrentPageRestore(key, restoreRequest)) {
1728
+ offsetDiag("scrollview-post-skip-cancelled", scroller, key, pageIndex, restoreRequest)
1729
+ return@post
1730
+ }
954
1731
  if (!isAttachedToWindow || generation != attachmentGeneration ||
955
1732
  restoredScrollerKeys[scroller] != key ||
956
1733
  originalScrollerPadding[scroller] !== original) {
957
1734
  if (restoredScrollerKeys[scroller] == key) restoredScrollerKeys.remove(scroller)
1735
+ clearPageRestoreForView(key, scroller, restoreRequest)
1736
+ return@post
1737
+ }
1738
+ val saved = restorationBodyOffset(key)
1739
+ offsetDiag("scrollview-restore-before-saved=$saved-current=${scroller.scrollY}-requestedDelta=${saved - scroller.scrollY}", scroller, key, pageIndex, restoreRequest)
1740
+ if (nativeSmoothHeaderScrollEnabled &&
1741
+ (scroller.height <= 0 || (scroller.getChildAt(0)?.height ?: 0) <= 0)) {
1742
+ restoredScrollerKeys.remove(scroller)
1743
+ if (pageRestoreRequests[key] === restoreRequest) pageRestoreRequests.remove(key)
958
1744
  return@post
959
1745
  }
960
- val saved = pageOffsets[key] ?: 0
961
1746
  if (scroller.scrollY != saved) scroller.scrollTo(scroller.scrollX, saved)
1747
+ if (nativeSmoothHeaderScrollEnabled) clearPageRestoreForView(key, scroller, restoreRequest)
1748
+ else pendingPageRestores.remove(key)
1749
+ offsetDiag("scrollview-restore-after-saved=$saved", scroller, key, pageIndex, restoreRequest)
1750
+ offsetDiagAfterDraw(scroller, key, pageIndex)
962
1751
  }
963
1752
  }
964
1753
  }
@@ -1017,7 +1806,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
1017
1806
  publishConsumedOffset(recycler, remove = true)
1018
1807
  val original = originalRecyclerPadding.remove(recycler) ?: return
1019
1808
  updateRecyclerPaddingOwnership(recycler, original)
1020
- restoredRecyclerKeys.remove(recycler)
1809
+ restoredRecyclerKeys.remove(recycler)?.let { clearPageRestoreForView(it, recycler) }
1021
1810
  recycler.clipToPadding = original.clipToPadding
1022
1811
  recycler.setPadding(original.left, original.top, original.right, original.bottom)
1023
1812
  }
@@ -1025,7 +1814,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
1025
1814
  private fun restoreNativeScrollerInsets(scroller: ScrollView) {
1026
1815
  val original = originalScrollerPadding.remove(scroller) ?: return
1027
1816
  updateScrollerPaddingOwnership(scroller, original)
1028
- restoredScrollerKeys.remove(scroller)
1817
+ restoredScrollerKeys.remove(scroller)?.let { clearPageRestoreForView(it, scroller) }
1029
1818
  if (scroller is ReactScrollView) {
1030
1819
  val state = scroller.reactScrollViewScrollState
1031
1820
  scroller.setScrollAwayPaddingEnabledUnstable(
@@ -1399,11 +2188,22 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
1399
2188
  override fun onNestedScrollAccepted(child: View, target: View, axes: Int, type: Int) {
1400
2189
  if (type == ViewCompat.TYPE_TOUCH) pendingRefreshPull = null
1401
2190
  nestedScrollingParentHelper.onNestedScrollAccepted(child, target, axes, type)
2191
+ updateHeaderSettledState(forceBusy = true)
2192
+ if (type == ViewCompat.TYPE_TOUCH) {
2193
+ val request = pageRestoreRequests[currentPageKey()]
2194
+ offsetDiag("touch-cancel-before", target, request = request)
2195
+ observedRecyclerView?.let { deferredRecyclerGeometry.remove(it) }
2196
+ pageRestoreRequests.remove(currentPageKey())
2197
+ pendingPageRestores.remove(currentPageKey())
2198
+ ownedScrollerRestoreRequests.remove(currentPageKey())
2199
+ offsetDiag("touch-cancel-after", target, request = request)
2200
+ }
1402
2201
  }
1403
2202
 
1404
2203
  override fun onStopNestedScroll(target: View, type: Int) {
1405
2204
  if (pendingRefreshPull?.get() === target) pendingRefreshPull = null
1406
2205
  nestedScrollingParentHelper.onStopNestedScroll(target, type)
2206
+ updateHeaderSettledState()
1407
2207
  onHeaderOffsetChanged?.invoke()
1408
2208
  }
1409
2209
 
@@ -1415,6 +2215,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
1415
2215
  // val used = min(dy, headerHeightPx - headerOffsetPx)
1416
2216
  if (dy > 0 && headerOffsetPx < maximumOffset) {
1417
2217
  val used = min(dy, maximumOffset - headerOffsetPx)
2218
+ if (type == ViewCompat.TYPE_TOUCH && used != 0) takeOverBodyForVerticalConsumption()
1418
2219
  setHeaderOffset(headerOffsetPx + used)
1419
2220
  consumed[1] += used
1420
2221
  } else if (dy < 0 && headerOffsetPx > 0) {
@@ -1426,6 +2227,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
1426
2227
  }
1427
2228
  if (!canScrollUp) {
1428
2229
  val used = max(dy, -headerOffsetPx)
2230
+ if (type == ViewCompat.TYPE_TOUCH && used != 0) takeOverBodyForVerticalConsumption()
1429
2231
  setHeaderOffset(headerOffsetPx + used)
1430
2232
  consumed[1] += used
1431
2233
  }
@@ -1441,8 +2243,11 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
1441
2243
  type: Int,
1442
2244
  consumed: IntArray,
1443
2245
  ) {
1444
- if (!isCurrentPageTarget(target) || dyUnconsumed >= 0) return
2246
+ if (!isCurrentPageTarget(target)) return
2247
+ if (type == ViewCompat.TYPE_TOUCH && dyConsumed != 0) takeOverBodyForVerticalConsumption()
2248
+ if (dyUnconsumed >= 0) return
1445
2249
  val used = max(dyUnconsumed, -headerOffsetPx)
2250
+ if (type == ViewCompat.TYPE_TOUCH && used != 0) takeOverBodyForVerticalConsumption()
1446
2251
  setHeaderOffset(headerOffsetPx + used)
1447
2252
  consumed[1] += used
1448
2253
  if (type == ViewCompat.TYPE_TOUCH && dyUnconsumed < used &&
@@ -1492,6 +2297,13 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
1492
2297
  restoredRecyclerKeys.clear()
1493
2298
  restoredScrollerKeys.clear()
1494
2299
  viewTreeObserver.addOnPreDrawListener(pageContentLayoutListener)
2300
+ resumeTransitionSource?.let { source ->
2301
+ resumeTransitionSource = null
2302
+ if (source in 0 until adapter.itemCount) {
2303
+ pager.setCurrentItem(source, false)
2304
+ selectPage(source)
2305
+ }
2306
+ }
1495
2307
  log(
1496
2308
  "host-attach generation=$attachmentGeneration inner=$selectedPage " +
1497
2309
  "outer=${outerPagerIndex()} nativePages=${adapter.itemCount}",
@@ -1499,6 +2311,9 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
1499
2311
  }
1500
2312
 
1501
2313
  override fun onDetachedFromWindow() {
2314
+ cancelHeaderSettledCheck()
2315
+ headerCommandOwner = null
2316
+ publishHeaderSettledState(false)
1502
2317
  pendingRefreshPull = null
1503
2318
  releaseRefreshForeground()
1504
2319
  attachmentGeneration += 1
@@ -1511,6 +2326,26 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
1511
2326
  nestedNativeGestureStarted = false
1512
2327
  viewTreeObserver.removeOnPreDrawListener(pageContentLayoutListener)
1513
2328
  detachPrimaryScrollObserver()
2329
+ pageRestoreRequests.clear()
2330
+ pendingPageRestores.clear()
2331
+ commandBodyKeys.clear()
2332
+ explicitTopCommand = null
2333
+ suspendedBodyKeys.clear()
2334
+ alignedBodyKeys.clear()
2335
+ transitionAlignedViews.clear()
2336
+ deferredRecyclerGeometry.clear()
2337
+ displayBodyOverrides.clear()
2338
+ displayRecyclerAnchors.clear()
2339
+ ownedScrollerRestoreRequests.clear()
2340
+ transitionHeaderOffsets?.let { headers ->
2341
+ resumeTransitionSource = transitionSourcePage
2342
+ syntheticPageRestore = true
2343
+ setHeaderOffset(headers[pageKey(transitionSourcePage)] ?: 0)
2344
+ syntheticPageRestore = false
2345
+ }
2346
+ transitionHeaderOffsets = null
2347
+ transitionPageOffsets = null
2348
+ transitionTargetPage = null
1514
2349
  // Snapshot live keys before restoration removes their padding records.
1515
2350
  for (recycler in originalRecyclerPadding.keys.toTypedArray()) {
1516
2351
  restoreRecyclerInsets(recycler)