spice picks

エンジニアをしているspiceが色々書きます

React NativeのTextInputで使える13個のコールバックをちゃんと見てみる

reactnative.dev

公式ドキュメントを見ると、TextInputは13種類のコールバック関数を渡せるらしい。

  • onBlur
  • onChange
  • onChangeText
  • onContentSizeChange
  • onEndEditing
  • onPressIn
  • onPressOut
  • onFocus
  • onKeyPress
  • onLayout
  • onScroll
  • onSelectionChange
  • onSubmitEditing

TextInput、たまに触る時にちょろちょろ公式ドキュメントを読んでいるだけで、これらのコールバックの細かい違いを把握していない・・・。

ちゃんと理解した方がいいよね、ということでそれぞれの挙動を実際に動かしながら確認してみました。

(本当はreact native自体の実装も読んだ方が良かったのですが、この記事をアドベントカレンダー予約日前日に書き始めてしまったので叶わぬ夢となりました。すみません。)

実際に動かしたサンプルコードは以下の通りで、観察したいもの以外のpropsを適宜コメントアウトしております。

import { TextInput, View } from "react-native";
 
 export default function App() {
   const onBlur = (e) => {
     console.log("onBlur");
     // console.log(e);
   };
   const onChange = (e) => {
     console.log("onChange");
     // console.log(e);
   };
   const onChangeText = (e) => {
     console.log("onChangeText");
     // console.log(e);
   };
   const onContentSizeChange = (e) => {
     console.log("onContentSizeChange");
     // console.log(e);
   };
   const onEndEditing = (e) => {
     console.log("onEndEditing");
     // console.log(e);
   };
   const onPressIn = (e) => {
     console.log("onPressIn");
     // console.log(e);
   };
   const onPressOut = (e) => {
     console.log("onPressOut");
     // console.log(e);
   };
   const onFocus = (e) => {
     console.log("onFocus");
     // console.log(e);
   };
   const onKeyPress = (e) => {
     console.log("onKeyPress");
     // console.log(e);
   };
   const onLayout = (e) => {
     console.log("onLayout");
     // console.log(e);
   };
   const onScroll = (e) => {
     console.log("onScroll");
     // console.log(e);
   };
   const onSelectionChange = (e) => {
     console.log("onSelectionChange");
     // console.log(e);
   };
   const onSubmitEditing = (e) => {
     console.log("onSubmitEditing");
     // console.log(e);
   };
 
   return (
     <View
       style={{
         flex: 1,
         backgroundColor: "#fff",
         alignItems: "center",
         justifyContent: "center",
       }}
     >
       <TextInput
         style={{
           width: 200,
           maxHeight: 200,
           borderColor: "gray",
           borderWidth: 1,
         }}
         onBlur={onBlur}
         onChange={onChange}
         onChangeText={onChangeText}
         onContentSizeChange={onContentSizeChange}
         onEndEditing={onEndEditing}
         onPressIn={onPressIn}
         onPressOut={onPressOut}
         onFocus={onFocus}
         onKeyPress={onKeyPress}
         onLayout={onLayout}
         onScroll={onScroll}
         onSelectionChange={onSelectionChange}
         onSubmitEditing={onSubmitEditing}
         // value="Hello World"
         // editable={false}
         // multiline
       />
     </View>
   );
 }

また、reactはv18.2.0、react-nativeはv0.72.6を使用しています。

onPressIn, onPressOut, onFocus

  • onPressIn: ({nativeEvent: PressEvent}) => void = TextInputの内部がタップされたとき
  • onPressOut: ({nativeEvent: PressEvent}) => void = onPressInの後、タップされている指がはなれた時
  • onFocus:({nativeEvent: LayoutEvent}) => void = TextInputにフォーカスが当たった時

ということで、よく使うし名前を見れば直感的に理解ができます。

また、これらの呼ばれる順番ですが、基本的にはonPressIn,onFocus,onPressOutの順番で呼ばれるようです。

簡単ですね。

って思ったんですけど、どうやらonPressOutは指を離さなくても、画面に触れたままTextInputの外に指を動かせば発火するようでした。

また、そのまま指を再度TextInputの中にいれるとonPressInも発火するようです。

そして、TextInputの外で指が離れた場合、TextInputへフォーカスは当たらず、onFocusは発火しないみたいでした。

タップして離す/タップして離さず動かす

知らなかった。

また、条件の詳細がわかっていないのですが、Androidで前述した操作を行いonPressIn,onPressOutを繰り返し発生させてから指を離すと、 「TextInputにフォーカスは当たっていないがテキスト貼り付けができる」状態になることがあるみたいです。

フォーカスが当たっていないのに貼り付けメニューが出ている時のスクショ。Pixel 7/ Android 13 で再現

困るシチュエーションは少なそうですが、バグっぽい挙動なのでReact NativeにPR送るチャンスなのかもしれません。

今度issueやコード見てみようかな。

onChange,onChangeText,onKeyPress

  • onChange:({nativeEvent: {eventCount, target, text}}) => void = TextInputの中身のテキストが変わった時に呼ばれる
  • onChangeText:(text: string) => void = TextInputの中身のテキストが変わった時に呼ばれる。引数には変更後のテキストだけが渡される
  • onKeyPress: ({nativeEvent: {key: keyValue} }) => void = キーボードが押された時に呼ばれる。

名前から想像がつきますね。

大体のケースで、中身の変更を捕捉したい時はonChangeTextで事足りるように思います。

onChangeを使うと、コンポーネントがマウントされてから何回テキストが変更されたかの情報がeventCountから取得できるので、 テキストの変更履歴を一定間隔で保存しておく、のような仕様で実装したい時には使えるかもしれません。

