Jetpack Compose で Kotlin を使用する

Jetpack Compose は Kotlin に基づいて構築されています。場合によっては、優れた Compose コードを簡単に作成できる特別なイディオムが Kotlin によって提供されます。別のプログラミング言語で考えて、その言語を頭の中で Kotlin に変換すると、Compose の強みの一部を見逃してしまうかもしれません。また、Kotlin のイディオムで書かれたコードは理解しづらいこともあるでしょう。Kotlin のスタイルに慣れると、このような落とし穴を避けることができます。

デフォルトの引数

Kotlin 関数を作成するとき、関数の引数のデフォルト値を指定できます。これは、呼び出し元が明示的に値を渡さなかった場合に使用されます。この機能により、関数のオーバーロードの必要性が減ります。

たとえば、正方形を描画する関数を作成するとします。この関数には、各辺の長さを指定する単一の必須パラメータ sideLength があり、thicknessedgeColor などの省略可能なパラメータが複数あるとします。呼び出し元がこれらを指定しなかった場合、関数はデフォルト値を使用します。他の言語では、複数の関数を作成することになる場合があります。

// We don't need to do this in Kotlin!
void drawSquare(int sideLength) { }

void drawSquare(int sideLength, int thickness) { }

void drawSquare(int sideLength, int thickness, Color edgeColor) { }

Kotlin では、単一の関数を作成して、引数にデフォルト値を指定できます。

fun drawSquare(
    sideLength: Int,
    thickness: Int = 2,
    edgeColor: Color = Color.Black
) {
}

この機能を使用すると、複数の冗長関数を作成する必要がなくなるだけではなく、コードが非常に読みやすくなります。呼び出し元が引数に値を指定していない場合、これはデフォルト値を使用する意思があることを示しています。さらに、パラメータに名前が付けられているため、状況の把握が容易になります。コードを見ていて次のような関数呼び出しがあった場合、drawSquare() コードを確認しないとパラメータの意味がわかりません。

drawSquare(30, 5, Color.Red);

一方、次のようなコードであれば、コードだけで意味がわかります。

drawSquare(sideLength = 30, thickness = 5, edgeColor = Color.Red)

ほとんどの Compose ライブラリではデフォルトの引数を使用します。作成するコンポーズ可能な関数についても同じようにすることをおすすめします。これにより、コンポーザブルをカスタマイズできるようになりますが、依然としてデフォルトの動作は簡単に呼び出すことができます。そのため、たとえば次のようなシンプルなテキスト要素を作成するとします。

Text(text = "Hello, Android!")

このコードは、次に示すような、より詳細な(より多くの Text パラメータを明示的に設定している)コードと同じ効果をもたらします。

Text(
    text = "Hello, Android!",
    color = Color.Unspecified,
    fontSize = TextUnit.Unspecified,
    letterSpacing = TextUnit.Unspecified,
    overflow = TextOverflow.Clip
)

最初のコード スニペットの方がはるかにシンプルで読みやすいだけでなく、コードだけで意味がわかります。text パラメータのみを指定することで、他のすべてのパラメータに対してデフォルト値を使用することを示せます。一方、2 つ目のスニペットは、他のパラメータの値を明示的に設定することを意味しますが、設定した値は関数のデフォルト値です。

高階関数とラムダ式

Kotlin は、高階関数(他の関数をパラメータとして受け取る関数)をサポートしています。Compose は、このアプローチに基づいています。たとえばコンポーズ可能な関数 Button には、ラムダ パラメータ onClick が用意されています。このパラメータの値は、ユーザーがボタンをクリックしたときに呼び出す関数です。

Button(
    // ...
    onClick = myClickFunction
)
// ...

高階関数は、ラムダ式(関数に評価される式)と自然に対になります。関数が一度だけ必要な場合は、高階関数に渡すために関数を他の場所で定義する必要はありません。代わりに、ラムダ式を使用して関数をその場で定義できます。上記の例では、myClickFunction() が他の場所で定義されていることを前提としています。関数をここでしか使用しない場合は、ラムダ式を使用して、インラインで関数を定義する方が簡単です。

Button(
    // ...
    onClick = {
        // do something
        // do something else
    }
) { /* ... */ }

末尾のラムダ

Kotlin には、最後のパラメータがラムダである高階関数を呼び出すための、特別な構文が用意されています。ラムダ式をパラメータとして渡す場合、末尾のラムダ構文を使用できます。ラムダ式をかっこで囲むのではなく、かっこの後ろに配置します。これは Compose ではよくあることであるため、コードの見た目に慣れておく必要があります。

たとえばコンポーズ可能な関数 Column() など、すべてのレイアウトに渡す最後のパラメータは content です。これは、子 UI 要素を出力する関数です。3 つのテキスト要素を含む列を作成し、書式を適用する必要があるとします。このコードは機能しますが、非常に面倒です。

