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.

(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.

, 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