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 newmessaging
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 aScrollView
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 UIKeyboardState
- This component will keep track of the keyboard's visibility, height, etcMessagingContainer
- 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 callingsetState
within this function, sincesetState
may cause the component to re-render, in which caseonLayout
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, whereX
indicates what will be rendered, e.g.renderMessages
. However, usingchildren
makes the hierarchy of the component tree more clear. Using thechildren
prop implies that these children components are the main thing the parent renders. As an analogy, this pattern is similar to choosing betweenexport default
andexport X
. If there's only one variable to export from a file, it's generally more clear to go withexport 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 withexport default
and export other variables withexport X
. Similarly, you should consider usingchildren
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 value250
(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 appearkeyboardWillHide
(iOS only) - The keyboard is going to disappearkeyboardDidShow
- The keyboard is now fully visiblekeyboardDidHide
- 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.