見出し画像

AndroidアプリをEdge-to-edgeに対応し画面の広いアプリを作る

この記事は、NAVITIME JAPAN Advent Calendar 2022の16日目の記事です。

こんにちは、ストーンです。
ナビタイムジャパンではAndroidやiOSのモバイルアプリ開発を担当しています。

今回は、KDDI株式会社と株式会社ナビタイムジャパンが共同で開発しているAndroid版『auナビウォークプラス』で、Edge-to-edgeの対応を行ってリリースしたので、対応したアプリUIや対応方法などを紹介したいと思います。

Edge-to-edgeとは

「端から端まで」
アプリの描画領域をシステムUIの裏まで拡張することで、全画面をアプリコンテンツの表現領域として使うことができ、アプリを使っているときの没入感をより高められる機能です。

対応しない場合、アプリの描画領域はステータスバーの下からナビゲーションバーの上まで、となりますが、対応するとそのシステムUIの裏側までアプリの描画領域として使えることができます。

Edge-to-edgeが導入された背景など、詳しくは下記のGoogle公式ブログで説明されています。

2019年の記事ですが、まだ読んだことが無い方は一読してみることをおすすめします。

また、先日行われたDroidKaigi2022でのセッション、「Android アプリの内と外をつなぐ UI」でも、Edge-to-edgeの紹介がありました。

その中では、どんなアプリがEdge-to-edgeを採用すべきかという問いに対して、全てのアプリ、という回答が示されていました。
理由としては、foldable、タブレット、Chromebookといった様々なフォームファクタでのアプリ表示や画面分割など、様々な状態に対応するための第一歩がEdge-to-edgeだから、とのことでした。

また、androidxで提供されているスプラッシュライブラリ(androidx.core:core-splashscreen)もEdge-to-edge対応をしてある前提で作っているという言及もありました。
今はEdge-to-edge対応しているアプリの数は少ないかもしれませんが、今後Edge-to-edge対応は当たり前に対応すべきもの、になりそうだなという感想を持ちました。

対応したアプリのUI


左 : 地図-ジェスチャーナビゲーション
右 : 地図-3ボタンナビゲーション
左 : 時刻表-ジェスチャーナビゲーション
右 : ルート検索結果-3ボタンナビゲーション

こちらが、対応した画面のいくつかです。
一部を除き、ほぼ全ての画面でEdge-to-edgeを対応しました。
(一部とはWebView画面ですが、後ほど対応できなかった理由を説明します)

メインのナビゲーションとしてBottomNavigationを使用しており、5つの主要な機能の切り替えを行うことができます。
このBottomNavigationは下にぴったりくっついておらず、フローティングしたデザインになっています。このデザインはEdge-to-edge対応するから、という決め方ではなく、主要画面の1つである地図を広く見せるため、といった理由などからフローティングしているのですが、結果的にEdge−to−edge対応するとより画面が広くなったように感じるUIでした。

左 : 対応しなかった場合の地図画面
右 : 対応した場合の地図画面

上下のシステムバー領域の裏まで地図が描画されることで、圧迫感が少なく、より広く地図を見れるようなUIになっていると思います。
自分が使っている端末では、ナビゲーション設定をジェスチャーナビゲーションにして使っていますが、3ボタンナビゲーションにすると違いがよりわかりやすいと思います。

また、時刻表画面やルート結果画面など、縦に長いリストも多く、そういった機能でも画面を広く感じることができるのではないかと思っています。

対応方法

ここからは対応方法を紹介していきます。

対応方法を分類すると、

  1. 全画面描画する設定にする

  2. システムバーの背景色を変更する

  3. システムバーに重なる部分をずらす

の3ステップとなります。

前提として、アプリのUI構築方法についてですが、AndroidViewで作っている画面がほとんど、少しJetpack Composeで作られている画面がある、という状況です。
Edge−to−edge対応としては、AndroidViewとJetpack Composeどちらも対応しました。
1と2に関してはActivityに設定するのでAndroidView,Jetpack Compose共通となりますが、3に関してはそれぞれ対応が異なりますので、両方の場合の対応方法を紹介します。

Edge-to-edge対応前の状態

Edge-to-edge対応をしていない状態の画面はこのようになっています。

Edge-to-edge対応に関わるライブラリバージョンは以下となっています。

  • androidx.core:core-ktx:1.7.0

  • androidx.compose.foundation:foundation:1.2.1

それでは、紹介した3ステップを順に説明していきます。

1. 全画面描画する設定にする

Activityで

WindowCompat.setDecorFitsSystemWindows(window, false)

を指定します。

以前は、

