Skip to main content

SelectField using react-native-bottom-sheet

In this guide, we'll be creating a SelectField component by extending the TextField with a scrollable options View and additional props to handle its customization.

We will be using the react-native-bottom-sheet library for the options list, the ListItem component for displaying individual options, and the TextField component for opening the options list and displaying selected options.

There are many ways you can setup react-native-bottom-sheet to function as a Picker. We'll keep it simple - pressing the TextField will open the options-list. Pressing the option(s) will update the value via callback. You can customize this to fit your usecase.

1. Installation

Let's start by installing the necessary dependencies. You can see complete installation instructions for react-native-bottom-sheet here.

yarn add @gorhom/bottom-sheet@^4

The library requires the react-native-gesture-handler and react-native-reanimated dependencies, but if you're using a newer Ignite boilerplate version, those should already be installed. Just check your package.json file and if you don't see them, follow these steps:

yarn add react-native-reanimated react-native-gesture-handler
# or
expo install react-native-reanimated react-native-gesture-handler

2. Create the SelectField.tsx Component File

Instead of extending the TextField component with more props and functionality, we'll be creating a wrapper for the TextField component that contains additional functionality.

We'll start by creating a new file in the components directory.

touch ./app/components/SelectField.tsx

Let's add some preliminary code to the file. Since the TextInput has its own touch handlers for focus, we'll want to disable that by wrapping it in a View with no pointer-events. The new TouchableOpacity will trigger our options sheet.

import React, { forwardRef, Ref, useImperativeHandle } from "react";
import { View, TouchableOpacity } from "react-native";
import { TextField, TextFieldProps } from "./TextField";

export interface SelectFieldProps
extends Omit<TextFieldProps, "ref" | "onValueChange" | "onChange" | "value"> {}
export interface SelectFieldRef {}

export const SelectField = forwardRef(function SelectField(
props: SelectFieldProps,
ref: Ref<SelectFieldRef>
) {
const { ...TextFieldProps } = props;

const disabled = TextFieldProps.editable === false || TextFieldProps.status === "disabled";

useImperativeHandle(ref, () => ({}));

return (
<>
<TouchableOpacity activeOpacity={1}>
<View pointerEvents="none">
<TextField {...TextFieldProps} />
</View>
</TouchableOpacity>
</>
);
});
Demo Preview
import { SelectField } from "../components/SelectField";

function FavoriteNBATeamsScreen() {
return (
<SelectField
label="NBA Team(s)"
helper="Select your team(s)"
placeholder="e.g. Trail Blazers"
/>
);
}

yulolimum-capture-2023-02-15--02-34-52

3. Add New Props and Customize the TextField

Now, we can start modifying the code we added in the previous step to support multiple options as well as making the TextField look like a SelectField.

Add a Caret Icon Accessory

Let's add an accessory to the input to make it look like a SelectField.

<TextField
{...TextFieldProps}
RightAccessory={(props) => <Icon icon="caretRight" containerStyle={props.style} />}
/>

Add Props

The options prop can be any structure that you want (e.g. flat array of values, object where the key is the option value and the value is the label, etc). For our SelectField guide, we'll be doing an array of objects.

We will support multi-select (by default) as well as a single select.

We will override the value prop.

A new renderValue prop can be used to format and display a custom text value. This can be useful when the TextField is not multiline, but your SelectField is.

Additionally, we'll add a new event callback called onSelect since that makes more sense for a SelectField. However, feel free to override TextField's onChange if you prefer.

export interface SelectFieldProps
extends Omit<TextFieldProps, "ref" | "onValueChange" | "onChange"> {
value?: string[];
renderValue?: (value: string[]) => string;
onSelect?: (newValue: string[]) => void;
multiple?: boolean;
options: { label: string; value: string }[];
}

// ...

const {
value = [],
renderValue,
onSelect,
options = [],
multiple = true,
...TextFieldProps
} = props;

Add Logic to Display Selected Options

We'll add some code to display the selected options inside the TextField. This will attempt to use the renderValue formatter function and fallback to a joined string.

const valueString =
renderValue?.(value) ??
value
.map((v) => options.find((o) => o.value === v)?.label)
.filter(Boolean)
.join(", ");