Column(
    modifier = Modifier.padding(16.dp),
    content = {
        Text("Some text")
        Text("Some more text")
        Text("Last text")
    }
)

content パラメータは関数署名の最後のパラメータであり、値をラムダ式として渡しているため、かっこ内から値を取り出せます。

Column(modifier = Modifier.padding(16.dp)) {
    Text("Some text")
    Text("Some more text")
    Text("Last text")
}

この 2 つの例の意味はまったく同じです。中かっこは、content パラメータに渡されるラムダ式を定義しています。

実際には、渡す唯一のパラメータが末尾のラムダである場合(つまり最後のパラメータがラムダであり、他のパラメータは渡さない場合)は、かっこを完全に省略できます。そのため、たとえば Column に修飾子を渡す必要がない場合、次のようなコードを作成できます。

Column {
    Text("Some text")
    Text("Some more text")
    Text("Last text")
}

この構文は、Compose ではごく一般的であり、特に Column のようなレイアウト要素でよく使われます。最後のパラメータは要素の子を定義するラムダ式であり、子は関数呼び出しの後に中かっこで指定します。

スコープとレシーバー

一部のメソッドとプロパティは、特定のスコープでしか利用できません。スコープを制限することで、必要な機能を提供し、適切でない機能を誤って使用しないようにできます。

Compose で使用されている例を考えてみましょう。Row レイアウト コンポーザブルを呼び出すと、コンテンツ ラムダが自動的に RowScope 内で呼び出されます。これにより、RowRow 内でのみ有効な機能を公開できます。下記の例は、Rowalign 修飾子の行固有の値をどのように公開しているかを示しています。

Row {
    Text(
        text = "Hello world",
        // This Text is inside a RowScope so it has access to
        // Alignment.CenterVertically but not to
        // Alignment.CenterHorizontally, which would be available
        // in a ColumnScope.
        modifier = Modifier.align(Alignment.CenterVertically)
    )
}

API によっては、レシーバー スコープで呼び出されるラムダを受け入れるものもあります。こうしたラムダは、パラメータ宣言に基づき、他の場所で定義されているプロパティと関数にアクセスできます。

