Implementing iOS picker in React Native — Part 1
A tutorial on implementing an iOS style picker, purely in react native using FlatList.
Picker is a common component used in mobile UX development, but its iOS and Android variants are very different. This can be an issue if you want your design to be consistent across these platforms in a react native app.
In this article we will implement a picker similar to the iOS variant, entirely in react native, using the FlatList component.
Overview of approach
Many of us would have used the FlatList component in react native for rendering a dynamic list of items. By using the Animated version of the same and by adding a few props like snapToInterval
, and doing a bit of math to get the selected item index, we would arrive at a fully functional picker, which might be simple, yet can do the trick
The design and colors used here might be simple, but they can be tweaked to suit your needs.
Listing Items
In this step, we will just render the list of items from which an item needs to be selected. No magic here, just pass items to FlatList & render them. We will create a new component called WheelPicker,
import React from 'react';
import {
FlatList,
ListRenderItemInfo,
StyleSheet,
Text,
View,
} from 'react-native';
interface Props {
items: string[];
onIndexChange: (index: number) => void;
itemHeight: number;
}
const WheelPicker: React.FC<Props> = props => {
const {items, onIndexChange, itemHeight} = props;
const renderItem = ({item}: ListRenderItemInfo<string>) => {
return (
<Text style={[styles.pickerItem, {height: itemHeight}]}>{item}</Text>
);
};
return (
<View style={{height: itemHeight * 3}}>
<FlatList
data={items}
renderItem={renderItem}
showsVerticalScrollIndicator={false}
/>
</View>
);
};
const styles = StyleSheet.create({
pickerItem: {
fontSize: 18,
fontWeight: '600',
textAlign: 'center',
color: '#000',
},
});
export default WheelPicker;
One prop to note here itemHeight
, which is passed to the component specifying height of each item in the picker. We are also limiting height of the component to 3 times the itemHeight, so that only 3 items are visible.
Adding Indicators for the selected item
Let’s add indicators so that the user can know which item is selected. The design of indicators can be changed accordingly to design needs.
// remaining code
// ...
return (
<View style={{height: itemHeight * 3}}>
<FlatList
data={items}
renderItem={renderItem}
showsVerticalScrollIndicator={false}
/>
<View style={[styles.indicatorHolder, {top: itemHeight}]}>
<View style={[styles.indicator]} />
<View style={[styles.indicator, {marginTop: itemHeight}]} />
</View>
</View>
);
};
const styles = StyleSheet.create({
// other styles
// ...
indicatorHolder: {
position: 'absolute',
},
indicator: {
width: 120,
height: 1,
backgroundColor: '#ccc',
},
});
Two things need fixing now:
- The first and last items are not selectable
- Scroll position can go in between 2 items
Modifying list and Snapping items
Let’s first add empty strings to the first and last index, so that the items in the original list are selectable by scrolling
...
const modifiedItems = ['', ...items, ''];
return (
<View style={{height: itemHeight * 3}}>
<FlatList
data={modifiedItems}
...
/>
...
</View>
...
)
Add snapToInterval as item height, so that items are snapped properly.
<FlatList
data={modifiedItems}
snapToInterval={itemHeight}
...
/>
First and last items are selectable and also items are getting snapped properly now.
Get selected item
We will be using onMomentumScrollEnd
prop of FlatList to get the scroll position and apply some math to get the selected item index.
...
const momentumScrollEnd = (
event: NativeSyntheticEvent<NativeScrollEvent>,
) => {
const y = event.nativeEvent.contentOffset.y;
const index = Math.round(y / itemHeight);
props.onIndexChange(index);
};
return (
<View style={{height: itemHeight * 3}}>
<FlatList
...other props
onMomentumScrollEnd={momentumScrollEnd}
/>
...
</View>
);
...
When the scroll ends, we get the y offset, and divide it by the item height to get the selected index. With this index, we are calling the props provided onIndexChange callback.
Adding animations
We will replace the normal FlatList with the Animated counterpart to use interpolation and animated scroll events to achieve an animation similar to the native iOS one.
...
const scrollY = useRef(new Animated.Value(0)).current;
const renderItem = ({item, index}: ListRenderItemInfo<string>) => {
const inputRange = [
(index - 2) * itemHeight,
(index - 1) * itemHeight,
index * itemHeight,
];
const scale = scrollY.interpolate({
inputRange,
outputRange: [0.8, 1, 0.8],
});
return (
<Animated.View
style={[
{height: itemHeight, transform: [{scale}]},
styles.animatedContainer,
]}>
<Text style={styles.pickerItem}>{item}</Text>
</Animated.View>
);
};
...
return (
<View style={{height: itemHeight * 3}}>
<Animated.FlatList
data={modifiedItems}
renderItem={renderItem}
showsVerticalScrollIndicator={false}
snapToInterval={itemHeight}
onMomentumScrollEnd={momentumScrollEnd}
scrollEventThrottle={16}
onScroll={Animated.event(
[{nativeEvent: {contentOffset: {y: scrollY}}}],
{useNativeDriver: true},
)}
/>
...
</View>
);
In the above code, we have done the following changes,
- replaced normal
FlatList
withAnimated.FlatList
- added
scrollY
as anAnimated.Value
- added
Animated.Event
toonScroll
gesture to map it to the animated value - wrapped text with a
Animated.View
- apply
scaleTransform
to theAnimated.View
byinterpolation
of scrollYAnimated.Value
Some optimizations
Since each item has a fixed layout size, it is better to implement getItemLayout
on the FlatList to have a performance improvement. Also, implement keyExtractor
callback which is required for a FlatList.
...
<Animated.FlatList
data={modifiedItems}
... other props
getItemLayout={(_, index) => ({
length: itemHeight,
offset: itemHeight * index,
index,
})}
/>
...
And, voilà!
With the complete code below, you must be able to get a beautiful iOS-style picker, which is simple yet does the trick.
import React, {useRef} from 'react';
import {
Animated,
ListRenderItemInfo,
NativeScrollEvent,
NativeSyntheticEvent,
StyleSheet,
Text,
View,
} from 'react-native';
interface Props {
items: string[];
onIndexChange: (index: number) => void;
itemHeight: number;
}
const WheelPicker: React.FC<Props> = props => {
const {items, onIndexChange, itemHeight} = props;
const scrollY = useRef(new Animated.Value(0)).current;
const renderItem = ({item, index}: ListRenderItemInfo<string>) => {
const inputRange = [
(index - 2) * itemHeight,
(index - 1) * itemHeight,
index * itemHeight,
];
const scale = scrollY.interpolate({
inputRange,
outputRange: [0.8, 1, 0.8],
});
return (
<Animated.View
style={[
{height: itemHeight, transform: [{scale}]},
styles.animatedContainer,
]}>
<Text style={styles.pickerItem}>{item}</Text>
</Animated.View>
);
};
const modifiedItems = ['', ...items, ''];
const momentumScrollEnd = (
event: NativeSyntheticEvent<NativeScrollEvent>,
) => {
const y = event.nativeEvent.contentOffset.y;
const index = Math.round(y / itemHeight);
onIndexChange(index);
};
return (
<View style={{height: itemHeight * 3}}>
<Animated.FlatList
data={modifiedItems}
renderItem={renderItem}
showsVerticalScrollIndicator={false}
snapToInterval={itemHeight}
onMomentumScrollEnd={momentumScrollEnd}
scrollEventThrottle={16}
onScroll={Animated.event(
[{nativeEvent: {contentOffset: {y: scrollY}}}],
{useNativeDriver: true},
)}
getItemLayout={(_, index) => ({
length: itemHeight,
offset: itemHeight * index,
index,
})}
/>
<View style={[styles.indicatorHolder, {top: itemHeight}]}>
<View style={[styles.indicator]} />
<View style={[styles.indicator, {marginTop: itemHeight}]} />
</View>
</View>
);
};
const styles = StyleSheet.create({
pickerItem: {
fontSize: 18,
fontWeight: '600',
textAlign: 'center',
textAlignVertical: 'center',
color: '#000',
},
indicatorHolder: {
position: 'absolute',
},
indicator: {
width: 120,
height: 1,
backgroundColor: '#ccc',
},
animatedContainer: {
justifyContent: 'center',
alignItems: 'center',
},
});
export default WheelPicker;
Wrapping Up
Animated.FlatList
is a heavy component and should be used with caution. Remember to memoize the component to prevent unwanted re-renders. There are some ready-made libraries available as well, but if the use case you are trying to solve is simple and want to try out some Animated
side of react-native, I guess this tutorial would have been a good reference material.