@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.
@@ -1437,6 +1437,9 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
1437
1437
  @end
1438
1438
 
1439
1439
  @implementation RNCCollapsiblePagerViewComponentView {
1440
+ #if DEBUG
1441
+ NSUInteger _offsetDiagSequence;
1442
+ #endif
1440
1443
  UIView *_containerView;
1441
1444
  RNCCollapsiblePagerSharedHeaderView *_sharedHeaderHostView;
1442
1445
  UIPageViewController *_pageViewController;
@@ -1478,6 +1481,19 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
1478
1481
  NSArray<NSString *> *_pageKeys;
1479
1482
  NSString *_retainedPages;
1480
1483
  NSMutableDictionary<NSString *, NSNumber *> *_pageOffsets;
1484
+ NSMutableSet<NSString *> *_commandBodyKeys;
1485
+ __weak UIScrollView *_pagerTopScrollView;
1486
+ NSString *_pagerTopPageKey;
1487
+ NSUInteger _pagerTopGeneration;
1488
+ NSInteger _pagerTopPageIndex;
1489
+ CGFloat _pagerTopLogicalTarget;
1490
+ BOOL _dispatchingPagerTop;
1491
+ NSUInteger _pagerTopToken;
1492
+ NSMutableSet<NSString *> *_alignedBodyKeys;
1493
+ NSMutableSet<NSString *> *_suspendedBodyKeys;
1494
+ CGFloat _transitionHeaderOffset;
1495
+ CGFloat _transitionSourceDisplayBody;
1496
+ NSMutableDictionary<NSString *, NSNumber *> *_displayBodyOverrides;
1481
1497
  NSMapTable<UIScrollView *, NSValue *> *_originalInsets;
1482
1498
  NSMapTable<UIScrollView *, UIRefreshControl *> *_managedRefreshControls;
1483
1499
  NSHashTable<UIScrollView *> *_refreshControlObservedScrollViews;
@@ -1491,6 +1507,28 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
1491
1507
  BOOL _needsScrollObserverReattachAfterMount;
1492
1508
  NSHashTable<UIScrollView *> *_mountingRemovedScrollViews;
1493
1509
  __weak UIScrollView *_observedScrollView;
1510
+ NSString *_observedPageKey;
1511
+ NSInteger _observedPageIndex;
1512
+ NSUInteger _observedPageGeneration;
1513
+ NSDictionary<NSString *, NSNumber *> *_transitionPageOffsets;
1514
+ NSMutableIndexSet *_transitionCompensatedPages;
1515
+ #if DEBUG
1516
+ NSInteger _transitionDiagBucket;
1517
+ #endif
1518
+ NSInteger _transitionSourceIndex;
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
+
1530
+ BOOL _finishingPageStateTransition;
1531
+ NSMutableSet<NSString *> *_pendingPageRestoreKeys;
1494
1532
  __weak UIScrollView *_sharedHeaderScrollView;
1495
1533
  __weak UIScrollView *_pendingFallbackScrollView;
1496
1534
  __weak UIView *_sharedHeaderPressCancellationGestureHost;
@@ -1525,6 +1563,13 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
1525
1563
  _logicalChildren = [NSMutableArray new];
1526
1564
  _pageControllers = [NSMutableArray new];
1527
1565
  _pageOffsets = [NSMutableDictionary new];
1566
+ _commandBodyKeys = [NSMutableSet new];
1567
+ _alignedBodyKeys = [NSMutableSet new];
1568
+ _suspendedBodyKeys = [NSMutableSet new];
1569
+ _displayBodyOverrides = [NSMutableDictionary new];
1570
+ [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(nativeListScrollCommandAccepted:)
1571
+ name:@"OneKeyNativeListScrollCommandAccepted" object:nil];
1572
+ _pendingPageRestoreKeys = [NSMutableSet new];
1528
1573
  _releasedPageScrollStates = [NSMutableDictionary new];
1529
1574
  _mountingRemovedScrollViews = [NSHashTable weakObjectsHashTable];
1530
1575
  _originalInsets = [NSMapTable weakToStrongObjectsMapTable];
@@ -1642,6 +1687,7 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
1642
1687
  {
1643
1688
  [super didMoveToWindow];
1644
1689
  [self updateSharedHeaderPressCancellationGesture];
1690
+ [self updateHeaderSettledStateForceBusy:NO];
1645
1691
  for (UIScrollView *scrollView in _originalInsets.keyEnumerator) {
1646
1692
  [self updateNativeScrollerViewport:scrollView stickyHeight:self.window == nil ? 0 : _stickyHeaderHeight];
1647
1693
  }
@@ -1668,6 +1714,8 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
1668
1714
 
1669
1715
  - (void)dealloc
1670
1716
  {
1717
+ [self cancelHeaderSettledCheck];
1718
+ [[NSNotificationCenter defaultCenter] removeObserver:self name:@"OneKeyNativeListScrollCommandAccepted" object:nil];
1671
1719
  for (UIScrollView *scrollView in _originalInsets.keyEnumerator) {
1672
1720
  [self updateNativeScrollerViewport:scrollView stickyHeight:0];
1673
1721
  }
@@ -1899,18 +1947,124 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
1899
1947
  [self applyHeaderOffset];
1900
1948
  }
1901
1949
 
1950
+ - (void)offsetDiag:(NSString *)reason page:(NSInteger)index scroll:(UIScrollView *)scroll
1951
+ {
1952
+ #if DEBUG
1953
+ NSString *key = index >= 0 && index < _pageControllers.count ? [self pageKeyForIndex:index] : @"none";
1954
+ if (![@[@"portfolio", @"perps", @"defi", @"nft", @"history"] containsObject:key]) key = @"other";
1955
+ NSNumber *saved = index >= 0 && index < _pageControllers.count ? _pageOffsets[[self pageKeyForIndex:index]] : nil;
1956
+ UIView *component = scroll == nil ? nil : RNCScrollComponentView(scroll);
1957
+ RNCCollapsiblePagerLog([NSString stringWithFormat:
1958
+ @"[HOME-OFFSET-DIAG] pager phase=%@ seq=%lu wallMs=%.0f mono=%.6f host=%p generation=%lu transitionId=%lu page=%ld key=%@ current=%ld destination=%ld transition=%d dragging=%d scroll=%p tag=%ld component=%p componentTag=%ld observed=%p ownerIndex=%ld ownerGeneration=%lu synthetic=%d cacheExists=%d cache=%.4f header=%.4f headerHeight=%ld physicalY=%.4f insetTop=%.4f insetBottom=%.4f logicalY=%.4f contentHeight=%.4f boundsHeight=%.4f",
1959
+ reason, (unsigned long)++_offsetDiagSequence, NSDate.date.timeIntervalSince1970 * 1000, CACurrentMediaTime(),
1960
+ (__bridge void *)self, (unsigned long)_generation, (unsigned long)_transitionId, (long)index, key,
1961
+ (long)_currentIndex, (long)_destinationIndex, _transitioning, _isPagerDragging,
1962
+ (__bridge void *)scroll, (long)scroll.tag, (__bridge void *)component, (long)component.tag,
1963
+ (__bridge void *)_observedScrollView, (long)_observedPageIndex, (unsigned long)_observedPageGeneration, _syntheticPageRestore, saved != nil, saved.doubleValue, _headerOffset, (long)_headerHeight,
1964
+ scroll.contentOffset.y, scroll.contentInset.top, scroll.contentInset.bottom,
1965
+ scroll.contentOffset.y + scroll.contentInset.top, scroll.contentSize.height, scroll.bounds.size.height]);
1966
+ #endif
1967
+ }
1968
+
1902
1969
  - (void)preparePageForHorizontalTransitionAtIndex:(NSInteger)pageIndex
1903
1970
  {
1904
- if (!_nativeSmoothHeaderScrollEnabled || _headerOffset >= _headerHeight ||
1971
+ [self offsetDiag:@"prepare-check" page:pageIndex scroll:nil];
1972
+ if (!_nativeSmoothHeaderScrollEnabled ||
1905
1973
  pageIndex < 0 || pageIndex >= _pageControllers.count) return;
1906
-
1907
- _pageOffsets[[self pageKeyForIndex:pageIndex]] = @(_headerOffset);
1908
1974
  UIScrollView *scrollView = [self verticalScrollViewForPageAtIndex:pageIndex];
1909
1975
  if (scrollView != nil && scrollView != _observedScrollView) {
1910
1976
  [self applyInsetsToScrollView:scrollView pageIndex:pageIndex restore:YES];
1911
1977
  }
1912
1978
  }
1913
1979
 
1980
+ - (void)beginPageStateTransition
1981
+ {
1982
+ [self cancelPagerTop];
1983
+ if (!_nativeSmoothHeaderScrollEnabled || _headerHeight <= 0 || _transitionPageOffsets != nil) return;
1984
+ _transitionSourceDisplayBody = MAX(_observedScrollView.contentOffset.y + _observedScrollView.contentInset.top - _headerHeight, 0);
1985
+ [self detachScrollObserver];
1986
+ _transitionSourceIndex = _currentIndex;
1987
+ _transitionHeaderOffset = _headerOffset;
1988
+ _transitionPageOffsets = [_pageOffsets copy];
1989
+ _transitionCompensatedPages = [NSMutableIndexSet new];
1990
+ #if DEBUG
1991
+ _transitionDiagBucket = -1;
1992
+ [self offsetDiag:[NSString stringWithFormat:@"screen-begin-source=%ld-savedQ=%.4f", (long)_transitionSourceIndex,
1993
+ [_transitionPageOffsets[self.currentPageKey] doubleValue]] page:_currentIndex scroll:nil];
1994
+ #endif
1995
+ }
1996
+
1997
+ - (void)updatePageStateTransitionProgress:(CGFloat)progress
1998
+ {
1999
+ if (_transitionPageOffsets == nil || _finishingPageStateTransition) return;
2000
+ NSInteger first = MAX(0, MIN((NSInteger)floor(progress), (NSInteger)_pageControllers.count - 1));
2001
+ NSInteger second = MIN(first + 1, (NSInteger)_pageControllers.count - 1);
2002
+ CGFloat fraction = RNCClamp(progress - first, 0, 1);
2003
+ if (!_isPagerDragging && _destinationIndex != _transitionSourceIndex) {
2004
+ first = _transitionSourceIndex;
2005
+ second = _destinationIndex;
2006
+ fraction = RNCClamp((progress - first) / (second - first), 0, 1);
2007
+ }
2008
+ CGFloat firstQ = [_transitionPageOffsets[[self pageKeyForIndex:first]] doubleValue];
2009
+ CGFloat secondQ = [_transitionPageOffsets[[self pageKeyForIndex:second]] doubleValue];
2010
+ _headerOffset = _transitionHeaderOffset;
2011
+ #if DEBUG
2012
+ NSInteger bucket = (NSInteger)floor(fraction * 4);
2013
+ if (_transitionDiagBucket != bucket) {
2014
+ _transitionDiagBucket = bucket;
2015
+ [self offsetDiag:[NSString stringWithFormat:
2016
+ @"screen-progress-kind=%@-source=%ld-target=%ld-sourceQ=%.4f-targetQ=%.4f-position=%.4f-fraction=%.4f",
2017
+ _isPagerDragging ? @"interactive" : @"programmatic-endpoint", (long)first, (long)second,
2018
+ firstQ, secondQ, progress, fraction] page:_currentIndex scroll:nil];
2019
+ }
2020
+ #endif
2021
+ _syntheticPageRestore = YES;
2022
+ NSInteger visibleFirst = MAX(0, MIN((NSInteger)floor(progress), (NSInteger)_pageControllers.count - 1));
2023
+ NSInteger visibleLast = MIN(visibleFirst + 1, (NSInteger)_pageControllers.count - 1);
2024
+ NSMutableIndexSet *displayPages = [NSMutableIndexSet indexSetWithIndexesInRange:
2025
+ NSMakeRange(visibleFirst, visibleLast - visibleFirst + 1)];
2026
+ if (!_isPagerDragging) {
2027
+ // UIView tap animation reports its endpoint once while the source exits.
2028
+ [displayPages addIndex:_transitionSourceIndex];
2029
+ [displayPages addIndex:_destinationIndex];
2030
+ }
2031
+ for (NSUInteger index = displayPages.firstIndex; index != NSNotFound;
2032
+ index = [displayPages indexGreaterThanIndex:index]) {
2033
+ UIScrollView *scroll = [self verticalScrollViewForPageAtIndex:index];
2034
+ if (scroll != nil) [self applyInsetsToScrollView:scroll pageIndex:index restore:YES];
2035
+ }
2036
+ _syntheticPageRestore = NO;
2037
+ [self layoutSharedHeaders];
2038
+ }
2039
+
2040
+ - (void)finishPageStateTransition
2041
+ {
2042
+ if (_transitionPageOffsets == nil) return;
2043
+ _finishingPageStateTransition = YES;
2044
+ _headerOffset = _transitionHeaderOffset;
2045
+ if (_currentIndex == _transitionSourceIndex) _displayBodyOverrides[self.currentPageKey] = @(_transitionSourceDisplayBody);
2046
+ else [_displayBodyOverrides removeObjectForKey:self.currentPageKey];
2047
+ if (_headerOffset >= _headerHeight) [_suspendedBodyKeys removeObject:self.currentPageKey];
2048
+ _syntheticPageRestore = YES;
2049
+ // Both completion and cancellation undo every temporary viewport offset.
2050
+ for (NSUInteger index = _transitionCompensatedPages.firstIndex; index != NSNotFound;
2051
+ index = [_transitionCompensatedPages indexGreaterThanIndex:index]) {
2052
+ UIScrollView *scroll = [self verticalScrollViewForPageAtIndex:index];
2053
+ if (scroll == nil) continue;
2054
+ [self applyInsetsToScrollView:scroll pageIndex:index restore:YES];
2055
+
2056
+ }
2057
+ _syntheticPageRestore = NO;
2058
+ _finishingPageStateTransition = NO;
2059
+ _transitionPageOffsets = nil;
2060
+ _transitionCompensatedPages = nil;
2061
+ [self layoutSharedHeaders];
2062
+ #if DEBUG
2063
+ [self offsetDiag:[NSString stringWithFormat:@"screen-finish-source=%ld-cancelled=%d", (long)_transitionSourceIndex,
2064
+ _currentIndex == _transitionSourceIndex] page:_currentIndex scroll:nil];
2065
+ #endif
2066
+ }
2067
+
1914
2068
  #pragma mark - React mounting
1915
2069
 
1916
2070
  // OneKey patch: Fabric can recycle a removed list as a descendant of our header
@@ -1958,8 +2112,16 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
1958
2112
  _releasedPageScrollStates[pageKey] = state;
1959
2113
  // Retained pages may be visually aligned to the expanded header while
1960
2114
  // their saved offsets still hold the user's independent list position.
1961
- if (scrollView == _observedScrollView || _pageOffsets[pageKey] == nil) {
2115
+ BOOL ownsObservedPage = scrollView == _observedScrollView &&
2116
+ (!_nativeSmoothHeaderScrollEnabled ||
2117
+ (_observedPageGeneration == _generation && _observedPageIndex == index &&
2118
+ [_observedPageKey isEqualToString:pageKey]));
2119
+ if (!_nativeSmoothHeaderScrollEnabled && _transitionPageOffsets == nil && !_syntheticPageRestore &&
2120
+ ![_pendingPageRestoreKeys containsObject:pageKey] &&
2121
+ (ownsObservedPage || _pageOffsets[pageKey] == nil)) {
2122
+ [self offsetDiag:@"mount-release-save-before" page:index scroll:scrollView];
1962
2123
  _pageOffsets[pageKey] = @(scrollView.contentOffset.y + scrollView.contentInset.top);
2124
+ [self offsetDiag:@"mount-release-save-after" page:index scroll:scrollView];
1963
2125
  }
1964
2126
  break;
1965
2127
  }
@@ -2213,6 +2375,7 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
2213
2375
  }
2214
2376
  }
2215
2377
  [self layoutSharedHeaders];
2378
+ [self updateHeaderSettledStateForceBusy:NO];
2216
2379
  dispatch_async(dispatch_get_main_queue(), ^{
2217
2380
  if (!self->_isBeingRecycled) {
2218
2381
  [self prepareAdjacentPageInsets];
@@ -2223,6 +2386,8 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
2223
2386
 
2224
2387
  - (void)prepareForRecycle
2225
2388
  {
2389
+ [self cancelHeaderSettledCheck];
2390
+ [self publishHeaderSettledAtTop:NO identityChanged:NO];
2226
2391
  _isBeingRecycled = YES;
2227
2392
  _generation++;
2228
2393
  [_mountingRemovedScrollViews removeAllObjects];
@@ -2243,7 +2408,9 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
2243
2408
  if (appliedTop != nil && scrollView.refreshControl != nil) {
2244
2409
  original.top += MAX(0, scrollView.contentInset.top - appliedTop.doubleValue);
2245
2410
  }
2246
- scrollView.contentInset = original;
2411
+ [self offsetDiag:@"release-original-inset-before" page:_currentIndex scroll:scrollView];
2412
+ scrollView.contentInset = original;
2413
+ [self offsetDiag:@"release-original-inset-after" page:_currentIndex scroll:scrollView];
2247
2414
  }
2248
2415
  NSValue *indicatorValue = [_originalIndicatorInsets objectForKey:scrollView];
2249
2416
  if (indicatorValue != nil) {
@@ -2295,6 +2462,15 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
2295
2462
  _pagerScrollView = nil;
2296
2463
  [super prepareForRecycle];
2297
2464
  [_pageOffsets removeAllObjects];
2465
+ [_commandBodyKeys removeAllObjects];
2466
+ [_alignedBodyKeys removeAllObjects];
2467
+ [_suspendedBodyKeys removeAllObjects];
2468
+ [_displayBodyOverrides removeAllObjects];
2469
+ [_pendingPageRestoreKeys removeAllObjects];
2470
+ _transitionPageOffsets = nil;
2471
+ _transitionCompensatedPages = nil;
2472
+ _syntheticPageRestore = NO;
2473
+ _finishingPageStateTransition = NO;
2298
2474
  _pageKeys = @[];
2299
2475
  _retainedPages = @"[]";
2300
2476
  _currentIndex = 0;
@@ -2627,6 +2803,9 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
2627
2803
  - (void)attachScrollObserverForCurrentPageAfterTransition:(BOOL)selectionSettled
2628
2804
  {
2629
2805
  if (_isBeingRecycled) return;
2806
+ if (_nativeSmoothHeaderScrollEnabled &&
2807
+ (_transitioning || _isPagerDragging || _transitionPageOffsets != nil) && !selectionSettled) return;
2808
+ if (selectionSettled) [self finishPageStateTransition];
2630
2809
  if (_needsScrollObserverReattachAfterMount) {
2631
2810
  if (self.window == nil || ((_transitioning || _isPagerDragging) && !selectionSettled)) return;
2632
2811
  [self prepareAdjacentPageInsets];
@@ -2685,24 +2864,38 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
2685
2864
  if (_pagerScrollView != nil && !_nativeSmoothHeaderScrollEnabled && !_needsPropsReapply) {
2686
2865
  [candidate.panGestureRecognizer requireGestureRecognizerToFail:_pagerScrollView.panGestureRecognizer];
2687
2866
  }
2688
- if (candidate == _observedScrollView) {
2867
+ if (candidate == _observedScrollView &&
2868
+ (!_nativeSmoothHeaderScrollEnabled ||
2869
+ ([_observedPageKey isEqualToString:self.currentPageKey] &&
2870
+ _observedPageIndex == _currentIndex && _observedPageGeneration == _generation))) {
2689
2871
  _needsScrollObserverReattachAfterMount = NO;
2872
+ if ([_pendingPageRestoreKeys containsObject:self.currentPageKey]) {
2873
+ [self applyInsetsToScrollView:candidate pageIndex:_currentIndex restore:YES];
2874
+ }
2690
2875
  [self attachSharedHeadersToScrollView:candidate];
2691
2876
  [self updateSharedHeaderPressCancellationGesture];
2692
2877
  return;
2693
2878
  }
2694
2879
 
2695
- BOOL replacingCurrentScrollView = _observingContentOffset;
2880
+ [self offsetDiag:@"observer-candidate" page:_currentIndex scroll:candidate];
2881
+ BOOL replacingCurrentScrollView = _observingContentOffset &&
2882
+ (!_nativeSmoothHeaderScrollEnabled || [_observedPageKey isEqualToString:self.currentPageKey]);
2696
2883
  [self detachScrollObserver];
2697
2884
  if (replacingCurrentScrollView) {
2698
- // A list replaced by an empty RN view keeps collapse, not the old row offset.
2699
- _pageOffsets[self.currentPageKey] = @(_headerOffset);
2885
+ // Legacy replacement discards body state; smooth replacement waits for layout.
2886
+ [self offsetDiag:@"observer-replacement-cache-before" page:_currentIndex scroll:candidate];
2887
+ if (!_nativeSmoothHeaderScrollEnabled) _pageOffsets[self.currentPageKey] = @(_headerOffset);
2888
+ [self offsetDiag:@"observer-replacement-cache-after" page:_currentIndex scroll:candidate];
2700
2889
  }
2701
2890
  _observedScrollView = candidate;
2891
+ _observedPageKey = [self.currentPageKey copy];
2892
+ _observedPageIndex = _currentIndex;
2893
+ _observedPageGeneration = _generation;
2894
+ [self offsetDiag:@"observer-attached-before-restore" page:_currentIndex scroll:candidate];
2702
2895
  [self applyInsetsToScrollView:candidate pageIndex:_currentIndex restore:YES];
2703
2896
  [candidate addObserver:self
2704
2897
  forKeyPath:@"contentOffset"
2705
- options:NSKeyValueObservingOptionNew
2898
+ options:NSKeyValueObservingOptionOld | NSKeyValueObservingOptionNew
2706
2899
  context:RNCCollapsiblePagerContentOffsetContext];
2707
2900
  [candidate addObserver:self
2708
2901
  forKeyPath:@"contentInset"
@@ -2718,14 +2911,29 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
2718
2911
  _currentLogicalOffset = candidate.contentOffset.y + candidate.contentInset.top;
2719
2912
  [self attachSharedHeadersToScrollView:candidate];
2720
2913
  [self updateSharedHeaderPressCancellationGesture];
2914
+ [self updateHeaderSettledStateForceBusy:NO];
2721
2915
  }
2722
2916
 
2723
2917
  - (void)detachScrollObserver
2724
2918
  {
2919
+ [self cancelHeaderSettledCheck];
2920
+ _headerCommandOwner = nil;
2921
+ _headerSettledGeometry = nil;
2922
+ _headerSettledWasBusy = YES;
2923
+ [self publishHeaderSettledAtTop:NO identityChanged:NO];
2924
+ [self cancelPagerTop];
2725
2925
  UIScrollView *scrollView = _observedScrollView;
2726
2926
  if (scrollView != nil) {
2727
2927
  [scrollView.panGestureRecognizer removeTarget:self action:@selector(observedListPanChanged:)];
2728
- _pageOffsets[self.currentPageKey] = @(scrollView.contentOffset.y + scrollView.contentInset.top);
2928
+ [self offsetDiag:@"detach-save-before" page:_currentIndex scroll:scrollView];
2929
+ NSString *ownerKey = _nativeSmoothHeaderScrollEnabled ? _observedPageKey : self.currentPageKey;
2930
+ BOOL ownerKeyExists = ownerKey != nil && ([_pageKeys containsObject:ownerKey] ||
2931
+ (_observedPageIndex >= 0 && _observedPageIndex < _pageControllers.count &&
2932
+ [[self pageKeyForIndex:_observedPageIndex] isEqualToString:ownerKey]));
2933
+ if (ownerKey != nil && !_nativeSmoothHeaderScrollEnabled) {
2934
+ _pageOffsets[ownerKey] = @(scrollView.contentOffset.y + scrollView.contentInset.top);
2935
+ }
2936
+ [self offsetDiag:@"detach-save-after" page:_currentIndex scroll:scrollView];
2729
2937
  if (_observingContentOffset) {
2730
2938
  [scrollView removeObserver:self
2731
2939
  forKeyPath:@"contentOffset"
@@ -2738,8 +2946,13 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
2738
2946
  context:RNCCollapsiblePagerContentOffsetContext];
2739
2947
  }
2740
2948
  }
2949
+ if (!_nativeSmoothHeaderScrollEnabled && _observedPageKey != nil) {
2950
+ [_alignedBodyKeys removeObject:_observedPageKey];
2951
+ }
2741
2952
  _observingContentOffset = NO;
2742
2953
  _observedScrollView = nil;
2954
+ _observedPageKey = nil;
2955
+ _observedPageIndex = NSNotFound;
2743
2956
  if ([scrollView.superview isKindOfClass:RNCNativeScrollerComponentView.class]) {
2744
2957
  [(RNCNativeScrollerComponentView *)scrollView.superview stopScrolling];
2745
2958
  }
@@ -2925,6 +3138,10 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
2925
3138
 
2926
3139
  - (void)observedListPanChanged:(UIPanGestureRecognizer *)recognizer
2927
3140
  {
3141
+ if (recognizer == _observedScrollView.panGestureRecognizer) {
3142
+ [self updateHeaderSettledStateForceBusy:
3143
+ recognizer.state == UIGestureRecognizerStateBegan || recognizer.state == UIGestureRecognizerStateChanged];
3144
+ }
2928
3145
  if (!_nativeSmoothHeaderScrollEnabled || recognizer != _observedScrollView.panGestureRecognizer) {
2929
3146
  return;
2930
3147
  }
@@ -3010,7 +3227,9 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
3010
3227
  [self releaseRefreshControlForScrollView:scrollView];
3011
3228
  if (scrollView == _observedScrollView) {
3012
3229
  [self detachScrollObserver];
3013
- if (resetSavedOffset) _pageOffsets[self.currentPageKey] = @(_headerOffset);
3230
+ [self offsetDiag:resetSavedOffset ? @"release-reset-before" : @"release-retain-before" page:_currentIndex scroll:scrollView];
3231
+ if (resetSavedOffset && !_nativeSmoothHeaderScrollEnabled) _pageOffsets[self.currentPageKey] = @(_headerOffset);
3232
+ [self offsetDiag:@"release-reset-after" page:_currentIndex scroll:scrollView];
3014
3233
  }
3015
3234
  if (scrollView == _sharedHeaderScrollView) {
3016
3235
  [self restoreSharedHeadersToContainer];
@@ -3030,8 +3249,10 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
3030
3249
  [_originalDirectionalLockEnabled objectForKey:scrollView].boolValue;
3031
3250
  scrollView.contentInsetAdjustmentBehavior = (UIScrollViewContentInsetAdjustmentBehavior)
3032
3251
  [_originalInsetAdjustmentBehavior objectForKey:scrollView].integerValue;
3252
+ [self offsetDiag:@"release-original-offset-before" page:_currentIndex scroll:scrollView];
3033
3253
  [scrollView setContentOffset:CGPointMake(scrollView.contentOffset.x, logicalOffset - original.top)
3034
3254
  animated:NO];
3255
+ [self offsetDiag:@"release-original-offset-after" page:_currentIndex scroll:scrollView];
3035
3256
  [_originalInsets removeObjectForKey:scrollView];
3036
3257
  [_appliedTopInsets removeObjectForKey:scrollView];
3037
3258
  [_originalIndicatorInsets removeObjectForKey:scrollView];
@@ -3060,8 +3281,9 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
3060
3281
  pageIndex:(NSInteger)pageIndex
3061
3282
  restore:(BOOL)restore
3062
3283
  {
3063
- // OneKey patch: a surviving list keeps its full offset. A replacement keeps
3064
- // only header collapse, even if Fabric reused the same native scroll object.
3284
+ // Smooth pages retain their complete offset while replacement identity and
3285
+ // layout settle. Legacy pages retain the historical header-only policy.
3286
+ [self offsetDiag:restore ? @"restore-enter" : @"insets-enter" page:pageIndex scroll:scrollView];
3065
3287
  NSString *pageKey = [self pageKeyForIndex:pageIndex];
3066
3288
  RNCCollapsiblePagerReleasedScrollState *released = _releasedPageScrollStates[pageKey];
3067
3289
  BOOL deferredReleasedIdentity = NO;
@@ -3107,8 +3329,13 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
3107
3329
  deferredReleasedIdentity = now - released.pendingFallbackDetectedAt <
3108
3330
  RNCCollapsiblePagerFallbackDebounceInterval;
3109
3331
  }
3332
+ #if DEBUG
3333
+ [self offsetDiag:[NSString stringWithFormat:@"released-identity-match=%d-deferred=%d-oldTag=%ld-oldScroll=%p", sameList, deferredReleasedIdentity, (long)released.componentTag, (__bridge void *)released.scrollView] page:pageIndex scroll:scrollView];
3334
+ #endif
3110
3335
  if (!deferredReleasedIdentity) {
3111
- if (!sameList) _pageOffsets[pageKey] = @(_headerOffset);
3336
+ [self offsetDiag:@"released-identity-cache-before" page:pageIndex scroll:scrollView];
3337
+ if (!sameList && !_nativeSmoothHeaderScrollEnabled) _pageOffsets[pageKey] = @(_headerOffset);
3338
+ [self offsetDiag:@"released-identity-cache-after" page:pageIndex scroll:scrollView];
3112
3339
  [_releasedPageScrollStates removeObjectForKey:pageKey];
3113
3340
  }
3114
3341
  }
@@ -3156,29 +3383,60 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
3156
3383
  if (pageIndex == _currentIndex) [self updateHeaderForScrollView:scrollView];
3157
3384
  return;
3158
3385
  }
3386
+ BOOL previousSyntheticRestore = _syntheticPageRestore;
3387
+ if (_nativeSmoothHeaderScrollEnabled || [_alignedBodyKeys containsObject:pageKey]) _syntheticPageRestore = YES;
3388
+ [self offsetDiag:@"inset-assignment-before" page:pageIndex scroll:scrollView];
3159
3389
  scrollView.contentInset = next;
3390
+ [self offsetDiag:@"inset-assignment-after" page:pageIndex scroll:scrollView];
3160
3391
  UIEdgeInsets indicatorInsets = scrollView.verticalScrollIndicatorInsets;
3161
3392
  indicatorInsets.top = pagerTopInset;
3162
3393
  scrollView.verticalScrollIndicatorInsets = indicatorInsets;
3163
3394
  scrollView.alwaysBounceVertical = YES;
3164
3395
 
3165
3396
  CGFloat targetOffset = previousLogicalOffset - next.top;
3397
+ CGFloat intendedLogicalOffset = previousLogicalOffset;
3166
3398
  if (restore) {
3167
- NSNumber *saved = _pageOffsets[[self pageKeyForIndex:pageIndex]];
3399
+ NSNumber *saved = _transitionPageOffsets != nil
3400
+ ? _transitionPageOffsets[pageKey] : _pageOffsets[pageKey];
3168
3401
  // The sticky slot can change height between pages. Cache offsets relative
3169
3402
  // to content start so a round trip does not add that height difference.
3170
- CGFloat restoredLogicalOffset = deferredReleasedIdentity || saved == nil
3171
- ? _headerOffset
3172
- : saved.doubleValue;
3173
- // While the shared header is visible, retained pages must agree on its
3174
- // collapse position. Deep offsets remain independent after full collapse.
3175
- if (_nativeSmoothHeaderScrollEnabled && _headerOffset < _headerHeight) {
3176
- restoredLogicalOffset = _headerOffset;
3403
+ CGFloat restoredLogicalOffset;
3404
+ if (_nativeSmoothHeaderScrollEnabled && _headerHeight > 0) {
3405
+ [_alignedBodyKeys addObject:pageKey];
3406
+ if (_headerOffset < _headerHeight && saved.doubleValue > 0) [_suspendedBodyKeys addObject:pageKey];
3407
+ CGFloat body = saved.doubleValue;
3408
+ if (_transitionPageOffsets != nil && pageIndex == _transitionSourceIndex) body = _transitionSourceDisplayBody;
3409
+ else if (_transitionPageOffsets != nil) [_displayBodyOverrides removeObjectForKey:pageKey];
3410
+ else if (_displayBodyOverrides[pageKey] != nil) body = _displayBodyOverrides[pageKey].doubleValue;
3411
+ restoredLogicalOffset = _headerOffset + (_headerOffset >= _headerHeight ? body : 0);
3412
+ if (_transitionPageOffsets != nil) [_transitionCompensatedPages addIndex:pageIndex];
3413
+ if (deferredReleasedIdentity || scrollView.bounds.size.height <= 0 || scrollView.contentSize.height <= 0) {
3414
+ [_pendingPageRestoreKeys addObject:pageKey];
3415
+ _syntheticPageRestore = previousSyntheticRestore;
3416
+ return;
3417
+ }
3418
+ [_pendingPageRestoreKeys removeObject:pageKey];
3419
+ } else {
3420
+ restoredLogicalOffset = deferredReleasedIdentity || saved == nil ? _headerOffset : saved.doubleValue;
3421
+ }
3422
+ #if DEBUG
3423
+ [self offsetDiag:[NSString stringWithFormat:@"restore-candidate-saved=%.4f-headerAlignedLogical=%.4f", saved.doubleValue, restoredLogicalOffset] page:pageIndex scroll:scrollView];
3424
+ #endif
3425
+ intendedLogicalOffset = restoredLogicalOffset;
3426
+ // Cancellation restores the visible source, while retaining its unreachable reading intent.
3427
+ if (_nativeSmoothHeaderScrollEnabled && _headerOffset >= _headerHeight &&
3428
+ _displayBodyOverrides[pageKey] != nil) {
3429
+ intendedLogicalOffset = MAX(intendedLogicalOffset, _headerOffset + _pageOffsets[pageKey].doubleValue);
3177
3430
  }
3178
3431
  targetOffset = restoredLogicalOffset - next.top;
3179
- targetOffset = MAX(targetOffset, -next.top + _headerOffset);
3432
+ if (!_nativeSmoothHeaderScrollEnabled || _headerHeight <= 0) {
3433
+ targetOffset = MAX(targetOffset, -next.top + _headerOffset);
3434
+ }
3180
3435
  // An inactive list may shrink after its offset observer was detached.
3181
3436
  // Do not overwrite its corrected offset with a now-unreachable cached one.
3437
+ #if DEBUG
3438
+ [self offsetDiag:[NSString stringWithFormat:@"restore-clamp-before-candidate=%.4f-max=%.4f", targetOffset, MAX(-next.top, scrollView.contentSize.height - scrollView.bounds.size.height + next.bottom)] page:pageIndex scroll:scrollView];
3439
+ #endif
3182
3440
  targetOffset = RNCLimitRestoredScrollOffset(
3183
3441
  targetOffset,
3184
3442
  scrollView.contentSize.height,
@@ -3187,11 +3445,23 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
3187
3445
  next.bottom
3188
3446
  );
3189
3447
  }
3448
+ #if DEBUG
3449
+ [self offsetDiag:[NSString stringWithFormat:@"restore-final-target=%.4f-restore=%d", targetOffset, restore] page:pageIndex scroll:scrollView];
3450
+ #endif
3190
3451
  // A bottom-inset change must not cancel UIKit's refresh or bounce settlement.
3191
3452
  if (restore || previousTopInset != next.top) {
3192
3453
  [scrollView setContentOffset:CGPointMake(scrollView.contentOffset.x, targetOffset) animated:NO];
3193
3454
  }
3194
- if (pageIndex == _currentIndex) [self updateHeaderForScrollView:scrollView];
3455
+ if (_nativeSmoothHeaderScrollEnabled && restore && targetOffset + next.top + 0.5 < intendedLogicalOffset) {
3456
+ [_pendingPageRestoreKeys addObject:pageKey];
3457
+ }
3458
+ _syntheticPageRestore = previousSyntheticRestore;
3459
+ if (pageIndex == _currentIndex) {
3460
+ _currentLogicalOffset = scrollView.contentOffset.y + scrollView.contentInset.top;
3461
+ if (!_nativeSmoothHeaderScrollEnabled) [self updateHeaderForScrollView:scrollView];
3462
+ else [self applyHeaderOffset];
3463
+ }
3464
+ [self offsetDiag:@"restore-post" page:pageIndex scroll:scrollView];
3195
3465
  }
3196
3466
 
3197
3467
  - (void)updateNativeScrollerViewport:(UIScrollView *)scrollView stickyHeight:(CGFloat)height
@@ -3201,6 +3471,75 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
3201
3471
  }
3202
3472
  }
3203
3473
 
3474
+ - (void)nativeListScrollCommandAccepted:(NSNotification *)notification
3475
+ {
3476
+ UIScrollView *scrollView = notification.object;
3477
+ if (scrollView != _observedScrollView ||
3478
+ _observedPageKey == nil || ![_observedPageKey isEqualToString:self.currentPageKey] ||
3479
+ _observedPageIndex != _currentIndex || _observedPageGeneration != _generation ||
3480
+ _transitioning || _isPagerDragging || _transitionPageOffsets != nil || _syntheticPageRestore) return;
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];
3486
+ if ([self matchesPagerTop:scrollView]) {
3487
+ if (phase == 0) [self cancelPagerTop];
3488
+ else return;
3489
+ }
3490
+ if (phase == 0) [_alignedBodyKeys removeObject:_observedPageKey];
3491
+ if (!_nativeSmoothHeaderScrollEnabled) return;
3492
+ _currentLogicalOffset = scrollView.contentOffset.y + scrollView.contentInset.top;
3493
+ if (phase == 1) { [_commandBodyKeys addObject:_observedPageKey]; return; }
3494
+ if (phase == 2) {
3495
+ if (![_commandBodyKeys containsObject:_observedPageKey]) return;
3496
+ _pageOffsets[_observedPageKey] = @(MAX(_currentLogicalOffset - _headerHeight, 0));
3497
+ [self updateHeaderForScrollView:scrollView];
3498
+ [_commandBodyKeys removeObject:_observedPageKey];
3499
+ return;
3500
+ }
3501
+ [_commandBodyKeys removeObject:_observedPageKey];
3502
+ [_pendingPageRestoreKeys removeObject:_observedPageKey];
3503
+ [_alignedBodyKeys removeObject:_observedPageKey];
3504
+ [_suspendedBodyKeys removeObject:_observedPageKey];
3505
+ [_displayBodyOverrides removeObjectForKey:_observedPageKey];
3506
+ _currentLogicalOffset = scrollView.contentOffset.y + scrollView.contentInset.top;
3507
+ BOOL explicitTop = [notification.userInfo[@"explicitTop"] boolValue];
3508
+ _pageOffsets[_observedPageKey] = @(explicitTop ? 0 : MAX(_currentLogicalOffset - _headerHeight, 0));
3509
+ if (explicitTop) {
3510
+ _headerOffset = 0;
3511
+ [self applyHeaderOffset];
3512
+ }
3513
+ [self offsetDiag:@"list-command-body-takeover" page:_currentIndex scroll:scrollView];
3514
+ }
3515
+
3516
+ - (void)nativeScrollerWillReceiveScrollCommand:(RNCNativeScrollerComponentView *)scroller
3517
+ {
3518
+ UIScrollView *scrollView = scroller.scrollView;
3519
+ NSString *ownerKey = _observedPageKey;
3520
+ if (scrollView != _observedScrollView ||
3521
+ ownerKey == nil || ![ownerKey isEqualToString:self.currentPageKey] ||
3522
+ _observedPageIndex != _currentIndex || _currentIndex < 0 || _currentIndex >= _pageControllers.count ||
3523
+ _observedPageGeneration != _generation || _transitioning || _isPagerDragging ||
3524
+ _transitionPageOffsets != nil || _syntheticPageRestore) return;
3525
+ if (!_dispatchingPagerTop) {
3526
+ [self cancelPagerTop];
3527
+ [_alignedBodyKeys removeObject:ownerKey];
3528
+ }
3529
+ if (!_nativeSmoothHeaderScrollEnabled) return;
3530
+ [self offsetDiag:@"command-pending-cancel-before" page:_observedPageIndex scroll:scrollView];
3531
+ [_pendingPageRestoreKeys removeObject:ownerKey];
3532
+ _currentLogicalOffset = scrollView.contentOffset.y + scrollView.contentInset.top;
3533
+ [_commandBodyKeys removeObject:ownerKey];
3534
+ [_alignedBodyKeys removeObject:ownerKey];
3535
+ [_suspendedBodyKeys removeObject:ownerKey];
3536
+ [_displayBodyOverrides removeObjectForKey:ownerKey];
3537
+ _pageOffsets[ownerKey] = @(MAX(_currentLogicalOffset - _headerHeight, 0));
3538
+ [self updateHeaderForScrollView:scrollView];
3539
+ [self offsetDiag:@"command-pending-cancel-after" page:_observedPageIndex scroll:scrollView];
3540
+ }
3541
+
3542
+
3204
3543
  - (void)nativeScroller:(RNCNativeScrollerComponentView *)scroller
3205
3544
  callerInsetChangedFrom:(UIEdgeInsets)previous to:(UIEdgeInsets)next
3206
3545
  {
@@ -3252,25 +3591,89 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
3252
3591
  if (context == RNCScrollRefreshControlContext) {
3253
3592
  UIScrollView *scrollView = (UIScrollView *)object;
3254
3593
  [self updateRefreshControlForScrollView:scrollView];
3255
- if (scrollView == _observedScrollView) [self updateHeaderForScrollView:scrollView];
3594
+ if (scrollView == _observedScrollView && !_syntheticPageRestore &&
3595
+ (!_nativeSmoothHeaderScrollEnabled || (!_transitioning && _transitionPageOffsets == nil))) {
3596
+ [self updateHeaderForScrollView:scrollView];
3597
+ }
3256
3598
  return;
3257
3599
  }
3258
3600
  if (context == RNCCollapsiblePagerContentOffsetContext && object == _observedScrollView) {
3601
+ if (![change[NSKeyValueChangeOldKey] isEqual:change[NSKeyValueChangeNewKey]]) {
3602
+ [self updateHeaderSettledStateForceBusy:YES];
3603
+ }
3604
+ if ((_nativeSmoothHeaderScrollEnabled || [_alignedBodyKeys containsObject:_observedPageKey]) &&
3605
+ (_observedPageGeneration != _generation || _observedPageKey == nil ||
3606
+ _observedPageIndex != _currentIndex || ![_observedPageKey isEqualToString:self.currentPageKey] ||
3607
+ _transitioning || _isPagerDragging ||
3608
+ _transitionPageOffsets != nil || _syntheticPageRestore)) return;
3609
+ UIScrollView *ownedScroll = (UIScrollView *)object;
3610
+ NSValue *oldPoint = change[NSKeyValueChangeOldKey];
3611
+ NSValue *newPoint = change[NSKeyValueChangeNewKey];
3612
+ if ([keyPath isEqualToString:@"contentOffset"] && [self matchesPagerTop:ownedScroll] && ownedScroll.panGestureRecognizer.state == UIGestureRecognizerStateChanged &&
3613
+ [oldPoint isKindOfClass:NSValue.class] && [newPoint isKindOfClass:NSValue.class] &&
3614
+ oldPoint.CGPointValue.y != newPoint.CGPointValue.y) {
3615
+ CGPoint interruptedOffset = ownedScroll.contentOffset;
3616
+ [self cancelPagerTop];
3617
+ [ownedScroll setContentOffset:interruptedOffset animated:NO];
3618
+ }
3619
+ if ([keyPath isEqualToString:@"contentOffset"] &&
3620
+ ownedScroll.panGestureRecognizer.state == UIGestureRecognizerStateChanged &&
3621
+ [oldPoint isKindOfClass:NSValue.class] && [newPoint isKindOfClass:NSValue.class] &&
3622
+ oldPoint.CGPointValue.y != newPoint.CGPointValue.y) {
3623
+ [_alignedBodyKeys removeObject:_observedPageKey];
3624
+ }
3625
+ if (_nativeSmoothHeaderScrollEnabled && [keyPath isEqualToString:@"contentOffset"] &&
3626
+ ownedScroll.panGestureRecognizer.state == UIGestureRecognizerStateChanged &&
3627
+ [oldPoint isKindOfClass:NSValue.class] && [newPoint isKindOfClass:NSValue.class] &&
3628
+ oldPoint.CGPointValue.y != newPoint.CGPointValue.y) {
3629
+ [self cancelPagerTop];
3630
+ [_commandBodyKeys removeObject:_observedPageKey];
3631
+ [_pendingPageRestoreKeys removeObject:_observedPageKey];
3632
+ [_alignedBodyKeys removeObject:_observedPageKey];
3633
+ [_suspendedBodyKeys removeObject:_observedPageKey];
3634
+ [_displayBodyOverrides removeObjectForKey:_observedPageKey];
3635
+ }
3636
+ if (_nativeSmoothHeaderScrollEnabled && [_pendingPageRestoreKeys containsObject:_observedPageKey]) {
3637
+ if ([keyPath isEqualToString:@"contentSize"] &&
3638
+ ![change[NSKeyValueChangeOldKey] isEqual:change[NSKeyValueChangeNewKey]]) {
3639
+ if (_headerOffset >= _headerHeight) [_displayBodyOverrides removeObjectForKey:_observedPageKey];
3640
+ [self applyInsetsToScrollView:_observedScrollView pageIndex:_observedPageIndex restore:YES];
3641
+ }
3642
+ return;
3643
+ }
3259
3644
  if ([keyPath isEqualToString:@"contentInset"]) {
3260
- [self updateHeaderForScrollView:(UIScrollView *)object];
3645
+ if (!_nativeSmoothHeaderScrollEnabled) [self updateHeaderForScrollView:(UIScrollView *)object];
3261
3646
  return;
3262
3647
  }
3263
3648
  if ([keyPath isEqualToString:@"contentSize"]) {
3264
3649
  NSValue *oldSize = change[NSKeyValueChangeOldKey];
3265
3650
  NSValue *newSize = change[NSKeyValueChangeNewKey];
3266
3651
  if (![oldSize isEqual:newSize]) {
3267
- [self applyInsetsToScrollView:_observedScrollView pageIndex:_currentIndex restore:NO];
3652
+ BOOL restoreAlignedBody =
3653
+ _observedPageGeneration == _generation && _observedPageIndex == _currentIndex &&
3654
+ _observedPageKey != nil && [_observedPageKey isEqualToString:self.currentPageKey] &&
3655
+ [_alignedBodyKeys containsObject:_observedPageKey] &&
3656
+ ![_commandBodyKeys containsObject:_observedPageKey] &&
3657
+ _observedScrollView.panGestureRecognizer.state != UIGestureRecognizerStateChanged &&
3658
+ !_observedScrollView.isDragging && !_observedScrollView.isDecelerating;
3659
+ [self applyInsetsToScrollView:_observedScrollView pageIndex:_currentIndex restore:restoreAlignedBody];
3268
3660
  }
3269
3661
  return;
3270
3662
  }
3271
3663
  UIScrollView *scrollView = (UIScrollView *)object;
3272
3664
  _currentLogicalOffset = scrollView.contentOffset.y + scrollView.contentInset.top;
3273
- _pageOffsets[self.currentPageKey] = @(_currentLogicalOffset);
3665
+ #if DEBUG
3666
+ BOOL offsetDiagChanged = _pageOffsets[self.currentPageKey] == nil || _pageOffsets[self.currentPageKey].doubleValue != (_nativeSmoothHeaderScrollEnabled ? MAX(_currentLogicalOffset - _headerHeight, 0) : _currentLogicalOffset);
3667
+ if (offsetDiagChanged) [self offsetDiag:@"kvo-cache-before" page:_currentIndex scroll:scrollView];
3668
+ #endif
3669
+ if ([_alignedBodyKeys containsObject:_observedPageKey] ||
3670
+ (_nativeSmoothHeaderScrollEnabled && scrollView.panGestureRecognizer.state != UIGestureRecognizerStateChanged &&
3671
+ !scrollView.isDecelerating && ![_commandBodyKeys containsObject:_observedPageKey])) return;
3672
+ _pageOffsets[_nativeSmoothHeaderScrollEnabled ? _observedPageKey : self.currentPageKey] =
3673
+ @(_nativeSmoothHeaderScrollEnabled ? MAX(_currentLogicalOffset - _headerHeight, 0) : _currentLogicalOffset);
3674
+ #if DEBUG
3675
+ if (offsetDiagChanged) [self offsetDiag:@"kvo-cache-after" page:_currentIndex scroll:scrollView];
3676
+ #endif
3274
3677
  [self updateHeaderForScrollView:scrollView];
3275
3678
  return;
3276
3679
  }
@@ -3279,6 +3682,16 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
3279
3682
 
3280
3683
  - (void)updateHeaderForScrollView:(UIScrollView *)scrollView
3281
3684
  {
3685
+ if ([self matchesPagerTop:scrollView]) {
3686
+ _currentLogicalOffset = scrollView.contentOffset.y + scrollView.contentInset.top;
3687
+ _headerOffset = _pagerTopLogicalTarget;
3688
+ [self applyHeaderOffset];
3689
+ [self finishPagerTopIfReached];
3690
+ return;
3691
+ }
3692
+ if ([_alignedBodyKeys containsObject:self.currentPageKey] ||
3693
+ (_nativeSmoothHeaderScrollEnabled && (_syntheticPageRestore || _transitionPageOffsets != nil ||
3694
+ [_pendingPageRestoreKeys containsObject:self.currentPageKey]))) return;
3282
3695
  CGFloat logicalOffset = scrollView.contentOffset.y + scrollView.contentInset.top;
3283
3696
  _currentLogicalOffset = logicalOffset;
3284
3697
  _headerOffset = MIN(MAX(logicalOffset, 0), _headerHeight);
@@ -3287,6 +3700,7 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
3287
3700
 
3288
3701
  - (void)applyHeaderOffset
3289
3702
  {
3703
+ [self updateHeaderSettledStateForceBusy:NO];
3290
3704
  BOOL attachedToList = _nativeSmoothHeaderScrollEnabled &&
3291
3705
  _sharedHeaderScrollView != nil && !_sharedHeadersLiftedForPagerTransition;
3292
3706
  if (attachedToList) {
@@ -3322,6 +3736,7 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
3322
3736
 
3323
3737
  - (void)goTo:(NSInteger)index animated:(BOOL)animated
3324
3738
  {
3739
+ [self offsetDiag:@"go-to" page:index scroll:_observedScrollView];
3325
3740
  if (_isBeingRecycled || index < 0 || index >= _pageControllers.count) return;
3326
3741
  _hasReceivedPageCommand = YES;
3327
3742
  _hasAppliedInitialPage = YES;
@@ -3347,6 +3762,7 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
3347
3762
  return;
3348
3763
  }
3349
3764
 
3765
+ [self beginPageStateTransition];
3350
3766
  [self preparePageForHorizontalTransitionAtIndex:index];
3351
3767
  _destinationIndex = index;
3352
3768
  [self liftSharedHeadersForPagerTransition:@"programmatic"];
@@ -3366,6 +3782,7 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
3366
3782
  void (^animations)(void) = ^{
3367
3783
  [self->_pagerScrollView setContentOffset:CGPointMake(targetOffset, 0)];
3368
3784
  [self->_nativeTabBarView setProgress:index];
3785
+ [self updatePageStateTransitionProgress:index];
3369
3786
  };
3370
3787
  void (^completion)(BOOL) = ^(BOOL finished) {
3371
3788
  __strong __typeof__(weakSelf) self = weakSelf;
@@ -3373,6 +3790,7 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
3373
3790
  self->_transitionId != capturedTransition || self->_isBeingRecycled) return;
3374
3791
  self->_pagerScrollView.scrollEnabled = self->_scrollEnabled;
3375
3792
  if (finished) {
3793
+ [self detachScrollObserver];
3376
3794
  self->_currentIndex = index;
3377
3795
  self->_destinationIndex = index;
3378
3796
  [self->_nativeTabBarView setProgress:index];
@@ -3410,6 +3828,7 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
3410
3828
  if (self == nil || self->_generation != capturedGeneration ||
3411
3829
  self->_transitionId != capturedTransition || self->_isBeingRecycled) return;
3412
3830
  if (finished) {
3831
+ [self detachScrollObserver];
3413
3832
  self->_currentIndex = index;
3414
3833
  self->_destinationIndex = index;
3415
3834
  [self->_nativeTabBarView setProgress:index];
@@ -3434,6 +3853,7 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
3434
3853
  dispatch_async(dispatch_get_main_queue(), ^{
3435
3854
  if (self->_generation != generation || self->_transitionId != transition ||
3436
3855
  self->_isBeingRecycled) return;
3856
+ [self finishPageStateTransition];
3437
3857
  self->_transitioning = NO;
3438
3858
  if (self->_hasPendingDirectNativePagerChange) {
3439
3859
  BOOL enabled = self->_pendingDirectNativePagerEnabled;
@@ -3497,9 +3917,12 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
3497
3917
 
3498
3918
  - (void)scrollViewWillBeginDragging:(UIScrollView *)scrollView
3499
3919
  {
3920
+ [self updateHeaderSettledStateForceBusy:YES];
3921
+ [self offsetDiag:@"pager-drag-begin" page:_currentIndex scroll:_observedScrollView];
3500
3922
  if (_nativeSmoothHeaderScrollEnabled && scrollView == _pagerScrollView) {
3501
3923
  [_contentPressCancellationGesture cancelPressForPan:scrollView.panGestureRecognizer];
3502
3924
  }
3925
+ [self beginPageStateTransition];
3503
3926
  if (_directNativePagerEnabled) {
3504
3927
  [self preparePageForHorizontalTransitionAtIndex:_currentIndex - 1];
3505
3928
  [self preparePageForHorizontalTransitionAtIndex:_currentIndex + 1];
@@ -3651,6 +4074,7 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
3651
4074
  NSInteger position = (NSInteger)floor(progress);
3652
4075
  CGFloat offset = progress - position;
3653
4076
  position = MAX(0, MIN(position, (NSInteger)_pageControllers.count - 1));
4077
+ [self updatePageStateTransitionProgress:progress];
3654
4078
  [_nativeTabBarView setProgress:progress];
3655
4079
  const auto emitter = [self eventEmitter];
3656
4080
  if (emitter) {
@@ -3668,6 +4092,7 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
3668
4092
  CGFloat transitionProgress = RNCClamp(fabs(rawOffset), 0, 1);
3669
4093
  tabProgress = _currentIndex + (_destinationIndex - _currentIndex) * transitionProgress;
3670
4094
  }
4095
+ [self updatePageStateTransitionProgress:tabProgress];
3671
4096
  [_nativeTabBarView setProgress:tabProgress];
3672
4097
  const auto emitter = [self eventEmitter];
3673
4098
  if (emitter) {
@@ -3685,10 +4110,98 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
3685
4110
 
3686
4111
  - (void)emitPageSelected:(NSInteger)position
3687
4112
  {
4113
+ [self offsetDiag:@"selection-complete" page:position scroll:_observedScrollView];
3688
4114
  const auto emitter = [self eventEmitter];
3689
4115
  if (emitter) emitter->onPageSelected({.position = (double)position});
3690
4116
  }
3691
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
+
3692
4205
  - (void)emitDiagnostics:(NSString *)reason
3693
4206
  {
3694
4207
  const auto emitter = [self eventEmitter];
@@ -3704,7 +4217,9 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
3704
4217
  .attachedPageCount = (int)attached,
3705
4218
  .observedScrollableCount = (int)_originalInsets.count,
3706
4219
  .retainedPages = std::string(_retainedPages.UTF8String ?: "[]"),
3707
- .reason = std::string(reason.UTF8String ?: "unknown")
4220
+ .reason = std::string(reason.UTF8String ?: "unknown"),
4221
+ .pageKey = std::string(self.currentPageKey.UTF8String ?: ""),
4222
+ .headerSettledAtTop = (bool)_headerSettledAtTop
3708
4223
  });
3709
4224
  }
3710
4225
 
@@ -3713,6 +4228,77 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
3713
4228
  RCTRNCCollapsiblePagerViewHandleCommand(self, commandName, args);
3714
4229
  }
3715
4230
 
4231
+ - (BOOL)matchesPagerTop:(UIScrollView *)scrollView
4232
+ {
4233
+ return scrollView != nil && scrollView == _pagerTopScrollView && scrollView == _observedScrollView &&
4234
+ _pagerTopPageKey != nil && [_pagerTopPageKey isEqualToString:self.currentPageKey] &&
4235
+ [_observedPageKey isEqualToString:_pagerTopPageKey] && _pagerTopPageIndex == _currentIndex &&
4236
+ _observedPageIndex == _currentIndex && _pagerTopGeneration == _generation &&
4237
+ _observedPageGeneration == _generation && scrollView.window != nil;
4238
+ }
4239
+
4240
+ - (void)cancelPagerTop
4241
+ {
4242
+ if (_pagerTopPageKey != nil) [_commandBodyKeys removeObject:_pagerTopPageKey];
4243
+ _pagerTopScrollView = nil;
4244
+ _pagerTopPageKey = nil;
4245
+ }
4246
+
4247
+ - (void)finishPagerTopIfReached
4248
+ {
4249
+ UIScrollView *scrollView = _pagerTopScrollView;
4250
+ if (_dispatchingPagerTop || ![self matchesPagerTop:scrollView]) return;
4251
+ CGFloat logical = scrollView.contentOffset.y + scrollView.contentInset.top;
4252
+ if (fabs(logical - _pagerTopLogicalTarget) > 0.5) return;
4253
+ _pageOffsets[_pagerTopPageKey] = @(_nativeSmoothHeaderScrollEnabled ? 0 : _pagerTopLogicalTarget);
4254
+ [_alignedBodyKeys addObject:_pagerTopPageKey];
4255
+ _currentLogicalOffset = logical;
4256
+ _headerOffset = _pagerTopLogicalTarget;
4257
+ [self applyHeaderOffset];
4258
+ [self offsetDiag:[NSString stringWithFormat:@"pager-top-complete-token=%lu-target=%.4f", (unsigned long)_pagerTopToken, _pagerTopLogicalTarget] page:_currentIndex scroll:scrollView];
4259
+ [self cancelPagerTop];
4260
+ }
4261
+
4262
+ - (void)scrollToTop:(NSInteger)target animated:(BOOL)animated
4263
+ {
4264
+ UIScrollView *scrollView = _observedScrollView;
4265
+ NSString *key = _observedPageKey;
4266
+ if ((target != 0 && target != 1) || _isBeingRecycled || _transitioning || _isPagerDragging ||
4267
+ _transitionPageOffsets != nil || _syntheticPageRestore || scrollView == nil || scrollView.window == nil ||
4268
+ key == nil || ![key isEqualToString:self.currentPageKey] || _observedPageIndex != _currentIndex ||
4269
+ _observedPageGeneration != _generation || self.window == nil) return;
4270
+ [self cancelPagerTop];
4271
+ // Old List phase2 notifications must run before the new target owns this page.
4272
+ [NSNotificationCenter.defaultCenter postNotificationName:@"OneKeyNativeListCancelScrollCommand" object:scrollView];
4273
+ if ([scrollView.superview isKindOfClass:RNCNativeScrollerComponentView.class]) {
4274
+ [(RNCNativeScrollerComponentView *)scrollView.superview stopScrolling];
4275
+ } else {
4276
+ [scrollView setContentOffset:scrollView.contentOffset animated:NO];
4277
+ }
4278
+ [_pendingPageRestoreKeys removeObject:key];
4279
+ [_alignedBodyKeys removeObject:key];
4280
+ [_suspendedBodyKeys removeObject:key];
4281
+ [_displayBodyOverrides removeObjectForKey:key];
4282
+ CGFloat logicalTarget = target == 1 ? _headerHeight : 0;
4283
+ _pageOffsets[key] = @(_nativeSmoothHeaderScrollEnabled ? 0 : logicalTarget);
4284
+ _pagerTopScrollView = scrollView;
4285
+ _pagerTopPageKey = [key copy];
4286
+ _pagerTopPageIndex = _currentIndex;
4287
+ _pagerTopGeneration = _generation;
4288
+ _pagerTopLogicalTarget = logicalTarget;
4289
+ _pagerTopToken++;
4290
+ [_commandBodyKeys addObject:key];
4291
+ _headerOffset = logicalTarget;
4292
+ [self applyHeaderOffset];
4293
+ CGFloat physicalTarget = RNCLimitRestoredScrollOffset(logicalTarget - scrollView.contentInset.top,
4294
+ scrollView.contentSize.height, scrollView.bounds.size.height, scrollView.contentInset.top, scrollView.contentInset.bottom);
4295
+ [self offsetDiag:[NSString stringWithFormat:@"pager-top-begin-token=%lu-target=%ld-animated=%d", (unsigned long)_pagerTopToken, (long)target, animated] page:_currentIndex scroll:scrollView];
4296
+ _dispatchingPagerTop = YES;
4297
+ [scrollView setContentOffset:CGPointMake(scrollView.contentOffset.x, physicalTarget) animated:animated];
4298
+ _dispatchingPagerTop = NO;
4299
+ [self finishPagerTopIfReached];
4300
+ }
4301
+
3716
4302
  - (void)setPage:(NSInteger)index
3717
4303
  {
3718
4304
  [self goTo:index animated:YES];