KINTO Tech Blog
Android

必須! 既存のAndroidアプリをedge-to-edge対応にするための速習ガイド

Cover Image for 必須! 既存のAndroidアプリをedge-to-edge対応にするための速習ガイド

KINTOテクノロジーズのAndroidエンジニア 山田 剛 です。
本記事では、Android API level 35以上への対応が必須化された今、既存のAndroidアプリを素早くedge-to-edge対応にするためのノウハウを紹介します。

1. はじめに

build.gradle.kts
android {
    defaultConfig {
        targetSdk = 35

        // ...
    }

    // ...
}

2025年8月31日以降、Google Playストアで公開されるAndroidアプリは、API level 35以上での公開が必須となりました。
すなわち、targetSdk を35以上の値にしてビルドしたAndroidアプリでなければ、Google Playストアでの新規アプリの公開、および既存アプリのアップデートが受け付けられなくなりました。
しかし、targetSdk を35以上の値にしてビルドしたアプリを開くと、画面のステータスバーやシステムナビゲーションバーの領域、およびノッチで隠れた画面の領域が、すべてアプリの表示領域になります。

これに伴い、既存のアプリをedge-to-edge対応にする必要があります。すでに対応済みのアプリも多数でしょうが、開発スケジュールが確保できずまだ対応できていない、という開発者にとってはすでに猶予のない状態です。ほとんどの画面では対応済みなのだが、一部の画面でどうにも変な表示が直らない、という状況もあるでしょう。
この記事では、既存アプリを極力早くedge-to-edge対応にするためのノウハウを紹介します。
本記事のおおまかな内容は以下の通りです:

  • View で構成された画面では、コールバックを活用しましょう。
  • Composable で構成された画面では、 WindowInsets に関係するさまざまな関数を活用しましょう。
  • View と Composable が混在するアプリでは、ViewとComposableのそれぞれの対策を組み合わせることによって生じる問題を調整するために用意された関数をうまく使いましょう。
  • リストの内部パディングなどをうまく使い、できるだけスクロール途中の表示でステータスバー、システムナビゲーションバーの領域を活用できるようにしましょう。

2. Viewで構成された画面のedge-to-edge対応


くもびぃ

以下は画像が縦に流れていく画面を View で構成した簡単なアプリです:

MainActivity.kt
class MainActivity : AppCompatActivity() {
    private lateinit var binding: ActivityMainBinding
    private lateinit var imageAdapter: ImageAdapter

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)

        withStyledAttributes(TypedValue().data, intArrayOf(android.R.attr.colorPrimary)) {
            window.statusBarColor = getColor(0, 0)
        }
        setSupportActionBar(binding.toolbar)

        imageAdapter = ImageAdapter(layoutInflater, 2)
        binding.recyclerView.adapter = imageAdapter
        binding.fab.setOnClickListener { _ ->
            imageAdapter.increment()
            binding.recyclerView.scrollToPosition(imageAdapter.length - 1)
        }
    }

    override fun onCreateOptionsMenu(menu: Menu): Boolean {
        menuInflater.inflate(R.menu.menu_main, menu)
        return true
    }

    override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
        R.id.action_settings -> {
            val editText = layoutInflater.inflate(R.layout.item_edit_text, null) as EditText
            val dialog = AlertDialog.Builder(this)
                .setView(editText)
                .setTitle(R.string.image_count)
                .setNegativeButton(R.string.cancel) { dialog, _ ->
                    dialog.dismiss()
                }
                .show()
            editText.setOnEditorActionListener { _, actionId, _ ->
                if (actionId != EditorInfo.IME_ACTION_DONE) return@setOnEditorActionListener false
                editText.text.toString().toIntOrNull()?.let { imageAdapter.length = it }
                dialog.dismiss()
                return@setOnEditorActionListener true
            }
            true
        }
        else -> super.onOptionsItemSelected(item)
    }
}

class ImageAdapter(private val inflater: LayoutInflater, initialLength: Int)
: RecyclerView.Adapter<ImageAdapter.ViewHolder>() {

    var length: Int = initialLength
        set(value) {
            val incremental = value - field
            if (incremental == 0) return
            field = value
            if (incremental < 0) {
                notifyItemRangeRemoved(value, -incremental)
            } else {
                notifyItemRangeInserted(value - incremental, incremental)
            }
        }

    override fun getItemViewType(position: Int) = position % IMAGE_LIST.size

    override fun onCreateViewHolder(
        parent: ViewGroup,
        viewType: Int
    ) = ViewHolder(ItemImageBinding.inflate(inflater, parent, false).apply {
        image.setImageResource(IMAGE_LIST[viewType])
    })

    override fun onBindViewHolder(holder: ViewHolder, position: Int) {
        val bias = (position * 0.3F).rem(2F).let { if (it < 1F) it else 2F - it }
        holder.binding.spaceStart.let { spaceStart ->
            (spaceStart.layoutParams as? LinearLayout.LayoutParams)?.let {
                it.weight = bias
                spaceStart.layoutParams = it
            }
        }
        holder.binding.spaceEnd.let { spaceEnd ->
            (spaceEnd.layoutParams as? LinearLayout.LayoutParams)?.let {
                it.weight = 1F - bias
                spaceEnd.layoutParams = it
            }
        }
    }

    override fun getItemCount() = length

    fun increment() {
        length = length + 1
    }

    class ViewHolder(val binding: ItemImageBinding) : RecyclerView.ViewHolder(binding.root)

    companion object {
        private val IMAGE_LIST = listOf(
            // ...
        )
    }
}

