Jetpack Compose でのレイアウト

1. はじめに

Jetpack Compose の基本の Codelab では、Text のようなコンポーザブルと、画面上にアイテムを配置してその中の要素の配置を設定できる ColumnRow(配置はそれぞれ縦、横)などの柔軟なレイアウト コンポーザブルを使用して、Compose でシンプルな UI を作成する方法を学びました。一方、アイテムを縦や横に並べないようにする場合は、Box を使用すると、アイテムを他のアイテムの手前や背後に配置できます。

fbd450e8eab10338.png

こうした標準的なレイアウト コンポーネントを使用して、次のような UI を作成できます。

d2c39f3c2416c321.png

@Composable
fun PhotographerProfile(photographer: Photographer) {
    Row(verticalAlignment = Alignment.CenterVertically) {
        Image(...)
        Column {
            Text(photographer.name)
            Text(photographer.lastSeenOnline, ...)
        }
    }
}

Compose の再利用性とコンポーザビリティにより、必要な各種のパーツを適切な抽象度で組み合わせ、新しいコンポーズ可能な関数にすることで、独自のコンポーザブルを作成できます。

この Codelab では、Compose における最上位の UI 抽象化であるマテリアル デザインと、測定して画面上に要素を配置できる Layout のような低レベルのコンポーザブルの使用方法について説明します。

マテリアル デザインに基づいた UI を作成する場合、この Codelab で示すように、Compose に組み込まれているマテリアル コンポーネントを使用できます。マテリアル デザインを使用しない場合、またはマテリアル デザインの仕様にないものを作成する場合の、カスタム レイアウトの作成方法についても学びます。

学習内容

この Codelab では、以下について学びます。

  • マテリアル コンポーネントのコンポーザブルの使用方法
  • 修飾子の意義とレイアウトでの使用方法
  • カスタム レイアウトの作成方法
  • intrinsic が必要となる状況

前提条件

  • ラムダを含む Kotlin 構文の使用経験。
  • Compose の基本に関する知識。

必要なもの

2. 新しい Compose プロジェクトを開始する

新しい Compose プロジェクトを開始するには、Android Studio Bumblebee を開き、[Start a new Android Studio project] を選択します。次の画面が表示されます。

ec53715fe31913e6.jpeg

上の画面が表示されない場合は、[File] > [New] > [New Project] にアクセスします。

新しいプロジェクトを作成するため、利用可能なテンプレートの中から [Empty Compose Activity] を選択します。

a67ba73a4f06b7ac.png

[Next] をクリックし、通常どおりにプロジェクトを設定します。API レベル 21(API Compose がサポートする最小レベル)以上の minimumSdkVersion を選択してください。

[Empty Compose Activity] テンプレートを選択すると、プロジェクトに次のコードが生成されます。

  • プロジェクトは、すでに Compose を使用するように設定されています。
  • AndroidManifest.xml ファイルが作成されます。
  • app/build.gradle(または build.gradle (Module: YourApplicationName.app))ファイルで Compose の依存関係がインポートされ、Android Studio で buildFeatures { compose true } フラグを使用して Compose を扱えるようになります。
android {
    ...
    kotlinOptions {
        jvmTarget = '1.8'
        useIR = true
    }
    buildFeatures {
        compose true
    }
    composeOptions {
        kotlinCompilerExtensionVersion compose_version
    }
}

dependencies {
    ...
    implementation "androidx.compose.ui:ui:$compose_version"
    implementation 'androidx.activity:activity-compose:1.4.0'
    implementation "androidx.constraintlayout:constraintlayout-compose:1.0.0-rc01"
    implementation "androidx.compose.material:material:$compose_version"
    implementation "androidx.compose.ui:ui-tooling:$compose_version"
    ...
}

Codelab の解答

この Codelab の解答コードは GitHub から入手できます。

$ git clone https://github.com/googlecodelabs/android-compose-codelabs

または、リポジトリを ZIP ファイルとしてダウンロードすることもできます。

解答コードは LayoutsCodelab プロジェクトにあります。自分のペースで順を追って Codelab の学習を進め、必要と思われるときに解答を確認することをおすすめします。Codelab を進める過程で、プロジェクトに追加する必要があるコード スニペットを示します。

3. 修飾子

修飾子を使用すると、コンポーザブルを装飾できます。動作や外観の変更、ユーザー補助ラベルなどの情報の追加、ユーザー入力の処理、さらにはクリック、スクロール、ドラッグやズームが可能なハイレベルのインタラクションの追加を行えます。修飾子は標準の Kotlin オブジェクトです。変数に代入し、再利用できます。複数の修飾子を交互に連鎖させて構成することもできます。

「はじめに」のセクションで見たプロフィール レイアウトを実装してみましょう。

d2c39f3c2416c321.png

MainActivity.kt を開き、次のコードを追加します。

@Composable
fun PhotographerCard() {
    Column {
        Text("Alfred Sisley", fontWeight = FontWeight.Bold)
        // LocalContentAlpha is defining opacity level of its children
        CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) {
            Text("3 minutes ago", style = MaterialTheme.typography.body2)
        }
    }
}

@Preview
@Composable
fun PhotographerCardPreview() {
    LayoutsCodelabTheme {
        PhotographerCard()
    }
}

プレビュー:

bf29f2c3f5d6a27.png

次に、画像を読み込んでいる間、プレースホルダを表示させるとします。そのためには、円形とプレースホルダの色を指定する Surface を使用します。大きさを指定するには size 修飾子を使用します。

@Composable
fun PhotographerCard() {
    Row {
        Surface(
            modifier = Modifier.size(50.dp),
            shape = CircleShape,
            color = MaterialTheme.colors.onSurface.copy(alpha = 0.2f)
        ) {
            // Image goes here
        }
        Column {
            Text("Alfred Sisley", fontWeight = FontWeight.Bold)
            CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) {
                Text("3 minutes ago", style = MaterialTheme.typography.body2)
            }
        }
    }
}

84f2bb229d67987b.png

改善すべき点がいくつかあります。

  1. プレースホルダとテキストをある程度離す
  2. テキストを垂直方向に中央揃えする

1 番については、テキストを含む ColumnModifier.padding を使用することで、コンポーザブルの start にスペースを追加して画像とテキストを離すことができます。2 番については、レイアウトによっては、そのレイアウトとレイアウト特性にしか適用されない修飾子があります。たとえば Row のコンポーザブルは、weightalign など、そこで意味をなす特定の修飾子に(Row のコンテンツの RowScope レシーバから)アクセスできます。このスコープ設定が型安全性をもたらすため、別のレイアウトでは意味をなさない修飾子を誤って使用することがなくなります。たとえば weightBox では意味をなさないため、コンパイル時エラーとして防止されます。

@Composable
fun PhotographerCard() {
    Row {
        Surface(
            modifier = Modifier.size(50.dp),
            shape = CircleShape,
            color = MaterialTheme.colors.onSurface.copy(alpha = 0.2f)
        ) {
            // Image goes here
        }
        Column(
            modifier = Modifier
                .padding(start = 8.dp)
                .align(Alignment.CenterVertically)
        ) {
            Text("Alfred Sisley", fontWeight = FontWeight.Bold)
            CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) {
                Text("3 minutes ago", style = MaterialTheme.typography.body2)
            }
        }
    }
}

プレビュー:

1542fadc7f68feb2.png

ほとんどのコンポーザブルは、オプションの修飾子パラメータを受け入れることで柔軟性が高くなっているおり、呼び出し元による変更が可能です。独自のコンポーザブルを作成する場合、パラメータとして修飾子を使用し、デフォルトで Modifier に設定して(つまり何もしない空の修飾子)、関数のルート コンポーザブルに適用することを検討してください。この場合、次のようになります。

@Composable
fun PhotographerCard(modifier: Modifier = Modifier) {
    Row(modifier) { ... }
}

修飾子の順序の重要性

コードで、工場出荷時の拡張機能を使用して複数の修飾子を交互に連鎖させることができる点に注目してくだい(つまり Modifier.padding(start = 8.dp).align(Alignment.CenterVertically))。

修飾子を連鎖させるときは、順序が重要であることに注意してください。1 つの引数に連結されるため、順序が最終結果に影響します。

写真家のプロフィールをクリックできるようにし、パディングを持たせる場合は、次のようにします。

