@onekeyfe/react-native-pager-view 3.0.157-alpha.257 → 3.0.157-alpha.258

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 (33) hide show
  1. package/README.md +51 -4
  2. package/android/src/main/java/com/reactnativepagerview/CollapsiblePagerHost.kt +237 -43
  3. package/android/src/main/java/com/reactnativepagerview/NativeScrollerScrollAwayInsets.kt +15 -11
  4. package/android/src/main/java/com/reactnativepagerview/NativeScrollerView.kt +573 -0
  5. package/android/src/main/java/com/reactnativepagerview/NativeScrollerViewManager.kt +130 -0
  6. package/android/src/main/java/com/reactnativepagerview/PagerViewViewPackage.kt +1 -1
  7. package/android/src/main/jni/CMakeLists.txt +19 -0
  8. package/android/src/main/jni/pagerview.h +11 -0
  9. package/android/src/main/res/values/native_scroll_coordinator_ids.xml +1 -0
  10. package/android/src/test/java/com/reactnativepagerview/NativeScrollerScrollAwayInsetsTest.kt +32 -52
  11. package/common/cpp/react/renderer/components/pagerview/RNCNativeScrollerComponentDescriptor.h +8 -0
  12. package/common/cpp/react/renderer/components/pagerview/RNCNativeScrollerShadowNode.cpp +15 -0
  13. package/common/cpp/react/renderer/components/pagerview/RNCNativeScrollerShadowNode.h +23 -0
  14. package/common/cpp/react/renderer/components/pagerview/RNCNativeScrollerState.h +33 -0
  15. package/docs/SPEC.md +267 -34
  16. package/ios/RNCCollapsiblePagerViewComponentView.mm +187 -1
  17. package/ios/RNCNativeScrollerComponentView.h +17 -0
  18. package/ios/RNCNativeScrollerComponentView.mm +479 -0
  19. package/ios/tests/RNCCollapsiblePagerPressCancellationTests.mm +346 -0
  20. package/ios/tests/RNCCollapsiblePagerPressCancellationWiringTests.mm +109 -0
  21. package/ios/tests/RNCNativeScrollerTests.mm +252 -0
  22. package/lib/module/CollapsiblePagerView.js +1 -1
  23. package/lib/module/NativeScroller.js +171 -11
  24. package/lib/module/NativeScrollerNativeComponent.ts +101 -0
  25. package/lib/typescript/src/CollapsiblePagerView.d.ts +2 -3
  26. package/lib/typescript/src/NativeScroller.d.ts +15 -7
  27. package/lib/typescript/src/NativeScrollerNativeComponent.d.ts +79 -0
  28. package/package.json +6 -3
  29. package/react-native-pager-view.podspec +2 -1
  30. package/react-native.config.js +15 -0
  31. package/src/CollapsiblePagerView.tsx +4 -4
  32. package/src/NativeScroller.tsx +291 -14
  33. package/src/NativeScrollerNativeComponent.ts +101 -0
