【React Native】キーボードへの文字数カウンターの実装(iOS)

#はじめに
`react-native-elements`の`Input`に文字数カウンターがあれば便利だなーと思ったので自分で実装してみました。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/569054/a0e81c3b-4f0b-1cdd-a180-851d1a9d2adb.gif" width="300">

実装した機能は以下の2つです。

- 文字数のカウント
- 入力画面と全画面表示の切替

同様の機能の実装を考えている方の参考になれば幸いです。
※iOS専用の機能のため、Androidには対応しておりません。あらかじめご了承ください。

#実装
機能ごとに実装内容を記載します。

##文字数カウント
文字数カウンターを表示する場所として、iOSキーボードの上部をカスタマイズするためのコンポーネントである`InputAccessoryView`を使用します。
文字数のカウントは、`wordCount`というstateを用意して、入力した文字の長さ(`text.lentgh`)を`setWordCount`で更新して表示するだけです。

`Input`の`inputAccessoryViewID`プロパティと`InputAccesoryView`の`nativeID`プロパティの値を共通にすることで、各コンポーネントを対応させています。

また、Androidで画面を開いたときにエラーが出ないよう、`Platform.OS === 'ios'`で`iOS`のときだけ`InputAccessoryView`が表示されるようにしています。

```index.tsx
export const useInput = (initialValue: string) => {
  const [value, setValue] = useState(initialValue);
  return { value, setValue };
};

export const InputWithWordCount = () => {
  const inputAccessoryViewID = '1';
  const bodyProps = useInput('');
  const [wordCount, setWordCount] = useState<number>(0);

  const onChangeText = (text: string) => {
    bodyProps.setValue(text);
    setWordCount(text.length);
  };

  return (
    <>
      <ScrollView style={styles.container}>
        <Input
          multiline
          value={bodyProps.value}
          onChangeText={onChangeText}
          placeholder="ここに入力してください"
          inputAccessoryViewID={inputAccessoryViewID}
          inputContainerStyle={{ borderBottomWidth: 0 }}
          autoFocus
        />
      </ScrollView>
      {Platform.OS === 'ios' && (
        <InputAccessoryView nativeID={inputAccessoryViewID}>
          <Divider />
          <View style={styles.accessoryContainer}>
            <View style={styles.keyboardClose}>
              <MaterialCommunityIcons
                size={24}
                name="keyboard-close"
                color="grey"
                onPress={() => Keyboard.dismiss()}
              />
            </View>
            <View style={styles.wordCount}>
              <Text style={styles.wordText}>{wordCount}文字</Text>
            </View>
          </View>
        </InputAccessoryView>
      )}
    </>
  );
};
```

##入力画面と全画面表示の切替
入力内容が長文になってしまったときに、全画面表示で確認できるようにしました。

この機能を実装するにあたって、以下の部分を工夫しました。

- 全画面表示でスクロールしたときに誤作動でキーボードが開かないこと
- 入力内容がキーボードに隠れないこと(Inputの高さも切り替える)

###全画面表示でスクロールしたときに誤作動でキーボードが開かないこと
全画面表示で入力内容を確認するとき、画面スクロールでキーボードが開いてしまう挙動がありました。
ユーザにストレスがかかる挙動だったので、キーボードの開閉をアイコンタップで行うようにしました。

まず、`InputAccessoryView`にアイコンを追加し、`onPress={() => Keyboard.dismiss()}`でタップ時にキーボードを閉じるようにしました。

```index.tsx
      //キーボード開のときに表示
      {Platform.OS === 'ios' && (
        <InputAccessoryView nativeID={inputAccessoryViewID}>
          <Divider />
          <View
            style={styles.accessoryContainer}
            onLayout={(e) =>
              setAccessoryViewHeight(e.nativeEvent.layout.height)
            }
          >
            <View style={styles.keyboardClose}>
              <MaterialCommunityIcons
                size={24}
                name="keyboard-close"
                color="grey"
                onPress={() => Keyboard.dismiss()} //キーボード閉じる
              />
            </View>
            <View style={styles.wordCount}>
              <Text style={styles.wordText}>{wordCount}文字</Text>
            </View>
          </View>
        </InputAccessoryView>
      )}
```


