🗂

Jetpack Compose入門 アプリを作る知識-1(一覧作成~Modifier/Scaffold/Surface/Columnなど)

2022/03/19に公開

概要

前回は、本当の最初の一歩の記事を書きました。
今回はその続きです。
アーキテクチャとか考えずにシンプルなアプリを作る知識を書いていきます。
ひとつづつステップを踏んで作ります。
簡単なアプリですが、ひとつづつ少しだけですが、深堀りしていくのでそれなりの学びがあるかもです。

作成アプリ

サンプルアプリは以下です。単純な一覧表示です。
100件のリストですが、表示されているところだけ作成されるようにはします。
アイテムをタップしたらリップルエフェクトがかかります。

主に以下のコンポーザブル関数とModifierを使います。

  • Column
  • Row
  • Surface
  • Scaffold
  • LazyColumn

Column

まずは文字列の縦積み分です。
この手のものはColumnコンポーザブル関数を使います。

こんな関数です。

@Composable
inline fun Column(
    modifier: Modifier! = Modifier,
    verticalArrangement: Arrangement.Vertical! = Arrangement.Top,
    horizontalAlignment: Alignment.Horizontal! = Alignment.Start,
    content: (@Composable @ExtensionFunctionType ColumnScope.() -> Unit)?
): Unit

引数のcontentに縦に置きたい関数を順番に記述していきます。
contentは関数を指定する最後のパラメータなので Column(){} のように {}の中に記述していきます。
(Kotlinでは、関数の最後のパラメータが関数型である場合、そのパラメータは括弧の外に指定することができるという仕様があります)

@Composable
private fun ListTitle(
  title: String,
  body: String,
) {
  Column(
  ) {
    Text(
      modifier = Modifier.align(Alignment.Start),
      text = title,
    )
    Spacer(modifier = Modifier.height(4.dp))
    Text(        
      text = body,
      style = MaterialTheme.typography.body2,
    )
  }
}
@Preview(
  uiMode = Configuration.UI_MODE_NIGHT_YES,
  showBackground = true
)
@Composable
fun DarkPreview() {
  SampleComposeTheme {
    ListTitle("This is a title", "Detail Content")
  }
}  

こんな結果です。

この結果を見るとデザインが微妙ですね。デザインを調整するのにModifierを使っていきます。

Modifier

日本語にすると修飾子です。コンポーザブルを装飾または拡張できます。次のことができます。

  • コンポーザブルのサイズ、レイアウト、動作、外観を変更できる
  • ユーザー補助ラベルなどの情報を追加できる
  • ユーザー入力を処理できる
  • コンポーザブルの要素にクリック、スクロール、ドラッグ、ズームなどの機能を追加できる

