@onekeyfe/react-native-pager-view 3.0.113 → 3.0.115

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.
@@ -15,8 +15,10 @@ static void *RNCCollapsiblePagerContentOffsetContext = &RNCCollapsiblePagerConte
15
15
  RCTRNCCollapsiblePagerViewViewProtocol,
16
16
  UIPageViewControllerDataSource,
17
17
  UIPageViewControllerDelegate,
18
- UIScrollViewDelegate
18
+ UIScrollViewDelegate,
19
+ UIGestureRecognizerDelegate
19
20
  >
21
+ - (void)finishPagerScrollEmittingSelection:(BOOL)emitSelection;
20
22
  @end
21
23
 
22
24
  @implementation RNCCollapsiblePagerViewComponentView {
@@ -34,13 +36,23 @@ static void *RNCCollapsiblePagerContentOffsetContext = &RNCCollapsiblePagerConte
34
36
  NSInteger _stickyHeaderHeight;
35
37
  CGFloat _headerOffset;
36
38
  BOOL _scrollEnabled;
39
+ // OneKey patch: Coordinate this inner pager with an outer horizontal pager.
40
+ BOOL _nestedScrollEnabled;
41
+ UIPanGestureRecognizer *_blockerGesture;
37
42
  BOOL _transitioning;
43
+ BOOL _isPagerDragging;
44
+ BOOL _hasReceivedPageCommand;
45
+ NSUInteger _transitionId;
46
+ // OneKey patch: Retain the latest tab tap while an animation is in flight.
47
+ NSInteger _pendingGoToIndex;
38
48
  BOOL _hasAppliedInitialPage;
49
+ BOOL _needsPropsReapply;
39
50
  NSString *_layoutDirection;
40
51
  NSArray<NSString *> *_pageKeys;
41
52
  NSString *_retainedPages;
42
53
  NSMutableDictionary<NSString *, NSNumber *> *_pageOffsets;
43
54
  NSMapTable<UIScrollView *, NSValue *> *_originalInsets;
55
+ NSMapTable<UIScrollView *, NSNumber *> *_appliedTopInsets;
44
56
  NSMapTable<UIScrollView *, NSValue *> *_originalIndicatorInsets;
45
57
  NSMapTable<UIScrollView *, NSNumber *> *_originalAlwaysBounceVertical;
46
58
  NSMapTable<UIScrollView *, NSNumber *> *_originalInsetAdjustmentBehavior;
@@ -64,6 +76,7 @@ static void *RNCCollapsiblePagerContentOffsetContext = &RNCCollapsiblePagerConte
64
76
  _pageControllers = [NSMutableArray new];
65
77
  _pageOffsets = [NSMutableDictionary new];
66
78
  _originalInsets = [NSMapTable weakToStrongObjectsMapTable];
79
+ _appliedTopInsets = [NSMapTable weakToStrongObjectsMapTable];
67
80
  _originalIndicatorInsets = [NSMapTable weakToStrongObjectsMapTable];
68
81
  _originalAlwaysBounceVertical = [NSMapTable weakToStrongObjectsMapTable];
69
82
  _originalInsetAdjustmentBehavior = [NSMapTable weakToStrongObjectsMapTable];
@@ -73,7 +86,10 @@ static void *RNCCollapsiblePagerContentOffsetContext = &RNCCollapsiblePagerConte
73
86
  _destinationIndex = 0;
74
87
  _pendingInitialPage = 0;
75
88
  _scrollEnabled = YES;
89
+ _nestedScrollEnabled = NO;
90
+ _pendingGoToIndex = -1;
76
91
  _hasAppliedInitialPage = NO;
92
+ _needsPropsReapply = YES;
77
93
  _layoutDirection = @"ltr";
78
94
 
79
95
  _containerView = [UIView new];
@@ -94,6 +110,17 @@ static void *RNCCollapsiblePagerContentOffsetContext = &RNCCollapsiblePagerConte
94
110
  }
95
111
  }
96
112
 
113
+ - (void)didMoveToWindow
114
+ {
115
+ [super didMoveToWindow];
116
+ // OneKey patch: removing a decelerating page from its window can suppress
117
+ // UIKit's final scroll callback. Restore the last acknowledged page on reattach.
118
+ if (self.window != nil && _isPagerDragging &&
119
+ !_pagerScrollView.dragging && !_pagerScrollView.decelerating) {
120
+ [self finishPagerScrollEmittingSelection:NO];
121
+ }
122
+ }
123
+
97
124
  - (void)initializePageViewController
