2

What I'm trying to do

I am trying to create an animated spacing/padding element that changes height when the Keyboard is shown or hidden in order to ensure a TextInput is not covered by the Keyboard or by a button that avoids the keyboard using KeyboardAvoidingView. I only want this animated space to change height if the button is going to cover the input—otherwise, I do not want the spacing to change height. This is a design requirement.

My current solution

I was able to previously achieve this using the Animated API from react-native, however I wanted to use react-native-reanimated to get the performance benefit of running everything on the UI thread. I actually have a working solution, however the UI thread drops to mid-50 fps during the animation, so I'm assuming I'm doing something wrong.

As you'll see in the code below, I am calculating the height of all elements to find out if the button that is anchored to the top of the keyboard is overlapping the TextInput. If so, I subtract the overlap amount from the height of the spacing above the text (the animHeaderHeight). You should be able to copy paste this code and run it. If you turn on the profiler and watch the UI thread, toggle the animation by focusing the input and tapping return to dismiss it. The animation works, but it causes UI thread to run below 60fps.

Reproducible code

I bootstrapped the project with expo init. Here are the package versions:

"expo": "^35.0.0",
"expo-constants": "~7.0.0",
"react": "16.8.3",
"react-native": "https://github.com/expo/react-native/archive/sdk-35.0.0.tar.gz",
"react-native-gesture-handler": "~1.3.0",
"react-native-reanimated": "~1.2.0",

Here is the code.

import React, { useEffect, useRef } from "react";
import {
  View,
  Text,
  TouchableOpacity,
  StyleSheet,
  Keyboard,
  KeyboardEvent,
  KeyboardEventName,
  Platform,
  TextInput,
  SafeAreaView,
  KeyboardAvoidingView,
  LayoutChangeEvent,
  Dimensions
} from "react-native";
import Animated, { Easing } from "react-native-reanimated";
import Constants from "expo-constants";

const DEVICE_HEIGHT = Dimensions.get("screen").height;
const STATUS_BAR_HEIGHT = Constants.statusBarHeight;
const HEADER_HEIGHT = 100;
const MAX_ANIMATED_HEIGHT = 75;
const BOTTOM_BUTTON_HEIGHT = 60;
const KEYBOARD_EASING = Easing.bezier(0.38, 0.7, 0.125, 1.0);

const {
  Value,
  Clock,
  set,
  block,
  cond,
  eq,
  and,
  neq,
  add,
  sub,
  max,
  startClock,
  stopClock,
  timing,
  interpolate
} = Animated;

export default App = () => {
  // These refs are used so the height calculations are only called once and don't cause re-renders
  const wasKeyboardMeasured = useRef(false);
  const wasContentMeasured = useRef(false);

  const clock = new Clock();
  const keyboardShown = new Value(-1);
  const animKeyboardHeight = new Value(0);
  const animContentHeight = new Value(0);

  function handleLayout(e) {
    if (!wasContentMeasured.current) {
      // Set animated value and set ref measured flag true
      const height = Math.floor(e.nativeEvent.layout.height);
      wasContentMeasured.current = true;
      animContentHeight.setValue(height);
    }
  }

  useEffect(() => {
    const handleKbdShow = (e: KeyboardEvent) => {
      if (!wasKeyboardMeasured.current) {
        // Set animated value and set ref measured flag true
        const kbdHeight = Math.floor(e.endCoordinates.height);
        wasKeyboardMeasured.current = true;
        animKeyboardHeight.setValue(kbdHeight);
      }
      keyboardShown.setValue(1);
    };
    const handleKbdHide = () => {
      keyboardShown.setValue(0
);
    };

    const kbdWillOrDid = Platform.select({ ios: "Will", android: "Did" });
    const showEventName = `keyboard${kbdWillOrDid}Show`;
    const hideEventName = `keyboard${kbdWillOrDid}Hide`;

    Keyboard.addListener(showEventName, handleKbdShow);
    Keyboard.addListener(hideEventName, handleKbdHide);

    return () => {
      Keyboard.removeListener(showEventName, handleKbdShow);
      Keyboard.removeListener(hideEventName, handleKbdHide);
    };
  }, []);

  const animHeaderHeight = runTiming(
    clock,
    keyboardShown,
    animContentHeight,
    animKeyboardHeight
  );

  return (
    <SafeAreaView style={styles.container}>
      <KeyboardAvoidingView style={styles.container} behavior="padding">
        <View style={styles.header}>
          <Text style={styles.headerText}>Header</Text>
        </View>
        <Animated.View
          style={[styles.animatedSpace, { height: animHeaderHeight }]}
        />
        <View onLayout={handleLayout}>
          <View style={styles.heading}>
            <Text style={styles.headingText}>
              Note: CHANGE THIS TEXT CONTENT TO WHATEVER LENGTH MAKES THE BOTTOM
              BUTTON OVERLAP THE TEXT INPUT WHEN THE KEYBOARD IS SHOWN! Lorem
              ipsum dolor sit amet, consectetur adipiscing elit.
            </Text>
          </View>
          <View style={styles.textInputContainer}>
            <TextInput style={styles.textInput} autoFocus={true} />
          </View>
        </View>
        <TouchableOpacity style={styles.bottomButton} />
      </KeyboardAvoidingView>
    </SafeAreaView>
  );
};