package/docs/SPEC.md CHANGED
@@ -3,43 +3,276 @@
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
- ## Android NativeScroller refresh indicator placement
7
-
8
- Status: implemented; Android API 36 Home pull-gesture visibility verified on
9
- 2026-09-28. Spot, Perps and DeFi (RN NativeScroller), plus the unchanged NFT and
10
- History (NativeList), each pass header-origin and content-origin pulls. Ten
11
- recordings show a visible indicator below the shared tabs, one refresh event and
12
- normal disappearance. This verifies UI feedback and event dispatch, not
13
- downstream request completion. Dynamic caller offset, refresh-time header resize
14
- and nested-pager replacement remain separate device acceptance work.
15
- In smooth-header mode, each pager contributes its header-plus-sticky height in
16
- physical pixels to the RN NativeScroller refresh indicator's `translationY`.
17
- The coordinator identifies only the refresh layout's direct indicator child;
18
- it MUST NOT translate the content ScrollView or change refresh offsets, bounds,
19
- scale, animation, visibility, trigger distance, refreshing state or callbacks.
20
- RN retains ownership of `progressViewOffset`, including dynamic caller updates.
21
- NativeList keeps its existing independent refresh-indicator positioning.
22
-
23
- One state per indicator combines weak owner contributions on the UI thread.
24
- Repeated application and header resizing replace only the calling owner's
25
- contribution. An external translation different from the last applied value
26
- becomes the new baseline. Removing an owner preserves the remaining contributions;
27
- removing the final owner restores the latest baseline. A zero contribution still
28
- has an owner until explicitly released. State MUST NOT strongly retain an owner
29
- or the indicator. Late mounting, indicator replacement and scroller/pager detach
30
- use the existing native layout lifecycle; there is no JavaScript frame callback.
31
-
32
- The standalone JVM policy test covers nonzero baselines, repeated application,
33
- resize, nested owners removed in either order, external translation updates,
34
- zero contributions and final restoration. It does not prove Android view
35
- discovery, actual refresh state or rendered placement. Runtime acceptance requires
36
- header-origin and content-origin pulls on NativeScroller pages, caller offset
37
- changes, refresh-time layout changes, replacement/detach, and visible disappearance
38
- after completion; existing NativeList pages must remain unchanged.
6
+ ## Independent NativeScroller migration
7
+
8
+ Status: implemented in source. Both native builds pass. Android focused emulator
9
+ cases below and the iOS same-source UIKit harness pass; the full acceptance
10
+ matrix remains incomplete. Device evidence is scoped below.
11
+ NativeScroller keeps React children and their Fabric/Yoga layout, but owns its
12
+ vertical native scrolling implementation: AndroidX NestedScrollView on Android
13
+ and UIScrollView on iOS. It MUST NOT inherit ReactScrollView/RCTScrollView or
14
+ require rebuilding ReactAndroid. Web retains its existing implementation.
15
+
16
+ The initial compatibility contract covers existing Home callers: viewport style,
17
+ contentContainerStyle including padding and flexGrow, dynamically mounted React
18
+ children, vertical scroll enablement/indicator, stable pagerScrollKey, scroll
19
+ throttling, onScroll/onScrollBeginDrag/onScrollEndDrag and momentum callbacks,
20
+ content-size notification, and scrollTo/scrollToEnd commands. Standard RN
21
+ RefreshControl JSX remains a supported input; its props and onRefresh callback
22
+ are adapted to owned native refresh controls rather than mounting an RN-private
23
+ scroll-view implementation. Supported properties and intentional platform limits
24
+ MUST be explicit in the exported types. Unknown RN ScrollView features MUST NOT
25
+ be silently advertised as supported. No business data or Home-specific geometry
26
+ belongs in the native component.
27
+
28
+ This is a vertical, non-virtualized subset, not a drop-in implementation of every
29
+ ScrollView prop. Horizontal scrolling, sticky rows, snapping, zoom and automatic
30
+ keyboard insets are outside this contract. iOS owns bounces and decelerationRate;
31
+ Android retains AndroidX fling physics without a public friction override.
32
+ Keyboard persist-tap behavior is coordinated by the JS responder wrapper using
33
+ RN 0.86's TextInput registry, while native owns drag-to-dismiss. The RN-private
34
+ registry dependency and keyboard behavior need validation on RN upgrades.
35
+ Arbitrary custom refresh components are not supported.
36
+
37
+ Native views, gestures, insets and refresh controllers belong to the UI thread.
38
+ React content layout remains Fabric-owned. Layout-size updates may cross the
39
+ bridge when dimensions change; native scrolling and header coordination MUST NOT
40
+ depend on per-frame JS updates. This is a main/UI component, with no background
41
+ runtime, persistent storage or cross-runtime heap state.
42
+
43
+ The wrapper mounts one non-collapsible React content View. Native MUST preserve
44
+ its Fabric-assigned frame and transform. A shared Fabric ShadowNode reads native
45
+ state describing local content displacement and separate ancestor displacement;
46
+ it MUST apply the ancestor displacement outside the content transform. UIKit
47
+ updates this state from contentOffset; Android updates it from content origin,
48
+ scroll position and pager consumption. Equal values are deduplicated, and a
49
+ replacement state wrapper is synchronized with the current native position.
50
+ These native state updates do not require per-frame JavaScript callbacks.
51
+ Fabric hit testing and descendant measureInWindow MUST include the displacement,
52
+ so measurement follows visible content after scroll, inset changes and page
53
+ return. Geometry acceptance MUST compare a measured React child with its visible
54
+ position, including the last child after scrollToEnd.
55
+
56
+ The automatic flexGrow minimum MUST use the native usable content viewport,
57
+ not the raw host height. An internal onContentViewportChange({height}) event
58
+ reports logical units only when static geometry changes; it is not a public
59
+ NativeScroller prop. Android reports viewport height minus caller/pager top
60
+ reservation and caller bottom inset. Its pager viewport already includes the
61
+ extra collapsible-header height. iOS reports bounds height minus caller top and
62
+ bottom insets and the sum of pager-owned sticky reservations. Both describe the
63
+ React area available with the shared header collapsed. The height MUST NOT
64
+ depend on contentSize, current header offset, refresh temporary insets or iOS
65
+ synthetic bottom fill, which would create a layout feedback loop. Owners MUST
66
+ remove their reservation on release. The current value MUST be delivered when
67
+ the view first becomes able to dispatch its event and after reattachment. An
68
+ event queued before its initial emitter is ready MUST be delivered when that
69
+ emitter becomes available. Replacing the emitter on the same attached view does
70
+ not require another event when the usable height is unchanged.
71
+
72
+ Before the first native viewport event, the wrapper contributes no positive
73
+ automatic minimum. It combines valid native height with an explicit numeric
74
+ caller minHeight using the greater value; nonnumeric explicit minima remain
75
+ untouched. Content padding remains part of Yoga layout. Long-to-short replacement
76
+ MUST clamp the local offset without leaving a pager-inset-sized artificial scroll
77
+ range or hiding the first React child. Tests and device acceptance MUST cover
78
+ long-to-empty/restore, page return, header/sticky resizing, refresh completion
79
+ without minimum-height changes, and an intentionally larger caller minimum.
80
+
81
+ Android MUST dispatch touch and non-touch nested scrolling continuously across
82
+ the content/header boundary. Content returns to its start before downward travel
83
+ expands the header; upward travel collapses the header before scrolling content.
84
+ Consumed and remaining distance MUST be accounted for once. Only touch pull can
85
+ trigger refresh; inertial movement MUST NOT trigger refresh. iOS MUST preserve a
86
+ single UIScrollView trajectory through the inset/header range. Existing Pager
87
+ refresh foreground placement and shared-header press cancellation remain in
88
+ force. NativeList and unrelated RN ScrollViews retain their implementations.
89
+
90
+ New touch, page deactivation, replacement of the mounted React content root and
91
+ detach MUST stop stale inertia. Every started drag/momentum sequence has one
92
+ terminal notification. An animated scrollTo/scrollToEnd command with a different
93
+ clamped target emits one momentum begin/end pair, including AndroidX's immediate
94
+ execution branch for closely spaced commands. A target equal to the current
95
+ position emits no new pair. A new command ends any previous active sequence
96
+ before starting its own, and non-animated movement does not start momentum;
97
+ recycled views MUST NOT emit into an old owner. Content shrink clamps unreachable
98
+ offsets without a blank tail; resize/inset changes MUST NOT accumulate offsets.
99
+ Refresh is controlled by the caller and emits once per qualifying gesture.
100
+
101
+ Acceptance requires both native builds, focused contract checks and iOS/Android
102
+ recordings: expanded/collapsed Home Spot/Perps/DeFi, short/empty and dynamically
103
+ resized content, fast return through the header boundary, new-touch interruption,
104
+ adjacent/distant page changes, horizontal gestures versus row presses, pull from
105
+ header/content, refresh completion, retained-page return, imperative
106
+ animation/no-op/interruption, and React child measureInWindow versus visible
107
+ geometry. History/NFT and Market/Discover are regression controls. Source/type/build success alone is not
108
+ device acceptance. Implementation findings and remaining limits are recorded
109
+ after validation, before this section is promoted to runtime verified.
110
+
111
+ Android acceptance checkpoint (2026-09-28): the official local-shell/local-vendor
112
+ DevSession build passed, with custom Fabric descriptor registration verified.
113
+ On the dedicated emulator, native field inspection confirmed NativeScrollerView
114
+ and its AndroidX viewport. Home Spot/Perps/DeFi return gestures crossed the
115
+ content/header boundary. In the final short-fill binary, Perps content offset
116
+ 1389 reached zero and the same non-touch momentum continued header offset
117
+ 755 -> 648 -> 441 -> 217 -> 56 before natural exhaustion. This proves continuity,
118
+ not that every gesture must fully expand the header.
119
+
120
+ The first valid binary exposed an artificial 284 px short-content range. After the
121
+ usable-viewport correction, Gallery long-to-empty replacement had local offset 0
122
+ with short text visible; restoring rows and scrollToEnd reached the final child.
123
+ measureInWindow y746.7/h72.0 matched native visible bounds y1960/h189px at
124
+ pixel density2.625. Short-content refresh advanced the fixture counter once and
125
+ controlled completion removed the indicator. Home three-page refresh visibility,
126
+ History/NFT vertical scrolling and Market/Browser horizontal controls passed on
127
+ the preceding valid binary; these control implementations were unchanged by the
128
+ short-fill correction. Evidence is recorded in the app task's ignored
129
+ `ignore/independent-native-scroller/android-acceptance.md`, alongside screenshots,
130
+ recording chunks and native field samples.
131
+
132
+ This checkpoint does not cover a physical handset, keyboard/multi-touch/accessory
133
+ interactions, exhaustive event counts and command interruption, explicit larger
134
+ caller minHeight, header/sticky resizing or the full refresh matrix. Existing
135
+ development warnings were observed. Android evidence does not establish iOS
136
+ acceptance, and source/build success is not a substitute for its runtime cases.
137
+
138
+ iOS acceptance checkpoint (2026-09-28): the final official local-shell/local-vendor
139
+ build passed and launched on the dedicated external-image simulator. Nine
140
+ same-source UIKit harness methods and 36 assertions pass, including negative-pull
141
+ preservation during shrink, caller inset ownership, static usable height and
142
+ refresh/recycle interruption. This is UIKit execution, not a formal XCTest run.
143
+
144
+ On the final binary, Home Spot/Perps/DeFi horizontal transitions and top-positioned
145
+ refresh feedback passed. Gallery scrollToEnd reached the last React row;
146
+ measureInWindow y786/h72 matched its native frame. Long-to-empty replacement,
147
+ restoring rows, controlled refresh counter 0 -> 1 with indicator dismissal,
148
+ normal row press and retained-page return passed. History/NFT scrolling and
149
+ media, Discover Browser -> DeFi and Market vertical scrolling plus Trending ->
150
+ Stocks horizontal transition passed without unintended detail navigation.
151
+ The final device is left on expanded Home Spot. Evidence is in the app task's
152
+ `ignore/independent-native-scroller/ios-acceptance.md` and associated recordings.
153
+
154
+ These iOS gestures use public XCTest coordinate drags. Earlier private synthesized
155
+ pan attempts had inconsistent timing and are excluded; their cause is not proven.
156
+ Gallery's floating application tab bar overlaps part of the last row, so matching
157
+ coordinates do not establish an unobscured bottom viewport. Existing development
158
+ warnings were observed. Keyboard, multi-touch, physical-device performance and
159
+ the full lifecycle/interaction matrix remain unverified. These are local source
160
+ builds; the currently installed npm version does not yet contain this migration.
161
+
162
+ ## Native tab-press animation default
163
+
164
+ On iOS and Android, an omitted `nativeTabPressAnimationEnabled` MUST animate a
165
+ press to an adjacent page and jump directly to a non-adjacent page. Adjacency is
166
+ the absolute difference between the target index and the currently selected
167
+ index, not the last requested target. Explicit `true` continues to animate all
168
+ tab presses, and explicit `false` continues to jump for all tab presses.
169
+ Page retention, selection callbacks, swipe gestures, imperative `setPage` /
170
+ `setPageWithoutAnimation` commands and Web behavior retain their contracts.
171
+ Callers rendering their own JS tab bar choose the matching imperative command.
172
+
173
+ Status: implemented; focused native-wrapper tests cover both directions and
174
+ explicit overrides. Home applies this policy to its JS tab bar, with iOS and
175
+ Android simulator recordings verifying distant jumps and adjacent transitions.
176
+
177
+ ## Android refresh foreground coordination
178
+
179
+ Status: implemented; Android Home simulator placement verified on 2026-09-28.
180
+ The previous below-tab placement is not accepted: the Home header can place the
181
+ indicator in the middle of asset rows. In smooth-header mode the outermost
182
+ coordinator MUST draw the active primary scroller's native refresh indicator
183
+ above the shared headers at the coordinator's top. SwipeRefreshLayout retains
184
+ trigger, animation, native visibility and refresh-state ownership. The indicator
185
+ MUST NOT be reparented, and the coordinator MUST NOT reset progress offsets.
186
+
187
+ NativeScroller and NativeList use the same foreground drawing boundary. A native
188
+ View tag, `onekey_native_scroll_coordinator_refresh_inset`, reports only the
189
+ refresh controller's actual coordinator-applied inset. An absent tag means zero.
190
+ The outer drawer subtracts that inset once, preserving caller geometry and the
191
+ native indicator matrix. This is native drawing metadata, with no JS API or row
192
+ semantics. Nested owners MUST NOT draw duplicate indicators.
193
+
194
+ The coordinator suppresses only the indicator's original drawing and restores
195
+ its prior alpha when ownership ends, the target changes, or the pager detaches.
196
+ An inactive page MUST NOT appear in the foreground. The foreground must clear
197
+ when the indicator disappears. Native animation transformations MUST NOT be
198
+ advanced a second time during drawing. Focused policy checks and device evidence
199
+ must cover alpha restoration, nested ownership, page changes and dismissal.
200
+ All five Home tabs require top-positioned feedback and stable header position
201
+ through pull, release and refresh completion. Source/compile success is not
202
+ runtime acceptance. Existing iOS/Web behavior is unchanged.
203
+
204
+ The five Home tabs were recorded from both header and content origins: the
205
+ foreground arc appears at the coordinator top and dismisses normally. The fast
206
+ release reversal that previously changed header offset from 0 to 767 px now
207
+ consumes the pending pull fling and keeps the header expanded. These recordings
208
+ verify visual feedback, not downstream network completion or new callback counts.
209
+ Production-helper tests cover alpha restoration and combined native insets;
210
+ real nested-owner replacement, dynamic caller geometry and detach remain separate
211
+ acceptance work. Direct foreground View drawing preserves the native circle and
212
+ arc but does not reproduce the original RenderNode elevation shadow.
213
+
214
+ ## Android refresh release settlement
215
+
216
+ Status: implemented; the captured release reversal passes a device A/B check.
217
+ A downward touch gesture that leaves unconsumed travel for the active
218
+ SwipeRefreshLayout is a pending native refresh pull. While that same gesture's
219
+ indicator is visible, the shared header is expanded, and primary content remains
220
+ at the top, the coordinator MUST consume nested pre-fling on release. A small
221
+ upward reversal before lifting must settle the pull instead of starting a
222
+ content fling and abruptly collapsing the header. SwipeRefreshLayout still owns
223
+ whether the release triggers or cancels refresh.
224
+
225
+ The pending target is weak, scoped to the current touch nested-scroll gesture,
226
+ and cleared at its stop or replacement. An old indicator's exit animation alone
227
+ is not a pending pull. Ordinary upward swipes, content scrolled away from the
228
+ top, inactive pages and non-smooth mode retain existing fling behavior. A pull
229
+ whose indicator is already hidden no longer qualifies. AndroidX can leave the
230
+ indicator visible when reverse travel exactly exhausts the pull; that same
231
+ gesture may still be settled instead of flung. Required regression cases include immediate reversal release,
232
+ a stationary release, below-threshold cancellation, ordinary upward fling and
233
+ header expansion before refresh. No JS timeout or data-refresh suppression is
234
+ introduced.
235
+
236
+ ## Android downward scroll ownership
237
+
238
+ Status: implemented; Android Home History runtime verified on 2026-09-28.
239
+ Downward scrolling MUST move the active content back to its top before expanding
240
+ the shared header. A refresh wrapper forwarding nested scrolling MUST report its
241
+ child's ability to scroll upward; the wrapper's own scroll range is not evidence
242
+ that the content is at the top. SwipeRefreshLayout's public canChildScrollUp()
243
+ contract, including caller overrides, supplies this check. Other nested-scroll
244
+ targets retain their own canScrollVertically(-1) check.
245
+
246
+ The correction changes only the downward pre-scroll eligibility check. Existing
247
+ delta consumption, content-observer alignment, refresh ownership and release
248
+ settlement remain unchanged. Runtime comparison captured premature expansion
249
+ with content offset 1960 px before the fix. Afterward the header stayed collapsed
250
+ while content returned to zero, and expanded only at the top, without an observer
251
+ forced collapse. Debugger state traces prove ownership, not frame timing;
252
+ recordings with the debugger detached cover History rapid direction reversal
253
+ and top refresh.
254
+ Spot's scroll-then-refresh release-reversal sequence still jumps in both the
255
+ baseline and corrected builds; that existing NativeScroller case remains open.
256
+
257
+ ## Superseded Android indicator placement
258
+
259
+ The alpha.257 approach translated the RN indicator below the shared tabs.
260
+ Its ten Home checks established visibility and event dispatch only. Subsequent
261
+ 2026-09-28 feedback showed the indicator over asset rows; those checks do not
262
+ establish correct placement. The foreground coordination contract above replaces
263
+ that translation policy. NativeList keeps its controller offset internally and
264
+ publishes only its actual native inset for foreground drawing.
39
265
 