レイアウトファイルは以下の通りです:

res/layout/activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <com.google.android.material.appbar.AppBarLayout
        android:id="@+id/layout_appbar"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:background="?colorPrimary"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent">

        <com.google.android.material.appbar.MaterialToolbar
            android:id="@+id/toolbar"
            style="@style/Widget.MaterialComponents.Toolbar.Primary"
            android:layout_width="match_parent"
            android:layout_height="?attr/actionBarSize" />

    </com.google.android.material.appbar.AppBarLayout>

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/recycler_view"
        android:layout_width="0dp"
        android:layout_height="0dp"
        android:background="?colorBackgroundFloating"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@id/layout_appbar"
        app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
        android:orientation="vertical" />

    <com.google.android.material.floatingactionbutton.FloatingActionButton
        android:id="@+id/fab"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="bottom|end"
        app:shapeAppearanceOverlay="@style/ShapeAppearance.App.Circle"
        app:backgroundTint="?colorSecondary"
        app:srcCompat="@android:drawable/ic_input_add"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

これを targetSdk を34に設定してビルドした場合と35に設定してビルドした場合とで、 Android OS 10 以上で実行した画面を比較します:

targetSdk = 34 targetSdk = 35 (対策なし)
View: API level 34 View: API level 35: no-countermeasure

targetSdk が34なら何も問題なく表示されますが、35だとステータスバーがタイトルバーに吸収されて見づらくなり、タイトル自体もカットアウト(カメラなどのセンサーをディスプレイ上に収めた部分の切り欠き)の穴が空き、システムナビゲーションバーの領域もアプリの表示領域に含まれてしまいます。

targetSdk = 35 におけるエッジ ツー エッジの適用とは、アプリの表示領域を否応なしにステータスバーやシステムナビゲーションバー、カットアウトの領域にまで拡げてしまうということなのです。
さすがにこの状態のアプリをアプリストアなどにリリースするのはつらいものがあります。もう時間がありませんが、何とかしなければなりません。

2.1. ViewCompat.setOnApplyWindowInsetsListener

ビューでコンテンツをエッジ ツー エッジで表示するにて、Viewをedge-to-edgeに対応させるさまざまな方法が紹介されています。
そのうち、汎用性の高い手段としてViewCompat.setOnApplyWindowInsetsListener(View, OnApplyWindowInsetsListener) を使用する方法があります:

MainActivity.kt
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)

        // window.statusBarColor = getColor(0, 0) // edge-to-edgeではstatusBarColorの設定は不要(無効)

        setSupportActionBar(binding.toolbar)

        imageAdapter = ImageAdapter(layoutInflater, 2)
        binding.recyclerView.adapter = imageAdapter
        binding.fab.setOnClickListener { _ ->
            imageAdapter.increment()
            binding.recyclerView.scrollToPosition(imageAdapter.length - 1)
        }

        applyWindowInsetsForE2E()
    }

    private fun applyWindowInsetsForE2E() {
        ViewCompat.setOnApplyWindowInsetsListener(binding.layoutAppbar) { v, windowInsets ->
            val insets = windowInsets.getInsets(
                WindowInsetsCompat.Type.systemBars()
                        or WindowInsetsCompat.Type.displayCutout()
            )
            v.updatePadding(left = insets.left, top = insets.top, right = insets.right)
            WindowInsetsCompat.CONSUMED
        }

        ViewCompat.setOnApplyWindowInsetsListener(binding.recyclerView) { v, windowInsets ->
            val insets = windowInsets.getInsets(
                WindowInsetsCompat.Type.systemBars()
                        or WindowInsetsCompat.Type.displayCutout()
            )
            v.updatePadding(left = insets.left, right = insets.right, bottom = insets.bottom)
            WindowInsetsCompat.CONSUMED
        }

        ViewCompat.setOnApplyWindowInsetsListener(binding.fab) { v, windowInsets ->
            val insets = windowInsets.getInsets(
                WindowInsetsCompat.Type.systemBars()
                        or WindowInsetsCompat.Type.displayCutout()
                        or WindowInsetsCompat.Type.ime()
            )
            v.updateLayoutParams<ViewGroup.MarginLayoutParams> {
                val margin = resources.getDimensionPixelOffset(R.dimen.fab_margin)
                bottomMargin = insets.bottom + margin
                rightMargin = insets.right + margin
            }
            WindowInsetsCompat.CONSUMED
        }
    }