View.setSystemUiVisibility(
    View.SYSTEM_UI_FLAG_LAYOUT_STABLE
    | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
    | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
)

のようにViewにフラグを設定する方法でしたが、API30からは非推奨になっており、WindowCompatを使うとAPIレベルの差分を吸収してくれるようになっています。

全画面描画する設定をした状態

全画面描画する設定をした状態の画面です。アプリの描画領域が上下の端まで広がりました。
BottomNavigation内の真ん中の円がずれているのは、ライブラリで提供されているBottomNavigationViewは自動でWindowInsetsを考慮しpaddingを付けているのに対し、真ん中の円は自分でずらす必要があるため、です(ステップ3で行います)。

2. システムバーの背景色を変更する

アプリの描画領域を全画面に拡張しただけでは、システムバーの裏側に隠れた部分はユーザーには見えません。

システムバーを透明化し、裏側の部分も見えるようにする必要があります。

『auナビウォークプラス』では、地図を含む色々な画面でシステムバーが見えやすいように、白をベースとした半透明色にし、システムバー上の要素(ステータスバーの通知アイコンや、ナビゲーションバーのボタンなど)はグレーで表示するようにしています。

<style name="AppTheme" parent="Theme.MaterialComponents.Light.NoActionBar">
    ...
    <!-- システムバーの色を半透明にする -->
    <item name="android:navigationBarColor">#B3FFFFFF</item>
    <item name="android:statusBarColor">#B3FFFFFF</item>
    <!-- システムバー上の要素の色をグレーにする -->
    <item name="android:windowLightStatusBar">true</item>
    <item name="android:windowLightNavigationBar">true</item>
    ...
</style>


システムバーの背景色を変更した状態

システムバーの背景色を変更した状態の画面です。透過色になったことにより、システムバーの裏側が見えるようになりました。

3. システムバーに重なる部分をずらす(AndroidViewの場合)

アプリの描画領域を全画面に拡張し、システムバーを透明化しただけでは、アプリのUI要素が端に埋まってしまい、見えにくい部分が発生してしまいます。

そこで、重なった部分のViewにはpaddingやmarginを設定して重ならないようにし、見えるようにする必要があります。
まず、AndroidViewの場合から説明します。

ViewCompatのsetOnApplyWindowInsetsListenerを使うとシステムバーの高さが取得できるので、その値を使ってpaddingを更新します。
その際、元々Viewに設定してあるpaddingも考慮し、paddingの値を更新するようにします。

val initialPaddingTop = view.paddingTop
val initialPaddingBottom = view.paddingBottom
ViewCompat.setOnApplyWindowInsetsListener(view) { _, inset ->
            if (isApplyPaddingTop) {
                val newTopPadding = initialPaddingTop + inset.getInsets(WindowInsetsCompat.Type.statusBars()).top
                if (paddingTop != newTopPadding) {
                    updatePadding(top = newTopPadding)
                }
            }
            if (isApplyPaddingBottom) {
                var newBottomPadding = initialPaddingBottom + inset.getInsets(WindowInsetsCompat.Type.navigationBars()).bottom
                if (paddingBottom != newBottomPadding) {
                    updatePadding(bottom = newBottomPadding)
                }
            }
            return@setOnApplyWindowInsetsListener inset
        }

WindowInsetsを考慮して、marginを更新する場合はこのようになります。

val initialMarginTop = marginTop
val initialMarginBottom = marginBottom
ViewCompat.setOnApplyWindowInsetsListener(this) { _, inset ->
    val layoutParams = layoutParams.castAs<ViewGroup.MarginLayoutParams>()
    if (isApplyMarginTop) {
        val newTopMargin = initialMarginTop + inset.getInsets(WindowInsetsCompat.Type.statusBars()).top
        if (layoutParams?.topMargin != newTopMargin) {
            updateLayoutParams<ViewGroup.MarginLayoutParams> {
                topMargin = newTopMargin
            }
        }
    }
    if (isApplyMarginBottom) {
        var newBottomMargin = (initialMarginBottom + inset.getInsets(WindowInsetsCompat.Type.navigationBars()).bottom)
        if (layoutParams?.bottomMargin != newBottomMargin) {
            updateLayoutParams<ViewGroup.MarginLayoutParams> {
                bottomMargin = newBottomMargin
            }
        }
    }
    return@setOnApplyWindowInsetsListener inset
}

updatePadding、updateLayoutParamsはandroidx.core:core-ktxで提供されているAPIで、1つの方向の要素だけpadding/marginを更新する際に便利です。

AndroidView使用部分ではdataBindingを利用しており、BindingAdapterを作りEdge-to-edgeの適用をを行いやすくしています。

@BindingAdapter(value = ["insetPaddingTop", "insetPaddingBottom", "insetBottomNav"], requireAll = false)
fun View.setPaddingInsets(applyTop: Boolean? = null, applyBottom: Boolean? = null, insetBottomNavigation: Boolean? = null, applyEdgeToEdge: Boolean? = null) {
    val fixedApplyTop = applyTop ?: false
    val fixedApplyBottom = applyBottom ?: false
    val fixedInsetBottomNav = insetBottomNavigation ?: false
    val fixedApplyEdgeToEdge = applyEdgeToEdge ?: true
    setVerticalPaddingInsets(top = fixedApplyTop, bottom = fixedApplyBottom, withBottomNav = fixedInsetBottomNav, applyEdgeToEdge = fixedApplyEdgeToEdge)
}
/**
 * 指定Viewに上/下のPadding Insetを付ける
 */
fun View.setVerticalPaddingInsets(
    applyTop: Boolean = false,
    applyBottom: Boolean = false,
    withBottomNavigation: Boolean = false,
) {
    val initialPaddingTop = paddingTop
    val initialPaddingBottom = paddingBottom

    ViewCompat.setOnApplyWindowInsetsListener(this) { _, inset ->
        if (applyTop) {
            val newTopPadding = initialPaddingTop + inset.getInsets(WindowInsetsCompat.Type.statusBars()).top
            if (paddingTop != newTopPadding) {
                updatePadding(top = newTopPadding)
            }
        }
        if (applyBottom) {
            var newBottomPadding = initialPaddingBottom + inset.getInsets(WindowInsetsCompat.Type.navigationBars()).bottom
            if (withBottomNavigation) {
                newBottomPadding += resources.getDimension(R.dimen.bottom_navigation_container).toInt()
            }
            if (paddingBottom != newBottomPadding) {
                updatePadding(bottom = newBottomPadding)
            }
        }
        return@setOnApplyWindowInsetsListener inset
    }
}

withBottomNavigationという引数は、BottomNavigationがある画面では、その分のpaddingを空ける処理を行うために用意しています。

使用する際はxmlで以下のように使用します。

<androidx.constraintlayout.widget.ConstraintLayout
    android:id="@+id/layout"
    android:layout_width="0dp"
    android:layout_height="0dp"
    app:insetBottomNavigation="@{true}"
    app:insetPaddingBottom="@{true}"
    app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
    app:layout_constraintBottom_toBottomOf="parent"
    app:layout_constraintEnd_toEndOf="parent"
    app:layout_constraintStart_toStartOf="parent"
    app:layout_constraintTop_toBottomOf="@id/toolbar_divider" />

RecyclerViewの場合は `android:clipToPadding="false"` を指定することで内部のスクロール部分の一番下だけにpaddingを与え、スクロール中はリストがナビゲーションバーの裏に見えている状態にできます。

<androidx.recyclerview.widget.RecyclerView
    android:id="@+id/diagram_top_recycler_view"
    android:layout_width="0dp"
    android:layout_height="0dp"
    android:background="@color/background_primary"
    android:clipToPadding="false"
    app:goneUnless="@{!viewModel.shouldShowAutoComplete}"
    app:insetBottomNav="@{true}"
    app:insetPaddingBottom="@{true}"
    app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
    app:layout_constraintBottom_toBottomOf="parent"
    app:layout_constraintEnd_toEndOf="parent"
    app:layout_constraintStart_toStartOf="parent"
    app:layout_constraintTop_toBottomOf="@id/toolbar_divider" />
システムバーに重なる部分をずらした状態

システムバーに重なる部分をずらした状態の画面です。これでEdge-to-edge対応を完了することができました。

3. システムバーに重なる部分をずらす(Jetpack Composeの場合)

次に、Jetpack Composeの場合の実装を説明します。
採用している画面数が少ないため、特定パターンの説明になっていますが、ご了承ください。

Edge-to-edgeを実現するWindowInsetsのCompose対応は、以前はAccompanist(Jetpack Composeを使いやすくするライブラリ)に含まれていましたが、Compose1.2からは公式でサポートされています。

LazyColumnを使用する画面でのEdge-to-edge対応を行いました。
対応は以下のようにcontentPaddingにWindowInsets APIから取得できるpaddingを指定するようにしています。

