「入力欄の上に『完了』ボタンを1つ置くだけ」のつもりで始めた作業が、iOS と Android で別物の実装になり、半日溶けたことがあります。私自身、2014 年から個人開発でアプリを出していて、壁紙や癒し系のアプリにユーザーがメモやひとことを書ける欄を足したのがきっかけでした。iOS では InputAccessoryView という専用部品があっさり動くのに、同じコードを Android で走らせると、ツールバーが影も形も出てこないのです。
公式ドキュメントの InputAccessoryView のページには「iOS のみ」と小さく書いてあります。Rork が生成するフォーム画面はこの差を吸収してくれないので、Android 側は自分で組むしかありません。ここでは、その2つを最終的に <KeyboardToolbar> という1つの再利用コンポーネントへ畳むまでの手順を、つまずいた箇所ごとに残しておきます。
なぜ「キーボードの上のバー」だけが二度手間になるのか
キーボードの上に固定するツールバーには、地味ですが確かな価値があります。日本語入力では変換確定の取りこぼしが起きやすく、「完了」ボタンで明示的に確定させてからフォーカスを外す導線があると、入力の取りこぼしがはっきり減ります。クイック挿入ボタン(定型文・絵文字・記号)を置けば、テキスト中心の画面の回遊が伸びます。
問題は、iOS と Android でこのバーの出し方がまったく違うことです。iOS の InputAccessoryView は、システムがキーボードに物理的にくっつけて動かしてくれる特別なビューです。キーボードが上がればバーも一緒に上がり、下がれば一緒に下がります。一方 Android には等価のものがありません。KeyboardAvoidingView は「入力欄をキーボードの上へ逃がす」部品であって、「キーボードに貼り付くバー」を描いてはくれません。
つまり、iOS は「専用部品に乗せる」、Android は「キーボード高さを測って自分で絶対配置する」という、設計の異なる2系統を書き分け、最後に同じ見た目へ揃える必要があります。
iOS 側:InputAccessoryView と nativeID を結ぶ
iOS は素直です。InputAccessoryView に一意な nativeID を与え、同じ値を TextInput の inputAccessoryViewID に渡すだけで、その入力欄にフォーカスが当たったときにバーがキーボードへ吸着します。
// IosAccessory.tsx — iOS 専用。Android では描画されない
import { InputAccessoryView, TextInput, View, Pressable, Text, Keyboard } from "react-native";
const ACCESSORY_ID = "memo-toolbar";
export function MemoInputIOS() {
return (
<>
<TextInput
// この ID で下のバーと紐づく
inputAccessoryViewID={ACCESSORY_ID}
placeholder="ひとことメモ"
multiline
style={{ minHeight: 96, padding: 12, fontSize: 16 }}
/>
<InputAccessoryView nativeID={ACCESSORY_ID}>
<View
style={{
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
paddingHorizontal: 12,
height: 44,
backgroundColor: "#f2f2f7",
borderTopWidth: 0.5,
borderTopColor: "#c6c6c8",
}}
>
<Pressable onPress={() => {/* 定型文を挿入する処理 */}}>
<Text style={{ fontSize: 16, color: "#007aff" }}>定型文</Text>
</Pressable>
<Pressable onPress={() => Keyboard.dismiss()}>
<Text style={{ fontSize: 16, fontWeight: "600", color: "#007aff" }}>完了</Text>
</Pressable>
</View>
</InputAccessoryView>
</>
);
}
このコードを iOS の実機で動かすと、入力欄をタップした瞬間にキーボードの最上段へグレーのバーが現れ、「完了」を押すとキーボードと一緒に消えます。Keyboard.dismiss() がフォーカスを外し、IME の未確定文字もそこで確定します。
ところが、この MemoInputIOS をそのまま Android で開くと、InputAccessoryView の中身は一切描画されません。エラーも警告も出ないので、最初は「自分のスタイル指定が悪いのか」と無駄に疑ってしまいます。Android には別の足場が要ります。
Android には InputAccessoryView が無い — 代わりに何を置くか
Android で同じ体験を作るには、「キーボードがいま何ピクセル分の高さを占めているか」を自分で知り、その分だけ画面下から持ち上げた位置にバーを絶対配置します。鍵になるのは React Native の Keyboard イベントです。keyboardDidShow と keyboardDidHide(Android では keyboardDidShow 系が安定)で、event.endCoordinates.height からキーボード高さが取れます。
まず注意したいのが android:windowSoftInputMode の挙動です。Expo(Rork が出力する構成)の既定は adjustResize に寄っており、これだとルートビューがキーボード分だけ縮みます。バーを「画面下から keyboardHeight だけ上」に置く設計では、adjustResize のリサイズと二重に効いて、バーが意図より上に飛ぶことがあります。私は Android では入力画面だけ adjustPan 相当の前提で組み、KeyboardAvoidingView をこの画面では使わない、と割り切っています。混ぜると原因切り分けが一気に難しくなるからです。
キーボード高さを測る小さなフック
iOS と Android の差を1か所に閉じ込めるため、キーボードの「高さ」と「表示中か」だけを返すフックを用意します。iOS は keyboardWillShow(アニメーションに先んじて滑らかに追従できる)、Android は keyboardDidShow を使い分けます。
// useKeyboardHeight.ts
import { useEffect, useState } from "react";
import { Keyboard, Platform } from "react-native";
export function useKeyboardHeight() {
const [height, setHeight] = useState(0);
useEffect(() => {
// iOS は will 系の方がバーの追従が滑らか。Android は did 系が確実
const showEvt = Platform.OS === "ios" ? "keyboardWillShow" : "keyboardDidShow";
const hideEvt = Platform.OS === "ios" ? "keyboardWillHide" : "keyboardDidHide";
const showSub = Keyboard.addListener(showEvt, (e) => {
setHeight(e.endCoordinates.height);
});
const hideSub = Keyboard.addListener(hideEvt, () => setHeight(0));
return () => {
showSub.remove();
hideSub.remove();
};
}, []);
return { keyboardHeight: height, isVisible: height > 0 };
}
endCoordinates.height は、Android では端末のナビゲーションバー(ジェスチャーバー)込みの値が返る端末と、そうでない端末が混在します。ここを実機で確かめずに数式を決めると、機種によって数ピクセルずれます。私は手元の数台で必ず console.log(e.endCoordinates) を一度流してから、後述のセーフエリア補正を入れています。
2系統を1つの部品に畳む
ここまでの iOS 専用ルートと Android 自前ルートを、呼び出し側からは同じに見える <KeyboardToolbar> へまとめます。中身を Platform.OS で分岐させ、外からは nativeID と子要素(ボタン群)だけを渡せば済む形にします。
// KeyboardToolbar.tsx
import React from "react";
import { InputAccessoryView, Platform, View, Animated, Keyboard } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useKeyboardHeight } from "./useKeyboardHeight";
type Props = {
nativeID: string; // iOS で TextInput と結ぶ ID
children: React.ReactNode; // バーの中身(ボタン群)
};
const BAR_HEIGHT = 44;
export function KeyboardToolbar({ nativeID, children }: Props) {
const insets = useSafeAreaInsets();
// iOS: システムがキーボードへ吸着させてくれる
if (Platform.OS === "ios") {
return (
<InputAccessoryView nativeID={nativeID}>
<View style={{ height: BAR_HEIGHT, ...barStyle }}>{children}</View>
</InputAccessoryView>
);
}
// Android: キーボード高さ分だけ持ち上げて絶対配置
return <AndroidBar insets={insets}>{children}</AndroidBar>;
}
function AndroidBar({ insets, children }: { insets: { bottom: number }; children: React.ReactNode }) {
const { keyboardHeight, isVisible } = useKeyboardHeight();
if (!isVisible) return null; // キーボードが無いときはバーも出さない
// 端末によって endCoordinates にナビバー分が含まれるため、含まれない端末向けに
// insets.bottom を足し引きして実機で合わせる(ここは数台で要確認)
const bottom = keyboardHeight;
return (
<View
pointerEvents="box-none"
style={{ position: "absolute", left: 0, right: 0, bottom }}
>
<View style={{ height: BAR_HEIGHT, ...barStyle }}>{children}</View>
</View>
);
}
const barStyle = {
flexDirection: "row" as const,
justifyContent: "space-between" as const,
alignItems: "center" as const,
paddingHorizontal: 12,
backgroundColor: "#f2f2f7",
borderTopWidth: 0.5,
borderTopColor: "#c6c6c8",
};
呼び出し側はこうなります。iOS では inputAccessoryViewID が効き、Android では KeyboardToolbar が自前バーを描くため、画面の JSX は1つで済みます。
// MemoScreen.tsx — iOS / Android 共通
import { TextInput, Pressable, Text, Keyboard, Platform } from "react-native";
import { KeyboardToolbar } from "./KeyboardToolbar";
const ID = "memo-toolbar";
export function MemoScreen() {
return (
<>
<TextInput
inputAccessoryViewID={Platform.OS === "ios" ? ID : undefined}
placeholder="ひとことメモ"
multiline
style={{ minHeight: 96, margin: 16, padding: 12, fontSize: 16 }}
/>
<KeyboardToolbar nativeID={ID}>
<Pressable onPress={() => {/* 定型文を挿入 */}}>
<Text style={{ fontSize: 16, color: "#007aff" }}>定型文</Text>
</Pressable>
<Pressable onPress={() => Keyboard.dismiss()}>
<Text style={{ fontSize: 16, fontWeight: "600", color: "#007aff" }}>完了</Text>
</Pressable>
</KeyboardToolbar>
</>
);
}
これで、iOS は OS 任せの吸着、Android は計測ベースの絶対配置、という別実装を、画面側からは意識せずに使えます。Rork が生成したフォーム画面 を後から直す場合も、TextInput の隣にこの部品を1つ置くだけで済みます。
セーフエリアとホームインジケーターの重なりを解く
Android のジェスチャーナビゲーション端末では、endCoordinates.height がジェスチャーバーを含むかどうかが分かれます。含まない端末でそのまま bottom: keyboardHeight にすると、バーがホームインジケーターと数ピクセル重なって、押しにくくなります。私は react-native-safe-area-context の insets.bottom を持っておき、実機で重なりが出た機種だけ bottom = keyboardHeight + insets.bottom に切り替える、という二択で運用しています。全機種を一律の数式で合わせようとせず、「重なる/重ならない」の2系統に割り切るほうが、結果的に破綻しませんでした。
iOS 側はシステムがセーフエリアごと面倒を見るため、InputAccessoryView の中で insets.bottom を足してはいけません。足すと逆に余白が二重になります。プラットフォームごとに「誰がセーフエリアを持つか」が違う、という点が、ここでいちばん混乱しやすいところです。
複数の入力欄で1本のバーを共有する
入力欄が複数ある画面では、欄ごとにバーを書くと、フォーカスを移したときに一瞬2本見えてちらつきます。iOS は同じ inputAccessoryViewID を複数の TextInput に渡せば、1つの InputAccessoryView を共有できます。バー側のボタンが「いまフォーカスされている欄」に対して働くよう、現在の入力欄を ref で持っておくのが安定します。
import { useRef } from "react";
import { TextInput } from "react-native";
const focusedRef = useRef<TextInput | null>(null);
// 各 TextInput に
<TextInput
inputAccessoryViewID={ID}
onFocus={(e) => { focusedRef.current = e.target as unknown as TextInput; }}
/>
// バーのボタンから focusedRef.current?.setNativeProps(...) などで対象を操作
Android では KeyboardToolbar 自体が画面に1つあれば足ります。useKeyboardHeight はキーボードの表示状態だけを見ているので、どの欄にフォーカスがあっても同じバーが出ます。対象の欄を区別したいときは、iOS と同様に onFocus で現在の欄を覚えておきます。
実運用でつまずいた4点
ひとつ目は、autoFocus との相性です。画面を開いた瞬間に autoFocus でキーボードを上げると、Android では keyboardDidShow の発火前にレイアウトが確定してしまい、初回だけバーが出ないことがありました。マウント直後に一度だけ短い遅延を挟むか、autoFocus をやめて明示タップに変えると安定します。
ふたつ目は、モーダルの中での挙動です。Modal 越しに InputAccessoryView を使うと、iOS でバーがモーダルの背面に隠れる事例がありました。入力を伴う画面はフルスクリーンの画面遷移にして、Modal の中にテキスト入力を置かない方針にしてから、この種の不具合は出ていません。
みっつ目は、バーを押したのにキーボードが閉じる問題です。バーの Pressable を押すと、いったん TextInput が blur してキーボードが下がり、Android のバーが消えてしまうことがあります。Android 側のバーには pointerEvents の扱いと、ボタン押下時に Keyboard.dismiss() を呼ばない限りフォーカスを保つ設計(onPress 内で入力欄へ focus() を戻す)を入れて回避しました。
よっつ目は、計測値のずれを収益面まで結びつけて確認したことです。私の壁紙・癒し系アプリではテキスト入力画面の滞在が伸びると、その後の画面遷移が増え、AdMob の表示機会も少し増えます。バーが機種ごとにずれて押しにくいと、ここが静かに目減りします。数式を1つに固めず、手元の数台で実測してから配信する、という地味な手順を私は省きません。
次の一手
まずは useKeyboardHeight フックだけを既存のテキスト入力画面に差し込み、console.log で自分の端末の endCoordinates を1回確認してみてください。そこで返る高さにナビバーが含まれるかどうかが分かれば、bottom の数式は2択に収束します。iOS の InputAccessoryView は素直なので、土台になるのは結局この Android 側の計測です。
入力まわりでは、フォーカス時に文字が欠ける・カーソルが飛ぶといった別系統の不具合も絡みやすいので、合わせて RorkアプリのTextInputで文字が消える・カーソルが飛ぶ問題の対処 と、キーボードが入力欄を覆う場合の キーボードが入力フィールドを隠す問題の対処 も参照すると、入力画面全体を一度に整えられます。セーフエリアの重なりが気になる場合は セーフエリア・ノッチ表示の調整 も役に立ちます。同じところで止まった方の参考になれば幸いです。