listener の中で WindowInsets$getInsets(Int)WindowInsetsCompat.Type から適切な関数を組み合わせて引数に与え、インセット(上、左、右、下の空間)の値を得ます。
MainActivity$applyWindowInsetsForE2E() の中で、 AppBarLayout, RecyclerView, FloatingActionButton のそれぞれに対して ViewCompat.setOnApplyWindowInsetsListener(...) を呼び出して設定している点に注意が必要です。
AppBarLayout ではステータスバーとカットアウトの領域分だけ上部のパディングが必要ですが、RecyclerView では上部を気にする必要がないので top のパディングは設定していません。
FloatingActionButton では、end と bottom のマージンを加算して LayoutParams のマージンを変更しています。
他方、RecyclerView では、以下のように android:clipToPadding="false" を追加して、 RecyclerView の表示領域にパディングをするのではなく、 RecyclerView のコンテンツ全体の左右と下部へのパディングとすることによって、下部のシステムナビゲーションバーの部分も表示領域として活用しつつ、スクロールの下端ではコンテンツとシステムナビゲーションが重複しなくて済むようにできます:

res/layout/activity_main.xml
    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/recycler_view"
        android:layout_width="0dp"
        android:layout_height="0dp"
        android:background="?colorBackgroundFloating"
        android:clipToPadding="false"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@id/layout_appbar"
        app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
        android:orientation="vertical" />

edge-to-edge対応で求められているのは、画面の端から端までを表示に活用しつつ、アプリUIがステータスバーやシステムナビゲーションバーの邪魔にならないように、カットアウトがアプリUIの邪魔にならないように両立させることです。
対策の時間が限られている状況では、ひとまずインセットの部分を背景色のみで潰しておく、という対応で時を稼がざるを得ないこともあるでしょう。それでも、できる限りはedge-to-edgeの長所を活かすように対応したいものです。

targetSdk = 35(対策あり、スクロール上端) targetSdk = 35 (対策あり、スクロール下端)
View: API level 35 scroll top View: API level 35 scroll bottom

listener の末尾で WindowInsetsCompat.CONSUMED を返すことで、各Viewのパディングもしくはマージンとして領域を消費することをAndroidのシステムに伝えています。 つまり、AppBarLayout の上部のパディング部分は AppBarLayout に属しているので、パディング部分も含めてXMLレイアウトファイルの android:background="?colorPrimary" という記述によって背景色が設定されます。
この性質によって、edge-to-edgeでは「ステータスバーの背景色」という概念がなくなっているので、API level 35以上では window.statusBarColor (Window$setStatusBarColor(Int)) は何も効果がない非推奨の関数に変わっています。

システムナビゲーションバーの高さは3ボタンナビゲーション(下の左画像)か、ジェスチャーナビゲーション(下の右画像)かで変化します。この変化にアプリUIが追随できるように実装する必要があります。

ViewCompat.setOnApplyWindowInsetsListener(...) を活用する方法の優れた点は、記述が非常にわかりやすく、かつアプリやAndroid端末の事情が絡み合う複雑な状況に対応しやすいことです。
MainActivity$applyWindowInsetsForE2E() は、3つのViewに対してそれぞれに必要なパディングの値を WindowInsetsCompat$getInsets(Int) で取得し、その値をパディングもしくはマージンとして設定しています。
そのうち FloatingActionButton のマージンの値の取得時に WindowInsetsCompat.Type.ime() のフラグの指定を含めています。 これで、下の右画像のように、ソフトウェアキーボードなどが表示されているときにその高さの分だけ FloatingActionButton が持ち上がるように実装できます(ボタンをこのように持ち上げる必要があることは多くはないでしょうが、例として挙げてみました)。
このようにアプリの状態変化に追随させるためには、画面の初期設定が終わった後もリスナーのunsetは行わずにに状態を監視させ続ける必要があります。

targetSdk = 35 (対策あり、音声入力UI表示時) targetSdk = 35(対策あり、ジェスチャーナビゲーション)
View: API level 35 voice input View: API level 35 gesture navigation

画面の回転を可能にしているアプリでは、 ViewCompat.setOnApplyWindowInsetsListener(...)はさらに強力です。
画面が回転するアプリだと、カットアウトは回転ごとに常に移動します。回転なしポートレイト(縦長)のときは上端でステータスバーと重なるので上端ではステータスバーとカットアウトのどちらか高い方のパディングを設定すればよいですが、それ以外の状態ではステータスバーとカットアウトの両方のパディングが必要になります。
システムナビゲーションバーについては、ボタンナビゲーションの場合はランドスケープ(横長)のとき左端または右端に移動、ジェスチャーナビゲーションの場合は常にシステムナビゲーションバーが下端。またタイトルバーは常に上端…と、多数の複雑な組み合わせが存在します。
このときも ViewCompat.setOnApplyWindowInsetsListener(...)は常に妥当なパディング・マージンの値を提供します。

