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

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

このシナリオでは、キーボードはテキスト入力フィールドにフォーカスされており、入力補助ビューは追加のキーボード機能を提供するために使用されます。この機能は入力フィールドの種類に固有です。マッピングアプリケーションでは住所の提案、テキストエディタではリッチテキストの書式設定ツールなどが考えられます。
このシナリオで<InputAccessoryView>
を所有するObjective-CのUIResponderは明確であるべきです。<TextInput>
がファーストレスポンダーになり、内部的にはUITextView
またはUITextField
のインスタンスになります。
2つ目の一般的なシナリオは、スティッキーテキスト入力(追従するテキスト入力欄)です。

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

iPhone Xでは、テキストキーボードと絵文字キーボードの高さが異なります。キーボードイベントを使用してテキスト入力フレームを操作するほとんどのアプリケーションは、上記のバグを修正する必要がありました。私たちの解決策は、.inputAccessoryView
プロパティを使用することにコミットすることでした。これにより、レスポンダーインフラストラクチャがこのようなフレームの更新を処理します。
iPhone Xのホームピルを避けるという、もう1つの厄介なバグに遭遇しました。「Appleは、まさにこの目的のためにsafeAreaLayoutGuide
を開発しました。これは些細なことです!」と考えるかもしれません。私たちも同じくらい世間知らずでした。最初の問題は、ネイティブの<InputAccessoryView>
実装には、表示されるまでアンカーするウィンドウがないことです。それは問題ありません。-(BOOL)becomeFirstResponder
をオーバーライドし、そこでレイアウト制約を適用できます。これらの制約に従うとアクセサリビューが上に移動しますが、別のバグが発生します。
インプットアクセサリビューはホームピルを回避することに成功しましたが、安全でない領域の後ろのコンテンツが見えるようになりました。解決策は、このレーダーにあります。ネイティブの<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で利用可能になります。
ハッピーキーボーディング :)