🕌

【Android】Jetpack ComposeでEdge-to-edgeをやってみた

に公開

Edge-to-edgeとは

AndroidでUIの没入感を高めておしゃれなデザインにしよう、というものです。
実際に見比べてみると確かになんかイケてると感じる人が多いと思います。
https://developer.android.com/design/ui/mobile/guides/layout-and-content/edge-to-edge?hl=ja

Edge-to-edge自体はかなり前から存在しているものですが、なんだかんだ放置されることも少なくなかったと思います。
ですが、Android15以降、SDK35以上の端末では強制的にEdge-to-edgeが適用されるため、SDKを上げるためには避けては通れない道となっています。

やったこと

親のColumnにpaddingすることもできますが、Scaffoldが一番楽で見やすいやり方と思います。
Scaffoldの基本的な説明については割愛しますが、要はヘッダーとフッターとかの領域をよしなにやってくれるやつです。便利。
https://developer.android.com/develop/ui/compose/components/scaffold?hl=ja

当初の実装

Scaffold(
    bottomBar = footer,
) { paddingValues ->
    HogeHogeScreenHolder(
        // Composableのパラメータ
        ...
        modifier = Modifier
            .padding(paddingValues)
            .windowInsetsPadding(WindowInsets.safeDrawing)
    )
}

bottomBarの領域はScaffoldが、それ以外の領域はmodifierに直接適用すればうまくいくでしょ、という意図です。

トラブル

壁1: 二重パディング

Before After

ステータスバーの下部、フッターの上部に余計なスペースが存在していることがわかると思います。
ここで、Scaffoldの内部実装は以下のようになっています(長いので抜粋)
重要なのは、contentWindowsInsetsにデフォルトでScaffoldDefaults.contentWindowInsetsとしてデフォルトで安全な領域が確保されていることが発覚しました。

@Composable
fun Scaffold(
    modifier: Modifier = Modifier,
    topBar: @Composable () -> Unit = {},
    bottomBar: @Composable () -> Unit = {},
    ...
    contentWindowInsets: WindowInsets = ScaffoldDefaults.contentWindowInsets,
    content: @Composable (PaddingValues) -> Unit
) {
    val safeInsets = remember(contentWindowInsets) {
        MutableWindowInsets(contentWindowInsets)
    }
    Surface(
        modifier = modifier.onConsumedWindowInsetsChanged { consumedWindowInsets ->
            // Exclude currently consumed window insets from user provided contentWindowInsets
            safeInsets.insets = contentWindowInsets.exclude(consumedWindowInsets)
        },
        ...
        )
    }
}

ということで、先ほどのコードから.windowInsetsPadding(WindowInsets.safeDrawing)を削除してあげれば、Scaffoldが全部うまいことやってくれて万事解決...となるわけもなく。

壁2: 横画面で崩壊

Before

今度はこうなりました。DisplayCutOut(カメラを含む領域)に食い込んで表示されています。
これではEdge-to-edgeの要件を満たしていません。

内部実装に戻ります。ScaffoldDefaults.contentWindowInsetsがデフォルトで安全な領域を獲得していると認識していましたが、詳しく見てみると以下のように定義されています。

object ScaffoldDefaults {
    /**
     * Default insets to be used and consumed by the scaffold content slot
     */
    val contentWindowInsets: WindowInsets
        @Composable
        get() = WindowInsets.systemBarsForVisualComponents
}
internal actual val WindowInsets.Companion.systemBarsForVisualComponents: WindowInsets
    @Composable
    get() = systemBars

つまり、システムバーの領域だけはデフォルトで用意するよ、というものでした。ということはDisplayCutOutは自分で管理しないといけないというものでした。
https://developer.android.com/design/ui/mobile/guides/foundations/system-bars?hl=ja

ということで
.windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal))
を追加して無事Edge-to-edge達成しました🎉

After

※ フッターについてもEdge-to-edgeを施しました。余談2で詳しく話します。

最終コード

Scaffold(
    bottomBar = footer,
) { paddingValues ->
        HogeHogeScreenHolder(
        // Composableのパラメータ
        ...
        modifier = Modifier
            .padding(paddingValues)
            .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal))
    )
}

余談

余談1: 簡潔な実装

.windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal))と書くぐらいだったら、最初からcontentWindowInsetsにWindowInsets.safeDrawingでまとめて書いておいた方が見やすいですね。
WindowInsets.safeDrawingならDisplayCutOutも含めて獲得できますし、デフォルトと競合もしませんし。

最終コード #2

Scaffold(
    bottomBar = footer,

    // デフォルトの挙動にDisplayCutOutを含ませることができる
    contentWindowInsets = WindowInsets.safeDrawing
) { paddingValues ->
        HogeHogeScreenHolder(
        // Composableのパラメータ
        ...
        modifier = Modifier.padding(paddingValues)
    )
}

余談2: フッターのEdge-to-edgeについて

フッターにはNavigationBarを使用しています。
NavigationBarの内部実装を見ると以下のようになっています。

@Composable
fun NavigationBar(
    ...
    windowInsets: WindowInsets = NavigationBarDefaults.windowInsets,
    content: @Composable RowScope.() -> Unit
) {
}
object NavigationBarDefaults {
    ...
    /**
     * Default window insets to be used and consumed by navigation bar
     */
    val windowInsets: WindowInsets
        @Composable
        get() = WindowInsets.systemBarsForVisualComponents
            .only(WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom)
}

繰り返しですが、systemBarsForVisualComponentsなのでシステムバーのみデフォルトで用意するという実装になっています。
そのため、WindowInsetInsets.safeDrawing.only(WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom)を使用すれば解決できます。

NavigationBar(
    windowInsets = WindowInsets.safeDrawing.only(
        WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom
    )
)

ほとんど同じこと話しているので、余談行きになりました。


備忘録のような記事で恐縮ですが、最後までご覧いただきありがとうございました。

Discussion