targetSdk = 35(対策あり、左90°回転ランドスケープ、ボタンナビゲーション)
View: API level 35 gesture navigation
targetSdk = 35 (対策あり、右90°回転ランドスケープ、ジェスチャーナビゲーション)
View: API level 35 right90

2.2. ItemDecoration などを使う

Viewの構成によっては、多くのViewに新たにコールバックを追加しにくいようなケースもあるかもしれません。そのような場合には、1箇所で取得したインセットの値を共有するような方法でもよいでしょう。アプリがポートレイトのみ対応で画面回転を考慮しなくてよい場合など、インセットの動的な変化があまり起こらないアプリでは、できるだけ静的に設定する処理にするのがエンバグの危険が少ないとも考えられます。Activityで取得したインセットの値をFragmentと共有するようにしてもよいでしょう。

MainActivity.kt
    private var insetBottom = 0
    override fun onCreate(savedInstanceState: Bundle?) {

        // ...

        ViewCompat.setOnApplyWindowInsetsListener(binding.layoutAppbar) { v, windowInsets ->
            // アプリがポートレイトのみ対応で画面回転を考慮しなくてよい場合
            val insets = windowInsets.getInsets(
                WindowInsetsCompat.Type.systemBars()
                        or WindowInsetsCompat.Type.displayCutout()
            )
            insetBottom = insets.bottom
            binding.recyclerView.addItemDecoration(createListBottomSpacingItemDecoration(insetBottom))
            binding.fab.updateLayoutParams<ViewGroup.MarginLayoutParams> {
                val margin = resources.getDimensionPixelOffset(R.dimen.fab_margin)
                bottomMargin = insets.bottom + margin
                rightMargin = insets.right + margin
            }
            v.updatePadding(left = insets.left, top = insets.top, right = insets.right)
            WindowInsetsCompat.CONSUMED
        }
    }

たとえば、 RecyclerView の場合は RecyclerView$addItemDecoration(RecyclerView.ItemDecoration) を使った以下のような関数で RecyclerView のスクロール下端にedge-to-edge対応のパディングを設定することができます。ItemDecorationは主に区切り線を設定するために使われますが、このように決まった位置に空白を設定するだけの目的にも使えます。 アプリの構成によっては、 android:clipToPadding="false" を使ってパディングを設定するよりも少ない変更で済みそうです:

ViewUtils.kt
fun createListBottomSpacingItemDecoration(insetBottom: Int) = object : RecyclerView.ItemDecoration() {
    override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) {
        outRect.set(0, 0, 0, if (parent.getChildAdapterPosition(view) < state.itemCount - 1) 0 else insetBottom)
    }
}

3. Composableで構成された画面のedge-to-edge対応

Jetpack Compose 1.0 が公開されて4年経過した今、既存のプロジェクトではViewからComposableへの移行がかなり進んだ、もしくは最近立ち上がったプロジェクトでは最初から大半のUIをComposableで構成している、という開発プロジェクトも多いでしょう。
先ほどのViewベースのアプリと同様のComposeベースのアプリは、以下のような記述になるでしょう。なお本記事では、compose-bom 2024.06.00以降、Material3を用いるものとします:

MainActivity.kt
class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            E2ESampleTheme {
                val showsDialog = remember { mutableStateOf(false) }
                val isFabClicked = remember { mutableStateOf(false) }

                Scaffold(
                    topBar = {
                        Row(
                            modifier = Modifier
                                .fillMaxWidth()
                                .heightIn(64.dp)
                                .background(MaterialTheme.colorScheme.primary),
                            verticalAlignment = Alignment.CenterVertically
                        ) {
                            Text(
                                stringResource(R.string.app_name),
                                modifier = Modifier
                                    .padding(start = 16.dp)
                                    .weight(1F, true),
                                fontSize = 20.sp,
                                fontWeight = FontWeight.W600,
                                maxLines = 1,
                                overflow = TextOverflow.Ellipsis
                            )

                            TextButton(
                                modifier = Modifier.padding(end = 8.dp),
                                onClick = { showsDialog.value = true },
                                colors = ButtonDefaults.buttonColors(contentColor = MaterialTheme.colorScheme.onPrimary)
                            ) {
                                Text(
                                    stringResource(R.string.image_count),
                                    fontSize = 16.sp,
                                    fontWeight = FontWeight.W600
                                )
                            }
                        }
                    },
                    floatingActionButton = {
                        FloatingActionButton(
                            modifier = Modifier
                                .padding(dimensionResource(R.dimen.fab_margin)),
                            shape = CircleShape,
                            containerColor = MaterialTheme.colorScheme.secondary,
                            contentColor = MaterialTheme.colorScheme.onSecondary,
                            onClick = { isFabClicked.value = true }
                        ) {
                            Icon(Icons.Filled.Add, "One more")
                        }
                    },
                    content = { innerPadding ->
                        ImageColumn(Modifier.padding(innerPadding), showsDialog, isFabClicked)
                    }
                )
            }
        }

        withStyledAttributes(TypedValue().data, intArrayOf(android.R.attr.colorPrimary)) {
            window.statusBarColor = getColor(0, 0)
        }
    }
}
ImageColumn.kt
@DrawableRes
private val IMAGE_LIST = listOf(
    // ...
)

