0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ネストされたScaffoldを使ってTopBarとBottomBarの管理を分けるときのメモ

0
Posted at

Androidアプリを開発していると、アプリのボトムバーはルートで管理し、トップバーは個別の画面に含めたい時があります。こうした時、Scaffoldをネストすることができます。こうすることでボトムバーは一箇所に置いて簡単に管理し、個別画面で柔軟にトップバーの見た目や振る舞いを変えることができます。

しかしScaffoldのcontentWindowInsetsの理解がない状態で、なんとなくでScaffoldをネストしようとすると、変な余白ができたり見切れたりしてハマることがあります。今回対応方法を整理したのでメモとして残しておきます。

いきなりネストされたScaffoldを説明するのではなく、まずScaffoldのcontentスロットのPaddingValuesのしくみなどを説明してから、本題に入ります。

理解するにはサンプルアプリを作ってみるとよいかと思います。自分も作っています。

hiroaki404/NestedScaffoldSample

なお下記説明では簡略化のため、縦向きのモバイル画面を想定しています。横向きやタブレットではちがったwindowInsetsが適用されますが、考え方は同じです。


予備知識

  • windowInsets: ステータスバーやナビゲーションバーなどにコンテンツが隠れないように情報を提供するインターフェース。ステータスバーやナビゲーションバーの高さの数値を提供。
  • edgeToEdge: ステータスバーやナビゲーションバーの領域までコンテンツを描画すること。ステータスバーやナビゲーションバーに重ねてコンテンツが描画される。
  • Scaffold: Jetpack Composeのレイアウトコンポーネント。アプリの基本的なレイアウト構造を提供する。TopBar、BottomBarなどのUI要素を簡単に配置できる。
  • contentWindowInsets: ScaffoldにセットできるコンテンツのwindowInsets。これはcontentスロットに渡されるpaddingValueに影響がある。topBarやbottomBarが与えられないときのみ上部と下部のinsetsを考慮し、paddingとして渡される。topBarやbottomBarが与えられた場合はtopBarやbottomBarがinsetsを処理する。

Scaffoldのcontentスロットに与えられるPaddingValues

ScaffoldにはcontentWindowInsetsの引数がある。デフォルトでScaffoldDefaults.contentWindowInsetsになっており、これによってstatusBarやnavigationBarを避けて、コンテンツが保護される。

@Composable
fun Scaffold(
    modifier: Modifier = Modifier,
    topBar: @Composable () -> Unit = {},
    bottomBar: @Composable () -> Unit = {},
    snackbarHost: @Composable () -> Unit = {},
    floatingActionButton: @Composable () -> Unit = {},
    floatingActionButtonPosition: FabPosition = FabPosition.End,
    containerColor: Color = MaterialTheme.colorScheme.background,
    contentColor: Color = contentColorFor(containerColor),
    contentWindowInsets: WindowInsets = ScaffoldDefaults.contentWindowInsets,
    content: @Composable (PaddingValues) -> Unit
) { ...
}

避けて保護されるといっても自動で避けるわけではない。ScaffoldのcontentスロットにPaddingValuesとして渡されて、contentスロットのコンテンツのそのpaddingを適用することが必要となる。

このPaddingValuesの値は状況によって別の値になる。TopBarやBottomBarが与えられるかどうかで、値が変わる。

TopBarやBottomBarが与えられない場合

PaddingValuesには上側にstatusBar分、下側にnavigationBar分のpaddingが与えられる。

TopBarやBottomBarが与えられた場合

PaddingValuesにはTopBarの高さ分とBottomBarの高さ分が与えられる。通常デフォルトでは(デフォルトについては後述)、このTopBarやBottomBarにはstatusBarやnavigationBarの高さが含まれているため、渡されたPaddingValuesを使えば、コンテンツはstatusBarやnavigationBar、さらにTopBarやBottomBarを避けて描画される。

ここで気をつけたいのは、TopBarやBottomBarが与えられたときは、ScaffoldはstatusBarやnavigationBarの高さをPaddingValuesに含めないこと。Scaffoldの実装には以下のコメントがある。つまりstatusBarやnavigationBarの高さはTopBarやBottomBarが処理することを期待している。

contentWindowInsets - window insets to be passed to content slot via PaddingValues params. Scaffold will take the insets into account from the top/bottom only if the topBar/ bottomBar are not present, as the scaffold expect topBar/bottomBar to handle insets instead. Any insets consumed by other insets padding modifiers or consumeWindowInsets on a parent layout will be excluded from contentWindowInsets.

ScaffoldにセットできるcontentWindowInsetsの挙動

ここでこの挙動を変えたい場合、ScaffoldのcontentWindowInsetsをカスタマイズする。

WindowInsets()(これはWindowInsets(0, 0, 0, 0)と同じ)を指定すると、ScaffoldはstatusBarやnavigationBarを考慮しなくなる。そのため以下の挙動になる。

TopBarやBottomBarが与えられない場合

PaddingValuesには上側も下側も0のpaddingが与えられる

TopBarやBottomBarが与えられた場合

