STORES Product Blog

こだわりを持ったお商売を支える「STORES」のテクノロジー部門のメンバーによるブログです。

edge-to-edge対応Tips

最近やったDIYは二重窓です。余っていた配線カバーを窓枠の上下に両面テープで固定して、表面にアルミシートを貼って断熱性を増したプラダンをはめこみました。リモートワーク環境が窓の真横で寒かったので、かなり改善されました。

こんにちは。 STORES 決済 でAndroidアプリエンジニアをしている Yamaton です。これは STORES アドベントカレンダーの3日目の記事です。さて、今回はAndroid15で突如導入された edge-to-edge について、対応した際のTipsを共有します。

Android 15 変更点のリスト公開

GoogleからAndroid 15に関する 変更点のリスト が公開された際、edge-to-edgeの説明を見て少なからず動揺したのではないでしょうか。もちろん私も動揺しました。「TargetSDKを15にするだけで影響がある」「対応しないと最悪の場合UI崩れが起きる」そう読み取れたからです。

Android Developers: edge-to-edge enforcement

上記スクリーンショットの通り、変更点のリストから「Behavior changes: Apps targeting Android 15 or higher」にページジャンプすると、edge-to-edgeに関する注意事項がなだれ込みます。 影響を受けるアプリと端末OSの組み合わせパターン、UIのうち悪影響を受ける箇所、すでに対応済みの場合に確認すべき点、まだ対応前の場合に確認すべき既存実装の箇所、サポートが終了するAPI、など 「こうなります」の情報はたくさんあるのに、「どうすれば」の情報がどこにあるのか分からずとても焦りました。

公式のedge-to-edge対応情報

Android Developers: Additional edge-to-edge resources

スクリーンショットの通り、答えは「Behavior changes: Apps targeting Android 15 or higher」のページ内にある「Additional edge-to-edge resources」の項に書いてありました。

ただ、これらのページに書かれている方法は一例です。世の中にあるアプリは、それぞれの方法でUIを実装しているので、それぞれの実態に合わせた方法を開発者が考える必要があります。

edge-to-edge対応Tips

タブレットのVirtual Deviceを用意する

Android15にアップデートできるタブレット端末をお持ちならいいのですが、ない場合はエミュレータで仮想デバイスを作成しておきましょう。のちの説明にも出てきますが、重要です。

Android Studio: Virtual Device Configration

作成の注意点ですが、APIのところが 35 のものを選んでください。 VanillaIceCream だと動作確認がうまくいかず、混乱しました。

影響する対象を把握する

Android Developers: edge-to-edge enforcement の冒頭に書かれていた通りですが、4箇所あります。

  • ナビゲーション
    • ジェスチャーナビゲーション
    • 3ボタンナビゲーション
  • ステータスバー
  • ディスプレイ カットアウト(こいつが超・曲者です)

1つずつ見ていきます。

ジェスチャーナビゲージョンと3ボタンナビゲーション

私はいまだに3ボタンナビゲーションを愛用しています。 edge-to-edgeでは、これらのナビゲーションエリアにも、アプリのコンテンツが表示されることになります。スクリーンショットは、Pixel 9 pro / Android 15のエミュレータです。設定アプリを起動している状態で撮影しています。

ジェスチャーナビゲーション 3ボタンナビゲーション

ステータスバー

画面上部のエリアであるステータスバー。こちらもedge-to-edgeでアプリのコンテンツが表示されるようになります。スクリーンショットは、YouTubeアプリを起動している状態で撮影しています。(わかりやすさのためステータスバーに色が付くアプリを選択)

ステータスバー

ディスプレイ カットアウト

ディスプレイ カットアウト とはつまり、スクリーン上で描画できないエリアのことを指します。初めて見た時は驚きを隠せなかった「ノッチ」や、最近では当たり前になったカメラホールなどが ディスプレイ カットアウト にあたります。 edge-to-edgeのおかげでスクリーンの描画範囲が広がりましたが、ディスプレイ カットアウト がある場所は浮き島のように描画できないわけです。この浮き島に被ってしまうアプリのコンテンツは、ずらさなければなりません。

