DEV Community

Cover image for A simple, reusable keyboard avoiding animation hook, with TypeScript & Reanimated V2!
Karl Marx Lopez
Karl Marx Lopez

Posted on

A simple, reusable keyboard avoiding animation hook, with TypeScript & Reanimated V2!

If you are developing app in React Native for quite some time now, you probably noticed that one of the most hard to work with component is the KeyboardAvoidingView.

Even the pros are guilty about this, here's a 1-on-1 talk of William Candillon and Jonny burger on the challenges on working with KeyboardAvoidingView.

There are packages like react-native-keyboard-aware-scrollview (just in case you are not "aware") that automatically scrolls to the focused TextInput component. But, in your use case, that is not enough. You probably want to push an element on top of the soft-keyboard or change style when it is visible or hidden.

Well, you've come to the right place! Today, I'll show you how to do it!

If you are new to react-native-reanimated v2, I recommend this amazing video!

I recommend to type the code instead of just copy and pasting, it will help you in learning. But if you are impatient, you can go ahead and check the final result and then copy and paste the example usage.

Let's get started!

Boilerplate

Copy and paste this boilerplate code to speed things up.

import {useCallback, useEffect} from 'react';
import {Keyboard, KeyboardEventListener, ScreenRect} from 'react-native';
import Animated, {
  useAnimatedStyle,
  useSharedValue,
  withTiming,
} from 'react-native-reanimated';

export const useKeyboardAvoiding = () => {

};
Enter fullscreen mode Exit fullscreen mode

Animated shared values

We need a way to track the keyboard visibility state and coordinates, so we will add two reanimated shared values. Shared values are "shared" because they run on the UI and JS thread. If you are not familiar with this, RN docs has a nice overview on this topic.

keyboardVisible, as the name implies, will hold the visibility state of the keyboard.

keyboardEndCoordinates will hold the coordinates, like x & y axis, and height after the keyboard is displayed or hidden.

Add these variables inside the hook:

// other codes from prev step
export const useKeyboardAvoiding = () => {
  const keyboardVisible = useSharedValue(false);
  const keyboardEndCoordinates = useSharedValue<ScreenRect | null>(null);
};
Enter fullscreen mode Exit fullscreen mode

Listening to keyboard events

Now, let's add the keyboardWillChangeFrame and keyboardWillHide listeners.

We will add them on mount, and remove the listeners on unmount using useEffect and then wrap the callbacks with useCallback.

Listening to keyboardWillChangeFrame:

export const useKeyboardAvoiding = () => {
  // other codes from prev step
  const handleKeyboardWillChangeFrame = useCallback<KeyboardEventListener>(
    () => {},
    [],
  );

  useEffect(() => {
    const emitter = Keyboard.addListener(
      'keyboardWillChangeFrame',
      handleKeyboardWillChangeFrame,
    );

    return () => emitter.remove();
  }, [handleKeyboardWillChangeFrame]);

};
Enter fullscreen mode Exit fullscreen mode

Listening to keyboardWillHide:

export const useKeyboardAvoiding = () => {
  // other codes from prev step
  const handleKeyboardWillHide = useCallback<KeyboardEventListener>(
    () => {},
    [],
  );

  useEffect(() => {
    const emitter = Keyboard.addListener(
      'keyboardWillHide',
      handleKeyboardWillHide,
    );

    return () => emitter.remove();
  }, [handleKeyboardWillHide]);

};
Enter fullscreen mode Exit fullscreen mode

Handling keyboard events

We will now store the keyboard state into the shared values.

In order to assign a value to the shared values, we will need to assign it to the value property.

Modify the handleKeyboardWillChangeFrame and handleKeyboardWillHide callbacks:

  const handleKeyboardWillChangeFrame = useCallback<KeyboardEventListener>(
    ({endCoordinates}) => {
      keyboardVisible.value = true;
      keyboardEndCoordinates.value = endCoordinates;
    },
    [keyboardEndCoordinates.value, keyboardVisible.value],
  );


  const handleKeyboardWillHide = useCallback<KeyboardEventListener>(
    ({endCoordinates}) => {
      keyboardVisible.value = false;
      keyboardEndCoordinates.value = endCoordinates;
    },
    [keyboardEndCoordinates.value, keyboardVisible.value],
  );
Enter fullscreen mode Exit fullscreen mode

Animating the style

And now, the most exciting part of the tutorial!

Let's create an animated style based on the shared values. To create an animated style in reanimated v2, we will use the useAnimatedStyle hook. We will also use the withTiming higher-order function to nicely animate the transition.

By default, we will animate the bottom style, but we will implement a way to customize this later.

We will use 90% of the keyboard height. It seems to have an extra offset to the height that will cause the component being animated to be pushed further from the top of the keyboard.

// add this below the shared value declarations
// and before any of the callbacks

  const animatedStyle = useAnimatedStyle(() => {
    const kbHeight = keyboardEndCoordinates.value?.height ?? 0;

    return {
      bottom: withTiming(keyboardVisible.value ? kbHeight * 0.9 : 0),
    };
  }, [keyboardEndCoordinates, keyboardVisible]);
Enter fullscreen mode Exit fullscreen mode

This is now ready to be used. But, it's not very flexible because it is fixed to animate the bottom style. The next step is to add a way to customize this.

Customizing the animated style

