@onekeyfe/react-native-pager-view 6.7.0-1 → 6.7.0-12

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.
Files changed (43) hide show
  1. package/README.md +9 -0
  2. package/android/src/main/java/com/reactnativepagerview/CollapsiblePagerViewManager.kt +2 -2
  3. package/android/src/main/java/com/reactnativepagerview/NativeScrollerViewManager.kt +2 -2
  4. package/docs/SPEC.md +108 -7
  5. package/ios/RNCCollapsiblePagerViewComponentView.mm +341 -23
  6. package/ios/RNCNativeScrollerComponentView.mm +30 -5
  7. package/ios/tests/RNCCollapsiblePagerFirstFrameTests.mm +124 -0
  8. package/ios/tests/RNCCollapsiblePagerHitTestTests.mm +186 -1
  9. package/ios/tests/RNCCollapsiblePagerRefreshControlTests.mm +50 -0
  10. package/ios/tests/RNCNativeScrollerTests.mm +39 -0
  11. package/lib/module/CollapsiblePagerView.js +16 -7
  12. package/lib/module/CollapsiblePagerView.web.js +428 -37
  13. package/lib/module/CollapsiblePagerViewNativeComponent.ts +1 -1
  14. package/lib/module/NativeScroller.js +2 -2
  15. package/lib/module/NativeScroller.web.js +16 -3
  16. package/lib/module/NativeScrollerNativeComponent.ts +1 -1
  17. package/lib/module/ScrollEdgeEffect.js +8 -0
  18. package/lib/module/WebCollapsiblePagerPage.js +182 -0
  19. package/lib/module/index.js +3 -0
  20. package/lib/module/index.native.js +9 -0
  21. package/lib/module/index.web.js +3 -0
  22. package/lib/typescript/src/CollapsiblePagerView.d.ts +8 -5
  23. package/lib/typescript/src/CollapsiblePagerView.web.d.ts +66 -4
  24. package/lib/typescript/src/CollapsiblePagerViewNativeComponent.d.ts +1 -1
  25. package/lib/typescript/src/NativeScroller.d.ts +3 -2
  26. package/lib/typescript/src/NativeScrollerNativeComponent.d.ts +1 -1
  27. package/lib/typescript/src/ScrollEdgeEffect.d.ts +5 -0
  28. package/lib/typescript/src/WebCollapsiblePagerPage.d.ts +42 -0
  29. package/lib/typescript/src/index.d.ts +5 -0
  30. package/lib/typescript/src/index.native.d.ts +13 -0
  31. package/lib/typescript/src/index.web.d.ts +5 -0
  32. package/package.json +6 -4
  33. package/src/CollapsiblePagerView.tsx +43 -11
  34. package/src/CollapsiblePagerView.web.tsx +640 -50
  35. package/src/CollapsiblePagerViewNativeComponent.ts +1 -1
  36. package/src/NativeScroller.tsx +6 -4
  37. package/src/NativeScroller.web.tsx +24 -2
  38. package/src/NativeScrollerNativeComponent.ts +1 -1
  39. package/src/ScrollEdgeEffect.ts +4 -0
  40. package/src/WebCollapsiblePagerPage.tsx +258 -0
  41. package/src/index.native.tsx +30 -0
  42. package/src/index.tsx +6 -0
  43. package/src/index.web.tsx +6 -0
@@ -8,6 +8,25 @@
8
8
 
9
9
  using namespace facebook::react;
10
10
 
11
+ static void RNCNativeScrollerHomeVisualLog(NSString *phase, UIScrollView *scrollView)
12
+ {
13
+ #if DEBUG
14
+ Class logClass = NSClassFromString(@"ReactNativeNativeLogger.OneKeyLog");
15
+ if (logClass == nil) logClass = NSClassFromString(@"OneKeyLog");
16
+ SEL selector = NSSelectorFromString(@"debug::");
17
+ if (logClass == nil || ![logClass respondsToSelector:selector]) return;
18
+ UIRefreshControl *control = scrollView.refreshControl;
19
+ NSString *message = [NSString stringWithFormat:
20
+ @"[IOS-HOME-VISUAL] epochMs=%.0f monoMs=%.0f phase=scroller-%@ scrollTag=%ld refresh=%d refreshing=%d scrollY=%.1f insetTop=%.1f",
21
+ [NSDate.date timeIntervalSince1970] * 1000, CACurrentMediaTime() * 1000,
22
+ phase, (long)scrollView.tag, control != nil, control.isRefreshing,
23
+ scrollView.contentOffset.y, scrollView.contentInset.top];
24
+ typedef void (*LogFunction)(id, SEL, NSString *, NSString *);
25
+ LogFunction logFunction = (LogFunction)[logClass methodForSelector:selector];
26
+ logFunction(logClass, selector, @"NativeScroller", message);
27
+ #endif
28
+ }
29
+
11
30
  @interface RNCNativeVerticalScrollView : UIScrollView
12
31
  @end
13
32
  @implementation RNCNativeVerticalScrollView
@@ -156,6 +175,7 @@ static Event RNCScrollerMetrics(UIScrollView *scroll, CGPoint velocity, CGPoint
156
175
  [_refreshControl addTarget:self action:@selector(refreshTriggered:)
157
176
  forControlEvents:UIControlEventValueChanged];
158
177
  _scrollView.refreshControl = _refreshControl;
178
+ RNCNativeScrollerHomeVisualLog(@"refresh-created", _scrollView);
159
179
  } else if (!next.refreshEnabled && _refreshControl != nil) {
160
180
  [_refreshControl endRefreshing];
161
181
  _scrollView.refreshControl = nil;
@@ -174,8 +194,10 @@ static Event RNCScrollerMetrics(UIScrollView *scroll, CGPoint velocity, CGPoint
174
194
  }
175
195
  if (next.refreshing && !_refreshControl.refreshing) {
176
196
  [_refreshControl beginRefreshing];
197
+ RNCNativeScrollerHomeVisualLog(@"refresh-begin-prop", _scrollView);
177
198
  } else if (!next.refreshing && (previous.refreshing || createdRefreshControl)) {
178
199
  [_refreshControl endRefreshing];
200
+ if (previous.refreshing) RNCNativeScrollerHomeVisualLog(@"refresh-end-prop", _scrollView);
179
201
  }
180
202
  }
181
203
 
@@ -196,7 +218,7 @@ static Event RNCScrollerMetrics(UIScrollView *scroll, CGPoint velocity, CGPoint
196
218
  BOOL refreshEnded = previous.refreshing && !next.refreshing;
197
219
  [super updateProps:props oldProps:oldProps];
198
220
  [self updateTopScrollEdge];
199
- if (next.topScrollEdgeHard && _topOverlay == nil) {
221
+ if ((next.topScrollEdgeEffect != RNCNativeScrollerTopScrollEdgeEffect::None) && _topOverlay == nil) {
200
222
  __weak __typeof__(self) weakSelf = self;
201
223
  dispatch_async(dispatch_get_main_queue(), ^{ [weakSelf updateTopScrollEdge]; });
202
224
  }
@@ -210,7 +232,7 @@ static Event RNCScrollerMetrics(UIScrollView *scroll, CGPoint velocity, CGPoint
210
232
  if (@available(iOS 26.0, *)) {
211
233
  const auto &props = *std::static_pointer_cast<const RNCNativeScrollerProps>(_props);
212
234
  UIView *overlay = nil;
213
- if (props.topScrollEdgeHard && !_recycled) {
235
+ if ((props.topScrollEdgeEffect != RNCNativeScrollerTopScrollEdgeEffect::None) && !_recycled) {
214
236
  NSUInteger fabricSiblings = 0;
215
237
  for (UIView *candidate in self.superview.subviews) {
216
238
  if (![candidate conformsToProtocol:@protocol(RCTComponentViewProtocol)]) continue;
@@ -220,7 +242,7 @@ static Event RNCScrollerMetrics(UIScrollView *scroll, CGPoint velocity, CGPoint
220
242
  if (fabricSiblings != 2) overlay = nil;
221
243
  }
222
244
  _topOverlay = overlay;
223
- CGFloat requestedOverlayInset = props.topScrollEdgeHard && !_recycled && overlay != nil &&
245
+ CGFloat requestedOverlayInset = (props.topScrollEdgeEffect != RNCNativeScrollerTopScrollEdgeEffect::None) && !_recycled && overlay != nil &&
224
246
  isfinite(props.topOverlayHeight) ? MAX(0, props.topOverlayHeight) : 0;
225
247
  if (_appliedOverlayInset != requestedOverlayInset) {
226
248
  // Keep UIKit's refresh rebound animation in control while it is overscrolled.
@@ -236,7 +258,7 @@ static Event RNCScrollerMetrics(UIScrollView *scroll, CGPoint velocity, CGPoint
236
258
  }
237
259
  [self emitContentViewportChangeIfNeeded];
238
260
  }
239
- BOOL enabled = props.topScrollEdgeHard && !_recycled && overlay != nil && self.window != nil;
261
+ BOOL enabled = (props.topScrollEdgeEffect != RNCNativeScrollerTopScrollEdgeEffect::None) && !_recycled && overlay != nil && self.window != nil;
240
262
  if (!enabled || _topEdgeInteractionHost != overlay) {
241
263
  if (_topEdgeInteraction != nil) {
242
264
  [_topEdgeInteractionHost removeInteraction:_topEdgeInteraction];
@@ -271,7 +293,8 @@ static Event RNCScrollerMetrics(UIScrollView *scroll, CGPoint velocity, CGPoint
271
293
  [overlay addInteraction:_topEdgeInteraction];
272
294
  _topEdgeInteractionHost = overlay;
273
295
  }
274
- _scrollView.topEdgeEffect.style = UIScrollEdgeEffectStyle.hardStyle;
296
+ _scrollView.topEdgeEffect.style = props.topScrollEdgeEffect == RNCNativeScrollerTopScrollEdgeEffect::Soft
297
+ ? UIScrollEdgeEffectStyle.softStyle : UIScrollEdgeEffectStyle.hardStyle;
275
298
  }
276
299
  }
277
300
 
@@ -378,6 +401,7 @@ static Event RNCScrollerMetrics(UIScrollView *scroll, CGPoint velocity, CGPoint
378
401
  const auto &props = *std::static_pointer_cast<const RNCNativeScrollerProps>(_props);
379
402
  if (!props.refreshEnabled || props.refreshing) return;
380
403
  _refreshEventSentForDrag = YES;
404
+ RNCNativeScrollerHomeVisualLog(@"refresh-native-trigger", _scrollView);
381
405
  if (_eventEmitter) {
382
406
  std::static_pointer_cast<const RNCNativeScrollerEventEmitter>(_eventEmitter)->onRefresh({});
383
407
  }
@@ -522,6 +546,7 @@ static Event RNCScrollerMetrics(UIScrollView *scroll, CGPoint velocity, CGPoint
522
546
  - (void)setRefreshing:(BOOL)refreshing
523
547
  {
524
548
  if (_recycled || _refreshControl == nil) return;
549
+ RNCNativeScrollerHomeVisualLog(refreshing ? @"refresh-command-start" : @"refresh-command-stop", _scrollView);
525
550
  if (refreshing) {
526
551
  if (!_refreshControl.refreshing) [_refreshControl beginRefreshing];
527
552
  } else {
@@ -1,6 +1,7 @@
1
1
  #import <XCTest/XCTest.h>
2
2
  #import "RNCCollapsiblePagerViewComponentView.h"
3
3
  #import "RNCNativeScrollerComponentView.h"
4
+ #import <react/renderer/components/pagerview/Props.h>
4
5
  #import <react/renderer/mounting/MountingTransaction.h>
5
6
 
6
7
  using namespace facebook::react;
@@ -22,6 +23,10 @@ using namespace facebook::react;
22
23
  - (void)updateHeaderSettledStateForceBusy:(BOOL)forceBusy;
23
24
  - (void)preparePageForHorizontalTransitionAtIndex:(NSInteger)index;
24
25
  - (void)applyInsetsToScrollView:(UIScrollView *)scrollView pageIndex:(NSInteger)index restore:(BOOL)restore;
26
+ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared &)oldProps;
27
+ - (void)mountChildComponentView:(UIView<RCTComponentViewProtocol> *)childComponentView index:(NSInteger)index;
28
+ - (CGFloat)effectiveTopOverlayHeight;
29
+ - (void)updateRefreshControlForScrollView:(UIScrollView *)scrollView;
25
30
  @end
26
31
 
27
32
  @interface RNCNativeScrollerComponentView (ScrollCommandTesting)
@@ -34,6 +39,125 @@ using namespace facebook::react;
34
39
 
35
40
  @implementation RNCCollapsiblePagerFirstFrameTests
36
41
 
42
+ - (void)testPendingLocalOverlayReservesManagedScrollerInsetBeforeSlotRebuild
43
+ {
44
+ RNCCollapsiblePagerViewComponentView *pager =
45
+ [[RNCCollapsiblePagerViewComponentView alloc] initWithFrame:CGRectMake(0, 0, 320, 844)];
46
+ UIWindow *window = [[UIWindow alloc] initWithFrame:pager.frame];
47
+ UIViewController *root = [UIViewController new];
48
+ window.rootViewController = root;
49
+ [root.view addSubview:pager];
50
+ window.hidden = NO;
51
+ [self addTeardownBlock:^{
52
+ [pager prepareForRecycle];
53
+ window.hidden = YES;
54
+ }];
55
+
56
+ auto baseline = std::make_shared<RNCCollapsiblePagerViewProps>();
57
+ baseline->nativeSmoothHeaderScrollEnabled = true;
58
+ baseline->headerHeight = 120;
59
+ baseline->stickyHeaderHeight = 44;
60
+ baseline->pageKeys = "[\"page-0\"]";
61
+ [pager updateProps:baseline oldProps:nullptr];
62
+
63
+ UIView *header = [[UIView alloc] initWithFrame:CGRectMake(0, 0, 320, 120)];
64
+ UIView *sticky = [[UIView alloc] initWithFrame:CGRectMake(0, 0, 320, 44)];
65
+ UIView *page = [[UIView alloc] initWithFrame:CGRectMake(0, 0, 320, 844)];
66
+ RNCNativeScrollerComponentView *scroller = [RNCNativeScrollerComponentView new];
67
+ scroller.frame = page.bounds;
68
+ scroller.scrollView.frame = scroller.bounds;
69
+ scroller.scrollView.contentSize = CGSizeMake(320, 1500);
70
+ [page addSubview:scroller];
71
+ [pager mountChildComponentView:(id)header index:0];
72
+ [pager mountChildComponentView:(id)sticky index:1];
73
+ [pager mountChildComponentView:(id)page index:2];
74
+ [root.view layoutIfNeeded];
75
+ [pager layoutIfNeeded];
76
+ XCTestExpectation *settled = [self expectationWithDescription:@"Baseline slots and observer settled"];
77
+ dispatch_async(dispatch_get_main_queue(), ^{ [settled fulfill]; });
78
+ [self waitForExpectations:@[settled] timeout:1];
79
+ [pager attachScrollObserverForCurrentPage];
80
+ UIScrollView *ownedScroll = scroller.scrollView;
81
+ XCTAssertEqual([pager valueForKey:@"observedScrollView"], ownedScroll);
82
+ XCTAssertEqualWithAccuracy(ownedScroll.contentInset.top, 164, 0.01);
83
+ [scroller scrollTo:0 y:40 - ownedScroll.contentInset.top animated:NO];
84
+ [pager setValue:@40 forKey:@"headerOffset"];
85
+ CGFloat initialLogicalOffset = ownedScroll.contentOffset.y + ownedScroll.contentInset.top;
86
+ XCTAssertEqualWithAccuracy(initialLogicalOffset, 40, 0.01);
87
+
88
+ auto hard = std::make_shared<RNCCollapsiblePagerViewProps>();
89
+ hard->nativeSmoothHeaderScrollEnabled = true;
90
+ hard->headerHeight = 120;
91
+ hard->stickyHeaderHeight = 44;
92
+ hard->pageKeys = "[\"page-0\"]";
93
+ hard->topScrollEdgeEffect = RNCCollapsiblePagerViewTopScrollEdgeEffect::Hard;
94
+ hard->topOverlayHeight = 162;
95
+ [pager updateProps:hard oldProps:baseline];
96
+ XCTAssertNil([pager valueForKey:@"topOverlayView"]);
97
+ XCTAssertEqual([pager valueForKey:@"observedScrollView"], ownedScroll);
98
+ XCTAssertEqualWithAccuracy([pager effectiveTopOverlayHeight], 162, 0.01);
99
+ XCTAssertEqualWithAccuracy(ownedScroll.contentInset.top, 326, 0.01);
100
+ XCTAssertEqualWithAccuracy(ownedScroll.contentOffset.y + ownedScroll.contentInset.top,
101
+ initialLogicalOffset, 0.01);
102
+ [self finishMount:pager];
103
+ XCTAssertNil([pager valueForKey:@"topOverlayView"]);
104
+ XCTAssertTrue([[pager valueForKey:@"deferTopEdgeSlotRebuild"] boolValue]);
105
+
106
+ UIView *overlay = [[UIView alloc] initWithFrame:CGRectMake(0, 0, 320, 162)];
107
+ [pager mountChildComponentView:(id)overlay index:2];
108
+ XCTAssertNil([pager valueForKey:@"topOverlayView"]);
109
+ [self finishMount:pager];
110
+ UIView *overlayHost = [pager valueForKey:@"topOverlayHostView"];
111
+ XCTAssertEqual([pager valueForKey:@"topOverlayView"], overlay);
112
+ XCTAssertEqual(overlay.superview, overlayHost);
113
+ XCTAssertEqual(overlay.window, window);
114
+ XCTAssertEqualWithAccuracy(ownedScroll.contentInset.top, 326, 0.01);
115
+ XCTAssertEqualWithAccuracy(ownedScroll.contentOffset.y + ownedScroll.contentInset.top,
116
+ initialLogicalOffset, 0.01);
117
+ XCTestExpectation *rebuilt = [self expectationWithDescription:@"Post-mount observer restored"];
118
+ dispatch_async(dispatch_get_main_queue(), ^{ [rebuilt fulfill]; });
119
+ [self waitForExpectations:@[rebuilt] timeout:1];
120
+ [pager attachScrollObserverForCurrentPage];
121
+ XCTAssertEqual([pager valueForKey:@"topOverlayView"], overlay);
122
+ XCTAssertEqual([pager valueForKey:@"observedScrollView"], ownedScroll);
123
+ XCTAssertEqualWithAccuracy(ownedScroll.contentInset.top, 326, 0.01);
124
+ XCTAssertEqualWithAccuracy(ownedScroll.contentOffset.y + ownedScroll.contentInset.top,
125
+ initialLogicalOffset, 0.01);
126
+
127
+ UIRefreshControl *control = [UIRefreshControl new];
128
+ ownedScroll.refreshControl = control;
129
+ [pager updateRefreshControlForScrollView:ownedScroll];
130
+ UIEdgeInsets refreshingInset = ownedScroll.contentInset;
131
+ refreshingInset.top += 60;
132
+ ownedScroll.contentInset = refreshingInset;
133
+ auto disabled = std::make_shared<RNCCollapsiblePagerViewProps>();
134
+ disabled->nativeSmoothHeaderScrollEnabled = true;
135
+ disabled->headerHeight = 120;
136
+ disabled->stickyHeaderHeight = 44;
137
+ disabled->pageKeys = "[\"page-0\"]";
138
+ disabled->topScrollEdgeEffect = RNCCollapsiblePagerViewTopScrollEdgeEffect::None;
139
+ disabled->topOverlayHeight = 0;
140
+ [pager updateProps:disabled oldProps:hard];
141
+ XCTAssertEqual(ownedScroll.refreshControl, control);
142
+ XCTAssertEqualWithAccuracy([pager effectiveTopOverlayHeight], 0, 0.01);
143
+ XCTAssertEqualWithAccuracy(ownedScroll.contentInset.top, 224, 0.01);
144
+ }
145
+
146
+ - (void)testPendingLocalOverlayHeightDoesNotOverrideExternalGeometry
147
+ {
148
+ RNCCollapsiblePagerViewComponentView *pager = [RNCCollapsiblePagerViewComponentView new];
149
+ auto props = std::make_shared<RNCCollapsiblePagerViewProps>();
150
+ props->topScrollEdgeEffect = RNCCollapsiblePagerViewTopScrollEdgeEffect::Hard;
151
+ props->topOverlayHeight = 162;
152
+ props->externalTopOverlayTag = 999;
153
+ [pager updateProps:props oldProps:nullptr];
154
+ XCTAssertNil([pager valueForKey:@"topOverlayView"]);
155
+ XCTAssertEqualWithAccuracy([pager effectiveTopOverlayHeight], 0, 0.01);
156
+ [pager setValue:@64 forKey:@"externalTopOverlayHeight"];
157
+ XCTAssertEqualWithAccuracy([pager effectiveTopOverlayHeight], 64, 0.01);
158
+ [pager prepareForRecycle];
159
+ }
160
+
37
161
  - (void)testHeaderSettledAtTopRequiresCurrentQuietOwnerAndCancelsDetachedTimer
38
162
  {
39
163
  RNCCollapsiblePagerViewComponentView *pager = [self pagerWithPageCount:1];
@@ -8,6 +8,7 @@ using namespace facebook::react;
8
8
 
9
9
  @interface RNCCollapsiblePagerViewComponentView (ScrollEdgeTesting)
10
10
  - (void)updateTopScrollEdgeForScrollView:(UIScrollView *)scrollView;
11
+ - (void)updateScrollEdgeContentMasks;
11
12
  @end
12
13
 
13
14
  @interface RNCCollapsiblePagerHitTestTests : XCTestCase
@@ -90,9 +91,17 @@ using namespace facebook::react;
90
91
  UIScrollView *scrollView = [[UIScrollView alloc] initWithFrame:self.pager.bounds];
91
92
  [self.pager addSubview:scrollView];
92
93
  UIScrollEdgeEffectStyle *originalStyle = scrollView.topEdgeEffect.style;
93
- [self.pager setValue:@YES forKey:@"topScrollEdgeHard"];
94
+ [self.pager setValue:@YES forKey:@"topScrollEdgeEnabled"];
95
+ [self.pager setValue:@(static_cast<NSInteger>(RNCCollapsiblePagerViewTopScrollEdgeEffect::Hard))
96
+ forKey:@"topScrollEdgeEffect"];
94
97
  [self.pager setValue:@123 forKey:@"externalTopOverlayTag"];
95
98
  [self.pager setValue:@64 forKey:@"externalTopOverlayHeight"];
99
+ UIView *container = [self.pager valueForKey:@"containerView"];
100
+ container.frame = self.pager.bounds;
101
+ UIView *overlayHost = [self.pager valueForKey:@"topOverlayHostView"];
102
+ overlayHost.frame = CGRectMake(0, 0, 320, 64);
103
+ overlayHost.hidden = NO;
104
+ self.pager.backgroundColor = UIColor.redColor;
96
105
  [self.pager updateTopScrollEdgeForScrollView:scrollView];
97
106
  XCTAssertEqual([self.pager valueForKey:@"topEdgeScrollView"], scrollView);
98
107
  UIScrollEdgeElementContainerInteraction *interaction =
@@ -100,6 +109,23 @@ using namespace facebook::react;
100
109
  XCTAssertEqual(interaction.scrollView, scrollView);
101
110
  UIView *host = [self.pager valueForKey:@"topEdgeInteractionHost"];
102
111
  XCTAssertTrue([host.interactions containsObject:interaction]);
112
+ XCTAssertEqual(scrollView.topEdgeEffect.style, UIScrollEdgeEffectStyle.hardStyle);
113
+ CALayer *cover = [self.pager valueForKey:@"topEdgeSeparatorCover"];
114
+ XCTAssertEqual(cover.superlayer, container.layer);
115
+ XCTAssertEqualWithAccuracy(CGRectGetMaxY(cover.frame), 64, 0.001);
116
+ XCTAssertEqualWithAccuracy(CGRectGetHeight(cover.frame), 1.0 / window.screen.scale, 0.001);
117
+ XCTAssertTrue(CGColorEqualToColor(cover.backgroundColor, UIColor.redColor.CGColor));
118
+ UIEdgeInsets inset = scrollView.contentInset;
119
+ CGPoint offset = scrollView.contentOffset;
120
+ [self.pager setValue:@(static_cast<NSInteger>(RNCCollapsiblePagerViewTopScrollEdgeEffect::Soft))
121
+ forKey:@"topScrollEdgeEffect"];
122
+ [self.pager updateTopScrollEdgeForScrollView:scrollView];
123
+ XCTAssertEqual(scrollView.topEdgeEffect.style, UIScrollEdgeEffectStyle.softStyle);
124
+ XCTAssertNil([self.pager valueForKey:@"topEdgeSeparatorCover"]);
125
+ XCTAssertNil(cover.superlayer);
126
+ XCTAssertEqual([self.pager valueForKey:@"topEdgeInteraction"], interaction);
127
+ XCTAssertTrue(UIEdgeInsetsEqualToEdgeInsets(scrollView.contentInset, inset));
128
+ XCTAssertTrue(CGPointEqualToPoint(scrollView.contentOffset, offset));
103
129
 
104
130
  RNCPagerViewComponentView *outer = [RNCPagerViewComponentView new];
105
131
  [self.pager setValue:outer forKey:@"externalOuterPager"];
@@ -126,4 +152,163 @@ using namespace facebook::react;
126
152
  }
127
153
  }
128
154
 
155
+ - (void)testPinnedHardEdgeHidesOnlyTopSeparatorCoverUntilUnpinned
156
+ {
157
+ if (@available(iOS 26.0, *)) {
158
+ UIWindow *window = [[UIWindow alloc] initWithFrame:self.root.bounds];
159
+ [window addSubview:self.root];
160
+ [self addTeardownBlock:^{ window.hidden = YES; }];
161
+ window.hidden = NO;
162
+ UIScrollView *scrollView = [[UIScrollView alloc] initWithFrame:self.pager.bounds];
163
+ [self.pager addSubview:scrollView];
164
+ [self.pager setValue:@YES forKey:@"topScrollEdgeEnabled"];
165
+ [self.pager setValue:@(static_cast<NSInteger>(RNCCollapsiblePagerViewTopScrollEdgeEffect::Hard))
166
+ forKey:@"topScrollEdgeEffect"];
167
+ [self.pager setValue:@123 forKey:@"externalTopOverlayTag"];
168
+ [self.pager setValue:@64 forKey:@"externalTopOverlayHeight"];
169
+ [self.pager setValue:self.sticky forKey:@"stickyHeaderView"];
170
+ UIView *container = [self.pager valueForKey:@"containerView"];
171
+ container.frame = self.pager.bounds;
172
+ UIView *overlayHost = [self.pager valueForKey:@"topOverlayHostView"];
173
+ overlayHost.frame = CGRectMake(0, 0, 320, 64);
174
+ overlayHost.hidden = NO;
175
+ UIView *pinnedHost = [self.pager valueForKey:@"pinnedStickyHostView"];
176
+ pinnedHost.frame = CGRectMake(0, 64, 320, 44);
177
+ pinnedHost.hidden = NO;
178
+ [scrollView addSubview:pinnedHost];
179
+
180
+ [self.pager updateTopScrollEdgeForScrollView:scrollView];
181
+ UIScrollEdgeElementContainerInteraction *topInteraction =
182
+ [self.pager valueForKey:@"topEdgeInteraction"];
183
+ CALayer *topCover = [self.pager valueForKey:@"topEdgeSeparatorCover"];
184
+ XCTAssertNotNil(topInteraction);
185
+ XCTAssertNotNil(topCover);
186
+ XCTAssertFalse(topCover.hidden);
187
+ XCTAssertEqual(scrollView.topEdgeEffect.style, UIScrollEdgeEffectStyle.hardStyle);
188
+
189
+ [self.pager setValue:@YES forKey:@"stickyHeaderIsPinned"];
190
+ [self.pager updateTopScrollEdgeForScrollView:scrollView];
191
+ UIScrollEdgeElementContainerInteraction *stickyInteraction =
192
+ [self.pager valueForKey:@"stickyEdgeInteraction"];
193
+ CALayer *stickyCover = [self.pager valueForKey:@"stickyEdgeSeparatorCover"];
194
+ XCTAssertEqual([self.pager valueForKey:@"topEdgeInteraction"], topInteraction);
195
+ XCTAssertEqual(topInteraction.scrollView, scrollView);
196
+ XCTAssertEqual(stickyInteraction.scrollView, scrollView);
197
+ XCTAssertEqual([self.pager valueForKey:@"topEdgeSeparatorCover"], topCover);
198
+ XCTAssertTrue(topCover.hidden);
199
+ XCTAssertNotNil(stickyCover);
200
+ XCTAssertFalse(stickyCover.hidden);
201
+ XCTAssertEqual(scrollView.topEdgeEffect.style, UIScrollEdgeEffectStyle.hardStyle);
202
+
203
+ [self.pager setValue:@NO forKey:@"stickyHeaderIsPinned"];
204
+ [self.pager updateTopScrollEdgeForScrollView:scrollView];
205
+ XCTAssertEqual([self.pager valueForKey:@"topEdgeInteraction"], topInteraction);
206
+ XCTAssertEqual([self.pager valueForKey:@"topEdgeSeparatorCover"], topCover);
207
+ XCTAssertFalse(topCover.hidden);
208
+ XCTAssertEqual(scrollView.topEdgeEffect.style, UIScrollEdgeEffectStyle.hardStyle);
209
+ [self.root removeFromSuperview];
210
+ } else {
211
+ XCTSkip(@"Scroll-edge interactions require iOS 26 or newer");
212
+ }
213
+ }
214
+
215
+ - (void)testIOS27LocalHardThemeBackgroundTracksMaskedHeaderGeometry
216
+ {
217
+ if (@available(iOS 27.0, *)) {
218
+ UIWindowScene *scene = nil;
219
+ for (UIScene *connectedScene in UIApplication.sharedApplication.connectedScenes) {
220
+ if ([connectedScene isKindOfClass:UIWindowScene.class]) {
221
+ scene = (UIWindowScene *)connectedScene;
222
+ break;
223
+ }
224
+ }
225
+ XCTAssertNotNil(scene);
226
+ UIWindow *window = [[UIWindow alloc] initWithWindowScene:scene];
227
+ window.frame = self.root.bounds;
228
+ [window addSubview:self.root];
229
+ window.hidden = NO;
230
+ [self addTeardownBlock:^{ window.hidden = YES; }];
231
+ [self.pager layoutIfNeeded];
232
+ self.pager.backgroundColor = [UIColor colorWithWhite:0.2 alpha:1];
233
+ UIView *container = [self.pager valueForKey:@"containerView"];
234
+ container.frame = self.pager.bounds;
235
+ UIScrollView *scroll = [[UIScrollView alloc] initWithFrame:container.bounds];
236
+ scroll.contentInsetAdjustmentBehavior = UIScrollViewContentInsetAdjustmentNever;
237
+ scroll.alwaysBounceVertical = YES;
238
+ scroll.contentSize = CGSizeMake(320, 1200);
239
+ scroll.contentInset = UIEdgeInsetsMake(50 + 100 + 20, 0, 0, 0);
240
+ [container insertSubview:scroll atIndex:0];
241
+ UIView *sharedHost = [self.pager valueForKey:@"sharedHeaderHostView"];
242
+ for (UIView *child in sharedHost.subviews.copy) [child removeFromSuperview];
243
+ [scroll addSubview:sharedHost];
244
+ sharedHost.frame = CGRectMake(0, -120, 320, 120);
245
+ UIView *header = [[UIView alloc] initWithFrame:CGRectMake(0, 0, 320, 100)];
246
+ [sharedHost addSubview:header];
247
+ [self.pager setValue:header forKey:@"headerView"];
248
+ UIView *overlayHost = [self.pager valueForKey:@"topOverlayHostView"];
249
+ overlayHost.frame = CGRectMake(0, 0, 320, 50);
250
+ overlayHost.hidden = NO;
251
+ UIView *overlay = [[UIView alloc] initWithFrame:overlayHost.bounds];
252
+ [overlayHost addSubview:overlay];
253
+ [self.pager setValue:overlay forKey:@"topOverlayView"];
254
+ [self.pager setValue:@50 forKey:@"topOverlayHeight"];
255
+ [self.pager setValue:@100 forKey:@"headerHeight"];
256
+ [self.pager setValue:@20 forKey:@"stickyHeaderHeight"];
257
+ [self.pager setValue:@YES forKey:@"nativeSmoothHeaderScrollEnabled"];
258
+ [self.pager setValue:@YES forKey:@"topScrollEdgeEnabled"];
259
+ [self.pager setValue:@(static_cast<NSInteger>(RNCCollapsiblePagerViewTopScrollEdgeEffect::Hard))
260
+ forKey:@"topScrollEdgeEffect"];
261
+ [self.pager setValue:scroll forKey:@"observedScrollView"];
262
+ [self.pager setValue:scroll forKey:@"sharedHeaderScrollView"];
263
+ scroll.contentOffset = CGPointMake(0, -170);
264
+ [self.pager updateTopScrollEdgeForScrollView:scroll];
265
+ CALayer *layer = [self.pager valueForKey:@"topOverlayThemeBackgroundLayer"];
266
+ XCTAssertNotNil(layer);
267
+ XCTAssertEqual(layer.superlayer, overlayHost.layer);
268
+ XCTAssertEqualWithAccuracy(layer.opacity, 1, 0.01);
269
+ XCTAssertEqual(scroll.topEdgeEffect.style, UIScrollEdgeEffectStyle.hardStyle);
270
+ XCTAssertEqual([self.pager valueForKey:@"topEdgeScrollView"], scroll);
271
+
272
+ scroll.contentOffset = CGPointMake(0, -120);
273
+ [self.pager updateScrollEdgeContentMasks];
274
+ XCTAssertEqualWithAccuracy(layer.opacity, 0.5, 0.01);
275
+ scroll.contentOffset = CGPointMake(0, -70);
276
+ [self.pager updateScrollEdgeContentMasks];
277
+ XCTAssertEqualWithAccuracy(layer.opacity, 0, 0.01);
278
+ scroll.contentOffset = CGPointMake(0, -210);
279
+ [self.pager updateScrollEdgeContentMasks];
280
+ XCTAssertEqualWithAccuracy(layer.opacity, 1, 0.01);
281
+
282
+ scroll.contentInset = UIEdgeInsetsMake(230, 0, 0, 0);
283
+ scroll.contentOffset = CGPointMake(0, -230);
284
+ [self.pager updateScrollEdgeContentMasks];
285
+ XCTAssertEqualWithAccuracy(layer.opacity, 1, 0.01);
286
+ self.pager.backgroundColor = UIColor.whiteColor;
287
+ [self.pager updateScrollEdgeContentMasks];
288
+ XCTAssertTrue(CGColorEqualToColor(layer.backgroundColor, UIColor.whiteColor.CGColor));
289
+
290
+ [self.pager updateTopScrollEdgeForScrollView:nil];
291
+ XCTAssertEqual([self.pager valueForKey:@"topOverlayThemeBackgroundLayer"], layer);
292
+ XCTAssertEqualWithAccuracy(layer.opacity, 1, 0.01);
293
+ [self.pager setValue:@YES forKey:@"topScrollEdgeEnabled"];
294
+ [self.pager updateTopScrollEdgeForScrollView:scroll];
295
+ [self.pager setValue:@123 forKey:@"externalTopOverlayTag"];
296
+ [self.pager updateScrollEdgeContentMasks];
297
+ XCTAssertNil([self.pager valueForKey:@"topOverlayThemeBackgroundLayer"]);
298
+ [self.pager setValue:@0 forKey:@"externalTopOverlayTag"];
299
+ [self.pager updateTopScrollEdgeForScrollView:scroll];
300
+ XCTAssertEqual(scroll.topEdgeEffect.style, UIScrollEdgeEffectStyle.hardStyle);
301
+ [self.pager setValue:@NO forKey:@"topScrollEdgeEnabled"];
302
+ [self.pager updateTopScrollEdgeForScrollView:scroll];
303
+ XCTAssertNil([self.pager valueForKey:@"topOverlayThemeBackgroundLayer"]);
304
+ [self.pager setValue:@YES forKey:@"topScrollEdgeEnabled"];
305
+ [self.pager updateTopScrollEdgeForScrollView:scroll];
306
+ XCTAssertNotNil([self.pager valueForKey:@"topOverlayThemeBackgroundLayer"]);
307
+ [self.pager prepareForRecycle];
308
+ XCTAssertNil([self.pager valueForKey:@"topOverlayThemeBackgroundLayer"]);
309
+ } else {
310
+ XCTSkip(@"The theme background applies only on iOS 27 or newer");
311
+ }
312
+ }
313
+
129
314
  @end
@@ -60,6 +60,56 @@
60
60
  XCTAssertEqualWithAccuracy(control.bounds.origin.x, 3, 0.001);
61
61
  }
62
62
 
63
+ - (void)testRefreshLayerWindowGeometryStartsBelowFixedOverlay
64
+ {
65
+ for (NSNumber *overlayHeight in @[@0, @162]) {
66
+ CGFloat fixedHeight = overlayHeight.doubleValue;
67
+ CGFloat baseInset = fixedHeight + 180 + 44;
68
+ UIWindow *window = [[UIWindow alloc] initWithFrame:CGRectMake(0, 0, 390, 844)];
69
+ UIViewController *root = [UIViewController new];
70
+ window.rootViewController = root;
71
+ UIScrollView *scroll = [[UIScrollView alloc] initWithFrame:window.bounds];
72
+ scroll.contentInsetAdjustmentBehavior = UIScrollViewContentInsetAdjustmentNever;
73
+ scroll.contentSize = CGSizeMake(390, 1500);
74
+ scroll.contentInset = UIEdgeInsetsMake(baseInset + 60, 0, 0, 0);
75
+ scroll.contentOffset = CGPointMake(0, -baseInset - 102);
76
+ [root.view addSubview:scroll];
77
+ UIView *overlay = [[UIView alloc] initWithFrame:CGRectMake(0, 0, 390, fixedHeight)];
78
+ [root.view addSubview:overlay];
79
+ UIRefreshControl *control = [UIRefreshControl new];
80
+ scroll.refreshControl = control;
81
+ window.hidden = NO;
82
+ [root.view layoutIfNeeded];
83
+ [control layoutIfNeeded];
84
+ // Match the control's window position recorded during a real refresh drag.
85
+ control.frame = [scroll convertRect:CGRectMake(0, baseInset, 390, 60)
86
+ fromView:window];
87
+
88
+ RNCCollapsiblePagerViewComponentView *pager = [self pagerWithHeader:180];
89
+ [pager setValue:@(fixedHeight) forKey:@"topOverlayHeight"];
90
+ if (fixedHeight > 0) [pager setValue:overlay forKey:@"topOverlayView"];
91
+ [pager updateRefreshControlForScrollView:scroll];
92
+ [CATransaction flush];
93
+ CALayer *firstChild = control.layer.sublayers.firstObject;
94
+ XCTAssertNotNil(firstChild);
95
+ XCTAssertEqual(control.window, window);
96
+ CGRect overlayWindow = [overlay convertRect:overlay.bounds toView:window];
97
+ CGRect controlWindow = [control convertRect:control.bounds toView:window];
98
+ CGRect childWindow = [firstChild convertRect:firstChild.bounds toLayer:window.layer];
99
+ XCTAssertEqualWithAccuracy(CGRectGetMinY(controlWindow), baseInset, 0.01);
100
+ XCTAssertEqualWithAccuracy(CGRectGetMinY(childWindow),
101
+ CGRectGetMaxY(overlayWindow), 0.01);
102
+ XCTAssertEqualWithAccuracy(scroll.contentInset.top, baseInset + 60, 0.01);
103
+ [pager updateRefreshControlForScrollView:scroll];
104
+ XCTAssertEqualWithAccuracy(CGRectGetMinY(
105
+ [firstChild convertRect:firstChild.bounds toLayer:window.layer]),
106
+ CGRectGetMaxY(overlayWindow), 0.01);
107
+ [pager releaseRefreshControlForScrollView:scroll];
108
+ XCTAssertTrue(CATransform3DIsIdentity(control.layer.sublayerTransform));
109
+ window.hidden = YES;
110
+ }
111
+ }
112
+
63
113
  - (void)testUIKitSizeChangesAndRefreshCycleDoNotAccumulateOffset
64
114
  {
65
115
  RNCCollapsiblePagerViewComponentView *pager = [self pagerWithHeader:64];
@@ -69,6 +69,45 @@ using namespace facebook::react;
69
69
  XCTAssertFalse([self.scroller.scrollView isKindOfClass:NSClassFromString(@"RCTEnhancedScrollView")]);
70
70
  }
71
71
 
72
+ - (void)testScrollEdgeStyleSwitchPreservesOverlayInsetAndRestoresOriginalStyle
73
+ {
74
+ if (@available(iOS 26.0, *)) {
75
+ UIWindow *window = [[UIWindow alloc] initWithFrame:CGRectMake(0, 0, 320, 600)];
76
+ UIView *root = [[UIView alloc] initWithFrame:window.bounds];
77
+ [window addSubview:root];
78
+ [root addSubview:self.scroller];
79
+ RCTViewComponentView *overlay = [RCTViewComponentView new];
80
+ overlay.frame = CGRectMake(0, 0, 320, 64);
81
+ [root addSubview:overlay];
82
+ UIScrollEdgeEffectStyle *original = self.scroller.scrollView.topEdgeEffect.style;
83
+ auto props = std::make_shared<RNCNativeScrollerProps>();
84
+ props->contentHeight = 1500;
85
+ props->topOverlayHeight = 64;
86
+ props->topScrollEdgeEffect = RNCNativeScrollerTopScrollEdgeEffect::Hard;
87
+ [self.scroller updateProps:props oldProps:nullptr];
88
+ XCTAssertEqual(self.scroller.scrollView.topEdgeEffect.style, UIScrollEdgeEffectStyle.hardStyle);
89
+ XCTAssertEqualWithAccuracy(self.scroller.scrollView.contentInset.top, 64, 0.01);
90
+ CGPoint offset = self.scroller.scrollView.contentOffset;
91
+ auto soft = std::make_shared<RNCNativeScrollerProps>();
92
+ soft->contentHeight = 1500;
93
+ soft->topOverlayHeight = 64;
94
+ soft->topScrollEdgeEffect = RNCNativeScrollerTopScrollEdgeEffect::Soft;
95
+ [self.scroller updateProps:soft oldProps:props];
96
+ XCTAssertEqual(self.scroller.scrollView.topEdgeEffect.style, UIScrollEdgeEffectStyle.softStyle);
97
+ XCTAssertEqualWithAccuracy(self.scroller.scrollView.contentInset.top, 64, 0.01);
98
+ XCTAssertTrue(CGPointEqualToPoint(self.scroller.scrollView.contentOffset, offset));
99
+ auto disabled = std::make_shared<RNCNativeScrollerProps>();
100
+ disabled->contentHeight = 1500;
101
+ disabled->topScrollEdgeEffect = RNCNativeScrollerTopScrollEdgeEffect::None;
102
+ [self.scroller updateProps:disabled oldProps:soft];
103
+ XCTAssertEqual(self.scroller.scrollView.topEdgeEffect.style, original);
104
+ XCTAssertEqualWithAccuracy(self.scroller.scrollView.contentInset.top, 0, 0.01);
105
+ [self.scroller removeFromSuperview];
106
+ } else {
107
+ XCTSkip(@"Scroll-edge styles require iOS 26 or newer");
108
+ }
109
+ }
110
+
72
111
  - (void)testShrinkClampsOnlyUnreachableUpperOffset
73
112
  {
74
113
  auto props = std::make_shared<RNCNativeScrollerProps>();
@@ -23,7 +23,7 @@ const pageKey = (child, index) => {
23
23
  */
24
24
  export class CollapsiblePagerView extends React.PureComponent {
25
25
  state = {
26
- topOverlayHeight: 0,
26
+ topOverlayHeight: null,
27
27
  selectedPage: clampedPageIndex(this.props.initialPage ?? 0, React.Children.toArray(this.props.children).length),
28
28
  transientRetainedPages: []
29
29
  };
@@ -41,7 +41,14 @@ export class CollapsiblePagerView extends React.PureComponent {
41
41
  componentDidMount() {
42
42
  this.notifyMountedPagesChanged();
43
43
  }
44
- componentDidUpdate() {
44
+ componentDidUpdate(previousProps) {
45
+ const previousOverlayEnabled = previousProps.topOverlay != null && (previousProps.topScrollEdgeEffect === "hard" || previousProps.topScrollEdgeEffect === "soft");
46
+ const overlayEnabled = this.props.topOverlay != null && (this.props.topScrollEdgeEffect === "hard" || this.props.topScrollEdgeEffect === "soft");
47
+ if (previousOverlayEnabled && !overlayEnabled && this.state.topOverlayHeight !== null) {
48
+ this.setState({
49
+ topOverlayHeight: null
50
+ });
51
+ }
45
52
  const pageCount = this.pages().length;
46
53
  const selectedPage = clampedPageIndex(this.state.selectedPage, pageCount);
47
54
  if (selectedPage !== this.state.selectedPage) {
@@ -197,6 +204,7 @@ export class CollapsiblePagerView extends React.PureComponent {
197
204
  headerHeight,
198
205
  stickyHeaderHeight,
199
206
  topOverlay,
207
+ topOverlayHeightHint,
200
208
  externalTopOverlayTag,
201
209
  topScrollEdgeEffect,
202
210
  topOverlayColorScheme,
@@ -217,8 +225,9 @@ export class CollapsiblePagerView extends React.PureComponent {
217
225
  } = this.props;
218
226
  const pages = this.pages();
219
227
  const supportsTopScrollEdge = Platform.OS === "ios" && parseInt(String(Platform.Version), 10) >= 26;
220
- const hardScrollEdge = supportsTopScrollEdge && topScrollEdgeEffect === "hard";
221
- const hasTopOverlay = hardScrollEdge && topOverlay != null;
228
+ const scrollEdgeEnabled = supportsTopScrollEdge && (topScrollEdgeEffect === "hard" || topScrollEdgeEffect === "soft");
229
+ const hasTopOverlay = scrollEdgeEnabled && topOverlay != null;
230
+ const initialTopOverlayHeight = typeof topOverlayHeightHint === "number" && Number.isFinite(topOverlayHeightHint) ? Math.max(0, Math.ceil(topOverlayHeightHint)) : 0;
222
231
  const pageKeys = pages.map(pageKey);
223
232
  const retained = new Set(this.mountedPages(pages.length));
224
233
  const deducedLayoutDirection = !layoutDirection || layoutDirection === "locale" ? I18nManager.isRTL ? "rtl" : "ltr" : layoutDirection;
@@ -243,9 +252,9 @@ export class CollapsiblePagerView extends React.PureComponent {
243
252
  offscreenPageLimit: Math.max(1, offscreenPageLimit ?? 1),
244
253
  headerHeight: Math.max(0, Math.round(headerHeight)),
245
254
  stickyHeaderHeight: Math.max(0, Math.round(stickyHeaderHeight)),
246
- topScrollEdgeHard: hardScrollEdge,
247
- topOverlayColorScheme: hardScrollEdge ? topOverlayColorScheme : undefined,
248
- topOverlayHeight: hasTopOverlay ? this.state.topOverlayHeight : 0,
255
+ topScrollEdgeEffect: scrollEdgeEnabled ? topScrollEdgeEffect : "none",
256
+ topOverlayColorScheme: scrollEdgeEnabled ? topOverlayColorScheme : undefined,
257
+ topOverlayHeight: hasTopOverlay ? this.state.topOverlayHeight ?? initialTopOverlayHeight : 0,
249
258
  externalTopOverlayTag: supportsTopScrollEdge && !hasTopOverlay ? externalTopOverlayTag ?? 0 : 0,
250
259
  nativeSmoothHeaderScrollEnabled: nativeSmoothHeaderScrollEnabled,
251
260
  pageKeys: JSON.stringify(pageKeys),