最近AndroidアプリをAndroid14→16へバージョンアップしていた中で、レイアウトが崩れる問題に陥りました。
その際結構解決に苦労したので、この記事ではバージョンアップで何が起きたか、なぜ対応にハマったのか、どう対処したかを備忘録も兼ねてまとめました。
読んでほしい人
- Androidのバージョンアップで、変な余白が生まれて困っている人。
(Edge-to-Edge問題でよくある、システムバーにコンテンツが重なってしまう問題とは異なります!)
※頻出するScaffoldに関しては、詳細説明は省くのでまだ知らない方はこちらを参考にしてください。
背景
2025年8月31日以降、GooglePlayStoreで公開されるアプリはAPI35(Android 15)以上を対象にすることが必須になり、機能修正を予定していた私たちも最新バージョン(Android16)へ更新を実施しました。
何が起きたか
Android14→16に更新後、以下の問題が発生しました。
- 画面の上部のタイトル上に謎の余白が生まれる
- ボトムバーの上にも余白が出る
実際のイメージだとこんな感じです
原因:Edge-to-Edgeの強制
これまでの実装では、
- アプリの描画領域がsystemBarsを避けた安全領域に制限される
という前提のもとで、UIが成り立っていました。
しかし、Android15以上で、Edge-To-Edgeが強制され、
上下のシステムバーの調整が必要になったのです。
結果、Insetsを考慮していなかった私たちのアプリが崩れる問題が発生しました。
なぜハマったのか
なぜ原因がわかっているのにハマったのか。2つの理由を以下で説明します。
1. そもそもInsetsの知識が曖昧だった
当時は、ScaffoldやWindowInsetsについて、レイアウトをいい感じに整えてくれるものなんだ〜ぐらいの感覚でした。
しかし、対応中に特定のInsetsを触ると他の部分でレイアウトが崩れたりと問題が起こり、理解が曖昧だったことを痛感しました。
Edge-to-Edge強制される前とされた後のInsetsの違い
実際のコード
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ParentScreen() {
Scaffold(
bottomBar = {
BottomAppBar {
Text(
"BottomBar",
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center
)
}
}
) { innerPadding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
) {
ChildScreen()
}
}
}
Android14以前:
デフォルトはEdge-to-Edge無効
→コンテンツが、システムバーやカットアウト分を避けた、安全領域に描画されていた
→システムバーを考慮する必要がない
上記のようにScaffoldにcontentWindowInsetsを指定していない場合は、
アプリバー分(上ではBottomAppBar)だけの余白が自動的に計算されてinnerPaddingに入る。
Android15以降:
Edge-to-Edge強制
→描画領域がシステムバーの部分まで広がり、Insetsにバー部分が含まれるようになった
→適切に処理していないと画面が崩れる
ただし上記コードのように、innerPaddingを適切に中のpaddingに当てて処理していると、システムバー分はいい感じに適用される。
アプリバーについて
ちなみにScaffoldと合わせてTopAppBarやBottomAppBarを使用している場合、
これらのアプリバーは自動的に内部でステータスバーやナビゲーションバーまで含めて計算してくれます。
例えば上のコードでは、bottomBarの中でBottomAppBarを使っているため、innerPaddingにはBottomAppBar自体の高さだけでなく、ナビゲーションバー分の余白も含まれています。
[ Content ] ← (BottomAppBar自体の高さ + navigationBarの高さ)がinnerPaddingとして計算される
[ BottomAppBar ] ← 自動でnavigationBarのInsetsを適用
[ navigationBar ]
まずこのあたりの理解が足りておらず、どのInsetsがどう影響しているのか調べるのに手間取りました。
2. Scaffoldがネストされていた
上のコードでは、SaffoldのInsetsは適切に制御できているように見えます。
ではなぜ崩れたのでしょうか
原因は、Scaffoldのネスト構造です。
既存の構造では、グローバルに使うScaffold + 決まった画面内だけで使うScaffold
の2つが重ねて使用されていました。
上のコード内のChilldScreen()を辿ると、さらにScaffoldが存在していたのです。
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ChildScreen() {
Scaffold(
topBar = {
TopAppBar(title = {
Text(
"画面タイトル",
style = MaterialTheme.typography.headlineLarge
)
},
)
}
) {paddingValues ->
LazyColumn(contentPadding = paddingValues) {
...
}
}
}
}
親のScaffoldでいい感じにEdge-to-Edge対応されていたのに、
子のInsetsが二重でかかっていたせいでシステムバー分の余白が上下に空くことに。
1つ目の要因に加えて、
このScaffoldネスト構造により、どの階層のScaffoldがどのInsetsを持っているのか把握しづらく、ネストしていないページもあったりして、原因を切り分けるのに時間がかかってしまいました。
Scaffoldのネストについて
Scaffoldについて公式では、TopBar、bottomBar、FABなどを1つの画面にまとめて管理するためのレイアウトであると定義しています。
つまり画面全体の構造(トップレベル)を作ることを想定していると考えられます。
公式からの正式な言及はありませんが、
ネストすることによって、InsetsやPadding周りの副作用が起きるリスクがあるので、
出来るなら、1画面=1Scaffoldでおさめるのが安全だと個人的には感じました。
解決方法
ChildScreen()内のScaffoldに以下の2行を追加しました。
@Composable
fun ChildScreen() {
Scaffold(
topBar = {
TopAppBar(
title = {
Text(
"画面タイトル",
style = MaterialTheme.typography.headlineLarge
)
},
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.White),
+ windowInsets = WindowInsets(0, 0, 0, 0)
)
},
+ contentWindowInsets = WindowInsets(0, 0, 0, 0)
) {paddingValues ->
LazyColumn(contentPadding = paddingValues) {
...
}
}
- TopAppBarに自動で適用されるステータスバー分の余白を、
WindowInsetsを打ち消し - paddingValueに自動で適用されるナビゲーションバー分の余白を、
contentWindowInsetsを打ち消し
Insetsの責任を親のScaffoldに一本化して、
子に効いていたInsetsを全て0に指定することで、解決しました!(めっちゃシンプル。。。)
まとめ
この経験から得た学びです。
- Insetsを意識せずScaffoldを使うと、バージョン更新など環境変化でUIが崩れやすい
- Scaffoldを二重で使うときは、Insetsを扱う方を決めて制御しておくと安全
今回のようなScaffoldのネストによるUI崩れは頻出ではありませんが、調べた時に情報があまり出てこなかったので、同じところで詰まった人のヒントになればうれしいです。
最後までお読みいただき、ありがとうございました!
参考