Blog

Jetpack Composeで再利用可能なコンポーネントを実現するために

Jetpack Composeの大きな特徴として、再利用可能なコンポーネントを作りやすくなったことがあるでしょう。

細かい単位でComposable関数に切り出すことで、同じUIを共通化できるだけでなく、今後の仕様追加/変更の際にもスムーズにコンポーネントを再利用できるでしょう。

一方で、再利用可能なコンポーネントの実現にはいくつか注意すべき点があります。 今回は公式から述べられている注意事項や、自分が書いていて注意していることについて紹介します。

その前に:再利用しない選択肢

再利用の話に入る前に、再利用しない選択肢について書かせてください。

同じコードを何度も再利用することができれば、変更時に一箇所だけ変更すれば良くなりますし、何より楽です。 一方で、共通化することにより予期していなかった問題が発生することはよくあります。

一般的にUIの変更は多く、コンポーネントの切り出しの難易度は高いです。 とりあえず共通化しておいたものに、それぞれ別の対応が必要になり、いつの間にか一つのコンポーネントが肥大化していることはないでしょうか。

Jetpack Composeの場合であっても、1つの関数内に大量のwhenifが存在する場合、それは責務を持ちすぎている可能性があります。 一見似ているものであっても、それが一時的なものであったり、異なる目的が存在する場合には、分けて扱ったほうが良いでしょう。

凝集度を意識しながらコードを書くことは、Jetpack Composeでも非常に重要です。

凝集度等の詳しい説明は以前こちらで書いています。

1. コンポーネントにマージンやサイズを持たせない

React等の宣言的UIでよく言われていることですが、コンポーネント内でマージンやサイズを決めるのではなく、外から設定できるようにすることで、汎用的に扱うことができます。

Jetpack Composeでは、Modifierを引数として受け付けるようにしましょう。 渡したModifierはコンポーネント内の一番外側にわたすように気をつけてください。

Modifierは最初のデフォルト引数を持つ引数として、modifierという名前で定義するのが推奨されているようです。参考

DO 👌

@Composable
fun Sample(
    text: String,
    modifier: Modifier = Modifier
) {
    Box(
        modifier = modifier
    ) {
        Text(text = text)
    }
}

Don’t ❌

@Composable
fun Sample(
    text: String
) {
    Box(
        modifier = Modifier
            .width(320.dp)
            .padding(16.dp)
    ) {
        Text(text = text)
    }
}

2. パディングは PaddingValues で渡す

1番を実践していくと、背景があるコンポーネントやスクロール可能なコンポーネントのパディングは、Modifierだけでは制御できないことがわかります。

Modifierは順番が大事で、背景(background)がある場合、背景より前に設定したpaddingはいわゆるマージンとして扱われ、背景の後に設定したpaddingはいわゆるパディングとして扱われます。

Modifier
    .padding(16.dp) // いわゆるマージン
    .background(Color.Red)
    .padding(16.dp) // いわゆるパディング

背景色等はコンポーネント内で決めつつ、背景の内側のパディングを外側から操作したい場合、PaddingValues を別途渡してあげるのが良いでしょう。

@Composable
fun Sample(
    text: String,
    modifier: Modifier = Modifier,
    contentPadding: PaddingValues = PaddingValues(0.dp)
) {
    Box(
        modifier = modifier
            .background(Color.Red)
            .padding(contentPadding)
    ) {
        Text(text = text)
    }
}

これはLazyColumn等でも使われている方法になります。

