🎨

Textコンポーザブルはいかにしてカラーを決定するか

2023/12/14に公開
ℹ️ Android Advent Calendar 2023 14日目

Jetpack Compose使っていますか?使っていますね?いいですよね。大好きです。

Composeで開発していると、必ずと言って良いほど使っているTextコンポーザブル。普段の実装で何気なく使っているコンポーネントのテキストカラーについて思いを馳せてみます。

BasicTextの内部までは深ぼっていかないのでご了承ください🙋🏻‍♀️

Textコンポーザブルの実装をのぞく👀

さっそく本題ですが、Textコンポーザブルはいかにして色を決定するか、答えを実装に求めてみます。
今回見ていくコードは、下記のBOMに含まれるコードとします。

libs.versions.toml
[libraries]
compose-bom = "androidx.compose:compose-bom:2023.10.01"

Textコンポーザブルのインタフェースについて、カラーに関係のある部分だけに絞ってのぞいて見ます👀

Text.kt
@Composable
fun Text(
    ...
    color: Color = Color.Unspecified,
    ...
    style: TextStyle = LocalTextStyle.current
) {

    val textColor = color.takeOrElse {
        style.color.takeOrElse {
            LocalContentColor.current
        }
    }
    ...
}

実装を見るとすぐに答えが出ますね、良い。実装からはこのようなことがわかります。

  • colorパラメータが指定されていればそれをテキストカラーとする
  • styleパラメータのカラーが指定されていればそれをテキストカラーとする
  • どちらも指定されていなければLocalContentColor.currentをテキストカラーとする

すでにおもしろいですね。

[補足] takeOrElseってなんぞ

さきほどのコードで出てきたtakeOrElseColorの拡張関数です。
名前からも推測できますが、Colorが指定されていればそのColorを返し、指定されていなければblockの実行結果を返すというものです。
"指定されている"という判定は、Color.Unspecifiedかどうかを見ています。これはColorの拡張プロパティとして定義されているものです。未定義、という状態を表せるのはおもしろいですね。

コードは下記のように実装されています。

Color.kt
inline fun Color.takeOrElse(block: () -> Color): Color = if (isSpecified) this else block()

すでに今回の記事のタイトルについては解決してしまいました。あとは蛇足です、ここからが本番です。

LocalContentColorってなんぞ

ColorTextStyleはわかる。LocalContentColorっていつ使われるの?という方はこちらの節を読んでください。知ってるよという人は気になる節に飛んでもらってOKです。

まず、LocalContentColorは下記のような実装になっています。

ContentColor.kt
val LocalContentColor = compositionLocalOf { Color.Black }

カラーのためのCompositionLocalのようです。CompositionLocalの詳しい説明はCompositionLocal でローカルにスコープ設定されたデータというドキュメントにおまかせすることにします。

一部だけ抜粋すると、下記のような記述があります。

Compose には CompositionLocal が用意されています。これを使用して、ツリーをスコープとする名前付きオブジェクトを作成し、データを UI ツリーに流し込むための暗黙の方法としてそれを使用できます。

LocalContentColorの場合は、特定のスコープでカラー情報を流し込めるわけです。
これだけだといまいち使い方が想像できませんが、実はこれ、おそらくよく使っていたりします。
ScaffoldSurfaceButtonなど。これを使っていると知らずのうちに活用しているんです。

Surfaceの実装をのぞく👀

ここではSurfaceの実装を抜粋してのぞいてみることにします。Scaffoldも内部的にはSurfaceでレイアウトをラップしているので一石二鳥です🪨🦅🦅

Surface.kt
@Composable
@NonRestartableComposable
fun Surface(
    ...
    color: Color = MaterialTheme.colorScheme.surface,
    contentColor: Color = contentColorFor(color),
    ...
) {
    val absoluteElevation = LocalAbsoluteTonalElevation.current + tonalElevation
    CompositionLocalProvider(
        LocalContentColor provides contentColor,
        LocalAbsoluteTonalElevation provides absoluteElevation
    ) {
        Box(...) {
            content()
        }
    }
}

LocalContentColor.currentは、そのLocalContentColorに値を設定する最も近いCompositionLocalProviderから設定された値を返します。

Surfaceでは、パラメータで指定されたcontentColorLocalContentColorとして設定しています。
つまり、Surfaceの子ComposableはcontentColorから暗黙的に色が指定されているわけです。

コードで見てみます。