40
266
  ## iOS refresh indicator placement
41
267
 
42
- Status: source implementation; real refresh-cycle acceptance pending.
268
+ Status: implemented; iOS Home pull-gesture visibility verified on 2026-09-27.
269
+ Spot, Perps and DeFi (RN NativeScroller), plus NFT and History (NativeList),
270
+ each pass header-origin and content-origin pulls: ten recordings show a visible
271
+ indicator above the shared header, one refresh event and normal disappearance.
272
+ This verifies UI feedback and event dispatch, not downstream request completion.
273
+ The Pager UIKit suite passes 14 tests, including six refresh-control cases
274
+ (native-tests run 36318243869). Rapid nested gestures and iPad geometry remain
275
+ separate acceptance work.
43
276
  In smooth-header mode, the pager shifts the refresh control's sublayers upward
44
277
  by its header-plus-sticky height. It does not change the refresh control frame,
45
278
  bounds, scroll offset, refresh state or JavaScript callback. RN's caller-owned
@@ -1,4 +1,5 @@
1
1
  #import "RNCCollapsiblePagerViewComponentView.h"
2
+ #import "RNCNativeScrollerComponentView.h"
2
3
  #import "RNCCollapsiblePagerReleasedStatePolicy.h"
3
4
 
4
5
  #import <react/renderer/components/pagerview/ComponentDescriptors.h>