@Composable
fun ImageColumn(modifier: Modifier = Modifier, showsDialog: MutableState<Boolean>, isFabClicked: MutableState<Boolean>) {
    var length by remember { mutableIntStateOf(2) }

    Box(modifier = modifier.fillMaxSize()) {
        val lazyListState = rememberLazyListState()
        LazyColumn(state = lazyListState, modifier = Modifier.fillMaxSize()) {
            items(length, key = { it }) { index ->
                Box(Modifier.fillParentMaxWidth()) {
                    val bias = (index * 0.6F).rem(4F).let { if (it < 2F) it - 1F else (4F - it) - 1F }
                    Image(
                        modifier = Modifier
                            .padding(8.dp)
                            .align(BiasAlignment(horizontalBias = bias, verticalBias = 0F))
                            .animateItemPlacement(), // If compose 1.8.0 or upper, use .animateItem()
                        painter = painterResource(IMAGE_LIST[index % 4]),
                        contentDescription = null
                    )
                }
            }
        }

        val coroutineScope = rememberCoroutineScope()
        LaunchedEffect(isFabClicked.value) {
            if (isFabClicked.value) {
                length += 1
                isFabClicked.value = false
                coroutineScope.launch {
                    lazyListState.animateScrollToItem(length - 1)
                }
            }
        }

        if (showsDialog.value) {
            var numberText by remember { mutableStateOf("") }
            AlertDialog(
                onDismissRequest = { showsDialog.value = false },
                title = { Text(stringResource(R.string.image_count)) },
                confirmButton = {},
                dismissButton = {
                    TextButton(onClick = { showsDialog.value = false }) {
                        Text(stringResource(R.string.cancel))
                    }
                },
                text = {
                    TextField(
                        numberText,
                        { text -> numberText = text.filter { it.isDigit() } },
                        keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
                        keyboardActions = KeyboardActions {
                            length = numberText.toIntOrNull() ?: 0
                            showsDialog.value = false
                        },
                        singleLine = true
                    )
                }
            )
        }
    }
}

Viewベースの場合と同様、 targetSdk を34に設定してビルドした場合と35に設定してビルドした場合で比較します。
やはりタイトルバーでは問題が起きていますが、Viewのときとは異なり FloatingActionButton はシステムナビゲーションバーとは重なっていません。これは Scaffold(...)content パラメータに設定している関数オブジェクトの引数 innerPadding を参照してパディングを設定しているためです。
また、 Scaffold(...)topBar パラメータに設定している関数オブジェクトの中で TopAppBar(...) を使えば、ステータスバーの領域にパディングが自動的に設定されます。
Viewベースでの開発と比べてJetpack Composeの各関数では、このようにedge-to-edgeへの対応が考慮されている部分が多く、宣言的UIの概念によってより直感的な記述によるedge-to-edge対応が可能になっています。
もっとも、現時点では TopAppBar(...) を使うには @ExperimentalMaterial3ExpressiveApi が必要です。ここでは、experimental API がプロジェクトの制約等で使えず、またシステムナビゲーションバーの部分に縦スクロールのコンテンツを表示させる必要がある、という場合の実装を考えてみましょう。

targetSdk = 34 targetSdk = 35 (対策なし)
Composable: API level 34 Composable: API level 35: no-countermeasure

3.1. WindowInsets のプロパティ

(androidx.compose.foundation.layout.)WindowInsetsのプロパティおよび関数を使って、ステータスバーやシステムナビゲーションバー、カットアウトの領域のインセットを取得できます。
WindowInsets.asPaddingValues() を使ってインセットをパディングの値に変換し、 Modifier などに適用することで、Composableとエッジとの間に適切な距離をとらせることができます:

MainActivity.kt
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            E2ESampleTheme {
                val showsDialog = remember { mutableStateOf(false) }
                val isFabClicked = remember { mutableStateOf(false) }

                val safeDrawingInsets = WindowInsets.safeDrawing.asPaddingValues()
                val direction = LocalLayoutDirection.current

                Scaffold(
                    topBar = {
                        Row(
                            modifier = Modifier
                                .background(MaterialTheme.colorScheme.primary)
                                .padding(
                                    start = safeDrawingInsets.calculateStartPadding(direction),
                                    top = safeDrawingInsets.calculateTopPadding(),
                                    end = safeDrawingInsets.calculateEndPadding(direction)
                                )
                                .fillMaxWidth()
                                .heightIn(64.dp),
                            verticalAlignment = Alignment.CenterVertically
                        ) {
                            // この部分は不変 ...
                        }
                    },
                    floatingActionButton = {
                        FloatingActionButton(
                            modifier = Modifier.padding(end = safeDrawingInsets.calculateEndPadding(direction)),
                            shape = CircleShape,
                            containerColor = MaterialTheme.colorScheme.secondary,
                            contentColor = MaterialTheme.colorScheme.onSecondary,
                            onClick = { isFabClicked.value = true }
                        ) {
                            Icon(Icons.Filled.Add, "One more")
                        }
                    },
                    content = { innerPadding ->
                        ImageColumn(
                            Modifier.padding(top = innerPadding.calculateTopPadding()),
                            showsDialog,
                            isFabClicked
                        )
                    }
                )
            }
        }

        // window.statusBarColor = getColor(0, 0) // edge-to-edgeではstatusBarColorの設定は不要(無効)
    }