@Composable
fun PhotographerCard(modifier: Modifier = Modifier) {
    Row(modifier
        .padding(16.dp)
        .clickable(onClick = { /* Ignoring onClick */ })
    ) {
        ...
    }
}

インタラクティブ プレビューを使用するか、エミュレータで実行します。

c15a1050b051617f.gif

エリア全体がクリックできるわけではないことに注目してください。これは、padding 修飾子が clickable 修飾子より前に適用されたためです。padding 修飾子を clickable 修飾子より後に適用すると、パディングはクリック可能エリア内に含まれます。

@Composable
fun PhotographerCard(modifier: Modifier = Modifier) {
    Row(modifier
        .clickable(onClick = { /* Ignoring onClick */ })
        .padding(16.dp)
    ) {
        ...
    }
}

インタラクティブ プレビューを使用するか、エミュレータで実行します。

a1ea4c8e16d61ffa.gif

創造力を発揮しましょう。修飾子を使用すると、非常に柔軟な方法でコンポーザブルを変更できます。たとえば、外側にスペースを追加し、コンポーザブルの背景色を変更して、Row の角を丸くする場合、次のようなコードを使用します。

@Composable
fun PhotographerCard(modifier: Modifier = Modifier) {
    Row(modifier
        .padding(8.dp)
        .clip(RoundedCornerShape(4.dp))
        .background(MaterialTheme.colors.surface)
        .clickable(onClick = { /* Ignoring onClick */ })
        .padding(16.dp)
    ) {
        ...
    }
}

インタラクティブ プレビューを使用するか、エミュレータで実行します。

4c7652fc71ccf8dc.gif

修飾子が機能する仕組みについては、この Codelab で後ほど詳しく説明します。

4. スロット API

Compose には、UI を作成するために使用できるハイレベルのマテリアル コンポーネント コンポーザブルが用意されています。UI を作成するためのビルディング ブロックであるため、画面に表示するものについての情報を提供する必要はあります。

スロット API は、コンポーザブル(このユースケースでは利用可能なマテリアル コンポーネント コンポーザブル)の上にカスタマイズのレイヤを適用するために Compose で導入されたパターンです。

次の例で確認しましょう。

マテリアル ボタンについて考えてみると、ボタンの外観や何を含むべきかについてのガイドラインがありますが、これはシンプルな API に置き換えることができます。

Button(text = "Button")

b3cb99320ec18268.png

しかし、想定を超えてコンポーネントをカスタマイズしたい場合もよくあります。カスタマイズできる個々の要素ごとにパラメータを追加することもできますが、すぐに手に負えなくなります。

Button(
    text = "Button",
    icon: Icon? = myIcon,
    textStyle = TextStyle(...),
    spacingBetweenIconAndText = 4.dp,
    ...
)

ef5893f332864e28.png

そこで、想定外の方法でコンポーネントをカスタマイズするために複数のパラメータを追加するのではなく、スロットを追加しました。スロットは UI に空のスペースを残し、デベロッパーが自由に使用できるようにします。

fccfb817afa8876e.png

たとえばボタンの場合、アイコンとテキストを含む行を挿入できるよう、ボタンの内側を残しておけます。

Button {
    Row {
        MyImage()
        Spacer(4.dp)
        Text("Button")
    }
}

これを実現するために、子のコンポーザブル ラムダ(content: @Composable () -> Unit)を受け取るボタンの API が用意されています。これによりボタン内で出力される独自のコンポーザブルを定義できます。

@Composable
fun Button(
    modifier: Modifier = Modifier,
    onClick: (() -> Unit)? = null,
    ...
    content: @Composable () -> Unit
)

content と名付けたこのラムダが、最後のパラメータとなっています。これにより、後置ラムダ構文を使用して、構造化された方法でボタンにコンテンツを挿入できます。

Compose では、トップ アプリバーなどのさらに複雑なコンポーネントでスロットを多用します。

4365ce9b02ec2805.png

タイトル以外にもさまざまなものをカスタマイズできます。

2decc9ec64c79a84.png

使用例を次に示します。

TopAppBar(
    title = {
        Text(text = "Page title", maxLines = 2)
    },
    navigationIcon = {
        Icon(myNavIcon)
    }
)

独自のコンポーザブルを作成する場合、スロット API パターンを使用することで、再利用しやすくできます。

次のセクションでは、利用可能なさまざまなマテリアル コンポーネント コンポーザブルと、Android アプリを作成するときに使用する方法について説明します。

5. マテリアル コンポーネント

Compose にはマテリアル コンポーネント コンポーザブルが組み込まれており、アプリの作成に使用できます。最もハイレベルのコンポーザブルは Scaffold です。

Scaffold

Scaffold を使用すると、マテリアル デザインの基本的なレイアウト構造で UI を実装できます。Scaffold には、TopAppBarBottomAppBarFloatingActionButtonDrawer など、最も一般的なトップレベルのマテリアル コンポーネント向けのスロットが用意されています。Scaffold を使用すると、こうしたコンポーネントを正しく配置し、連携させることができます。

生成された Android Studio テンプレートに基づき、Scaffold を使用するようにサンプルコードを変更します。MainActivity.kt を開きます。GreetingGreetingPreview のコンポーザブルは使用しないため自由に削除してください。

LayoutsCodelab という新しいコンポーザブルを作成します。Codelab の全体を通してこれを変更していきます。

import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import com.codelab.layouts.ui.LayoutsCodelabTheme

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            LayoutsCodelabTheme {
                LayoutsCodelab()
            }
        }
    }
}

@Composable
fun LayoutsCodelab() {
    Text(text = "Hi there!")
}

@Preview
@Composable
fun LayoutsCodelabPreview() {
    LayoutsCodelabTheme {
        LayoutsCodelab()
    }
}

@Preview のアノテーションを付ける必要がある Compose プレビュー関数では、LayoutsCodelab が次のように表示されます。

bd1c58d4497f523f.png

典型的なマテリアル デザイン構造になるよう、例に Scaffold コンポーザブルを追加してみましょう。Scaffold API のパラメータは、@Composable (InnerPadding) -> Unit 型の本文コンテンツを除き、すべてオプションです。ラムダはパラメータとしてパディングを受け取ります。これは、画面上でアイテムを適切に制約するために、コンテンツのルート コンポーザブルに適用する必要のあるパディングです。簡単に始めるために、他のマテリアル コンポーネントなしで Scaffold を追加してみましょう。

@Composable
fun LayoutsCodelab() {
    Scaffold { innerPadding ->
        Text(text = "Hi there!", modifier = Modifier.padding(innerPadding))
    }
}

プレビュー:

54b175d305766292.png

Column を画面のメイン コンテンツにする場合、Column に修飾子を適用する必要があります。

@Composable
fun LayoutsCodelab() {
    Scaffold { innerPadding ->
        Column(modifier = Modifier.padding(innerPadding)) {
            Text(text = "Hi there!")
            Text(text = "Thanks for going through the Layouts codelab")
        }
    }
}

プレビュー:

aceda77e27f25fe9.png

コードを再利用しやすくし、テストしやすくするには、コードを小さなチャンクに構造化する必要があります。そのため、画面のコンテンツを使用して別のコンポーズ可能な関数を作成してみましょう。

@Composable
fun LayoutsCodelab() {
    Scaffold { innerPadding ->
        BodyContent(Modifier.padding(innerPadding))
    }
}

@Composable
fun BodyContent(modifier: Modifier = Modifier) {
    Column(modifier = modifier) {
        Text(text = "Hi there!")
        Text(text = "Thanks for going through the Layouts codelab")
    }
}

一般的に、Android アプリのトップ アプリバーには、現在の画面、ナビゲーション、アクションに関する情報が表示されます。それでは例に追加してみましょう。

TopAppBar

Scaffold には、@Composable () -> Unit 型の topBar パラメータによるトップ アプリバーのスロットがあります。つまり、このスロットは自由にコンポーザブルで埋めることができます。たとえば単に h3 スタイルのテキストを入れる場合、次のように、提供されたスロットに Text を使用できます。

@Composable
fun LayoutsCodelab() {
    Scaffold(
        topBar = {
            Text(
                text = "LayoutsCodelab",
                style = MaterialTheme.typography.h3
            )
        }
    ) { innerPadding ->
        BodyContent(Modifier.padding(innerPadding))
    }
}

プレビュー:

6adf05bb92b48b76.png

