React Native用の<InputAccessoryView>の構築
動機
3年前、React Nativeからinput accessory viewをサポートするためのGitHub issueが立てられました。

それからの数年間、このissueに対しては数え切れないほどの「+1」や様々な回避策が投稿されましたが、RNへの具体的な変更は今日までゼロでした。iOSを皮切りに、ネイティブのinput accessory viewにアクセスするためのAPIを公開します。そして、私たちがどのようにそれを構築したかをお伝えできることを楽しみにしています。
背景
input accessory viewとは一体何でしょうか?Appleの開発者ドキュメントを読むと、それはレシーバーがファーストレスポンダーになったときに、システムキーボードの上部に固定できるカスタムビューであることがわかります。UIResponder
から継承するものはすべて、.inputAccessoryView
プロパティを読み書き可能として再宣言し、ここでカスタムビューを管理できます。レスポンダーのインフラストラクチャがビューをマウントし、システムキーボードと同期させ続けます。ドラッグやタップのようなキーボードを閉じるジェスチャーは、フレームワークレベルでinput accessory viewに適用されます。これにより、iMessageやWhatsAppのようなトップクラスのメッセージングアプリに不可欠な機能である、インタラクティブなキーボード非表示を持つコンテンツを構築できます。
キーボードの上部にビューを固定する一般的なユースケースは2つあります。1つ目は、Facebookの投稿作成画面の背景ピッカーのようなキーボードツールバーを作成することです。

このシナリオでは、キーボードはテキスト入力フィールドにフォーカスしており、input accessory viewは追加のキーボード機能を提供するために使用されます。この機能は、入力フィールドのタイプに応じたコンテキストを持ちます。地図アプリケーションでは住所の提案であったり、テキストエディタではリッチテキストのフォーマットツールであったりします。
このシナリオで<InputAccessoryView>
を所有するObjective-CのUIResponderは明確であるべきです。<TextInput>
がファーストレスポンダーとなり、内部的にはこれはUITextView
またはUITextField
のインスタンスになります。
2つ目の一般的なシナリオは、スティッキーテキスト入力(追従するテキスト入力欄)です。

ここでは、テキスト入力自体がinput accessory viewの一部になっています。これはメッセージングアプリケーションでよく使用され、過去のメッセージのスレッドをスクロールしながらメッセージを作成できます。
この例では、誰が<InputAccessoryView>
を所有するのでしょうか?再びUITextView
やUITextField
でしょうか?テキスト入力はinput accessory viewの*内部*にあり、これは循環依存のように聞こえます。この問題だけで別のブログ記事が書けるほどです。ネタバレ:所有者は、私たちが手動でbecomeFirstResponderを呼び出す、汎用的なUIView
のサブクラスです。
API設計
これで<InputAccessoryView>
が何であるか、そしてそれをどのように使いたいかがわかりました。次のステップは、両方のユースケースにとって意味があり、<TextInput>
のような既存のReact Nativeコンポーネントとうまく機能するAPIを設計することです。
キーボードツールバーについては、考慮したい点がいくつかあります。
- 任意の汎用的なReact Nativeのビュー階層を
<InputAccessoryView>
にホイスティング(引き上げ)できるようにしたい。 - この汎用的で分離されたビュー階層が、タッチイベントを受け付け、アプリケーションの状態を操作できるようにしたい。
- 特定の
<TextInput>
に<InputAccessoryView>
をリンクさせたい。 - コードを複製することなく、複数のテキスト入力で
<InputAccessoryView>
を共有できるようにしたい。
#1は、Reactのポータルに似た概念を使用して達成できます。この設計では、React Nativeのビューを、レスポンダーのインフラストラクチャによって管理されるUIView
階層にポータルします。React NativeのビューはUIViewとしてレンダリングされるため、これは実際には非常に簡単です。私たちは単にオーバーライドすればよいのです。
- (void)insertReactSubview:(UIView *)subview atIndex:(NSInteger)atIndex
そして、すべてのサブビューを新しいUIView階層に送ります。#2については、<InputAccessoryView>
用に新しいRCTTouchHandlerを設定します。状態の更新は、通常のイベントコールバックを使用して実現します。#3と#4については、<TextInput>
コンポーネントの作成中に、nativeIDフィールドを使用してネイティブコード内でaccessory viewのUIView階層を特定します。この関数は、基礎となるネイティブテキスト入力の.inputAccessoryView
プロパティを使用します。これにより、ObjC実装において<InputAccessoryView>
と<TextInput>
が効果的にリンクされます。
スティッキーテキスト入力(シナリオ2)をサポートするには、さらにいくつかの制約が加わります。この設計では、input accessory viewは子としてテキスト入力を持つため、nativeIDによるリンクは選択肢になりません。代わりに、汎用的な画面外のUIView
の.inputAccessoryView
に、私たちのネイティブの<InputAccessoryView>
階層を設定します。この汎用的なUIView
に手動でファーストレスポンダーになるように指示することで、階層はレスポンダーのインフラストラクチャによってマウントされます。この概念は、前述のブログ記事で詳しく説明されています。
落とし穴
もちろん、このAPIを構築する過程ですべてが順風満帆だったわけではありません。ここでは、私たちが遭遇したいくつかの落とし穴と、それらをどのように修正したかを紹介します。
このAPIを構築するための初期のアイデアの1つは、UIKeyboardWill(Show/Hide/ChangeFrame)イベントに対してNSNotificationCenter
をリッスンすることでした。このパターンは、いくつかのオープンソースライブラリやFacebookアプリの一部の内部で使用されています。残念ながら、スワイプ時に<InputAccessoryView>
のフレームを更新するには、UIKeyboardDidChangeFrame
イベントが間に合いませんでした。また、キーボードの高さの変更もこれらのイベントでは捕捉されません。これにより、次のようなバグが発生します。

