Help us understand the problem. What is going on with this article?

Jetpack Compose (アルファ版) ハンズオン

Jetpack Compose がアルファリリースされたので遅まきながら触ってみました。

色々と新しいワードやコーディングばかりで学習コストがかかりそうかも...?
でも Kotlin で書けるのは嬉しかったりします👏

ひとまず簡単に ViewModel なんかを使いながら、リストやツールバーの表示を実装してみます。

Environment

今回使用した環境は以下になります。

Tools version
Android Studio 4.2 Canary 9
Kotlin 1.4.0
Jetpack Compose 1.0.0-alpha02

Get Started

まずはプロジェクトを作成します。
Canary 版には Empty Compose Activity のテンプレートがあるので開いてみましょう。

empty_compose_activity.png

作成すると自動で以下の様な Activity が出来上がります。
これだけでも結構色々出てきて困惑しますね。

MainActivity.kt
class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            JetpackComposeCodelabTheme {
                // A surface container using the 'background' color from the theme
                Surface(color = MaterialTheme.colors.background) {
                    Greeting("Android")
                }
            }
        }
    }
}

@Composable
fun Greeting(name: String) {
    Text(text = "Hello $name!")
}

@Preview(showBackground = true)
@Composable
fun DefaultPreview() {
    JetpackComposeCodelabTheme {
        Greeting("Android")
    }
}

setContent

Activity と ViewGroup の拡張関数として定義されている、レイアウトを定義していくスコープ

Wrapper.kt
fun ComponentActivity.setContent(
    // Note: Recomposer.current() is the default here since all Activity view trees are hosted
    // on the main thread.
    recomposer: Recomposer = Recomposer.current(),
    content: @Composable () -> Unit
): Composition {
    FrameManager.ensureStarted()
    val composeView: AndroidOwner = window.decorView
        .findViewById<ViewGroup>(android.R.id.content)
        .getChildAt(0) as? AndroidOwner
        ?: AndroidOwner(this, this, this).also {
            setContentView(it.view, DefaultLayoutParams)
        }
    return doSetContent(composeView, recomposer, null, content)
}

content ブロック内に UI を記述する事で、ルートの View (AndroidOwner) にレイアウトしていきます。
@Composable で定義された関数を呼び出す事で、レイアウトの定義を関数に切り出す事も可能です。

CompositionsetContentdispose を持つインタフェースで、setContent 内で宣言した UI をレイアウトする役割があります。
内部の処理は CompositionImpl がやってくれるので戻り値の Composition に対して何かする必要はありません。
dispose で空のレイアウトを上書きできる様ですが使いみちが思いつきません🤔

Theme

JetpackComposeCodelabTheme は端末のライトモード/ダークモードの設定を元に MaterialTheme のスタイルをセットアップしてくれます。
他にフォントやカラーの定義もしてくれています。

こういったテーマを定義しておくと View のスタイルに適応できる様です。
MaterialTheme を指定しておくと Text 等でマテリアルデザインのスタイルを指定できる様になります。

Theme.kt
@Composable
fun JetpackComposeCodelabTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable() () -> Unit) {
    val colors = if (darkTheme) {
        DarkColorPalette
    } else {
        LightColorPalette
    }

    MaterialTheme(
        colors = colors,
        typography = typography,
        shapes = shapes,
        content = content
    )
}
Type.kt
val typography = Typography(
    body1 = TextStyle(
        fontFamily = FontFamily.Default,
        fontWeight = FontWeight.Normal,
        fontSize = 16.sp
    )
    /* Other default text styles to override
    button = TextStyle(
        fontFamily = FontFamily.Default,
        fontWeight = FontWeight.W500,
        fontSize = 14.sp
    ),
    caption = TextStyle(
        fontFamily = FontFamily.Default,
        fontWeight = FontWeight.Normal,
        fontSize = 12.sp
    )
    */
)
Shape.kt
val shapes = Shapes(
    small = RoundedCornerShape(4.dp),
    medium = RoundedCornerShape(4.dp),
    large = RoundedCornerShape(0.dp)
)

これからは colors や dimens なんかのリソースに定義していたものもコード管理していく事になるんでしょうか?🤔

Surface

Jetpack Compose Surface でググると Microsoft の Surface Duo が出てきます。
(ググラビリティ。。。)