Full Code For This Step

import React, { forwardRef, Ref, useImperativeHandle } from "react";
import { TouchableOpacity, View } from "react-native";
import { Icon } from "./Icon";
import { TextField, TextFieldProps } from "./TextField";

export interface SelectFieldProps
extends Omit<TextFieldProps, "ref" | "onValueChange" | "onChange" | "value"> {
value?: string[];
renderValue?: (value: string[]) => string;
onSelect?: (newValue: string[]) => void;
multiple?: boolean;
options: { label: string; value: string }[];
}
export interface SelectFieldRef {}

export const SelectField = forwardRef(function SelectField(
props: SelectFieldProps,
ref: Ref<SelectFieldRef>
) {
const {
value = [],
onSelect,
renderValue,
options = [],
multiple = true,
...TextFieldProps
} = props;

const disabled = TextFieldProps.editable === false || TextFieldProps.status === "disabled";

useImperativeHandle(ref, () => ({}));

const valueString =
renderValue?.(value) ??
value
.map((v) => options.find((o) => o.value === v)?.label)
.filter(Boolean)
.join(", ");

return (
<>
<TouchableOpacity activeOpacity={1}>
<View pointerEvents="none">
<TextField
{...TextFieldProps}
value={valueString}
RightAccessory={(props) => <Icon icon="caretRight" containerStyle={props.style} />}
/>
</View>
</TouchableOpacity>
</>
);
});
Demo Preview
import { SelectField } from "../components/SelectField";

const teams = [
{ label: "Hawks", value: "ATL" },
{ label: "Celtics", value: "BOS" },
// ...
{ label: "Jazz", value: "UTA" },
{ label: "Wizards", value: "WAS" },
];

// prettier-ignore
function FavoriteNBATeamsScreen() {
return (
<>
<SelectField
label="NBA Team(s)"
helper="Select your team(s)"
placeholder="e.g. Trail Blazers"
value={["POR", "MEM", "NOP", "CHI", "CLE", "SAS", "MIL", "LAL", "PHX", "WAS"]}
options={teams}
containerStyle={{ marginBottom: spacing.lg }}
/>

<SelectField
label="NBA Team(s)"
helper="Select your team(s)"
placeholder="e.g. Trail Blazers"
value={["POR", "MEM", "NOP", "CHI", "CLE", "SAS", "MIL", "LAL", "PHX", "WAS"]}
options={teams}
containerStyle={{ marginBottom: spacing.lg }}
multiline
/>

<SelectField
label="NBA Team(s)"
helper="Select your team(s)"
placeholder="e.g. Trail Blazers"
value={["POR", "MEM", "NOP", "CHI", "CLE", "SAS", "MIL", "LAL", "PHX", "WAS"]}
options={teams}
containerStyle={{ marginBottom: spacing.lg }}
renderValue={(value) => `Selected ${value.length} Teams`}
/>
</>
)
}

yulolimum-capture-2023-02-15--03-07-33

4. Add the Sheet Components

In this step, we'll be adding the BottomSheetModal and related components and setting up the touch-events to show/hide it.

Add the BottomSheetModalProvider

Since we will be using the BottomSheetModal component instead of BottomSheet, we will need to add a provider to your entry file.

./app/app.tsx
//...
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";

//...

return (
<SafeAreaProvider initialMetrics={initialWindowMetrics}>
<ErrorBoundary catchErrors={Config.catchErrors}>
<BottomSheetModalProvider>
<AppNavigator
linking={linking}
initialState={initialNavigationState}
onStateChange={onNavigationStateChange}
/>
</BottomSheetModalProvider>
</ErrorBoundary>
</SafeAreaProvider>
);

//...

Add the Necessary Components to SelectField

Now we will add the UI components that will display our options. This will be a basic example and can be customized as needed.

import {
BottomSheetBackdrop,
BottomSheetFlatList,
BottomSheetFooter,
BottomSheetModal,
} from "@gorhom/bottom-sheet";
import React, { forwardRef, Ref, useImperativeHandle, useRef } from "react";
import { TouchableOpacity, View, ViewStyle } from "react-native";
import type { ThemedStyle } from "app/theme";
import { useAppTheme } from "app/utils/useAppTheme";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { spacing } from "../theme";
import { Button } from "./Button";
import { Icon } from "./Icon";
import { ListItem } from "./ListItem";
import { TextField, TextFieldProps } from "./TextField";