The final step is to allow the hook to accept a custom function that returns the custom style. We need to use a worklet function in order for this to work.

A worklet function is run on the UI thread by reanimated V2. We will need to add a 'worklet' directive at the very top of the function block.

Let's modify the hook to accept a animatedStyleWorkletFn function and then we will use it inside the useAnimatedStyle hook

// imported files are here

export type KeyboardAvoidingStyleWorkletFn = (
  endCoordinates: Animated.SharedValue<ScreenRect | null>,
  isVisible: Animated.SharedValue<boolean>,
) => ReturnType<typeof useAnimatedStyle>;


export const useKeyboardAvoiding = (
  animatedStyleWorkletFn?: KeyboardAvoidingStyleWorkletFn,
) => {
  const keyboardVisible = useSharedValue(false);
  const keyboardEndCoordinates = useSharedValue<ScreenRect | null>(null);

  const animatedStyle = useAnimatedStyle(() => {
    const kbHeight = keyboardEndCoordinates.value?.height ?? 0;

    return animatedStyleWorkletFn
      ? animatedStyleWorkletFn(keyboardEndCoordinates, keyboardVisible)
      : {
          bottom: withTiming(keyboardVisible.value ? kbHeight * 0.9 : 0),
        };
  }, [animatedStyleWorkletFn, keyboardEndCoordinates, keyboardVisible]);


   // callbacks are here

   return {keyboardVisible, keyboardEndCoordinates, animatedStyle};
};
Enter fullscreen mode Exit fullscreen mode

Final result

import React from 'react';
import {useCallback, useEffect} from 'react';
import {
  Keyboard,
  KeyboardEventListener,
  ScreenRect,
  StyleSheet,
  TextInput,
  View,
  Button,
} from 'react-native';
import Animated, {
  useAnimatedStyle,
  useSharedValue,
  withTiming,
} from 'react-native-reanimated';

export type KeyboardAvoidingStyleWorkletFn = (
  endCoordinates: Animated.SharedValue<ScreenRect | null>,
  isVisible: Animated.SharedValue<boolean>,
) => ReturnType<typeof useAnimatedStyle>;

export const useKeyboardAvoiding = (
  animatedStyleWorkletFn?: KeyboardAvoidingStyleWorkletFn,
) => {
  const keyboardVisible = useSharedValue(false);
  const keyboardEndCoordinates = useSharedValue<ScreenRect | null>(null);

  const animatedStyle = useAnimatedStyle(() => {
    const kbHeight = keyboardEndCoordinates.value?.height ?? 0;

    return animatedStyleWorkletFn
      ? animatedStyleWorkletFn(keyboardEndCoordinates, keyboardVisible)
      : {
          bottom: withTiming(keyboardVisible.value ? kbHeight * 0.9 : 0),
        };
  }, [animatedStyleWorkletFn, keyboardEndCoordinates, keyboardVisible]);

  const handleKeyboardWillChangeFrame = useCallback<KeyboardEventListener>(
    ({endCoordinates}) => {
      keyboardVisible.value = true;
      keyboardEndCoordinates.value = endCoordinates;
    },
    [keyboardEndCoordinates, keyboardVisible],
  );

  const handleKeyboardWillHide = useCallback<KeyboardEventListener>(
    ({endCoordinates}) => {
      keyboardVisible.value = false;
      keyboardEndCoordinates.value = endCoordinates;
    },
    [keyboardEndCoordinates, keyboardVisible],
  );

  useEffect(() => {
    const emitter = Keyboard.addListener(
      'keyboardWillChangeFrame',
      handleKeyboardWillChangeFrame,
    );

    return () => emitter.remove();
  }, [handleKeyboardWillChangeFrame]);

  useEffect(() => {
    const emitter = Keyboard.addListener(
      'keyboardWillHide',
      handleKeyboardWillHide,
    );

    return () => emitter.remove();
  }, [handleKeyboardWillHide]);

  return {keyboardVisible, keyboardEndCoordinates, animatedStyle};
};

export const DefaultStyle = () => {
  const {animatedStyle} = useKeyboardAvoiding();

  return (
    <View style={styles.containerStyle}>
      <TextInput multiline style={styles.input} />
      <Animated.View style={animatedStyle}>
        <Button title="Save" onPress={() => {}} />
      </Animated.View>
    </View>
  );
};

export const CustomStyle = () => {
  const {animatedStyle} = useKeyboardAvoiding((endCoords, isVisible) => {
    'worklet';
    const height = endCoords.value?.height ?? 0;

    return {
      marginBottom: withTiming(isVisible.value ? height * 0.7 : 0),
    };
  });

  return (
    <Animated.View style={[styles.containerStyle, animatedStyle]}>
      <TextInput multiline style={styles.input} />
      <Button title="Save" onPress={() => {}} />
    </Animated.View>
  );
};

const styles = StyleSheet.create({
  containerStyle: {
    flex: 1,
  },
  input: {
    height: 500,
  },
});

Enter fullscreen mode Exit fullscreen mode

I hope you enjoyed this tutorial! This is my first tech blog so I apologize for the rough edges. If you have any tutorial suggestions, please comment down below. Thank you!

Oldest comments (1)

Collapse
 
skyline674 profile image
Denis

Thank you so much! It's been a while since you posted this, but it really helped me right now, so thanks again, man.
I hope you doing well :)