onKeyPressは、キーボードが押下された時に発火するようです。

文字だけでなく、EnterBackspaceが押された時にも発火するので、エンターキーが押された時に特定の処理を差し込みたい! という時に便利かもしれないですね。

ちなみに、キーボードの種類(日本語/英語/記号/絵文字)を切り替えるボタンを押した時には発火しないように作られています。ありがたいですね。

しかしこのonKeyPress、キーボード上に表示される予測変換を選択した時にも発火するようです。

iOS(少なくともiOS17)については予測変換の単語選択の全てでonKeyPressが発火し、単語の直後に空白スペースが入る時も発火するようです。

Androidの場合は、入力中の文字に対する予測変換の選択時とその直後の空白スペースの自動入力時には発火しましたが、その後出てくる予測変換の選択では発火しませんでした。

予測変換を選択した時にonKeyPressが2度発火する様子

これは認識しておいた方がよさそう。

また、この挙動があるので「エンターキーやバックスペースキーを実際に押した時」と「予測変換からEnterやBackspaceを選択した時」の区別をつけられなさそうです🤔

エンターキーを押した時と予測変換の「Enter」を選択した時のkeyValueが'Enter'なので区別がつかない

これまで何度か、このonKeyPressで以下のような分岐を書いていたのですが

if (keyValue === 'Enter') {
  // do something
}

意図しない発火を捕捉できていなさそうだ・・・。

これって解決方法あるんですかね?🤔

もしご存じの方がいたら教えてください!

また、日本語のローマ字入力などをする場合は、キーボードがアルファベットを他の言語へ自動で変換する時にもonKeyPressは呼ばれるようなので注意が必要そうでした。

日本語ローマ字入力でonKeyPressが発火する様子

onBlur ,onEndEditing,onSubmitEditing

  • onBlur: function = TextInputからフォーカスが外れた時に呼ばれる
  • onEndEditing: function = TextInputのテキスト入力が終わった時に呼ばれる
  • onSubmitEditing: ({nativeEvent: {text, eventCount, target}}) => void = submitボタンが押された時に呼ばれる

ドキュメントを読む限り、入力が終わった時のテキストを取得したい場合はonEndEditingonSubmitEditingを使うのがよいらしいです。

oBlurでもその時のテキストを引数から取得できたのですが、推奨されていませんでした。

onSubmitEditingは、「submitボタンを押してフォーカスが外れる時」には発火し、「他の要素をタップしてフォーカスが外れる時」は発火しないようでした。

「submitボタンを押してフォーカスが外れる時」の発火順

  1. onSubmitEditing
  2. onEndEditing
  3. onBlur

「他の要素をタップしてフォーカスが外れる時」の発火順

  1. onEndEditing
  2. onBlur

これら2つの動作を区別して処理を分けたい場合は、onSubmitEdigingが呼ばれた時にコンポーネントのstateを操作し、onEndEditingでそのstateを参照することで実現できそうです。

const [isSubmitFlow,setIsSubmitFlow] = useState(false);
  const onSubmitEditing = (e) => {
    setIsSubmitFlow(true);
    // do something
  };
  const onEndEditing = (e) => {
    if(!isSubmitFlow){
      // do something
      return;
    }
    // do other things
  };
  const onBlur = (e) => {
    // do something
    setIsSubmitFlow(false);
  };

onLayout, onScroll, onContentSizeChange, onSelectionChange

  • onLayout: ({nativeEvent: LayoutEvent}) => void = 要素がマウントされた時と、レイアウトに変更があった時に呼ばれます
  • onScroll: {nativeEvent: {contentOffset: {x, y} }}) => void = TextInputの中身がスクロールされた時に呼ばれます
  • onContentSizeChange: ({nativeEvent: {contentSize: {width, height} }}) => void = TextInputの中身の大きさに変更があった時に呼ばれます
  • onSelectionChange: ({nativeEvent: {selection: {start, end} }}) => void = 選択中のテキスト範囲に変更があった時に呼ばれます。

ここら辺は名前の通りの挙動かなと思います。

が、onSelectionChangeについてはテキスト選択中の時のみ発火するのではなく、 カーソルの位置が変わった時にも発火するので注意が必要です。

startとendが同じ値の時は単純なカーソル位置、startとendが違う場合は選択中の範囲を示します。

そして、ローマ字入力で母音を入力する時にはonSelectionChangeが2回発火するようでした。

ローマ字日本語入力では母音の入力でonSelectionChangeが2回発火する様子

おわりに

実際に動かしてみると、細かい挙動で認識していないものがあったので勉強になりました。

それぞれの挙動は時間が経てば忘れてしまいそうですが、

「ちょっと注意しなければいけない挙動があるぞ」

という認識を持てたので、今後TextInputを使って多少複雑な処理を書く時に気を付けることができそうです。

また、今までTextInputを使う時はコンポーネントのstateとTextInputvalueonChangeTextでリアルタイムに同期させる処理を深く考えずに書いていたのですが

const [value, setValue] = useState("Hello World");
  const onChangeText = (text) => {
    setValue(text);
  }
  return <TextInput value={value} onChangeText={onChangeText} />;

リアルタイムで中身を参照する必要がないのなら、onEndEditingで入力が終わった時だけ同期するのでもいいのか?と思ったりしました。

  const [value, setValue] = useState("");
  const onEndEditing = (e) => {
    setValue(e.nativeEvent.text);
  }
  return <TextInput onEndEditing={onEndEditing} defaultValue="Hello World"/>;

また時間がある時に考えてみようと思います。


この記事は

React Native Advent Calendar 2023 3日目の記事でした!