ただし、ほとんどのマテリアル コンポーネントと同様に、Compose にはタイトル、ナビゲーション アイコン、アクションのスロットを持つ TopAppBar コンポーザブルがあります。また、各コンポーネントで使用する色など、一部のデフォルト設定がマテリアル仕様の推奨事項に合わせて調整されています。

スロット API のパターンに沿って、TopAppBartitle スロットに画面タイトルの Text を入れます。

@Composable
fun LayoutsCodelab() {
    Scaffold(
        topBar = {
            TopAppBar(
                title = {
                    Text(text = "LayoutsCodelab")
                }
            )
        }
    ) { innerPadding ->
        BodyContent(Modifier.padding(innerPadding))
    }
}

プレビュー:

c93d09851d6560c7.png

トップ アプリバーには通常、なんらかのアクション アイテムがあります。この例では、何かを学んだと思ったときにタップできるお気に入りボタンを追加します。Compose には、たとえば閉じるアイコン、お気に入りアイコン、メニューアイコンに使用できる、事前定義されたマテリアル アイコンも用意されています。

トップ アプリバーのアクション アイテム用のスロットは、内部で Row を使用する actions パラメータであるため、複数のアクションが横並びに配置されます。事前定義されたアイコンのいずれかを使用するには、IconButton コンポーザブルと、その内部に Icon を使用します。

@Composable
fun LayoutsCodelab() {
    Scaffold(
        topBar = {
            TopAppBar(
                title = {
                    Text(text = "LayoutsCodelab")
                },
                actions = {
                    IconButton(onClick = { /* doSomething() */ }) {
                        Icon(Icons.Filled.Favorite, contentDescription = null)
                    }
                }
            )
        }
    ) { innerPadding ->
        BodyContent(Modifier.padding(innerPadding))
    }
}

プレビュー:

b2d81ccec4667ef5.png

通常、アクションはなんらかの形でアプリの状態を変更します。状態については、Jetpack Compose の基本の Codelab で状態管理の基本を詳しく学ぶことができます。

修飾子の配置

コンポーザブルを再利用しやすくするために、新しいコンポーザブルを作成するときは常に、デフォルトが Modifier である modifier パラメータを使用することをおすすめします。BodyContent コンポーザブルはすでに修飾子をパラメータとして受け取っています。BodyContent にパディングをさらに追加する場合は、どこに padding 修飾子を配置すればよいでしょうか。

2 通り考えられます。

  1. BodyContent の呼び出しすべてに追加のパディングが適用されるように、コンポーザブル内にある唯一の直接の子に修飾子を適用する。
@Composable
fun BodyContent(modifier: Modifier = Modifier) {
    Column(modifier = modifier.padding(8.dp)) {
        Text(text = "Hi there!")
        Text(text = "Thanks for going through the Layouts codelab")
    }
}
  1. コンポーザブルを呼び出すとき、必要な場合にのみパディングをさらに追加する修飾子を適用する。
@Composable
fun LayoutsCodelab() {
    Scaffold(...) { innerPadding ->
        BodyContent(Modifier.padding(innerPadding).padding(8.dp))
    }
}

どこで行うかは、コンポーザブルの種類とユースケースによって異なります。修飾子がコンポーザブルに固有のものである場合は内側に配置し、そうでない場合は外側に配置します。今回、パディングは BodyContent を呼び出すたびに強制するものではなく、ケースバイケースで適用する必要があるため、2 つ目の方法を採用します。

修飾子は、前の修飾子関数において次の修飾子関数を呼び出すことで、連鎖させることができます。利用可能な連鎖メソッドがない場合は .then() を使用できます。この例では modifier(小文字)で始めます。つまり、パラメータとして渡された連鎖の上に連鎖を作成します。

他のアイコン

前述のアイコンとは別に、プロジェクトに新しい依存関係を追加することで、マテリアル アイコンの完全なリストを使用できます。こうしたアイコンを試す場合は、app/build.gradle(または build.gradle (Module: app))ファイルを開き、ui-material-icons-extended 依存関係をインポートします。

dependencies {
  ...
  implementation "androidx.compose.material:material-icons-extended:$compose_version"
}

TopAppBar のアイコンは自由に変更して構いません。

追加の課題

ScaffoldTopAppBar は、マテリアル ルックのアプリを作成するために使用できるコンポーザブルの一部にすぎません。BottomNavigationBottomDrawer など、他のマテリアル コンポーネントでも同じことができます。演習として、これまでと同様にこれらの API を使用して Scaffold スロットを埋めてみましょう。

6. リストの利用

アイテムのリストを表示することは、アプリでよくあるパターンです。Jetpack Compose では、Column コンポーザブルと Row コンポーザブルを使用してこのパターンを簡単に実装できますが、現在表示されているアイテムのみを作成して配置する遅延リストも用意されています。

Column コンポーザブルを使用し、アイテム 100 個の縦方向リストを作成して練習してみましょう。

@Composable
fun SimpleList() {
    Column {
        repeat(100) {
            Text("Item #$it")
        }
    }
}

Column はデフォルトでスクロールを処理しないため、一部のアイテムが画面外にはみ出し、表示されません。verticalScroll 修飾子を追加して、Column 内をスクロールできるようにします。

@Composable
fun SimpleList() {
    // We save the scrolling position with this state that can also
    // be used to programmatically scroll the list
    val scrollState = rememberScrollState()

    Column(Modifier.verticalScroll(scrollState)) {
        repeat(100) {
            Text("Item #$it")
        }
    }
}

遅延リスト

Column は、画面に表示されないものも含め、すべてのリストアイテムをレンダリングします。リストサイズが大きくなったとき、これはパフォーマンスの問題となります。この問題を防ぐには、LazyColumn を使用します。画面に表示されているアイテムのみがレンダリングされ、パフォーマンスが向上し、scroll 修飾子が必要なくなります。

LazyColumn には、リストのコンテンツを記述する DSL があります。リストサイズとして数字を受け取ることができる items を使用します。配列とリストもサポートされています(詳細についてはリストのドキュメントのセクションをご覧ください)。

@Composable
fun LazyList() {
    // We save the scrolling position with this state that can also
    // be used to programmatically scroll the list
    val scrollState = rememberLazyListState()

    LazyColumn(state = scrollState) {
        items(100) {
            Text("Item #$it")
        }
    }
}

1c747e54111e28c.gif

画像の表示

先ほど PhotographCard で確認したように、Imageビットマップやベクター画像を表示するために使用できるコンポーザブルです。画像をリモートで取得する場合、アプリはアセットをダウンロードし、ビットマップにデコードして、最終的に Image 内にレンダリングする必要があるため、プロセスのステップが多くなります。

こうしたステップを簡略化するために、タスクを効率的に行うコンポーザブルが用意されている Coil ライブラリを使用します。

プロジェクトの build.gradle ファイルに Coil の依存関係を追加します。

// build.gradle
implementation 'io.coil-kt:coil-compose:1.4.0'

リモートで画像を取得するため、マニフェスト ファイルに INTERNET 権限を追加します。

<!-- AndroidManifest.xml -->
<uses-permission android:name="android.permission.INTERNET" />

ここで、画像とその横にアイテムのインデックスを表示するアイテム コンポーザブルを作成します。

@Composable
fun ImageListItem(index: Int) {
    Row(verticalAlignment = Alignment.CenterVertically) {

        Image(
            painter = rememberImagePainter(
                data = "https://developer.android.com/images/brand/Android_Robot.png"
            ),
            contentDescription = "Android Logo",
            modifier = Modifier.size(50.dp)
        )
        Spacer(Modifier.width(10.dp))
        Text("Item #$index", style = MaterialTheme.typography.subtitle1)
    }
}

次に、リストの Text コンポーザブルをこの ImageListItem と入れ替えます。

@Composable
fun ImageList() {
    // We save the scrolling position with this state
    val scrollState = rememberLazyListState()

    LazyColumn(state = scrollState) {
        items(100) {
            ImageListItem(it)
        }
    }
}

9c6a666c57a84211.gif

リストのスクロール

リストのスクロール位置を手動で制御してみましょう。リストの最上部と最下部までスムーズにスクロールできるように、ボタンを 2 つ追加します。スクロール中にリストのレンダリングがブロックされないよう、スクロール API は suspend 関数になっています。そこで、コルーチン内で呼び出す必要があります。そのためには、rememberCoroutineScope 関数を使用して CoroutineScope を作成し、ボタンイベント ハンドラからコルーチンを作成します。この CoroutineScope は、コールサイトのライフサイクルに従います。コンポーザブルのライフサイクル、コルーチン、副作用について詳しくは、こちらのガイドをご覧ください。