PaddingValuesにはTopBarの高さ分とBottomBarの高さ分が与えられる。このときTopBarやBottomBarにstatusBarやnavigationBarの高さが含まれているかどうかは、Scaffoldは感知せず、Scaffoldは機械的にTopBarやBottomBarの高さ分をPaddingValuesに与える。

つまりScaffoldはTopBarやBottomBarが与えられていなければ、statusBarやnavigationBarを避けるPaddingValuesを与え、TopBarやBottomBarが与えられていれば、TopBarやBottomBarの高さ分を与える。 このときcontentWindowInsetsを変えることで、statusBarやnavigationBarの高さをどう認識させるかを変える。

TopBarやBottomBarが処理するWindowInsets

後述としていた部分。ScaffoldはTopBarやBottomBarが与えられた場合、statusBarやnavigationBarの高さを考慮せず、TopBarやBottomBarがwindowInsetsを処理する。

ではどうやってTopBarやBottomBarがwindowInsetsを処理するかというと、TopBarやBottomBarにはwindowInsetsという引数がある。デフォルトではTopAppBarDefaults.windowInsetsが与えられており、これによってTopBarはstatusBarの高さを含めるように構成される。

@ExperimentalMaterial3Api
@Composable
fun CenterAlignedTopAppBar(
    title: @Composable () -> Unit,
    modifier: Modifier = Modifier,
    navigationIcon: @Composable () -> Unit = {},
    actions: @Composable RowScope.() -> Unit = {},
    expandedHeight: Dp = TopAppBarDefaults.TopAppBarExpandedHeight,
    windowInsets: WindowInsets = TopAppBarDefaults.windowInsets,
    colors: TopAppBarColors = TopAppBarDefaults.centerAlignedTopAppBarColors(),
    scrollBehavior: TopAppBarScrollBehavior? = null
) = ...

デフォルトから振る舞いを変えて、例えばWindowInsets()を与えると、TopBarはstatusBarの高さを含まなくなる。

WindowInsetsの消費の概念

WindowInsetsには消費(consume)の概念がある。あるコンポーネントがWindowInsetsを消費すると、その下位のコンポーネントはそのWindowInsetsを受け取れなくなる。

しかしScaffoldは、自身が提供するPaddingValues分のWindowInsetsを自動的には消費しない。PaddingValuesとして余白は提供されるが、WindowInsetsの情報自体は消費されずにそのまま子コンポーネントに伝わる。

ネストされたScaffoldの場合

さて本題。親のScaffoldでbottomBarを指定し、子のScaffoldでTopBarを指定した場合を考える。

単純にScaffoldをネストした場合、以下のようになる。

@Composable
fun NestedScaffoldBadExampleScreen() {
    // 親のScaffold (BottomBarを持つ)
    Scaffold(
        modifier = Modifier.fillMaxSize(),
        bottomBar = {
            BottomAppBar {
                NavigationBar {
                    repeat(4) {
                        Icon(Icons.Default.Home, contentDescription = "Home Icon")
                    }
                }
            }
        },
    ) { outerPadding ->
        // 子のScaffold (TopBarを持つ)。outerPaddingをそのまま適用。
        Scaffold(
            modifier = Modifier.padding(outerPadding),
            topBar = {
                CenterAlignedTopAppBar(
                    title = { Text(text = "Bad Example (Double Padding)") },
                    colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Red),
                )
            },
        ) { innerPadding ->
            // innerPaddingを使ってコンテンツを配置
            LazyColumn(modifier = Modifier.padding(innerPadding)) {
                items(20) {
                    Greeting(name = "Bad Example $it")
                }
            }
        }
    }
}

画面は以下。余計なpaddingが入ってしまっている。トップバーの部分だけでなく、画像ではわかりにくいが、コンテンツの下部にもnavigationBar分のpaddingが入っている。

親のScaffoldのcontentスロットには、statusBarと、navigationBarを考慮したbottomBarの高さのpaddingが与えられる。しかし親Scaffoldはこの分のWindowInsetsを消費しないため、子のScaffoldにも「statusBarとnavigationBarがある」という情報(WindowInsets)がそのまま届く。前述のようにScaffoldは機械的にPaddingValuesを決定するため、このように余白が重複して中身のコンテンツにパスされていく。

解決方法

考えた方法は以下の2つのパターン(これだけではなく、他にもいろいろ方法はあると思います)。

パターンA

親子両方のScaffoldでcontentWindowInsetsWindowInsets()にする。PaddingValuesは普通に渡していく。TopBarやBottomBarのwindowInsetsはデフォルトのままにする。

@Composable
fun NestedScaffoldPatternAScreen() {
    Scaffold(
        modifier = Modifier.fillMaxSize(),
        contentWindowInsets = WindowInsets(), // WindowInsets(0, 0, 0, 0) と同じ
        bottomBar = {
            BottomAppBar {
                NavigationBar {
                    repeat(4) {
                        Icon(Icons.Default.Home, contentDescription = "Home Icon")
                    }
                }
            }
        },
    ) { outerPadding ->
        Scaffold(
            modifier = Modifier.padding(outerPadding),
            contentWindowInsets = WindowInsets(),
            topBar = {
                CenterAlignedTopAppBar(
                    title = { Text(text = "Pattern A") },
                    colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Blue),
                )
            },
        ) { innerPadding ->
            LazyColumn(modifier = Modifier.padding(innerPadding)) {
                items(20) { Greeting(name = "Pattern A $it") }
            }
        }
    }
}