@Preview(showBackground = true)
@Composable
private fun SamplePreview() {
    Column {
        Surface {
            Text(text = "Hello")
        }
        Surface(
            contentColor = Color.Blue,
        ) {
            Text(text = "World")
        }
    }
}

このPreviewはこのように表示されます。

Textコンポーザブルのパラメータには何も指定してないのに、色が変わっていることがわかります。わくわくしちゃいますね。

[補足] contentColorForってなんぞ

いくつかのコンポーザブルでも使われているこの関数。
これは、引数で与えたbackgroundColorという背景色に適したコンテンツのカラーを返してくれるものです。

実装は下記の様になっています。

ColorScheme.kt
@Composable
@ReadOnlyComposable
fun contentColorFor(backgroundColor: Color) =
    MaterialTheme.colorScheme.contentColorFor(backgroundColor).takeOrElse {
        LocalContentColor.current
    }
    
...

fun ColorScheme.contentColorFor(backgroundColor: Color): Color =
    when (backgroundColor) {
        primary -> onPrimary
        secondary -> onSecondary
        tertiary -> onTertiary
        background -> onBackground
        error -> onError
        surface -> onSurface
        surfaceVariant -> onSurfaceVariant
        primaryContainer -> onPrimaryContainer
        secondaryContainer -> onSecondaryContainer
        tertiaryContainer -> onTertiaryContainer
        errorContainer -> onErrorContainer
        inverseSurface -> inverseOnSurface
        else -> Color.Unspecified
    }

ここでもLocalContentColorが使われていますね。おもしろい。

Buttonの実装ものぞいちゃう👀

「そういえばButtonコンポーザブルってなんか色を指定してないのに、enabledとかの状態によってテキストの色変わってるな…?」ってみんな思ったと思います。わかります。さっそく抜粋してのぞいてみましょう👀

Button.kt
@Composable
fun Button(
    ...
    enabled: Boolean = true,
    ..
    colors: ButtonColors = ButtonDefaults.buttonColors(),
    ...
) {
    ...
    val contentColor = colors.contentColor(enabled).value
    ...
    Surface(
        ...
        contentColor = contentColor,
        ...
    ) {
        CompositionLocalProvider(LocalContentColor provides contentColor) {
            ...
        }
    }
}

Surfaceコンポーザブルで予習したので完全に理解ですね!
CompositionLocalProviderがなくてもよさそうに思ったりしたけどSurfaceに依存しないように明示的に指定してるのかな。知ってる人がいたら教えてください🙏🏻)

ButtonColorsの実装詳細はぜひ興味があればのぞいて欲しいところです。enabledに応じてカラーを切り替えているわけですが、ButtonDefaults.buttonColors()も合わせてのぞいて見ると、直接Colorをパラメータでテキストカラーを指定させずに状態によっていい感じにカラーが適用されるようになっていて🆒です。

こちらもコードとPreviewで見てみましょう。

@Preview(showBackground = true)
@Composable
private fun SamplePreview() {
    Button(onClick = { /*TODO*/ }) {
        Text(text = "Hello")
    }
}

このPreviewはこのように表示されます。

Textコンポーザブルには何も指定してないのに、テキストカラーがボタンのコンテンツカラーに合わせてホワイトになっていることがわかります。いやあ、よくできてる。

Textコンポーザブルのカラーをどう指定するか

復習をすると、Textコンポーザブルは、下記の優先度に従ってカラーを決定します。

  • colorパラメータが指定されていればそれをテキストカラーとする
  • styleパラメータのカラーが指定されていればそれをテキストカラーとする
  • どちらも指定されていなければLocalContentColor.currentをテキストカラーとする

どれがベストということはありません、よくありがちなケースバイケースですね。別の言い方をすると、colorで指定する以外の方法もあるということです。

LocalContentColorを使ったコンポーネントの実装

アプリでよく利用するコンポーネントを、Composable関数として実装して共通化するケースはよくあると思います。場合によって、LocalContentColorを使うとより柔軟な利用ができるコンポーネントを作ることが出来ます。

たとえば、下記のようなデザインをコンポーネント化したいとしましょう。ぜひ実装を想像しながら続きを読んでみてください。


※斜線や数字はデザインスペックを表示しているだけ

今のデザインを実現するだけを考えると下記のように実装できそうです。

@Composable
fun TitleAndDescription1(
    title: String,
    description: String,
) {
    Column(
        modifier = Modifier
            .fillMaxWidth()
            .padding(horizontal = 16.dp),
        verticalArrangement = spacedBy(8.dp),
    ) {
        Text(text = title)
        Text(
            text = description,
            color = Color(0xFF7C7C7C),
        )
    }
}

