How to dismiss keyboard with react-navigation in React Native apps
Issue #263
Original post https://medium.com/react-native-training/how-to-dismiss-keyboard-with-react-navigation-in-react-native-apps-4b987bbfdc48
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.
Using react-navigation 3.0 in React Native apps
react-navigation is probably the only dependency I use in React Native apps. I’m happy with it so far, then version 3.0…codeburst.io
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.