feat(iOS, Tabs): integration with ScrollViewMarker#4191
Conversation
There are a couple of important things here. First, note that we set the *contentScrollView* on the `RNSTabsScreenViewController`. This works, because on tab change the `RNSTabBarController` queries the child view controller for its *content scroll view*. Second - I've improved search method for `RNSScrollViewSeeking`, since at the moment of transaction end, the `RNSTabsScreenComponentView` is not yet attached to it's parent - it's either not currently selected or it's just too early. This way I give a change to find a seeking parent. Please note however, that if there were multiple `ScrillViewMarkers`, then we'd have a kind of race condition.
Thanks to how we've structured this beforehand, it was easy! There are things to note, however. First, this commit handles only case where the scrollview marker is under Tabs, but there is no Stack v4 or v5 between. These will be integrated separately in separate PRs.
e94b623 to
93ed840
Compare
There was a problem hiding this comment.
Pull request overview
This PR aims to integrate iOS Tabs with ScrollViewMarker so that Tabs-related behaviors (e.g., resolving the “content” scroll view for special effects / edge-related behavior) work even when the scroll view isn’t discoverable via the existing first-descendant-chain heuristic. It also adds a new component-integration scenario in the example app to exercise ScrollViewMarker-in-Tabs behavior.
Changes:
- Update iOS Tabs screen controller to resolve the content scroll view via a new helper method.
- Add
RNSScrollViewSeekingplumbing inRNSTabsScreenComponentViewto receive marker-provided scroll views. - Add a new component-integration test scenario group + scenario for ScrollViewMarker in Tabs (iOS).
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| ios/tabs/screen/RNSTabsScreenViewController.mm | Adds a helper to resolve the content scroll view for repeated tab-selection behavior. |
| ios/tabs/screen/RNSTabsScreenComponentView.mm | Adds ScrollViewMarker “seeking” integration to pass a descendant scroll view upward. |
| ios/gamma/scroll-view-marker/RNSScrollViewMarkerComponentView.mm | Adjusts ancestor traversal to use React superview semantics and resets registration when removed from window. |
| apps/src/tests/component-integration-tests/scroll-view-marker/test-svm-tabs-edge-effects/scenario-description.ts | Introduces metadata for a new integration scenario. |
| apps/src/tests/component-integration-tests/scroll-view-marker/test-svm-tabs-edge-effects/index.tsx | Adds a new integration scenario app exercising ScrollViewMarker inside Tabs. |
| apps/src/tests/component-integration-tests/scroll-view-marker/index.ts | Registers the new ScrollViewMarker integration scenarios group. |
| apps/src/tests/component-integration-tests/index.tsx | Exposes the new ScrollViewMarker scenario group in the component-integration test menu. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
RNSTabsScreenComponentView stored the registered scroll view and its marker in ivars that were only ever assigned and nilled, never read. The marker was held strongly even though it lives inside the screen's own view subtree, adding a redundant strong edge to a descendant. Remove both ivars and their assignments; registerDescendantScrollView: now just forwards the scroll view to the controller.
Add a second ScrollViewMarker integration scenario covering the tab-repetition special effect with a Stack nested inside Tabs, and register it in the scenario group. HeuristicBreakingView is now an actual heuristic-breaking element (non-collapsable, zero-height view rendered before the marker) so both scenarios exercise the marker-based content-scroll-view registration path instead of falling back to the descendant-chain finder. Also drop unused imports left over in the edge-effects scenario.
Clarify that the scenario covers scroll edge effects specifically. Renames the directory, scenario key, and import symbol.
| import type { ScenarioDescription } from '@apps/tests/shared/helpers'; | ||
|
|
||
| export const scenarioDescription: ScenarioDescription = { | ||
| name: 'Integration: SVM in Tabs - edge effects', |
There was a problem hiding this comment.
nit: do we need the "Integration:" in the name if it's already in component integration tests directory? Applies to both test screens.
| if (auto sv = [self contentScrollViewForEdge:NSDirectionalRectEdgeBottom]; sv != nil) { | ||
| return sv; | ||
| } | ||
| if (auto sv = [self contentScrollViewForEdge:NSDirectionalRectEdgeTop]; sv != nil) { | ||
| return sv; | ||
| } |
There was a problem hiding this comment.
Is there a reason why we check bottom and then top edge in this order or is it arbitrarily chosen? It would be nice to have a comment here.
There was a problem hiding this comment.
No, I don't think there is. There might be once we add possibility to specify multiple scrollviews (different scrollviews for different edges), but it is not yet supported.
| { | ||
| [super didMoveToWindow]; | ||
| if (self.window == nil) { | ||
| _hasAttemptedRegistration = NO; |
There was a problem hiding this comment.
The marker immediately re-registers the scroll view after any mounting transaction:
Screen.Recording.2026-06-23.at.09.33.35.mov
Should we gate registration with window != nil check maybe?
There was a problem hiding this comment.
That won't do unfortunately. But I think I'll try to organise the registration in a bit different manner.
|
I'm seeing some inconsistency: after the 1st tab switch, scrollToTop isn't working, after subsequent switches it started working stack-svm-tabs.mov |
| return <TabsContainer routeConfigs={TABS_ROUTE_CONFIGS} />; | ||
| } | ||
|
|
||
| function StackTabScreen() { |
There was a problem hiding this comment.
aren't we missing export or is this intentional?
There was a problem hiding this comment.
we are coming back to state where we will export only App() (now it should be renamed) and default export createScenario()
#4201
LKuchno
left a comment
There was a problem hiding this comment.
I left a few comments with suggested changes to better align with our current test and scenario structures. Also changes corresponding to new export/import PR#4201 changes were propose. Some comments include open questions regarding the correct naming conventions.
| return <TabsContainer routeConfigs={TABS_ROUTE_CONFIGS} />; | ||
| } | ||
|
|
||
| function StackTabScreen() { |
There was a problem hiding this comment.
we are coming back to state where we will export only App() (now it should be renamed) and default export createScenario()
#4201
|
|
||
| - [ ] There should be two tabs: `Home` and `Second`. | ||
| - [ ] `Home` tab should be selected. | ||
| - [ ] `Hard` scroll-edge-effect should be applied (opqaue background of tab bar). |
There was a problem hiding this comment.
Maybe we can expand a bit on what is expected for Hard:
| - [ ] `Hard` scroll-edge-effect should be applied (opqaue background of tab bar). | |
| - [ ] `Hard` scroll-edge-effect should be applied (opaque background of tab bar with a hard cutoff and dividing line). |
There was a problem hiding this comment.
Dividing line? I haven't noticed such. Will take another look later 😄
There was a problem hiding this comment.
It's actually taken from 'hard' scroll-edge-effect documentation I interpret it as visible cutoff tab bar 😅
Co-authored-by: Krzysztof Ligarski <63918941+kligarski@users.noreply.github.com> Co-authored-by: lkuchno <45803783+LKuchno@users.noreply.github.com>
|
|
||
| ## Prerequisites | ||
|
|
||
| iOS: simulator with iOS 26+ is enough, |
| - [ ] There should be two tabs: `Home` and `Stack`. | ||
| - [ ] `Home` tab should be selected. | ||
|
|
||
| 2. Scrolldown a bit. |
Description
Integrates the
ScrollViewMarkercomponent with the Tabs special-effects system on iOS.Until now, Tabs relied purely on a descendant-chain heuristic (
RNSScrollViewFinder) tolocate the scroll view backing a tab screen. This PR lets a
ScrollViewMarkerexplicitlyregister its wrapped scroll view with the enclosing tab screen, so the content scroll view
is known precisely instead of being guessed. The heuristic is kept as a fallback when no
marker is present.
This integration is currently gated behind the
RNS_GAMMA_ENABLEDcompilation flag and willremain so until we are able to remove that flag.
Closes https://github.com/software-mansion/react-native-screens-labs/issues/1536
Closes https://github.com/software-mansion/react-native-screens-labs/issues/1537
Changes
RNSTabsScreenComponentViewnow conforms toRNSScrollViewSeeking. When a descendantmarker registers its scroll view, the screen forwards it to its view controller via
setContentScrollView:forEdge:NSDirectionalRectEdgeAll. On a tab change theRNSTabBarControllerqueries the child view controller for its content scroll view, so theregistered scroll view flows into the special-effects system automatically.
RNSTabsScreenViewControllergainsresolveContentScrollView, which prefers the explicitlyregistered content scroll view (querying the bottom, then top edge) and falls back to the
RNSScrollViewFinderheuristic. The repeated-tab-selection scroll-to-top effect now uses it.RNSScrollViewMarkerComponentView:reactSuperview) when looking for a seeking ancestor,because at transaction-end time the
RNSTabsScreenComponentViewmay not yet be attached toits parent (tab not selected, or registration runs too early);
re-attachment (e.g. switching back to a tab).
RNSTabsHostComponentView.Notes / limitations
ScrollViewMarkersits directly under Tabs (no Stack v4/v5 inbetween) is handled here. Those compositions will be integrated in follow-up PRs.
ScrollViewMarkers under the same seeking ancestor there is currently apotential race condition in which marker wins registration.
Visual docs
Edge effects
Important
Seemingly the edge effect is applied correctly no matter the integration with the scrollview marker. Likely
UITabBarControlleruses some different logic toUIViewController.contentScrollViewto detect scrollview, because even when I have had returnednilfrom the method, the edge effect had still been applied. Nevertheless, I decided to include this test case here, just to make sure it works. Also this allows us to test that different ScrollViewMarkers from different tab update the edge effect correctly on tab change.ios-26-scroll-edge-effects-before.mov
ios-26-scroll-edge-effects-after.mov
Special effects
ios-26-tabs-special-effects-before.mov
ios-26-tabs-special-effects-after.mov
Test plan
Tested with the new integration scenario:
apps/src/tests/component-integration-tests/scroll-view-marker/test-svm-tabs-edge-effects/index.tsxapps/src/tests/component-integration-tests/scroll-view-marker/test-svm-tabs-edge-effects/scenario-description.tsapps/src/tests/component-integration-tests/index.tsxasScrollViewMarkerSteps (iOS,
RNS_GAMMA_ENABLED=1):FabricExampleand open the component-integration tests.ScrollViewMarker integration cases→Integration: SVM in Tabs - edge effects.(
Home→hard,Second→soft).selection effect resolves the marker-registered scroll view).
Checklist