val listSize = 100
// We save the scrolling position with this state
val scrollState = rememberLazyListState()
// We save the coroutine scope where our animated scroll will be executed
val coroutineScope = rememberCoroutineScope()

最後に、スクロールを制御するボタンを追加します。

Row {
    Button(onClick = {
        coroutineScope.launch {
            // 0 is the first item index
            scrollState.animateScrollToItem(0)
        }
    }) {
        Text("Scroll to the top")
    }

    Button(onClick = {
        coroutineScope.launch {
            // listSize - 1 is the last index of the list
            scrollState.animateScrollToItem(listSize - 1)
        }
    }) {
        Text("Scroll to the end")
    }
}

9bc52801a90401f3.gif

このセクションのコードの全文

import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.Button
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import coil.compose.rememberImagePainter
import kotlinx.coroutines.launch

@Composable
fun ImageListItem(index: Int) {
    Row(verticalAlignment = Alignment.CenterVertically) {
        Image(
            painter = rememberImagePainter(
                data = "https://developer.android.com/images/brand/Android_Robot.png"
            ),
            contentDescription = "Android Logo",
            modifier = Modifier.size(50.dp)
        )
        Spacer(Modifier.width(10.dp))
        Text("Item #$index", style = MaterialTheme.typography.subtitle1)
    }
}

@Composable
fun ScrollingList() {
    val listSize = 100
    // We save the scrolling position with this state
    val scrollState = rememberLazyListState()
    // We save the coroutine scope where our animated scroll will be executed
    val coroutineScope = rememberCoroutineScope()

    Column {
        Row {
            Button(onClick = {
                coroutineScope.launch {
                    // 0 is the first item index
                    scrollState.animateScrollToItem(0)
                }
            }) {
                Text("Scroll to the top")
            }

            Button(onClick = {
                coroutineScope.launch {
                    // listSize - 1 is the last index of the list
                    scrollState.animateScrollToItem(listSize - 1)
                }
            }) {
                Text("Scroll to the end")
            }
        }

        LazyColumn(state = scrollState) {
            items(listSize) {
                ImageListItem(it)
            }
        }
    }
}

7. カスタム レイアウトを作成する

Compose は、ColumnRowBox などの組み込みコンポーザブルを組み合わせることで、カスタム レイアウトに十分な小さいチャンクとして、コンポーザブルの再利用性を高めます。

ただし、手動で測定して子を配置する必要がある、アプリに固有なものを作成する必要が生じることがあります。その場合、Layout コンポーザブルを使用できます。実際、ColumnRow のような上位のレイアウトはすべて、このコンポーザブルで作成します。

カスタム レイアウトの作成方法に入る前に、Compose におけるレイアウトの原則について詳しく知っておく必要があります。

Compose におけるレイアウトの原則

コンポーズ可能な関数の中には、呼び出されたときに UI の一部を出力し、それを画面にレンダリングされる UI ツリーに追加するものもあります。各出力(要素)には親が 1 つあり、場合によっては多くの子があります。また、親要素内の場所((x, y) 位置)とサイズ(widthheight)もあります。

要素は、満たす必要がある制約を使用して、自身を測定するよう求められます。制約により、要素の widthheight の最小値と最大値が制限されます。要素に子要素がある場合、親の要素は、自身のサイズを判断しやすくするために、子のそれぞれを測定できます。要素が自身のサイズを報告すると、子要素を自身に対し相対的に配置できるようになります。これについてはカスタム レイアウトを作成する際に詳しく説明します。

注: Compose UI では、マルチパス測定は許可されていません。つまり、さまざまな測定構成を試すために、レイアウト要素でその子要素を複数回測定することはできません。シングルパス測定はパフォーマンスに優れており、Compose は深い UI ツリーを効率的に処理できます。レイアウト要素が子を 2 回測定し、その子が自身の子のいずれかを 2 回測定した場合など、UI 全体をレイアウトしようとすると多くの作業が必要になるため、アプリのパフォーマンスを良好に保つことが難しくなります。しかし、子の測定 1 回でわかることに加えて、追加の情報が本当に必要な場合もあります。そうした場合に対応する方法については後ほど説明します。

レイアウト修飾子の使用

layout 修飾子を使用して、要素の測定と配置の方法を手動で制御します。通常、カスタム layout 修飾子の共通構造は次のとおりです。

fun Modifier.customLayoutModifier(...) = Modifier.layout { measurable, constraints ->
  ...
})

layout 修飾子を使用する場合、ラムダ パラメータを 2 つ取得します。

  • measurable: 測定し配置する子
  • constraints: 子の幅と高さの最小値と最大値

画面に Text を表示し、上端からテキスト先頭行のベースラインまでの距離を制御するとします。そのためには、layout 修飾子を使用して画面上に手動でコンポーザブルを配置する必要があります。望ましい動作を次の画像に示します。上端から最初のベースラインまでの距離は 24.dp です。

4ee1054702073598.png

まず、firstBaselineToTop 修飾子を作成しましょう。

fun Modifier.firstBaselineToTop(
  firstBaselineToTop: Dp
) = this.then(
    layout { measurable, constraints ->
        ...
    }
)

コンポーザブルの測定から行います。コンポーザブルにおけるレイアウトの原則のセクションで述べたように、子の測定は 1 回しかできません

measurable.measure(constraints) を呼び出してコンポーザブルを測定します。measure(constraints) を呼び出すときは、constraints ラムダ パラメータで利用可能なコンポーザブルの所与の制約を渡すことも、独自に作成することもできます。Measurablemeasure() 呼び出しの結果は Placeable であり、後で行うように placeRelative(x, y) を呼び出して配置できます。

このユースケースでは、測定をそれ以上制約することなく、単に所与の制約を使用します。

fun Modifier.firstBaselineToTop(
    firstBaselineToTop: Dp
) = this.then(
    layout { measurable, constraints ->
        val placeable = measurable.measure(constraints)

        ...
    }
)

コンポーザブルを測定したので、サイズを計算する必要があります。また、コンテンツを配置するために使用するラムダも受け入れる layout(width, height) メソッドを呼び出して、サイズを指定する必要があります。

この場合、対象のコンポーザブルの幅は測定したコンポーザブルの width になり、高さは、希望の上端からベースラインまでの高さと最初のベースラインの差を、コンポーザブルの height に足したものになります。

fun Modifier.firstBaselineToTop(
    firstBaselineToTop: Dp
) = this.then(
    layout { measurable, constraints ->
        val placeable = measurable.measure(constraints)

        // Check the composable has a first baseline
        check(placeable[FirstBaseline] != AlignmentLine.Unspecified)
        val firstBaseline = placeable[FirstBaseline]

        // Height of the composable with padding - first baseline
        val placeableY = firstBaselineToTop.roundToPx() - firstBaseline
        val height = placeable.height + placeableY
        layout(placeable.width, height) {
            ...
        }
    }
)

これで、placeable.placeRelative(x, y) を呼び出して画面上にコンポーザブルを配置できるようになりました。placeRelative を呼び出さない場合、コンポーザブルは表示されません。placeRelative は、現在の layoutDirection に基づいて placeable の位置を自動的に調整します。

この場合、テキストの y 位置は、上パディングと最初のベースライン位置の差に相当します。

fun Modifier.firstBaselineToTop(
    firstBaselineToTop: Dp
) = this.then(
    layout { measurable, constraints ->
        ...

        // Height of the composable with padding - first baseline
        val placeableY = firstBaselineToTop.roundToPx() - firstBaseline
        val height = placeable.height + placeableY
        layout(placeable.width, height) {
            // Where the composable gets placed
            placeable.placeRelative(0, placeableY)
        }
    }
)

これが期待どおりに機能することを確認するには、上の図のように、Text でこの修飾子を使用します。

@Preview
@Composable
fun TextWithPaddingToBaselinePreview() {
  LayoutsCodelabTheme {
    Text("Hi there!", Modifier.firstBaselineToTop(32.dp))
  }
}

@Preview
@Composable
fun TextWithNormalPaddingPreview() {
  LayoutsCodelabTheme {
    Text("Hi there!", Modifier.padding(top = 32.dp))
  }
}

プレビュー:

dccb4473e2ca09c6.png

Layout コンポーザブルの使用