Box(
    modifier = Modifier.drawBehind {
        // This method accepts a lambda of type DrawScope.() -> Unit
        // therefore in this lambda we can access properties and functions
        // available from DrawScope, such as the `drawRectangle` function.
        drawRect(
            /*...*/
            /* ...
        )
    }
)

詳細については、Kotlin ドキュメントの Function literals with receiver をご覧ください。

委譲プロバティ

Kotlin は、委譲プロパティをサポートしています。こうしたプロパティはあたかもフィールドであるかのように呼び出されますが、その値は、式を評価することで動的に決定されます。こうしたプロパティは、by 構文を使用することで認識できます。

class DelegatingClass {
    var name: String by nameGetterFunction()

    // ...
}

他のコードでは、次のようなコードを使用してプロパティにアクセスできます。

val myDC = DelegatingClass()
println("The name property is: " + myDC.name)

println() が実行されると、nameGetterFunction() が呼び出され、文字列の値が返されます。

委譲プロパティは、状態に基づくプロパティを扱う場合に特に有用です。

var showDialog by remember { mutableStateOf(false) }

// Updating the var automatically triggers a state change
showDialog = true

データクラスの分解

データクラスを定義する場合、分解宣言を使用してデータに簡単にアクセスできます。たとえば、Person クラスを定義するとします。

data class Person(val name: String, val age: Int)

その型のオブジェクトがある場合、次のようなコードを使用してその値にアクセスできます。

val mary = Person(name = "Mary", age = 35)

// ...

val (name, age) = mary

Compose 関数では、そのような種類のコードがよくあります。

Row {

    val (image, title, subtitle) = createRefs()

    // The `createRefs` function returns a data object;
    // the first three components are extracted into the
    // image, title, and subtitle variables.

    // ...
}

データクラスには、他にも多くの便利な機能があります。たとえば、データクラスを定義すると、コンパイラは equals()copy() などの便利な関数を自動的に定義します。詳細については、データクラスのドキュメントをご覧ください。

シングルトン オブジェクト

Kotlin を使用すると、シングルトン(インスタンスを常に 1 つだけ持つクラス)を簡単に宣言できます。シングルトンは object キーワードで宣言されます。Compose では、このようなオブジェクトをよく利用します。たとえば、MaterialTheme はシングルトン オブジェクトとして定義されます。MaterialTheme.colorsshapestypography の各プロパティには、現在のテーマの値が含まれています。

タイプセーフ ビルダーと DSL

Kotlin では、タイプセーフなビルダーを使用してドメイン固有の言語(DSL)を作成できます。DSL を使用すると、管理しやすく読みやすい方法で、階層的なデータ構造を構築できます。

Jetpack Compose は、LazyRowLazyColumn などの API に DSL を使用します。

@Composable
fun MessageList(messages: List<Message>) {
    LazyColumn {
        // Add a single item as a header
        item {
            Text("Message List")
        }

        // Add list of messages
        items(messages) { message ->
            Message(message)
        }
    }
}

Kotlin は、レシーバを備えた関数リテラルを使用してタイプセーフ ビルダーの作成を保証します。Canvas コンポーザブルを例にとると、DrawScope をレシーバとして備えた関数(onDraw: DrawScope.() -> Unit)をパラメータとして受け取り、コードのブロックが DrawScope で定義されたメンバー関数を呼び出すことができるようにします。

Canvas(Modifier.size(120.dp)) {
    // Draw grey background, drawRect function is provided by the receiver
    drawRect(color = Color.Gray)

    // Inset content by 10 pixels on the left/right sides
    // and 12 by the top/bottom
    inset(10.0f, 12.0f) {
        val quadrantSize = size / 2.0f

        // Draw a rectangle within the inset bounds
        drawRect(
            size = quadrantSize,
            color = Color.Red
        )

        rotate(45.0f) {
            drawRect(size = quadrantSize, color = Color.Blue)
        }
    }
}

タイプセーフ ビルダーと DSL について詳しくは、Kotlin のドキュメントをご覧ください。

Kotlin コルーチン

コルーチンを使用すると、Kotlin 言語レベルで非同期プログラミングが可能になります。コルーチンは、スレッドをブロックせずに実行を停止できます。レスポンシブ UI は本質的に非同期なものですが、Jetpack Compose は、コールバックの代わりに API レベルでコルーチンを使用することによりこれを実現しています。

Jetpack Compose には、UI レイヤ内でコルーチンを安全に使用するための API が用意されています。rememberCoroutineScope 関数から返される CoroutineScope を使用することで、イベント ハンドラ内でコルーチンを作成し、Compose の停止 API を呼び出せます。次の例では、ScrollStateanimateScrollTo を使用しています。

// Create a CoroutineScope that follows this composable's lifecycle
val composableScope = rememberCoroutineScope()
Button(
    // ...
    onClick = {
        // Create a new coroutine that scrolls to the top of the list
        // and call the ViewModel to load data
        composableScope.launch {
            scrollState.animateScrollTo(0) // This is a suspend function
            viewModel.loadData()
        }
    }
) { /* ... */ }

コルーチンは、デフォルトでコードブロックを順次実行します。suspend 関数を呼び出している実行中のコルーチンは、suspend 関数が戻るまでその実行を停止します。これは、suspend 関数が実行を別の CoroutineDispatcher に移動した場合にも当てはまります。上記の例では、loadData は suspend 関数 animateScrollTo が返されるまで実行されません。

コードを同時に実行するには、新しいコルーチンを作成する必要があります。上記の例で、画面の一番上までスクロールしながら viewModel からデータを読み込むには、2 つのコルーチンが必要です。

// Create a CoroutineScope that follows this composable's lifecycle
val composableScope = rememberCoroutineScope()
Button( // ...
    onClick = {
        // Scroll to the top and load data in parallel by creating a new
        // coroutine per independent work to do
        composableScope.launch {
            scrollState.animateScrollTo(0)
        }
        composableScope.launch {
            viewModel.loadData()
        }
    }
) { /* ... */ }

コルーチンを使用すると、非同期 API を簡単に組み合わせることができます。次の例では、pointerInput 修飾子とアニメーション API を組み合わせて、ユーザーが画面をタップしたときに、要素の位置にアニメーション効果を表示します。

@Composable
fun MoveBoxWhereTapped() {
    // Creates an `Animatable` to animate Offset and `remember` it.
    val animatedOffset = remember {
        Animatable(Offset(0f, 0f), Offset.VectorConverter)
    }

    Box(
        // The pointerInput modifier takes a suspend block of code
        Modifier
            .fillMaxSize()
            .pointerInput(Unit) {
                // Create a new CoroutineScope to be able to create new
                // coroutines inside a suspend function
                coroutineScope {
                    while (true) {
                        // Wait for the user to tap on the screen
                        val offset = awaitPointerEventScope {
                            awaitFirstDown().position
                        }
                        // Launch a new coroutine to asynchronously animate to
                        // where the user tapped on the screen
                        launch {
                            // Animate to the pressed position
                            animatedOffset.animateTo(offset)
                        }
                    }
                }
            }
    ) {
        Text("Tap anywhere", Modifier.align(Alignment.Center))
        Box(
            Modifier
                .offset {
                    // Use the animated offset as the offset of this Box
                    IntOffset(
                        animatedOffset.value.x.roundToInt(),
                        animatedOffset.value.y.roundToInt()
                    )
                }
                .size(40.dp)
                .background(Color(0xff3c1361), CircleShape)
        )
    }

コルーチンについて詳しくは、Android での Kotlin コルーチン ガイドをご覧ください。