@Composable
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
) {
    val elevationPx = with(DensityAmbient.current) { elevation.toPx() }
    SurfaceLayout(
        modifier.drawLayer(shadowElevation = elevationPx, shape = shape)
            .zIndex(elevation.value)
            .then(if (border != null) Modifier.border(border, shape) else Modifier)
            .background(
                color = getBackgroundColorForElevation(color, elevation),
                shape = shape
            )
            .clip(shape)
    ) {
        Providers(ContentColorAmbient provides contentColor, children = content)
    }
}

マテリアルデザインなレイアウトを作るための Layout コンポーネントです。
ボーダーやエレベーションを指定できますね。

SurfaceLayout は左上にコンポーネントを配置する Layout の様です。
(FrameLayout かな?🤔)

Text

Text を表示する UI コンポーネントです。 TextView ですね。
引数に color や style を指定する事で今までの xml 上の textColor 等が設定できます。

Modifier

サンプルには出て来ていませんでしたが、Modifier について触れておきます。

Modifier は UI コンポーネントに対して Padding を設定したり、ドロー系の色々ができたり、クリックのイベント処理を指定したりできるインタフェースです。
今まで xml で指定していたレイアウト系の設定 (width, gravity, ... ) は Modifier で指定する事になりそうですね。

MainActivity.kt
Text(
    text = "Hello World!",
    modifier = Modifier
          .fillMaxWidth()
          .fillMaxHeight()
          .padding(10.dp)
          .clickable { }
)

少し話がずれますが、すごいと思ったのが以下の記事の内容です。

Jetpack ComposeのConstraintLayoutなどがどのようにModifierクラスだけで様々なレイアウトを処理するか - Qiita

例えば ConstraintLayout での constraint も Modifier で指定しますが、普通の Layout スコープでは見えません。(使えたら困りますもんね。。。)

ちゃんと配慮されていてかっこいいなと思いました😄

@ Preview

Android Studio で宣言した UI をプレビューするための関数を定義できる。
@Composable で定義した物とは別にプレビュー用の関数を作成する必要があります。

List 表示と State による画面更新

ここから、リスト表示とアイテムがクリックされた時に中のテキストを変更するまでを実装していきます。
まずはリストの表示から。

LazyFor

Jetpack Compose でリスト表示する時は LazyFor 関数を使います。

関数 向き
LazyColumnFor
LazyRowFor

text_list.png

このような縦方向のリストを表示する場合は

MainActivity.kt
LazyColumnFor(items = (0 until 100).map { it.toString() },
              contentPadding = InnerPadding(8.dp)) {
    Text(text = it)
}

の様に記述できます。

↓の様にもできますが、LazyFor を使った方が
ネストが少なかったり、コンテンツ同士の Padding が指定できたりしていいですね。

Column は UI コンポーネントを上から順に縦に並べていきます。
(LinearLayout: orientation = "vertical" みたいな感じでしょうか。)

MainActivity.kt
Column {
    (0 until 100).forEach {
        Text(it.toString())
    }
}

アイテム間のスペースを空ける場合は、Text に Modifier を設定しましょう。

State で画面更新

表示されたリストのアイテムがタップされた時に中のテキストを変更してみます。
視覚的に分かりやすい様に少しレイアウトを変更します。

MainActivity.kt
val items = (0 until 100).map { it.toString }.toMutableList()
LazyColumnForIndexed(items = items) { index, item ->
    Card(modifier = Modifier.fillParentMaxWidth().padding(6.dp).clickable {
        items[index] = "Clicked!"
    }) {
        Text(text = item,
             textAlign = TextAlign.Start,
             modifier = Modifier.fillMaxWidth().padding(30.dp, 16.dp, 16.dp, 16.dp))
    }
}

Modifier.clickable がクリックイベントの処理になります。
ただし、このままでは思った様に画面が更新されません。

list_mistake.gif

A stateless composable is a composable that cannot directly change any state.

Jetpack Compose での UI は基本的にはステートレスです。
そのためプロパティが変更されても再描画されず、スクロールして描画され直すまで更新されません。

クリックのタイミングで更新するにはプロパティがステートを持つ必要があります。
ステートを持たせるには、今回はリストなので toMutableStateList() が使えます。