@@ -12,6 +13,7 @@
12
13
  #import "React/RCTSurfaceTouchHandler.h"
13
14
  #import "React/RCTTouchHandler.h"
14
15
  #import <objc/runtime.h>
16
+ #import <UIKit/UIGestureRecognizerSubclass.h>
15
17
 
16
18
  // OneKey patch: deduplicate structural mutation parents before scanning the page.
17
19
  #include <unordered_set>
@@ -1019,6 +1021,114 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
1019
1021
 
1020
1022
  @end
1021
1023
 
1024
+ // This observer never competes for scrolling. The owning pager explicitly
1025
+ // recognizes it only after its real pan begins, so UIKit terminates RN presses.
1026
+ @interface RNCCollapsiblePagerContentPressCancellationGestureRecognizer : UIGestureRecognizer
1027
+ @property (nonatomic, weak) UIGestureRecognizer *reactTouchHandler;
1028
+ - (void)cancelPressForPan:(UIPanGestureRecognizer *)pan;
1029
+ - (void)observeIncomingTouch:(UITouch *)touch;
1030
+ @end
1031
+
1032
+ @implementation RNCCollapsiblePagerContentPressCancellationGestureRecognizer {
1033
+ __weak UITouch *_trackedTouch;
1034
+ UIEvent *_touchEvent;
1035
+ BOOL _multipleSurfaceTouchesObserved;
1036
+ }
1037
+
1038
+ - (BOOL)canPreventGestureRecognizer:(UIGestureRecognizer *)other
1039
+ {
1040
+ return other == self.reactTouchHandler;
1041
+ }
1042
+
1043
+ - (void)observeIncomingTouch:(UITouch *)touch
1044
+ {
1045
+ if (_trackedTouch != nil && touch != _trackedTouch &&
1046
+ [touch.view isDescendantOfView:self.reactTouchHandler.view]) {
1047
+ _multipleSurfaceTouchesObserved = YES;
1048
+ }
1049
+ }
1050
+
1051
+ - (void)recordEvent:(UIEvent *)event
1052
+ {
1053
+ _touchEvent = event;
1054
+ NSUInteger count = 0;
1055
+ for (UITouch *touch in event.allTouches) {
1056
+ if (touch.phase != UITouchPhaseEnded && touch.phase != UITouchPhaseCancelled &&
1057
+ [touch.view isDescendantOfView:self.reactTouchHandler.view]) ++count;
1058
+ }
1059
+ if (count > 1) _multipleSurfaceTouchesObserved = YES;
1060
+ }
1061
+
1062
+ - (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
1063
+ {
1064
+ if (_trackedTouch != nil || touches.count != 1) {
1065
+ _multipleSurfaceTouchesObserved = YES;
1066
+ if (self.state == UIGestureRecognizerStatePossible) {
1067
+ self.state = UIGestureRecognizerStateFailed;
1068
+ }
1069
+ return;
1070
+ }
1071
+ _trackedTouch = touches.anyObject;
1072
+ [self recordEvent:event];
1073
+ }
1074
+
1075
+ - (void)touchesMoved:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
1076
+ {
1077
+ [self recordEvent:event];
1078
+ }
1079
+
1080
+ - (void)cancelPressForPan:(UIPanGestureRecognizer *)pan
1081
+ {
1082
+ UIGestureRecognizer *handler = self.reactTouchHandler;
1083
+ UITouch *touch = _trackedTouch;
1084
+ if (self.state != UIGestureRecognizerStatePossible || _multipleSurfaceTouchesObserved ||
1085
+ pan.state != UIGestureRecognizerStateBegan || touch == nil ||
1086
+ touch.phase == UITouchPhaseEnded || touch.phase == UITouchPhaseCancelled ||
1087
+ handler.view == nil || self.view != handler.view.superview ||
1088
+ handler.state == UIGestureRecognizerStateFailed ||
1089
+ handler.state == UIGestureRecognizerStateCancelled ||
1090
+ handler.state == UIGestureRecognizerStateEnded) return;
1091
+
1092
+ // RN cancellation belongs to a Surface, not one finger. Do not terminate an
1093
+ // unrelated, already active touch on that Surface during a multi-touch sequence.
1094
+ NSUInteger activeTouches = 0;
1095
+ for (UITouch *activeTouch in _touchEvent.allTouches) {
1096
+ if (activeTouch.phase == UITouchPhaseEnded ||
1097
+ activeTouch.phase == UITouchPhaseCancelled) continue;
1098
+ if ([activeTouch.view isDescendantOfView:handler.view]) ++activeTouches;
1099
+ }
1100
+ if (activeTouches != 1) return;
1101
+ self.state = UIGestureRecognizerStateBegan;
1102
+ }
1103
+
1104
+ - (void)touchesEnded:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
1105
+ {
1106
+ if (![touches containsObject:_trackedTouch]) return;
1107
+ self.state = self.state == UIGestureRecognizerStateBegan
1108
+ ? UIGestureRecognizerStateEnded : UIGestureRecognizerStateFailed;
1109
+ _trackedTouch = nil;
1110
+ _touchEvent = nil;
1111
+ }
1112
+
1113
+ - (void)touchesCancelled:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
1114
+ {
1115
+ if (![touches containsObject:_trackedTouch]) return;
1116
+ self.state = self.state == UIGestureRecognizerStateBegan
1117
+ ? UIGestureRecognizerStateCancelled : UIGestureRecognizerStateFailed;
1118
+ _trackedTouch = nil;
1119
+ _touchEvent = nil;
1120
+ }
1121
+
1122
+ - (void)reset
1123
+ {
1124
+ [super reset];
1125
+ _trackedTouch = nil;
1126
+ _touchEvent = nil;
1127
+ _multipleSurfaceTouchesObserved = NO;
1128
+ }
1129
+
1130
+ @end
1131
+
1022
1132
  @interface RNCCollapsiblePagerOuterPagerPanGestureRecognizer : UIPanGestureRecognizer
1023
1133
  @property (nonatomic, strong) NSHashTable<UIGestureRecognizer *> *blockedPagerGestures;
1024
1134
  @end
@@ -1065,7 +1175,8 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
1065
1175
  UIPageViewControllerDelegate,
1066
1176
  UIScrollViewDelegate,
1067
1177
  UIGestureRecognizerDelegate,
1068
- RCTMountingTransactionObserving
1178
+ RCTMountingTransactionObserving,
1179
+ RNCNativeScrollerInsetCoordinator
1069
1180
  >
1070
1181
  - (void)finishPagerScrollEmittingSelection:(BOOL)emitSelection;
1071
1182
  - (void)completeTransitionOnNextRunLoopForGeneration:(NSUInteger)generation
@@ -1151,6 +1262,7 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
1151
1262
  __weak UIView *_sharedHeaderPressCancellationGestureHost;
1152
1263
  __weak UIGestureRecognizer *_reactTouchHandler;
1153
1264
  UIPanGestureRecognizer *_sharedHeaderPressCancellationGesture;
1265
+ RNCCollapsiblePagerContentPressCancellationGestureRecognizer *_contentPressCancellationGesture;
1154
1266
  RNCCollapsiblePagerOuterPagerPanGestureRecognizer *_sharedHeaderOuterPagerGesture;
1155
1267
  UIPanGestureRecognizer *_verticalPagerGesture;
1156
1268
  BOOL _observingContentOffset;
@@ -1295,6 +1407,9 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
1295
1407
  {
1296
1408
  [super didMoveToWindow];
1297
1409
  [self updateSharedHeaderPressCancellationGesture];
1410
+ for (UIScrollView *scrollView in _originalInsets.keyEnumerator) {
1411
+ [self updateNativeScrollerViewport:scrollView stickyHeight:self.window == nil ? 0 : _stickyHeaderHeight];
1412
+ }
1298
1413
  // OneKey patch: removing a decelerating page from its window can suppress
1299
1414
  // UIKit's final scroll callback. Restore the last acknowledged page on reattach.
1300
1415
  if (self.window != nil && _isPagerDragging &&
@@ -1318,6 +1433,9 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
1318
1433
 
1319
1434
  - (void)dealloc
1320
1435
  {
1436
+ for (UIScrollView *scrollView in _originalInsets.keyEnumerator) {
1437
+ [self updateNativeScrollerViewport:scrollView stickyHeight:0];
1438
+ }
1321
1439
  for (UIScrollView *scrollView in _refreshControlObservedScrollViews.allObjects) {
1322
1440
  [self releaseRefreshControlForScrollView:scrollView];
1323
1441
  }
@@ -1863,6 +1981,7 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
1863
1981
  [self releaseRefreshControlForScrollView:scrollView];
1864
1982
  }
1865
1983
  for (UIScrollView *scrollView in _originalInsets.keyEnumerator) {
1984
+ [self updateNativeScrollerViewport:scrollView stickyHeight:0];
1866
1985
  NSValue *value = [_originalInsets objectForKey:scrollView];
1867
1986
  if (value != nil) {
1868
1987
  UIEdgeInsets original = value.UIEdgeInsetsValue;
@@ -2130,6 +2249,9 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
2130
2249
  return [(RNCCollapsiblePagerViewComponentView *)view
2131
2250
  currentVerticalScrollViewForParentPager];
2132
2251
  }
2252
+ if ([view isKindOfClass:RNCNativeScrollerComponentView.class]) {
2253
+ return ((RNCNativeScrollerComponentView *)view).scrollView;
2254
+ }
2133
2255
  if ([view isKindOfClass:RCTScrollViewComponentView.class]) {
2134
2256
  RCTScrollViewComponentView *componentView =
2135
2257
  (RCTScrollViewComponentView *)view;
@@ -2344,10 +2466,18 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
2344
2466
  }
2345
2467
  _observingContentOffset = NO;
2346
2468
  _observedScrollView = nil;
2469
+ if ([scrollView.superview isKindOfClass:RNCNativeScrollerComponentView.class]) {
2470
+ [(RNCNativeScrollerComponentView *)scrollView.superview stopScrolling];
2471
+ }
2347
2472
  }
2348
2473
 
2349
2474
  - (void)detachSharedHeaderPressCancellationGesture
2350
2475
  {
2476
+ if (_contentPressCancellationGesture.view != nil) {
2477
+ [_contentPressCancellationGesture.view removeGestureRecognizer:_contentPressCancellationGesture];
2478
+ }
2479
+ _contentPressCancellationGesture.reactTouchHandler = nil;
2480
+ _contentPressCancellationGesture = nil;
2351
2481
  if (_sharedHeaderPressCancellationGesture.view != nil) {
2352
2482
  [_sharedHeaderPressCancellationGesture.view
2353
2483
  removeGestureRecognizer:_sharedHeaderPressCancellationGesture];
@@ -2432,6 +2562,18 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
2432
2562
  _sharedHeaderOuterPagerGesture.maximumNumberOfTouches = 1;
2433
2563
  }
2434
2564
  _sharedHeaderOuterPagerGesture.blockedPagerGestures = outerPagerGestures;
2565
+ if (_contentPressCancellationGesture == nil) {
2566
+ _contentPressCancellationGesture =
2567
+ [RNCCollapsiblePagerContentPressCancellationGestureRecognizer new];
2568
+ _contentPressCancellationGesture.delegate = self;
2569
+ // RN's external-recognizer path cancels its responder immediately only
2570
+ // when this flag is set; otherwise reset can be deferred until finger-up.
2571
+ _contentPressCancellationGesture.cancelsTouchesInView = YES;
2572
+ _contentPressCancellationGesture.delaysTouchesBegan = NO;
2573
+ _contentPressCancellationGesture.delaysTouchesEnded = NO;
2574
+ }
2575
+ _contentPressCancellationGesture.reactTouchHandler = reactTouchHandler;
2576
+ [gestureHost addGestureRecognizer:_contentPressCancellationGesture];
2435
2577
  _reactTouchHandler = reactTouchHandler;
2436
2578
  _sharedHeaderPressCancellationGestureHost = gestureHost;
2437
2579
  [gestureHost addGestureRecognizer:_sharedHeaderPressCancellationGesture];
@@ -2588,6 +2730,7 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
2588
2730
  // still represents the old list, before Fabric applies its next owner's props.
2589
2731
  - (void)restoreScrollViewState:(UIScrollView *)scrollView resetSavedOffset:(BOOL)resetSavedOffset
2590
2732
  {
2733
+ [self updateNativeScrollerViewport:scrollView stickyHeight:0];
2591
2734
  [self releaseRefreshControlForScrollView:scrollView];
2592
2735
  if (scrollView == _observedScrollView) {
2593
2736
  [self detachScrollObserver];
@@ -2705,6 +2848,7 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
2705
2848
  // This host already includes the fixed header and safe area in its frame.
2706
2849
  // UIKit automatic adjustment would add them a second time to short pages.
2707
2850
  scrollView.contentInsetAdjustmentBehavior = UIScrollViewContentInsetAdjustmentNever;
2851
+ [self updateNativeScrollerViewport:scrollView stickyHeight:_stickyHeaderHeight];
2708
2852
  [self updateRefreshControlForScrollView:scrollView];
2709
2853
  CGFloat previousTopInset = scrollView.contentInset.top;
2710
2854
  CGFloat previousLogicalOffset = scrollView.contentOffset.y + previousTopInset;
@@ -2765,6 +2909,31 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
2765
2909
  if (pageIndex == _currentIndex) [self updateHeaderForScrollView:scrollView];
2766
2910
  }
2767
2911
 
2912
+ - (void)updateNativeScrollerViewport:(UIScrollView *)scrollView stickyHeight:(CGFloat)height
2913
+ {
2914
+ if ([scrollView.superview isKindOfClass:RNCNativeScrollerComponentView.class]) {
2915
+ [(RNCNativeScrollerComponentView *)scrollView.superview setPagerStickyHeight:height forOwner:self];
2916
+ }
2917
+ }
2918
+
2919
+ - (void)nativeScroller:(RNCNativeScrollerComponentView *)scroller
2920
+ callerInsetChangedFrom:(UIEdgeInsets)previous to:(UIEdgeInsets)next
2921
+ {
2922
+ UIScrollView *scrollView = scroller.scrollView;
2923
+ NSValue *stored = [_originalInsets objectForKey:scrollView];
2924
+ if (stored == nil) return;
2925
+ UIEdgeInsets original = stored.UIEdgeInsetsValue;
2926
+ original.top += next.top - previous.top;
2927
+ original.left += next.left - previous.left;
2928
+ original.bottom += next.bottom - previous.bottom;
2929
+ original.right += next.right - previous.right;
2930
+ [_originalInsets setObject:[NSValue valueWithUIEdgeInsets:original] forKey:scrollView];
2931
+ NSNumber *appliedTop = [_appliedTopInsets objectForKey:scrollView];
2932
+ if (appliedTop != nil) {
2933
+ [_appliedTopInsets setObject:@(appliedTop.doubleValue + next.top - previous.top) forKey:scrollView];
2934
+ }
2935
+ }
2936
+
2768
2937
  - (void)reapplyInsetsToObservedScrollView
2769
2938
  {
2770
2939
  [self restoreDetachedScrollInsets];
@@ -3030,6 +3199,9 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
3030
3199
 
3031
3200
  - (void)scrollViewWillBeginDragging:(UIScrollView *)scrollView
3032
3201
  {
3202
+ if (_nativeSmoothHeaderScrollEnabled && scrollView == _pagerScrollView) {
3203
+ [_contentPressCancellationGesture cancelPressForPan:scrollView.panGestureRecognizer];
3204
+ }
3033
3205
  if (_directNativePagerEnabled) {
3034
3206
  [self preparePageForHorizontalTransitionAtIndex:_currentIndex - 1];
3035
3207
  [self preparePageForHorizontalTransitionAtIndex:_currentIndex + 1];
@@ -3375,6 +3547,14 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
3375
3547
  (unsigned long)_sharedHeaderOuterPagerGesture.blockedPagerGestures.count]);
3376
3548
  return YES;
3377
3549
  }
3550
+ if (gestureRecognizer == _contentPressCancellationGesture) {
3551
+ [_contentPressCancellationGesture observeIncomingTouch:touch];
3552
+ UIView *touchView = touch.view;
3553
+ return _nativeSmoothHeaderScrollEnabled && !_isBeingRecycled &&
3554
+ _contentPressCancellationGesture.view != nil &&
3555
+ [touchView isDescendantOfView:_pagerScrollView] &&
3556
+ ![touchView isDescendantOfView:_sharedHeaderHostView];
3557
+ }
3378
3558
  if (gestureRecognizer == _sharedHeaderPressCancellationGesture) {
3379
3559
  if (!_nativeSmoothHeaderScrollEnabled || _isBeingRecycled ||
3380
3560
  _sharedHeaderPressCancellationGestureHost == nil) return NO;
@@ -3415,6 +3595,12 @@ static void RNCLogNativeTabScrollBoundary(NSString *owner,
3415
3595
  ![other isKindOfClass:RCTTouchHandler.class] &&
3416
3596
  ![_sharedHeaderOuterPagerGesture.blockedPagerGestures containsObject:other];
3417
3597
  }
3598
+ if (gestureRecognizer == _contentPressCancellationGesture ||
3599
+ otherGestureRecognizer == _contentPressCancellationGesture) {
3600
+ UIGestureRecognizer *other = gestureRecognizer == _contentPressCancellationGesture
3601
+ ? otherGestureRecognizer : gestureRecognizer;
3602
+ return other != _contentPressCancellationGesture.reactTouchHandler;
3603
+ }
3418
3604
  if (gestureRecognizer == _sharedHeaderPressCancellationGesture ||
3419
3605
  otherGestureRecognizer == _sharedHeaderPressCancellationGesture) {
3420
3606
  UIGestureRecognizer *other = gestureRecognizer == _sharedHeaderPressCancellationGesture
@@ -0,0 +1,17 @@
1
+ #import <React/RCTViewComponentView.h>
2
+
3
+ NS_ASSUME_NONNULL_BEGIN
4
+
5
+ @interface RNCNativeScrollerComponentView : RCTViewComponentView
6
+ @property (nonatomic, readonly) UIScrollView *scrollView;
7
+ - (void)stopScrolling;
8
+ - (void)setPagerStickyHeight:(CGFloat)height forOwner:(id)owner;
9
+ @end
10
+
11
+ // Native inset ownership stays separate from Fabric/Yoga content layout.
12
+ @protocol RNCNativeScrollerInsetCoordinator <NSObject>
13
+ - (void)nativeScroller:(RNCNativeScrollerComponentView *)scroller
14
+ callerInsetChangedFrom:(UIEdgeInsets)previous to:(UIEdgeInsets)next;
15
+ @end
16
+
17
+ NS_ASSUME_NONNULL_END