function runTiming(
  clock,
  keyboardShown,
  animContentHeight,
  animKeyboardHeight
) {
  const state = {
    finished: new Value(0),
    position: new Value(0),
    time: new Value(0),
    frameTime: new Value(0)
  };

  const config = {
    duration: 300,
    toValue: new Value(-1),
    easing: KEYBOARD_EASING
  };

  const upperContentHeightNode = add(
    STATUS_BAR_HEIGHT,
    HEADER_HEIGHT,
    MAX_ANIMATED_HEIGHT,
    animContentHeight
  );
  const keyboardContentHeightNode = add(
    BOTTOM_BUTTON_HEIGHT,
    animKeyboardHeight
  );
  const overlap = max(
    sub(add(upperContentHeightNode, keyboardContentHeightNode), DEVICE_HEIGHT),
    0
  );
  const headerMinHeightNode = max(sub(MAX_ANIMATED_HEIGHT, overlap), 0);

  return block([
    cond(and(eq(keyboardShown, 1), neq(config.toValue, 1)), [
      set(state.finished, 0),
      set(state.time, 0),
      set(state.frameTime, 0),
      set(config.toValue, 1),
      startClock(clock)
    ]),
    cond(and(eq(keyboardShown, 0), neq(config.toValue, 0)), [
      set(state.finished, 0),
      set(state.time, 0),
      set(state.frameTime, 0),
      set(config.toValue, 0),
      startClock(clock)
    ]),
    timing(clock, state, config),
    cond(state.finished, stopClock(clock)),
    interpolate(state.position, {
      inputRange: [0, 1],
      outputRange: [MAX_ANIMATED_HEIGHT, headerMinHeightNode]
    })
  ]);
}

// Coloring below is used just to easily see the different components
const styles = StyleSheet.create({
  container: {
    flex: 1
  },
  header: {
    height: HEADER_HEIGHT,
    width: "100%",
    backgroundColor: "teal",
    justifyContent: "center",
    alignItems: "center"
  },
  headerText: {
    color: "white"
  },
  heading: {
    alignItems: "center",
    marginBottom: 15,
    paddingHorizontal: 30
  },
  headingText: {
    fontSize: 28,
    fontWeight: "600",
    textAlign: "center"
  },
  animatedSpace: {
    backgroundColor: "pink",
    width: "100%"
  },
  textInputContainer: {
    alignItems: "center",
    paddingHorizontal: 40,
    width: "100%",
    height: 60
  },
  textInput: {
    backgroundColor: "lightgray",
    width: "100%",
    height: 60
  },
  bottomButton: {
    marginTop: "auto",
    height: BOTTOM_BUTTON_HEIGHT,
    backgroundColor: "orange",
    paddingHorizontal: 20
  }
});

Final thoughts

I expected the UI fps to stay at a consistent 60, however something in the way I have things set up is causing frame drops. I am wondering if it has to do with the fact that my react-native-reanimated animation is dependent on the state of the Keyboard (i.e. dependent on information from the JS thread). I'm kind of wondering if this is even possible to do without constant communication between the JS and UI threads over the bridge. Any help or direction would be greatly appreciated.

nhuesmann
  • 437
  • 3
  • 10

1 Answers1

0

Just for safety, could you wrap the runTiming call into use case with the proper dependencies? [keyboardShown, etc]. There are a lot of side effects in your code snippet that could trigger issues.

wcandillon
  • 2,086
  • 19
  • 19
  • Hi William, thanks so much for your reply. Could you be a little more specific about what you're recommending I try? I didn't fully understand. – nhuesmann Oct 21 '19 at 22:26
  • useCode has a second argument (dependency) which is similar to the dependency argument in useEffect (https://reactjs.org/docs/hooks-effect.html). One needs to be careful with this argument and really understand the lifecycle of its component. – wcandillon Oct 23 '19 at 07:13