単一のコンポーザブルを測定して画面に配置する方法を制御するのではなく、コンポーザブルのグループに対して同じことを行う必要が生じることもあります。その場合、Layout コンポーザブルを使用して、レイアウトの子要素を測定し配置する方法を手動で制御できます。通常、Layout を使用するコンポーザブルの共通構造は次のとおりです。

@Composable
fun CustomLayout(
    modifier: Modifier = Modifier,
    // custom layout attributes
    content: @Composable () -> Unit
) {
    Layout(
        modifier = modifier,
        content = content
    ) { measurables, constraints ->
        // measure and position children given constraints logic here
    }
}

CustomLayout に最低限必要なパラメータは、Layout に渡される modifiercontent です。Layout の後置ラムダ(MeasurePolicy 型)では、layout 修飾子の場合と同じラムダ パラメータが得られます。

Layout の動作を確認するために、まずは Layout を使用して非常に基本的な Column を実装し、API を理解しましょう。後ほど、さらに複雑なものを作成して Layout コンポーザブルの柔軟性を紹介します。

基本的な Column の実装

Column のカスタム実装では、アイテムを縦方向に配置します。また、簡略化するために、レイアウトが親の中で可能な限り多くのスペースを占有しています。

MyOwnColumn という新しいコンポーザブルを作成し、Layout コンポーザブルの共通構造を追加します。

@Composable
fun MyOwnColumn(
    modifier: Modifier = Modifier,
    content: @Composable () -> Unit
) {
    Layout(
        modifier = modifier,
        content = content
    ) { measurables, constraints ->
        // measure and position children given constraints logic here
    }
}

前のように、まずは 1 回しか測定できない子を測定します。レイアウト修飾子の仕組みと同様に、measurables ラムダ パラメータで、measurable.measure(constraints) を呼び出して測定できる content をすべて取得します。

このユースケースでは、子ビューをさらに制約することはありません。子ビューを測定するときは、後で画面上に正しく配置できるように、各行の width と最大 height を記録しておく必要もあります。

@Composable
fun MyOwnColumn(
    modifier: Modifier = Modifier,
    content: @Composable () -> Unit
) {
    Layout(
        modifier = modifier,
        content = content
    ) { measurables, constraints ->

        // Don't constrain child views further, measure them with given constraints
        // List of measured children
        val placeables = measurables.map { measurable ->
            // Measure each child
            measurable.measure(constraints)
        }
    }
}

これで、測定した子のリストがロジック内にできました。画面上に配置する前に、今回の Column のサイズを計算する必要があります。親と同じ大きさにするため、サイズは親から渡された制約となります。layout(width, height) メソッドを呼び出して、独自の Column のサイズを指定します。これにより、子を配置するために使用するラムダも得られます。

@Composable
fun MyOwnColumn(
    modifier: Modifier = Modifier,
    content: @Composable () -> Unit
) {
    Layout(
        modifier = modifier,
        content = content
    ) { measurables, constraints ->
        // Measure children - code in the previous code snippet
        ...

        // Set the size of the layout as big as it can
        layout(constraints.maxWidth, constraints.maxHeight) {
            // Place children
        }
    }
}

最後に、placeable.placeRelative(x, y) を呼び出して画面上に子を配置します。子を縦方向に配置するために、子を配置した y 座標を記録しておきます。MyOwnColumn のコードは最終的に次のようになります。

@Composable
fun MyOwnColumn(
    modifier: Modifier = Modifier,
    content: @Composable () -> Unit
) {
    Layout(
        modifier = modifier,
        content = content
    ) { measurables, constraints ->
        // Don't constrain child views further, measure them with given constraints
        // List of measured children
        val placeables = measurables.map { measurable ->
            // Measure each child
            measurable.measure(constraints)
        }

        // Track the y co-ord we have placed children up to
        var yPosition = 0

        // Set the size of the layout as big as it can
        layout(constraints.maxWidth, constraints.maxHeight) {
            // Place children in the parent layout
            placeables.forEach { placeable ->
                // Position item on the screen
                placeable.placeRelative(x = 0, y = yPosition)

                // Record the y co-ord placed up to
                yPosition += placeable.height
            }
        }
    }
}

MyOwnColumn の動作

BodyContent コンポーザブルの中で MyOwnColumn を使用して画面上で確認してみましょう。BodyContent の内容を次のように置き換えます。

@Composable
fun BodyContent(modifier: Modifier = Modifier) {
    MyOwnColumn(modifier.padding(8.dp)) {
        Text("MyOwnColumn")
        Text("places items")
        Text("vertically.")
        Text("We've done it by hand!")
    }
}

プレビュー:

e69cdb015e4d8abe.png

8. 複雑なカスタム レイアウト

Layout の基本を理解したところで、API の柔軟性を紹介するために、さらに複雑な例を作成しましょう。次の図の中ほどにある、カスタムの Material Study Owl の千鳥格子を作成します。

7a54fe8390fe39d2.png

Owl の千鳥格子はアイテムを縦方向に配置し、n 行で一度に 1 列を埋めます。ColumnsRow でこれを行うことはできません。レイアウトを千鳥状にできないためです。RowsColumn では、縦方向に表示できるようにデータを準備すれば可能です。

しかしカスタム レイアウトでは、千鳥格子内のすべてのアイテムの高さを制約することもできます。そこで、レイアウトをさらに制御し、カスタム レイアウトを作成する方法を学ぶために、自身で子を測定し配置しましょう。

格子をさまざまな方向で再利用できるようにする場合、画面に表示する行数をパラメータとして受け取ります。この情報はレイアウトが呼び出されたときに得られるため、パラメータとして渡します。

@Composable
fun StaggeredGrid(
    modifier: Modifier = Modifier,
    rows: Int = 3,
    content: @Composable () -> Unit
) {
    Layout(
        modifier = modifier,
        content = content
    ) { measurables, constraints ->
        // measure and position children given constraints logic here
    }
}

前のように、まずは子を測定します。ご存じのように、子は 1 回しか測定できません

このユースケースでは、子ビューをさらに制約することはありません。子ビューを測定するときは、各行の width と最大 height を記録しておく必要もあります。

Layout(
    modifier = modifier,
    content = content
) { measurables, constraints ->

    // Keep track of the width of each row
    val rowWidths = IntArray(rows) { 0 }

    // Keep track of the max height of each row
    val rowHeights = IntArray(rows) { 0 }

    // Don't constrain child views further, measure them with given constraints
    // List of measured children
    val placeables = measurables.mapIndexed { index, measurable ->

        // Measure each child
        val placeable = measurable.measure(constraints)

        // Track the width and max height of each row
        val row = index % rows
        rowWidths[row] += placeable.width
        rowHeights[row] = Math.max(rowHeights[row], placeable.height)

        placeable
    }
    ...
}

これで、測定した子のリストがロジック内にできました。画面上に配置する前に、格子のサイズを計算する必要があります(全 width と全 height)。また、各行の最大高は既知であるため、各行の要素を配置する Y 位置を計算できます。Y 位置は rowY 変数に保存します。

Layout(
    content = content,
    modifier = modifier
) { measurables, constraints ->
    ...

    // Grid's width is the widest row
    val width = rowWidths.maxOrNull()
        ?.coerceIn(constraints.minWidth.rangeTo(constraints.maxWidth)) ?: constraints.minWidth

    // Grid's height is the sum of the tallest element of each row
    // coerced to the height constraints
    val height = rowHeights.sumOf { it }
        .coerceIn(constraints.minHeight.rangeTo(constraints.maxHeight))

    // Y of each row, based on the height accumulation of previous rows
    val rowY = IntArray(rows) { 0 }
    for (i in 1 until rows) {
        rowY[i] = rowY[i-1] + rowHeights[i-1]
    }

    ...
}

最後に、placeable.placeRelative(x, y) を呼び出して画面上に子を配置します。このユースケースでは、各行の X 座標も rowX に記録しておきます。

Layout(
    content = content,
    modifier = modifier
) { measurables, constraints ->
    ...

    // Set the size of the parent layout
    layout(width, height) {
        // x cord we have placed up to, per row
        val rowX = IntArray(rows) { 0 }

        placeables.forEachIndexed { index, placeable ->
            val row = index % rows
            placeable.placeRelative(
                x = rowX[row],
                y = rowY[row]
            )
            rowX[row] += placeable.width
        }
    }
}

カスタムの StaggeredGrid の使用例