Scaffold(backgroundColor = colorResource(id = R.color.background_primary)) { padding ->
    LazyColumn(
        modifier = Modifier.padding(padding),
        contentPadding = WindowInsets.systemBars.only(
            WindowInsetsSides.Top + WindowInsetsSides.Bottom
        ).add(
            // 下タブ分のpadding
            WindowInsets(bottom = dimensionResource(id = R.dimen.bottom_navigation_container))
        ).asPaddingValues()
    ) {
        items(menuList) { type ->
            if (type == MenuType.ACCOUNT_MANAGEMENT) {
                MenuRow(type.iconRes, memberType.itemTextRes, memberType.loginStatusTextRes) {
                    clickAction(type, memberType)
                }
            } else {
                MenuRow(type.iconRes, type.labelRes) {
                    clickAction(type, memberType)
                }
            }
        }
    }
}

この画面はAppBarのない画面なので、TopとBottomにpaddingを設定しています。
LazyColumnのcontentPaddingにはPaddingValuesを指定するのですが、そのpaddingをWindowInsets.systemBars.only()で取得し、asPaddingValues()でWindowInsets型からPaddingValues型に変換しています。
また、BottomNavigation分のpaddingを追加で設定し、BottomNavigationにかぶらないようにしています。

LazyColumn(
    modifier = Modifier.padding(padding)
        .windowInsetsPadding(
            WindowInsets.systemBars.only(WindowInsetsSides.Bottom)
                .add(
                    WindowInsets(bottom = dimensionResource(id = R.dimen.bottom_navigation_container))
                )
        ),
) 

ModifierとしてwindowInsetsPaddingがあり、そちらでもできるかなと思ったのですが、LazyColumn全体にpaddingがかかってしまい、BottomNavigationの裏側への描画ができなかったため、今回はcontentPaddingに指定する実装にしました。

Jetpack Composeで作った画面のEdge-to-edge対応

続いて、TopAppBarのEdge-to-edge対応を紹介します。

val topPadding: Dp = WindowInsets.systemBars.only(
    WindowInsetsSides.Top
).asPaddingValues().calculateTopPadding()
val horizontalPadding: Dp = AppBarHorizontalPadding

TopAppBar(
    backgroundColor = colorResource(id = R.color.background_primary),
    contentPadding = PaddingValues(top = topPadding, start = horizontalPadding, end = horizontalPadding),
) {
    Row(verticalAlignment = Alignment.CenterVertically) {
        IconButton(
            modifier = TitleIconModifier,
            onClick = onNavigationIconClickAction
        ) {
            Icon(
                painterResource(navigationIcon),
                tint = colorResource(id = R.color.system_gray2),
                contentDescription = null,
            )
        }
        ProvideTextStyle(value = MaterialTheme.typography.h6) {
            Text(
                uiModel.title.getString(context),
                color = colorResource(id = R.color.text_primary),
            )
        }
    }
}

こちらも、TopAppBarの引数contentPaddingにWindowInsets APIから取得したpaddingを設定して対応しました。

TopAppBar(
    backgroundColor = colorResource(id = R.color.background_primary),
    title = { Text(uiModel.title.getString(context)) },
    navigationIcon = {
        IconButton(onClick = onNavigationIconClickAction) {
            Icon(
                painterResource(navigationIcon),
                tint = colorResource(id = R.color.system_gray2),
                contentDescription = null,
            )
        }
    },
    modifier = Modifier
        .statusBarsPadding()
)

元々は上記のように、ModifierのstatusBarsPadding()(windowInsetsPadding()を内部で使用)を使っていたのですが、先ほど説明したLazyColumnの時と同様、TopAppBar全体にpaddingがかかり上部に影が出てしまっていました。
現在はcontentPaddingを利用する実装に修正しています。

左 : modifierでstatusBarPadding()を使用すると影が出ている
右: TopAppBarのcontentPaddingを使用すると影が出ない

Edge-to-edgeが適用できなかった画面

全体がWebViewになっている画面はEdge-to-edge対応で上手くいかない部分が出たのでEdge-to-edge対応を断念しました。

WebViewの中にテキストボックスがあり、入力中にキーボードが出ている状態でスクロールができなくなってしまう、という事象です。
ご意見送信をするWebView画面で、入力中にテキストボックスやボタンがキーボードに隠れてしまい、キーボードを一度閉じないと内容の確認や送信ができない状態でした。

対応方法は調査中ですが、まだ解決にいたっていません。

おわりに

今回はAndroidアプリにEdge-to-edgeを対応した話を紹介しました。

個別の画面の対応は難しくないと思いますが、画面数の多い既存アプリへの対応は確認も含め大変なものになるかなと思います。
しかし、対応することによって、圧迫感が少なく画面を広く使えるユーザー体験を提供できると思います。

今回の記事がEdge-to-edge対応を行う際の意思決定や実装の手助けになれば幸いです。