adjustResize を書いたのに、Android 16 ではキーボードが入力欄を隠したままだった
勤怠アプリ(React Native 0.86 / Expo SDK 57)のホーム画面に、その場でカテゴリを足せる入力欄を置いた。Android ではキーボードは出るのに、入力欄はその下に隠れたままだった。softwareKeyboardLayoutMode: 'resize' は設定してあり、生成されたマニフェストにも adjustResize が入っている。原因は Android 16 で必須になった edge-to-edge で、ウィンドウがキーボードの分だけ縮まなくなっていた。入力欄・ScrollView・キーボードの上端を画面座標で測って scrollTo する 41 行のフックで直した。
adjustResize は書いてあるのに、タブバーごとキーボードの下に消えた
入力欄はホーム画面の Animated.ScrollView の中ほどから下寄りにあり、ScrollView の下には兄弟としてタブバーが並ぶ。「+ ふやす」チップを押すと autoFocus 付きの TextInput が出てキーボードが上がる。ここまでは期待どおりで、入力欄だけが見えない。
決め手はスクリーンショットだった。画面下部にあるはずのタブバーが無い。キーボードの直上には「いま やっていること」のチップの上端だけが見えている。adjustResize が効いてウィンドウが縮んでいるなら、タブバーはキーボードの上に残るはずだ。入力欄が隠れているのではなく、画面の下の帯がまるごとキーボードの裏に回っている。
先に試したのは autoFocus をやめて配置後に focus() を呼ぶ案で、結果は同じだった。フォーカスのタイミングの問題ではない。
見落とした理由は、効くはずの根拠が3つ揃っていたことにある。app.config.js の resize は prebuild 後の AndroidManifest.xml に android:windowSoftInputMode="adjustResize" として実在する。ScrollView は KeyboardAvoidingView で包んであり、Expo の Keyboard handling ガイドは Android なら KeyboardAvoidingView を置くだけで入力欄は覆われないと書いている。softwareKeyboardLayoutMode の型定義の説明も、android:windowSoftInputMode に対応すると言うだけで、効かなくなる条件には触れていない。
ウィンドウが縮んでいなかった
推測を止めて測った。keyboardDidShow の endCoordinates と、ScrollView と TextInput を measureInWindow した結果を Metro のコンソールに出す。単位は dp。
LOG [kbd] didShow {"width":392.7272644042969,"screenX":0,"screenY":572,"height":254.90908813476562} true true
LOG [kbd] scroll 0 754.5454711914062 input 717.45458984375 47.272705078125キーボードの上端は screenY = 572。ScrollView は top 0、高さ 754.5 で、キーボードが出ても画面いっぱいのまま。入力欄の top は 717.45 で、キーボードの上端より下にある。ウィンドウが縮んでいれば ScrollView の高さは 572 付近まで落ちるはずで、そうなっていない以上、ScrollView の側には「隠れている」という情報が一切届いていない。
縮まない理由は edge-to-edge だった。prebuild が生成する gradle.properties には edgeToEdgeEnabled=true が入っていて、SDK 57 の prebuild-config はこれを切る設定を受け付けない。
// node_modules/@expo/prebuild-config/build/plugins/unversioned/edge-to-edge/withEdgeToEdge.js
if ('edgeToEdgeEnabled' in (config.android ?? {})) {
_configPlugins().WarningAggregator.addWarningAndroid(TAG, '`edgeToEdgeEnabled` customization is no longer available - Android 16 makes edge-to-edge mandatory. Remove the `edgeToEdgeEnabled` entry from your app.json/app.config.js.');
}edge-to-edge と adjustResize の相性は、公式というより周辺が先に書いていた。react-native-edge-to-edge の README は、edge-to-edge を有効にすると adjustResize によるキーボード管理が壊れ、代替手段が要ると明記している。Expo 側の設定の説明にはその一文が無く、Android 16 でその前提が必須になった。
設定が書いてあることと、その設定が前提にしていた挙動が残っていることは別だ。adjustResize はマニフェストに残ったまま、それが頼っていた「ウィンドウが縮む」だけが OS 側で消えた。
入力欄とキーボードの上端を画面座標で測って送る
ScrollView が何も知らないなら、こちらで測って送るしかない。見える下端を「ScrollView の下端」と「キーボードの上端」の低いほうと定義する。iOS のように KeyboardAvoidingView の padding で ScrollView 自体が縮むなら前者が、Android のようにキーボードが画面に重なるなら後者が効く。入力欄の下端がそれより下にあるときだけ scrollTo する。
厄介なのは、いまのスクロール量が ScrollView から取れないことだ。scrollTo は中身座標の絶対位置を受け取るので、中身の View をもう1回 measureInWindow して、入力欄の画面座標から中身の画面座標を引いて中身座標に直す。測るのは3回で、フックはこれで全部になる。
import { useEffect, type RefObject } from 'react'
import { Keyboard, type HostInstance, type ScrollView, type TextInput } from 'react-native'
import type Animated from 'react-native-reanimated'
import type { AnimatedRef } from 'react-native-reanimated'
/** RN 0.86 の型定義にない undocumented API。中身の View のネイティブ参照を返す */
type ScrollViewWithInnerRef = ScrollView & { getInnerViewRef?: () => HostInstance | null }
/**
* キーボードが出たとき、入力欄がその下に隠れていたら見える位置まで送る。
* Android は ScrollView が勝手に送ってくれないので、入力欄と ScrollView を画面座標で測って比べる。
* 見える下端は「ScrollView の下端」と「キーボードの上端」の低いほう。ウィンドウが縮むとき
* (iOS の padding)は前者、キーボードが画面に重なるとき(edge-to-edge の Android)は後者が効く。
*/
export function useScrollIntoViewOnKeyboard(
scrollRef: AnimatedRef<Animated.ScrollView> | undefined,
inputRef: RefObject<TextInput | null>,
margin = 12,
) {
useEffect(() => {
const subscription = Keyboard.addListener('keyboardDidShow', (event) => {
const scroll = scrollRef?.current as ScrollViewWithInnerRef | null | undefined
const scrollHost = scroll?.getNativeScrollRef()
const innerHost = scroll?.getInnerViewRef?.()
const input = inputRef.current
if (!scroll || !scrollHost || !innerHost || !input) return
scrollHost.measureInWindow((_x, scrollTop, _width, scrollHeight) => {
input.measureInWindow((_inputX, inputTop, _inputWidth, inputHeight) => {
const visibleBottom = Math.min(scrollTop + scrollHeight, event.endCoordinates.screenY)
if (inputTop + inputHeight + margin <= visibleBottom) return
// いまのスクロール量は取れないので、中身の View の画面位置から中身座標に直して絶対位置へ送る
innerHost.measureInWindow((_innerX, innerTop) => {
const contentY = inputTop - innerTop
scroll.scrollTo({ y: contentY + inputHeight + margin - (visibleBottom - scrollTop), animated: true })
})
})
})
})
return () => subscription.remove()
}, [scrollRef, inputRef, margin])
}使う側は ref を渡すだけで、scrollRef は App 側の useAnimatedRef を画面経由で配線した。「+ ふやす」を押すと入力欄がキーボードの直上に来て、カーソルが見えるようになった。
const inputRef = useRef<TextInput>(null)
useScrollIntoViewOnKeyboard(scrollRef, inputRef)
// ...
<TextInput ref={inputRef} value={name} autoFocus ... />捨てた案は2つある。1つは React Native 組み込みの scrollResponderScrollNativeHandleToKeyboard。内部の計算は入力欄の下端をキーボードの上端に合わせるもので、ScrollView の外にあるタブバーの高さは呼び出し側が additionalOffset として知っている前提になっている。iOS では ScrollView が縮み、Android では縮まずキーボードが重なるので、「見える下端」の決まり方が OS で違う。両方を1本で扱うには自前で測るほうが短かった。もう1つは react-native-keyboard-controller の導入で、ネイティブ依存が増えて APK の焼き直しが要る。入力欄ひとつのために払う代償ではない。
getInnerViewNode() は Fabric の measureLayout に渡せない
最初に書いた版は、入力欄の中身座標を TextInput#measureLayout で直接取ろうとして、基準に ScrollView#getInnerViewNode() の戻り値を渡していた。何も起きず、警告だけが出た。
ERROR Warning: ref.measureLayout must be called with a ref to a native component.
出どころは ReactNativeElement.js の measureLayout で、基準が ReactNativeElement のインスタンスでなければ __DEV__ でだけ警告を出してそのまま return する。コールバックは呼ばれない。本番ビルドなら警告も無く、ただ動かない。
// node_modules/react-native/Libraries/Components/ScrollView/ScrollView.js
getInnerViewNode: ScrollViewImperativeMethods['getInnerViewNode'] = () => {
return findNodeHandle<$FlowFixMe>(this._innerView.nativeInstance);
};
getInnerViewRef: ScrollViewImperativeMethods['getInnerViewRef'] = () => {
return this._innerView.nativeInstance;
};getInnerViewNode() は findNodeHandle を通した数値のノードハンドルで、getInnerViewRef() ならネイティブ参照そのものが返る。ところが ScrollView.d.ts には getInnerViewNode(): any と getNativeScrollRef はあるのに getInnerViewRef が無く、コメントに名前が出てくるだけだった。フック冒頭の ScrollViewWithInnerRef はそのための型で、無いかもしれないメソッドとして optional に足し、取れなければ何もせず抜ける。同じ理由で ScrollView のインスタンス自体には measureInWindow が無く、getNativeScrollRef() で取ったホスト参照に対して呼んでいる。
まとめ
adjustResize が効かなくなったのではない。設定はマニフェストに残り、設定の説明文も変わらず、その設定が前提にしていた「キーボードの分だけウィンドウが縮む」だけが Android 16 の edge-to-edge で既定から外れた。設定とドキュメントを何度読み直しても、この種の変化は見つからない。
決着をつけたのは、タブバーが消えている1枚のスクリーンショットと、572 / 754.5 / 717.45 の3つの数値だった。「設定した」と「効いている」の間に測れる事実を1つ置くと、推測が一度で終わる。
iOS は未検証だ。フックは KeyboardAvoidingView の padding で ScrollView が縮む側を Math.min の前者で拾う想定になっているが、実機で確かめてはいない。