Jetpack Compose v1.3.0に気になったAPIのまとめ

こんにちは!Jetpack Compose と KMM が好きなエンジニアのスージです。

今年開催された Android Developers Summit では素晴らしいセッションが盛り沢山でしたね。

developer.android.com

同じタイミングでJetpack Compose v1.3.0も stable になりました。今回もたくさんのAPIの追加や更新がありますし、その中で個人的に気になった API を紹介しようと思います。

Compose BoM

1.3.0 から新しい Compose BoM(Bill of Materials)パッケージが追加されました。BoMは内部で全てのComposeライブラリのバージョン管理し、各Compose ライブラリのstableバージョンへのリンクを指定します。 build.gradle で BoM だけのバージョンを指定し、追加したい Composeライブラリのバージョンを指定せず使えます。

https://developer.android.com/jetpack/compose/setup#using-the-bom

//build.gradke.kts
dependencies {
    implementation(platform("androidx.compose:compose-bom:2022.10.00"))
    implementation("androidx.compose.foundation:foundation")
    implementation("androidx.compose.animation:animation")
    ..
}

LookaheadLayout

まだv1.2.0が rc バージョンだった時@doris4lt氏の shared elements transition を実現できそうな LookaheadLayout API が開発中だという呟きがありましたね。

これはやっと v1.3.0で追加され、個人的に今回の一番気に入ったAPIです!

LookaheadLayoutは子供たちが変更された時に、start から finishフレームの間の intermediate フレームで子供たちの Measure と Placementの計算をフレームのレンダリングの前にできてます。

つまり、アニメーションの途中のフレームの Measure と Placement を取得可能になってます。 この途中のフレームの計算を Lookahead step と呼ばれています。

Intermediate フレームとは
Intermediate フレームとは

Lookahead step で計算した Placement の情報を Modifier.onPlaced()で取得でき、そのフレームで子供たちの Placement を調整することができます。

/**
 * [onPlaced] gets invoked after the parent [LayoutModifier] has been placed
 * and before child [LayoutModifier] is placed. This allows child [LayoutModifier] to adjust
 * its own placement based on its parent.
 * ..
 */
fun Modifier.onPlaced(
    onPlaced: (
        lookaheadScopeCoordinates: LookaheadLayoutCoordinates,
        layoutCoordinates: LookaheadLayoutCoordinates
    ) -> Unit
): Modifier

Modifier.intermediateLayout()では Lookahead step で子供たちの Measure を取得でき、onPlacedで調整した Placement を使って intermediate フレームの Layout を作ります。

/**
 * Creates an intermediate layout based on target size of the child layout calculated
 * in the lookahead. This allows the intermediate layout to morph the child layout
 * after lookahead through [measure], in which the size of the child layout calculated from the
 * lookahead is provided. 
 * ..
 */
fun Modifier.intermediateLayout(
    measure: MeasureScope.(
        measurable: Measurable,
        constraints: Constraints,
        lookaheadSize: IntSize
    ) -> MeasureResult,
): Modifier

このまま次の Lookahead step の計算された Measure と Placement 使って子供たちのサイズを変更したりポジションを変えたりします。これを繰り返すと時間の経過で徐々に変化していきアニメーションに見えますね。

v1.2.0で発表した movableContentOfを使って、ある @Composable lambda を Compositionツリーの中にRecomposition せず移動できますね。 goodpatch-tech.hatenablog.com

LookaheadLayoutで作れるアニメーションの間・後にmovableContentOfを使用して @Composable UI の状態を失わず、再利用できるようにしたらShare Element Transitionを実現できますね。既にこれを活用して@github_skydoves氏は Orbitalという Jetpack Compose の Shared Element Transition ライブラリを作ってくれました! github.com

もっと理解を深めるため Jetpack Compose Internals を書いてくれた@JorgeCastilloPr氏の2つの記事もぜひ読んでみてください。 effectiveandroid.substack.com effectiveandroid.substack.com

Variable フォントサポート

Compose ではじめて Variable フォントのサポートが追加されました!

Variable fonts
Variable fonts

val font = remember(weight, spacing) {
    Font(
        resId = ..,
        variationSettings = FontVariation.Settings(
            FontVariation.weight(weight),
            FontVariation.width(spacing)
        )
    ).toFontFamily()
}

Text(
    text = "Variable fonts are here!",
    fontFamily = font,
    ..
)

Variable フォントサポートしてるフォント一覧と Variable Fonts について詳しく知りたいならこの記事を読んでみてください。

fonts.google.com

Staggered Grid サポート

