React Native用の<InputAccessoryView>の構築
動機
3年前、React Nativeからinput accessory viewをサポートするためのGitHub issueが立てられました。
長年にわたり、この問題に関して数え切れないほどの「+1」やさまざまな回避策がありましたが、今日まで RN に具体的な変更はありませんでした。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>を共有できるようにしたい。
React ポータルに似た概念を使用して #1 を実現できます。この設計では、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 のホームピルを避けることでした。「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 リリースで利用可能になります。
ハッピーキーボーディング :)