子を測定し配置する方法がわかっているカスタムの格子レイアウトができたので、アプリで使用しましょう。格子内の Owl のチップをシミュレートするために、同様のことを行うコンポーザブルを簡単に作成できます。

@Composable
fun Chip(modifier: Modifier = Modifier, text: String) {
    Card(
        modifier = modifier,
        border = BorderStroke(color = Color.Black, width = Dp.Hairline),
        shape = RoundedCornerShape(8.dp)
    ) {
        Row(
            modifier = Modifier.padding(start = 8.dp, top = 4.dp, end = 8.dp, bottom = 4.dp),
            verticalAlignment = Alignment.CenterVertically
        ) {
            Box(
                modifier = Modifier.size(16.dp, 16.dp)
                    .background(color = MaterialTheme.colors.secondary)
            )
            Spacer(Modifier.width(4.dp))
            Text(text = text)
        }
    }
}

@Preview
@Composable
fun ChipPreview() {
    LayoutsCodelabTheme {
        Chip(text = "Hi there")
    }
}

プレビュー:

f1f8c6bb7f12cf1.png

今度は、BodyContent に表示できるトピックのリストを作成し、StaggeredGrid に表示しましょう。

val topics = listOf(
    "Arts & Crafts", "Beauty", "Books", "Business", "Comics", "Culinary",
    "Design", "Fashion", "Film", "History", "Maths", "Music", "People", "Philosophy",
    "Religion", "Social sciences", "Technology", "TV", "Writing"
)

@Composable
fun BodyContent(modifier: Modifier = Modifier) {
    StaggeredGrid(modifier = modifier) {
        for (topic in topics) {
            Chip(modifier = Modifier.padding(8.dp), text = topic)
        }
    }
}

@Preview
@Composable
fun LayoutsCodelabPreview() {
    LayoutsCodelabTheme {
        BodyContent()
    }
}

プレビュー:

e9861768e4e27dd4.png

格子の行数を変更しても期待どおりに動作します。

@Composable
fun BodyContent(modifier: Modifier = Modifier) {
    StaggeredGrid(modifier = modifier, rows = 5) {
        for (topic in topics) {
            Chip(modifier = Modifier.padding(8.dp), text = topic)
        }
    }
}

プレビュー:

555f88fd41e4dff4.png

行数によってはトピックが画面外にはみ出すため、StaggeredGrid をスクロール可能な Row でラップして、StaggeredGrid ではなく修飾子を渡すと、BodyContent をスクロール可能にできます。

@Composable
fun BodyContent(modifier: Modifier = Modifier) {
    Row(modifier = modifier.horizontalScroll(rememberScrollState())) {
        StaggeredGrid {
            for (topic in topics) {
                Chip(modifier = Modifier.padding(8.dp), text = topic)
            }
        }
    }
}

インタラクティブ プレビュー ボタン(bb4c8dfe4b8debaa.png)を使用するか、Android Studio の実行ボタンをタップしてデバイスでアプリを実行すると、コンテンツを横方向にスクロールできることが確認できます。

9. レイアウト修飾子の仕組み

修飾子の基本、カスタム コンポーザブルの作成方法、子を手動で測定し配置する方法について理解したところで、修飾子の仕組みについて理解を深めましょう。

まとめると、修飾子を使用することで、コンポーザブルの動作をカスタマイズできます。複数の修飾子を互いに連鎖させることで組み合わせることができます。修飾子の種類は複数ありますが、UI コンポーネントを測定し配置する方法を変更できることから、このセクションでは LayoutModifier に着目します。

コンポーザブルは自身のコンテンツを扱い、そのコンテンツは、コンポーザブルの作成者が明示的な API を公開しない限り、親によって検査されたり、操作されたりしません。同様に、コンポーザブルの修飾子は、変更内容を同じ不透明な方法で装飾します(修飾子がカプセル化されます)。

修飾子の分析

ModifierLayoutModifier は公開インターフェースであるため、独自の修飾子を作成できます。前に使用した Modifier.padding の実装を分析し、修飾子について理解を深めましょう。

padding は、LayoutModifier インターフェースを実装するクラスでバックアップされる関数であり、measure メソッドをオーバーライドします。PaddingModifierequals() を実装する通常のクラスであるため、修飾子は再コンポジション間で比較できます。

例として、適用される要素のサイズと制約が padding によって変更されるソースコードを示します。

// How to create a modifier
@Stable
fun Modifier.padding(all: Dp) =
    this.then(
        PaddingModifier(start = all, top = all, end = all, bottom = all, rtlAware = true)
    )

// Implementation detail
private class PaddingModifier(
    val start: Dp = 0.dp,
    val top: Dp = 0.dp,
    val end: Dp = 0.dp,
    val bottom: Dp = 0.dp,
    val rtlAware: Boolean,
) : LayoutModifier {

    override fun MeasureScope.measure(
        measurable: Measurable,
        constraints: Constraints
    ): MeasureResult {

        val horizontal = start.roundToPx() + end.roundToPx()
        val vertical = top.roundToPx() + bottom.roundToPx()

        val placeable = measurable.measure(constraints.offset(-horizontal, -vertical))

        val width = constraints.constrainWidth(placeable.width + horizontal)
        val height = constraints.constrainHeight(placeable.height + vertical)
        return layout(width, height) {
            if (rtlAware) {
                placeable.placeRelative(start.roundToPx(), top.roundToPx())
            } else {
                placeable.place(start.roundToPx(), top.roundToPx())
            }
        }
    }
}

要素の新しい width は、子の width と、要素の幅の制約に強制された左右のパディング値の和です。height は、子の height と、要素の高さの制約に強制された上下のパディング値の和です。

順序の重要性

最初のセクションで確認したように、修飾子を連鎖させるときは順序が重要です。これは、変更するコンポーザブルに対して、早いものから順に適用されるためです。つまり、左側の修飾子の測定とレイアウトが右側の修飾子に影響します。コンポーザブルの最終的なサイズは、パラメータとして渡されるすべての修飾子に左右されます。

まず、修飾子は左から右に制約を更新し、その後、右から左にサイズを戻します。次の例で確認しましょう。

@Composable
fun BodyContent(modifier: Modifier = Modifier) {
    Row(
        modifier = modifier
            .background(color = Color.LightGray)
            .size(200.dp)
            .padding(16.dp)
            .horizontalScroll(rememberScrollState())
    ) {
        StaggeredGrid {
            for (topic in topics) {
                Chip(modifier = Modifier.padding(8.dp), text = topic)
            }
        }
    }
}

このように適用された修飾子のプレビューは次のようになります。

cb209bb5edf634d6.png

まず、修飾子が UI に与える影響を確認するために背景を変更し、次に widthheight200.dp になるようにサイズを制約して、最後にテキストと周囲の間が空くようにパディングを適用します。

制約は左から右への連鎖を通じて反映されるため、測定される Row のコンテンツの制約は、最小と最大の widthheight について、(200-16-16)=168 dp です。つまり StaggeredGrid のサイズはちょうど 168x168 dp になります。そのため、modifySize の連鎖が右から左に行われた後で、スクロール可能な Row の最終的なサイズは 200x200 dp になります。

修飾子の順序を変更し、最初にパディングを適用してからサイズを適用した場合は、異なる UI が得られます。

@Composable
fun BodyContent(modifier: Modifier = Modifier) {
    Row(
        modifier = modifier
            .background(color = Color.LightGray, shape = RectangleShape)
            .padding(16.dp)
            .size(200.dp)
            .horizontalScroll(rememberScrollState())
    ) {
        StaggeredGrid {
            for (topic in topics) {
                Chip(modifier = Modifier.padding(8.dp), text = topic)
            }
        }
    }
}

プレビュー:

17da5805d6d8fc91.png

この場合、スクロール可能な Rowpadding の元の制約が size 制約に強制されて子が測定されます。そのため StaggeredGrid は、最小と最大の widthheight について、200 dp に制約されます。StaggeredGrid サイズは 200x200 dp です。サイズが右から左に変更されると、padding 修飾子がサイズを (200+16+16)x(200+16+16)=232x232 に増加させ、これが Row の最終的なサイズにもなります。

レイアウト方向

LayoutDirection アンビエントを使用してコンポーザブルのレイアウト方向を変更できます。

コンポーザブルを手動で画面に配置する場合、layoutDirection は、layout 修飾子または Layout コンポーザブルの LayoutScope の一部になります。layoutDirection を使用するときは、placeRelative メソッドとは異なり、右から左へのコンテキストで位置を自動的にミラーリングしないため、place を使用してコンポーザブルを配置してください。