98
125
  {
99
126
  _pageViewController = [[UIPageViewController alloc]
@@ -102,7 +129,9 @@ static void *RNCCollapsiblePagerContentOffsetContext = &RNCCollapsiblePagerConte
102
129
  options:nil];
103
130
  _pageViewController.dataSource = self;
104
131
  _pageViewController.delegate = self;
105
- [_containerView addSubview:_pageViewController.view];
132
+ // Fabric may mount header slots before a recycled pager reattaches.
133
+ // Keep the recreated page container below those existing headers.
134
+ [_containerView insertSubview:_pageViewController.view atIndex:0];
106
135
 
107
136
  for (UIView *subview in _pageViewController.view.subviews) {
108
137
  if ([subview isKindOfClass:UIScrollView.class]) {
@@ -113,6 +142,7 @@ static void *RNCCollapsiblePagerContentOffsetContext = &RNCCollapsiblePagerConte
113
142
  break;
114
143
  }
115
144
  }
145
+ [self applyNestedScrollBlocker];
116
146
  }
117
147
 
118
148
  #pragma mark - React mounting
@@ -140,6 +170,10 @@ static void *RNCCollapsiblePagerContentOffsetContext = &RNCCollapsiblePagerConte
140
170
  - (void)rebuildSlots
141
171
  {
142
172
  [self detachScrollObserver];
173
+ // OneKey patch: Fabric reuses these views without resetting transforms
174
+ // written by a native parent. Release our collapse translation with the slot.
175
+ _headerView.transform = CGAffineTransformIdentity;
176
+ _stickyHeaderView.transform = CGAffineTransformIdentity;
143
177
  [_headerView removeFromSuperview];
144
178
  [_stickyHeaderView removeFromSuperview];
145
179
  _headerView = nil;
@@ -166,7 +200,8 @@ static void *RNCCollapsiblePagerContentOffsetContext = &RNCCollapsiblePagerConte
166
200
  _pendingInitialPage >= 0 &&
167
201
  _pendingInitialPage < _pageControllers.count) {
168
202
  _currentIndex = _pendingInitialPage;
169
- _hasAppliedInitialPage = YES;
203
+ // A recycled host may receive slots before its page controller exists.
204
+ _hasAppliedInitialPage = _pageViewController != nil;
170
205
  } else {
171
206
  _currentIndex = MIN(MAX(_currentIndex, 0), _pageControllers.count - 1);
172
207
  }
@@ -192,6 +227,19 @@ static void *RNCCollapsiblePagerContentOffsetContext = &RNCCollapsiblePagerConte
192
227
  [super layoutSubviews];
193
228
  _containerView.frame = self.bounds;
194
229
  _pageViewController.view.frame = _containerView.bounds;
230
+ // OneKey patch: Fabric may mount page slots before applying the host props.
231
+ // Commit the pending initial page once both props and page controllers exist.
232
+ if (!_hasAppliedInitialPage && _pageViewController != nil &&
233
+ _pendingInitialPage >= 0 && _pendingInitialPage < _pageControllers.count) {
234
+ [self detachScrollObserver];
235
+ _currentIndex = _pendingInitialPage;
236
+ _destinationIndex = _currentIndex;
237
+ _hasAppliedInitialPage = YES;
238
+ [_pageViewController setViewControllers:@[_pageControllers[_currentIndex]]
239
+ direction:UIPageViewControllerNavigationDirectionForward
240
+ animated:NO
241
+ completion:nil];
242
+ }
195
243
  // UIKit frame assignment is undefined while a view has a non-identity transform.
196
244
  // Lay out the original slots, then restore the shared collapse translation.
197
245
  _headerView.transform = CGAffineTransformIdentity;
@@ -203,6 +251,8 @@ static void *RNCCollapsiblePagerContentOffsetContext = &RNCCollapsiblePagerConte
203
251
  self.bounds.size.width,
204
252
  _stickyHeaderHeight
205
253
  );
