Skip to content

Commit 21e40a2

Browse files
authored
feat(iOS 26, Stack v4): support for new search bar placements, integration with toolbar (#3168)
## Description This PR addresses changes to search bar on iOS 26. Closes software-mansion/react-native-screens-labs#339. After merging, docs in `react-navigation` should also be updated, issue for this: software-mansion/react-native-screens-labs#384. ## Changes - add support for new search bar placements (`integrated`, `integratedButton`, `integratedCentered`), - add prop for controlling new search bar toolbar integration on iPhone (`allowToolbarIntegration`), - deprecate one of the placements (`inline` has been renamed to `integrated` starting from iOS 26) and `cancelButtonText` (search cancel button does not have any text on iOS 26), - reduce scope of workaround introduced in #3098 (only to `stacked` placement) - disable recycling for `SearchBar` (workaround above does not solve all bugs with search bar on iOS 26). ## Screenshots / GIFs https://github.com/user-attachments/assets/bcbb86e1-298d-4942-a290-3acdaef4214d > [!NOTE] > If you use `integrated`, `integratedButton` or `integratedCentered`, the integration into toolbar is delayed and causes a flash (you can see it on the video). This bug happens also in bare UIKit app. The best workarounds I could find are: > - use `automatic` with transparent header, > - use `automatic` with `hideWhenScrolling: true` > > Details: software-mansion/react-native-screens-labs#331 (comment) > > There is also another bug (also reproducible but due to how tabs+freeze work in screens, it's a little bit worse in our case). I think it's more of an edge case though (if you use tab with search systemItem but disable toolbar integration, the search bar is empty). Details: software-mansion/react-native-screens-labs#331 (comment) ## Test code and steps to reproduce Run `Test3168`. You can also see other Search Bar related test screens like `SearchBar.tsx`, `Test758`, `Test1097`. ## Checklist - [x] Included code example that can be used to test this change - [x] Updated TS types - [ ] Updated documentation: <!-- For adding new props to native-stack --> - [x] https://github.com/software-mansion/react-native-screens/blob/main/guides/GUIDE_FOR_LIBRARY_AUTHORS.md - [ ] https://github.com/software-mansion/react-native-screens/blob/main/native-stack/README.md - [x] https://github.com/software-mansion/react-native-screens/blob/main/src/types.tsx - [ ] https://github.com/software-mansion/react-native-screens/blob/main/src/native-stack/types.tsx - [ ] Ensured that CI passes
1 parent 4966d59 commit 21e40a2

File tree

13 files changed

+444
-9
lines changed

13 files changed

+444
-9
lines changed

android/src/main/java/com/swmansion/rnscreens/SearchBarManager.kt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,13 @@ class SearchBarManager :
196196
logNotAvailable("setPlacement")
197197
}
198198