このセクションのコードの全文

import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Card
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Scaffold
import androidx.compose.material.Text
import androidx.compose.material.TopAppBar
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.codelab.layouts.ui.LayoutsCodelabTheme
import kotlin.math.max

val topics = listOf(
    "Arts & Crafts", "Beauty", "Books", "Business", "Comics", "Culinary",
    "Design", "Fashion", "Film", "History", "Maths", "Music", "People", "Philosophy",
    "Religion", "Social sciences", "Technology", "TV", "Writing"
)

@Composable
fun LayoutsCodelab() {
    Scaffold(
        topBar = {
            TopAppBar(
                title = {
                    Text(text = "LayoutsCodelab")
                },
                actions = {
                    IconButton(onClick = { /* doSomething() */ }) {
                        Icon(Icons.Filled.Favorite, contentDescription = null)
                    }
                }
            )
        }
    ) { innerPadding ->
        BodyContent(Modifier.padding(innerPadding))
    }
}

@Composable
fun BodyContent(modifier: Modifier = Modifier) {
    Row(modifier = modifier
        .background(color = Color.LightGray)
        .padding(16.dp)
        .size(200.dp)
        .horizontalScroll(rememberScrollState()),
        content = {
            StaggeredGrid {
                for (topic in topics) {
                    Chip(modifier = Modifier.padding(8.dp), text = topic)
                }
            }
        })
}

@Composable
fun StaggeredGrid(
    modifier: Modifier = Modifier,
    rows: Int = 3,
    content: @Composable () -> Unit
) {
    Layout(
        modifier = modifier,
        content = content
    ) { measurables, constraints ->

        // Keep track of the width of each row
        val rowWidths = IntArray(rows) { 0 }

        // Keep track of the max height of each row
        val rowHeights = IntArray(rows) { 0 }

        // Don't constrain child views further, measure them with given constraints
        // List of measured children
        val placeables = measurables.mapIndexed { index, measurable ->
            // Measure each child
            val placeable = measurable.measure(constraints)

            // Track the width and max height of each row
            val row = index % rows
            rowWidths[row] += placeable.width
            rowHeights[row] = Math.max(rowHeights[row], placeable.height)

            placeable
        }

        // Grid's width is the widest row
        val width = rowWidths.maxOrNull()
            ?.coerceIn(constraints.minWidth.rangeTo(constraints.maxWidth)) ?: constraints.minWidth

        // Grid's height is the sum of the tallest element of each row
        // coerced to the height constraints
        val height = rowHeights.sumOf { it }
            .coerceIn(constraints.minHeight.rangeTo(constraints.maxHeight))

        // Y of each row, based on the height accumulation of previous rows
        val rowY = IntArray(rows) { 0 }
        for (i in 1 until rows) {
            rowY[i] = rowY[i - 1] + rowHeights[i - 1]
        }

        // Set the size of the parent layout
        layout(width, height) {
            // x co-ord we have placed up to, per row
            val rowX = IntArray(rows) { 0 }

            placeables.forEachIndexed { index, placeable ->
                val row = index % rows
                placeable.placeRelative(
                    x = rowX[row],
                    y = rowY[row]
                )
                rowX[row] += placeable.width
            }
        }
    }
}

@Composable
fun Chip(modifier: Modifier = Modifier, text: String) {
    Card(
        modifier = modifier,
        border = BorderStroke(color = Color.Black, width = Dp.Hairline),
        shape = RoundedCornerShape(8.dp)
    ) {
        Row(
            modifier = Modifier.padding(start = 8.dp, top = 4.dp, end = 8.dp, bottom = 4.dp),
            verticalAlignment = Alignment.CenterVertically
        ) {
            Box(
                modifier = Modifier
                    .size(16.dp, 16.dp)
                    .background(color = MaterialTheme.colors.secondary)
            )
            Spacer(Modifier.width(4.dp))
            Text(text = text)
        }
    }
}

@Preview
@Composable
fun ChipPreview() {
    LayoutsCodelabTheme {
        Chip(text = "Hi there")
    }
}

@Preview
@Composable
fun LayoutsCodelabPreview() {
    LayoutsCodelabTheme {
        LayoutsCodelab()
    }
}

10. 制約レイアウト

ConstraintLayout を使用すると、コンポーザブルを画面上の他の要素と相対的に配置できます。RowColumnBox を複数使用することに代わる手段です。ConstraintLayout は、配置要件がより複雑な、大規模なレイアウトを実装する場合に便利です。

Compose の制約レイアウトの依存関係は、プロジェクトの build.gradle ファイルに記載されています。

// build.gradle
implementation "androidx.constraintlayout:constraintlayout-compose:1.0.0-rc01"

Compose の ConstraintLayoutDSL で機能します。

  • 参照は createRefs() または createRef() を使用して作成されます。ConstraintLayout 内の各コンポーザブルは、関連付けられた参照を持つ必要があります。
  • 制約は、参照をパラメータとして受け取る constrainAs 修飾子を使用して指定します。この修飾子により、本文のラムダで制約を指定できます。
  • 制約は、linkTo またはその他の便利なメソッドを使用して指定します。
  • parent は、ConstraintLayout コンポーザブル自体に対する制約を指定するために使用できる、既存の参照です。

簡単な例を見てみましょう。

@Composable
fun ConstraintLayoutContent() {
    ConstraintLayout {

        // Create references for the composables to constrain
        val (button, text) = createRefs()

        Button(
            onClick = { /* Do something */ },
            // Assign reference "button" to the Button composable
            // and constrain it to the top of the ConstraintLayout
            modifier = Modifier.constrainAs(button) {
                top.linkTo(parent.top, margin = 16.dp)
            }
        ) {
            Text("Button")
        }

        // Assign reference "text" to the Text composable
        // and constrain it to the bottom of the Button composable
        Text("Text", Modifier.constrainAs(text) {
            top.linkTo(button.bottom, margin = 16.dp)
        })
    }
}

@Preview
@Composable
fun ConstraintLayoutContentPreview() {
    LayoutsCodelabTheme {
        ConstraintLayoutContent()
    }
}

このコードは、Button の頂部を親に対しマージン 16.dp に制約し、TextButton の底部に対しマージン 16.dp に制約します。

72fcb81ab2c0483c.png

テキストを横方向に中央揃えする場合は、centerHorizontallyTo 関数を使用して、Textstartendparent の両端に設定します。

@Composable
fun ConstraintLayoutContent() {
    ConstraintLayout {
        ... // Same as before

        Text("Text", Modifier.constrainAs(text) {
            top.linkTo(button.bottom, margin = 16.dp)
            // Centers Text horizontally in the ConstraintLayout
            centerHorizontallyTo(parent)
        })
    }
}

プレビュー:

729a1b4c03f1f187.png

ConstraintLayout のサイズは、そのコンテンツをラップできる範囲で可能な限り小さくなります。そのため、Text が親ではなく Button を中心にしているように見えます。他のサイズ設定動作が必要な場合は、Compose の他のレイアウトと同様に、サイズ設定修飾子(fillMaxSizesize など)を ConstraintLayout コンポーザブルに適用する必要があります。

ヘルパー

DSL は、ガイドライン、バリア、チェーンの作成もサポートしています。次に例を示します。

@Composable
fun ConstraintLayoutContent() {
    ConstraintLayout {
        // Creates references for the three composables
        // in the ConstraintLayout's body
        val (button1, button2, text) = createRefs()

        Button(
            onClick = { /* Do something */ },
            modifier = Modifier.constrainAs(button1) {
                top.linkTo(parent.top, margin = 16.dp)
            }
        ) {
            Text("Button 1")
        }

        Text("Text", Modifier.constrainAs(text) {
            top.linkTo(button1.bottom, margin = 16.dp)
            centerAround(button1.end)
        })

        val barrier = createEndBarrier(button1, text)
        Button(
            onClick = { /* Do something */ },
            modifier = Modifier.constrainAs(button2) {
                top.linkTo(parent.top, margin = 16.dp)
                start.linkTo(barrier)
            }
        ) {
            Text("Button 2")
        }
    }
}

プレビュー:

a4117576ef1768a2.png

