@onekeyfe/react-native-pager-view 3.0.174 → 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.
@@ -204,6 +204,18 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
204
204
  private var observedNativeScrollerTreeObserver: ViewTreeObserver? = null
205
205
  private var observedPageRemoved = false
206
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
+
207
219
  private val touchSlopPx = ViewConfiguration.get(context).scaledTouchSlop
208
220
  private val hostIdentity = System.identityHashCode(this)
209
221
  private var headerView: View? = null
@@ -228,6 +240,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
228
240
  finishPagerTopIfSettled()
229
241
  // OneKey patch: a filtered short page can reduce the existing collapse range.
230
242
  setHeaderOffset(headerOffsetPx)
243
+ updateHeaderSettledState()
231
244
  if (offsetDiagEnabled() && offsetDiagNextDraw.isNotEmpty()) {
232
245
  val samples = offsetDiagNextDraw.entries.map { it.key to it.value }
233
246
  offsetDiagNextDraw.clear()
@@ -554,6 +567,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
554
567
  }
555
568
 
556
569
  internal fun onPagerScrollStateChanged(state: Int) {
570
+ updateHeaderSettledState(forceBusy = state != ViewPager2.SCROLL_STATE_IDLE)
557
571
  if (state == ViewPager2.SCROLL_STATE_DRAGGING || state == ViewPager2.SCROLL_STATE_SETTLING) {
558
572
  beginPageTransition()
559
573
  } else if (state == ViewPager2.SCROLL_STATE_IDLE && transitionHeaderOffsets != null) {
@@ -855,6 +869,14 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
855
869
  if (phase == 0 && view === (observedRecyclerView ?: observedOwnedScroller ?: observedNativeScroller)) cancelPagerTop()
856
870
  if (phase != 0 && matchesPagerTop(view)) { finishPagerTopIfSettled(); return }
857
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
+ }
858
880
  if (!nativeSmoothHeaderScrollEnabled || view !== owner || transitionHeaderOffsets != null || syntheticPageRestore ||
859
881
  !isAttachedToWindow || !view.isAttachedToWindow) return
860
882
  val key = currentPageKey()
@@ -1030,6 +1052,94 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
1030
1052
  return (contentHeight.roundToInt() - height).coerceIn(0, headerHeightPx)
1031
1053
  }
1032
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
+
1033
1143
  private fun setHeaderOffset(offset: Int) {
1034
1144
  // val next = offset.coerceIn(0, headerHeightPx)
1035
1145
  val pending = nativeSmoothHeaderScrollEnabled && currentPageKey() in pendingPageRestores
@@ -1037,6 +1147,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
1037
1147
  val next = offset.coerceIn(0, maximum)
1038
1148
  if (headerOffsetPx == next) return
1039
1149
  headerOffsetPx = next
1150
+ updateHeaderSettledState(forceBusy = true)
1040
1151
  if (nativeSmoothHeaderScrollEnabled && transitionHeaderOffsets == null && !syntheticPageRestore && !pending) {
1041
1152
  pageHeaderOffsets[currentPageKey()] = next
1042
1153
  }
@@ -1195,9 +1306,11 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
1195
1306
  val key = currentPageKey()
1196
1307
  val listener = object : RecyclerView.OnScrollListener() {
1197
1308
  override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
1309
+ if (observedRecyclerView === recyclerView) updateHeaderSettledState(forceBusy = newState != RecyclerView.SCROLL_STATE_IDLE)
1198
1310
  if (newState == RecyclerView.SCROLL_STATE_IDLE) finishPagerTopIfSettled()
1199
1311
  }
1200
1312
  override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
1313
+ if (observedRecyclerView === recyclerView && (dx != 0 || dy != 0)) updateHeaderSettledState(forceBusy = true)
1201
1314
  if (observedScrollListener !== this || observedRecyclerKey != key ||
1202
1315
  key != currentPageKey() || !isAttachedToWindow ||
1203
1316
  transitionHeaderOffsets != null || syntheticPageRestore || key in pendingPageRestores || key in alignedBodyKeys) {
@@ -1327,6 +1440,11 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
1327
1440
  }
1328
1441
 
1329
1442
  private fun detachPrimaryScrollObserver(recordOffset: Boolean = true) {
1443
+ cancelHeaderSettledCheck()
1444
+ headerCommandOwner = null
1445
+ headerSettledGeometry = null
1446
+ headerSettledWasBusy = true
1447
+ publishHeaderSettledState(false)
1330
1448
  detachOwnedScrollerObserver(recordOffset)
1331
1449
  detachRecyclerObserver(recordOffset)
1332
1450
  detachNativeScrollerObserver(recordOffset)
@@ -2070,6 +2188,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
2070
2188
  override fun onNestedScrollAccepted(child: View, target: View, axes: Int, type: Int) {
2071
2189
  if (type == ViewCompat.TYPE_TOUCH) pendingRefreshPull = null
2072
2190
  nestedScrollingParentHelper.onNestedScrollAccepted(child, target, axes, type)
2191
+ updateHeaderSettledState(forceBusy = true)
2073
2192
  if (type == ViewCompat.TYPE_TOUCH) {
2074
2193
  val request = pageRestoreRequests[currentPageKey()]
2075
2194
  offsetDiag("touch-cancel-before", target, request = request)
@@ -2084,6 +2203,7 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
2084
2203
  override fun onStopNestedScroll(target: View, type: Int) {
2085
2204
  if (pendingRefreshPull?.get() === target) pendingRefreshPull = null
2086
2205
  nestedScrollingParentHelper.onStopNestedScroll(target, type)
2206
+ updateHeaderSettledState()
2087
2207
  onHeaderOffsetChanged?.invoke()
2088
2208
  }
2089
2209
 
@@ -2191,6 +2311,9 @@ class CollapsiblePagerHost(context: Context) : NestedScrollableHost(context), Ne
2191
2311
  }
2192
2312
 
2193
2313
  override fun onDetachedFromWindow() {
2314
+ cancelHeaderSettledCheck()
2315
+ headerCommandOwner = null
2316
+ publishHeaderSettledState(false)
2194
2317
  pendingRefreshPull = null
2195
2318
  releaseRefreshForeground()
2196
2319
  attachmentGeneration += 1
@@ -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
@@ -717,3 +717,12 @@ cases additionally verified recorded refresh, header, restoration and gesture
717
717
  paths; they do not establish every possible sequence or runtime acceptance of a
718
718
  newly installed module release. Physical-device feel and semantic accessibility
719
719
  activation remain unverified.
720
+
721
+
722
+ ### Native Header stability snapshot
723
+
724
+ `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.
725
+
726
+ 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.
727
+
728
+ 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]) {
@@ -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<{
@@ -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.175",
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.175",
76
76
  "react": "*",
77
77
  "react-native": "*"
78
78
  },
@@ -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<{