export interface SelectFieldProps
extends Omit<TextFieldProps, "ref" | "onValueChange" | "onChange" | "value"> {
value?: string[];
renderValue?: (value: string[]) => string;
onSelect?: (newValue: string[]) => void;
multiple?: boolean;
options: { label: string; value: string }[];
}
export interface SelectFieldRef {
presentOptions: () => void;
dismissOptions: () => void;
}

export const SelectField = forwardRef(function SelectField(
props: SelectFieldProps,
ref: Ref<SelectFieldRef>
) {
const {
value = [],
onSelect,
renderValue,
options = [],
multiple = true,
...TextFieldProps
} = props;
const sheet = useRef<BottomSheetModal>(null);
const { bottom } = useSafeAreaInsets();

const { themed } = useAppTheme();

const disabled = TextFieldProps.editable === false || TextFieldProps.status === "disabled";

useImperativeHandle(ref, () => ({ presentOptions, dismissOptions }));

const valueString =
renderValue?.(value) ??
value
.map((v) => options.find((o) => o.value === v)?.label)
.filter(Boolean)
.join(", ");

function presentOptions() {
if (disabled) return;
sheet.current?.present();
}

function dismissOptions() {
sheet.current?.dismiss();
}

return (
<>
<TouchableOpacity
activeOpacity={1}
onPress={presentOptions}
>
<View pointerEvents="none">
<TextField
{...TextFieldProps}
value={valueString}
RightAccessory={(props) => <Icon icon="caretRight" containerStyle={props.style} />}
/>
</View>
</TouchableOpacity>

<BottomSheetModal
ref={sheet}
snapPoints={["50%"]}
stackBehavior="replace"
enableDismissOnClose
backdropComponent={(props) => (
<BottomSheetBackdrop {...props} appearsOnIndex={0} disappearsOnIndex={-1} />
)}
footerComponent={
!multiple
? undefined
: (props) => (
<BottomSheetFooter
{...props}
style={themed($bottomSheetFooter)}
bottomInset={bottom}
>
<Button text="Dismiss" preset="reversed" onPress={dismissOptions} />
</BottomSheetFooter>
)
}
>
<BottomSheetFlatList
style={{ marginBottom: bottom + (multiple ? spacing.xl * 2 : 0) }}
data={options}
keyExtractor={(o) => o.value}
renderItem={({ item, index }) => (
<ListItem text={item.label} topSeparator={index !== 0} style={themed($listItem)} />
)}
/>
</BottomSheetModal>
</>
);
});

const $bottomSheetFooter: ThemedStyle<ViewStyle> = ({ spacing }) => ({
paddingHorizontal: spacing.lg,
paddingBottom: spacing.xs,
});

const $listItem: ThemedStyle<ViewStyle> = ({ spacing }) => ({
paddingHorizontal: spacing.lg,
});
Demo Preview

yulolimum-capture-2023-02-15--04-38-11

5. Add Selected State to Options and Hook Up Callback

The last step is to add the selected state to our options inside the sheet as well as hook up the callback to change the value.

import {
BottomSheetBackdrop,
BottomSheetFlatList,
BottomSheetFooter,
BottomSheetModal,
} from "@gorhom/bottom-sheet";
import React, { forwardRef, Ref, useImperativeHandle, useRef } from "react";
import { TouchableOpacity, View, ViewStyle } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import type { ThemedStyle } from "app/theme";
import { useAppTheme } from "app/utils/useAppTheme";
import { Button } from "./Button";
import { Icon } from "./Icon";
import { ListItem } from "./ListItem";
import { TextField, TextFieldProps } from "./TextField";

export interface SelectFieldProps
extends Omit<TextFieldProps, "ref" | "onValueChange" | "onChange" | "value"> {
value?: string[];
renderValue?: (value: string[]) => string;
onSelect?: (newValue: string[]) => void;
multiple?: boolean;
options: { label: string; value: string }[];
}
export interface SelectFieldRef {
presentOptions: () => void;
dismissOptions: () => void;
}