次に、キーボード閉のときに表示する内容を追加し、`TouchableOpacity`に`onPress={() => inputRef.current?.focus()}`を加えることで、タッチしたときに入力カーソルが当たるようにしました。

このとき、`const inputRef = useRef<Input>(null)`を定義し、`<Input>`に`ref={inputRef}`の記述も加えています。

```index.tsx
      //キーボード閉のときに表示
      {Platform.OS === 'ios' && keyboardHeight === 0 && (
        <TouchableOpacity
          onPress={() => inputRef.current?.focus()} //キーボード開く
          style={{ marginHorizontal: 10, marginBottom: 30 }}
        >
          <Divider />
          <MaterialCommunityIcons size={24} name="keyboard" color="grey" />
        </TouchableOpacity>
      )}
```

###入力内容がキーボードに隠れないこと
内容が長文になってくると、入力した文字がキーボードにかかって隠れてしまいます。
とはいえ、キーボードの隠れないように`<Input>`の高さを固定してしまうと、キーボードを閉じたときに内容が全画面に表示されなくなってしまいます。
これらの問題を起こさないために、キーボードの開閉で`<Input>`の高さが可変するような工夫をしました。

以下の図のようにそれぞれのパーツの高さを設定して、キーボードの開閉ごとに`contentHeight`を可変させるようなイメージになります。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/569054/5c82cce5-c765-9624-d94a-8b5bde3f1c65.png" width="500">

`Input`と`InputAccessoryView`の間にマージンがほしかったので、OFFSET_HEIGHTを加えました。

```index.tsx
  const SCREEN_HEIGHT = Dimensions.get('window').height; //スクリーンの高さ
  const OFFSET_HEIGHT = 90; //オフセットの高さ

  const [contentHeight, setContentHeight] = useState<number>(SCREEN_HEIGHT); //Inputの高さ
  const [keyboardHeight, setKeyboardHeight] = useState<number>(0); //キーボードの高さ
  const [accessoryViewHeight, setAccessoryViewHeight] = useState<number>(0); //InputAccessoryViewの高さ

  const onKeyboardDidShow = (e: KeyboardEvent): void => {
    setKeyboardHeight(e.endCoordinates.height); //キーボードの高さ(開いているとき)を設定
  };

  const onKeyboardDidHide = (): void => {
    setKeyboardHeight(0); //キーボードの高さ(閉じているとき)を設定
  };

  useEffect(() => {
    Keyboard.addListener('keyboardDidShow', onKeyboardDidShow);
    Keyboard.addListener('keyboardDidHide', onKeyboardDidHide);
    return (): void => {
      Keyboard.removeListener('keyboardDidShow', onKeyboardDidShow);
      Keyboard.removeListener('keyboardDidHide', onKeyboardDidHide);
    };
  }, []);

  キーボード開閉のたびにcontentHeightを設定
  useEffect(() => {
    setContentHeight(
      SCREEN_HEIGHT - keyboardHeight - accessoryViewHeight - OFFSET_HEIGHT
    );
  }, [keyboardHeight]);
```
`Input`の高さ指定は、`inputStyle`プロパティで行っています。
キーボードを閉じているときの高さは`auto`にします。

```tsx
        <Input
          inputStyle={{ height: keyboardHeight === 0 ? 'auto' : contentHeight }} //キーボード開閉のたびにheightを設定
          pointerEvents={keyboardHeight === 0 ? 'none' : 'auto'} //キーボードが開いたときにカーソルをあてる
        />
```
また、`InputAccessoryView`の高さについては、内部に新しく追加した`View`の`onLayout`プロパティを使用しました。

```tsx
        <InputAccessoryView nativeID={inputAccessoryViewID}>
          <Divider />
          <View
            style={styles.accessoryContainer}
            onLayout={(e) =>
              setAccessoryViewHeight(e.nativeEvent.layout.height) //accessoryViewHeightを設定
            }
          >
```

#コード全文
完成したコードを掲載します。
ご参考になれば幸いです。