ImageColumn.kt
@Composable
fun ImageColumn(modifier: Modifier = Modifier, showsDialog: MutableState<Boolean>, isFabClicked: MutableState<Boolean>) {
    var length by remember { mutableIntStateOf(2) }

    val direction = LocalLayoutDirection.current
    val navigationBars = WindowInsets.navigationBars.asPaddingValues()
    val verticalBars = WindowInsets.displayCutout.union(navigationBars).asPaddingValues()
    Box(modifier = modifier
        .padding(
            start = verticalBars.calculateStartPadding(direction),
            end = verticalBars.calculateEndPadding(direction)
        )
        .fillMaxSize()
    ) {
        val lazyListState = rememberLazyListState()
        val bottomPadding = navigationBars.calculateBottomPadding()
        LazyColumn(
            state = lazyListState,
            modifier = Modifier.fillMaxSize(),
            contentPadding = PaddingValues(bottom = bottomPadding)
        ) {
            items(length, key = { it }) { index ->
                Box(Modifier.fillParentMaxWidth()) {
                    val bias = (index * 0.6F).rem(4F).let { if (it < 2F) it - 1F else (4F - it) - 1F }
                    Image(
                        modifier = Modifier
                            .padding(8.dp)
                            .align(BiasAlignment(horizontalBias = bias, verticalBias = 0F))
                            .animateItemPlacement(), // If compose 1.8.0 or upper, use .animateItem()
                        painter = painterResource(IMAGE_LIST[index % 4]),
                        contentDescription = null
                    )
                }
            }
        }

        // ...
    }
}

各Composableの Modifier に対して Modifier.windowInsetsPadding(WindowInsets) を使えればもう少し簡潔に書けますが、画面の回転に応じて上、左右、下のそれぞれで異なる処理を行わせるため、細かく設定を計算しています。
要領はWindowInsetsCompatの使い方とよく似ています。 WindowInsets の方が少し簡略化されて直感的にわかりやすくなっています。
WindowInsets.Companion.statusBars でステータスバー、 WindowInsets.Companion.navigationBars でシステムナビゲーションバーのインセットを表します。
また、複数のインセットを組み合わせたインセットを考えることもできます。WindowInsets.Companion.systemBarsWindowInsets.statusBars.union(WindowInsets.captionBar).union(WindowInsets.navigationBars)と同じ、 WindowInsets.Companion.safeDrawingWindowInsets.systemBars.union(WindowInsets.ime).union(WindowInsets.displayCutout) と同じです。
ここで WindowInsets.union(WindowInsets) は上、左、右、下のそれぞれで各インセットの最大値をとる関数です。たとえば回転なしポートレイトのときは上端にはステータスバーとカットアウトがあり、両者のうち高い方の高さを上端のインセットとする、という演算を行います(高さの最大値の距離をとることでステータスバーとカットアウトの両方を避けられるため)。
インセットの値を得て、 Scaffold(...)topBarfloatingActionButtonLazyColumn(...) のそれぞれに適切なパディングの値を与えます。Viewベースの場合にはコールバックという手続き的なコードでパディングやマージンを設定しましたが、宣言的UI概念のComposeではパラメータのように与えることができ、より直感的にわかりやすくなっているように思います。
floatingActionButton に対しては、ランドスケープ時の対策としてインセットの end の値のみにパディングを設定しています。
LazyColumn(...) では、Viewベースの RecyclerView にて android:clipToPadding="false" を設定したのと同様にスクロールするコンテンツの全体に対して下端のパディングを与えるため、 Modifier.padding(Dp) ではなく contentPadding パラメータに safeDrawingInsets.calculateBottomPadding() を与えています。

targetSdk = 35 (対策あり、ボタンナビゲーション) targetSdk = 35 (対策あり、ジェスチャーナビゲーション)
Composable: API level 34 Composable: API level 35: no-countermeasure

3.2. レイアウトの内側にパディングを設定する

2.2.のItemDecorationのように、LazyLayoutの内側に空白を設定することでedge-to-edgeにすることもできます:

