Skip to content

Commit 665a583

Browse files
committed
feat(iOS, Tabs): support active nested content scroll view markers
1 parent be64b6d commit 665a583

16 files changed

Lines changed: 423 additions & 11 deletions

File tree

android/src/main/java/com/swmansion/rnscreens/gamma/scrollviewmarker/ScrollViewMarkerViewManager.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,11 @@ class ScrollViewMarkerViewManager :
4949
value: String?,
5050
) = Unit
5151

52+
override fun setActive(
53+
view: ScrollViewMarker?,
54+
value: Boolean,
55+
) = Unit
56+
5257
companion object {
5358
const val REACT_CLASS = "RNSScrollViewMarker"
5459
}
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import { ScenarioGroup } from '../../shared/helpers';
2+
import TestSvmActiveScrollViewForTabs from './test-svm-active-scroll-view-for-tabs';
23
import TestSvmConfiguresScrollView from './test-svm-configures-scroll-view';
34

45
const ScrollViewMarkerScenarioGroup: ScenarioGroup = {
56
name: 'ScrollViewMarker scenarios',
67
details: 'Scenarios related to ScrollViewMarker component',
7-
scenarios: [TestSvmConfiguresScrollView],
8+
scenarios: [TestSvmConfiguresScrollView, TestSvmActiveScrollViewForTabs],
89
};
910

1011
export default ScrollViewMarkerScenarioGroup;
Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
import React from 'react';
2+
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
3+
import type { Scenario } from '../../shared/helpers';
4+
import { ScrollViewMarker } from 'react-native-screens/experimental';
5+
import { TabsContainer } from '../../../shared/gamma/containers/tabs';
6+
import { Rectangle } from '../../../shared/Rectangle';
7+
import Colors from '../../../shared/styling/Colors';
8+
import { generateNextColor } from '../../../shared/utils/color-generator';
9+
10+
const SCENARIO: Scenario = {
11+
name: 'Active marker for tabs',
12+
key: 'test-svm-active-scroll-view-for-tabs',
13+
details:
14+
'Reproduces nested tab content where multiple descendant ScrollViews stay mounted under a non-scroll wrapper. ' +
15+
'Switch pages with the buttons and verify that tab bar minimize behavior follows the active ScrollViewMarker ' +
16+
'instead of remaining attached to the first registered ScrollView.',
17+
platforms: ['ios'],
18+
AppComponent: App,
19+
};
20+
21+
export default SCENARIO;
22+
23+
const ITEM_COUNT = 24;
24+
25+
function App() {
26+
return (
27+
<TabsContainer
28+
routeConfigs={[
29+
{
30+
name: 'Journal',
31+
Component: JournalLikeScreen,
32+
options: {
33+
title: 'Journal',
34+
ios: {
35+
icon: { type: 'sfSymbol', name: 'book.closed' },
36+
},
37+
},
38+
},
39+
{
40+
name: 'Other',
41+
Component: PlaceholderTab,
42+
options: {
43+
title: 'Other',
44+
ios: {
45+
icon: { type: 'sfSymbol', name: 'square.grid.2x2' },
46+
},
47+
},
48+
},
49+
]}
50+
ios={{
51+
tabBarMinimizeBehavior: 'onScrollDown',
52+
}}
53+
/>
54+
);
55+
}
56+
57+
function JournalLikeScreen() {
58+
const [activePage, setActivePage] = React.useState(0);
59+
60+
return (
61+
<View style={styles.screen}>
62+
<View style={styles.header}>
63+
<Text style={styles.title}>Nested content with active marker</Text>
64+
<View style={styles.controls}>
65+
<PageButton
66+
label="Day 1"
67+
selected={activePage === 0}
68+
onPress={() => setActivePage(0)}
69+
/>
70+
<PageButton
71+
label="Day 2"
72+
selected={activePage === 1}
73+
onPress={() => setActivePage(1)}
74+
/>
75+
</View>
76+
</View>
77+
78+
<View style={styles.pagerHost}>
79+
<Page
80+
active={activePage === 0}
81+
label="Day 1"
82+
accent={Colors.YellowLight100}
83+
/>
84+
<Page
85+
active={activePage === 1}
86+
label="Day 2"
87+
accent={Colors.GreenLight100}
88+
/>
89+
</View>
90+
</View>
91+
);
92+
}
93+
94+
function PlaceholderTab() {
95+
return (
96+
<ScrollView contentContainerStyle={styles.placeholderScrollContent}>
97+
<Text style={styles.placeholderText}>Second tab placeholder</Text>
98+
{Array.from({ length: 20 }, (_, index) => (
99+
<Rectangle
100+
key={index}
101+
color={generateNextColor()}
102+
width={'100%'}
103+
height={88}
104+
/>
105+
))}
106+
</ScrollView>
107+
);
108+
}
109+
110+
function Page(props: { active: boolean; label: string; accent: string }) {
111+
const { active, label, accent } = props;
112+
113+
return (
114+
<View
115+
pointerEvents={active ? 'auto' : 'none'}
116+
style={[
117+
styles.pageContainer,
118+
{ opacity: active ? 1 : 0, zIndex: active ? 1 : 0 },
119+
]}>
120+
<ScrollViewMarker active={active} style={styles.fillParent}>
121+
<ScrollView
122+
style={styles.fillParent}
123+
contentContainerStyle={styles.scrollContent}>
124+
<View style={[styles.pageIntro, { borderColor: accent }]}>
125+
<Text style={styles.pageTitle}>{label}</Text>
126+
<Text style={styles.pageSubtitle}>
127+
Scroll this page after switching tabs. The tab bar should minimize
128+
using the currently active marker.
129+
</Text>
130+
</View>
131+
{Array.from({ length: ITEM_COUNT }, (_, index) => (
132+
<Rectangle
133+
key={`${label}-${index}`}
134+
color={generateNextColor()}
135+
width={'100%'}
136+
height={96}
137+
/>
138+
))}
139+
</ScrollView>
140+
</ScrollViewMarker>
141+
</View>
142+
);
143+
}
144+
145+
function PageButton(props: {
146+
label: string;
147+
selected: boolean;
148+
onPress: () => void;
149+
}) {
150+
const { label, onPress, selected } = props;
151+
152+
return (
153+
<Pressable
154+
onPress={onPress}
155+
style={[styles.button, selected ? styles.buttonSelected : undefined]}>
156+
<Text style={styles.buttonText}>{label}</Text>
157+
</Pressable>
158+
);
159+
}
160+
161+
const styles = StyleSheet.create({
162+
screen: {
163+
flex: 1,
164+
backgroundColor: Colors.White,
165+
},
166+
header: {
167+
paddingTop: 16,
168+
paddingHorizontal: 16,
169+
paddingBottom: 12,
170+
gap: 12,
171+
},
172+
title: {
173+
fontSize: 17,
174+
fontWeight: '700',
175+
},
176+
controls: {
177+
flexDirection: 'row',
178+
gap: 12,
179+
},
180+
button: {
181+
paddingVertical: 10,
182+
paddingHorizontal: 14,
183+
borderRadius: 10,
184+
backgroundColor: '#E5E7EB',
185+
},
186+
buttonSelected: {
187+
backgroundColor: '#CBD5E1',
188+
},
189+
buttonText: {
190+
fontSize: 14,
191+
fontWeight: '600',
192+
},
193+
pagerHost: {
194+
flex: 1,
195+
},
196+
pageContainer: {
197+
...StyleSheet.absoluteFillObject,
198+
},
199+
fillParent: {
200+
flex: 1,
201+
width: '100%',
202+
height: '100%',
203+
},
204+
scrollContent: {
205+
padding: 16,
206+
gap: 12,
207+
},
208+
pageIntro: {
209+
padding: 16,
210+
borderRadius: 12,
211+
borderWidth: 2,
212+
backgroundColor: '#F8FAFC',
213+
gap: 6,
214+
},
215+
pageTitle: {
216+
fontSize: 16,
217+
fontWeight: '700',
218+
},
219+
pageSubtitle: {
220+
fontSize: 14,
221+
color: '#475569',
222+
},
223+
placeholderScrollContent: {
224+
padding: 16,
225+
gap: 12,
226+
},
227+
placeholderText: {
228+
fontSize: 16,
229+
fontWeight: '600',
230+
},
231+
});