LazyVerticalStaggeredGridLazyHorizontalStaggeredGridを追加され、やっとStaggered Grid サポートが追加されました。

var (value, onValueChange) = remember { mutableStateOf(0f) }

Column {
    Slider(value = value, onValueChange = onValueChange, steps = 10)
    LazyVerticalStaggeredGrid(
        columns = StaggeredGridCells.Adaptive(((value * 50) + 10).dp)
    ) {
        items(50) {
            Box(
                modifier = Modifier
                    ..
                    .fillMaxWidth()
                    .height((50 until 200).random().dp)
            )
        }
    }
}

StaggeredGridCells.Adaptive
StaggeredGridCells.Adaptive

LazyVerticalStaggeredGridcolumnsパラメータで列の数(LazyHorizontalStaggeredGridの場合は行の数)を定義します。columnsに渡すStaggeredGridCellsの2つプリセットも用意されていて、どっちでも 全ての列のwidth が同じになります。

  • StaggeredGridCells.Adaptive:列の数が決まってなくて、画面の width によって列の数をダイナミックにしたい場合これを使います。列のミニマム width を定義すると、それでできるだけ多くの列に収まるように調整します。 例えば、ミニマム width を 30.dp に定義し画面の width は 124.dpだったら 31.dpwidthの4つの列になります。
  columns = StaggeredGridCells.Adaptive(minWidth = 30.dp)
  • StaggeredGridCells.Fixed:列の数が決まっていたら、これを使います。例えば、3つの列にしたい場合
  columns = StaggeredGridCells.Fixed(count = 3)

Canvas.drawText()

テキストを Canvas でレンダリングできる drawText() が追加されました。drawTextTextMeasurer またはTextMeasurer.measure(..)が返す TextLayoutResultをパラメータで渡します。

TextMeasurer.measure()でテキスト間に空白を作るような placholdersパラメータがあります。返す TextLayoutResultplaceholderRectsを使って非テキスト(例えば、inline 画像、絵文字など)を配置できます。

drawText
drawText

val textMeasurer = rememberTextMeasurer()
var textLayoutResult by remember { mutableStateOf<TextLayoutResult?>(null) }

Canvas(
    modifier = Modifier
        .fillMaxSize()
        .layout { measurable, constraints ->
            val placeable = measurable.measure(constraints)

            textLayoutResult = textMeasurer.measure(
                text = AnnotatedString(text),
                constraints = constraints,
                placeholders = listOf(
                    AnnotatedString.Range(
                        item = Placeholder(
                              width = /* 40.dp */, 
                              height = /* 40.dp */, 
                              placeholderVerticalAlign = Center
                         ),
                        // range of indices where placeholder will be placed
                        start = ..
                        end = ..
                    ),
                    ..
                )
            )
            layout(placeable.width, placeable.height) {..}
        }
) {
    textLayoutResult?.let {
        drawText(textLayoutResult = it, color = Color.Black)
        
        it.placeholderRects.forEach { rect ->
            drawRect(
                topLeft = rect.topLeft,
                size = Size(rect.width, rect.height),
                ..
            )
        }
    }
}

Hyphens API

ハイフンで言葉を改行できるHyphensパラメータが TextStyleで追加されました。

発表された時早速試してみようと思ったら反映せず、バグだと思い Kotlinlang Slack で相談してみました。Compose Text を担当してるエンジニア相談に乗ってくれて、端末の言語が「日本語」だったので反映しないということ教えてくれました。

つまり、このAPIを使う時文字が英語だけではなくLocaleは英語でない場合も反映されないということが注意点だとわかりました。

ハイフンで改行させる
ハイフンで改行させる

val text = "Hyphenation \u00AD is here!"
Column {
    val (width, setWidth) = remember { mutableStateOf(300f) }
    Slider(
        value = width,
        onValueChange = setWidth,
        valueRange = 300f..1000f
    )

    val widthInDp = with(LocalDensity.current) { width.toDp() }

    Text(
        modifier = Modifier.width(width = widthInDp),
        text = text,
        style = TextStyle(hyphens = Hyphens.Auto),
    )
}

LineBreak API で改行サポート

改行を指定できる新しい LineBreak APIが追加されました。 アプリのよくあるユースケースに対応する LineBreak プリセットも用意されてます。

  • LineBreak.Simple: これは一番早く処理できるタイプです。TextField みたいな頻繁ににテキストが変わる場合に使います。
  • LineBreak.Paragraph:長文・説明など、可読性が最優先で見た目も大事な場合使います。
  • LineBreak.Heading:タイトルみたいな短文で改行の影響が少ない場合使います。

LineBreak使って改行
LineBreak使って改行