function without<T>(array: T[], value: T) {
return array.filter((v) => v !== value);
}

export const SelectField = forwardRef(function SelectField(
props: SelectFieldProps,
ref: Ref<SelectFieldRef>
) {
const {
value = [],
onSelect,
renderValue,
options = [],
multiple = true,
...TextFieldProps
} = props;
const sheet = useRef<BottomSheetModal>(null);
const { bottom } = useSafeAreaInsets();
const {
themed,
theme: { colors },
} = useAppTheme();

const disabled = TextFieldProps.editable === false || TextFieldProps.status === "disabled";

useImperativeHandle(ref, () => ({ presentOptions, dismissOptions }));

const valueString =
renderValue?.(value) ??
value
.map((v) => options.find((o) => o.value === v)?.label)
.filter(Boolean)
.join(", ");

function presentOptions() {
if (disabled) return;

sheet.current?.present();
}

function dismissOptions() {
sheet.current?.dismiss();
}

function updateValue(optionValue: string) {
if (value.includes(optionValue)) {
onSelect?.(multiple ? without(value, optionValue) : []);
} else {
onSelect?.(multiple ? [...value, optionValue] : [optionValue]);
if (!multiple) dismissOptions();
}
}

return (
<>
<TouchableOpacity activeOpacity={1} onPress={presentOptions}>
<View pointerEvents="none">
<TextField
{...TextFieldProps}
value={valueString}
RightAccessory={(props) => <Icon icon="caretRight" containerStyle={props.style} />}
/>
</View>
</TouchableOpacity>

<BottomSheetModal
ref={sheet}
snapPoints={["50%"]}
stackBehavior="replace"
enableDismissOnClose
backdropComponent={(props) => (
<BottomSheetBackdrop {...props} appearsOnIndex={0} disappearsOnIndex={-1} />
)}
footerComponent={
!multiple
? undefined
: (props) => (
<BottomSheetFooter
{...props}
style={themed($bottomSheetFooter)}
bottomInset={bottom}
>
<Button text="Dismiss" preset="reversed" onPress={dismissOptions} />
</BottomSheetFooter>
)
}
>
<BottomSheetFlatList
style={{ marginBottom: bottom + (multiple ? spacing.xl * 2 : 0) }}
data={options}
keyExtractor={(o) => o.value}
renderItem={({ item, index }) => (
<ListItem
text={item.label}
topSeparator={index !== 0}
style={themed($listItem)}
rightIcon={value.includes(item.value) ? "check" : undefined}
rightIconColor={colors.palette.angry500}
onPress={() => updateValue(item.value)}
/>
)}
/>
</BottomSheetModal>
</>
);
});

const $bottomSheetFooter: ThemedStyle<ViewStyle> = ({ spacing }) => ({
paddingHorizontal: spacing.lg,
paddingBottom: spacing.xs,
});

const $listItem: ThemedStyle<ViewStyle> = ({ spacing }) => ({
paddingHorizontal: spacing.lg,
});

And we're done!

Demo Preview
import { SelectField } from "../components/SelectField";

const teams = [
{ label: "Hawks", value: "ATL" },
{ label: "Celtics", value: "BOS" },
// ...
{ label: "Jazz", value: "UTA" },
{ label: "Wizards", value: "WAS" },
];

function FavoriteNBATeamsScreen() {
const [selectedTeam, setSelectedTeam] = useState<string[]>([]);
const [selectedTeams, setSelectedTeams] = useState<string[]>([]);

return (
<>
<SelectField
label="NBA Team(s)"
helper="Select your team(s)"
placeholder="e.g. Knicks"
value={selectedTeam}
onSelect={setSelectedTeam}
options={teams}
multiple={false}
containerStyle={{ marginBottom: spacing.lg }}
/>

<SelectField
label="NBA Team(s)"
helper="Select your team(s)"
placeholder="e.g. Trail Blazers"
value={selectedTeams}
onSelect={setSelectedTeams}
options={teams}
containerStyle={{ marginBottom: spacing.lg }}
renderValue={(value) => `Selected ${value.length} Teams`}
/>
</>
);
}

yulolimum-capture-2023-02-15--05-11-11

Is this page still up to date? Did it work for you?