ios/gamma/scroll-view-marker/RNSScrollViewMarkerComponentView.h

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ NS_ASSUME_NONNULL_BEGIN
66

77
@interface RNSScrollViewMarkerComponentView : RNSReactBaseView
88

9+
@property (nonatomic, readonly, getter=isActive) BOOL active;
10+
911
@end
1012

1113
NS_ASSUME_NONNULL_END

ios/gamma/scroll-view-marker/RNSScrollViewMarkerComponentView.mm

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ @interface RNSScrollViewMarkerComponentView () <RNSScrollEdgeEffectProviding, RC
1919

2020
@implementation RNSScrollViewMarkerComponentView {
2121
BOOL _hasAttemptedRegistration;
22+
BOOL _isActive;
2223
BOOL _needsEdgeEffectUpdate;
2324

2425
RNSScrollEdgeEffect _leftScrollEdgeEffect;
@@ -48,6 +49,7 @@ - (void)resetProps
4849
{
4950
static const auto defaultProps = std::make_shared<const react::RNSScrollViewMarkerProps>();
5051
_props = defaultProps;
52+
_isActive = NO;
5153
_leftScrollEdgeEffect = RNSScrollEdgeEffectAutomatic;
5254
_topScrollEdgeEffect = RNSScrollEdgeEffectAutomatic;
5355
_rightScrollEdgeEffect = RNSScrollEdgeEffectAutomatic;
@@ -109,6 +111,24 @@ - (void)registerWithSeekingAncestor
109111
}
110112

111113
[seekingAncestor registerDescendantScrollView:scrollView fromMarker:self];
114+
[seekingAncestor updateDescendantScrollView:scrollView fromMarker:self isActive:self.isActive];
115+
}
116+
117+
- (void)notifySeekingAncestorAboutActiveStateIfNeeded
118+
{
119+
UIScrollView *scrollView = [self findScrollView];
120+
121+
if (scrollView == nil) {
122+
return;
123+
}
124+
125+
id<RNSScrollViewSeeking> seekingAncestor = [self findFirstSeekingAncestor];
126+
127+
if (seekingAncestor == nil) {
128+
return;
129+
}
130+
131+
[seekingAncestor updateDescendantScrollView:scrollView fromMarker:self isActive:self.isActive];
112132
}
113133

