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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -324,6 +324,10 @@ class CollapsiblePagerViewManager : ViewGroupManager<CollapsiblePagerHost>(),
324
324
  delegate.receiveCommand(root, commandId, args)
325
325
  }
326
326
 
327
+ override fun scrollToTop(view: CollapsiblePagerHost?, target: Int, animated: Boolean) {
328
+ view?.scrollToTop(target, animated)
329
+ }
330
+
327
331
  override fun setPage(view: CollapsiblePagerHost?, selectedPage: Int) {
328
332
  if (view != null && selectedPage in 0 until view.adapter.itemCount) {
329
333
  view.beginPageTransition(selectedPage)
@@ -295,11 +295,22 @@ class NativeScrollerView(context: Context) : FrameLayout(context) {
295
295
  }
296
296
 
297
297
  internal fun scrollToPosition(x: Int, y: Int, animated: Boolean) {
298
+ notifyScrollCommand(0, y <= 0)
298
299
  viewport.stopMotion()
299
300
  pendingRestore.cancel()
301
+ notifyScrollCommand(1, y <= 0)
300
302
  if (animated) viewport.scrollToAnimated(y)
301
303
  else viewport.scrollTo(0, y.coerceAtLeast(0))
302
304
  emitScroll(force = true)
305
+ if (!viewport.isReadingMotionActive()) notifyScrollCommand(2, y <= 0)
306
+ }
307
+
308
+ internal fun notifyScrollCommand(phase: Int, explicitTop: Boolean = false) {
309
+ var owner = parent
310
+ while (owner is View) {
311
+ if (owner is CollapsiblePagerHost) owner.acceptObservedScrollCommand(viewport, explicitTop, phase)
312
+ owner = owner.parent
313
+ }
303
314
  }
304
315
 
305
316
  internal fun scrollToEnd(animated: Boolean) = scrollToPosition(
@@ -589,6 +600,8 @@ internal class NativeScrollerViewport(context: Context, internal val host: Nativ
589
600
  super.fling(velocityY)
590
601
  }
591
602
 
603
+ internal fun isReadingMotionActive() = dragging || momentum
604
+
592
605
  internal fun scrollToAnimated(y: Int) {
593
606
  val child = getChildAt(0) ?: return
594
607
  val margins = child.layoutParams as FrameLayout.LayoutParams
@@ -639,6 +652,7 @@ internal class NativeScrollerViewport(context: Context, internal val host: Nativ
639
652
  private fun finishMomentum() {
640
653
  if (!momentum) return
641
654
  momentum = false
655
+ host.notifyScrollCommand(2)
642
656
  host.emitScroll(force = true)
643
657
  host.emitLifecycle("topMomentumScrollEnd")
644
658
  }
@@ -2,4 +2,6 @@
2
2
  <item name="onekey_native_scroll_coordinator_consumed_offsets" type="id" />
3
3
  <item name="onekey_native_scroll_coordinator_offset_listener" type="id" />
4
4
  <item name="onekey_native_scroll_coordinator_refresh_inset" type="id" />
5
+ <item name="onekey_native_scroll_coordinator_command_listener" type="id" />
6
+ <item name="onekey_native_scroll_coordinator_command_cancel_listener" type="id" />
5
7
  </resources>
package/docs/SPEC.md CHANGED
@@ -3,53 +3,59 @@
3
3
  Existing PagerView and CollapsiblePagerView APIs are documented in README.md.
4
4
  The sections below define native coordination boundaries without new React props.
5
5
 
6
- ## Smooth per-page screen restoration
7
-
8
- When nativeSmoothHeaderScrollEnabled is enabled with a collapsible header, each
9
- page owns its header collapse and body offset. For a settled position outside
10
- pull overscroll, Q = H + L, H = min(Q, C), and L = max(Q - C, 0), where C is
11
- the header-collapse distance. These are semantic coordinates, not new props.
12
- An unvisited page starts expanded
13
- at body start. Leaving a page preserves its expanded, partial or collapsed header
14
- and its body position; tap, swipe and programmatic selection restore the same
15
- complete screen state. Legacy mode and pagers without a collapsible header retain
16
- their existing behavior.
17
-
18
- Horizontal progress interpolates the shared header between immutable source and
19
- target snapshots. Native body geometry follows that header without obscured rows
20
- or blank gaps. Temporary transition offsets MUST NOT overwrite page snapshots.
21
- Non-adjacent commands use the original source and destination, not intermediate
22
- pages, as the header interpolation endpoints. Cancellation restores the source
23
- and all temporarily compensated pages before observation resumes.
24
-
25
- A scroll observer owns one page identity and generation independently of mutable
26
- selection. Detach, replacement and late notifications MUST NOT save another
27
- page's offset. Zero layout and unresolved replacement identity preserve pending
28
- restoration until an actual usable layout; settled content shrink still clamps to
29
- the legal range. This state is native UI-thread state, not persistent wallet data
30
- or per-frame JavaScript synchronization. Vertical scrolling, refresh, snapping
31
- and repeated-current-page selection retain their existing contracts.
32
-
33
- Android owned NativeScroller restoration keeps its intent while a posted request
34
- has not executed or native content is still too short to reach the saved offset.
35
- Only the same viewport, page key and attachment generation may reconcile an
36
- issued request. Once native restoration settles or is cancelled by touch, an
37
- imperative command or content replacement, the host resumes recording its live
38
- body offset. Existing pre-draw observation also reconciles a same-position command
39
- that emits no scroll notification. Zero-sized geometry retains the pending intent;
40
- no timeout infers that delayed content is permanently short.
41
-
42
- In smooth-header mode, Android queued RecyclerView and native ScrollView
43
- restoration requests belong to one page, native view and attachment generation.
44
- Touch cancellation or replacement MUST invalidate the old request. A stale posted
45
- callback MUST NOT restore its offset or clear a newer request's pending state.
46
-
47
- On iOS, a valid scrollTo or scrollToEnd command for the currently observed page
48
- MUST cancel that page's pre-layout restoration intent before executing, including
49
- commands whose target is already the current offset. The command takes ownership
50
- of the live reading position; other pages' pending intent is unchanged. This
51
- coordination does not apply during horizontal transitions or synthetic alignment,
52
- and does not change legacy mode or invalid-command behavior.
6
+ ## Smooth shared-header and per-page body memory
7
+
8
+ With nativeSmoothHeaderScrollEnabled, the header collapse C is shared across all
9
+ pages and the available collapse distance is D. Each page separately remembers
10
+ its sticky-body reading distance B (unvisited pages default to zero). Outside
11
+ pull overscroll, the rendered whole-page distance is Q = C + effectiveB.
12
+ B denotes the saved reading position. For an owned vertical LinearLayoutManager
13
+ RecyclerView, restoration uses the first visible adapter position and its
14
+ decorated top relative to padding; scrollbar offset estimates MUST NOT decide
15
+ completion of that anchor restoration.
16
+ When C = D, selection restores the destination's saved B. When C < D, effectiveB
17
+ is zero while the previous saved B remains suspended. Horizontal selection by
18
+ tap, swipe or an existing programmatic command MUST preserve C throughout.
19
+
20
+ Alignment, prefetch, replacement, inset changes and pending restoration MUST NOT
21
+ commit new B. Actual consumed vertical scrolling takes over the visible body
22
+ position; reaching full collapse during that gesture MUST NOT resurrect the old
23
+ suspended B. Selecting a page anew at full collapse may restore its saved B.
24
+ Valid explicit native scroll commands, including same-offset commands, cancel
25
+ only their current owned restoration and take over body memory. Invalid commands
26
+ and stale owners do not affect another page. Existing command coordinate systems
27
+ are unchanged; in particular NativeScroller iOS uses physical offsets whereas
28
+ NativeList offset commands exclude the content-start inset.
29
+
30
+ Transitions use immutable destination memory and the source's actual displayed
31
+ position. Cancellation restores that source presentation, even when a cached B
32
+ was temporarily unreachable. Loading or zero geometry preserves pending intent;
33
+ a genuinely short page clamps its display without silently replacing saved B.
34
+ Cancelling a horizontal gesture invalidates queued callbacks, but preserves
35
+ unreachable reading intent until real vertical consumption or a valid command
36
+ takes over; later usable geometry may resume it.
37
+ Late callbacks cannot revive cancelled restoration or erase a newer request.
38
+ Observers and commands must match page key, view identity and generation.
39
+ Removed or recycled owners cannot apply callbacks to another page; recycling
40
+ resets owned memory.
41
+ Legacy mode and pagers without a collapsible header retain their existing
42
+ behavior. Vertical scrolling, refresh, snapping and repeated-current-tab behavior
43
+ are unchanged. This state belongs to native UI instances, with no new React prop
44
+ or per-frame JavaScript synchronization.
45
+
46
+ ## Current-page top commands
47
+
48
+ `scrollToPageTop(options?: Readonly<{ animated?: boolean }>)` restores the current
49
+ page to C = 0 and B = 0. `scrollToStickyTop(options?: Readonly<{ animated?: boolean }>)`
50
+ restores it to C = D and B = 0, including from a partially expanded header.
51
+ Animation defaults to true. Neither command clears another page's memory.
52
+ Both commands require a stable, attached current owner; horizontal transitions
53
+ or missing owners reject them rather than queueing a command for another page.
54
+ Acceptance cancels older owned list commands and pending restoration before the
55
+ new target is established. Same-target commands also take over old reading
56
+ intent. Real vertical consumption, a newer valid command, or owner detachment
57
+ releases the target. Existing NativeList and NativeScroller public coordinates
58
+ are unchanged. The same page-top/sticky-top destinations are available without smooth-header mode.
53
59
 
54
60
  ## Independent NativeScroller migration
55
61
 
@@ -503,7 +509,7 @@ the upper bound to the current content height, viewport and applied insets. A
503
509
  cache captured before rows were removed MUST NOT restore an offset below the new
504
510
  content bottom, including when removal occurs during a page transition. Valid
505
511
  deep offsets and pull-overscroll behavior are preserved; smooth-header selection
506
- follows the per-page reading-position contract above.
512
+ follows the shared-header and per-page body-memory contract above.
507
513
  This check runs only on explicit restoration, not on scroll frames; UIKit pull
508
514
  overscroll and refresh settlement retain their existing ownership. An unmeasured
509
515
  zero-height viewport defers this upper-bound limit to a later restoration.
@@ -1481,6 +1481,19 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
1481
1481
  NSArray<NSString *> *_pageKeys;
1482
1482
  NSString *_retainedPages;
1483
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;
1484
1497
  NSMapTable<UIScrollView *, NSValue *> *_originalInsets;
1485
1498
  NSMapTable<UIScrollView *, UIRefreshControl *> *_managedRefreshControls;
1486
1499
  NSHashTable<UIScrollView *> *_refreshControlObservedScrollViews;
@@ -1540,6 +1553,12 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
1540
1553
  _logicalChildren = [NSMutableArray new];
1541
1554
  _pageControllers = [NSMutableArray new];
1542
1555
  _pageOffsets = [NSMutableDictionary new];
1556
+ _commandBodyKeys = [NSMutableSet new];
1557
+ _alignedBodyKeys = [NSMutableSet new];
1558
+ _suspendedBodyKeys = [NSMutableSet new];
1559
+ _displayBodyOverrides = [NSMutableDictionary new];
1560
+ [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(nativeListScrollCommandAccepted:)
1561
+ name:@"OneKeyNativeListScrollCommandAccepted" object:nil];
1543
1562
  _pendingPageRestoreKeys = [NSMutableSet new];
1544
1563
  _releasedPageScrollStates = [NSMutableDictionary new];
1545
1564
  _mountingRemovedScrollViews = [NSHashTable weakObjectsHashTable];
@@ -1684,6 +1703,7 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
1684
1703
 
1685
1704
  - (void)dealloc
1686
1705
  {
1706
+ [[NSNotificationCenter defaultCenter] removeObserver:self name:@"OneKeyNativeListScrollCommandAccepted" object:nil];
1687
1707
  for (UIScrollView *scrollView in _originalInsets.keyEnumerator) {
1688
1708
  [self updateNativeScrollerViewport:scrollView stickyHeight:0];
1689
1709
  }
@@ -1947,9 +1967,12 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
1947
1967
 
1948
1968
  - (void)beginPageStateTransition
1949
1969
  {
1970
+ [self cancelPagerTop];
1950
1971
  if (!_nativeSmoothHeaderScrollEnabled || _headerHeight <= 0 || _transitionPageOffsets != nil) return;
1972
+ _transitionSourceDisplayBody = MAX(_observedScrollView.contentOffset.y + _observedScrollView.contentInset.top - _headerHeight, 0);
1951
1973
  [self detachScrollObserver];
1952
1974
  _transitionSourceIndex = _currentIndex;
1975
+ _transitionHeaderOffset = _headerOffset;
1953
1976
  _transitionPageOffsets = [_pageOffsets copy];
1954
1977
  _transitionCompensatedPages = [NSMutableIndexSet new];
1955
1978
  #if DEBUG
@@ -1972,9 +1995,7 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
1972
1995
  }
1973
1996
  CGFloat firstQ = [_transitionPageOffsets[[self pageKeyForIndex:first]] doubleValue];
1974
1997
  CGFloat secondQ = [_transitionPageOffsets[[self pageKeyForIndex:second]] doubleValue];
1975
- CGFloat firstH = RNCClamp(firstQ, 0, _headerHeight);
1976
- CGFloat secondH = RNCClamp(secondQ, 0, _headerHeight);
1977
- _headerOffset = firstH + (secondH - firstH) * fraction;
1998
+ _headerOffset = _transitionHeaderOffset;
1978
1999
  #if DEBUG
1979
2000
  NSInteger bucket = (NSInteger)floor(fraction * 4);
1980
2001
  if (_transitionDiagBucket != bucket) {
@@ -2008,7 +2029,10 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
2008
2029
  {
2009
2030
  if (_transitionPageOffsets == nil) return;
2010
2031
  _finishingPageStateTransition = YES;
2011
- _headerOffset = RNCClamp([_transitionPageOffsets[self.currentPageKey] doubleValue], 0, _headerHeight);
2032
+ _headerOffset = _transitionHeaderOffset;
2033
+ if (_currentIndex == _transitionSourceIndex) _displayBodyOverrides[self.currentPageKey] = @(_transitionSourceDisplayBody);
2034
+ else [_displayBodyOverrides removeObjectForKey:self.currentPageKey];
2035
+ if (_headerOffset >= _headerHeight) [_suspendedBodyKeys removeObject:self.currentPageKey];
2012
2036
  _syntheticPageRestore = YES;
2013
2037
  // Both completion and cancellation undo every temporary viewport offset.
2014
2038
  for (NSUInteger index = _transitionCompensatedPages.firstIndex; index != NSNotFound;
@@ -2016,10 +2040,7 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
2016
2040
  UIScrollView *scroll = [self verticalScrollViewForPageAtIndex:index];
2017
2041
  if (scroll == nil) continue;
2018
2042
  [self applyInsetsToScrollView:scroll pageIndex:index restore:YES];
2019
- NSString *key = [self pageKeyForIndex:index];
2020
- if (![_pendingPageRestoreKeys containsObject:key]) {
2021
- _pageOffsets[key] = @(scroll.contentOffset.y + scroll.contentInset.top);
2022
- }
2043
+
2023
2044
  }
2024
2045
  _syntheticPageRestore = NO;
2025
2046
  _finishingPageStateTransition = NO;
@@ -2083,8 +2104,8 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
2083
2104
  (!_nativeSmoothHeaderScrollEnabled ||
2084
2105
  (_observedPageGeneration == _generation && _observedPageIndex == index &&
2085
2106
  [_observedPageKey isEqualToString:pageKey]));
2086
- if (_transitionPageOffsets == nil && !_syntheticPageRestore &&
2087
- (!_nativeSmoothHeaderScrollEnabled || ![_pendingPageRestoreKeys containsObject:pageKey]) &&
2107
+ if (!_nativeSmoothHeaderScrollEnabled && _transitionPageOffsets == nil && !_syntheticPageRestore &&
2108
+ ![_pendingPageRestoreKeys containsObject:pageKey] &&
2088
2109
  (ownsObservedPage || _pageOffsets[pageKey] == nil)) {
2089
2110
  [self offsetDiag:@"mount-release-save-before" page:index scroll:scrollView];
2090
2111
  _pageOffsets[pageKey] = @(scrollView.contentOffset.y + scrollView.contentInset.top);
@@ -2426,6 +2447,10 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
2426
2447
  _pagerScrollView = nil;
2427
2448
  [super prepareForRecycle];
2428
2449
  [_pageOffsets removeAllObjects];
2450
+ [_commandBodyKeys removeAllObjects];
2451
+ [_alignedBodyKeys removeAllObjects];
2452
+ [_suspendedBodyKeys removeAllObjects];
2453
+ [_displayBodyOverrides removeAllObjects];
2429
2454
  [_pendingPageRestoreKeys removeAllObjects];
2430
2455
  _transitionPageOffsets = nil;
2431
2456
  _transitionCompensatedPages = nil;
@@ -2855,7 +2880,7 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
2855
2880
  [self applyInsetsToScrollView:candidate pageIndex:_currentIndex restore:YES];
2856
2881
  [candidate addObserver:self
2857
2882
  forKeyPath:@"contentOffset"
2858
- options:NSKeyValueObservingOptionNew
2883
+ options:NSKeyValueObservingOptionOld | NSKeyValueObservingOptionNew
2859
2884
  context:RNCCollapsiblePagerContentOffsetContext];
2860
2885
  [candidate addObserver:self
2861
2886
  forKeyPath:@"contentInset"
@@ -2875,6 +2900,7 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
2875
2900
 
2876
2901
  - (void)detachScrollObserver
2877
2902
  {
2903
+ [self cancelPagerTop];
2878
2904
  UIScrollView *scrollView = _observedScrollView;
2879
2905
  if (scrollView != nil) {
2880
2906
  [scrollView.panGestureRecognizer removeTarget:self action:@selector(observedListPanChanged:)];
@@ -2883,9 +2909,7 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
2883
2909
  BOOL ownerKeyExists = ownerKey != nil && ([_pageKeys containsObject:ownerKey] ||
2884
2910
  (_observedPageIndex >= 0 && _observedPageIndex < _pageControllers.count &&
2885
2911
  [[self pageKeyForIndex:_observedPageIndex] isEqualToString:ownerKey]));
2886
- if (ownerKey != nil && (!_nativeSmoothHeaderScrollEnabled ||
2887
- (ownerKeyExists && _observedPageGeneration == _generation && _transitionPageOffsets == nil &&
2888
- !_syntheticPageRestore && ![_pendingPageRestoreKeys containsObject:ownerKey]))) {
2912
+ if (ownerKey != nil && !_nativeSmoothHeaderScrollEnabled) {
2889
2913
  _pageOffsets[ownerKey] = @(scrollView.contentOffset.y + scrollView.contentInset.top);
2890
2914
  }
2891
2915
  [self offsetDiag:@"detach-save-after" page:_currentIndex scroll:scrollView];
@@ -2901,6 +2925,9 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
2901
2925
  context:RNCCollapsiblePagerContentOffsetContext];
2902
2926
  }
2903
2927
  }
2928
+ if (!_nativeSmoothHeaderScrollEnabled && _observedPageKey != nil) {
2929
+ [_alignedBodyKeys removeObject:_observedPageKey];
2930
+ }
2904
2931
  _observingContentOffset = NO;
2905
2932
  _observedScrollView = nil;
2906
2933
  _observedPageKey = nil;
@@ -3331,6 +3358,8 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
3331
3358
  if (pageIndex == _currentIndex) [self updateHeaderForScrollView:scrollView];
3332
3359
  return;
3333
3360
  }
3361
+ BOOL previousSyntheticRestore = _syntheticPageRestore;
3362
+ if (_nativeSmoothHeaderScrollEnabled || [_alignedBodyKeys containsObject:pageKey]) _syntheticPageRestore = YES;
3334
3363
  [self offsetDiag:@"inset-assignment-before" page:pageIndex scroll:scrollView];
3335
3364
  scrollView.contentInset = next;
3336
3365
  [self offsetDiag:@"inset-assignment-after" page:pageIndex scroll:scrollView];
@@ -3340,6 +3369,7 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
3340
3369
  scrollView.alwaysBounceVertical = YES;
3341
3370
 
3342
3371
  CGFloat targetOffset = previousLogicalOffset - next.top;
3372
+ CGFloat intendedLogicalOffset = previousLogicalOffset;
3343
3373
  if (restore) {
3344
3374
  NSNumber *saved = _transitionPageOffsets != nil
3345
3375
  ? _transitionPageOffsets[pageKey] : _pageOffsets[pageKey];
@@ -3347,13 +3377,17 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
3347
3377
  // to content start so a round trip does not add that height difference.
3348
3378
  CGFloat restoredLogicalOffset;
3349
3379
  if (_nativeSmoothHeaderScrollEnabled && _headerHeight > 0) {
3350
- restoredLogicalOffset = saved.doubleValue;
3351
- if (_transitionPageOffsets != nil && !_finishingPageStateTransition) {
3352
- [_transitionCompensatedPages addIndex:pageIndex];
3353
- restoredLogicalOffset = MAX(restoredLogicalOffset - _headerHeight, 0) + _headerOffset;
3354
- }
3380
+ [_alignedBodyKeys addObject:pageKey];
3381
+ if (_headerOffset < _headerHeight && saved.doubleValue > 0) [_suspendedBodyKeys addObject:pageKey];
3382
+ CGFloat body = saved.doubleValue;
3383
+ if (_transitionPageOffsets != nil && pageIndex == _transitionSourceIndex) body = _transitionSourceDisplayBody;
3384
+ else if (_transitionPageOffsets != nil) [_displayBodyOverrides removeObjectForKey:pageKey];
3385
+ else if (_displayBodyOverrides[pageKey] != nil) body = _displayBodyOverrides[pageKey].doubleValue;
3386
+ restoredLogicalOffset = _headerOffset + (_headerOffset >= _headerHeight ? body : 0);
3387
+ if (_transitionPageOffsets != nil) [_transitionCompensatedPages addIndex:pageIndex];
3355
3388
  if (deferredReleasedIdentity || scrollView.bounds.size.height <= 0 || scrollView.contentSize.height <= 0) {
3356
3389
  [_pendingPageRestoreKeys addObject:pageKey];
3390
+ _syntheticPageRestore = previousSyntheticRestore;
3357
3391
  return;
3358
3392
  }
3359
3393
  [_pendingPageRestoreKeys removeObject:pageKey];
@@ -3363,6 +3397,12 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
3363
3397
  #if DEBUG
3364
3398
  [self offsetDiag:[NSString stringWithFormat:@"restore-candidate-saved=%.4f-headerAlignedLogical=%.4f", saved.doubleValue, restoredLogicalOffset] page:pageIndex scroll:scrollView];
3365
3399
  #endif
3400
+ intendedLogicalOffset = restoredLogicalOffset;
3401
+ // Cancellation restores the visible source, while retaining its unreachable reading intent.
3402
+ if (_nativeSmoothHeaderScrollEnabled && _headerOffset >= _headerHeight &&
3403
+ _displayBodyOverrides[pageKey] != nil) {
3404
+ intendedLogicalOffset = MAX(intendedLogicalOffset, _headerOffset + _pageOffsets[pageKey].doubleValue);
3405
+ }
3366
3406
  targetOffset = restoredLogicalOffset - next.top;
3367
3407
  if (!_nativeSmoothHeaderScrollEnabled || _headerHeight <= 0) {
3368
3408
  targetOffset = MAX(targetOffset, -next.top + _headerOffset);
@@ -3387,7 +3427,15 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
3387
3427
  if (restore || previousTopInset != next.top) {
3388
3428
  [scrollView setContentOffset:CGPointMake(scrollView.contentOffset.x, targetOffset) animated:NO];
3389
3429
  }
3390
- if (pageIndex == _currentIndex) [self updateHeaderForScrollView:scrollView];
3430
+ if (_nativeSmoothHeaderScrollEnabled && restore && targetOffset + next.top + 0.5 < intendedLogicalOffset) {
3431
+ [_pendingPageRestoreKeys addObject:pageKey];
3432
+ }
3433
+ _syntheticPageRestore = previousSyntheticRestore;
3434
+ if (pageIndex == _currentIndex) {
3435
+ _currentLogicalOffset = scrollView.contentOffset.y + scrollView.contentInset.top;
3436
+ if (!_nativeSmoothHeaderScrollEnabled) [self updateHeaderForScrollView:scrollView];
3437
+ else [self applyHeaderOffset];
3438
+ }
3391
3439
  [self offsetDiag:@"restore-post" page:pageIndex scroll:scrollView];
3392
3440
  }
3393
3441
 
@@ -3398,20 +3446,66 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
3398
3446
  }
3399
3447
  }
3400
3448
 
3449
+ - (void)nativeListScrollCommandAccepted:(NSNotification *)notification
3450
+ {
3451
+ UIScrollView *scrollView = notification.object;
3452
+ if (scrollView != _observedScrollView ||
3453
+ _observedPageKey == nil || ![_observedPageKey isEqualToString:self.currentPageKey] ||
3454
+ _observedPageIndex != _currentIndex || _observedPageGeneration != _generation ||
3455
+ _transitioning || _isPagerDragging || _transitionPageOffsets != nil || _syntheticPageRestore) return;
3456
+ NSInteger phase = [notification.userInfo[@"phase"] integerValue];
3457
+ if ([self matchesPagerTop:scrollView]) {
3458
+ if (phase == 0) [self cancelPagerTop];
3459
+ else return;
3460
+ }
3461
+ if (phase == 0) [_alignedBodyKeys removeObject:_observedPageKey];
3462
+ if (!_nativeSmoothHeaderScrollEnabled) return;
3463
+ _currentLogicalOffset = scrollView.contentOffset.y + scrollView.contentInset.top;
3464
+ if (phase == 1) { [_commandBodyKeys addObject:_observedPageKey]; return; }
3465
+ if (phase == 2) {
3466
+ if (![_commandBodyKeys containsObject:_observedPageKey]) return;
3467
+ _pageOffsets[_observedPageKey] = @(MAX(_currentLogicalOffset - _headerHeight, 0));
3468
+ [self updateHeaderForScrollView:scrollView];
3469
+ [_commandBodyKeys removeObject:_observedPageKey];
3470
+ return;
3471
+ }
3472
+ [_commandBodyKeys removeObject:_observedPageKey];
3473
+ [_pendingPageRestoreKeys removeObject:_observedPageKey];
3474
+ [_alignedBodyKeys removeObject:_observedPageKey];
3475
+ [_suspendedBodyKeys removeObject:_observedPageKey];
3476
+ [_displayBodyOverrides removeObjectForKey:_observedPageKey];
3477
+ _currentLogicalOffset = scrollView.contentOffset.y + scrollView.contentInset.top;
3478
+ BOOL explicitTop = [notification.userInfo[@"explicitTop"] boolValue];
3479
+ _pageOffsets[_observedPageKey] = @(explicitTop ? 0 : MAX(_currentLogicalOffset - _headerHeight, 0));
3480
+ if (explicitTop) {
3481
+ _headerOffset = 0;
3482
+ [self applyHeaderOffset];
3483
+ }
3484
+ [self offsetDiag:@"list-command-body-takeover" page:_currentIndex scroll:scrollView];
3485
+ }
3486
+
3401
3487
  - (void)nativeScrollerWillReceiveScrollCommand:(RNCNativeScrollerComponentView *)scroller
3402
3488
  {
3403
3489
  UIScrollView *scrollView = scroller.scrollView;
3404
3490
  NSString *ownerKey = _observedPageKey;
3405
- if (!_nativeSmoothHeaderScrollEnabled || scrollView != _observedScrollView ||
3491
+ if (scrollView != _observedScrollView ||
3406
3492
  ownerKey == nil || ![ownerKey isEqualToString:self.currentPageKey] ||
3407
3493
  _observedPageIndex != _currentIndex || _currentIndex < 0 || _currentIndex >= _pageControllers.count ||
3408
3494
  _observedPageGeneration != _generation || _transitioning || _isPagerDragging ||
3409
- _transitionPageOffsets != nil || _syntheticPageRestore ||
3410
- ![_pendingPageRestoreKeys containsObject:ownerKey]) return;
3495
+ _transitionPageOffsets != nil || _syntheticPageRestore) return;
3496
+ if (!_dispatchingPagerTop) {
3497
+ [self cancelPagerTop];
3498
+ [_alignedBodyKeys removeObject:ownerKey];
3499
+ }
3500
+ if (!_nativeSmoothHeaderScrollEnabled) return;
3411
3501
  [self offsetDiag:@"command-pending-cancel-before" page:_observedPageIndex scroll:scrollView];
3412
3502
  [_pendingPageRestoreKeys removeObject:ownerKey];
3413
3503
  _currentLogicalOffset = scrollView.contentOffset.y + scrollView.contentInset.top;
3414
- _pageOffsets[ownerKey] = @(_currentLogicalOffset);
3504
+ [_commandBodyKeys removeObject:ownerKey];
3505
+ [_alignedBodyKeys removeObject:ownerKey];
3506
+ [_suspendedBodyKeys removeObject:ownerKey];
3507
+ [_displayBodyOverrides removeObjectForKey:ownerKey];
3508
+ _pageOffsets[ownerKey] = @(MAX(_currentLogicalOffset - _headerHeight, 0));
3415
3509
  [self updateHeaderForScrollView:scrollView];
3416
3510
  [self offsetDiag:@"command-pending-cancel-after" page:_observedPageIndex scroll:scrollView];
3417
3511
  }
@@ -3475,36 +3569,76 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
3475
3569
  return;
3476
3570
  }
3477
3571
  if (context == RNCCollapsiblePagerContentOffsetContext && object == _observedScrollView) {
3478
- if (_nativeSmoothHeaderScrollEnabled &&
3572
+ if ((_nativeSmoothHeaderScrollEnabled || [_alignedBodyKeys containsObject:_observedPageKey]) &&
3479
3573
  (_observedPageGeneration != _generation || _observedPageKey == nil ||
3480
3574
  _observedPageIndex != _currentIndex || ![_observedPageKey isEqualToString:self.currentPageKey] ||
3481
3575
  _transitioning || _isPagerDragging ||
3482
3576
  _transitionPageOffsets != nil || _syntheticPageRestore)) return;
3577
+ UIScrollView *ownedScroll = (UIScrollView *)object;
3578
+ NSValue *oldPoint = change[NSKeyValueChangeOldKey];
3579
+ NSValue *newPoint = change[NSKeyValueChangeNewKey];
3580
+ if ([keyPath isEqualToString:@"contentOffset"] && [self matchesPagerTop:ownedScroll] && ownedScroll.panGestureRecognizer.state == UIGestureRecognizerStateChanged &&
3581
+ [oldPoint isKindOfClass:NSValue.class] && [newPoint isKindOfClass:NSValue.class] &&
3582
+ oldPoint.CGPointValue.y != newPoint.CGPointValue.y) {
3583
+ CGPoint interruptedOffset = ownedScroll.contentOffset;
3584
+ [self cancelPagerTop];
3585
+ [ownedScroll setContentOffset:interruptedOffset animated:NO];
3586
+ }
3587
+ if ([keyPath isEqualToString:@"contentOffset"] &&
3588
+ ownedScroll.panGestureRecognizer.state == UIGestureRecognizerStateChanged &&
3589
+ [oldPoint isKindOfClass:NSValue.class] && [newPoint isKindOfClass:NSValue.class] &&
3590
+ oldPoint.CGPointValue.y != newPoint.CGPointValue.y) {
3591
+ [_alignedBodyKeys removeObject:_observedPageKey];
3592
+ }
3593
+ if (_nativeSmoothHeaderScrollEnabled && [keyPath isEqualToString:@"contentOffset"] &&
3594
+ ownedScroll.panGestureRecognizer.state == UIGestureRecognizerStateChanged &&
3595
+ [oldPoint isKindOfClass:NSValue.class] && [newPoint isKindOfClass:NSValue.class] &&
3596
+ oldPoint.CGPointValue.y != newPoint.CGPointValue.y) {
3597
+ [self cancelPagerTop];
3598
+ [_commandBodyKeys removeObject:_observedPageKey];
3599
+ [_pendingPageRestoreKeys removeObject:_observedPageKey];
3600
+ [_alignedBodyKeys removeObject:_observedPageKey];
3601
+ [_suspendedBodyKeys removeObject:_observedPageKey];
3602
+ [_displayBodyOverrides removeObjectForKey:_observedPageKey];
3603
+ }
3483
3604
  if (_nativeSmoothHeaderScrollEnabled && [_pendingPageRestoreKeys containsObject:_observedPageKey]) {
3484
- if ([keyPath isEqualToString:@"contentSize"]) {
3605
+ if ([keyPath isEqualToString:@"contentSize"] &&
3606
+ ![change[NSKeyValueChangeOldKey] isEqual:change[NSKeyValueChangeNewKey]]) {
3607
+ if (_headerOffset >= _headerHeight) [_displayBodyOverrides removeObjectForKey:_observedPageKey];
3485
3608
  [self applyInsetsToScrollView:_observedScrollView pageIndex:_observedPageIndex restore:YES];
3486
3609
  }
3487
3610
  return;
3488
3611
  }
3489
3612
  if ([keyPath isEqualToString:@"contentInset"]) {
3490
- [self updateHeaderForScrollView:(UIScrollView *)object];
3613
+ if (!_nativeSmoothHeaderScrollEnabled) [self updateHeaderForScrollView:(UIScrollView *)object];
3491
3614
  return;
3492
3615
  }
3493
3616
  if ([keyPath isEqualToString:@"contentSize"]) {
3494
3617
  NSValue *oldSize = change[NSKeyValueChangeOldKey];
3495
3618
  NSValue *newSize = change[NSKeyValueChangeNewKey];
3496
3619
  if (![oldSize isEqual:newSize]) {
3497
- [self applyInsetsToScrollView:_observedScrollView pageIndex:_currentIndex restore:NO];
3620
+ BOOL restoreAlignedBody =
3621
+ _observedPageGeneration == _generation && _observedPageIndex == _currentIndex &&
3622
+ _observedPageKey != nil && [_observedPageKey isEqualToString:self.currentPageKey] &&
3623
+ [_alignedBodyKeys containsObject:_observedPageKey] &&
3624
+ ![_commandBodyKeys containsObject:_observedPageKey] &&
3625
+ _observedScrollView.panGestureRecognizer.state != UIGestureRecognizerStateChanged &&
3626
+ !_observedScrollView.isDragging && !_observedScrollView.isDecelerating;
3627
+ [self applyInsetsToScrollView:_observedScrollView pageIndex:_currentIndex restore:restoreAlignedBody];
3498
3628
  }
3499
3629
  return;
3500
3630
  }
3501
3631
  UIScrollView *scrollView = (UIScrollView *)object;
3502
3632
  _currentLogicalOffset = scrollView.contentOffset.y + scrollView.contentInset.top;
3503
3633
  #if DEBUG
3504
- BOOL offsetDiagChanged = _pageOffsets[self.currentPageKey] == nil || _pageOffsets[self.currentPageKey].doubleValue != _currentLogicalOffset;
3634
+ BOOL offsetDiagChanged = _pageOffsets[self.currentPageKey] == nil || _pageOffsets[self.currentPageKey].doubleValue != (_nativeSmoothHeaderScrollEnabled ? MAX(_currentLogicalOffset - _headerHeight, 0) : _currentLogicalOffset);
3505
3635
  if (offsetDiagChanged) [self offsetDiag:@"kvo-cache-before" page:_currentIndex scroll:scrollView];
3506
3636
  #endif
3507
- _pageOffsets[_nativeSmoothHeaderScrollEnabled ? _observedPageKey : self.currentPageKey] = @(_currentLogicalOffset);
3637
+ if ([_alignedBodyKeys containsObject:_observedPageKey] ||
3638
+ (_nativeSmoothHeaderScrollEnabled && scrollView.panGestureRecognizer.state != UIGestureRecognizerStateChanged &&
3639
+ !scrollView.isDecelerating && ![_commandBodyKeys containsObject:_observedPageKey])) return;
3640
+ _pageOffsets[_nativeSmoothHeaderScrollEnabled ? _observedPageKey : self.currentPageKey] =
3641
+ @(_nativeSmoothHeaderScrollEnabled ? MAX(_currentLogicalOffset - _headerHeight, 0) : _currentLogicalOffset);
3508
3642
  #if DEBUG
3509
3643
  if (offsetDiagChanged) [self offsetDiag:@"kvo-cache-after" page:_currentIndex scroll:scrollView];
3510
3644
  #endif
@@ -3516,8 +3650,16 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
3516
3650
 
3517
3651
  - (void)updateHeaderForScrollView:(UIScrollView *)scrollView
3518
3652
  {
3519
- if (_nativeSmoothHeaderScrollEnabled &&
3520
- (_syntheticPageRestore || _transitionPageOffsets != nil)) return;
3653
+ if ([self matchesPagerTop:scrollView]) {
3654
+ _currentLogicalOffset = scrollView.contentOffset.y + scrollView.contentInset.top;
3655
+ _headerOffset = _pagerTopLogicalTarget;
3656
+ [self applyHeaderOffset];
3657
+ [self finishPagerTopIfReached];
3658
+ return;
3659
+ }
3660
+ if ([_alignedBodyKeys containsObject:self.currentPageKey] ||
3661
+ (_nativeSmoothHeaderScrollEnabled && (_syntheticPageRestore || _transitionPageOffsets != nil ||
3662
+ [_pendingPageRestoreKeys containsObject:self.currentPageKey]))) return;
3521
3663
  CGFloat logicalOffset = scrollView.contentOffset.y + scrollView.contentInset.top;
3522
3664
  _currentLogicalOffset = logicalOffset;
3523
3665
  _headerOffset = MIN(MAX(logicalOffset, 0), _headerHeight);
@@ -3963,6 +4105,77 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
3963
4105
  RCTRNCCollapsiblePagerViewHandleCommand(self, commandName, args);
3964
4106
  }
3965
4107
 
4108
+ - (BOOL)matchesPagerTop:(UIScrollView *)scrollView
4109
+ {
4110
+ return scrollView != nil && scrollView == _pagerTopScrollView && scrollView == _observedScrollView &&
4111
+ _pagerTopPageKey != nil && [_pagerTopPageKey isEqualToString:self.currentPageKey] &&
4112
+ [_observedPageKey isEqualToString:_pagerTopPageKey] && _pagerTopPageIndex == _currentIndex &&
4113
+ _observedPageIndex == _currentIndex && _pagerTopGeneration == _generation &&
4114
+ _observedPageGeneration == _generation && scrollView.window != nil;
4115
+ }
4116
+
4117
+ - (void)cancelPagerTop
4118
+ {
4119
+ if (_pagerTopPageKey != nil) [_commandBodyKeys removeObject:_pagerTopPageKey];
4120
+ _pagerTopScrollView = nil;
4121
+ _pagerTopPageKey = nil;
4122
+ }
4123
+
4124
+ - (void)finishPagerTopIfReached
4125
+ {
4126
+ UIScrollView *scrollView = _pagerTopScrollView;
4127
+ if (_dispatchingPagerTop || ![self matchesPagerTop:scrollView]) return;
4128
+ CGFloat logical = scrollView.contentOffset.y + scrollView.contentInset.top;
4129
+ if (fabs(logical - _pagerTopLogicalTarget) > 0.5) return;
4130
+ _pageOffsets[_pagerTopPageKey] = @(_nativeSmoothHeaderScrollEnabled ? 0 : _pagerTopLogicalTarget);
4131
+ [_alignedBodyKeys addObject:_pagerTopPageKey];
4132
+ _currentLogicalOffset = logical;
4133
+ _headerOffset = _pagerTopLogicalTarget;
4134
+ [self applyHeaderOffset];
4135
+ [self offsetDiag:[NSString stringWithFormat:@"pager-top-complete-token=%lu-target=%.4f", (unsigned long)_pagerTopToken, _pagerTopLogicalTarget] page:_currentIndex scroll:scrollView];
4136
+ [self cancelPagerTop];
4137
+ }
4138
+
4139
+ - (void)scrollToTop:(NSInteger)target animated:(BOOL)animated
4140
+ {
4141
+ UIScrollView *scrollView = _observedScrollView;
4142
+ NSString *key = _observedPageKey;
4143
+ if ((target != 0 && target != 1) || _isBeingRecycled || _transitioning || _isPagerDragging ||
4144
+ _transitionPageOffsets != nil || _syntheticPageRestore || scrollView == nil || scrollView.window == nil ||
4145
+ key == nil || ![key isEqualToString:self.currentPageKey] || _observedPageIndex != _currentIndex ||
4146
+ _observedPageGeneration != _generation || self.window == nil) return;
4147
+ [self cancelPagerTop];
4148
+ // Old List phase2 notifications must run before the new target owns this page.
4149
+ [NSNotificationCenter.defaultCenter postNotificationName:@"OneKeyNativeListCancelScrollCommand" object:scrollView];
4150
+ if ([scrollView.superview isKindOfClass:RNCNativeScrollerComponentView.class]) {
4151
+ [(RNCNativeScrollerComponentView *)scrollView.superview stopScrolling];
4152
+ } else {
4153
+ [scrollView setContentOffset:scrollView.contentOffset animated:NO];
4154
+ }
4155
+ [_pendingPageRestoreKeys removeObject:key];
4156
+ [_alignedBodyKeys removeObject:key];
4157
+ [_suspendedBodyKeys removeObject:key];
4158
+ [_displayBodyOverrides removeObjectForKey:key];
4159
+ CGFloat logicalTarget = target == 1 ? _headerHeight : 0;
4160
+ _pageOffsets[key] = @(_nativeSmoothHeaderScrollEnabled ? 0 : logicalTarget);
4161
+ _pagerTopScrollView = scrollView;
4162
+ _pagerTopPageKey = [key copy];
4163
+ _pagerTopPageIndex = _currentIndex;
4164
+ _pagerTopGeneration = _generation;
4165
+ _pagerTopLogicalTarget = logicalTarget;
4166
+ _pagerTopToken++;
4167
+ [_commandBodyKeys addObject:key];
4168
+ _headerOffset = logicalTarget;
4169
+ [self applyHeaderOffset];
4170
+ CGFloat physicalTarget = RNCLimitRestoredScrollOffset(logicalTarget - scrollView.contentInset.top,
4171
+ scrollView.contentSize.height, scrollView.bounds.size.height, scrollView.contentInset.top, scrollView.contentInset.bottom);
4172
+ [self offsetDiag:[NSString stringWithFormat:@"pager-top-begin-token=%lu-target=%ld-animated=%d", (unsigned long)_pagerTopToken, (long)target, animated] page:_currentIndex scroll:scrollView];
4173
+ _dispatchingPagerTop = YES;
4174
+ [scrollView setContentOffset:CGPointMake(scrollView.contentOffset.x, physicalTarget) animated:animated];
4175
+ _dispatchingPagerTop = NO;
4176
+ [self finishPagerTopIfReached];
4177
+ }
4178
+
3966
4179
  - (void)setPage:(NSInteger)index
3967
4180
  {
3968
4181
  [self goTo:index animated:YES];