Core APIs, Part 2

In the first part of this section, we covered a variety of React Native APIs for accessing device information. In this part, we'll focus on one fundamental feature of mobile devices: the keyboard.

This is a code checkpoint. If you haven't been coding along with us but would like to start now, we've included a snapshot of our current progress in the sample code for this book.

If you haven't created a project yet, you'll need to do so with:

$ expo init messaging --template blank@sdk-33 --yarn

Then, copy the contents of the directory messaging/1 from the sample code into your new messaging project directory.

The keyboard

Keyboard handling in React Native can be very complex. We're going to learn how to manage the complexity, but it's a challenging problem with a lot of nuanced details.

Our UI is currently a bit flawed: on iOS, when we focus the message input field, the keyboard opens up and covers the toolbar. We have no way of switching between the image picker and the keyboard. We'll focus on fixing these issues.

We're about to embark on a deep dive into keyboard handling. We'll cover some extremely useful APIs and patterns -- however, you shouldn't feel like you have to complete the entire chapter now. Feel free to stop here and return again when you're actively building a React Native app that involves the keyboard.

Why it's difficult

Keyboard handling can be challenging for many reasons:

  • The keyboard is enabled, rendered, and animated natively, so we have much less control over its behavior than if it were a component (where we control the lifecycle).
  • We have to handle a variety of asynchronous events when the keyboard is shown, hidden, or resized, and update our UI accordingly. These events are somewhat different on iOS and Android, and even slightly different in the simulator compared to a real device.
  • The keyboard works differently on iOS and Android at a fundamental level. On iOS, the keyboard appears on top of the existing UI; the existing UI doesn't resize to avoid the keyboard. On Android, the keyboard resizes the UI above it; the existing UI will shrink to fit in the available space. We generally want interactions to feel similar on both platforms, despite this fundamental difference.
  • Keyboards interact specially with certain native elements e.g. ScrollView. On iOS, dragging downward on a ScrollView can dismiss the keyboard at the same rate of the pan gesture.
  • Keyboards are user-customizable on both platforms, meaning there's an almost unlimited number of shapes and sizes our UI has to handle.

In this app, we'll attempt to achieve a native-quality messaging experience. Ultimately though, there will be a few aspects that don't quite feel native. It's extremely difficult to get an app with complex keyboard interactions to feel perfect without dropping down to the native level. If you can't achieve the right experience in React Native, consider writing a native module for the screen that interacts heavily with the keyboard. This is part of the beauty of React Native -- you can start with a JavaScript version in your initial implementation of a screen or feature, then seamlessly swap it out for a native implementation when you're certain it's worth the time and effort.

If you're lucky, you'll be able to find an existing open source native component that does exactly that!

KeyboardAvoidingView

In the first chapter, we demonstrated how to use the KeyboardAvoidingView component to move the UI of the app out from under the keyboard. This component is great for simple use cases, e.g. focusing the UI on an input field in a form.

When we need more precise control, it's often better to write something custom. That's what we'll do here, since we need to coordinate the keyboard with our custom image input method.

Our goal here is for our image picker to have the same height as the native keyboard, in essence acting as a custom keyboard created by our app. We'll want to smoothly animate the transition between these two input methods.