これをPreview表示するとこのようになります。

よさそうですね。

では、このデザインが利用する箇所によってテキストカラーが変わるコンポーネントにしたいというケースはどうでしょう。


たとえばタイトルのカラーを変えたいときなど

愚直に実装するとこのようになるでしょうか。

@Composable
fun TitleAndDescription2(
    title: String,
    description: String,
    titleColor: Color = Color(0xFF000000),
    descriptionColor: Color = Color(0xFF7C7C7C),
) {
    Column(
        modifier = Modifier
            .fillMaxWidth()
            .padding(horizontal = 16.dp),
        verticalArrangement = spacedBy(8.dp),
    ) {
        Text(
            text = title,
            color = titleColor
        )
        Text(
            text = description,
            color = descriptionColor,
        )
    }
}

これでも実現できそうですね。

では、カラーだけでなく、フォントサイズやフォントスタイルなども可変にしたいケースが出たときはどうでしょう。汎用的にしようとすればするほどパラメータが増えていくことが想像できます。

こういうケースを解決する方法の1つとして、スロットベースのレイアウトが検討できるでしょう。
詳しい説明は割愛しますが、コンポーザブルラムダをパラメータに取ることで、コンポーネントの利用側が子コンポーネントをある程度柔軟に指定できるようになるような方法です。

これを利用して実装してみると、下記のように書くことも出来ます。

@Composable
fun TitleAndDescription3(
    title: @Composable () -> Unit,
    description: @Composable () -> Unit,
) {
    Column(
        modifier = Modifier
            .fillMaxWidth()
            .padding(horizontal = 16.dp),
        verticalArrangement = spacedBy(8.dp),
    ) {
        title()
        description()
    }
}

この状態で素直に利用するときはこのようになります。

@Preview(showBackground = true)
@Composable
private fun TitleAndDescription3Preview() {
    TitleAndDescription3(
        title = {
            Text(text = "タイトル")
        },
        description = {
            Text(
                text = "なにか説明が入ったりする",
                color = Color(0xFF7C7C7C),
            )
        }
    )
}

できればdescriptionにあたるTextコンポーネントはデフォルトでテキストカラーがグレーになっていて欲しいですよね。ここでやっとLocalContentColorです。きました。

CompositionLocalProviderを使ってdescriptionコンポーザブルラムダをラップしてあげればOKです。

@Composable
fun TitleAndDescription4(
    title: @Composable () -> Unit,
    description: @Composable () -> Unit,
) {
    Column(
        modifier = Modifier
            .fillMaxWidth()
            .padding(horizontal = 16.dp),
        verticalArrangement = spacedBy(8.dp),
    ) {
        title()
        CompositionLocalProvider(
            LocalContentColor provides Color(0xFF7C7C7C)
        ) {
            description()
        }
    }
}

こうすると下記のように利用することが出来ます。

@Preview(showBackground = true)
@Composable
private fun TitleAndDescription4Preview() {
    TitleAndDescription4(
        title = {
            Text(text = "タイトル")
        },
        description = {
            Text(text = "なにか説明が入ったりする")
        }
    )
}

もし、色を変えたい場合はTextコンポーザブルのcolorパラメータを指定してあげればよいというわけです。これはテキストカラーはcolorの指定が優先されることを実装から見たので説明は不要でしょう。

ここでは単純なケースのみで説明しましたが、いろんなバックグラウンドカラーに対応できるようにだったり、ダークモードのときのことだったりを考慮すると、今回のコンポーネントではまだ対応しきれていません。奥が深い。さらにテキストスタイルのことも考慮すると…わくわくしますね。

おわりに

この記事ではTextコンポーザブルのカラーについて取り上げました。ここで出てきたLocalContentColorはテキストカラーのみではなく、Iconコンポーザブルのtintに使われてもいます。
基本的な考え方は同じなので、なんか思った通りのカラーになってくれないときや、うまくカラーを指定してあげたいときはぜひ活用してみてください🙆🏻‍♂️

マテリアルコンポーネントでは、CompositionLocalはカラーのみではなく、シェイプやタイポグラフィ、アルファなどさまざまなところで活用されています。コンポーネントの内部実装をのぞいてみると、より理解が深まるし、なによりわくわくするのでオススメです。

得た知識をすぐ活用できなくても問題ありません、鞘から抜かなくても刀を差していることが大事なのです。

たのしく開発していきましょう🙌🏻

Android Advent Calendar 2023

Discussion