以下の点に注意してください。

  • バリア(および他のすべてのヘルパー)は、ConstraintLayout の本文で作成できますが、constrainAs 内では作成できません。
  • linkTo は、レイアウトの端の場合と同様に、ガイドラインとバリアで制約するために使用できます。

サイズのカスタマイズ

デフォルトで、ConstraintLayout の子は、コンテンツをラップするために必要なサイズを選択できます。つまり、たとえばテキストが長すぎる場合、Text は画面の外にはみ出てしまいます。

@Composable
fun LargeConstraintLayout() {
    ConstraintLayout {
        val text = createRef()

        val guideline = createGuidelineFromStart(fraction = 0.5f)
        Text(
            "This is a very very very very very very very long text",
            Modifier.constrainAs(text) {
                linkTo(start = guideline, end = parent.end)
            }
        )
    }
}

@Preview
@Composable
fun LargeConstraintLayoutPreview() {
    LayoutsCodelabTheme {
        LargeConstraintLayout()
    }
}

616c19b971811cfa.png

もちろん、空いているスペースにテキストを改行します。そのためには、テキストの width の動作を変更します。

@Composable
fun LargeConstraintLayout() {
    ConstraintLayout {
        val text = createRef()

        val guideline = createGuidelineFromStart(0.5f)
        Text(
            "This is a very very very very very very very long text",
            Modifier.constrainAs(text) {
                linkTo(guideline, parent.end)
                width = Dimension.preferredWrapContent
            }
        )
    }
}

プレビュー:

fc41cacd547bbea.png

利用可能な Dimension の動作は次のとおりです。

  • preferredWrapContent - レイアウトは、そのサイズでの制約に従ったラップ コンテンツになります。
  • wrapContent - レイアウトは、制約で許可されない場合でもラップ コンテンツになります。
  • fillToConstraints - レイアウトは、そのサイズでの制約で定義されたスペースを埋めるように拡大します。
  • preferredValue - レイアウトは、そのサイズでの制約に従った固定 dp 値になります。
  • value - レイアウトは、そのサイズでの制約に関係なく固定 dp 値になります。

また、特定の Dimension を強制することもできます。

width = Dimension.preferredWrapContent.atLeast(100.dp)

API の分離

ここまでの例では、制約をインラインで指定し、適用対象のコンポーザブルの修飾子を使用していました。しかし、適用対象のレイアウトから制約を分離しておくことが重要な場合があります。よくある例としては、画面構成に基づいて制約を簡単に変更する場合や、2 つの制約セット間でアニメーション化する場合などが挙げられます。

このような場合は、別の方法で ConstraintLayout を使用できます。

  1. ConstraintSet を、ConstraintLayout のパラメータとして渡します。
  2. ConstraintSet で作成した参照を、layoutId 修飾子を使用してコンポーザブルに割り当てます。

この API の形を前述した最初の ConstraintLayout の例に適用し、画面の幅について最適化すると、次のようになります。

@Composable
fun DecoupledConstraintLayout() {
    BoxWithConstraints {
        val constraints = if (maxWidth < maxHeight) {
            decoupledConstraints(margin = 16.dp) // Portrait constraints
        } else {
            decoupledConstraints(margin = 32.dp) // Landscape constraints
        }

        ConstraintLayout(constraints) {
            Button(
                onClick = { /* Do something */ },
                modifier = Modifier.layoutId("button")
            ) {
                Text("Button")
            }

            Text("Text", Modifier.layoutId("text"))
        }
    }
}

private fun decoupledConstraints(margin: Dp): ConstraintSet {
    return ConstraintSet {
        val button = createRefFor("button")
        val text = createRefFor("text")

        constrain(button) {
            top.linkTo(parent.top, margin= margin)
        }
        constrain(text) {
            top.linkTo(button.bottom, margin)
        }
    }
}

11. Intrinsic

Compose には、子を 1 回しか測定できないというルールがあります。子を 2 回測定した場合、ランタイム例外がスローされます。しかし、測定する前に子の情報が必要になる場合もあります。

Intrinsic を使用すると、実際に測定する前に子をクエリできます。

コンポーザブルに対して、次のように intrinsicWidth または intrinsicHeight を要求できます。

  • (min|max)IntrinsicWidth: ある高さの場合に、コンテンツを正しく描画できる最小 / 最大の幅。
  • (min|max)IntrinsicHeight: ある幅の場合に、コンテンツを正しく描画できる最小 / 最大の高さ。

たとえば、TextminIntrinsicHeight を無限大の width で要求した場合、テキストが 1 行で描画されているかのように、Textheight が返されます。

Intrinsic の動作

次のように、2 つのテキストを分割線で区切って画面上に表示するコンポーザブルを作成するとします。

835f0b8c9f07cd9.png

これを実現するには、1 つの RowText を 2 つ入れて可能な限り広がるようにし、中央に Divider を置きます。Divider の高さは最も高い Text と同じにして、幅は狭くします(width = 1.dp)。

@Composable
fun TwoTexts(modifier: Modifier = Modifier, text1: String, text2: String) {
    Row(modifier = modifier) {
        Text(
            modifier = Modifier
                .weight(1f)
                .padding(start = 4.dp)
                .wrapContentWidth(Alignment.Start),
            text = text1
        )

        Divider(color = Color.Black, modifier = Modifier.fillMaxHeight().width(1.dp))
        Text(
            modifier = Modifier
                .weight(1f)
                .padding(end = 4.dp)
                .wrapContentWidth(Alignment.End),

            text = text2
        )
    }
}

@Preview
@Composable
fun TwoTextsPreview() {
    LayoutsCodelabTheme {
        Surface {
            TwoTexts(text1 = "Hi", text2 = "there")
        }
    }
}

これをプレビューすると、想定とは異なり、分割線が画面全体に拡大されます。

d61f179394ded825.png

これは、Row がそれぞれの子を個別に測定し、Text の高さを Divider の制約に使用できないためです。Divider が指定の高さで空きスペースを埋めるように設定する必要があります。そのためには、height(IntrinsicSize.Min) 修飾子を使用します。

height(IntrinsicSize.Min) は、子の高さが Intrinsic の最小の高さと同じになるように強制します。この修飾子は再帰的であるため、Row とその子の minIntrinsicHeight をクエリします。

これを次のようにコードに適用すると、想定どおりに動作します。

@Composable
fun TwoTexts(modifier: Modifier = Modifier, text1: String, text2: String) {
    Row(modifier = modifier.height(IntrinsicSize.Min)) {
        Text(
            modifier = Modifier
                .weight(1f)
                .padding(start = 4.dp)
                .wrapContentWidth(Alignment.Start),
            text = text1
        )

        Divider(color = Color.Black, modifier = Modifier.fillMaxHeight().width(1.dp))
        Text(
            modifier = Modifier
                .weight(1f)
                .padding(end = 4.dp)
                .wrapContentWidth(Alignment.End),
            text = text2
        )
    }
}

@Preview
@Composable
fun TwoTextsPreview() {
    LayoutsCodelabTheme {
        Surface {
            TwoTexts(text1 = "Hi", text2 = "there")
        }
    }
}

プレビュー:

835f0b8c9f07cd9.png

Row の minIntrinsicHeight は、その子の最大 minIntrinsicHeight になります。Divider の minIntrinsicHeight は、制約がない場合はスペースを占有しないため 0 になります。Text の minIntrinsicHeight は、特定の width が指定されたテキストの minIntrinsicHeight になります。そのため、Row の height 制約が、Text の最大 minIntrinsicHeight になります。Divider は自身の height を、Row で指定された height 制約まで拡大します。

DIY

カスタム レイアウトを作成するときはいつでも、MeasurePolicy インターフェースの (min|max)Intrinsic(Width|Height) で intrinsic を計算する方法を変更できます。ただしほとんどの場合、デフォルトで十分です。

また、Modifier インターフェースの Density.(min|max)Intrinsic(Width|Height)Of メソッドをオーバーライドする修飾子で intrinsic を変更できます。これもデフォルト値で間に合います。

12. 完了

これで、この Codelab は終了です。

Codelab の解答

この Codelab の解答コードは GitHub から入手できます。

$ git clone https://github.com/googlecodelabs/android-compose-codelabs

または、リポジトリを ZIP ファイルとしてダウンロードすることもできます。

次のステップ

Compose パスウェイに関する他の Codelab をご確認ください。

参考資料

サンプルアプリ

  • カスタム レイアウトを作成する Owl
  • グラフと表を示す Rally
  • カスタム レイアウトの Jetsnack