このAPIを試してみた結果をこの記事でまとめたのでぜひ読んでみてください。

goodpatch-tech.hatenablog.com

Pull-to-refresh

まだ @ExperimentalMaterialApi になりますが Pull-to-refresh は Accompanist から卒業し、Material Design ライブラリーに追加されました。でも、Accompanist の SwipeRefreshLayout と異なって全体のレイアウトではなくインジケーターだけが追加されました。

Pull-to-refresh
Pull-to-refresh

var refreshing by remember { mutableStateOf(false) }

val state = rememberPullRefreshState(
    refreshing = refreshing,
    onRefresh = { .. }
)

Box(modifier = Modifier.pullRefresh(state = state)) {
    LazyColumn {
        if (!refreshing) {
            items(..)
        }
    }

    PullRefreshIndicator(
        refreshing = refreshing, 
        state = state, 
        modifier = Modifier.align(Alignment.TopCenter)
    )
}

Modifier.Node の大リファクタリング

v1.3.0から Modifier.Node が追加され、Compose UI のパフォーマンス向上に向けて Modifierの大リファクタリングが始まりました。

今年開催された Android Dev Summit で@intelligibabble氏が これについて説明セッションをやりましたのでぜひ見てみてください!

理解した上でざっくり説明してみます。これまで .clickable() などの Modifier は、Modifier.then()Modifier.composable{}を用いて、複数の statelessとstateful Modifier.Elementのチェーンで構成されていました。

でも Modifier.composed{}自体はパフォーマンス問題があります。

  • Modifier.composed{}はバリューを返す関数なので、Recomposition の時はスキップできない。
  • @Composable 関数じゃないので、Recomposition の前に返す Modifier.ElementチェーンとRecompositionで返す Modifier.Elementチェーンを Compose コンパイラが equals() で比較できない。基本的に Compose は Layout が変わったかどうかを Modifierの比較で判断します。ここで Modifierが変わらなくてもコンパイラが比較できず新しい Modifier として扱いし Modifierを Layoutにまた適用してしまいます。

この記事を書くときまだ未実装なんですが Leland 氏は想定してる形は、.clickable()みたいな Modifier.Elementで構成された ModifierModifier.composed{} を使って Modifier.Elementのチェーンを構成する代わりにModifier.Nodeオブジェクトを発行します。そのModifier.NodeオブジェクトをModifier.Node のチェーンに追加しLayoutNodeに適用します。そうするとメリットは:

Modifier.Node のメリット
Modifier.Node のメリット

  • Modifier.Nodeは mutable なので Modifier.Node を比較でき、 Recompositionの時 Modifier が更新されたかどうかを判断できます。
  • Modifier.Elementチェーンを unwrap することがなくなりますので、処理が必要な Compositionツリーの数が減ります。
  • Modifier.Elementチェーンの代わりに Modifier.Node オブジェクトのツリーになりますのでツリーが短くなり、tree traversalも早くなります。

Modifier.focusModifier()Modifier.Nodeのマイグレーションが始まったみたいなので次のバージョンでパフォーマンス向上を期待してます!

Kotlin 1.7.20 サポート

compose-compiler バージョン v1.3.2 から Kotlin 1.7.20 サポートが入りました!個人的に Kotlin 1.7.20 で以下が気になりました。

  when (value) {
    in 0.0..<0.25 -> { /* 0.25を含まれてない */ }
    in 0.25..<0.5 -> { /* 0.5を含まれてない */ }
  }
  • 新しい data object タイプ追加されました。基本的にもっといい toStringを実装してるobject です。
  object MyObject
  data object MyDataObject

  sealed class ReadResult {
    data class Number(val value: Int) : ReadResult()
    data class Text(val value: String) : ReadResult()
    data object EndOfFile : ReadResult()
  }

  fun main() {
    println(MyObject) // org.example.MyObject@1f32e575
    println(MyDataObject) // MyDataObject

    println(ReadResult.Number(1)) // Number(value=1)
    println(ReadResult.Text("Foo")) // Text(value=Foo)
    println(ReadResult.EndOfFile) // EndOfFile
  }
  • Generic value クラスが追加され、value クラスでも安定した type-safety を確保できます。
  @JvmInline
  inline class Pet<T>(value: T)
  
  fun cuddle(cat: Pet<Cat>)
  fun takeOutForAWalk(dog: Pet<Dog>)

終わりに

外にも、ここに書いてない 1.3.0 のAPI追加と更新盛り沢山です。v1.4.0 にも Modifier.Nodeのパフォーマンス向上含めてどんなAPIが追加や更新されるのか楽しみですね!