ImageColumn.kt
fun ImageColumn(modifier: Modifier = Modifier, showsDialog: MutableState<Boolean>, isFabClicked: MutableState<Boolean>) {
    var length by remember { mutableIntStateOf(2) }

    val direction = LocalLayoutDirection.current
    val navigationBars = WindowInsets.navigationBars.asPaddingValues()
    val verticalBars = WindowInsets.displayCutout.union(WindowInsets.navigationBars).asPaddingValues()
    Box(
        // ...
    ) {
        val lazyListState = rememberLazyListState()
        val bottomPadding = navigationBars.calculateBottomPadding()
        LazyColumn(
            state = lazyListState,
            modifier = Modifier.fillMaxSize()
        ) {
            items(length, key = { it }) { index ->

                // ...

            }

            item {
                Spacer(modifier = Modifier.height(bottomPadding)) // <-
            }
        }

        // ...

    }

    // ...

}

LazyColumn(...) の末尾に item { Spacer(...) } を追加するだけです。仮に、タイトルバーがなくステータスバーとカットアウトの分のパディングが必要であれば、LazyColumn(...) の先頭に同様の item を追加すればよいでしょう。contentPadding よりも宣言的でわかりやすいと言えるかもしれません。
このように、レイアウトの内側にパディングを設定することも有力な手段です。Jetpack Compose には、このような柔軟な対応がしやすいという利点があります。

4. ViewとComposableが混在する画面のedge-to-edge対応

Jetpack Compose の公開後、ViewベースのアプリのComposable化を鋭意進めているが、残っているViewのすべてはすぐにはComposable化できない、というプロジェクトも多いでしょう。実際、WebViewなど、pure composable なソリューションがまだ存在しないUI部品も残っており[1]、Viewとの完全なお別れはまだ非現実的、というプロジェクトが多いことと推察いたします。
そんなアプリでは ComposeView などを使ってViewとComposableを混在させて画面を構成していると思われますが、これに対して上記で紹介したようなテクニックを使う際、状況が複雑化し、思わぬ問題が起きることがあります。

MainActivity.kt
class MainActivity : AppCompatActivity() {

    private lateinit var imageAdapter: ImageAdapter

    val showsDialog = mutableStateOf(false)

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        findViewById<ComposeView>(R.id.compose_view).apply {
            setContent {
                setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
                E2ESampleTheme {
                    ImageColumnScaffold(showsDialog)
                }
            }
        }

        withStyledAttributes(TypedValue().data, intArrayOf(android.R.attr.colorPrimary)) {
            window.statusBarColor = getColor(0, 0)
        }
        setSupportActionBar(findViewById(R.id.toolbar))
    }

    override fun onCreateOptionsMenu(menu: Menu): Boolean {
        menuInflater.inflate(R.menu.menu_main, menu)
        return true
    }

    override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
        R.id.action_settings -> {
            showsDialog.value = true
            true
        }
        else -> super.onOptionsItemSelected(item)
    }
}

@Composable
fun ImageColumnScaffold(showsDialog: MutableState<Boolean>) {
    val isFabClicked = remember { mutableStateOf(false) }
    Scaffold(
        floatingActionButton = {
            FloatingActionButton(
                shape = CircleShape,
                containerColor = MaterialTheme.colorScheme.secondary,
                contentColor = MaterialTheme.colorScheme.onSecondary,
                onClick = { isFabClicked.value = true }
            ) {
                Icon(Icons.Filled.Add, "One more")
            }
        },
        content = { innerPadding ->
            ImageColumn(Modifier.padding(innerPadding), showsDialog, isFabClicked)
        }
    )
}

レイアウトXMLファイルには ComposeView が含まれます:

res/layout/activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:clipToPadding="false"
    tools:context=".MainActivity">

    <com.google.android.material.appbar.AppBarLayout
        android:id="@+id/layout_appbar"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:background="?colorPrimary"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent">

        <com.google.android.material.appbar.MaterialToolbar
            android:id="@+id/toolbar"
            style="@style/Widget.MaterialComponents.Toolbar.Primary"
            android:layout_width="match_parent"
            android:layout_height="?attr/actionBarSize" />

    </com.google.android.material.appbar.AppBarLayout>

    <androidx.compose.ui.platform.ComposeView
        android:id="@+id/compose_view"
        android:layout_width="0dp"
        android:layout_height="0dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@id/layout_appbar" />

</androidx.constraintlayout.widget.ConstraintLayout>

ImageColumn.kt は3.と同じものを使います。

スクロール部分やフローティングアクションボタンはComposeで実装しているが、タイトルバーとメニューの処理は AppCompatActivity$setSupportActionBar(Toolbar) を使っている、という例です。この例は該当しませんが、たとえばFragmentで多くの画面を作っているアプリでは、このような構成になることが多そうですね。
このアプリで targetSdk を34→35としてビルドすると:

targetSdk = 34 targetSdk = 35 (対策なし)
Composable: API level 34 Composable: API level 35: no-countermeasure

