When working on a mobile user interface, there are many things to consider that you wouldn't for a PC. One of those things is adjusting the view's height, padding, or position to keep the target input visible when the keyboard pops up. This is a common issue mobile users encounter, and I assumed there would be an easy solution. I found KeyboardAvoidingView in the documentation, but it didn't work with my modal components. I checked out other implementations too, but none of them quite fit my needs.
I decided to create my own hook to solve this. I might have missed an existing solution, but I'm happy with the one I created. It works well enough for me. It works with View, ScrollView, and Modal.
I will show you some examples and the code at the end.
Examples
The FormInput
is a component that renders a label and an input inside a view.
import { Text, TextInput, View, StyleSheet } from 'react-native';
export default function FormInput({ num }: { num: number }) {
return (
<View>
<Text>Label {num}</Text>
<TextInput style={styles.textInput} />
</View>
);
}
const styles = StyleSheet.create({
textInput: {
width: 200,
height: 40,
borderColor: '#acacac',
borderRadius: 4,
borderWidth: 1,
},
});
ScrollView
import { ScrollView } from 'react-native';
import FormInput from './FormInput';
import useKeyboardAvoiding from '../hooks/useKeyboardAvoiding';
export default function FormWithKeyboardAvoidingHook() {
const { translateY } = useKeyboardAvoiding();
return (
<ScrollView
style={{
flex: 1,
backgroundColor: 'white',
transform: [{ translateY }],
}}
>
<FormInput num={1} />
<FormInput num={2} />
<FormInput num={3} />
<FormInput num={4} />
<FormInput num={5} />
<FormInput num={6} />
<FormInput num={7} />
<FormInput num={8} />
<FormInput num={9} />
<FormInput num={10} />
<FormInput num={11} />
<FormInput num={12} />
<FormInput num={13} />
<FormInput num={14} />
<FormInput num={15} />
</ScrollView>
);
}
View
import { View } from 'react-native';
import FormInput from './FormInput';
import useKeyboardAvoiding from '../hooks/useKeyboardAvoiding';
export default function NormalViewWithKeyboardAvoidingHook() {
const { translateY } = useKeyboardAvoiding();
return (
<View
style={{
flex: 1,
backgroundColor: 'white',
transform: [{ translateY }],
}}
>
<FormInput num={1} />
<FormInput num={2} />
<FormInput num={3} />
<FormInput num={4} />
<FormInput num={5} />
<FormInput num={6} />
<FormInput num={7} />
<FormInput num={8} />
<FormInput num={9} />
<FormInput num={10} />
<FormInput num={11} />
</View>
);
}
ScrollView with Animation
import { Animated } from 'react-native';
import FormInput from './FormInput';
import useKeyboardAvoiding from '../hooks/useKeyboardAvoiding';
export default function AnimatedFormWithKeyboardAvoidingHook() {
const { animatedTranslateY } = useKeyboardAvoiding();
return (
<Animated.ScrollView
style={{
flex: 1,
backgroundColor: 'white',
transform: [{ translateY: animatedTranslateY }],
}}
>
<FormInput num={1} />
<FormInput num={2} />
<FormInput num={3} />
<FormInput num={4} />
<FormInput num={5} />
<FormInput num={6} />
<FormInput num={7} />
<FormInput num={8} />
<FormInput num={9} />
<FormInput num={10} />
<FormInput num={11} />
<FormInput num={12} />
<FormInput num={13} />
<FormInput num={14} />
<FormInput num={15} />
</Animated.ScrollView>
);
}
To enable animation, you should use an Animated
View and the animatedTranslateY
instead of translateY
. The animation duration is configurable, and I will cover that later.
Modal
import { StyleSheet, Modal, View, Button, Animated } from 'react-native';
import FormInput from './FormInput';
import useKeyboardAvoiding from '../hooks/useKeyboardAvoiding';
export default function ModalWithKeyboardAvoidingHook({
onClose,
}: {
onClose: VoidFunction;
}) {
const { animatedTranslateY } = useKeyboardAvoiding();
return (
<Modal>
<View style={styles.container}>
<View style={styles.modal}>
<Animated.ScrollView
style={{
flex: 1,
transform: [{ translateY: animatedTranslateY }],
}}
>
<FormInput num={1} />
<FormInput num={2} />
<FormInput num={3} />
<FormInput num={4} />
<FormInput num={5} />
<FormInput num={6} />
<FormInput num={7} />
<FormInput num={8} />
<FormInput num={9} />
<FormInput num={10} />
<FormInput num={11} />
<FormInput num={12} />
<FormInput num={13} />
<FormInput num={14} />
<FormInput num={15} />
</Animated.ScrollView>
<View style={styles.footer}>
<Button title="close" onPress={onClose} />
</View>
</View>
</View>
</Modal>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
modal: {
width: 300,
height: 450,
borderRadius: 4,
borderWidth: 1,
overflow: 'hidden',
},
footer: {
height: 40,
borderTopWidth: 1,
justifyContent: 'center',
alignItems: 'center',
},
});
Implementation
import { createContext, ReactNode, useEffect, useRef, useState } from 'react';
import { Animated, Keyboard, TextInput } from 'react-native';
interface KeyboardAvoidingContextType {
translateY: number;
animatedTranslateY: Animated.Value;
}
export const KeyboardAvoidingContext =
createContext<KeyboardAvoidingContextType>({} as KeyboardAvoidingContextType);
export const KeyboardAvoidingProvider = ({
children,
padding = 0,
duration = 250,
}: {
children: ReactNode;
padding?: number;
duration?: number;
}) => {
const [translateY, setTranslateY] = useState(0);
const animatedTranslateY = useRef<Animated.Value>(
new Animated.Value(0)
).current;
useEffect(() => {
const keyboardUpListener = Keyboard.addListener('keyboardWillShow', (e) => {
const { State: TextInputState } = TextInput;
const focusedInput = TextInputState.currentlyFocusedInput();
focusedInput?.measure((_x, _y, _width, height, _pageX, pageY) => {
const keyboardStartY = e.endCoordinates.screenY;
const bottomY = height + pageY;
const translateY =
bottomY <= keyboardStartY ? 0 : -(bottomY - keyboardStartY + padding);
setTranslateY(translateY);
});
});
const keyboardDownListener = Keyboard.addListener(
'keyboardWillHide',
() => {
setTranslateY(0);
}
);
return () => {
keyboardUpListener.remove();
keyboardDownListener.remove();
};
}, []);
useEffect(() => {
Animated.timing(animatedTranslateY, {
toValue: translateY,
duration,
useNativeDriver: true,
}).start();
}, [translateY]);
const value = { translateY, animatedTranslateY };
return (
<KeyboardAvoidingContext.Provider value={value}>
{children}
</KeyboardAvoidingContext.Provider>
);
};
I implemented this using Context to prevent generating new events every time the hook is called. This means you will need to wrap the components that use the hook with the KeyboardAvoidingProvider
.
It detects when the keyboard is up using keyboardWillShow
and when it's down using keyboardWillHide
. You can find more details in the documentation.
I initially used keyboardDidShow
and keyboardDidHide
because these were the events I found during my research. However, they were too slow to respond, and I discovered events that trigger before the keyboard actually shows or hides.
Once the keyboard is detected, it retrieves the currently focused input component usingTextInput.State.currentlyFocusedInput
. Then, by calling the measure function, it gets the absolute position of the component on the screen and then calculates the translationY
based on the keyboard's Y position.
The hook provides the translateY
as both a state value and an animated value. The padding
(the space between the keyboard and the component) and duration
(the time for the animation) are both configurable.
To use this, simply wrap your components with the provider like this.
export default function App() {
const [screen, setScreen] = useState(0);
return (
<KeyboardAvoidingProvider padding={24}>
<SafeAreaView style={{ flex: 1 }}>
<View style={styles.tools}>
<Button title="AvoidingHook" onPress={() => setScreen(0)} />
<Button title="WithoutScroll" onPress={() => setScreen(1)} />
<Button title="Animated" onPress={() => setScreen(2)} />
<Button title="Modal" onPress={() => setScreen(3)} />
</View>
<View style={styles.main}>
{screen === 0 && <FormWithKeyboardAvoidingHook />}
{screen === 1 && <NormalViewWithKeyboardAvoidingHook />}
{screen === 2 && <AnimatedFormWithKeyboardAvoidingHook />}
{screen === 3 && (
<ModalWithKeyboardAvoidingHook onClose={() => setScreen(0)} />
)}
</View>
</SafeAreaView>
</KeyboardAvoidingProvider>
);
}
Wrap Up
The goal of this implementation was simplicity, and I believe it's quite easy to use. Just wrap components and use hooks. Additionally, make sure to apply overflow: hidden
to the parent of the target view. As translationY
changes, the component may move outside its parent.
I hope you found it helpful.
Happy Coding!