Custom useKeyboardAvoiding Hook: Adjusting View Translation Based on Keyboard Height in Expo/React Native

SeongKuk Han - Sep 7 - - Dev Community

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,
  },
});
Enter fullscreen mode Exit fullscreen mode

ScrollView

example_with_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>
  );
}
Enter fullscreen mode Exit fullscreen mode

View

example_with_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>
  );
}
Enter fullscreen mode Exit fullscreen mode

ScrollView with Animation

example_with_animatino

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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

example_with_view

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',
  },
});
Enter fullscreen mode Exit fullscreen mode

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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!

Github Source Code

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .