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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -123,6 +123,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
123
123
  private val restoredRecyclerKeys = WeakHashMap<RecyclerView, String>()
124
124
  private val originalScrollerPadding = WeakHashMap<ScrollView, ScrollerPadding>()
125
125
  private val restoredScrollerKeys = WeakHashMap<ScrollView, String>()
126
+ private val deferredScrollerGeometry = WeakHashMap<ScrollView, Triple<Int, Int, Int>>()
126
127
  private val ownedScrollerKeys = WeakHashMap<NativeScrollerViewport, String>()
127
128
  private val ownedScrollerRestoreRequests = HashMap<String, Pair<WeakReference<NativeScrollerViewport>, Int?>>()
128
129
  private var foregroundRefreshIndicator: WeakReference<ImageView>? = null
@@ -204,6 +205,18 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
204
205
  private var observedNativeScrollerTreeObserver: ViewTreeObserver? = null
205
206
  private var observedPageRemoved = false
206
207
  private var attachmentGeneration = 0
208
+ internal var headerSettledAtTop = false
209
+ private set
210
+ var onHeaderSettledStateChanged: (() -> Unit)? = null
211
+ private var headerSettledOwner: WeakReference<View>? = null
212
+ private var headerSettledKey: String? = null
213
+ private var headerSettledGeneration = -1
214
+ private var headerSettledGeometry: String? = null
215
+ private var headerSettledSince = 0L
216
+ private var headerSettledWasBusy = true
217
+ private var headerSettledCheck: Runnable? = null
218
+ private var headerCommandOwner: WeakReference<View>? = null
219
+
207
220
  private val touchSlopPx = ViewConfiguration.get(context).scaledTouchSlop
208
221
  private val hostIdentity = System.identityHashCode(this)
209
222
  private var headerView: View? = null
@@ -228,6 +241,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
228
241
  finishPagerTopIfSettled()
229
242
  // OneKey patch: a filtered short page can reduce the existing collapse range.
230
243
  setHeaderOffset(headerOffsetPx)
244
+ updateHeaderSettledState()
231
245
  if (offsetDiagEnabled() && offsetDiagNextDraw.isNotEmpty()) {
232
246
  val samples = offsetDiagNextDraw.entries.map { it.key to it.value }
233
247
  offsetDiagNextDraw.clear()
@@ -500,6 +514,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
500
514
  alignedBodyKeys.clear()
501
515
  transitionAlignedViews.clear()
502
516
  deferredRecyclerGeometry.clear()
517
+ deferredScrollerGeometry.clear()
503
518
  displayBodyOverrides.clear()
504
519
  displayRecyclerAnchors.clear()
505
520
  ownedScrollerRestoreRequests.clear()
@@ -554,6 +569,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
554
569
  }
555
570
 