For a demo of the desired behavior, you can try playing around with the completed app (it's the same app as the previous section):

  • On Android, you can scan this QR code from within the Expo app:

  • On iOS, you can build the app and preview it on the iOS simulator or send the link of the project URL to your device like we've done in previous chapters.

On managing complexity

Since this problem is fairly complicated, we're going to break it down into 3 parts, each with its own component:

  • MeasureLayout - This component will measure the available space for our messaging UI
  • KeyboardState - This component will keep track of the keyboard's visibility, height, etc
  • MessagingContainer - This component will displaying the correct IME (text, images) at the correct size

We'll connect them so that MeasureLayout renders KeyboardState, which in turn renders MessagingContainer.

We could build one massive component that handles everything, but this would get very complicated and be difficult to modify or reuse elsewhere.

Keyboard

We'll need to measure the available space on the screen and the keyboard height ourselves, and adjust our UI accordingly. We'll keep track of whether the keyboard is currently transitioning. And we'll animate our UI to transition between the different keyboard states.

To do this, we'll use the Keyboard API. The Keyboard API is the lower-level API that KeyboardAvoidingView uses under the hood.

On iOS, the keyboard uses an animation with a special easing curve that's hard to replicate in JavaScript, so we'll hook into the native animation directly using the LayoutAnimation API. LayoutAnimation is one of the two main ways to animate our UI (the other being Animated). We'll cover animation more in a later chapter.

Measuring the available space

Let's start by measuring the space we have to work with. We want to measure the space that our MessageList can use, so we'll measure from below the status bar (anything above our MessageList) to the bottom of the screen. We need to do this to get a numeric value for height, so we can transition between the height when the keyboard isn't visible to the height when the keyboard is visible. Since the keyboard doesn't actually take up any space in our UI, we can't rely on flex: 1 to take care of this for us.

Measuring in React Native is always asynchronous. In other words, the first time we render our UI, we have no general-purpose way of knowing the height. If the content above our MessageList has a fixed height, we can calculate the initial height by taking Dimensions.get('window').width and subtracting the height of the content above our MessageList -- however, this is not very flexible. Instead, let's create a container View with a flexible height flex: 1 and measure it on first render. After that, we'll always have a numeric value for height.

We can measure this View with the onLayout prop. By passing a callback to onLayout, we can get the layout of the View. This layout contains values for x, y, width, and height.


import Constants from 'expo-constants';
import { Platform, StyleSheet, View } from 'react-native';
import PropTypes from 'prop-types';
import React from 'react';

export default class MeasureLayout extends React.Component {
  static propTypes = {
    children: PropTypes.func.isRequired,
  };

  state = {
    layout: null,
  };

  handleLayout = event => {
    const { nativeEvent: { layout } } = event;

    this.setState({
      layout: {
        ...layout,
        y:
          layout.y +
          (Platform.OS === 'android' ? Constants.statusBarHeight : 0),
      },
    });
  };

  render() {
    const { children } = this.props;
    const { layout } = this.state;

    // Measure the available space with a placeholder view set to
    // flex 1
    if (!layout) {
      return (
        <View onLayout={this.handleLayout} style={styles.container} />
      );
    }

    return children(layout);
  }
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
  },
});

Here we render a placeholder View with an onLayout prop. When called, we update state with the new layout.

Most React Native components accept an onLayout function prop. This is conceptually similar to a React lifecycle method: the function we pass is called every time the component updates its dimensions. We need to be careful when calling setState within this function, since setState may cause the component to re-render, in which case onLayout will get called again... and now we're stuck in an infinite loop!

We have to compensate for the solid color status bar we use on Android by adjusting the y value, since the status bar height isn't included in the layout data. We can do this by merging the existing properties of layout, ...layout, and an updated y value that includes the status bar height.

We use a new pattern here for propagating the layout into the children of this component: we require the children prop to be a function. When we use our MeasureLayout component, it will look something like this:

<MeasureLayout>
  {layout => <View ... />}
</MeasureLayout>

This pattern is similar to having a renderX prop, where X indicates what will be rendered, e.g. renderMessages. However, using children makes the hierarchy of the component tree more clear. Using the children prop implies that these children components are the main thing the parent renders. As an analogy, this pattern is similar to choosing between export default and export X. If there's only one variable to export from a file, it's generally more clear to go with export default. If there's a variable with the same name as the file, or a variable that seems like the primary purpose of the file, you would also likely export it with export default and export other variables with export X. Similarly, you should consider using children if this prop is the "default" or "primary" thing a component renders. Ultimately this is an API style preference. Even if you choose not to use it, it's useful to be aware of the pattern since you may encounter it when using open source libraries.

We're now be able to get a precise height which we can use to resize our UI when the keyboard appears and disappears.

Keyboard events

We have the initial height for our messaging UI, but we need to update the height when the keyboard appears and disappears. The Keyboard object emits events to let us know when it appears and disappears. These events contain layout information, and on iOS, information about the animation that will/did occur.

KeyboardState

Let's create a new component called KeyboardState to encapsulate the keyboard event handling logic. For this component, we're going to use the same pattern as we did for MeasureLayout: we'll take a children function prop and call it with information about the keyboard layout.

We can start by figuring out the propTypes for this component. We know we're going to have a children function prop. We're also going to consume the layout from the MeasureLayout component, and use it in our keyboard height calculations.