@Composable
fun LazyColumn(
    modifier: Modifier = Modifier,
    state: LazyListState = rememberLazyListState(),
    contentPadding: PaddingValues = PaddingValues(0.dp),
...

3. 引数に出来る限りComposableを渡す

ボタンのラベル等、何かを表示したい場合に、できるだけ引数をStringなどの実態ではなく、Composableを渡すようにしておくと、汎用的に利用することができます。

例えば、以下のボタンの例だと、一部の利用箇所でのみ文字色を変えたり、アイコン付きのボタンにすることが容易になります。

DO 👌

@Composable
fun SampleButton(
    onClick: () -> Unit,
    modifier: Modifier = Modifier,
    content: @Composable () -> Unit
) {
    Box(
        modifier = modifier.clickable(onClick = onClick)
    ) {
        content()
    }
}

Don’t ❌

@Composable
fun SampleButton(
    text: String,
    onClick: () -> Unit,
    modifier: Modifier = Modifier
) {
    Box(
        modifier = modifier.clickable(onClick = onClick)
    ) {
        Text(text = text)
    }
}

LocalContentColorProvideTextStyleで子Composableを制御する

Composable を渡すようにしていくと、今度は利用側のコードが多く書かないといけなくなり、また場合によってそれらが重複しているケースも出てくるでしょう。

CompositionLocalを使うことで、それらを緩和することができます。例えば、文字色やアイコン色等は LocalContentColor を上書きすることで固定することができます。

@Composable
fun SampleButton(
    onClick: () -> Unit,
    modifier: Modifier = Modifier,
    content: @Composable () -> Unit
) {
    CompositionLocalProvider(
        LocalContentColor provides Color.Red // 赤色に固定
    ) {
        Box(
            modifier = modifier.clickable(onClick = onClick)
        ) {
            content()
        }
    }
}

このコンポーネントに対して、文字色を指定せずに利用した場合は文字色が赤色になり、明示的に指定した場合はその色になります。

SampleButton(onClick = { /* ... */ }) {
    Text(
        text = "Hello, World"  // 赤色
    )
}

SampleButton(onClick = { /* ... */ }) {
    Text(
        color = Color.Blue,
        text = "Hello, World"// 青色
    )
}

文字サイズやFontWeightを指定する場合はProvideTextStyleを使うことができます。

@Composable
fun SampleButton(
    onClick: () -> Unit,
    modifier: Modifier = Modifier,
    content: @Composable () -> Unit
) {
    ProvideTextStyle(
        TextStyle(
            fontSize = 12.sp,
            fontWeight = FontWeight.Bold
        )
    ) {
        Box(
            modifier = modifier.clickable(onClick = onClick)
        ) {
            content()
        }
    }
}

これらは material のpackageで提供されているため、BasicText等には影響しないことに注意してください。

他の要素も独自にCompositionLocalを配布することで共通化をすることが可能ですが、暗黙的な値の受け渡しになるので、乱用は注意したほうが良さそうです。

明示的な引数で子Composableに値を渡す

引数で渡されたComposableにデータを渡す方法として、CompositionLocalを使う他に、明示的な引数を渡す方法があります。

@Composable
fun SampleButton(
    onClick: () -> Unit,
    modifier: Modifier = Modifier,
    content: @Composable (contentPadding: PaddingValues) -> Unit
) {
    Box(
        modifier = modifier.clickable(onClick = onClick)
    ) {
        content(PaddingValues(16.dp))
    }
}

以下のように使うことができます。

SampleButton(onClick = { /* ... */ }) { contentPadding ->
    Text(
        modifier = Modifier
            .background(Color.Red)
            .padding(contentPadding),
        text = "Hello, World"
    )
}

Stateだけでなく、Composableを渡すことも可能です。

fun SampleButton(
    onClick: () -> Unit,
    modifier: Modifier = Modifier,
    content: @Composable (innerContent: @Composable () -> Unit) -> Unit
....

利用イメージは以下のようになります。 あまりやりすぎると理解が難しくなるので注意が必要そうです。

SampleButton(onClick = { /* ... */ }) { innerContent ->
    Box(modifier = Modifier.background(Color.Red)) {
        innerContent()
    }
}

BasicTextFielddecorationBoxではこの方法でフォームを装飾できるようになっています。

@Composable
fun BasicTextField(
    value: String?,
    onValueChange: ((String) -> Unit)?,
    /* ... */
    decorationBox: (@Composable (@Composable innerTextField: () -> Unit) -> Unit)? = @Composable { innerTextField -> innerTextField() }
): Unit

カスタムScopeを作成する

引数として渡す方法の他に、BoxBoxScopeのように、Scopeとして渡す方法も考えられます。

@Composable
fun SampleButton(
    onClick: () -> Unit,
    modifier: Modifier = Modifier,
    content: @Composable SampleButtonScope.() -> Unit
) {
    Box(
        modifier = modifier.clickable(onClick = onClick)
    ) {
        val scope = remember { SampleButtonScope() }
        scope.content()
    }
}

class SampleButtonScope {
    fun Modifier.customWidth(): Modifier {
        return this.width(120.dp)
    }
}

このように、利用することができます。 その中だけで利用できるModifierを定義する場合はこちらが良いかもしれません。

SampleButton(onClick = { /* ... */ }) {
    Text(
        modifier = Modifier.customWidth(),
        text = "Hello, World"
    )
}

4. 状態は外部で管理可能にする

Jetpack ComposeにはState Hoistingの考え方があり、出来る限り状態を外部で制御できるようにすることが推奨されています。 状態を外部から制御することができれば、そのUIは容易に再利用できるようになります。

例えば、テキストの入力できるTextFieldなども、現在の状態を表すvalueと変更を通知するonValueChangeのパラメータを受け取ります。 onValueChangeのイベントに応じてvalueを更新しない限り、表示は変更されません。

@Composable
fun TextField(
    value: String?,
    onValueChange: ((String) -> Unit)?,
    modifier: Modifier? = Modifier,
    /* ... */
): Unit

TextFieldを内部で使うような独自コンポーネントも、同様に状態を外部で制御できるようにします。 内部で状態を保持すると、常にそれらの同期が必要になり、バグが混入する可能性が増加します。

DO 👌

@Composable
fun EmailTextField(
    email: String,
    onEmailChange: (String) -> Unit,
    modifier: Modifier = Modifier
) {
    TextField(
        value = email,
        onValueChange = onEmailChange,
        modifier = modifier,
        label = { Text("Email") }
    )
}

Don’t ❌

@Composable
fun EmailTextField(
    onEmailChange: (String) -> Unit,
    modifier: Modifier = Modifier
) {
    var email by remember { mutableStateOf("") }
    TextField(
        value = email,
        onValueChange = {
            email = it
            onEmailChange(it)
        },
        modifier = modifier,
        label = { Text("Email") }
    )
}

スクロール可能なコンポーネント

Jetpack Composeにおいて、スクロール位置はScrollStateにて管理を行います。

スクロール可能なコンポーネントを作成する場合、ScrollStateを引数として受け取ることにより、外部からスクロール位置を監視したり、制御することが可能になります。

デフォルト引数で rememberScrollState を指定しておくことで、制御や監視の必要がない場合は、指定せずに使うこともできます。

その他のStateも、出来る限り外部から管理可能な形にすることが良いでしょう。

DO 👌

@Composable
fun EmailTextField(
    modifier: Modifier = Modifier,
    scrollState: ScrollState = rememberScrollState()
) {
    Column(
        modifier = modifier.verticalScroll(scrollState)
    ) {
        /* ... */
    }
}

Don’t ❌

@Composable
@Composable
fun EmailTextField(
    modifier: Modifier = Modifier
) {
    Column(
        modifier = modifier.verticalScroll(rememberScrollState())
    ) {
        /* ... */
    }
}

カスタムStateを作る

複雑なコンポーネントを作成する場合、独自でStateを用意するのが良いでしょう。

例えば、開閉が出来るアコーディオンメニューのUIを考えます。

以下のようなStateを作成し、それを引数に受け取るようにします。

@Stable
class AccordionState {
    var expanded by mutableStateOf(false)
        private set

    fun toggle() {
        if (expanded) {
            collapsed()
        } else {
            expand()
        }
    }

    fun expand() {
        expanded = true
    }

    fun collapsed() {
        expanded = false
    }
}
@Composable
fun Accordion(
   modifier: Modifier = Modifier,
   state: AccordionState = remember { AccordionState() }
) {
    Column(
        modifier = modifier
    ) {
        Text(
            modifier = Modifier
                .fillMaxWidth()
                .clickable(onClick = { state.toggle() }),
            text = "Header”
        )
        if (state. expanded) {
             Text(text = "Content")
        }
    }
}

state class内で扱う状態も、Stateを使い、常にComposeに通知されるされるようにします。

また、Stableアノテーションを付けておくことで、不要なrecomposeを抑制することができます。

ScrollState と同様にデフォルト引数を指定しておくことで、呼び出し側で制御しない場合は意識せずに使うことができます。

画面回転時等に状態を維持したい場合はSaverを実装し、rememberSaveableを使う必要があります。

まとめ

Jetpack ComposeでUIを再利用するための注意事項について紹介してきました。

再利用可能にするために、汎用的にコンポーネントを作成することは重要ですが、それによって不必要な考慮をし過ぎないように気をつけてください。(YAGNIの原則)

今回の記事も参考にしつつ、ぜひJetpack Composeで効率的なUI構築を実現してほしいと考えています。

人気の記事

kotlin coroutinesのFlow, SharedFlow, StateFlowを整理する

Jetpack ComposeとKotlin Coroutinesを連携させる

Layout Composableを使って複雑なレイアウトを組む【Jetpack Compose】

テスト用Dispatcherの使い分け【Kotlin Coroutines】

Flow.combineの内部実装がすごい話

Jetpack ComposeのRippleエフェクトを深堀り、カスタマイズも