556
571
  internal fun onPagerScrollStateChanged(state: Int) {
572
+ updateHeaderSettledState(forceBusy = state != ViewPager2.SCROLL_STATE_IDLE)
557
573
  if (state == ViewPager2.SCROLL_STATE_DRAGGING || state == ViewPager2.SCROLL_STATE_SETTLING) {
558
574
  beginPageTransition()
559
575
  } else if (state == ViewPager2.SCROLL_STATE_IDLE && transitionHeaderOffsets != null) {
@@ -855,6 +871,14 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
855
871
  if (phase == 0 && view === (observedRecyclerView ?: observedOwnedScroller ?: observedNativeScroller)) cancelPagerTop()
856
872
  if (phase != 0 && matchesPagerTop(view)) { finishPagerTopIfSettled(); return }
857
873
  val owner = observedRecyclerView ?: observedOwnedScroller ?: observedNativeScroller
874
+ val stateOwner = observedRecyclerView ?: observedOwnedScroller ?: observedNativeScroller
875
+ val stateKey = observedRecyclerKey ?: observedOwnedScrollerKey ?: observedNativeScrollerKey
876
+ if (view === stateOwner && stateKey == currentPageKey() && view.isAttachedToWindow && isAttachedToWindow && transitionHeaderOffsets == null && !syntheticPageRestore) {
877
+ updateHeaderSettledState()
878
+ if (phase == 1) headerCommandOwner = WeakReference(view)
879
+ else if (phase == 0 || phase == 2) headerCommandOwner = null
880
+ updateHeaderSettledState(forceBusy = phase != 2)
881
+ }
858
882
  if (!nativeSmoothHeaderScrollEnabled || view !== owner || transitionHeaderOffsets != null || syntheticPageRestore ||
859
883
  !isAttachedToWindow || !view.isAttachedToWindow) return
860
884
  val key = currentPageKey()
@@ -885,6 +909,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
885
909
  pendingPageRestores.remove(key)
886
910
  ownedScrollerRestoreRequests.remove(key)
887
911
  if (view is RecyclerView) deferredRecyclerGeometry.remove(view)
912
+ if (view is ScrollView) deferredScrollerGeometry.remove(view)
888
913
  (view as? NativeScrollerViewport)?.host?.cancelPendingScrollRestore()
889
914
  suspendedBodyKeys.remove(key)
890
915
  alignedBodyKeys.remove(key)
@@ -963,6 +988,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
963
988
  displayRecyclerAnchors.remove(key)
964
989
  recyclerReadingAnchors.remove(key)
965
990
  if (view is RecyclerView) deferredRecyclerGeometry.remove(view)
991
+ if (view is ScrollView) deferredScrollerGeometry.remove(view)
966
992
  pageOffsets[key] = 0
967
993
  val request = PagerTopRequest(view, key, selectedPage, attachmentGeneration, target, ++pagerTopToken)
968
994
  pagerTopRequest = request
@@ -1030,6 +1056,94 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
1030
1056
  return (contentHeight.roundToInt() - height).coerceIn(0, headerHeightPx)
1031
1057
  }
1032
1058
 
1059
+ internal fun headerStatePageKey(): String = currentPageKey()
1060
+
1061
+ private fun publishHeaderSettledState(settled: Boolean, identityChanged: Boolean = false) {
1062
+ if (headerSettledAtTop == settled && !identityChanged) return
1063
+ headerSettledAtTop = settled
1064
+ log("header-settled-state page=$selectedPage generation=$attachmentGeneration settled=$settled")
1065
+ onHeaderSettledStateChanged?.invoke()
1066
+ }
1067
+
1068
+ private fun cancelHeaderSettledCheck() {
1069
+ headerSettledCheck?.let(::removeCallbacks)
1070
+ headerSettledCheck = null
1071
+ }
1072
+
1073
+ private fun updateHeaderSettledState(forceBusy: Boolean = false) {
1074
+ val owner = observedRecyclerView ?: observedOwnedScroller ?: observedNativeScroller
1075
+ val ownerKey = observedRecyclerKey ?: observedOwnedScrollerKey ?: observedNativeScrollerKey
1076
+ val valid = isAttachedToWindow && width > 0 && height > 0 && headerHeightPx > 0 &&
1077
+ headerView?.isAttachedToWindow == true && owner != null && owner.isAttachedToWindow &&
1078
+ owner.width > 0 && owner.height > 0 && ownerKey == currentPageKey() &&
1079
+ selectedPage in 0 until adapter.itemCount && isDescendant(owner, adapter.pageAt(selectedPage))
1080
+ val identityChanged = headerSettledOwner?.get() !== owner || headerSettledKey != currentPageKey() ||
1081
+ headerSettledGeneration != attachmentGeneration
1082
+ if (identityChanged) {
1083
+ cancelHeaderSettledCheck()
1084
+ headerCommandOwner = null
1085
+ headerSettledOwner = owner?.let(::WeakReference)
1086
+ headerSettledKey = currentPageKey()
1087
+ headerSettledGeneration = attachmentGeneration
1088
+ headerSettledGeometry = null
1089
+ headerSettledWasBusy = true
1090
+ publishHeaderSettledState(false, true)
1091
+ }
1092
+ if (!valid || owner == null || headerOffsetPx != 0) {
1093
+ cancelHeaderSettledCheck()
1094
+ headerSettledGeometry = null
1095
+ headerSettledWasBusy = true
1096
+ publishHeaderSettledState(false)
1097
+ return
1098
+ }
1099
+ val windowPoint = IntArray(2)
1100
+ owner.getLocationInWindow(windowPoint)
1101
+ val recycler = owner as? RecyclerView
1102
+ val manager = recycler?.layoutManager as? LinearLayoutManager
1103
+ val first = manager?.findFirstVisibleItemPosition() ?: -1
1104
+ val firstTop = manager?.let { layoutManager ->
1105
+ layoutManager.findViewByPosition(first)?.let(layoutManager::getDecoratedTop)
1106
+ } ?: 0
1107
+ val content = (owner as? ViewGroup)?.getChildAt(0)
1108
+ val geometry = listOf(headerOffsetPx, headerHeightPx, stickyHeaderHeightPx, width, height,
1109
+ owner.width, owner.height, owner.paddingTop, owner.paddingBottom, owner.scrollY,
1110
+ windowPoint[0], windowPoint[1], first, firstTop, recycler?.adapter?.itemCount ?: 0,
1111
+ content?.height ?: 0, headerView?.height ?: 0).joinToString(":")
1112
+ val busy = forceBusy || pager.isFakeDragging || pager.scrollState != ViewPager2.SCROLL_STATE_IDLE ||
1113
+ transitionHeaderOffsets != null || syntheticPageRestore || pagerTopRequest != null ||
1114
+ headerCommandOwner?.get() === owner ||
1115
+ nestedScrollingParentHelper.nestedScrollAxes and ViewCompat.SCROLL_AXIS_VERTICAL != 0 ||
1116
+ (recycler != null && (recycler.scrollState != RecyclerView.SCROLL_STATE_IDLE ||
1117
+ recycler.isComputingLayout || recycler.layoutManager?.isSmoothScrolling == true)) ||
1118
+ (owner is NativeScrollerViewport && owner.isReadingMotionActive())
1119
+ val changed = geometry != headerSettledGeometry
1120
+ val now = SystemClock.uptimeMillis()
1121
+ if (changed || busy || headerSettledWasBusy) {
1122
+ headerSettledSince = now
1123
+ headerSettledGeometry = geometry
1124
+ cancelHeaderSettledCheck()
1125
+ publishHeaderSettledState(false)
1126
+ }
1127
+ headerSettledWasBusy = busy
1128
+ if (!busy && now - headerSettledSince >= 150L) {
1129
+ cancelHeaderSettledCheck()
1130
+ publishHeaderSettledState(true)
1131
+ return
1132
+ }
1133
+ if (headerSettledCheck == null) {
1134
+ val weakOwner = WeakReference(owner)
1135
+ val key = currentPageKey()
1136
+ val generation = attachmentGeneration
1137
+ val check = Runnable {
1138
+ headerSettledCheck = null
1139
+ if (generation == attachmentGeneration && key == currentPageKey() && weakOwner.get() ===
1140
+ (observedRecyclerView ?: observedOwnedScroller ?: observedNativeScroller)) updateHeaderSettledState()
1141
+ }
1142
+ headerSettledCheck = check
1143
+ postDelayed(check, 150L)
1144
+ }
1145
+ }
1146
+
1033
1147
  private fun setHeaderOffset(offset: Int) {
1034
1148
  // val next = offset.coerceIn(0, headerHeightPx)
1035
1149
  val pending = nativeSmoothHeaderScrollEnabled && currentPageKey() in pendingPageRestores
@@ -1037,6 +1151,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
1037
1151
  val next = offset.coerceIn(0, maximum)
1038
1152
  if (headerOffsetPx == next) return
1039
1153
  headerOffsetPx = next
1154
+ updateHeaderSettledState(forceBusy = true)
1040
1155
  if (nativeSmoothHeaderScrollEnabled && transitionHeaderOffsets == null && !syntheticPageRestore && !pending) {
1041
1156
  pageHeaderOffsets[currentPageKey()] = next
1042
1157
  }
@@ -1195,9 +1310,11 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
1195
1310
  val key = currentPageKey()
1196
1311
  val listener = object : RecyclerView.OnScrollListener() {
1197
1312
  override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
1313
+ if (observedRecyclerView === recyclerView) updateHeaderSettledState(forceBusy = newState != RecyclerView.SCROLL_STATE_IDLE)
1198
1314
  if (newState == RecyclerView.SCROLL_STATE_IDLE) finishPagerTopIfSettled()
1199
1315
  }
1200
1316
  override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
1317
+ if (observedRecyclerView === recyclerView && (dx != 0 || dy != 0)) updateHeaderSettledState(forceBusy = true)
1201
1318
  if (observedScrollListener !== this || observedRecyclerKey != key ||
1202
1319
  key != currentPageKey() || !isAttachedToWindow ||
1203
1320
  transitionHeaderOffsets != null || syntheticPageRestore || key in pendingPageRestores || key in alignedBodyKeys) {
@@ -1327,6 +1444,11 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
1327
1444
  }
1328
1445
 
1329
1446
  private fun detachPrimaryScrollObserver(recordOffset: Boolean = true) {
1447
+ cancelHeaderSettledCheck()
1448
+ headerCommandOwner = null
1449
+ headerSettledGeometry = null
1450
+ headerSettledWasBusy = true
1451
+ publishHeaderSettledState(false)
1330
1452
  detachOwnedScrollerObserver(recordOffset)
1331
1453
  detachRecyclerObserver(recordOffset)
1332
1454
  detachNativeScrollerObserver(recordOffset)
@@ -1599,6 +1721,17 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
1599
1721
  original.appliedTopInset = topInset
1600
1722
 
1601
1723
  val key = pageKey(pageIndex)
1724
+ val geometry = Triple(scroller.getChildAt(0)?.height ?: 0, scroller.height,
1725
+ scroller.paddingTop + scroller.paddingBottom)
1726
+ deferredScrollerGeometry[scroller]?.let { deferred ->
1727
+ if (geometry != deferred) {
1728
+ deferredScrollerGeometry.remove(scroller)
1729
+ restoredScrollerKeys.remove(scroller)
1730
+ if (nativeSmoothHeaderScrollEnabled && headerOffsetPx == headerHeightPx) {
1731
+ displayBodyOverrides.remove(key)
1732
+ }
1733
+ }
1734
+ }
1602
1735
  if (restoredScrollerKeys[scroller] != key) {
1603
1736
  restoredScrollerKeys[scroller] = key
1604
1737
  val restoreRequest = registerPageRestore(key, scroller, pageIndex)
@@ -1626,6 +1759,16 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
1626
1759
  return@post
1627
1760
  }
1628
1761
  if (scroller.scrollY != saved) scroller.scrollTo(scroller.scrollX, saved)
1762
+ val unreachableReadingIntent = nativeSmoothHeaderScrollEnabled &&
1763
+ headerOffsetPx == headerHeightPx && displayBodyOverrides.containsKey(key) &&
1764
+ (pageOffsets[key] ?: 0) > scroller.scrollY && !scroller.canScrollVertically(1)
1765
+ if (nativeSmoothHeaderScrollEnabled && (scroller.scrollY != saved || unreachableReadingIntent)) {
1766
+ deferredScrollerGeometry[scroller] = Triple(scroller.getChildAt(0)?.height ?: 0,
1767
+ scroller.height, scroller.paddingTop + scroller.paddingBottom)
1768
+ offsetDiag("scrollview-restore-pending-saved=$saved-current=${scroller.scrollY}", scroller, key, pageIndex, restoreRequest)
1769
+ return@post
1770
+ }
1771
+ deferredScrollerGeometry.remove(scroller)
1629
1772
  if (nativeSmoothHeaderScrollEnabled) clearPageRestoreForView(key, scroller, restoreRequest)
1630
1773
  else pendingPageRestores.remove(key)
1631
1774
  offsetDiag("scrollview-restore-after-saved=$saved", scroller, key, pageIndex, restoreRequest)
@@ -1695,6 +1838,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
1695
1838
 
1696
1839
  private fun restoreNativeScrollerInsets(scroller: ScrollView) {
1697
1840
  val original = originalScrollerPadding.remove(scroller) ?: return
1841
+ deferredScrollerGeometry.remove(scroller)
1698
1842
  updateScrollerPaddingOwnership(scroller, original)
1699
1843
  restoredScrollerKeys.remove(scroller)?.let { clearPageRestoreForView(it, scroller) }
1700
1844
  if (scroller is ReactScrollView) {
@@ -2070,10 +2214,12 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
2070
2214
  override fun onNestedScrollAccepted(child: View, target: View, axes: Int, type: Int) {
2071
2215
  if (type == ViewCompat.TYPE_TOUCH) pendingRefreshPull = null
2072
2216
  nestedScrollingParentHelper.onNestedScrollAccepted(child, target, axes, type)
2217
+ updateHeaderSettledState(forceBusy = true)
2073
2218
  if (type == ViewCompat.TYPE_TOUCH) {
2074
2219
  val request = pageRestoreRequests[currentPageKey()]
2075
2220
  offsetDiag("touch-cancel-before", target, request = request)
2076
2221
  observedRecyclerView?.let { deferredRecyclerGeometry.remove(it) }
2222
+ observedNativeScroller?.let { deferredScrollerGeometry.remove(it) }
2077
2223
  pageRestoreRequests.remove(currentPageKey())
2078
2224
  pendingPageRestores.remove(currentPageKey())
2079
2225
  ownedScrollerRestoreRequests.remove(currentPageKey())
@@ -2084,6 +2230,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
2084
2230
  override fun onStopNestedScroll(target: View, type: Int) {
2085
2231
  if (pendingRefreshPull?.get() === target) pendingRefreshPull = null
2086
2232
  nestedScrollingParentHelper.onStopNestedScroll(target, type)
2233
+ updateHeaderSettledState()
2087
2234
  onHeaderOffsetChanged?.invoke()
2088
2235
  }
2089
2236
 
@@ -2191,6 +2338,9 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
2191
2338
  }
2192
2339
 
2193
2340
  override fun onDetachedFromWindow() {
2341
+ cancelHeaderSettledCheck()
2342
+ headerCommandOwner = null
2343
+ publishHeaderSettledState(false)
2194
2344
  pendingRefreshPull = null
2195
2345
  releaseRefreshForeground()
2196
2346
  attachmentGeneration += 1
@@ -2211,6 +2361,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
2211
2361
  alignedBodyKeys.clear()
2212
2362
  transitionAlignedViews.clear()
2213
2363
  deferredRecyclerGeometry.clear()
2364
+ deferredScrollerGeometry.clear()
2214
2365
  displayBodyOverrides.clear()
2215
2366
  displayRecyclerAnchors.clear()
2216
2367
  ownedScrollerRestoreRequests.clear()
@@ -94,6 +94,9 @@ class CollapsiblePagerViewManager : ViewGroupManager<CollapsiblePagerHost>(),
94
94
  ),
95
95
  )
96
96
  }
97
+ host.onHeaderSettledStateChanged = {
98
+ dispatchDiagnostics(reactContext, host, "header-settled-state")
99
+ }
97
100
  host.onHeaderOffsetChanged = {
98
101
  if (!host.pager.isFakeDragging && host.pager.scrollState == ViewPager2.SCROLL_STATE_IDLE) {
99
102
  dispatchDiagnostics(reactContext, host, "vertical-idle")
@@ -127,6 +130,8 @@ class CollapsiblePagerViewManager : ViewGroupManager<CollapsiblePagerHost>(),
127
130
  host.observedScrollableCount(),
128
131
  host.retainedPages,
129
132
  reason,
133
+ host.headerStatePageKey(),
134
+ host.headerSettledAtTop,
130
135
  ),
131
136
  )
132
137
  }
@@ -14,6 +14,8 @@ class CollapsibleStateChangedEvent(
14
14
  private val observedScrollableCount: Int,
15
15
  private val retainedPages: String,
16
16
  private val reason: String,
17
+ private val pageKey: String,
18
+ private val headerSettledAtTop: Boolean,
17
19
  ) : Event<CollapsibleStateChangedEvent>(viewTag) {
18
20
  override fun getEventName() = EVENT_NAME
19
21
 
@@ -31,6 +33,8 @@ class CollapsibleStateChangedEvent(
31
33
  putInt("observedScrollableCount", observedScrollableCount)
32
34
  putString("retainedPages", retainedPages)
33
35
  putString("reason", reason)
36
+ putString("pageKey", pageKey)
37
+ putBoolean("headerSettledAtTop", headerSettledAtTop)
34
38
  }
35
39
 
36
40
  companion object {
package/docs/SPEC.md CHANGED
@@ -38,6 +38,12 @@ Late callbacks cannot revive cancelled restoration or erase a newer request.
38
38
  Observers and commands must match page key, view identity and generation.
39
39
  Removed or recycled owners cannot apply callbacks to another page; recycling
40
40
  resets owned memory.
41
+ The Web backend shares C and stores per-page B separately from absolute DOM
42
+ scrollTop. Selection aligns to C while expanded and C + B while fully collapsed;
43
+ programmatic alignment and geometry-only scrollend events MUST NOT erase the
44
+ suspended reading position. Real vertical input or a valid List command takes
45
+ over the current display. PageTop/StickyTop clear only the current B.
46
+
41
47
  Legacy mode and pagers without a collapsible header retain their existing
42
48
  behavior. Vertical scrolling, refresh, snapping and repeated-current-tab behavior
43
49
  are unchanged. This state belongs to native UI instances, with no new React prop
@@ -717,3 +723,12 @@ cases additionally verified recorded refresh, header, restoration and gesture
717
723
  paths; they do not establish every possible sequence or runtime acceptance of a
718
724
  newly installed module release. Physical-device feel and semantic accessibility
719
725
  activation remain unverified.
726
+
727
+
728
+ ### Native Header stability snapshot
729
+
730
+ `onCollapsibleStateChanged` retains its existing diagnostic fields and reasons and adds native-only `pageKey: string` and `headerSettledAtTop: boolean`. The key is the actual mounted React child key, not an application tab identifier. Unattached, unknown, replaced or transitioning owners MUST report false. A mounted current owner emits an initial identity snapshot without requiring a first user scroll.
731
+
732
+ The Header is settled only when C is at its expanded top, the current attached view/key/index/generation still matches, and there is no actual drag, deceleration, scrolling command or horizontal transition. Offset/inset/content/header geometry MUST remain stable for 150ms before true is emitted. Motion or geometry changes revoke true promptly. Deferred reading memory alone MUST NOT be treated as active motion. Pull-to-refresh may settle at a normalized negative offset after drag, bounce and inset geometry stop; network refreshing alone is not a motion gate. Checks stop for collapsed, detached and recycled owners; delayed callbacks revalidate their captured identity. State changes are deduplicated; identity changes still publish an unknown/false snapshot.
733
+
734
+ The native Home Spotlight adapter consumes this snapshot in the main UI runtime and keeps unknown states paused. It MUST use the mounted page-key mapping and reject old page/source callbacks. Native snapshots already include the 150ms stabilization delay: consumers MUST NOT add another such delay. Legacy Tabs keep their existing normalized-offset and debounce behavior. Anchor measurement, guide qualification and the existing display delay remain independent gates.
@@ -1517,6 +1517,16 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
1517
1517
  #endif
1518
1518
  NSInteger _transitionSourceIndex;
1519
1519
  BOOL _syntheticPageRestore;
1520
+ BOOL _headerSettledAtTop;
1521
+ __weak UIScrollView *_headerSettledOwner;
1522
+ NSString *_headerSettledKey;
1523
+ NSUInteger _headerSettledGeneration;
1524
+ NSString *_headerSettledGeometry;
1525
+ CFTimeInterval _headerSettledSince;
1526
+ BOOL _headerSettledWasBusy;
1527
+ NSTimer *_headerSettledTimer;
1528
+ __weak UIScrollView *_headerCommandOwner;
1529
+
1520
1530
  BOOL _finishingPageStateTransition;
1521
1531
  NSMutableSet<NSString *> *_pendingPageRestoreKeys;
1522
1532
  __weak UIScrollView *_sharedHeaderScrollView;
@@ -1677,6 +1687,7 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
1677
1687
  {
1678
1688
  [super didMoveToWindow];
1679
1689
  [self updateSharedHeaderPressCancellationGesture];
1690
+ [self updateHeaderSettledStateForceBusy:NO];
1680
1691
  for (UIScrollView *scrollView in _originalInsets.keyEnumerator) {
1681
1692
  [self updateNativeScrollerViewport:scrollView stickyHeight:self.window == nil ? 0 : _stickyHeaderHeight];
1682
1693
  }
@@ -1703,6 +1714,7 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
1703
1714
 
1704
1715
  - (void)dealloc
1705
1716
  {
1717
+ [self cancelHeaderSettledCheck];
1706
1718
  [[NSNotificationCenter defaultCenter] removeObserver:self name:@"OneKeyNativeListScrollCommandAccepted" object:nil];
1707
1719
  for (UIScrollView *scrollView in _originalInsets.keyEnumerator) {
1708
1720
  [self updateNativeScrollerViewport:scrollView stickyHeight:0];
@@ -2363,6 +2375,7 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
2363
2375
  }
2364
2376
  }
2365
2377
  [self layoutSharedHeaders];
2378
+ [self updateHeaderSettledStateForceBusy:NO];
2366
2379
  dispatch_async(dispatch_get_main_queue(), ^{
2367
2380
  if (!self->_isBeingRecycled) {
2368
2381
  [self prepareAdjacentPageInsets];
@@ -2373,6 +2386,8 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
2373
2386
 
2374
2387
  - (void)prepareForRecycle
2375
2388
  {
2389
+ [self cancelHeaderSettledCheck];
2390
+ [self publishHeaderSettledAtTop:NO identityChanged:NO];
2376
2391
  _isBeingRecycled = YES;
2377
2392
  _generation++;
2378
2393
  [_mountingRemovedScrollViews removeAllObjects];
@@ -2896,10 +2911,16 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
2896
2911
  _currentLogicalOffset = candidate.contentOffset.y + candidate.contentInset.top;
2897
2912
  [self attachSharedHeadersToScrollView:candidate];
2898
2913
  [self updateSharedHeaderPressCancellationGesture];
2914
+ [self updateHeaderSettledStateForceBusy:NO];
2899
2915
  }
2900
2916
 
2901
2917
  - (void)detachScrollObserver
2902
2918
  {
2919
+ [self cancelHeaderSettledCheck];
2920
+ _headerCommandOwner = nil;
2921
+ _headerSettledGeometry = nil;
2922
+ _headerSettledWasBusy = YES;
2923
+ [self publishHeaderSettledAtTop:NO identityChanged:NO];
2903
2924
  [self cancelPagerTop];
2904
2925
  UIScrollView *scrollView = _observedScrollView;
2905
2926
  if (scrollView != nil) {
@@ -3117,6 +3138,10 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
3117
3138
 
3118
3139
  - (void)observedListPanChanged:(UIPanGestureRecognizer *)recognizer
3119
3140
  {
3141
+ if (recognizer == _observedScrollView.panGestureRecognizer) {
3142
+ [self updateHeaderSettledStateForceBusy:
3143
+ recognizer.state == UIGestureRecognizerStateBegan || recognizer.state == UIGestureRecognizerStateChanged];
3144
+ }
3120
3145
  if (!_nativeSmoothHeaderScrollEnabled || recognizer != _observedScrollView.panGestureRecognizer) {
3121
3146
  return;
3122
3147
  }
@@ -3454,6 +3479,10 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
3454
3479
  _observedPageIndex != _currentIndex || _observedPageGeneration != _generation ||
3455
3480
  _transitioning || _isPagerDragging || _transitionPageOffsets != nil || _syntheticPageRestore) return;
3456
3481
  NSInteger phase = [notification.userInfo[@"phase"] integerValue];
3482
+ [self updateHeaderSettledStateForceBusy:NO];
3483
+ if (phase == 1) _headerCommandOwner = scrollView;
3484
+ else if (phase == 0 || phase == 2) _headerCommandOwner = nil;
3485
+ [self updateHeaderSettledStateForceBusy:phase != 2];
3457
3486
  if ([self matchesPagerTop:scrollView]) {
3458
3487
  if (phase == 0) [self cancelPagerTop];
3459
3488
  else return;
@@ -3569,6 +3598,9 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
3569
3598
  return;
3570
3599
  }
3571
3600
  if (context == RNCCollapsiblePagerContentOffsetContext && object == _observedScrollView) {
3601
+ if (![change[NSKeyValueChangeOldKey] isEqual:change[NSKeyValueChangeNewKey]]) {
3602
+ [self updateHeaderSettledStateForceBusy:YES];
3603
+ }
3572
3604
  if ((_nativeSmoothHeaderScrollEnabled || [_alignedBodyKeys containsObject:_observedPageKey]) &&
3573
3605
  (_observedPageGeneration != _generation || _observedPageKey == nil ||
3574
3606
  _observedPageIndex != _currentIndex || ![_observedPageKey isEqualToString:self.currentPageKey] ||
@@ -3668,6 +3700,7 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
3668
3700
 
3669
3701
  - (void)applyHeaderOffset
3670
3702
  {
3703
+ [self updateHeaderSettledStateForceBusy:NO];
3671
3704
  BOOL attachedToList = _nativeSmoothHeaderScrollEnabled &&
3672
3705
  _sharedHeaderScrollView != nil && !_sharedHeadersLiftedForPagerTransition;
3673
3706
  if (attachedToList) {
@@ -3884,6 +3917,7 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
3884
3917
 
3885
3918
  - (void)scrollViewWillBeginDragging:(UIScrollView *)scrollView
3886
3919
  {
3920
+ [self updateHeaderSettledStateForceBusy:YES];
3887
3921
  [self offsetDiag:@"pager-drag-begin" page:_currentIndex scroll:_observedScrollView];
3888
3922
  if (_nativeSmoothHeaderScrollEnabled && scrollView == _pagerScrollView) {
3889
3923
  [_contentPressCancellationGesture cancelPressForPan:scrollView.panGestureRecognizer];
@@ -4081,6 +4115,93 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
4081
4115
  if (emitter) emitter->onPageSelected({.position = (double)position});
4082
4116
  }
4083
4117
 
4118
+ - (void)publishHeaderSettledAtTop:(BOOL)settled identityChanged:(BOOL)identityChanged
4119
+ {
4120
+ if (_headerSettledAtTop == settled && !identityChanged) return;
4121
+ _headerSettledAtTop = settled;
4122
+ RNCCollapsiblePagerLog([NSString stringWithFormat:@"header-settled-state page=%ld generation=%lu settled=%d",
4123
+ (long)_currentIndex, (unsigned long)_generation, settled]);
4124
+ [self emitDiagnostics:@"header-settled-state"];
4125
+ }
4126
+
4127
+ - (void)cancelHeaderSettledCheck
4128
+ {
4129
+ [_headerSettledTimer invalidate];
4130
+ _headerSettledTimer = nil;
4131
+ }
4132
+
4133
+ - (void)updateHeaderSettledStateForceBusy:(BOOL)forceBusy
4134
+ {
4135
+ UIScrollView *owner = _observedScrollView;
4136
+ NSString *key = self.currentPageKey;
4137
+ BOOL valid = !_isBeingRecycled && self.window != nil && _headerView.window == self.window &&
4138
+ _headerHeight > 0 && owner.window == self.window && owner != nil &&
4139
+ CGRectGetWidth(owner.bounds) > 0 && CGRectGetHeight(owner.bounds) > 0 &&
4140
+ _observedPageGeneration == _generation && _observedPageIndex == _currentIndex &&
4141
+ _currentIndex >= 0 && _currentIndex < _pageControllers.count &&
4142
+ [_observedPageKey isEqualToString:key] && [owner isDescendantOfView:_pageControllers[_currentIndex].view];
4143
+ BOOL identityChanged = _headerSettledOwner != owner || ![_headerSettledKey isEqualToString:key] ||
4144
+ _headerSettledGeneration != _generation;
4145
+ if (identityChanged) {
4146
+ [self cancelHeaderSettledCheck];
4147
+ _headerCommandOwner = nil;
4148
+ _headerSettledOwner = owner;
4149
+ _headerSettledKey = [key copy];
4150
+ _headerSettledGeneration = _generation;
4151
+ _headerSettledGeometry = nil;
4152
+ _headerSettledWasBusy = YES;
4153
+ [self publishHeaderSettledAtTop:NO identityChanged:YES];
4154
+ }
4155
+ if (!valid || fabs(_headerOffset) > 0.5) {
4156
+ [self cancelHeaderSettledCheck];
4157
+ _headerSettledGeometry = nil;
4158
+ _headerSettledWasBusy = YES;
4159
+ [self publishHeaderSettledAtTop:NO identityChanged:NO];
4160
+ return;
4161
+ }
4162
+ CGRect headerRect = [_headerView convertRect:_headerView.bounds toView:self.window];
4163
+ CGRect ownerRect = [owner convertRect:owner.bounds toView:self.window];
4164
+ UIEdgeInsets inset = owner.contentInset;
4165
+ UIEdgeInsets adjustedInset = owner.adjustedContentInset;
4166
+ NSString *geometry = [NSString stringWithFormat:
4167
+ @"%.4f:%ld:%ld:%@:%@:%@:%@:%@:%@:%@",
4168
+ _headerOffset, (long)_headerHeight, (long)_stickyHeaderHeight,
4169
+ NSStringFromCGPoint(owner.contentOffset), NSStringFromCGSize(owner.contentSize),
4170
+ NSStringFromCGRect(owner.bounds), NSStringFromUIEdgeInsets(inset),
4171
+ NSStringFromUIEdgeInsets(adjustedInset), NSStringFromCGRect(headerRect), NSStringFromCGRect(ownerRect)];
4172
+ UIGestureRecognizerState panState = owner.panGestureRecognizer.state;
4173
+ BOOL busy = forceBusy || _transitioning || _isPagerDragging || _transitionPageOffsets != nil ||
4174
+ _syntheticPageRestore || _pagerTopPageKey != nil || _headerCommandOwner == owner ||
4175
+ owner.isDragging || owner.isDecelerating || owner.isTracking ||
4176
+ panState == UIGestureRecognizerStateBegan || panState == UIGestureRecognizerStateChanged;
4177
+ CFTimeInterval now = CACurrentMediaTime();
4178
+ if (![geometry isEqualToString:_headerSettledGeometry] || busy || _headerSettledWasBusy) {
4179
+ _headerSettledSince = now;
4180
+ _headerSettledGeometry = geometry;
4181
+ [self cancelHeaderSettledCheck];
4182
+ [self publishHeaderSettledAtTop:NO identityChanged:NO];
4183
+ }
4184
+ _headerSettledWasBusy = busy;
4185
+ if (!busy && now - _headerSettledSince >= 0.15) {
4186
+ [self cancelHeaderSettledCheck];
4187
+ [self publishHeaderSettledAtTop:YES identityChanged:NO];
4188
+ return;
4189
+ }
4190
+ if (_headerSettledTimer == nil) {
4191
+ __weak RNCCollapsiblePagerViewComponentView *weakSelf = self;
4192
+ __weak UIScrollView *weakOwner = owner;
4193
+ NSUInteger generation = _generation;
4194
+ _headerSettledTimer = [NSTimer scheduledTimerWithTimeInterval:0.15 repeats:NO block:^(NSTimer *timer) {
4195
+ RNCCollapsiblePagerViewComponentView *strongSelf = weakSelf;
4196
+ if (strongSelf == nil) return;
4197
+ if (strongSelf->_headerSettledTimer != timer) return;
4198
+ strongSelf->_headerSettledTimer = nil;
4199
+ if (strongSelf->_generation == generation && [strongSelf.currentPageKey isEqualToString:key] &&
4200
+ strongSelf->_observedScrollView == weakOwner) [strongSelf updateHeaderSettledStateForceBusy:NO];
4201
+ }];
4202
+ }
4203
+ }
4204
+
4084
4205
  - (void)emitDiagnostics:(NSString *)reason
4085
4206
  {
4086
4207
  const auto emitter = [self eventEmitter];
@@ -4096,7 +4217,9 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
4096
4217
  .attachedPageCount = (int)attached,
4097
4218
  .observedScrollableCount = (int)_originalInsets.count,
4098
4219
  .retainedPages = std::string(_retainedPages.UTF8String ?: "[]"),
4099
- .reason = std::string(reason.UTF8String ?: "unknown")
4220
+ .reason = std::string(reason.UTF8String ?: "unknown"),
4221
+ .pageKey = std::string(self.currentPageKey.UTF8String ?: ""),
4222
+ .headerSettledAtTop = (bool)_headerSettledAtTop
4100
4223
  });
4101
4224
  }
4102
4225
 
@@ -19,6 +19,7 @@ using namespace facebook::react;
19
19
  - (void)updatePageStateTransitionProgress:(CGFloat)progress;
20
20
  - (void)finishPageStateTransition;
21
21
  - (void)scrollToTop:(NSInteger)target animated:(BOOL)animated;
22
+ - (void)updateHeaderSettledStateForceBusy:(BOOL)forceBusy;
22
23
  - (void)preparePageForHorizontalTransitionAtIndex:(NSInteger)index;
23
24
  - (void)applyInsetsToScrollView:(UIScrollView *)scrollView pageIndex:(NSInteger)index restore:(BOOL)restore;
24
25
  @end
@@ -33,6 +34,44 @@ using namespace facebook::react;
33
34
 
34
35
  @implementation RNCCollapsiblePagerFirstFrameTests
35
36
 
37
+ - (void)testHeaderSettledAtTopRequiresCurrentQuietOwnerAndCancelsDetachedTimer
38
+ {
39
+ RNCCollapsiblePagerViewComponentView *pager = [self pagerWithPageCount:1];
40
+ // The fixture mounts page 0 directly; UIKit must not select it again.
41
+ [pager setValue:@YES forKey:@"hasAppliedInitialPage"];
42
+ UIView *header = [[UIView alloc] initWithFrame:CGRectMake(0, 0, 320, 120)];
43
+ [pager addSubview:header];
44
+ [pager setValue:header forKey:@"headerView"];
45
+ RNCNativeScrollerComponentView *scroller = [self addScrollerToPager:pager atIndex:0];
46
+ [pager.window layoutIfNeeded];
47
+ [pager layoutIfNeeded];
48
+ XCTestExpectation *initialLayout = [self expectationWithDescription:@"Initial observer attachment drained"];
49
+ dispatch_async(dispatch_get_main_queue(), ^{ [initialLayout fulfill]; });
50
+ [self waitForExpectations:@[initialLayout] timeout:1];
51
+ [pager detachScrollObserver];
52
+ [pager attachScrollObserverForCurrentPage];
53
+ [pager updateHeaderSettledStateForceBusy:NO];
54
+ XCTAssertFalse([[pager valueForKey:@"headerSettledAtTop"] boolValue]);
55
+ [[NSRunLoop mainRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:0.25]];
56
+ XCTAssertTrue([[pager valueForKey:@"headerSettledAtTop"] boolValue]);
57
+
58
+ [scroller scrollTo:0 y:200 - scroller.scrollView.contentInset.top animated:NO];
59
+ XCTAssertFalse([[pager valueForKey:@"headerSettledAtTop"] boolValue]);
60
+ [scroller scrollTo:0 y:-scroller.scrollView.adjustedContentInset.top animated:NO];
61
+ [pager updateHeaderSettledStateForceBusy:NO];
62
+ XCTAssertFalse([[pager valueForKey:@"headerSettledAtTop"] boolValue]);
63
+ [pager detachScrollObserver];
64
+ [[NSRunLoop mainRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:0.25]];
65
+ XCTAssertFalse([[pager valueForKey:@"headerSettledAtTop"] boolValue]);
66
+ XCTAssertNil([pager valueForKey:@"headerSettledTimer"]);
67
+
68
+ [pager attachScrollObserverForCurrentPage];
69
+ [pager updateHeaderSettledStateForceBusy:NO];
70
+ XCTAssertFalse([[pager valueForKey:@"headerSettledAtTop"] boolValue]);
71
+ [[NSRunLoop mainRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:0.25]];
72
+ XCTAssertTrue([[pager valueForKey:@"headerSettledAtTop"] boolValue]);
73
+ }
74
+
36
75
  - (void)testReachableTopCommandBeforeLayoutMustNotReplayPendingPagePosition
37
76
  {
38
77
  for (NSNumber *command in @[@0, @1, @2]) {
@@ -44,6 +44,8 @@ export class CollapsiblePagerView extends React.PureComponent {
44
44
  pageOffsets = new Map();
45
45
  pageHosts = new Map();
46
46
  sharedHeaderOffset = 0;
47
+ selectionRestoreToken = 0;
48
+ bodyAlignment = null;
47
49
  scrollEnabled = this.props.scrollEnabled ?? true;
48
50
  lastMountSignature = null;
49
51
  configureFrame = null;
@@ -71,6 +73,7 @@ export class CollapsiblePagerView extends React.PureComponent {
71
73
  const pageCount = this.pages().length;
72
74
  const selectedPage = clampedPageIndex(this.state.selectedPage, pageCount);
73
75
  if (selectedPage !== this.state.selectedPage) {
76
+ this.selectionRestoreToken += 1;
74
77
  this.cancelPendingTopFrame();
75
78
  this.finishPageTransition();
76
79
  this.setState({
@@ -83,6 +86,7 @@ export class CollapsiblePagerView extends React.PureComponent {
83
86
  this.notifyMountedPagesChanged();
84
87
  }
85
88
  componentWillUnmount() {
89
+ this.selectionRestoreToken += 1;
86
90
  this.cancelPendingTopFrame();
87
91
  this.savePageOffset(this.state.selectedPage);
88
92
  if (this.configureFrame !== null) {
@@ -116,16 +120,32 @@ export class CollapsiblePagerView extends React.PureComponent {
116
120
  if (this.pageScrollElement(this.state.selectedPage) === pending.element && this.pages().map(pageKey)[this.state.selectedPage] === pending.key) this.savePageOffset(this.state.selectedPage);
117
121
  };
118
122
  onQueuedTopWheel = event => {
119
- if (event.deltaY !== 0) this.cancelPendingTopFrame();
123
+ if (event.deltaY !== 0) this.takeOverVerticalScroll();
124
+ };
125
+ onScrollbarPointerDown = event => {
126
+ const element = this.pageScrollElement(this.state.selectedPage);
127
+ if (!element || event.target !== element || event.button !== 0) return;
128
+ const rect = element.getBoundingClientRect();
129
+ const computed = element.ownerDocument.defaultView?.getComputedStyle(element);
130
+ const borderLeft = Number.parseFloat(computed?.borderLeftWidth ?? "0") || 0;
131
+ const borderRight = Number.parseFloat(computed?.borderRightWidth ?? "0") || 0;
132
+ const clientStart = rect.left + element.clientLeft;
133
+ const clientEnd = clientStart + element.clientWidth;
134
+ const leftGutter = clientStart - (rect.left + borderLeft);
135
+ const rightGutter = rect.right - borderRight - clientEnd;
136
+ const inVerticalTrack = event.clientY >= rect.top + element.clientTop && event.clientY < rect.top + element.clientTop + element.clientHeight;
137
+ const inGutter = leftGutter > 0 && event.clientX >= rect.left + borderLeft && event.clientX < clientStart || rightGutter > 0 && event.clientX >= clientEnd && event.clientX < rect.right - borderRight;
138
+ if (inVerticalTrack && inGutter) this.takeOverVerticalScroll();
120
139
  };
121
140
  onQueuedTopKey = event => {
122
- if (["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " "].includes(event.key)) this.cancelPendingTopFrame();
141
+ if (["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " "].includes(event.key)) this.takeOverVerticalScroll();
123
142
  };
124
143
  scrollCurrentPageToTop(offset, animated) {
125
144
  if (this.horizontalTouch || this.transitionTimer !== null) return;
126
145
  const element = this.pageScrollElement(this.state.selectedPage);
127
146
  const key = this.pages().map(pageKey)[this.state.selectedPage];
128
147
  if (!element?.isConnected || !key) return;
148
+ this.selectionRestoreToken += 1;
129
149
  this.cancelPendingTopFrame();
130
150
  const EventConstructor = element.ownerDocument.defaultView?.Event;
131
151
  if (EventConstructor) element.dispatchEvent(new EventConstructor("ok-collapsible-pager-cancel-scroll-command"));
@@ -138,8 +158,12 @@ export class CollapsiblePagerView extends React.PureComponent {
138
158
  if (this.pendingTopFrame?.frame !== frame) return;
139
159
  this.pendingTopFrame = null;
140
160
  if (!element.isConnected || this.horizontalTouch || this.transitionTimer !== null || this.pageScrollElement(this.state.selectedPage) !== element || this.pages().map(pageKey)[this.state.selectedPage] !== key) return;
141
- this.pageOffsets.set(key, offset);
161
+ this.pageOffsets.set(key, 0);
142
162
  this.sharedHeaderOffset = offset;
163
+ this.bodyAlignment = {
164
+ element,
165
+ key
166
+ };
143
167
  element.scrollTo({
144
168
  top: offset,
145
169
  behavior: animated ? "smooth" : "instant"
@@ -218,8 +242,9 @@ export class CollapsiblePagerView extends React.PureComponent {
218
242
  const configured = this.configuredScrollElement;
219
243
  if (!configured) return;
220
244
  this.cancelPendingTopFrame();
221
- configured.element.removeEventListener("ok-native-list-scroll-command-accepted", this.cancelPendingTopFrame);
245
+ configured.element.removeEventListener("ok-native-list-scroll-command-accepted", this.onListScrollCommandAccepted);
222
246
  configured.element.removeEventListener("wheel", this.onQueuedTopWheel);
247
+ configured.element.removeEventListener("pointerdown", this.onScrollbarPointerDown);
223
248
  configured.element.removeEventListener("keydown", this.onQueuedTopKey);
224
249
  configured.element.removeEventListener("scrollend", this.onVerticalScrollSettled);
225
250
  const style = timelineStyle(configured.element);
@@ -234,6 +259,7 @@ export class CollapsiblePagerView extends React.PureComponent {
234
259
  configured.element.removeAttribute("data-collapsible-pager-active");
235
260
  this.notifyInsetsChanged(configured.element);
236
261
  this.configuredScrollElement = null;
262
+ if (this.bodyAlignment?.element === configured.element) this.bodyAlignment = null;
237
263
  }
238
264
  configureActiveScrollElement = () => {
239
265
  this.configureFrame = null;
@@ -269,8 +295,9 @@ export class CollapsiblePagerView extends React.PureComponent {
269
295
  pagerStickyInset: style.getPropertyValue(PAGER_STICKY_INSET)
270
296
  };
271
297
  element.addEventListener("scrollend", this.onVerticalScrollSettled);
272
- element.addEventListener("ok-native-list-scroll-command-accepted", this.cancelPendingTopFrame);
298
+ element.addEventListener("ok-native-list-scroll-command-accepted", this.onListScrollCommandAccepted);
273
299
  element.addEventListener("wheel", this.onQueuedTopWheel);
300
+ element.addEventListener("pointerdown", this.onScrollbarPointerDown);
274
301
  element.addEventListener("keydown", this.onQueuedTopKey);
275
302
  }
276
303
  const style = timelineStyle(element);
@@ -299,8 +326,18 @@ export class CollapsiblePagerView extends React.PureComponent {
299
326
  this.configureActiveScrollElement();
300
327
  }
301
328
  }
302
- onVerticalScrollSettled = () => {
303
- if (this.pendingTopFrame) return;
329
+ takeOverVerticalScroll = () => {
330
+ this.selectionRestoreToken += 1;
331
+ this.bodyAlignment = null;
332
+ this.cancelPendingTopFrame();
333
+ this.savePageOffset(this.state.selectedPage);
334
+ };
335
+ onListScrollCommandAccepted = event => {
336
+ if (event.target !== this.pageScrollElement(this.state.selectedPage)) return;
337
+ this.takeOverVerticalScroll();
338
+ };
339
+ onVerticalScrollSettled = event => {
340
+ if (this.pendingTopFrame || event && event.target !== this.pageScrollElement(this.state.selectedPage)) return;
304
341
  this.savePageOffset(this.state.selectedPage);
305
342
  this.emitDiagnostics("scroll-settled");
306
343
  };
@@ -308,15 +345,24 @@ export class CollapsiblePagerView extends React.PureComponent {
308
345
  const key = this.pages().map(pageKey)[index];
309
346
  const scrollElement = this.pageScrollElement(index);
310
347
  if (key && scrollElement) {
311
- this.pageOffsets.set(key, scrollElement.scrollTop);
312
- this.sharedHeaderOffset = Math.min(Math.max(scrollElement.scrollTop, 0), Math.max(0, this.props.headerHeight));
348
+ if (this.bodyAlignment?.key === key && this.bodyAlignment.element === scrollElement) return;
349
+ const D = Math.max(0, this.props.headerHeight);
350
+ this.pageOffsets.set(key, Math.max(0, scrollElement.scrollTop - D));
351
+ this.sharedHeaderOffset = Math.min(Math.max(scrollElement.scrollTop, 0), D);
313
352
  }
314
353
  }
315
354
  restorePageOffset(index) {
316
355
  const key = this.pages().map(pageKey)[index];
317
356
  const scrollElement = this.pageScrollElement(index);
318
357
  if (!key || !scrollElement) return;
319
- scrollElement.scrollTop = Math.max(this.pageOffsets.get(key) ?? this.sharedHeaderOffset, this.sharedHeaderOffset);
358
+ const D = Math.max(0, this.props.headerHeight);
359
+ const C = Math.min(Math.max(0, this.sharedHeaderOffset), D);
360
+ const expectedQ = C + (C === D ? this.pageOffsets.get(key) ?? 0 : 0);
361
+ this.bodyAlignment = {
362
+ element: scrollElement,
363
+ key
364
+ };
365
+ scrollElement.scrollTop = expectedQ;
320
366
  }
321
367
  selectPage(selectedPage, animated) {
322
368
  const pages = this.pages();
@@ -324,6 +370,8 @@ export class CollapsiblePagerView extends React.PureComponent {
324
370
  if (position === null || position < 0 || position >= pages.length || position === this.state.selectedPage) {
325
371
  return;
326
372
  }
373
+ const selectionToken = ++this.selectionRestoreToken;
374
+ const expectedKey = this.pages().map(pageKey)[position];
327
375
  this.cancelPendingTopFrame();
328
376
  this.savePageOffset(this.state.selectedPage);
329
377
  if (animated) this.beginPageTransition();else this.finishPageTransition();
@@ -333,6 +381,7 @@ export class CollapsiblePagerView extends React.PureComponent {
333
381
  }, () => {
334
382
  this.scheduleScrollConfiguration();
335
383
  const restore = () => {
384
+ if (this.selectionRestoreToken !== selectionToken || this.state.selectedPage !== position || this.pages().map(pageKey)[position] !== expectedKey || !this.pageScrollElement(position)?.isConnected) return;
336
385
  this.configureActiveScrollElement();
337
386
  this.restorePageOffset(position);
338
387
  };
@@ -392,7 +441,7 @@ export class CollapsiblePagerView extends React.PureComponent {
392
441
  if (!touch) return;
393
442
  const deltaX = touch.pageX - this.touchStartX;
394
443
  const deltaY = touch.pageY - (this.touchStartY ?? touch.pageY);
395
- if (Math.abs(deltaY) >= 8 && Math.abs(deltaY) > Math.abs(deltaX)) this.cancelPendingTopFrame();
444
+ if (Math.abs(deltaY) >= 8 && Math.abs(deltaY) > Math.abs(deltaX)) this.takeOverVerticalScroll();
396
445
  if (Math.abs(deltaX) >= 8 && Math.abs(deltaX) > Math.abs(deltaY)) {
397
446
  this.cancelPendingTopFrame();
398
447
  this.horizontalTouch = true;
@@ -35,6 +35,8 @@ export type OnCollapsibleStateChangedEventData = Readonly<{
35
35
  observedScrollableCount: Int32;
36
36
  retainedPages: string;
37
37
  reason: string;
38
+ pageKey: string;
39
+ headerSettledAtTop: boolean;
38
40
  }>;
39
41
 
40
42
  export type OnNativeTabPressEventData = Readonly<{
@@ -59,6 +59,8 @@ export declare class CollapsiblePagerView extends React.PureComponent<Collapsibl
59
59
  private pageOffsets;
60
60
  private pageHosts;
61
61
  private sharedHeaderOffset;
62
+ private selectionRestoreToken;
63
+ private bodyAlignment;
62
64
  private scrollEnabled;
63
65
  private lastMountSignature;
64
66
  private configureFrame;
@@ -80,6 +82,7 @@ export declare class CollapsiblePagerView extends React.PureComponent<Collapsibl
80
82
  }>) => void;
81
83
  private cancelPendingTopFrame;
82
84
  private onQueuedTopWheel;
85
+ private onScrollbarPointerDown;
83
86
  private onQueuedTopKey;
84
87
  private scrollCurrentPageToTop;
85
88
  setScrollEnabled: (scrollEnabled: boolean) => void;
@@ -97,6 +100,8 @@ export declare class CollapsiblePagerView extends React.PureComponent<Collapsibl
97
100
  private restoreConfiguredScrollElement;
98
101
  private configureActiveScrollElement;
99
102
  private scheduleScrollConfiguration;
103
+ private takeOverVerticalScroll;
104
+ private onListScrollCommandAccepted;
100
105
  private onVerticalScrollSettled;
101
106
  private savePageOffset;
102
107
  private restorePageOffset;
@@ -19,6 +19,8 @@ export type OnCollapsibleStateChangedEventData = Readonly<{
19
19
  observedScrollableCount: Int32;
20
20
  retainedPages: string;
21
21
  reason: string;
22
+ pageKey: string;
23
+ headerSettledAtTop: boolean;
22
24
  }>;
23
25
  export type OnNativeTabPressEventData = Readonly<{
24
26
  position: Int32;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onekeyfe/react-native-pager-view",
3
- "version": "3.0.174",
3
+ "version": "3.0.176",
4
4
  "description": "React Native wrapper for Android and iOS ViewPager",
5
5
  "source": "./src/index.tsx",
6
6
  "main": "./lib/module/index.js",
@@ -72,7 +72,7 @@
72
72
  "typescript": "^5.9.2"
73
73
  },
74
74
  "peerDependencies": {
75
- "@onekeyfe/react-native-native-logger": "3.0.174",
75
+ "@onekeyfe/react-native-native-logger": "3.0.176",
76
76
  "react": "*",
77
77
  "react-native": "*"
78
78
  },
@@ -154,6 +154,11 @@ export class CollapsiblePagerView extends React.PureComponent<
154
154
  private pageOffsets = new Map<string, number>();
155
155
  private pageHosts = new Map<number, HTMLElement>();
156
156
  private sharedHeaderOffset = 0;
157
+ private selectionRestoreToken = 0;
158
+ private bodyAlignment: Readonly<{
159
+ element: HTMLElement;
160
+ key: string;
161
+ }> | null = null;
157
162
  private scrollEnabled = this.props.scrollEnabled ?? true;
158
163
  private lastMountSignature: string | null = null;
159
164
  private configureFrame: number | null = null;
@@ -188,6 +193,7 @@ export class CollapsiblePagerView extends React.PureComponent<
188
193
  const pageCount = this.pages().length;
189
194
  const selectedPage = clampedPageIndex(this.state.selectedPage, pageCount);
190
195
  if (selectedPage !== this.state.selectedPage) {
196
+ this.selectionRestoreToken += 1;
191
197
  this.cancelPendingTopFrame();
192
198
  this.finishPageTransition();
193
199
  this.setState({
@@ -201,6 +207,7 @@ export class CollapsiblePagerView extends React.PureComponent<
201
207
  }
202
208
 
203
209
  componentWillUnmount() {
210
+ this.selectionRestoreToken += 1;
204
211
  this.cancelPendingTopFrame();
205
212
  this.savePageOffset(this.state.selectedPage);
206
213
  if (this.configureFrame !== null) {
@@ -247,7 +254,33 @@ export class CollapsiblePagerView extends React.PureComponent<
247
254
  };
248
255
 
249
256
  private onQueuedTopWheel = (event: WheelEvent) => {
250
- if (event.deltaY !== 0) this.cancelPendingTopFrame();
257
+ if (event.deltaY !== 0) this.takeOverVerticalScroll();
258
+ };
259
+
260
+ private onScrollbarPointerDown = (event: PointerEvent) => {
261
+ const element = this.pageScrollElement(this.state.selectedPage);
262
+ if (!element || event.target !== element || event.button !== 0) return;
263
+ const rect = element.getBoundingClientRect();
264
+ const computed =
265
+ element.ownerDocument.defaultView?.getComputedStyle(element);
266
+ const borderLeft = Number.parseFloat(computed?.borderLeftWidth ?? "0") || 0;
267
+ const borderRight =
268
+ Number.parseFloat(computed?.borderRightWidth ?? "0") || 0;
269
+ const clientStart = rect.left + element.clientLeft;
270
+ const clientEnd = clientStart + element.clientWidth;
271
+ const leftGutter = clientStart - (rect.left + borderLeft);
272
+ const rightGutter = rect.right - borderRight - clientEnd;
273
+ const inVerticalTrack =
274
+ event.clientY >= rect.top + element.clientTop &&
275
+ event.clientY < rect.top + element.clientTop + element.clientHeight;
276
+ const inGutter =
277
+ (leftGutter > 0 &&
278
+ event.clientX >= rect.left + borderLeft &&
279
+ event.clientX < clientStart) ||
280
+ (rightGutter > 0 &&
281
+ event.clientX >= clientEnd &&
282
+ event.clientX < rect.right - borderRight);
283
+ if (inVerticalTrack && inGutter) this.takeOverVerticalScroll();
251
284
  };
252
285
 
253
286
  private onQueuedTopKey = (event: KeyboardEvent) => {
@@ -262,7 +295,7 @@ export class CollapsiblePagerView extends React.PureComponent<
262
295
  " ",
263
296
  ].includes(event.key)
264
297
  )
265
- this.cancelPendingTopFrame();
298
+ this.takeOverVerticalScroll();
266
299
  };
267
300
 
268
301
  private scrollCurrentPageToTop(offset: number, animated: boolean) {
@@ -270,6 +303,7 @@ export class CollapsiblePagerView extends React.PureComponent<
270
303
  const element = this.pageScrollElement(this.state.selectedPage);
271
304
  const key = this.pages().map(pageKey)[this.state.selectedPage];
272
305
  if (!element?.isConnected || !key) return;
306
+ this.selectionRestoreToken += 1;
273
307
  this.cancelPendingTopFrame();
274
308
  const EventConstructor = element.ownerDocument.defaultView?.Event;
275
309
  if (EventConstructor)
@@ -292,8 +326,9 @@ export class CollapsiblePagerView extends React.PureComponent<
292
326
  this.pages().map(pageKey)[this.state.selectedPage] !== key
293
327
  )
294
328
  return;
295
- this.pageOffsets.set(key, offset);
329
+ this.pageOffsets.set(key, 0);
296
330
  this.sharedHeaderOffset = offset;
331
+ this.bodyAlignment = { element, key };
297
332
  element.scrollTo({
298
333
  top: offset,
299
334
  behavior: animated ? "smooth" : "instant",
@@ -392,9 +427,13 @@ export class CollapsiblePagerView extends React.PureComponent<
392
427
  this.cancelPendingTopFrame();
393
428
  configured.element.removeEventListener(
394
429
  "ok-native-list-scroll-command-accepted",
395
- this.cancelPendingTopFrame
430
+ this.onListScrollCommandAccepted
396
431
  );
397
432
  configured.element.removeEventListener("wheel", this.onQueuedTopWheel);
433
+ configured.element.removeEventListener(
434
+ "pointerdown",
435
+ this.onScrollbarPointerDown
436
+ );
398
437
  configured.element.removeEventListener("keydown", this.onQueuedTopKey);
399
438
  configured.element.removeEventListener(
400
439
  "scrollend",
@@ -412,6 +451,8 @@ export class CollapsiblePagerView extends React.PureComponent<
412
451
  configured.element.removeAttribute("data-collapsible-pager-active");
413
452
  this.notifyInsetsChanged(configured.element);
414
453
  this.configuredScrollElement = null;
454
+ if (this.bodyAlignment?.element === configured.element)
455
+ this.bodyAlignment = null;
415
456
  }
416
457
 
417
458
  private configureActiveScrollElement = () => {
@@ -458,9 +499,10 @@ export class CollapsiblePagerView extends React.PureComponent<
458
499
  element.addEventListener("scrollend", this.onVerticalScrollSettled);
459
500
  element.addEventListener(
460
501
  "ok-native-list-scroll-command-accepted",
461
- this.cancelPendingTopFrame
502
+ this.onListScrollCommandAccepted
462
503
  );
463
504
  element.addEventListener("wheel", this.onQueuedTopWheel);
505
+ element.addEventListener("pointerdown", this.onScrollbarPointerDown);
464
506
  element.addEventListener("keydown", this.onQueuedTopKey);
465
507
  }
466
508
 
@@ -501,8 +543,26 @@ export class CollapsiblePagerView extends React.PureComponent<
501
543
  }
502
544
  }
503
545
 
504
- private onVerticalScrollSettled = () => {
505
- if (this.pendingTopFrame) return;
546
+ private takeOverVerticalScroll = () => {
547
+ this.selectionRestoreToken += 1;
548
+ this.bodyAlignment = null;
549
+ this.cancelPendingTopFrame();
550
+ this.savePageOffset(this.state.selectedPage);
551
+ };
552
+
553
+ private onListScrollCommandAccepted = (event: Event) => {
554
+ if (event.target !== this.pageScrollElement(this.state.selectedPage))
555
+ return;
556
+ this.takeOverVerticalScroll();
557
+ };
558
+
559
+ private onVerticalScrollSettled = (event?: Event) => {
560
+ if (
561
+ this.pendingTopFrame ||
562
+ (event &&
563
+ event.target !== this.pageScrollElement(this.state.selectedPage))
564
+ )
565
+ return;
506
566
  this.savePageOffset(this.state.selectedPage);
507
567
  this.emitDiagnostics("scroll-settled");
508
568
  };
@@ -511,10 +571,16 @@ export class CollapsiblePagerView extends React.PureComponent<
511
571
  const key = this.pages().map(pageKey)[index];
512
572
  const scrollElement = this.pageScrollElement(index);
513
573
  if (key && scrollElement) {
514
- this.pageOffsets.set(key, scrollElement.scrollTop);
574
+ if (
575
+ this.bodyAlignment?.key === key &&
576
+ this.bodyAlignment.element === scrollElement
577
+ )
578
+ return;
579
+ const D = Math.max(0, this.props.headerHeight);
580
+ this.pageOffsets.set(key, Math.max(0, scrollElement.scrollTop - D));
515
581
  this.sharedHeaderOffset = Math.min(
516
582
  Math.max(scrollElement.scrollTop, 0),
517
- Math.max(0, this.props.headerHeight)
583
+ D
518
584
  );
519
585
  }
520
586
  }
@@ -523,10 +589,11 @@ export class CollapsiblePagerView extends React.PureComponent<
523
589
  const key = this.pages().map(pageKey)[index];
524
590
  const scrollElement = this.pageScrollElement(index);
525
591
  if (!key || !scrollElement) return;
526
- scrollElement.scrollTop = Math.max(
527
- this.pageOffsets.get(key) ?? this.sharedHeaderOffset,
528
- this.sharedHeaderOffset
529
- );
592
+ const D = Math.max(0, this.props.headerHeight);
593
+ const C = Math.min(Math.max(0, this.sharedHeaderOffset), D);
594
+ const expectedQ = C + (C === D ? this.pageOffsets.get(key) ?? 0 : 0);
595
+ this.bodyAlignment = { element: scrollElement, key };
596
+ scrollElement.scrollTop = expectedQ;
530
597
  }
531
598
 
532
599
  private selectPage(selectedPage: number, animated: boolean) {
@@ -540,6 +607,8 @@ export class CollapsiblePagerView extends React.PureComponent<
540
607
  ) {
541
608
  return;
542
609
  }
610
+ const selectionToken = ++this.selectionRestoreToken;
611
+ const expectedKey = this.pages().map(pageKey)[position];
543
612
  this.cancelPendingTopFrame();
544
613
  this.savePageOffset(this.state.selectedPage);
545
614
 
@@ -550,6 +619,13 @@ export class CollapsiblePagerView extends React.PureComponent<
550
619
  () => {
551
620
  this.scheduleScrollConfiguration();
552
621
  const restore = () => {
622
+ if (
623
+ this.selectionRestoreToken !== selectionToken ||
624
+ this.state.selectedPage !== position ||
625
+ this.pages().map(pageKey)[position] !== expectedKey ||
626
+ !this.pageScrollElement(position)?.isConnected
627
+ )
628
+ return;
553
629
  this.configureActiveScrollElement();
554
630
  this.restorePageOffset(position);
555
631
  };
@@ -629,7 +705,7 @@ export class CollapsiblePagerView extends React.PureComponent<
629
705
  const deltaX = touch.pageX - this.touchStartX;
630
706
  const deltaY = touch.pageY - (this.touchStartY ?? touch.pageY);
631
707
  if (Math.abs(deltaY) >= 8 && Math.abs(deltaY) > Math.abs(deltaX))
632
- this.cancelPendingTopFrame();
708
+ this.takeOverVerticalScroll();
633
709
  if (Math.abs(deltaX) >= 8 && Math.abs(deltaX) > Math.abs(deltaY)) {
634
710
  this.cancelPendingTopFrame();
635
711
  this.horizontalTouch = true;
@@ -35,6 +35,8 @@ export type OnCollapsibleStateChangedEventData = Readonly<{
35
35
  observedScrollableCount: Int32;
36
36
  retainedPages: string;
37
37
  reason: string;
38
+ pageKey: string;
39
+ headerSettledAtTop: boolean;
38
40
  }>;
39
41
 
40
42
  export type OnNativeTabPressEventData = Readonly<{