199+
override fun setAllowToolbarIntegration(
200+
view: SearchBarView,
201+
value: Boolean,
202+
) {
203+
logNotAvailable("allowToolbarIntegration")
204+
}
205+
199206
override fun setHideWhenScrolling(
200207
view: SearchBarView?,
201208
value: Boolean,

android/src/paper/java/com/facebook/react/viewmanagers/RNSSearchBarManagerDelegate.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@ public void setProperty(T view, String propName, @Nullable Object value) {
3636
case "placement":
3737
mViewManager.setPlacement(view, (String) value);
3838
break;
39+
case "allowToolbarIntegration":
40+
mViewManager.setAllowToolbarIntegration(view, value == null ? true : (boolean) value);
41+
break;
3942
case "obscureBackground":
4043
mViewManager.setObscureBackground(view, value == null ? false : (boolean) value);
4144
break;

android/src/paper/java/com/facebook/react/viewmanagers/RNSSearchBarManagerInterface.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ public interface RNSSearchBarManagerInterface<T extends View> {
1818
void setAutoCapitalize(T view, @Nullable String value);
1919
void setPlaceholder(T view, @Nullable String value);
2020
void setPlacement(T view, @Nullable String value);
21+
void setAllowToolbarIntegration(T view, boolean value);
2122
void setObscureBackground(T view, boolean value);
2223
void setHideNavigationBar(T view, boolean value);
2324
void setCancelButtonText(T view, @Nullable String value);

apps/src/tests/Test3168.tsx

Lines changed: 306 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,306 @@
1+
import {
2+
NavigationContainer,
3+
ParamListBase,
4+
useNavigation,
5+
} from '@react-navigation/native';
6+
import {
7+
createNativeStackNavigator,
8+
NativeStackNavigationProp,
9+
} from '@react-navigation/native-stack';
10+
import React, {
11+
createContext,
12+
ReactNode,
13+
useContext,
14+
useLayoutEffect,
15+
useState,
16+
} from 'react';
17+
import { Button, ScrollView, Text, View } from 'react-native';
18+
import { SearchBarPlacement, SearchBarProps } from 'react-native-screens';
19+
import { ListItem, SettingsPicker, SettingsSwitch } from '../shared';
20+
import { CenteredLayoutView } from '../shared/CenteredLayoutView';
21+
import {
22+
BottomTabsContainer,
23+
TabConfiguration,
24+
} from '../shared/gamma/containers/bottom-tabs/BottomTabsContainer';
25+
import ConfigWrapperContext, {
26+
Configuration,
27+
DEFAULT_GLOBAL_CONFIGURATION,
28+
} from '../shared/gamma/containers/bottom-tabs/ConfigWrapperContext';
29+
30+
type NavigationProp<ParamList extends ParamListBase> = {
31+
navigation: NativeStackNavigationProp<ParamList>;
32+
};
33+
34+
type SearchBarConfig = {
35+
placement: SearchBarProps['placement'];
36+
allowToolbarIntegration: boolean;
37+
useSystemItem: boolean;
38+
};
39+
40+
const defaultSearchBarConfig: SearchBarConfig = {
41+
placement: 'integrated',
42+
allowToolbarIntegration: true,
43+
useSystemItem: true,
44+
};
45+
46+
const SearchBarConfigContext = createContext({
47+
searchBarConfig: defaultSearchBarConfig,
48+
setSearchBarConfig: (_: SearchBarConfig) => {},
49+
});
50+
51+
export function useSearchBarConfig() {
52+
return useContext(SearchBarConfigContext);
53+
}
54+
55+
export const SearchBarConfigProvider: React.FC<{
56+
children: ReactNode;
57+
}> = ({ children }) => {
58+
const [searchBarConfig, setSearchBarConfig] = useState(
59+
defaultSearchBarConfig,
60+
);
61+
62+
return (
63+
<SearchBarConfigContext.Provider
64+
value={{ searchBarConfig, setSearchBarConfig }}>
65+
{children}
66+
</SearchBarConfigContext.Provider>
67+
);
68+
};
69+
70+
type MainRouteParamList = {
71+
Home: undefined;
72+
Stack: undefined;
73+
StackAndTabs: undefined;
74+
};
75+
76+
type MainStackNavigationProp = NavigationProp<MainRouteParamList>;
77+
78+
const MainStack = createNativeStackNavigator<MainRouteParamList>();
79+
80+
function Home({ navigation }: MainStackNavigationProp) {
81+
return (
82+
<View
83+
style={{
84+
flex: 1,
85+
justifyContent: 'center',
86+
alignItems: 'center',
87+
gap: 10,
88+
}}>
89+
<Text>Test Search Bar placement</Text>
90+
<Button title="Stack only" onPress={() => navigation.push('Stack')} />
91+
<Button
92+
title="Bottom Tabs and Stack"
93+
onPress={() => navigation.push('StackAndTabs')}
94+
/>
95+
</View>
96+
);
97+
}
98+
99+
export default function App() {
100+
return (
101+
<NavigationContainer>
102+
<SearchBarConfigProvider>
103+
<MainStack.Navigator>
104+
<MainStack.Screen name="Home" component={Home} />
105+
<MainStack.Screen
106+
name="Stack"
107+
component={ExamplesStackComponent}
108+
options={{ headerShown: false }}
109+
/>
110+
<MainStack.Screen
111+
name="StackAndTabs"
112+
component={TabsStackComponent}
113+
options={{ headerShown: false }}
114+
/>
115+
</MainStack.Navigator>
116+
</SearchBarConfigProvider>
117+
</NavigationContainer>
118+
);
119+
}
120+
121+
type ExamplesRouteParamList = {
122+
Menu: undefined;
123+
Test: undefined;
124+
};
125+
126+
type ExamplesStackNavigationProp = NavigationProp<ExamplesRouteParamList>;
127+
128+
const ExamplesStack = createNativeStackNavigator<ExamplesRouteParamList>();
129+
130+
function ExamplesStackComponent({ showMenu = true }: { showMenu?: boolean }) {
131+
return (
132+
<ExamplesStack.Navigator>
133+
{showMenu && <ExamplesStack.Screen name="Menu" component={Menu} />}
134+
<ExamplesStack.Screen
135+
name="Test"
136+
component={Test}
137+
options={{
138+
headerLargeTitle: true,
139+
headerTransparent: true,
140+
headerBackButtonDisplayMode: 'minimal',
141+
}}
142+
/>
143+
</ExamplesStack.Navigator>
144+
);
145+
}
146+
147+
function Test({ navigation }: ExamplesStackNavigationProp) {
148+
const [search, setSearch] = useState('');
149+
const { searchBarConfig } = useSearchBarConfig();
150+
151+
const places = [
152+
'🏝️ Desert Island',
153+
'🏞️ National Park',
154+
'⛰️ Mountain',
155+
'🏰 Castle',
156+
'🗽 Statue of Liberty',
157+
'🌉 Bridge at Night',
158+
'🏦 Bank',
159+
'🏛️ Classical Building',
160+
'🏟️ Stadium',
161+
'🏪 Convenience Store',
162+
'🏫 School',
163+
'⛲ Fountain',
164+
'🌄 Sunrise Over Mountains',
165+
'🌆 Cityscape at Dusk',
166+
'🎡 Ferris Wheel',
167+
];
168+
169+
useLayoutEffect(() => {
170+
navigation.setOptions({
171+
headerSearchBarOptions: {
172+
placement: searchBarConfig.placement ?? 'automatic',
173+
allowToolbarIntegration: searchBarConfig.allowToolbarIntegration,
174+
onChangeText: event => setSearch(event.nativeEvent.text),
175+
},
176+
});
177+
}, [navigation, search, searchBarConfig]);
178+
179+
return (
180+
<ScrollView
181+
contentInsetAdjustmentBehavior="automatic"
182+
keyboardDismissMode="on-drag">
183+
{places
184+
.filter(item => item.toLowerCase().indexOf(search.toLowerCase()) !== -1)
185+
.map(place => (
186+
<ListItem
187+
key={place}
188+
title={place}
189+
onPress={() => navigation.goBack()}
190+
/>
191+
))}
192+
</ScrollView>
193+
);
194+
}
195+
196+
function TabsStackComponent() {
197+
const [config, setConfig] = React.useState<Configuration>(
198+
DEFAULT_GLOBAL_CONFIGURATION,
199+
);
200+
const { searchBarConfig } = useSearchBarConfig();
201+
202+
const TAB_CONFIGS: TabConfiguration[] = [
203+
{
204+
tabScreenProps: {
205+
tabKey: 'main',
206+
title: 'Main',
207+
icon: {
208+
sfSymbolName: 'house',
209+
},
210+
},
211+
component: () => Menu({ tabsMode: true }),
212+
},
213+
{
214+
tabScreenProps: {
215+
tabKey: 'another',
216+
title: 'Another',
217+
icon: {
218+
sfSymbolName: 'ellipsis',
219+
},
220+
},
221+
component: AnotherTab,
222+
},
223+
{
224+
tabScreenProps: {
225+
tabKey: 'examples',
226+
title: 'Search',
227+
icon: {
228+
sfSymbolName: 'magnifyingglass',
229+
},
230+
systemItem: searchBarConfig.useSystemItem ? 'search' : undefined,
231+
},
232+
component: () => ExamplesStackComponent({ showMenu: false }),
233+
},
234+
];
235+
236+
return (
237+
<ConfigWrapperContext.Provider
238+
value={{
239+
config,
240+
setConfig,
241+
}}>
242+
<BottomTabsContainer tabConfigs={TAB_CONFIGS} />
243+
</ConfigWrapperContext.Provider>
244+
);
245+
}
246+
247+
function AnotherTab() {
248+
return (
249+
<CenteredLayoutView>
250+
<Text>Another tab</Text>
251+
</CenteredLayoutView>
252+
);
253+
}
254+
255+
function Menu({ tabsMode = false }: { tabsMode?: boolean }) {
256+
const { searchBarConfig, setSearchBarConfig } = useSearchBarConfig();
257+
const navigation = useNavigation<ExamplesStackNavigationProp['navigation']>();
258+
259+
return (
260+
<CenteredLayoutView>
261+
<SettingsSwitch
262+
label="allowToolbarIntegration"
263+
value={searchBarConfig.allowToolbarIntegration}
264+
onValueChange={value =>
265+
setSearchBarConfig({
266+
...searchBarConfig,
267+
allowToolbarIntegration: value,
268+
})
269+
}
270+
/>
271+
<SettingsPicker<SearchBarPlacement>
272+
label="placement"
273+
value={searchBarConfig.placement ?? 'automatic'}
274+
onValueChange={value =>
275+
setSearchBarConfig({
276+
...searchBarConfig,
277+
placement: value,
278+
})
279+
}
280+
items={[
281+
'automatic',
282+
'inline',
283+
'stacked',
284+
'integrated',
285+
'integratedButton',
286+
'integratedCentered',
287+
]}
288+
/>
289+
{tabsMode && (
290+
<SettingsSwitch
291+
label="use systemItem"
292+
value={searchBarConfig.useSystemItem}
293+
onValueChange={value =>
294+
setSearchBarConfig({
295+
...searchBarConfig,
296+
useSystemItem: value,
297+
})
298+
}
299+
/>
300+
)}
301+
{!tabsMode && (
302+
<Button title="Go to screen" onPress={() => navigation.push('Test')} />
303+
)}
304+
</CenteredLayoutView>
305+
);
306+
}

apps/src/tests/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,7 @@ export { default as Test3045 } from './Test3045';
146146
export { default as Test3093 } from './Test3093';
147147
export { default as Test3111 } from './Test3111';
148148
export { default as Test3115 } from './Test3115';
149+
export { default as Test3168 } from './Test3168';
149150
export { default as TestScreenAnimation } from './TestScreenAnimation';
150151
export { default as TestScreenAnimationV5 } from './TestScreenAnimationV5';
151152
export { default as TestHeader } from './TestHeader';

guides/GUIDE_FOR_LIBRARY_AUTHORS.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -253,7 +253,7 @@ For iOS:
253253
- `modal` will use:
254254
* on iOS 18 and later [`UIModalPresentationAutomatic`](https://developer.apple.com/documentation/uikit/uimodalpresentationstyle/uimodalpresentationautomatic?language=objc). However, since the iOS 18 changed the default behaviour from `UIModalPresentationPageSheet` to `UIModalPresentationFormSheet` (it looks vastly different on regular size classes devices, e.g. iPad), for the sake of backward compatibility, we keep the default behaviour from before iOS 18. *This might change in future major release of `react-native-screens`.
255255
* on iOS 13 and later [`UIModalPresentationAutomatic`](https://developer.apple.com/documentation/uikit/uimodalpresentationstyle/uimodalpresentationautomatic?language=objc)
256-
* on iOS 12 and earlier will use [`UIModalPresentationFullScreen`](https://developer.apple.com/documentation/uikit/uimodalpresentationstyle/uimodalpresentationfullscreen?language=objc).
256+
* on iOS 12 and earlier will use [`UIModalPresentationFullScreen`](https://developer.apple.com/documentation/uikit/uimodalpresentationstyle/uimodalpresentationfullscreen?language=objc).
257257
- `fullScreenModal` will use [`UIModalPresentationFullScreen`](https://developer.apple.com/documentation/uikit/uimodalpresentationstyle/uimodalpresentationfullscreen?language=objc)
258258
- `formSheet` will use [`UIModalPresentationFormSheet`](https://developer.apple.com/documentation/uikit/uimodalpresentationstyle/uimodalpresentationformsheet?language=objc)
259259
- `pageSheet` will use [`UIModalPresentationPageSheet`](https://developer.apple.com/documentation/uikit/uimodalpresentationstyle/pagesheet?language=objc)
@@ -456,7 +456,7 @@ To render a search bar use `ScreenStackHeaderSearchBarView` with `<SearchBar>` c
456456
- `autoFocus` - If `true` automatically focuses search bar when screen is appearing. (Android only)
457457
- `barTintColor` - The search field background color. By default bar tint color is translucent.
458458
- `tintColor` - The color for the cursor caret and cancel button text. (iOS only)
459-
- `cancelButtonText` - The text to be used instead of default `Cancel` button text. (iOS only)
459+
- `cancelButtonText` - The text to be used instead of default `Cancel` button text. Has no effect starting from iOS 26. **Deprecated**. (iOS only)
460460
- `disableBackButtonOverride` - Default behavior is to prevent screen from going back when search bar is open (`disableBackButtonOverride: false`). If you don't want this to happen set `disableBackButtonOverride` to `true`. (Android only)
461461
- `hideNavigationBar` - Boolean indicating whether to hide the navigation bar during searching. Defaults to `true`. (iOS only)
462462
- `hideWhenScrolling` - Boolean indicating whether to hide the search bar when scrolling. Defaults to `true`. (iOS only)
@@ -471,6 +471,7 @@ To render a search bar use `ScreenStackHeaderSearchBarView` with `<SearchBar>` c
471471
- `onSearchButtonPress` - A callback that gets called when the search button is pressed. It receives the current text value of the search bar.
472472
- `placeholder` - Text displayed when search field is empty. Defaults to an empty string.
473473
- `placement` - Placement of the search bar in the navigation bar. (iOS only)
474+
- `allowToolbarIntegration` - Indicates whether the system can place the search bar among other toolbar items on iPhone. (iOS only)
474475
- `textColor` - The search field text color.
475476
- `hintTextColor` - The search hint text color. (Android only)
476477
- `headerIconColor` - The search and close icon color shown in the header. (Android only)

ios/RNSConvert.mm

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,12 @@ + (RNSSearchBarPlacement)RNSScreenSearchBarPlacementFromCppEquivalent:(react::RN
181181
return RNSSearchBarPlacementAutomatic;
182182
case Inline:
183183
return RNSSearchBarPlacementInline;
184+
case Integrated:
185+
return RNSSearchBarPlacementIntegrated;
186+
case IntegratedButton:
187+
return RNSSearchBarPlacementIntegratedButton;
188+
case IntegratedCentered:
189+
return RNSSearchBarPlacementIntegratedCentered;
184190
}
185191
}
186192

ios/RNSEnums.h

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,9 @@ typedef NS_ENUM(NSInteger, RNSSearchBarPlacement) {
7171
RNSSearchBarPlacementAutomatic,
7272
RNSSearchBarPlacementInline,
7373
RNSSearchBarPlacementStacked,
74+
RNSSearchBarPlacementIntegrated,
75+
RNSSearchBarPlacementIntegratedButton,
76+
RNSSearchBarPlacementIntegratedCentered,
7477
};
7578

7679
typedef NS_ENUM(NSInteger, RNSSplitViewScreenColumnType) {

0 commit comments

Comments
 (0)