-
-
Notifications
You must be signed in to change notification settings - Fork 654
feat: support hintTextColor on iOS search bar #4089
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
42fde59
015d02d
6278fec
a94c5d8
dc6aced
6fbb400
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,198 @@ | ||
| import { NavigationContainer } from '@react-navigation/native'; | ||
| import { createNativeStackNavigator } from '@react-navigation/native-stack'; | ||
| import type { NativeStackScreenProps } from '@react-navigation/native-stack'; | ||
| import React, { useLayoutEffect, useState } from 'react'; | ||
| import { Button, ScrollView, StyleSheet, Text, View } from 'react-native'; | ||
| import type { SearchBarPlacement, SearchBarProps } from 'react-native-screens'; | ||
|
|
||
| type StackParamList = { | ||
| Home: undefined; | ||
| SearchBar: undefined; | ||
| }; | ||
|
|
||
| type PlaceholderMode = 'default' | 'custom' | 'empty'; | ||
| type HintColorMode = 'default' | 'red' | 'blue'; | ||
|
|
||
| type SearchBarConfig = { | ||
| placement: SearchBarPlacement; | ||
| placeholderMode: PlaceholderMode; | ||
| hintColorMode: HintColorMode; | ||
| allowToolbarIntegration: boolean; | ||
| }; | ||
|
|
||
| const Stack = createNativeStackNavigator<StackParamList>(); | ||
|
|
||
| const placements: SearchBarPlacement[] = [ | ||
| 'automatic', | ||
| 'inline', | ||
| 'stacked', | ||
| 'integrated', | ||
| 'integratedButton', | ||
| 'integratedCentered', | ||
| ]; | ||
|
|
||
| const placeholderModes: PlaceholderMode[] = ['default', 'custom', 'empty']; | ||
| const hintColorModes: HintColorMode[] = ['default', 'red', 'blue']; | ||
|
|
||
| const defaultConfig: SearchBarConfig = { | ||
| placement: 'automatic', | ||
| placeholderMode: 'default', | ||
| hintColorMode: 'default', | ||
| allowToolbarIntegration: true, | ||
| }; | ||
|
|
||
| function getNextValue<T>(values: readonly T[], currentValue: T): T { | ||
| const currentIndex = values.indexOf(currentValue); | ||
| return values[(currentIndex + 1) % values.length]; | ||
| } | ||
|
|
||
| function getPlaceholder(mode: PlaceholderMode): SearchBarProps['placeholder'] { | ||
| switch (mode) { | ||
| case 'custom': | ||
| return 'Custom placeholder'; | ||
| case 'empty': | ||
| return ''; | ||
| default: | ||
| return undefined; | ||
| } | ||
| } | ||
|
|
||
| function getHintTextColor( | ||
| mode: HintColorMode, | ||
| ): SearchBarProps['hintTextColor'] { | ||
| switch (mode) { | ||
| case 'red': | ||
| return 'red'; | ||
| case 'blue': | ||
| return 'blue'; | ||
| default: | ||
| return undefined; | ||
| } | ||
| } | ||
|
|
||
| function Home({ navigation }: NativeStackScreenProps<StackParamList, 'Home'>) { | ||
| return ( | ||
| <View style={styles.centeredContainer}> | ||
| <Text style={styles.title}>Test4089</Text> | ||
| <Button | ||
| title="Open search bar test" | ||
| onPress={() => navigation.navigate('SearchBar')} | ||
| /> | ||
| </View> | ||
| ); | ||
| } | ||
|
|
||
| function SearchBarScreen({ | ||
| navigation, | ||
| }: NativeStackScreenProps<StackParamList, 'SearchBar'>) { | ||
| const [config, setConfig] = useState<SearchBarConfig>(defaultConfig); | ||
|
|
||
| useLayoutEffect(() => { | ||
| navigation.setOptions({ | ||
| headerSearchBarOptions: { | ||
| placement: config.placement, | ||
| allowToolbarIntegration: config.allowToolbarIntegration, | ||
| hideWhenScrolling: false, | ||
| placeholder: getPlaceholder(config.placeholderMode), | ||
| hintTextColor: getHintTextColor(config.hintColorMode), | ||
| }, | ||
| }); | ||
| }, [config, navigation]); | ||
|
|
||
| return ( | ||
| <ScrollView | ||
| contentInsetAdjustmentBehavior="automatic" | ||
| contentContainerStyle={styles.contentContainer}> | ||
| <Text style={styles.title}>Search bar options</Text> | ||
| <Text>Placement: {config.placement}</Text> | ||
| <Text>Placeholder: {config.placeholderMode}</Text> | ||
| <Text>Hint text color: {config.hintColorMode}</Text> | ||
| <Text> | ||
| Allow toolbar integration:{' '} | ||
| {config.allowToolbarIntegration ? 'true' : 'false'} | ||
| </Text> | ||
|
|
||
| <Button | ||
| title="Cycle placement" | ||
| onPress={() => | ||
| setConfig(currentConfig => ({ | ||
| ...currentConfig, | ||
| placement: getNextValue(placements, currentConfig.placement), | ||
| })) | ||
| } | ||
| /> | ||
| <Button | ||
| title="Cycle placeholder" | ||
| onPress={() => | ||
| setConfig(currentConfig => ({ | ||
| ...currentConfig, | ||
| placeholderMode: getNextValue( | ||
| placeholderModes, | ||
| currentConfig.placeholderMode, | ||
| ), | ||
| })) | ||
| } | ||
| /> | ||
| <Button | ||
| title="Cycle hint color" | ||
| onPress={() => | ||
| setConfig(currentConfig => ({ | ||
| ...currentConfig, | ||
| hintColorMode: getNextValue( | ||
| hintColorModes, | ||
| currentConfig.hintColorMode, | ||
| ), | ||
| })) | ||
| } | ||
| /> | ||
| <Button | ||
| title="Toggle toolbar integration" | ||
| onPress={() => | ||
| setConfig(currentConfig => ({ | ||
| ...currentConfig, | ||
| allowToolbarIntegration: !currentConfig.allowToolbarIntegration, | ||
| })) | ||
| } | ||
| /> | ||
| <Button | ||
| title="Restore default values" | ||
| onPress={() => setConfig(defaultConfig)} | ||
| /> | ||
| </ScrollView> | ||
| ); | ||
| } | ||
|
|
||
| export default function App(): React.JSX.Element { | ||
| return ( | ||
| <NavigationContainer> | ||
| <Stack.Navigator> | ||
| <Stack.Screen name="Home" component={Home} /> | ||
| <Stack.Screen | ||
| name="SearchBar" | ||
| component={SearchBarScreen} | ||
| options={{ | ||
| headerLargeTitle: true, | ||
| title: 'Search bar', | ||
| }} | ||
| /> | ||
| </Stack.Navigator> | ||
| </NavigationContainer> | ||
| ); | ||
| } | ||
|
|
||
| const styles = StyleSheet.create({ | ||
| centeredContainer: { | ||
| flex: 1, | ||
| alignItems: 'center', | ||
| justifyContent: 'center', | ||
| gap: 12, | ||
| }, | ||
| contentContainer: { | ||
| padding: 16, | ||
| gap: 12, | ||
| }, | ||
| title: { | ||
| fontSize: 20, | ||
| fontWeight: '600', | ||
| }, | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -614,6 +614,7 @@ + (void)updateViewController:(UIViewController *)vc | |
| } | ||
| } | ||
| #endif /* Check for iOS 26.0 */ | ||
| [searchBar updatePlaceholder]; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm not sure if we need this. We will be informed about any changes in |
||
| #endif /* !TARGET_OS_TV */ | ||
| } | ||
| break; | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -20,10 +20,19 @@ | |
| namespace react = facebook::react; | ||
| #endif // RCT_NEW_ARCH_ENABLED | ||
|
|
||
| @interface RNSSearchBar () | ||
| - (void)setPlaceholder:(NSString *)placeholder; | ||
| - (void)setHintTextColor:(UIColor *)hintTextColor; | ||
| @end | ||
|
|
||
| @implementation RNSSearchBar { | ||
| __weak RCTBridge *_bridge; | ||
| UISearchController *_controller; | ||
| UIColor *_textColor; | ||
| UIColor *_hintTextColor; | ||
| NSString *_placeholder; | ||
| NSString *_defaultPlaceholder; | ||
| BOOL _didSetPlaceholder; | ||
|
|
||
| // We use those booleans to log a warning if user attempts to restore | ||
| // default behavior after setting explicit value for the prop. | ||
|
|
@@ -66,6 +75,17 @@ - (void)initCommonProps | |
|
|
||
| _controller.searchBar.delegate = self; | ||
|
|
||
| _didSetPlaceholder = NO; | ||
|
|
||
| #if !TARGET_OS_TV | ||
| _defaultPlaceholder = _controller.searchBar.placeholder; | ||
| if (_defaultPlaceholder == nil) { | ||
| _defaultPlaceholder = _controller.searchBar.searchTextField.placeholder; | ||
| } | ||
| #else | ||
| _defaultPlaceholder = _controller.searchBar.placeholder; | ||
| #endif | ||
|
|
||
| _isObscureBackgroundSet = NO; | ||
| _isHideNavigationBarSet = NO; | ||
|
|
||
|
|
@@ -204,7 +224,48 @@ - (void)setAutoCapitalize:(UITextAutocapitalizationType)autoCapitalize | |
|
|
||
| - (void)setPlaceholder:(NSString *)placeholder | ||
| { | ||
| [_controller.searchBar setPlaceholder:placeholder]; | ||
| _placeholder = placeholder; | ||
| _didSetPlaceholder = YES; | ||
| } | ||
|
|
||
| - (void)setHintTextColor:(UIColor *)hintTextColor | ||
| { | ||
| _hintTextColor = hintTextColor; | ||
| } | ||
|
|
||
| - (void)updatePlaceholder | ||
| { | ||
| NSString *placeholder = _placeholder; | ||
|
|
||
| #if !TARGET_OS_TV | ||
| if (placeholder == nil) { | ||
| if (_didSetPlaceholder) { | ||
| placeholder = _defaultPlaceholder; | ||
| } else { | ||
| NSString *currentPlaceholder = _controller.searchBar.placeholder ?: _controller.searchBar.searchTextField.placeholder; | ||
| placeholder = currentPlaceholder ?: _defaultPlaceholder; | ||
| } | ||
| } | ||
|
|
||
| if (_hintTextColor != nil && placeholder != nil) { | ||
| [_controller.searchBar setPlaceholder:placeholder]; | ||
| _controller.searchBar.searchTextField.attributedPlaceholder = | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It seems like it doesn't work on iOS 26 for new search bar at the bottom. We should investigate why & if we can't fix it, describe the limitations in the prop docs. Simulator.Screen.Recording.-.iPhone.17.Pro.-.2026-05-25.at.11.24.44.mov |
||
| [[NSAttributedString alloc] initWithString:placeholder attributes:@{NSForegroundColorAttributeName : _hintTextColor}]; | ||
| } else { | ||
| _controller.searchBar.searchTextField.attributedPlaceholder = nil; | ||
| if (placeholder != nil || _didSetPlaceholder) { | ||
| [_controller.searchBar setPlaceholder:placeholder]; | ||
| } | ||
| } | ||
| #else | ||
| if (placeholder == nil) { | ||
| placeholder = _defaultPlaceholder; | ||
| } | ||
|
|
||
| if (placeholder != nil || _didSetPlaceholder) { | ||
| [_controller.searchBar setPlaceholder:placeholder]; | ||
| } | ||
| #endif | ||
| } | ||
|
|
||
| - (void)setBarTintColor:(UIColor *)barTintColor | ||
|
|
@@ -407,8 +468,11 @@ - (void)updateProps:(react::Props::Shared const &)props oldProps:(react::Props:: | |
| RNSOptionalBooleanFromRNSSearchBarHideNavigationBar:newScreenProps.hideNavigationBar]]; | ||
| } | ||
|
|
||
| BOOL shouldUpdatePlaceholder = NO; | ||
|
|
||
| if (oldScreenProps.placeholder != newScreenProps.placeholder) { | ||
| [self setPlaceholder:RCTNSStringFromStringNilIfEmpty(newScreenProps.placeholder)]; | ||
| shouldUpdatePlaceholder = YES; | ||
| } | ||
|
|
||
| #if !TARGET_OS_VISION | ||
|
|
@@ -429,6 +493,15 @@ - (void)updateProps:(react::Props::Shared const &)props oldProps:(react::Props:: | |
| [self setTextColor:RCTUIColorFromSharedColor(newScreenProps.textColor)]; | ||
| } | ||
|
|
||
| if (oldScreenProps.hintTextColor != newScreenProps.hintTextColor) { | ||
| [self setHintTextColor:RCTUIColorFromSharedColor(newScreenProps.hintTextColor)]; | ||
| shouldUpdatePlaceholder = YES; | ||
| } | ||
|
|
||
| if (shouldUpdatePlaceholder) { | ||
| [self updatePlaceholder]; | ||
| } | ||
|
|
||
| if (oldScreenProps.placement != newScreenProps.placement) { | ||
| self.placement = [RNSConvert RNSScreenSearchBarPlacementFromCppEquivalent:newScreenProps.placement]; | ||
| } | ||
|
|
@@ -508,10 +581,21 @@ - (UIView *)view | |
| } | ||
| } | ||
|
|
||
| RCT_EXPORT_VIEW_PROPERTY(placeholder, NSString) | ||
| RCT_CUSTOM_VIEW_PROPERTY(placeholder, NSString, RNSSearchBar) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We've dropped support for Paper so these are not necessary. I think recently PR removing those landed on main. We should merge/rebase onto current main. |
||
| { | ||
| RNSSearchBar *searchBarView = static_cast<RNSSearchBar *>(view); | ||
| [searchBarView setPlaceholder:[RCTConvert NSString:json]]; | ||
| [searchBarView updatePlaceholder]; | ||
| } | ||
| RCT_EXPORT_VIEW_PROPERTY(barTintColor, UIColor) | ||
| RCT_EXPORT_VIEW_PROPERTY(tintColor, UIColor) | ||
| RCT_EXPORT_VIEW_PROPERTY(textColor, UIColor) | ||
| RCT_CUSTOM_VIEW_PROPERTY(hintTextColor, UIColor, RNSSearchBar) | ||
| { | ||
| RNSSearchBar *searchBarView = static_cast<RNSSearchBar *>(view); | ||
| [searchBarView setHintTextColor:[RCTConvert UIColor:json]]; | ||
| [searchBarView updatePlaceholder]; | ||
| } | ||
| RCT_EXPORT_VIEW_PROPERTY(cancelButtonText, NSString) | ||
| RCT_EXPORT_VIEW_PROPERTY(placement, RNSSearchBarPlacement) | ||
| RCT_EXPORT_VIEW_PROPERTY(allowToolbarIntegration, BOOL) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1064,8 +1064,6 @@ export interface SearchBarProps { | |
| textColor?: ColorValue | undefined; | ||
| /** | ||
| * The search hint text color | ||
| * | ||
| * @plaform android | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We should also remove |
||
| */ | ||
| hintTextColor?: ColorValue | undefined; | ||
| /** | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The search bar doesn't really support runtime changes for e.g. placement so we should probably have configuration on root screen and then apply the config on new pushed screen with the config.