また、この ディスプレイ カットアウト は、開発者向けオプションでエミュレートすることができます。edge-to-edge対応の結果が問題ないか確認するのに重宝するので覚えておいてください。そして、いざ動作確認を始めた時に、絶望します。 ちなみに、メニューを選択してもカットアウトがエミュレートされない場合があります。そのときは、画面を回転するとカットアウトが出現します。

スマホ版のスクリーンショットは先程から登場しているPixel 9 pro / Android 15のエミュレータです。

デフォ 画面隅 ダブル
パンチホール 縦長 エッジ

タブレット版のスクリーンショットは同じく、Pixel Tablet / Android 15のエミュレータです。

デフォ デフォ
画面隅:縦+左 画面隅:縦+右 画面隅:横+左 画面隅:横+右
ダブル:縦 ダブル:横
パンチホール:縦+左 パンチホール:縦+右 パンチホール:横+左 パンチホール:横+右
縦長:縦+左 縦長:縦+右 縦長:横+上 縦長:横+下
エッジ:縦 エッジ:横

この中で特に注意が必要なのが、 画面隅パンチホール です。

一例として、下にgif動画を掲載しますが、このようにアプリのコンテンツの一部が隠れてしまう場合、それを避けるように対応しなければなりません。

デフォ 画面隅

対応する:View xml編

developer.android.com

こちらを参考にしましょう。おおまかに言うと、こうなります。

  1. ViewCompat.setOnApplyWindowInsetsListenerブロックを用意します
  2. ブロック内でinsetを取得します
  3. edge-to-edge表示に干渉するviewにinsetを設定する

例えば、Toolbarの場合、上方向の干渉はもちろん、タブレットのカットアウトのエミュレートで見た通り左右の干渉もあります。そのため、弊チームでは次のように対応しました。

<androidx.constraintlayout.widget.ConstraintLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto">

    <androidx.appcompat.widget.Toolbar
        android:id="@+id/toolbar"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent" />

    <!-- メインコンテンツ -->

</androidx.constraintlayout.widget.ConstraintLayout>
private fun optimizeEdgeToEdge() {
    ViewCompat.setOnApplyWindowInsetsListener(binding.root) { root, windowInsets ->
        val insets = windowInsets.getInsets(
             // システムバー=ステータスバー、ナビゲーションバー
            WindowInsetsCompat.Type.systemBars() or
                // ディスプレイカットアウト
                WindowInsetsCompat.Type.displayCutout(), 
        )

        binding.toolbar.updatePadding(
            top = insets.top,
            left = insets.left,
            right = insets.right,
        )

        // このAcitivityでWindowInsetsを消費する(子Viewには伝播しない)
        WindowInsetsCompat.CONSUMED 
    }
}

padding と margin

干渉を回避する手段は、公式で説明されている通り、 View.updateLayoutParamsmargin を更新する方法と、 View.updatePaddingpadding を更新する2通りの方法があります。

marginpadding の違いについて詳しくはネット検索などしていただきたいのですが、Toolbarの場合になぜ padding を使うかは、公式の説明にあった「The top offset is disabled so content draws behind the status bar unless insets are applied.」が理由です。つまり、Toolbarはedge-to-edge表示になると、ステータスバーの背面まで引き伸ばされます。その結果は、公式にあったスクリーンショットの通りです。

Behavior changes: Apps targeting Android 15 or higher:Edge-to-edge enforcement

  1. toolbarの描画範囲がステータスバーのエリアまで拡張される
  2. toolbar内のコンテンツは上寄せされるので、ステータスバーのコンテンツと重なる
  3. toolbarの 内側 余白であるpaddingで、toolbar内のコンテンツをステータスバーのエリア分だけ下げる

margin の場合

paddingの例は挙げたので、marginの例を挙げます。marginの使い所は次のような箇所です。

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="vertical">

    <androidx.appcompat.widget.Toolbar
        android:id="@+id/toolbar" />

    <FrameLayout
        android:id="@+id/main_content"
        android:layout_width="match_parent"
        android:layout_height="0dp"
        android:layout_weight="1" />

    <Button
        android:id="@+id/button_positive"
        android:layout_width="match_parent"
        android:layout_height="48dp"
        android:text="@string/ok" />