114134
- (void)configureScrollView:(nullable UIScrollView *)scrollView
@@ -151,13 +171,24 @@ - (void)willMoveToWindow:(UIWindow *)newWindow
151171
[self maybeRegisterWithSeekingAncestor];
152172
}
153173

174+
- (void)layoutSubviews
175+
{
176+
[super layoutSubviews];
177+
[self registerWithSeekingAncestor];
178+
}
179+
154180
#pragma mark - RNSScrollEdgeEffectProviding
155181

156182
- (RNSScrollEdgeEffect)leftScrollEdgeEffect
157183
{
158184
return _leftScrollEdgeEffect;
159185
}
160186

187+
- (BOOL)isActive
188+
{
189+
return _isActive;
190+
}
191+
161192
- (RNSScrollEdgeEffect)topScrollEdgeEffect
162193
{
163194
return _topScrollEdgeEffect;
@@ -188,6 +219,11 @@ - (void)updateProps:(const facebook::react::Props::Shared &)props
188219
_needsEdgeEffectUpdate = true;
189220
}
190221

222+
if (oldComponentProps.active != newComponentProps.active) {
223+
_isActive = newComponentProps.active;
224+
[self notifySeekingAncestorAboutActiveStateIfNeeded];
225+
}
226+
191227
if (oldComponentProps.topScrollEdgeEffect != newComponentProps.topScrollEdgeEffect) {
192228
_topScrollEdgeEffect = RNSScrollEdgeEffectFromSVMTopEdgeEffect(newComponentProps.topScrollEdgeEffect);
193229
_needsEdgeEffectUpdate = true;

ios/gamma/scroll-view-marker/RNSScrollViewSeeking.h

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,11 @@
1212
- (void)registerDescendantScrollView:(nonnull UIScrollView *)scrollView
1313
fromMarker:(nonnull RNSScrollViewMarkerComponentView *)marker;
1414

15+
/**
16+
* Updates which of the registered marker-wrapped ScrollViews should be preferred by the seeking ancestor.
17+
*/
18+
- (void)updateDescendantScrollView:(nonnull UIScrollView *)scrollView
19+
fromMarker:(nonnull RNSScrollViewMarkerComponentView *)marker
20+
isActive:(BOOL)isActive;
21+
1522
@end

ios/helpers/scroll-view/RNSScrollViewHelper.h

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,9 @@
99
*/
1010
+ (void)overrideScrollViewBehaviorInFirstDescendantChainFrom:(nullable UIView *)view;
1111

12+
/**
13+
* Overrides contentInsetAdjustmentBehavior for a specific ScrollView instance.
14+
*/
15+
+ (void)overrideContentInsetAdjustmentBehaviorIfNeededForScrollView:(nullable UIScrollView *)scrollView;
16+
1217
@end

ios/helpers/scroll-view/RNSScrollViewHelper.mm

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,12 @@ @implementation RNSScrollViewHelper
55

66
+ (void)overrideScrollViewBehaviorInFirstDescendantChainFrom:(nullable UIView *)view
77
{
8-
UIScrollView *scrollView = [RNSScrollViewFinder findScrollViewInFirstDescendantChainFrom:view];
8+
[self overrideContentInsetAdjustmentBehaviorIfNeededForScrollView:
9+
[RNSScrollViewFinder findScrollViewInFirstDescendantChainFrom:view]];
10+
}
911

12+
+ (void)overrideContentInsetAdjustmentBehaviorIfNeededForScrollView:(nullable UIScrollView *)scrollView
13+
{
1014
if ([scrollView contentInsetAdjustmentBehavior] == UIScrollViewContentInsetAdjustmentNever) {
1115
[scrollView setContentInsetAdjustmentBehavior:UIScrollViewContentInsetAdjustmentAutomatic];
1216
}

ios/tabs/host/RNSTabBarController.mm

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,10 +149,12 @@ - (BOOL)updateSelectedViewControllerTo:(nullable RNSTabsScreenViewController *)n
149149
[self progressNavigationState:nextSelectedViewController.getScreenKeyOrNull];
150150

151151
if (currSelectedViewController == nextSelectedViewController) {
152+
[nextSelectedViewController updateTabBarObservedContentScrollViewIfNeeded];
152153
return YES;
153154
}
154155

155156
[self setSelectedViewController:nextSelectedViewController];
157+
[nextSelectedViewController updateTabBarObservedContentScrollViewIfNeeded];
156158
return YES;
157159
}
158160

0 commit comments

Comments
 (0)