ステータスバー・カットアウトとタイトルが重なるのは承知の上ですが、それに加えてタイトルバーとスクロール部分の間に奇妙な隙間が生じています。
これは Scaffold(...)content に渡している innerPadding がステータスバーとシステムナビゲーションバーの領域のパディングの値を持っているために起こっています。
この画面では、ステータスバー・カットアウトの領域を確保するのは AppBarLayout の責務、すなわちViewの責務であり、Composableは余計なことをしてはいけません。この画面に限って対策するなら Scaffold(...)content に渡すパディングを 0dp にすればよいのですが、 Scaffold(...) を使うComposableが大きな関数で、ある画面では純粋なComposableからなる画面から呼び出され、ある画面では ComposeView を含むViewベースの画面から呼び出され…というように多くの画面で共通に使われていて、、多岐にわたる処理を引き受けていたりすると、修正が大変です。
こういった場合に対処するため、Jetpack Composeには便利な関数が用意されています。

4.1. Modifier.consumeWindowInsets(WindowInsets)

以下のコードで解決します:

MainActivity.kt
class MainActivity : AppCompatActivity() {

    val showsDialog = mutableStateOf(false)

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        findViewById<ComposeView>(R.id.compose_view).apply {
            setContent {
                setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
                E2ESampleTheme {
                    ImageColumnScaffold(
                        Modifier.consumeWindowInsets(WindowInsets.systemBars), // <-
                        showsDialog
                    )
                }
            }
        }

        // window.statusBarColor = getColor(0, 0) // edge-to-edgeではstatusBarColorの設定は不要(無効)
        setSupportActionBar(findViewById(R.id.toolbar))

        ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.layout_appbar)) { v, windowInsets ->
            val insets = windowInsets.getInsets(
                WindowInsetsCompat.Type.systemBars()
                        or WindowInsetsCompat.Type.displayCutout()
            )

            v.updatePadding(
                left = insets.left,
                top = insets.top,
                right = insets.right,
            )
            WindowInsetsCompat.CONSUMED
        }
    }

    // ...

}

@Composable
fun ImageColumnScaffold(modifier: Modifier = Modifier, showsDialog: MutableState<Boolean>) {
    val isFabClicked = remember { mutableStateOf(false) }
    Scaffold(
        modifier,
        floatingActionButton = {
            FloatingActionButton(
                modifier = Modifier.padding(WindowInsets.safeDrawing.asPaddingValues()),
                shape = CircleShape,
                containerColor = MaterialTheme.colorScheme.secondary,
                contentColor = MaterialTheme.colorScheme.onSecondary,
                onClick = { isFabClicked.value = true }
            ) {
                Icon(Icons.Filled.Add, "One more")
            }
        },
        content = { innerPadding ->
            ImageColumn(Modifier.padding(innerPadding), showsDialog, isFabClicked)
        }
    )
}

ステータスバーの問題は、Viewベースの方法ViewCompat.setOnApplyWindowInsetsListenerで解決します。
隙間の問題は、 Scaffold(...)modifier 引数に Modifier.consumeWindowInsets(WindowInsets) を追加したものを渡すことで解決します。これは、Composableが systemBars の領域、すなわちステータスバーとシステムナビゲーションバーの領域を消費することを宣言するものです。
これで隙間だった部分にも Scaffold(...) のコンテンツが表示されて正常な表示に戻りますが、そのかわりに FloatingActionButton(...) のようなUIも systemBars の領域に入り込むようになるため、 FloatingActionButton(...)modifierModifier.padding(WindowInsets.safeDrawing.asPaddingValues()) を追加して、システムナビゲーションバーやカットアウトの領域を避けるように明示する必要が生じます。
ImageColumn(...) には 3.1.と同じものが使えます。このときと同じく、画面の左右の障害物を避けつつ、下端には LazyColumn(...)contentPadding を設定し、スクロールコンテンツの表示をシステムナビゲーションバーなどの領域にも許す一方、スクロールの下端がシステムナビゲーションバーなどの領域に重ならないようにしています。

5. まとめ

Android 15 対応が必須となった今、次のアプリリリース・アプリアップデートの日までに大急ぎでedge-to-edgeに対応しなければならない、という開発者の皆さんにも助けになることを願って、Viewベース、Composableベース、そしてViewとComposableが混在する場合のedge-to-edge対応の方法を紹介しました。非常に単純な画面例をサンプルにしていますが、それでも状況によってはかなり複雑な思考を要する場合があります。開発プロジェクトによっては、ほとんどの問題は解決しているが一部の画面がまだ解決していない、ということもあるでしょう。
edge-to-edge対応にはさまざまなツールが提供されています。ここで紹介したテクニックのうち試していないものがあれば、手を替え品を替えて試してみてください。また筆者自身、上記のような簡単なサンプルでもさまざまな対策を組み合わせて試すことで多くの学びがありました。何らかの形で本記事が皆さんの助けになれば幸いです。

6. 参考文献

脚注
  1. Composableとして使えるWebViewのラッパーAccompanistに含まれていますが、2025年8月時点でdeprecatedとなっており、今のところWebViewを置き換えるComposableの決定版は存在しないようです。 ↩︎

Facebook

関連記事 | Related Posts

イベント情報

Appium Meetup Tokyo #3