上のコードでいうとSpacer(modifier = Modifier.height(4.dp))Text(modifier = Modifier.align(Alignment.Start), のようにコンポーザブル関数の引数に渡します。ほとんどのコンポーザブル関数では、引数にModifierを指定できます。

たとえば、Columnを横幅一杯に左右に8dp、上下に16dpのパディングを入れると

Column(
      modifier = Modifier
        .fillMaxWidth()
        .padding(horizontal = 8.dp, vertical = 16.dp),

こんなデザインになります。こんな感じで調整してデザインを洗練させていくわけです。

Modifierのスコープ

話が全くずれますが、ここでModifierのスコープについて書いてみます。
Modifierのスコープは、全てのコンポーザブル関数で修飾子を利用する場合に重要な概念になります。

今回の場合のスコープに関連する箇所は Modifier.align() です。

このサンプルでは左寄せなので記述しなくても良いのですが、中央揃えにしたい場合は、Modifier.align(Alignment.CenterHorizontally) を指定できたりします。

これは、Column関数の中でしか利用できない修飾子になります。(とはいえ同じインターフェイスを持ってる関数であれば利用できます)

    Column(...) {
      Text(
        modifier = Modifier.align(Alignment.Start), // コンパイルエラーにならない
        text = title,
      )
    }

Column関数内ではない場合はコンパイルエラーになります。

  Text(
    modifier = Modifier.align(Alignment.Start), // コンパイルエラーになる
    text = title,
  )

なぜ、Column関数内で利用できるのでしょうか。それは、Kotlinの仕様のFunction literals with receiverが関係しています。

Function literals with receiver

これはKotlinの仕様に記述されています。

ちなみにレシーバーとは、インスタンスメソッドを呼び出すときのインスタンスオブジェクトのことです。
以下の場合、Personクラスのgreet()メソッドを呼んでいますが、この時の変数personがレシーバーになります。

val person = Person()
person.greet()

Column関数のcontent引数の型は、content: @Composable ColumnScope.() -> Unitになっています。

この書き方は、どういうことかというと関数オブジェクトを生成する拡張関数を作っていることだと理解するとわかりやすいです。
関数オブジェクトとは、無名関数やラムダ式のような関数型のオブジェクトのことです。拡張関数とは、決まった型に後から関数を追加できるKotlinの仕様です。

ColumnScope に、() -> Unitという関数を後から付けているということです。
() -> Unitの部分はラムダ式で記述できるので、ColumnScopeにラムダ式オブジェクトを生成する関数を追加していて、そのラムダ式内でのthisは、暗黙的にColumnScopeになります。
なぜ、暗黙的にthisになるかというと、拡張関数を作るときとわかります。
fun String.hello() = "$this hello"のようにStringにhello()関数を追加し、呼び出し時に"Please say".hello()とした場合、Please sayが関数内のthisになって、結果、"Please say Hello"と返されます。
これと同じことです。

ちなみにColumnScopeの実装は、ColumnScopeInstanceクラスです。

    Layout(
        content = { ColumnScopeInstance.content() },

このため、Column関数の中では、ColumnScopeInstanceのメソッドのModifier.align()が利用できるわけです。

Row

次は横のレイアウトを組んでいきます。Columnコンポーザブルが縦で、その横バージョンがRowコンポーザブルになります。

こんな関数になります。

@Composable
inline fun Row(
    modifier: Modifier! = Modifier,
    horizontalArrangement: Arrangement.Horizontal! = Arrangement.Start,
    verticalAlignment: Alignment.Vertical! = Alignment.Top,
    content: (@Composable @ExtensionFunctionType RowScope.() -> Unit)?
): Unit

左側に画像で、その右に上記で記述したレイアウトが表示されるようにします。

    Row(
      modifier = Modifier.padding(all = 8.dp),
      verticalAlignment = Alignment.CenterVertically,
    ) {
      Image(
        painter = painterResource(R.drawable.ic_launcher_background),
        contentDescription = "Contact profile picture",
        modifier = Modifier
          .size(40.dp)
          .clip(CircleShape)
          .border(1.5.dp, MaterialTheme.colors.secondary, CircleShape)
      )
       Column() {
           ・・・
       }
    }

ImageコンポーザブルはModifierを使って丸くしたりborderをつけたりサイズを指定したりしています。

今回の画像は、リソースファイルから表示しましたがネットワーク経由で表示する場合は、Glideなどのライブラリを使うと良いでしょう。

その場合は、painter引数にGlideの関数を指定するだけです。

painter = rememberGlidePainter("画像のURL"),

Surface

次に上記で作ったレイアウトの親にSurfaceコンポーザブル関数を指定します。

こんな関数です。

@Composable
@NonRestartableComposable
fun Surface(
    modifier: Modifier! = Modifier,
    shape: Shape! = RectangleShape,
    color: Color! = MaterialTheme.colors.surface,
    contentColor: Color! = contentColorFor(color),
    border: BorderStroke? = null,
    elevation: Dp! = 0.dp,
    content: (@Composable () -> Unit)?
): Unit

Surfaceの説明は前回記述しています。
このサンプルではSurfaceを使うことで以下を実現しています

  • shapeを指定することで、形状を角丸にクリッピングしています
  • elevationによって奥行きを表現しています
  • MaterialThemeのcolorとcontentColorのデフォルトが利用されるようになります
    • contentColorとはたとえば、backgroudColorが背景色であり、その中にある文字などの色のことです
    • これによりダークモード対応がされます

Modifier.clickableを記述すことでリップエフェクトがついた状態でクリック可能になります。

    Surface(
      modifier = Modifier.clickable { onClick() },
      shape = MaterialTheme.shapes.medium, elevation = 1.dp,
    ) {
      Row(
      ・・・

結果は次の画像です。いままでダークモードになってませんでしたが、Surfaceを入れたことで変わりました。
角丸になり、わかりづらいですが奥行きのあるレイアウトになりました。

ちなみにScaffoldコンポーザブルを使うとダークモード対応されます。これは、内部でSurfaceを使っているからです。

LazyColumn

次に上記まで作ったアイテムを複数件表示してスクロールで見れるようにします。いわゆる一覧画面です。
このときのポイントはColumnではなく、LazyColumnコンポーザブルを使うことです。
たとえば、100件のアイテムがあった場合にColumnを使うと表示されない部分も描画処理が走ってしまいパフォーマンスの問題が出てきます。
LazyColumnはその名の通り、表示するまで描画処理が走りません。

こんな関数です。

@Composable
fun LazyColumn(
    modifier: Modifier! = Modifier,
    state: LazyListState! = rememberLazyListState(),
    contentPadding: PaddingValues! = PaddingValues(0.dp),
    reverseLayout: Boolean! = false,
    verticalArrangement: Arrangement.Vertical! = if (!reverseLayout) Arrangement.Top else Arrangement.Bottom,
    horizontalAlignment: Alignment.Horizontal! = Alignment.Start,
    flingBehavior: FlingBehavior! = ScrollableDefaults.flingBehavior(),
    userScrollEnabled: Boolean! = true,
    content: (@ExtensionFunctionType LazyListScope.() -> Unit)?
): Unit

使ってみます。

  @Composable
  private fun ListTitles(contents: List<Pair<String, String>>) {
    LazyColumn {
      items(contents.size) { index ->
        val content = contents[index]
        ListTitle(title = content.first, body = content.second)
        {
        }
      }
    }
  }
  @Preview(
    uiMode = Configuration.UI_MODE_NIGHT_YES,
    showBackground = true
  )
  @Composable
  fun ListTitlesDarkPreview() {
    SampleComposeTheme {
      val mocks = (1..100).map {
        Pair("This is a title$it", "Detail Content$it")
      }
      ListTitles(contents = mocks)
    }
  }  

Scaffold

Scaffoldコンポーザブル関数は、Material Designの基本的なレイアウト構造を提供する関数です。TopAppBar、BottomAppBar、FloatingActionButton、DrawerなどのMaterialコンポーネントを配置する場所を提供します。コンポーザブルを配置する箱を用意して、必要なものをそこに入れていくというもので、Slotと呼ばれています。

インターフェイスは以下です。

@Composable
fun Scaffold(
    modifier: Modifier! = Modifier,
    scaffoldState: ScaffoldState! = rememberScaffoldState(),
    topBar: (@Composable () -> Unit)? = {},
    bottomBar: (@Composable () -> Unit)? = {},
    snackbarHost: (@Composable (SnackbarHostState) -> Unit)? = { SnackbarHost(it) },
    floatingActionButton: (@Composable () -> Unit)? = {},
    floatingActionButtonPosition: FabPosition! = FabPosition.End,
    isFloatingActionButtonDocked: Boolean! = false,
    drawerContent: (@Composable @ExtensionFunctionType ColumnScope.() -> Unit)? = null,
    drawerGesturesEnabled: Boolean! = true,
    drawerShape: Shape! = MaterialTheme.shapes.large,
    drawerElevation: Dp! = DrawerDefaults.Elevation,
    drawerBackgroundColor: Color! = MaterialTheme.colors.surface,
    drawerContentColor: Color! = contentColorFor(drawerBackgroundColor),
    drawerScrimColor: Color! = DrawerDefaults.scrimColor,
    backgroundColor: Color! = MaterialTheme.colors.background,
    contentColor: Color! = contentColorFor(backgroundColor),
    content: (@Composable (PaddingValues) -> Unit)?
): Unit

今回はTopAppBarコンポーザブルを設定しています。

@Composable
private fun ListTitles(contents: List<Pair<String, String>>) {
  Scaffold(
    topBar = {
      TopAppBar(
        title = { Text("My TopAppBar") },
      )
    }
  ) {
    LazyColumn {
      items(contents.size) { index ->
        val content = contents[index]
        ListTitle(title = content.first, body = content.second)
        {
        }
      }
    }
  }
}  

これで完成です。

全体のソース

class MainActivity : ComponentActivity() {
  override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContent {
      SampleComposeTheme {
        Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colors.background) {
          MyContentView()
        }
      }
    }
  }
}

@Composable
fun MyContentView() {
    val mocks = (1..100).map {
      Pair("This is a title$it", "Detail Content$it")
    }
    ListTitles(contents = mocks)
}

@Composable
private fun ListTitles(contents: List<Pair<String, String>>) {
  Scaffold(
    topBar = {
      TopAppBar(
        title = { Text("My TopAppBar") },
      )
    }
  ) {
    LazyColumn {
      items(contents.size) { index ->
        val content = contents[index]
        ListTitle(title = content.first, body = content.second)
        {
        }
      }
    }
  }
}

@Composable
private fun ListTitle(
  title: String,
  body: String,
  onClick: () -> Unit,
) {
  Surface(
    modifier = Modifier.clickable { onClick() },
    shape = MaterialTheme.shapes.medium, elevation = 1.dp,
  ) {
    Row(
      verticalAlignment = Alignment.CenterVertically,
      modifier = Modifier.padding(all = 8.dp)
    ) {
      Image(
        painter = painterResource(R.drawable.ic_launcher_background),
        contentDescription = "Contact profile picture",
        modifier = Modifier
          .size(40.dp)
          .clip(CircleShape)
          .border(1.5.dp, MaterialTheme.colors.secondary, CircleShape)
      )

      Column(
        modifier = Modifier
          .fillMaxWidth()
          .padding(horizontal = 8.dp, vertical = 16.dp),
      ) {
        Text(
          text = title,
          style = MaterialTheme.typography.subtitle2,
        )
        Spacer(modifier = Modifier.height(4.dp))
        Text(
          text = body,
          style = MaterialTheme.typography.body2,
        )
      }
    }
  }
}

@Preview(
  uiMode = Configuration.UI_MODE_NIGHT_NO,
  showBackground = true
)
@Composable
fun DefaultPreview() {
  SampleComposeTheme {
    MyContentView()
  }
}

@Preview(
  uiMode = Configuration.UI_MODE_NIGHT_YES,
  showBackground = true
)
@Composable
fun MyContentViewDarkPreview() {
  SampleComposeTheme {
    MyContentView()
  }
}

@Preview(
  uiMode = Configuration.UI_MODE_NIGHT_YES,
  showBackground = true
)
@Composable
fun ListTitlesDarkPreview() {
  SampleComposeTheme {
    val mocks = (1..100).map {
      Pair("This is a title$it", "Detail Content$it")
    }
    ListTitles(contents = mocks)
  }
}

@Preview(
  uiMode = Configuration.UI_MODE_NIGHT_YES,
  showBackground = true
)
@Composable
fun ListTitleDarkPreview() {
  SampleComposeTheme {
    ListTitle("This is a title", "Detail Content", {})
  }
}
NewsPicks の Zenn

Discussion