254
+ // Settle UIKit page containers before sizing their autoresizing child views.
255
+ [_pageViewController.view layoutIfNeeded];
206
256
  for (UIViewController *controller in _pageControllers) {
207
257
  controller.view.frame = _pageViewController.view.bounds;
208
258
  controller.view.subviews.firstObject.frame = controller.view.bounds;
@@ -223,7 +273,14 @@ static void *RNCCollapsiblePagerContentOffsetContext = &RNCCollapsiblePagerConte
223
273
  [self detachScrollObserver];
224
274
  for (UIScrollView *scrollView in _originalInsets.keyEnumerator) {
225
275
  NSValue *value = [_originalInsets objectForKey:scrollView];
226
- if (value != nil) scrollView.contentInset = value.UIEdgeInsetsValue;
276
+ if (value != nil) {
277
+ UIEdgeInsets original = value.UIEdgeInsetsValue;
278
+ NSNumber *appliedTop = [_appliedTopInsets objectForKey:scrollView];
279
+ if (appliedTop != nil && scrollView.refreshControl != nil) {
280
+ original.top += MAX(0, scrollView.contentInset.top - appliedTop.doubleValue);
281
+ }
282
+ scrollView.contentInset = original;
283
+ }
227
284
  NSValue *indicatorValue = [_originalIndicatorInsets objectForKey:scrollView];
228
285
  if (indicatorValue != nil) {
229
286
  scrollView.verticalScrollIndicatorInsets = indicatorValue.UIEdgeInsetsValue;
@@ -236,9 +293,14 @@ static void *RNCCollapsiblePagerContentOffsetContext = &RNCCollapsiblePagerConte
236
293
  }
237
294
  }
238
295
  [_originalInsets removeAllObjects];
296
+ [_appliedTopInsets removeAllObjects];
239
297
  [_originalIndicatorInsets removeAllObjects];
240
298
  [_originalAlwaysBounceVertical removeAllObjects];
241
299
  [_originalInsetAdjustmentBehavior removeAllObjects];
300
+ // OneKey patch: Fabric reuses these views without resetting transforms
301
+ // written by a native parent. Release our collapse translation with the slot.
302
+ _headerView.transform = CGAffineTransformIdentity;
303
+ _stickyHeaderView.transform = CGAffineTransformIdentity;
242
304
  [_headerView removeFromSuperview];
243
305
  [_stickyHeaderView removeFromSuperview];
244
306
  [_logicalChildren removeAllObjects];
@@ -248,6 +310,10 @@ static void *RNCCollapsiblePagerContentOffsetContext = &RNCCollapsiblePagerConte
248
310
  _pageViewController.dataSource = nil;
249
311
  _pageViewController.delegate = nil;
250
312
  _pagerScrollView.delegate = nil;
313
+ if (_blockerGesture != nil) {
314
+ [self removeGestureRecognizer:_blockerGesture];
315
+ _blockerGesture = nil;
316
+ }
251
317
  [_pageViewController.view removeFromSuperview];
252
318
  _pageViewController = nil;
253
319
  _pagerScrollView = nil;
@@ -262,8 +328,14 @@ static void *RNCCollapsiblePagerContentOffsetContext = &RNCCollapsiblePagerConte
262
328
  _stickyHeaderHeight = 0;
263
329
  _headerOffset = 0;
264
330
  _scrollEnabled = YES;
331
+ _nestedScrollEnabled = NO;
265
332
  _transitioning = NO;
333
+ _isPagerDragging = NO;
334
+ _hasReceivedPageCommand = NO;
335
+ _pendingGoToIndex = -1;
266
336
  _hasAppliedInitialPage = NO;
337
+ // OneKey patch: Fabric retains old props when recycling this view.
338
+ _needsPropsReapply = YES;
267
339
  _layoutDirection = @"ltr";
268
340
  _isBeingRecycled = NO;
269
341
  }
@@ -275,38 +347,46 @@ static void *RNCCollapsiblePagerContentOffsetContext = &RNCCollapsiblePagerConte
275
347
  const auto &oldViewProps = *std::static_pointer_cast<const RNCCollapsiblePagerViewProps>(_props);
276
348
  const auto &newViewProps = *std::static_pointer_cast<const RNCCollapsiblePagerViewProps>(props);
277
349
 