親のScaffoldは子のScaffoldにnavigationBarの高さを含むBottomBarのpaddingのみを渡すようになっている。子のScaffoldは子のコンテンツにTopBar(statusBar分を含む)のpaddingを渡すようになっている。

パターンB

2つのScaffoldでcontentWindowInsetsはデフォルトを使うパターン。親のScaffoldではPaddingValuesのbottomだけ子に渡す。子のScaffoldでは子のコンテンツに、上側のPaddingValuesだけ渡す。TopBarやBottomBarのwindowInsetsはデフォルトのままにする。

@Composable
fun NestedScaffoldPatternBScreen() {
    Scaffold(
        modifier = Modifier.fillMaxSize(),
        // contentWindowInsetsはデフォルト(ステータスバー、ナビゲーションバーを考慮)
        bottomBar = {
            BottomAppBar {
                NavigationBar {
                    repeat(4) {
                        Icon(Icons.Default.Home, contentDescription = "Home Icon")
                    }
                }
            }
        },
    ) { outerPadding ->
        Scaffold(
            modifier = Modifier.padding(
                bottom = outerPadding.calculateBottomPadding() // 下側(BottomBar分)だけ適用
            ),
            topBar = {
                CenterAlignedTopAppBar(
                    title = { Text(text = "Pattern B") },
                    colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Cyan),
                )
            },
        ) { innerPadding ->
            LazyColumn(
                modifier = Modifier.padding(
                    top = innerPadding.calculateTopPadding() // 上側(TopBar分)だけ適用
                )
            ) {
                items(20) {
                    Greeting(name = "Pattern B $it")
                }
            }
        }
    }
}

個人的にはパターンAがシンプルになると思います。なぜかというと子のScaffoldが個別の画面を担当することになりますが、パターンAではWindowInsets()をセットするだけですが、パターンBではPaddingValuesを分解して渡す必要があり、認知負荷が高いからです。

ここでもう一つパターンCを紹介します。これは普通の用途では微妙なパターンですが、使い所はあるかもしれません。

パターンC

2つのScaffoldでcontentWindowInsetsはデフォルトを使う。親のScaffoldはPaddingValuesをそのまま子のScaffoldにわたす。子のScaffoldは子のコンテンツにtop側のpaddingだけを渡す。TopBarのwindowInsetsWindowInsets()をセットする。

@Composable
fun NestedScaffoldPatternCScreen() {
    Scaffold(
        modifier = Modifier.fillMaxSize(),
        bottomBar = {
            BottomAppBar {
                NavigationBar {
                    repeat(4) {
                        Icon(Icons.Default.Home, contentDescription = "Home Icon")
                    }
                }
            }
        },
    ) { outerPadding ->
        Scaffold(
            modifier = Modifier.padding(outerPadding),
            topBar = {
                CenterAlignedTopAppBar(
                    title = { Text(text = "Pattern C") },
                    colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Magenta),
                    windowInsets = WindowInsets() // TopBarがWindowInsets(ステータスバー)を処理しないようにする
                )
            },
        ) { innerPadding ->
            LazyColumn(
                modifier = Modifier.padding(
                    top = innerPadding.calculateTopPadding()
                )
            ) {
                items(20) {
                    Greeting(name = "Pattern C $it")
                }
            }
        }
    }
}

こうすると余白的にはokで、コンテンツがシステムのバーやアプリバーに被ることもない。ただしトップバーはステータスバー領域を含めない。あくまでステータスバーの下からアプリが始まっている、という状態になっている。

これが何が良くないというと、topBarとBackgroundの色が同じ場合は気付かないが、違う場合は、ステータスバーを除外した領域がアプリが表示領域となっているように見えること。topBarの実装でstatusBarの部分の色などを制御できなくなること。ただしそういう体験を狙うなら良いかと思うので、ケースバイケースかとは思います。

DroidKaigi2025のアプリの場合

DroidKaigi 2025 AppもScaffoldをネストして使っています。親のScaffoldでBottomBarを指定し、子のScaffoldでTopBarを指定している点は、この記事の内容と同じです。

DroidKaigiアプリはボトムバーはフローティングになっています。ボトムバーの下にコンテンツは表示しているが、スクロールしたらボトムバー分の余白が下に挿入されるという実装。

こういう場合BottomBarのPaddingを、親のScaffoldから、子のScaffoldのさらに中のコンテンツに渡す必要があります。バケツリレーで渡していくこともできますが、DroidKaigi AppではCompositionLocal使って伝搬させています。

またScaffoldのPaddingValuesからではなく、WindowInsets.safeDrawing.only(WindowInsetsSides.Top).asPaddingValues()のような形でstatusBarのpaddingを取得することもでき、そのような方法を使っているようです。

見てみると理解が深まると思います。

DroidKaigi/conference-app-2025: The Official Conference App for DroidKaigi 2025

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?