Architecture2026.08.23 · 6 min read

adjustResize を書いたのに、Android 16 ではキーボードが入力欄を隠したままだった

NCP認証基盤・クラウドインフラ

勤怠アプリ(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。

Metro console(Android 16 エミュレータ、1080×2340 / 440dpi)
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 はこれを切る設定を受け付けない。

@expo/prebuild-config の edge-to-edge プラグイン
// 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回で、フックはこれで全部になる。

src/app/hooks/useScrollIntoViewOnKeyboard.ts
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 を画面経由で配線した。「+ ふやす」を押すと入力欄がキーボードの直上に来て、カーソルが見えるようになった。

src/app/screens/home/CategoryPicker.tsx(NewCategoryRow)
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() の戻り値を渡していた。何も起きず、警告だけが出た。

Metro console
ERROR  Warning: ref.measureLayout must be called with a ref to a native component.

出どころは ReactNativeElement.js measureLayout で、基準が ReactNativeElement のインスタンスでなければ __DEV__ でだけ警告を出してそのまま return する。コールバックは呼ばれない。本番ビルドなら警告も無く、ただ動かない。

getInnerViewNode と getInnerViewRef の違い
// 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 の前者で拾う想定になっているが、実機で確かめてはいない。

React NativeAndroidExpoKeyboard