React Native の Stack Navigator 内で KeyboardAvoidingView がうまく動かない問題に対処した

発生した問題

  • 複数行を入力できる TextInput を作成したら、 TextInput がキーボードの後ろの隠れてしまい、入力している内容が見えなくなった
  • TextInput の下に置いた View の内容もキーボードに隠れてしまった

うまく動いたコード

ScrollViewstylemarginBottom を設定したら、キーボード分の余白が下に設定されました。

抜粋すると以下のような ScrollView でコンテンツを囲みました。

Sample.tsx
  const keyboardHeight = useKeyboardHeight();

  return (
    <ScrollView style={{ marginBottom: keyboardHeight }}>
      <TextInput

こうすると、Viewの下の方に余白ができて、キーボードが表示されてもView全体が見えるようになりました。

キーボードの高さは以下の hook を使って取得しています。

コード全体は以下の通りです。関係ない部分も入っているので、参考までに。

NoteDetailScreen.tsx
import { updateNoteBody, updateNoteTitle } from '../../databases/repositories/noteRepository';
import { useKeyboardHeight } from '../../hooks/useKeyboardHeight';
import { ReloadTabState } from '../../states/atoms/ReloadTabState';
import { MaterialCommunityIcons, MaterialIcons } from '@expo/vector-icons';
import { useNavigation } from '@react-navigation/native';
import * as Clipboard from 'expo-clipboard';
import * as Haptics from 'expo-haptics';
import { useEffect, useState } from 'react';
import {
  Keyboard,
  ScrollView,
  StyleSheet,
  Text,
  TextInput,
  TouchableWithoutFeedback,
  View,
} from 'react-native';
import 'react-native-get-random-values';
import Toast from 'react-native-toast-message';
import { useSetRecoilState } from 'recoil';
import { v4 as uuidv4 } from 'uuid';

export default function NoteDetailScreen({ route }: any) {
  const setReloadTabState = useSetRecoilState(ReloadTabState);
  const noteTitle = route.params.note.title;
  const noteBody = route.params.note.body;
  const noteDate = route.params.note.date;

  const [title, setTitle] = useState<string>(noteTitle);
  const [body, setBody] = useState<string>(noteBody);
  const [date] = useState<string>(noteDate);
  const onChangeTitle = async (title: string) => {
    setTitle(title);
    updateNoteTitle(route.params.note.id, title).then((note) => {});
  };

  const onChangeBody = (body: string) => {
    setBody(body);
    updateNoteBody(route.params.note.id, body).then((note) => {});
  };

  const countLines = (str: string) => {
    return str.split(/\r\n|\r|\n/).length;
  };

  const navigation = useNavigation();

  useEffect(() => {
    const unsubscribe = navigation.addListener('blur', () => {
      setReloadTabState(uuidv4());
      console.log('Navigating away from NoteDetailScreen');
    });

    return unsubscribe;
  }, [navigation]);

  const keyboardHeight = useKeyboardHeight();

  return (
    <ScrollView style={{ marginBottom: keyboardHeight }}>
      <TextInput
        onChangeText={onChangeTitle}
        value={title}
        style={styles.titleInput}
        autoFocus={title.length === 0}
        placeholder={'Title'}
      />
      <View style={styles.bodyContainer}>
        <TextInput
          onChangeText={onChangeBody}
          value={body}
          multiline={true}
          style={styles.bodyInput}
          placeholder={'Note'}
          scrollEnabled={false}
          autoFocus={title.length > 0}
        />
        <MaterialIcons
          name="content-copy"
          size={24}
          color="gray"
          style={styles.copyIcon}
          onPress={() => {
            Clipboard.setStringAsync(body).then(() => {
              Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
              Toast.show({
                type: 'success',
                text1: 'Copied to clipboard',
                visibilityTime: 1000,
              });
            });
          }}
        />
        {(countLines(body) > 8 || body.length >= 100) && (
          <MaterialCommunityIcons
            name="keyboard-close"
            size={24}
            color="gray"
            style={styles.dismissKeyboardIcon}
            onPress={Keyboard.dismiss}
          />
        )}
        <Toast position={'bottom'} />
      </View>
      <TouchableWithoutFeedback onPress={Keyboard.dismiss}>
        <View style={styles.dateAndCharCountContainer}>
          <Text style={styles.labelText}>{date}</Text>
          <Text style={styles.labelText}>{body.length}</Text>
        </View>
      </TouchableWithoutFeedback>
    </ScrollView>
  );
}

const styles = StyleSheet.create({
  scrollContainer: {
    flex: 1,
  },
  container: {
    flex: 1,
  },
  titleInput: {
    width: '100%',
    height: 50,
    fontSize: 25,
    fontWeight: 'bold',
    paddingHorizontal: 16,
    borderBottomWidth: 1,
    borderBottomColor: '#ccc',
  },

  bodyContainer: {
    position: 'relative',
  },
  bodyInput: {
    width: '100%',
    fontSize: 18,
    paddingHorizontal: 16,
    paddingVertical: 16,
    marginBottom: 24,
  },
  dismissKeyboardIcon: {
    position: 'absolute',
    bottom: 10,
    right: 10,
  },
  copyIcon: {
    position: 'absolute',
    top: 10,
    right: 10,
  },
  dateAndCharCountContainer: {
    flexDirection: 'row',
    justifyContent: 'space-between',
    alignItems: 'center',
    paddingHorizontal: 16,
    paddingVertical: 8,
    borderTopWidth: 1,
    borderTopColor: '#ccc',
    borderBottomWidth: 1,
    borderBottomColor: '#ccc',
    marginBottom: 16,
  },
  labelText: {
    fontSize: 15,
    color: '#666',
  },
});

うまく動かなかったコード

KeyboardAvoidingView を使っても、予想していた「キーボードが出てきたら、キーボードを避けて上の方ににゅるっと動いてくれる」ような動作にはなりませんでした。

Sample.tsx
return (
    <KeyboardAvoidingView
      behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
      style={styles.container}
    >
      <TextInput
        onChangeText={onChangeTitle}
        value={title}
        style={styles.titleInput}
        autoFocus={true}
        placeholder={'Title'}
      />
      <View style={styles.bodyContainer}>
        <TextInput
          onChangeText={onChangeBody}
          value={body}
          multiline={true}
          style={styles.bodyInput}
          placeholder={'Note'}
        />
        <MaterialIcons
          name="content-copy"
          size={24}
          color="gray"
          style={styles.copyIcon}
          onPress={() => {
            Clipboard.setStringAsync(body).then(() => {
              Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
              Toast.show({
                type: 'success',
                text1: 'Copied to clipboard',
                visibilityTime: 1000,
              });
            });
          }}
        />
        {(countLines(body) > 8 || body.length >= 100) && (
          <MaterialCommunityIcons
            name="keyboard-close"
            size={24}
            color="gray"
            style={styles.dismissKeyboardIcon}
            onPress={Keyboard.dismiss}
          />
        )}
        <Toast position={'bottom'} />
      </View>
      <TouchableWithoutFeedback onPress={Keyboard.dismiss}>
        <View style={styles.dateAndCharCountContainer}>
          <Text style={styles.labelText}>{date}</Text>
          <Text style={styles.labelText}>{body.length}</Text>
        </View>
      </TouchableWithoutFeedback>
    </KeyboardAvoidingView>
  );

useHeaderHeight でヘッダーの高さを取得してもうまく動かなかった

調べていると、 useHeaderHeight でヘッダーの高さを取得して、 keyboardVerticalOffset を設定すればいい、という話もありました。

shell
npm install @react-navigation/elements

以下のようにすれば、 headerHeight が取れるらしいです。

Sample.tsx
import { useHeaderHeight } from '@react-navigation/elements';

// ...

const headerHeight = useHeaderHeight();

それで、 KeyboardAvoidingViewkeyboardVerticalOffsetheaderHeight を設定しなさい、という記事もありましたが、これはうまく動きませんでした。