How to dismiss keyboard with react-navigation in React Native apps
--
Showing and dismiss keyboard seems like a trivial thing to do in mobile apps, but it can be tricky in automatically dismissing it when it comes together with react-navigation
and modal presentation. At least that’s according to my initial assumption. This article aims to detail what I have learned about keyboard handling and how to avoid extra tap when dealing with TextInput
There will also be lots of code spelunking, thanks to the all the libraries being open source. The version of React Native I’m using at the time of writing is 0.57.5
The built in TextInput component
React Native comes with a bunch of basic components, one of them is the TextInput for inputting text into the app via a keyboard.
import React, { Component } from 'react';
import { AppRegistry, TextInput } from 'react-native';export default class UselessTextInput extends Component {
constructor(props) {
super(props);
this.state = { text: 'Useless Placeholder' };
}render() {
return (
<TextInput
style={{height: 40, borderColor: 'gray', borderWidth: 1}}
onChangeText={(text) => this.setState({text})}
value={this.state.text}
/>
);
}
}
That’s it, whenever we click on the text input, keyboard appears allowing us to enter values. To dismiss the keyboard by pressing anywhere on the screen, the easy solution is to TouchableWithoutFeedback
together with Keyboard
. This is similar to having UITapGestureRecognizer
in iOS UIView
and calling view.endEditing
import { Keyboard } from 'react-native'
Keyboard.dismiss()
TextInput inside ScrollView
Normally we should have some text inputs inside a scrolling component, in React Native that is mostly ScrollView
to be able to handle long list of content and avoid keyboard. If TextInput
is inside ScrollView
then the way keyboard gets dismissed behaves a bit differently, and depends on keyboardShouldPersistTaps
Determines when the keyboard should stay visible after a tap.
'never'
(the default), tapping outside of the focused text input when the keyboard is up dismisses the keyboard. When this happens, children won't receive the tap.'always'
, the keyboard will not dismiss automatically, and the scroll view will not catch taps, but children of the scroll view can catch taps.'handled'
, the keyboard will not dismiss automatically when the tap was handled by a children, (or captured by an ancestor).
The never
mode should be the desired behaviour in most cases, clicking anywhere outside the focused text input should dismiss the keyboard.
In my app, there are some text inputs and an action button. The scenario is that users enter some infos and then press that button to register data. With the never
mode, we have to press button twice, one for dismissing the keyboard, and two for the onPress
of the Button
. So the solution is to use always
mode. This way the Button
always gets the press event first.
<ScrollView keyboardShouldPersistTaps='always' />
ScrollView cares about keyboard
The native RCTScrollView
class that power react native ScrollView
has code to handle dismiss mode
RCT_SET_AND_PRESERVE_OFFSET(setKeyboardDismissMode, keyboardDismissMode, UIScrollViewKeyboardDismissMode)
The option that it chooses is UIScrollViewKeyboardDismissMode
for keyboardDismissMode
property
The manner in which the keyboard is dismissed when a drag begins in the scroll view.
As you can see, the possible modes are onDrag
and interactive
. And react native exposes customization point for this via keyboardShouldPersistTaps
case none
The keyboard does not get dismissed with a drag.
case onDrag
The keyboard is dismissed when a drag begins.
case interactive
The keyboard follows the dragging touch offscreen, and can be pulled upward again to cancel the dismiss.
ScrollView inside a Modal
But that does not work when ScrollView
is inside Modal
. By Modal
I meant the Modal component in React Native. The only library that I use is react-navigation
, and it supports Opening a full-screen modal too, but they way we declare modal in react-navigation
looks like stack and it is confusing, so I would rather not use it. I use Modal
in react-native
and that works pretty well.
So if we have TextInput
inside ScrollView
inside Modal
then keyboardShouldPersistTaps
does not work. Modal
seems to be aware of parent ScrollView
so we have to declare keyboardShouldPersistTaps='always'
on every parent ScrollView
. In React Native FlatList
and SectionList
uses ScrollView
under the hood, so we need to be aware of all those ScrollView
components.
Spelunking react-navigation
Since my app relies heavily on react-navigation
, it’s good to have a deep understanding about its components so we make sure where the problem lies. I’ve written a bit about react-navigation structure below.
Like every traditional mobile apps, my app consists of many stack navigators inside tab navigator. In iOS that means many UINavigationViewController
inside UITabbarController
. In react-navigation
I use createMaterialTopTabNavigator
inside createBottomTabNavigator
import { createMaterialTopTabNavigator } from 'react-navigation'
import { createBottomTabNavigator, BottomTabBar } from 'react-navigation-tabs'
The screen I have keyboard issue is a Modal
presented from the 2nd screen in one of the stack navigators, so let’s examine every possible ScrollView
up the hierarchy. This process involves lots of code reading and this’s how I love open source.
First let’s start with createBottomTabNavigator which uses createTabNavigator together with its own TabNavigationView
class TabNavigationView extends React.PureComponent<Props, State>export default createTabNavigator(TabNavigationView);
Tab navigator has tab bar view below ScreenContainer
, which is used to contain view. ScreenContainer
is from react-native-screens “This project aims to expose native navigation container components to React Native”. Below is how tab navigator works.
render() {
const { navigation, renderScene, lazy } = this.props;
const { routes } = navigation.state;
const { loaded } = this.state
return (
<View style={styles.container}>
<ScreenContainer style={styles.pages}>
{routes.map((route, index) => {
if (lazy && !loaded.includes(index)) {
// Don't render a screen if we've never navigated to it
return null;
const isFocused = navigation.state.index === index
return (
<ResourceSavingScene
key={route.key}
style={StyleSheet.absoluteFill}
isVisible={isFocused}
>
{renderScene({ route })}
</ResourceSavingScene>
);
})}
</ScreenContainer>
{this._renderTabBar()}
</View>
);
}
Tab bar is rendered using BottomTabBar in _renderTabBar
function. Looking at the code, the whole tab navigator has nothing to do with ScrollView
.
So there is only createMaterialTopTabNavigator left on the suspecting list. I use it in the app with swipeEnabled: true
. And by looking at the imports, top tab navigator has
import MaterialTopTabBar, { type TabBarOptions,} from '../views/MaterialTopTabBar';
MaterialTopTabBar has import from react-native-tab-view
import { TabBar } from 'react-native-tab-view';
which has ScrollView
<View style={styles.scroll}>
<Animated.ScrollView
horizontal
keyboardShouldPersistTaps="handled"
The property keyboardShouldPersistTaps
was initial set to always
, then set back to handled to avoid the bug that we can’t press any button in tab bar while keyboard is open https://github.com/react-native-community/react-native-tab-view/issues/375
But this TabBar
has nothing with our problem, because it’s just for containing tab bar buttons.
Swiping in createMaterialTopTabNavigator
Taking another look at createMaterialTopTabNavigator we see more imports from react-native-tab-view
import { TabView, PagerPan } from 'react-native-tab-view';
TabView
has swipeEnabled
passed in
return (
<TabView
{...rest}
navigationState={navigation.state}
animationEnabled={animationEnabled}
swipeEnabled={swipeEnabled}
onAnimationEnd={this._handleAnimationEnd}
onIndexChange={this._handleIndexChange}
onSwipeStart={this._handleSwipeStart}
renderPager={renderPager}
renderTabBar={this._renderTabBar}
renderScene={
/* $FlowFixMe */
this._renderScene
}
/>
);
and it renders PagerDefault, which in turn uses PagerScroll
for iOS
import { Platform } from 'react-native';let Pager;switch (Platform.OS) {
case 'android':
Pager = require('./PagerAndroid').default;
break;
case 'ios':
Pager = require('./PagerScroll').default;
break;
default:
Pager = require('./PagerPan').default;
break;
}export default Pager;
So PagerScroll uses ScrollView
to handle scrolling to match material style that user can scroll between pages, and it has keyboardShouldPersistTaps=”always”
which should be correct.
return (
<ScrollView
horizontal
pagingEnabled
directionalLockEnabled
keyboardDismissMode="on-drag"
keyboardShouldPersistTaps="always"
So nothing looks suspicious in react-navigation
, which urges me to look at code from my project.
Debugging FlatList, SectionList and ScrollView
Like I stated in the beginning of this article, the root problem is that we need to declare keyboardShouldPersistTaps
for all parent ScrollView
in the hierarchy. That means to look out for any FlatList
, SectionList
and ScrollView
Luckily, there is react-devtools
that shows tree of all rendered components in react app, and that is also guided in Debugging section of react native.
You can use the standalone version of React Developer Tools to debug the React component hierarchy. To use it, install the react-devtools
package globally:
npm install -g react-devtools
So after searching I found out that there is a SectionList up the hierarchy that should have keyboardShouldPersistTaps='always'
while it didn’t.
Taking a thorough look at the code, I found out that the Modal
is trigged from a SectionList
item. We already know that triggering Modal
in react native means that to embed that Modal inside the view hierarchy and control its visibility via a state. So in terms of view and component, that Modal is inside a SectionList
. And for your interest, if you dive deep into react native code, SectionList
in my case is just VirtualizedSectionList
, which is VirtualizedList
, which uses ScrollView
So after I declare keyboardShouldPersistTaps='always'
in that SectionList
, the problem is solved. User can now just enters some values in the text inputs, then press once on the submit button to submit data. The button now captures touch events first instead of scrollview.
Where to go from here
The solution for this is fortunately simple as it involves fixing our code without having to alter react-navigation code. But it’s good to look at the library code to know what it does, and to trace where the problem originates. Thanks for following such long exploring and hope you learn something.