MainActivity.kt
val items = (0 until 100).map { it.toString }.toMutableStateList()  // <-
LazyColumnForIndexed(items = items) { index, item ->
    Card(modifier = Modifier.fillParentMaxWidth().padding(6.dp).clickable {
        items[index] = "Clicked!"
    }) {
        Text(text = item,
             textAlign = TextAlign.Start,
             modifier = Modifier.fillMaxWidth().padding(30.dp, 16.dp, 16.dp, 16.dp))
    }
}

こうすることでステートを持ったリストができるので

list_updated.gif

と、クリックと同時に更新ができました。

カウントアップのサンプル等で見かけた state {} は alpha 版では deprecated なので remember { mutableStateOf() } を使いましょうとの事です。

ViewModel + LiveData で画面更新

今までの様に ViewModel + LiveData でデータソースの管理と通知による画面更新をしていきます。

LiveData を使う場合には拡張関数の observeAsState() が value にステートを持たせてくれるので、
使うために livedata 用のサブライブラリをインポートします。

build.gradle
implementation "androidx.compose.runtime:runtime-livedata:1.0.0-alpha02"

後はいつもの様に ViewModel を作り LiveData を持たせます。

MainViewModel.kt
class MainViewModel: ViewModel() {
    private val _items: MutableLiveData<MutableList<String>> = MutableLiveData()
    val items: LiveData<List<String>> = _items.map { it.toList() }

    init {
        _items.postValue((0 until 100).map { it.toString() }.toMutableList())
    }

    fun click(index: Int) {
        _items.value?.let {
            it[index] = "Clicked!"
            _items.postValue(it)
        }
    }
}

いつもならこの items を observe して流れてきたデータに対して色々していましたが、
Composable 関数で使う時は observeAsState でステートを持った value を扱います。

MainActivity.kt
class MainActivity : AppCompatActivity() {

    private val viewModel: MainViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            JetpackComposeCodelabTheme {
                Surface(color = MaterialTheme.colors.background) {
                    // observeAsState でステートを持たせた value を渡す
                    Greeting(viewModel.items.observeAsState(listOf()).value)
                }
            }
        }
    }

    @Composable
    private fun Greeting(items: List<String>) {
        LazyColumnForIndexed(items, contentPadding = InnerPadding(8.dp)) { index, item ->
            val cardModifier = Modifier.fillParentMaxWidth().padding(6.dp).clickable {
                viewModel.click(index)
            }
            Card(modifier = cardModifier) {
                Text(text = item,
                     textAlign = TextAlign.Start,
                     modifier = Modifier.fillMaxWidth().padding(30.dp, 16.dp, 16.dp, 16.dp))
            }
        }
    }
}

こうする事で今までの ViewModel から LiveData のデータの流れが出来ました。

ちょっと疑問なんですけど、Composable 関数はトップレベルに書くのがベターなんでしょうか?
Screen 用のファイルを作成してそのトップレベルに書いてあるサンプルが多いので... (JetNews とか)

そうすると View -> ViewModel にイベントを上げる時に、どうするのがいいんでしょうね🤔

  • Composable 関数に ViewModel を渡す?
  • onClick みたいな関数リテラルを渡す?

今回は Activity 内に Composable 関数を書いてみましたが再利用性がないなぁ、と。
これはこれで一個の画面構成としては正しい気も。
Codelab では ViewModel を渡してたな...

色々試してみて最適解を見つけていきたいですね。
こうやってみましたみたいな意見があれば教えていただけると嬉しいです。

ツールバー (AppBar)

画面トップのツールバーを表示してメニューボタンを実装してみます。

list_toolbar.gif

ツールバーを表示する場合は TopAppBar を使います。
メニューボタンには Android Studio から VectorImage を drawable に吐き出してアイコンにします。

MainActivity.kt
class MainActivity : AppCompatActivity() {

    private val viewModel: MainViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            JetpackComposeCodelabTheme {
                Scaffold(topBar = { AppBar(title = "Codelab") }) {
                    Greeting(viewModel.items.observeAsState(listOf()).value)
                }
            }
        }
    }

    @Composable
    private fun AppBar(title: String) {
        TopAppBar(
            title = { Text(text = title) },
            actions = {
                IconButton(onClick = { viewMode.refresh() }) {
                    Image(vectorResource(id = R.drawable.ic_refresh))
                }
            }
        )
    }
}