import { Keyboard, Platform } from "react-native";
import PropTypes from 'prop-types';
import React from 'react';

export default class KeyboardState extends React.Component {
  static propTypes = {
    layout: PropTypes.shape({
      x: PropTypes.number.isRequired,
      y: PropTypes.number.isRequired,
      width: PropTypes.number.isRequired,
      height: PropTypes.number.isRequired,
    }).isRequired,
    children: PropTypes.func.isRequired,
  };

  // ...
}

Now let's think about the state. We want to keep track of 6 different values, which we'll pass into the children of this component:

  • contentHeight: The height available for our messaging content.
  • keyboardHeight: The height of the keyboard. We keep track of this so we set our image picker to the same size as the keyboard.
  • keyboardVisible: Is the keyboard fully visible or fully hidden?
  • keyboardWillShow: Is the keyboard animating into view currently? This is only relevant on iOS.
  • keyboardWillHide: Is the keyboard animating out of view currently? This is only relevant on iOS, and we'll only use it for fixing visual issues on the iPhone X.
  • keyboardAnimationDuration: When we animate our UI to avoid the keyboard, we'll want to use the same animation duration as the keyboard. Let's initialize this with the value 250 (in milliseconds) as an approximation.
// ...

const INITIAL_ANIMATION_DURATION = 250;

export default class KeyboardState extends React.Component {
  // ...

  constructor(props) {
    super(props);

    const { layout: { height } } = props;

    this.state = {
      contentHeight: height,
      keyboardHeight: 0,
      keyboardVisible: false,
      keyboardWillShow: false,
      keyboardWillHide: false,
      keyboardAnimationDuration: INITIAL_ANIMATION_DURATION,
    };
  }

  // ...
}

Now that we've determined which properties to keep track of, let's update them based on keyboard events.

There are 4 Keyboard events we should listen for:

  • keyboardWillShow (iOS only) - The keyboard is going to appear
  • keyboardWillHide (iOS only) - The keyboard is going to disappear
  • keyboardDidShow - The keyboard is now fully visible
  • keyboardDidHide - The keyboard is now fully hidden

In componentWillMount we can add listeners to each keyboard event;

And in componentWillUnmount we can remove them:

// ...

  componentWillMount() {
    if (Platform.OS === 'ios') {
      this.subscriptions = [
        Keyboard.addListener(
          'keyboardWillShow',
          this.keyboardWillShow,
        ),
        Keyboard.addListener(
          'keyboardWillHide',
          this.keyboardWillHide,
        ),
        Keyboard.addListener('keyboardDidShow', this.keyboardDidShow),
        Keyboard.addListener('keyboardDidHide', this.keyboardDidHide),
      ];
    } else {
      this.subscriptions = [
        Keyboard.addListener('keyboardDidHide', this.keyboardDidHide),
        Keyboard.addListener('keyboardDidShow', this.keyboardDidShow),
      ];
    }
  }

  componentWillUnmount() {
    this.subscriptions.forEach(subscription => subscription.remove());
  }

// ...

We'll add the listeners slightly differently for each platform: on Android, we don't get events for keyboardWillHide or keyboardWillShow.

Storing subscription handles in an array is a common practice in React Native. We don't know exactly how many subscriptions we'll have until runtime, since it's different on each platform, so removing all subscriptions from an array is easier than storing and removing a reference to each listener callback.

Let's use these events to update keyboardVisible, keyboardWillShow, and keyboardWillHide in our state:

  // ...

  keyboardWillShow = (event) => {
    this.setState({ keyboardWillShow: true });

    // ...
  };

  keyboardDidShow = () => {
    this.setState({
      keyboardWillShow: false,
      keyboardVisible: true,
    });

    // ...
  };

  keyboardWillHide = (event) => {
    this.setState({ keyboardWillHide: true });

    // ...
  };

  keyboardDidHide = () => {
    this.setState({
      keyboardWillHide: false,
      keyboardVisible: false
    });
  };

  // ...

The listeners keyboardWillShow, keyboardDidShow, and keyboardWillHide will each be called with an event object, which we can use to measure the contentHeight and keyboardHeight. Let's do that now, using this.measure(event) as a placeholder for the function which will perform measurements.