Five years ago, I created react-native-textinput-effects library. And the Animated
library behind that has not changed much since then. So I'm going to use the same principles, and build a new React Native TextInput
component from scratch.
I'll implement the material-ui
animation this time. It's a simple one so I think it's a useful example to understand the basics of animation in React Native.
You may find the full version of this component on Github. I also managed to port the React Native code into the web thanks to react-native-web. You can play with it on my blog.
As you may see in the preview, the TextField has four main UI states that we are going to implement:
- Empty and unfocused state
- Focused state
- Filled and unfocused state
- Error state
Starting with a basic TextField
Let's start with a basic TextField
that extends react-native TextInput and styles it.
import React from 'react';
import { StyleSheet, TextInput } from 'react-native';
// extend from native TextInput props
type Props = React.ComponentProps<typeof TextInput>;
const TextField: React.FC<Props> = (props) => {
/*
** spread operator helps to extract style prop and assign
** any remaining props to the `restOfProps` variable.
** it is pretty handy here as we need to support
** all the props the native TextInput component has.
*/
const { style, ...restOfProps } = props;
return <TextInput style={[style, styles.input]} {...restOfProps} />;
};
const styles = StyleSheet.create({
input: {
padding: 24,
borderColor: '#B9C4CA',
borderWidth: 1,
borderRadius: 4,
fontFamily: 'Avenir-Medium',
fontSize: 16
}
});
export default TextField;
The output is a simple TextInput with a border and placeholder text.
<TextField placeholder="Cardholder name" />
Creating the label
placeholder
prop plays the label role only when the input is empty. This isn't enough for our case so we need to create our custom label that'll be displayed on top of the input.
Wrapping the TextInput with a parent View
and creating another View
as a sibling for the label would give us lots of room to customize our label. We'll use position: absolute
style for the label to make sure it's located on top of the TextInput
.
Note that I've extended the native TextInput
component props with a new prop called label
which will be unique to the TextField
.
-type Props = React.ComponentProps<typeof TextInput>
+type Props = React.ComponentProps<typeof TextInput> & {
+ label: string
+}
const TextField: React.FC<Props> = (props) => {
- const { style, ...restOfProps } = props
+ const { label, style, ...restOfProps } = props
return (
- <TextInput
- style={[style, styles.input]}
- {...restOfProps}
- />
+ <View style={style}>
+ <TextInput style={styles.input} {...restOfProps} />
+ <View style={styles.labelContainer}>
+ <Text style={styles.label}>{label}</Text>
+ </View>
+ </View>
)
}
const styles = StyleSheet.create({
+ labelContainer: {
+ position: 'absolute',
+ left: 16,
+ top: -6,
+ paddingHorizontal: 8,
+ backgroundColor: 'white',
+ },
+ label: {
+ fontFamily: 'Avenir-Heavy',
+ fontSize: 12,
+ },
The TextField
looks like this now:
Positioning the label based on focused state
The label needs to move between the center and top of the input depending on the focused state. Let's start with simply positioning the label based on an internal isFocused
state without any animation.
We may listen TextInput
s onBlur
and onFocus
methods and modify our isFocused
state based on them. And manipulating the top
style of our label based on isFocused
state will be enough to re-position the label. We'll also modify the label font size and color.
const TextField: React.FC<Props> = (props) => {
const {
label,
style,
+ onBlur,
+ onFocus,
...restOfProps
} = props
+ const [isFocused, setIsFocused] = useState(false)
return (
<View style={style}>
- <TextInput style={styles.input} {...restOfProps} />
- <View style={styles.labelContainer}>
- <Text style={styles.label}>{label}</Text>
+ <TextInput
+ style={styles.input}
+ {...restOfProps}
+ onBlur={(event) => {
+ setIsFocused(false)
+ onBlur?.(event)
+ }}
+ onFocus={(event) => {
+ setIsFocused(true)
+ onFocus?.(event)
+ }}
+ />
+ <View
+ style={[
+ styles.labelContainer,
+ {
+ top: isFocused ? -6 : 24,
+ },
+ ]}
+ >
+ <Text
+ style={[
+ styles.label,
+ {
+ fontSize: isFocused ? 12 : 16,
+ color: isFocused ? '#080F9C' : '#B9C4CA',
+ },
+ ]}
+ >
+ {label}
+ </Text>
</View>
</View>
Animating the label
We now have a label that positions itself based on the focused state. React Native has a built-in Animated
component that lets you build animations and that's good enough to support our simple animation. We will create an Animated.Value
to represent the focused state and interpolate that to label positioning styles.
Animated.Value
accepts a number parameter so we need to express our isFocused
state with a number. I'm going to use 0 for the unfocused and 1 for the focused state.
const [isFocused, setIsFocused] = useState(false)
+ const focusAnim = useRef(new Animated.Value(0)).current
+
+ /*
+ ** This effect will trigger the animation every
+ ** time `isFocused` value changes.
+ */
+ useEffect(() => {
+ Animated.timing(focusAnim, {
+ toValue: isFocused ? 1 : 0,
+ // I took duration and easing values
+ // from material.io demo page
+ duration: 150,
+ easing: Easing.bezier(0.4, 0, 0.2, 1),
+ // we'll come back to this later
+ useNativeDriver: false,
+ }).start()
+ }, [focusAnim, isFocused])
+
return (
<View style={style}>
- <View
+ <Animated.View
style={[
styles.labelContainer,
{
- top: isFocused ? -6 : 24,
+ top: focusAnim.interpolate({
+ inputRange: [0, 1],
+ outputRange: [24, -6],
+ }),
},
]}
>
- <Text
+ <Animated.Text
style={[
styles.label,
{
- fontSize: isFocused ? 12 : 16,
+ fontSize: focusAnim.interpolate({
+ inputRange: [0, 1],
+ outputRange: [16, 12],
+ }),
color: isFocused ? '#080F9C' : '#B9C4CA',
},
]}
>
{label}
- </Text>
- </View>
+ </Animated.Text>
+ </Animated.View>
</View>
)
}
Using the native driver
Our animation works perfectly right now. But there is one more thing we can do to make it more smooth on lower-end devices by passing useNativeDriver
parameter to the Animated
API.
Here is the description from React Native documentation:
By using the native driver, we send everything about the animation to native before starting the animation, allowing native code to perform the animation on the UI thread without having to go through the bridge on every frame. Once the animation has started, the JS thread can be blocked without affecting the animation.
The problem is: the native driver can work with a limited set of properties such as transform
and opacity
. So it doesn't work with top
and fontSize
properties and we need to replace them with supported properties. Animated
throws an exception when you set useNativeDriver: true
:
Fortunately, transform
can create the same animation behavior here. We'll use its scale
property to replace the fontSize
animation, and translateY
to move the label. Unfortunately, using scale
transform causes the label to move on the x-axis. The only solution I could find to fix it was creating an extra translateX
transform and undo the x-axis movement by manipulating it manually.
style={[
styles.labelContainer,
{
- top: focusAnim.interpolate({
- inputRange: [0, 1],
- outputRange: [24, -6],
- }),
+ transform: [
+ {
+ scale: focusAnim.interpolate({
+ inputRange: [0, 1],
+ outputRange: [1, 0.75],
+ }),
+ },
+ {
+ translateY: focusAnim.interpolate({
+ inputRange: [0, 1],
+ outputRange: [24, -12],
+ }),
+ },
+ {
+ translateX: focusAnim.interpolate({
+ inputRange: [0, 1],
+ outputRange: [16, 0],
+ }),
+ },
+ ],
},
]}
>
- <Animated.Text
+ <Text
style={[
styles.label,
{
- fontSize: focusAnim.interpolate({
- inputRange: [0, 1],
- outputRange: [16, 12],
- }),
color: isFocused ? '#080F9C' : '#B9C4CA',
},
]}
>
{label}
- </Animated.Text>
+ </Text>
</Animated.View>
</View>
)
You can now start using the native driver by passing useNativeDriver: true
to Animated
.
Creating the errored state
This is the final TextField state we need to support. We'll simply define a new prop called errorText
and modify the label and border-color when that prop is not empty.
type Props = React.ComponentProps<typeof TextInput> & {
label: string
+ errorText?: string | null
}
const TextField: React.FC<Props> = (props) => {
const {
label,
+ errorText,
style,
onBlur,
onFocus,
...restOfProps
} = props
+ let color = isFocused ? '#080F9C' : '#B9C4CA'
+ if (errorText) {
+ color = '#B00020'
+ }
+
return (
<View style={style}>
<TextInput
- style={styles.input}
+ style={[
+ styles.input,
+ {
+ borderColor: color,
+ },
+ ]}
{...restOfProps}
onBlur={(event) => {
setIsFocused(false)
@@ -72,13 +83,15 @@ const TextField: React.FC<Props> = (props) => {
style={[
styles.label,
{
- color: isFocused ? '#080F9C' : '#B9C4CA',
+ color,
},
]}
>
{label}
+ {errorText ? '*' : ''}
</Text>
</Animated.View>
+ {!!errorText && <Text style={styles.error}>{errorText}</Text>}
</View>
)
}
const styles = StyleSheet.create({
+ error: {
+ marginTop: 4,
+ marginLeft: 12,
+ fontSize: 12,
+ color: '#B00020',
+ fontFamily: 'Avenir-Medium',
+ },
})
Final touches
TextField looks great now but there are a few minor problems we should fix.
The first problem is: The text we enter disappears when isFocused: false
. So we need to make sure we're always positioning the label at the top when the input value is not empty:
const {
label,
errorText,
+ value,
style,
onBlur,
onFocus,
...restOfProps
} = props
const [isFocused, setIsFocused] = useState(false)
const focusAnim = useRef(new Animated.Value(0)).current
useEffect(() => {
Animated.timing(focusAnim, {
- toValue: isFocused ? 1 : 0,
+ toValue: isFocused || !!value ? 1 : 0,
duration: 150,
easing: Easing.bezier(0.4, 0, 0.2, 1),
useNativeDriver: true,
}).start()
- }, [focusAnim, isFocused])
+ // make sure you are passing `value` to the dependency array
+ // so the effect will be run anytime the value changes.
+ }, [focusAnim, isFocused, value]
The second problem is happening when you click on the label on empty input. React Native doesn't trigger the keyboard here as the clicked element is just a Text
rather than a TextInput
. So we need to turn the label into a button and trigger the input focus event manually:
// create an input ref
const inputRef = useRef<TextInput>(null)
// pass `inputRef` to the TextInput
<TextInput ref={inputRef} />
// wrap label View with `TouchableWithoutFeedback`
<TouchableWithoutFeedback onPress={() => inputRef.current?.focus()}>
Output
Here is the gif preview of the TextField
:
And again, you can find the full version on Github.
Top comments (10)
I'm on the phone an I cannot try now: did you test it with Chrome's autofill (the light yellow one, for logins and personal information)? If I remember correctly, the form isn't in state "filled" and yet has content shown, and I've often see it disrupt this kind of effects.
Precog bug report 😁
Thanks for the feedback Franco. I actually didn't think that through as I intended this for react-native use. But it might still be used on the web with react-native-web.
So I checked it over the demo on my blog(bilir.me/blog/creating-an-animated...) and it the background color doesn't match when the user auto completes the input:
This should be easily fixed if there is a way to understand the form is autocompleted. Not sure about it though.
I've completely missed the "native" in the title! My fault 😂
Very nice and useful articles, thanks Halil ;)
Thank you for the feedback Michael!
Very interesting, nice job Halil, thanks for sharing it!
Thanks a lot Adolfo :)
Nice post! very clear ;)
Great! Don't you think to have it as npm package with some enhancements on props ?
Interesting idea. I could actually do that after making it feature complete according to the material-ui document. Thanks a lot for the feedback!