TopAppBar

ツールバーを表示するのに使うコンポーネントです。

AppBar.kt
@Composable
fun TopAppBar(
    title: @Composable () -> Unit,
    modifier: Modifier = Modifier,
    navigationIcon: @Composable (() -> Unit)? = null,
    actions: @Composable RowScope.() -> Unit = {},
    backgroundColor: Color = MaterialTheme.colors.primarySurface,
    contentColor: Color = contentColorFor(backgroundColor),
    elevation: Dp = TopAppBarElevation
) {
    AppBar(backgroundColor, contentColor, elevation, RectangleShape, modifier) {
        val emphasisLevels = EmphasisAmbient.current
        if (navigationIcon == null) {
            Spacer(TitleInsetWithoutIcon)
        } else {
            Row(TitleIconModifier, verticalGravity = ContentGravity.CenterVertically) {
                ProvideEmphasis(emphasisLevels.high, navigationIcon)
            }
        }

        Row(
            Modifier.fillMaxHeight().weight(1f),
            verticalGravity = ContentGravity.CenterVertically
        ) {
            ProvideTextStyle(value = MaterialTheme.typography.h6) {
                ProvideEmphasis(emphasisLevels.high, title)
            }
        }

        ProvideEmphasis(emphasisLevels.medium) {
            Row(
                Modifier.fillMaxHeight(),
                horizontalArrangement = Arrangement.End,
                verticalGravity = ContentGravity.CenterVertically,
                children = actions
            )
        }
    }
}

title にも Composable を渡すので柔軟にレイアウトを作れそうです。

actions に渡した Composable が右側のメニューのレイアウトになります。
今回はアイコンを表示するので IconButton を使っています。

プレビュー版から AppBarIcon は削除され IconButton を使うように変わっています。

JetNews では navigationIcon に Drawer を実装していました。

TopAppBar の他に BottomAppBar もあり、BottomNavigation みたいな事もできそうです👏

Image

Image == ImageView ですね。

TopAppBar の actions には Composable を渡す事になるので、アイコンを表示するだけなら Image を渡せば問題ないです。

ただし drawable から読み込む際は image か vector かを意識してロードしないと、間違えると NPE になります😂

Scaffold

  • scaffold == 足場

AppBar を使う場合はそのまま TopAppBar を追加すると、バーの下にコンテンツが潜り込んでしまいます。
Scaffold で UI を定義しておくと引数に AppBar を渡せて、マテリアルデザインないい感じのレイアウトにしてくれます。

Scaffold のトップのレイアウトは Surface でレイアウトされていました。

Conclusion

以上、今回試してみたハンズオンのリポジトリはこちらになります🙋

ダイアログ表示したりとかもしてみました。
個人的にはダイアログ表示のハンドリングの仕方がもう少しどうにかならないか悩んでます🤔

GitHub - tick-taku/Jetpack_Compose_Codelab: Sample for Jetpack Compose.

間違いやご指摘等ありましたらコメントいただけると幸いです。

alpha 版なのでばんばん変更されるかもしれませんし、まだ参考が少ないですね。😂
でも xml と行き来したりがなくなって管理しやすかったり、新しい書き方ができて楽しいです👏

関数リテラルをぽんぽん渡す事になるので、どうしたら可読性が上がるか考えないと。。。

一番の問題はまだ Navigation Component に対応してないみたいなので、Fragment をどう実装するかですね。

今のところ xml にも良さがあって勝手も分かっているので比較はあまり出来ませんでしたが、今後たくさん触って慣れていきたいと思います。
Gradle も含めほぼ全て Kotlin で Android アプリが書けるのはとても嬉しいです!

参考

Layouts in Jetpack Compose  |  Android デベロッパー  |  Android Developers

[Published] Jetpack Compose basics

Using State in Jetpack Compose  |  Android デベロッパー  |  Android Developers

compose-samples/JetNews at master · android/compose-samples · GitHub

android - How to create recycler view in Compose Jetpack? - Stack Overflow

tick-taku
Android えんじにゃー。猫とロードバイクが好きです。 気になったことや知ったことを吸収するために、少しずつ書いていこうと思います。 #Android #Kotlin #AWS #Python
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away