278
- if (oldViewProps.initialPage != newViewProps.initialPage) {
350
+ if (_needsPropsReapply || oldViewProps.initialPage != newViewProps.initialPage) {
279
351
  _pendingInitialPage = MAX(0, newViewProps.initialPage);
280
- _hasAppliedInitialPage = NO;
352
+ // Focus commands can arrive before the recycled host receives its props.
353
+ _hasAppliedInitialPage = _hasReceivedPageCommand;
354
+ // OneKey patch: Prop updates can arrive after the last slot was mounted.
355
+ [self setNeedsLayout];
281
356
  }
282
- if (oldViewProps.scrollEnabled != newViewProps.scrollEnabled) {
357
+ if (_needsPropsReapply || oldViewProps.scrollEnabled != newViewProps.scrollEnabled) {
283
358
  _scrollEnabled = newViewProps.scrollEnabled;
284
359
  _pagerScrollView.scrollEnabled = _scrollEnabled;
285
360
  }
286
- if (oldViewProps.layoutDirection != newViewProps.layoutDirection) {
361
+ if (_needsPropsReapply || oldViewProps.nestedScrollEnabled != newViewProps.nestedScrollEnabled) {
362
+ _nestedScrollEnabled = newViewProps.nestedScrollEnabled;
363
+ [self applyNestedScrollBlocker];
364
+ }
365
+ if (_needsPropsReapply || oldViewProps.layoutDirection != newViewProps.layoutDirection) {
287
366
  _layoutDirection = RCTNSStringFromString(toString(newViewProps.layoutDirection));
288
367
  }
289
368
  BOOL insetsChanged = NO;
290
- if (oldViewProps.headerHeight != newViewProps.headerHeight) {
369
+ if (_needsPropsReapply || oldViewProps.headerHeight != newViewProps.headerHeight) {
291
370
  _headerHeight = MAX(0, newViewProps.headerHeight);
292
371
  _headerOffset = MIN(_headerOffset, _headerHeight);
293
372
  insetsChanged = YES;
294
373
  }
295
- if (oldViewProps.stickyHeaderHeight != newViewProps.stickyHeaderHeight) {
374
+ if (_needsPropsReapply || oldViewProps.stickyHeaderHeight != newViewProps.stickyHeaderHeight) {
296
375
  _stickyHeaderHeight = MAX(0, newViewProps.stickyHeaderHeight);
297
376
  insetsChanged = YES;
298
377
  }
299
- if (oldViewProps.pageKeys != newViewProps.pageKeys) {
378
+ if (_needsPropsReapply || oldViewProps.pageKeys != newViewProps.pageKeys) {
300
379
  NSString *json = RCTNSStringFromString(newViewProps.pageKeys);
301
380
  NSData *data = [json dataUsingEncoding:NSUTF8StringEncoding];
302
381
  id parsed = data == nil ? nil : [NSJSONSerialization JSONObjectWithData:data options:0 error:nil];
303
382
  _pageKeys = [parsed isKindOfClass:NSArray.class] ? parsed : @[];
304
383
  }
305
- if (oldViewProps.retainedPages != newViewProps.retainedPages) {
384
+ if (_needsPropsReapply || oldViewProps.retainedPages != newViewProps.retainedPages) {
306
385
  _retainedPages = RCTNSStringFromString(newViewProps.retainedPages);
307
386
  [self setNeedsLayout];
308
387
  }
309
388
 
389
+ _needsPropsReapply = NO;
310
390
  [super updateProps:props oldProps:oldProps];
311
391
  if (insetsChanged) {
312
392
  [self reapplyInsetsToObservedScrollView];
@@ -358,7 +438,12 @@ static void *RNCCollapsiblePagerContentOffsetContext = &RNCCollapsiblePagerConte
358
438
  [self restoreDetachedScrollInsets];
359
439
  if (_currentIndex < 0 || _currentIndex >= _pageControllers.count) return;
360
440
  UIScrollView *candidate = [self findVerticalScrollViewInView:_pageControllers[_currentIndex].view];
361
- if (candidate == nil || candidate == _observedScrollView) return;
441
+ if (candidate == nil) return;
442
+ // A decelerating list can otherwise claim a new horizontal touch immediately.
443
+ if (_pagerScrollView != nil) {
444
+ [candidate.panGestureRecognizer requireGestureRecognizerToFail:_pagerScrollView.panGestureRecognizer];
445
+ }
446
+ if (candidate == _observedScrollView) return;
362
447
 
363
448
  BOOL replacingCurrentScrollView = _observingContentOffset;
364
449
  [self detachScrollObserver];
@@ -416,6 +501,10 @@ static void *RNCCollapsiblePagerContentOffsetContext = &RNCCollapsiblePagerConte
416
501
  // pager-owned insets before that view is used as a search or dialog list.
417
502
  CGFloat logicalOffset = scrollView.contentOffset.y + scrollView.contentInset.top;
418
503
  UIEdgeInsets original = [_originalInsets objectForKey:scrollView].UIEdgeInsetsValue;
504
+ NSNumber *appliedTop = [_appliedTopInsets objectForKey:scrollView];
505
+ if (appliedTop != nil && scrollView.refreshControl != nil) {
506
+ original.top += MAX(0, scrollView.contentInset.top - appliedTop.doubleValue);
507
+ }
419
508
  scrollView.contentInset = original;
420
509
  scrollView.verticalScrollIndicatorInsets =
421
510
  [_originalIndicatorInsets objectForKey:scrollView].UIEdgeInsetsValue;
@@ -426,6 +515,7 @@ static void *RNCCollapsiblePagerContentOffsetContext = &RNCCollapsiblePagerConte
426
515
  [scrollView setContentOffset:CGPointMake(scrollView.contentOffset.x, logicalOffset - original.top)
427
516
  animated:NO];
428
517
  [_originalInsets removeObjectForKey:scrollView];
518
+ [_appliedTopInsets removeObjectForKey:scrollView];
429
519
  [_originalIndicatorInsets removeObjectForKey:scrollView];
430
520
  [_originalAlwaysBounceVertical removeObjectForKey:scrollView];
431
521
  [_originalInsetAdjustmentBehavior removeObjectForKey:scrollView];
@@ -452,9 +542,17 @@ static void *RNCCollapsiblePagerContentOffsetContext = &RNCCollapsiblePagerConte
452
542
  // This host already includes the fixed header and safe area in its frame.
453
543
  // UIKit automatic adjustment would add them a second time to short pages.
454
544
  scrollView.contentInsetAdjustmentBehavior = UIScrollViewContentInsetAdjustmentNever;
455
- CGFloat previousLogicalOffset = scrollView.contentOffset.y + scrollView.contentInset.top;
545
+ CGFloat previousTopInset = scrollView.contentInset.top;
546
+ CGFloat previousLogicalOffset = scrollView.contentOffset.y + previousTopInset;
547
+ NSNumber *appliedTop = [_appliedTopInsets objectForKey:scrollView];
548
+ // UIRefreshControl owns its temporary top inset, including while ending.
549
+ CGFloat refreshInset = appliedTop != nil && scrollView.refreshControl != nil
550
+ ? MAX(0, previousTopInset - appliedTop.doubleValue) : 0;
456
551
  UIEdgeInsets next = original;
457
552
  next.top += _headerHeight + _stickyHeaderHeight;
553
+ CGFloat pagerTopInset = next.top;
554
+ next.top += refreshInset;
555
+ [_appliedTopInsets setObject:@(pagerTopInset) forKey:scrollView];
458
556
  // Short pages still need enough range to collapse the header; long pages
459
557
  // must end at the last row rather than leave a header-sized blank footer.
460
558
  next.bottom += MAX(0, scrollView.bounds.size.height - _stickyHeaderHeight
@@ -467,7 +565,7 @@ static void *RNCCollapsiblePagerContentOffsetContext = &RNCCollapsiblePagerConte
467
565
  }
468
566
  scrollView.contentInset = next;
469
567
  UIEdgeInsets indicatorInsets = scrollView.verticalScrollIndicatorInsets;
470
- indicatorInsets.top = next.top;
568
+ indicatorInsets.top = pagerTopInset;
471
569
  scrollView.verticalScrollIndicatorInsets = indicatorInsets;
472
570
  scrollView.alwaysBounceVertical = YES;
473
571
 
@@ -479,7 +577,10 @@ static void *RNCCollapsiblePagerContentOffsetContext = &RNCCollapsiblePagerConte
479
577
  targetOffset = (saved == nil ? _headerOffset : saved.doubleValue) - next.top;
480
578
  targetOffset = MAX(targetOffset, -next.top + _headerOffset);
481
579
  }
482
- [scrollView setContentOffset:CGPointMake(scrollView.contentOffset.x, targetOffset) animated:NO];
580
+ // A bottom-inset change must not cancel UIKit's refresh or bounce settlement.
581
+ if (restore || previousTopInset != next.top) {
582
+ [scrollView setContentOffset:CGPointMake(scrollView.contentOffset.x, targetOffset) animated:NO];
583
+ }
483
584
  if (pageIndex == _currentIndex) [self updateHeaderForScrollView:scrollView];
484
585
  }
485
586
 
@@ -553,7 +654,24 @@ static void *RNCCollapsiblePagerContentOffsetContext = &RNCCollapsiblePagerConte
553
654
  - (void)goTo:(NSInteger)index animated:(BOOL)animated
554
655
  {
555
656
  if (_isBeingRecycled || index < 0 || index >= _pageControllers.count) return;
556
- if (_transitioning && animated) return;
657
+ _hasReceivedPageCommand = YES;
658
+ _hasAppliedInitialPage = YES;
659
+ if (_transitioning && animated) {
660
+ _pendingGoToIndex = index;
661
+ return;
662
+ }
663
+
664
+ _pendingGoToIndex = -1;
665
+
666
+ // Re-selecting the displayed controller removes its view briefly and cancels
667
+ // an in-flight list touch. Focus synchronization must be idempotent.
668
+ if (!_transitioning && index == _currentIndex &&
669
+ _pageViewController.viewControllers.firstObject == _pageControllers[index]) {
670
+ [self attachScrollObserverForCurrentPage];
671
+ [self emitPageSelected:index];
672
+ [self emitDiagnostics:@"page-selected"];
673
+ return;
674
+ }
557
675
 
558
676
  [self detachScrollObserver];
559
677
  _destinationIndex = index;
@@ -563,6 +681,8 @@ static void *RNCCollapsiblePagerContentOffsetContext = &RNCCollapsiblePagerConte
563
681
  ? UIPageViewControllerNavigationDirectionForward
564
682
  : UIPageViewControllerNavigationDirectionReverse;
565
683
  _transitioning = YES;
684
+ _isPagerDragging = NO;
685
+ NSUInteger capturedTransition = ++_transitionId;
566
686
  NSUInteger capturedGeneration = _generation;
567
687
  __weak __typeof__(self) weakSelf = self;
568
688
  [_pageViewController setViewControllers:@[_pageControllers[index]]
@@ -570,12 +690,22 @@ static void *RNCCollapsiblePagerContentOffsetContext = &RNCCollapsiblePagerConte
570
690
  animated:animated && index != _currentIndex
571
691
  completion:^(BOOL finished) {
572
692
  __strong __typeof__(weakSelf) self = weakSelf;
573
- if (self == nil || self->_generation != capturedGeneration || self->_isBeingRecycled) return;
574
- self->_transitioning = NO;
575
- self->_currentIndex = index;
576
- [self attachScrollObserverForCurrentPage];
577
- [self emitPageSelected:index];
578
- [self emitDiagnostics:@"page-selected"];
693
+ if (self == nil || self->_generation != capturedGeneration ||
694
+ self->_transitionId != capturedTransition || self->_isBeingRecycled) return;
695
+ if (finished) {
696
+ self->_currentIndex = index;
697
+ [self attachScrollObserverForCurrentPage];
698
+ [self emitPageSelected:index];
699
+ [self emitDiagnostics:@"page-selected"];
700
+ }
701
+ // UIKit is still unwinding its transition when this completion runs.
702
+ // Start a queued animation only after that callback has returned.
703
+ dispatch_async(dispatch_get_main_queue(), ^{
704
+ if (self->_generation != capturedGeneration ||
705
+ self->_transitionId != capturedTransition || self->_isBeingRecycled) return;
706
+ self->_transitioning = NO;
707
+ [self drainPendingGoTo];
708
+ });
579
709
  }];
580
710
  }
581
711
 
@@ -604,27 +734,23 @@ static void *RNCCollapsiblePagerContentOffsetContext = &RNCCollapsiblePagerConte
604
734
  return [self adjacentController:viewController delta:self.isLtrLayout ? -1 : 1];
605
735
  }
606
736
 
607
- - (void)pageViewController:(UIPageViewController *)pageViewController
608
- didFinishAnimating:(BOOL)finished
609
- previousViewControllers:(NSArray<UIViewController *> *)previousViewControllers
610
- transitionCompleted:(BOOL)completed
737
+ - (void)drainPendingGoTo
611
738
  {
612
- _transitioning = NO;
613
- if (!completed) return;
614
- UIViewController *controller = _pageViewController.viewControllers.firstObject;
615
- NSInteger index = [_pageControllers indexOfObjectIdenticalTo:controller];
616
- if (index == NSNotFound) return;
617
- [self detachScrollObserver];
618
- _currentIndex = index;
619
- [self attachScrollObserverForCurrentPage];
620
- [self emitPageSelected:index];
621
- [self emitDiagnostics:@"page-selected"];
739
+ NSInteger pending = _pendingGoToIndex;
740
+ _pendingGoToIndex = -1;
741
+ if (pending >= 0 && pending != _currentIndex) {
742
+ [self goTo:pending animated:YES];
743
+ }
622
744
  }
623
745
 
624
746
  #pragma mark - Pager UIScrollViewDelegate
625
747
 
626
748
  - (void)scrollViewWillBeginDragging:(UIScrollView *)scrollView
627
749
  {
750
+ // A user drag owns the result even if UIKit later completes the old animation.
751
+ ++_transitionId;
752
+ _isPagerDragging = YES;
753
+ _transitioning = YES;
628
754
  const auto emitter = [self eventEmitter];
629
755
  if (emitter) {
630
756
  emitter->onPageScrollStateChanged({
@@ -645,8 +771,57 @@ static void *RNCCollapsiblePagerContentOffsetContext = &RNCCollapsiblePagerConte
645
771
  }
646
772
  }
647
773
 
648
- - (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView
774
+ - (void)scrollViewDidEndDragging:(UIScrollView *)scrollView
775
+ willDecelerate:(BOOL)decelerate
649
776
  {
777
+ if (!decelerate) [self scrollViewDidEndDecelerating:scrollView];
778
+ }
779
+
780
+ - (void)finishPagerScrollEmittingSelection:(BOOL)emitSelection
781
+ {
782
+ if (_isPagerDragging) {
783
+ UIViewController *controller = nil;
784
+ if (!emitSelection && _currentIndex >= 0 && _currentIndex < _pageControllers.count) {
785
+ // Detaching can make UIKit settle on a neighbouring controller without a
786
+ // completion callback. Keep the last page acknowledged by JavaScript.
787
+ controller = _pageControllers[_currentIndex];
788
+ } else {
789
+ controller = _pageViewController.viewControllers.firstObject;
790
+ // An interrupted programmatic animation can report its target while a
791
+ // different controller is centered. Read the settled UIKit viewport.
792
+ for (UIViewController *candidate in _pageControllers) {
793
+ if (candidate.view.window == nil) continue;
794
+ CGRect frame = [candidate.view convertRect:candidate.view.bounds toView:_containerView];
795
+ if (fabs(frame.origin.x) < 1 &&
796
+ fabs(frame.size.width - _containerView.bounds.size.width) < 1) {
797
+ controller = candidate;
798
+ break;
799
+ }
800
+ }
801
+ }
802
+ NSInteger index = [_pageControllers indexOfObjectIdenticalTo:controller];
803
+ if (index != NSNotFound) {
804
+ if (_pageViewController.viewControllers.firstObject != controller) {
805
+ // Correct UIKit only after its animation completion has returned.
806
+ [_pageViewController setViewControllers:@[controller]
807
+ direction:UIPageViewControllerNavigationDirectionForward
808
+ animated:NO
809
+ completion:nil];
810
+ }
811
+ [self detachScrollObserver];
812
+ _currentIndex = index;
813
+ _destinationIndex = index;
814
+ [self attachScrollObserverForCurrentPage];
815
+ // A newer tab tap remains authoritative while its queued command runs.
816
+ if (emitSelection &&
817
+ (_pendingGoToIndex < 0 || _pendingGoToIndex == index)) {
818
+ [self emitPageSelected:index];
819
+ [self emitDiagnostics:@"page-selected"];
820
+ }
821
+ }
822
+ _isPagerDragging = NO;
823
+ _transitioning = NO;
824
+ }
650
825
  const auto emitter = [self eventEmitter];
651
826
  if (emitter) {
652
827
  emitter->onPageScrollStateChanged({
@@ -654,6 +829,12 @@ static void *RNCCollapsiblePagerContentOffsetContext = &RNCCollapsiblePagerConte
654
829
  });
655
830
  }
656
831
  [self emitDiagnostics:@"pager-idle"];
832
+ [self drainPendingGoTo];
833
+ }
834
+
835
+ - (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView
836
+ {
837
+ [self finishPagerScrollEmittingSelection:YES];
657
838
  }
658
839
 
659
840
  - (void)scrollViewDidScroll:(UIScrollView *)scrollView
@@ -725,6 +906,78 @@ static void *RNCCollapsiblePagerContentOffsetContext = &RNCCollapsiblePagerConte
725
906
  _pagerScrollView.scrollEnabled = scrollEnabled;
726
907
  }
727
908
 
909
+ #pragma mark - Nested pager gesture coordination
910
+
911
+ - (void)applyNestedScrollBlocker
912
+ {
913
+ if (_pagerScrollView == nil) return;
914
+
915
+ if (_nestedScrollEnabled && _blockerGesture == nil) {
916
+ _blockerGesture = [[UIPanGestureRecognizer alloc]
917
+ initWithTarget:self
918
+ action:@selector(blockerGestureFired:)];
919
+ _blockerGesture.delegate = self;
920
+ _blockerGesture.cancelsTouchesInView = NO;
921
+ _blockerGesture.delaysTouchesBegan = NO;
922
+ [self addGestureRecognizer:_blockerGesture];
923
+ [_pagerScrollView.panGestureRecognizer requireGestureRecognizerToFail:_blockerGesture];
924
+ } else if (!_nestedScrollEnabled && _blockerGesture != nil) {
925
+ [self removeGestureRecognizer:_blockerGesture];
926
+ _blockerGesture = nil;
927
+ }
928
+ }
929
+
930
+ - (void)blockerGestureFired:(UIPanGestureRecognizer *)recognizer
931
+ {
932
+ // Reset through the public enabled API. UIGestureRecognizer.state is
933
+ // read-only for clients; only recognizer subclasses may assign it.
934
+ if (recognizer.state == UIGestureRecognizerStateBegan) {
935
+ recognizer.enabled = NO;
936
+ recognizer.enabled = YES;
937
+ }
938
+ }
939
+
940
+ - (BOOL)gestureRecognizerShouldBegin:(UIGestureRecognizer *)gestureRecognizer
941
+ {
942
+ if (gestureRecognizer != _blockerGesture) return YES;
943
+ if (!_nestedScrollEnabled) return NO;
944
+ if (![gestureRecognizer isKindOfClass:[UIPanGestureRecognizer class]]) return NO;
945
+
946
+ UIPanGestureRecognizer *pan = (UIPanGestureRecognizer *)gestureRecognizer;
947
+ CGPoint velocity = [pan velocityInView:self];
948
+ NSInteger pageCount = _pageControllers.count;
949
+ if (pageCount == 0 || _currentIndex < 0 || _currentIndex >= pageCount) return NO;
950
+
951
+ CGFloat absVelocityX = fabs(velocity.x);
952
+ CGFloat absVelocityY = fabs(velocity.y);
953
+ if (absVelocityX <= absVelocityY || absVelocityX < 1.0f) return NO;
954
+
955
+ BOOL isLtr = self.isLtrLayout;
956
+ NSInteger firstPageIndex = isLtr ? 0 : pageCount - 1;
957
+ NSInteger lastPageIndex = isLtr ? pageCount - 1 : 0;
958
+ if (_currentIndex == firstPageIndex && velocity.x > 0) return YES;
959
+ if (_currentIndex == lastPageIndex && velocity.x < 0) return YES;
960
+ return NO;
961
+ }
962
+
963
+ - (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldReceiveTouch:(UITouch *)touch
964
+ {
965
+ // OneKey patch: a new touch can cancel UIKit's animation before its first
966
+ // frame without starting a drag or completing the animation. Finish the
967
+ // commanded page before handing that touch to the nested scroll views.
968
+ if (gestureRecognizer == _blockerGesture && _transitioning && !_isPagerDragging &&
969
+ [touch.view isDescendantOfView:_pagerScrollView]) {
970
+ [self goTo:_destinationIndex animated:NO];
971
+ }
972
+ return YES;
973
+ }
974
+
975
+ - (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer
976
+ shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer
977
+ {
978
+ return gestureRecognizer == _blockerGesture;
979
+ }
980
+
728
981
  + (ComponentDescriptorProvider)componentDescriptorProvider
729
982
  {
730
983
  return concreteComponentDescriptorProvider<RNCCollapsiblePagerViewComponentDescriptor>();
@@ -593,9 +593,11 @@ using namespace facebook::react;
593
593
 
594
594
  // The blocker gesture never actually does anything — it only serves as a gate
595
595
  - (void)blockerGestureFired:(UIPanGestureRecognizer *)recognizer {
596
- // Immediately cancel so it doesn't interfere with other gestures
596
+ // Reset through the public enabled API. UIGestureRecognizer.state is
597
+ // read-only for clients; only recognizer subclasses may assign it.
597
598
  if (recognizer.state == UIGestureRecognizerStateBegan) {
598
- recognizer.state = UIGestureRecognizerStateCancelled;
599
+ recognizer.enabled = NO;
600
+ recognizer.enabled = YES;
599
601
  }
600
602
  }
601
603
 
@@ -4,6 +4,11 @@ import React from "react";
4
4
  import { I18nManager, Platform, StyleSheet, View } from "react-native";
5
5
  import CollapsiblePagerViewNativeComponent, { Commands as CollapsiblePagerViewNativeCommands } from "./CollapsiblePagerViewNativeComponent";
6
6
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
7
+ const pageIndex = value => {
8
+ const index = Math.trunc(value);
9
+ return Number.isFinite(index) ? index : null;
10
+ };
11
+ const clampedPageIndex = (value, pageCount) => Math.min(Math.max(0, pageIndex(value) ?? 0), Math.max(0, pageCount - 1));
7
12
  const pageKey = (child, index) => {
8
13
  if (/*#__PURE__*/React.isValidElement(child) && child.key != null) {
9
14
  return String(child.key);
@@ -18,7 +23,7 @@ const pageKey = (child, index) => {
18
23
  */
19
24
  export class CollapsiblePagerView extends React.PureComponent {
20
25
  state = {
21
- selectedPage: Math.max(0, this.props.initialPage ?? 0)
26
+ selectedPage: clampedPageIndex(this.props.initialPage ?? 0, React.Children.toArray(this.props.children).length)
22
27
  };
23
28
  nativeRef = null;
24
29
  lastMountSignature = null;
@@ -26,16 +31,26 @@ export class CollapsiblePagerView extends React.PureComponent {
26
31
  this.notifyMountedPagesChanged();
27
32
  }
28
33
  componentDidUpdate() {
34
+ const pageCount = this.pages().length;
35
+ const selectedPage = clampedPageIndex(this.state.selectedPage, pageCount);
36
+ if (selectedPage !== this.state.selectedPage) {
37
+ this.setState({
38
+ selectedPage
39
+ });
40
+ return;
41
+ }
29
42
  this.notifyMountedPagesChanged();
30
43
  }
31
44
  setPage = selectedPage => {
32
- if (this.nativeRef) {
33
- CollapsiblePagerViewNativeCommands.setPage(this.nativeRef, selectedPage);
45
+ const position = pageIndex(selectedPage);
46
+ if (this.nativeRef && position !== null && position >= 0 && position < this.pages().length) {
47
+ CollapsiblePagerViewNativeCommands.setPage(this.nativeRef, position);
34
48
  }
35
49
  };
36
50
  setPageWithoutAnimation = selectedPage => {
37
- if (this.nativeRef) {
38
- CollapsiblePagerViewNativeCommands.setPageWithoutAnimation(this.nativeRef, selectedPage);
51
+ const position = pageIndex(selectedPage);
52
+ if (this.nativeRef && position !== null && position >= 0 && position < this.pages().length) {
53
+ CollapsiblePagerViewNativeCommands.setPageWithoutAnimation(this.nativeRef, position);
39
54
  }
40
55
  };
41
56
  setScrollEnabled = scrollEnabled => {
@@ -78,7 +93,7 @@ export class CollapsiblePagerView extends React.PureComponent {
78
93
  this.props.onMountedPagesChanged(state);
79
94
  };
80
95
  onPageSelected = event => {
81
- const position = Math.max(0, Math.trunc(event.nativeEvent.position));
96
+ const position = clampedPageIndex(event.nativeEvent.position, this.pages().length);
82
97
  if (position !== this.state.selectedPage) {
83
98
  this.setState({
84
99
  selectedPage: position