```index.tsx
import { MaterialCommunityIcons } from '@expo/vector-icons';
import React, { useState, useEffect, useRef } from 'react';
import {
  View,
  StyleSheet,
  Text,
  Keyboard,
  KeyboardEvent,
  InputAccessoryView,
  Platform,
  TouchableOpacity,
  ScrollView,
  Dimensions,
} from 'react-native';
import { Divider, Input } from 'react-native-elements';

export const useInput = (initialValue: string) => {
  const [value, setValue] = useState(initialValue);
  return { value, setValue };
};

export const InputWithWordCount = () => {
  const SCREEN_HEIGHT = Dimensions.get('window').height;
  const OFFSET_HEIGHT = 90;
  const inputAccessoryViewID = '1';

  const bodyProps = useInput('');
  const [wordCount, setWordCount] = useState<number>(0);
  const [contentHeight, setContentHeight] = useState<number>(SCREEN_HEIGHT);
  const [keyboardHeight, setKeyboardHeight] = useState<number>(0);
  const [accessoryViewHeight, setAccessoryViewHeight] = useState<number>(0);

  const inputRef = useRef<Input>(null);

  const onKeyboardDidShow = (e: KeyboardEvent): void => {
    setKeyboardHeight(e.endCoordinates.height);
  };

  const onKeyboardDidHide = (): void => {
    setKeyboardHeight(0);
  };

  useEffect(() => {
    Keyboard.addListener('keyboardDidShow', onKeyboardDidShow);
    Keyboard.addListener('keyboardDidHide', onKeyboardDidHide);
    return (): void => {
      Keyboard.removeListener('keyboardDidShow', onKeyboardDidShow);
      Keyboard.removeListener('keyboardDidHide', onKeyboardDidHide);
    };
  }, []);

  useEffect(() => {
    setContentHeight(
      SCREEN_HEIGHT - keyboardHeight - accessoryViewHeight - OFFSET_HEIGHT
    );
  }, [keyboardHeight]);

  const onChangeText = (text: string) => {
    bodyProps.setValue(text);
    setWordCount(text.length);
  };

  return (
    <>
      <ScrollView style={styles.container}>
        <Input
          multiline
          value={bodyProps.value}
          inputStyle={{ height: keyboardHeight === 0 ? 'auto' : contentHeight }}
          onChangeText={onChangeText}
          placeholder="ここに入力してください"
          inputAccessoryViewID={inputAccessoryViewID}
          inputContainerStyle={{ borderBottomWidth: 0 }}
          autoFocus
          ref={inputRef}
          pointerEvents={keyboardHeight === 0 ? 'none' : 'auto'}
        />
      </ScrollView>
      {Platform.OS === 'ios' && keyboardHeight === 0 && (
        <TouchableOpacity
          onPress={() => inputRef.current?.focus()}
          style={{ marginHorizontal: 10, marginBottom: 30 }}
        >
          <Divider />
          <MaterialCommunityIcons size={24} name="keyboard" color="grey" />
        </TouchableOpacity>
      )}
      {Platform.OS === 'ios' && (
        <InputAccessoryView nativeID={inputAccessoryViewID}>
          <Divider />
          <View
            style={styles.accessoryContainer}
            onLayout={(e) =>
              setAccessoryViewHeight(e.nativeEvent.layout.height)
            }
          >
            <View style={styles.keyboardClose}>
              <MaterialCommunityIcons
                size={24}
                name="keyboard-close"
                color="grey"
                onPress={() => Keyboard.dismiss()}
              />
            </View>
            <View style={styles.wordCount}>
              <Text style={styles.wordText}>{wordCount}文字</Text>
            </View>
          </View>
        </InputAccessoryView>
      )}
    </>
  );
};

const styles = StyleSheet.create({
  container: {
    marginTop: 100,
    backgroundColor: '#fff',
  },
  accessoryContainer: {
    display: 'flex',
    flex: 1,
    flexDirection: 'row',
    marginVertical: 5,
    marginHorizontal: 10,
  },
  keyboardClose: {
    flex: 1,
  },
  wordCount: {
    flex: 1,
    alignItems: 'flex-end',
    marginRight: 20,
  },
  wordText: {
    fontSize: 16,
    color: 'grey',
  },
});

```

#おわりに
サードパーティのライブラリで、Androidでも同様の機能を実装できるかどうか模索してみます。

#参考資料

https://reactnative.dev/

https://reactnativeelements.com/