</LinearLayout>
private fun optimizeEdgeToEdge() {
    ViewCompat.setOnApplyWindowInsetsListener(binding.root) { root, windowInsets ->
        val insets = windowInsets.getInsets(
            WindowInsetsCompat.Type.systemBars()
                WindowInsetsCompat.Type.displayCutout(),
        )

        binding.buttonPositive.updateLayoutParams<ViewGroup.MarginLayoutParams> {
            bottomMargin = insets.bottom
        }

        WindowInsetsCompat.CONSUMED
    }
}

LinearLayoutによって縦方向にViewが並べられています。Toolbar、FrameLayout(メインコンテンツ)、ボタン。そして、FramenLayoutが画面の縦いっぱいまで広がる指定になっています。従って、ボタンは画面の最下部に配置されます。

普段からView xmlに慣れ親しんでいて、edge-to-edge対応も「Viewの扱い」と認識していれば、自ずとmarginを使う選択をすると思います。ただ、画面数が多く、単純作業の繰り返しをしていると、こういったところもpaddingでinsetを設定してしまい、いざ動作確認してみると あれっ!? となったりします。なりました。具体的には、paddingを設定すると、ボタン内のテキストが中心から上に押し上げられて、ボタン内部で見切れます(それはそう)

と言うわけで、例に挙げたようなレイアウト構成の場合はmarginを使ってinsetを設定します。

marginでinsetを設定する場合の注意

FrameLayouなどをベースにしていて、gravityとmarginで位置調整をしているViewにinsetを設定する場合、既存のmargin設定とinsetの値を合計しないと、干渉が回避できない可能性があります。

そう言う場合は、あらかじめ既存のmarginを取得しておいて、insetの値と合計してmarginに設定し直しましょう。

insetは、カットアウトが存在しない場合は 0 を返してくれるので、余計な数値が加算される心配はありません。

<FrameLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto">

    <ImageButton
        android:id="@+id/plus"
        android:layout_marginStart="12dp"
        android:layout_marginBottom="12dp"
        android:layout_gravity="bottom|start" />

</FrameLayout>
private fun optimizeEdgeToEdge() {
    val initialMarginStart = binding.plus.marginLeft
    val initialMarginBottom = binding.plus.marginBottom
    ViewCompat.setOnApplyWindowInsetsListener(binding.root) { root, windowInsets ->
        val insets = windowInsets.getInsets(
            WindowInsetsCompat.Type.systemBars() or
                WindowInsetsCompat.Type.displayCutout(),
        )

        binding.plus.updateLayoutParams<ViewGroup.MarginLayoutParams> {
            bottomMargin = insets.bottom + initialMarginBottom
            leftMargin = insets.left + initialMarginStart
        }

        WindowInsetsCompat.CONSUMED
    }
}

対応する:Compose編

developer.android.com

こちらを参考にしましょう。Compose版の公式情報はかなり手厚いので、ここで説明する必要はなさそうです。

ただ、気になったのが、リスト系のコンテンツで、末尾のアイテムがナビゲーションバーやカットアウトに重ならないようにするために取るべき方法が、Spacerの追加、ということ。

LazyColumn(
    Modifier()
) {
    // Other content
    item {
        Spacer(
            Modifier.windowInsetsBottomHeight(
                WindowInsets.systemBars
            )
        )
    }
}

カットアウトが無い状況なら高さは 0 になるし、カットアウトがあるなら適切な高さのSpacerになるし、いいのですが、なんというか……無骨だな、と思いました。

おわりに

STORES 決済 AndroidではAndroid 15のみedge-to-edge対応していて、14以下のバージョンに適応するかどうかは検討中です。ここで紹介した方法で問題なく対応できるのは、15以上の場合だけです。14以下に関してはそれぞれのバージョンでいくつかの問題があります。例えば、windowInsets.getInsetsでカットアウトのサイズを取得しても常に 0 が返ってくるOSバージョンもあります。

記事としてまとめると、edge-to-edgeの対応は順調にいったように見えますが、実際のところは試行錯誤の繰り返しでした。問題なく対応できただろう、と思って動作確認してみると、タブレットのカットアウトエミュレーションでコンテンツが干渉しているのが見つかったり、前述の低いOSバージョンではwindowInsets.getInsetsの値が返ってこなかったりと、散々でした。この記事が、これからedge-to-edge対応する方に少しでも助けになれば幸いです。