iPhone Xでは、テキストキーボードと絵文字キーボードの高さが異なります。キーボードイベントを使用してテキスト入力フレームを操作していたほとんどのアプリケーションは、上記のバグを修正する必要がありました。私たちの解決策は、.inputAccessoryView
プロパティを使用することにコミットすることでした。これにより、レスポンダーのインフラストラクチャがこのようなフレームの更新を処理してくれることになります。
私たちが遭遇したもう1つの厄介なバグは、iPhone Xのホームピル(ホームインジケータ)を避けることでした。皆さんは、「AppleはこのためにsafeAreaLayoutGuideを開発したのだから、これは些細なことだ!」と思うかもしれません。私たちも同じくらい甘く考えていました。最初の問題は、ネイティブの<InputAccessoryView>
実装には、表示される直前までアンカーとなるウィンドウがないことです。それは問題ありません。-(BOOL)becomeFirstResponder
をオーバーライドして、そこでレイアウト制約を適用すればよいのです。これらの制約に従うと、accessory viewは上に押し上げられますが、別のバグが発生します。
input accessory viewはホームピルを正常に回避しますが、今度はアンセーフエリアの背後にあるコンテンツが見えてしまいます。解決策は、このradarにあります。私は、ネイティブの<InputAccessoryView>
階層を、safeAreaLayoutGuide
制約に準拠しないコンテナでラップしました。ネイティブコンテナはアンセーフエリアのコンテンツを覆い、一方で<InputAccessoryView>
はセーフエリアの境界内に留まります。
使用例
以下は、<TextInput>
の状態をリセットするためのキーボードツールバーボタンを構築する例です。
class TextInputAccessoryViewExample extends React.Component<
{},
*,
> {
constructor(props) {
super(props);
this.state = {text: 'Placeholder Text'};
}
render() {
const inputAccessoryViewID = 'inputAccessoryView1';
return (
<View>
<TextInput
style={styles.default}
inputAccessoryViewID={inputAccessoryViewID}
onChangeText={text => this.setState({text})}
value={this.state.text}
/>
<InputAccessoryView nativeID={inputAccessoryViewID}>
<View style={{backgroundColor: 'white'}}>
<Button
onPress={() =>
this.setState({text: 'Placeholder Text'})
}
title="Reset Text"
/>
</View>
</InputAccessoryView>
</View>
);
}
}
いつから利用できますか?
この機能実装の完全なコミットはこちらです。<InputAccessoryView>
は、次のv0.55.0リリースで利用可能になります。
ハッピーキーボーディング :)