Jetpack Compose: 文字は何色か

こんにちは、カンカクでAndroidエンジニアをやっている @haru067です。
最近はJetpack Compose(以下、Compose)をゴリゴリに書いています。

ということで、今回はComposeの話をしたいと思います。
(執筆時のComposeのstableバージョンは1.0.5であり、これを想定して説明します。)

TextFieldとエラーラベル

Composeには文字入力を扱うComposableとして、TextFieldがあります。

@Composable
fun Example() {
    TextField(
        value = "value",
        onValueChange = {},
        label = {
            Text("Error label")
        },
        isError = true,
    )
}

f:id:kankak_inc:20211221122617p:plain

labelを設定すると、入力文字の上にラベルが表示されます。isError = trueを設定したときにはエラー表示となり、ラベルは赤色で表示されます。

ここでクイズ

次の2つのComposable、Quiz1Quiz2を実行したとき、エラーラベルの色はそれぞれ何色になるでしょう?

@Composable
fun Quiz1() {
    MaterialTheme(typography = Typography(body1 = TextStyle(Color.Green))) {
        TextField(
            value = "value",
            onValueChange = {},
            label = {
                Text("Error label1", color = Color.Blue, style = TextStyle(Color.Yellow))
            },
            isError = true,
        )
    }
}

@Composable
fun Quiz2() {
    MaterialTheme(typography = Typography(body1 = TextStyle(Color.Green))) {
        TextField(
            value = "value",
            onValueChange = {},
            label = {
                Text("Error label2")
            },
            isError = true,
        )
    }
}
1. Color.Green
2. Color.Blue
3. Color.Yellow
4. エラーの赤色
5. それ以外

正解

次のリンクを押して、答えを確認してみてください。

いかがでしたでしょうか? 両方とも正解できた方はおめでとうございます! このあとの文章を読む必要はなさそうなので、こちらをクリックしてください。

正解できなかった人も安心してください。このあとしっかり解説します。

解説: Quiz1

Quiz1はそこまで難しくありません。Textの実装を確認すると、Textは次のようなロジックで文字色を決定していることがわかります。

  1. 引数color: Colorが指定してある場合、colorを使用する
  2. 引数style: TextStylestyle.colorが指定してある場合、style.colorを使用する
  3. それ以外の場合はLocalContentColorを参照する(後述)

Quiz1Text("Error label1", color = Color.Blue, ...)となっており、colorが最優先された結果、エラーラベルはColor.Blueになります。

ということで、これは単純な優先順位の問題でした。厄介なのはQuiz2です。Quiz2では、Textに何も指定していません。単純に考えるとエラーの赤色が適用されそうですが、実際にはそうなりませんでした。謎ですね。

これを理解するには、CompositionLocalについて知る必要があります。

CompositionLocal

CompositionLocalは、Composableに対して暗黙的にデータ渡すための仕組みです。
・・・と言っても理解しづらいと思うので、まずは例を見てみましょう。

@Composable
fun CompositionLocalExample() {
    MaterialTheme {
        Column {
            // MaterialThemeがデフォルト値としてContentAlpha.highを設定
            Text("High alpha") 
            CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) {
                Text("Medium alpha")
                CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.disabled) {
                    DescendantExample()
                }
            }
        }
    }
}

@Composable
fun DescendantExample() {
    // Composableを跨ぐ場合も適用される
    Text("Disabled alpha")
}

f:id:kankak_inc:20211221123138p:plain:w300

Textに何も指定していないにも関わらず、alpha値が変化しています。
Textは内部でLocalContentAlpha.currentという値を参照しており、CompositionLocalProviderを通じてLocalContentAlphaに値を設定することでalpha値が暗黙的に変化する、という仕組みになっています。

なんて危険な仕組みだ!と思った人は嗅覚が鋭いです。知らないうちに値が変わるということは便利であると同時に、問題が発生したときのデバッグを困難にします。実際、公式ドキュメントでも使うべき/使うべきでない用途については丁寧に解説されています。 developer.android.com

では逆に、これを使うべきタイミングはいつでしょうか?典型的な例はThemingです。先程の例ではMaterialThemeで囲った箇所のalpha値が変化していますが、MaterialThemeの実装を覗くと、CompositionLocalProviderを使用していることがわかります。

脱線: provides

LocalContentAlpha provides ContentAlpha.mediumという表記を見て「ん?」と思った方は多いのではないでしょうか。 provides中置記法で、ComposeというよりはKotlinの機能です。LocalContentAlpha provides ContentAlpha.mediumLocalContentAlpha.provides(Content.Alpha.medium)と実質的に同じです。

解説: Quiz2

さて、CompositionLocalについて学んだところで、Quiz2に戻りましょう。

@Composable
fun Quiz2() {
    MaterialTheme(typography = Typography(body1 = TextStyle(Color.Green))) {
        TextField(
            value = "value",
            onValueChange = {},
            label = {
                Text("Error label2")
            },
            isError = true,
        )
    }
}

Quiz2ではMaterialThemetypographyを指定していました。 実装を確認すると、MaterialThemeではtypography.body1に対して CompositionLocalProviderLocalTextStyleを設定していることがわかります。

ここで Textの文字色の決定ロジックを再確認しましょう。

  1. 引数color: Colorが指定してある場合、colorを使用する
  2. 引数style: TextStylestyle.colorが指定してある場合、style.colorを使用する
  3. それ以外の場合はLocalContentColorを参照する

ポイントは2です。styleのデフォルト値は LocalTextStyle.currentなのでQuiz2におけるTextstyleの値はMaterialTheme(...)でprovideされたスタイルになります。 この結果、style.colorMaterialTheme.typography.body1.colorということになり、Color.Greenが優先された、となるわけです。

エラー時の振る舞い

残る最後の疑問は「エラー時の赤色はどうやって実現しているのか?」です。

余白が少ないので細かい流れは追いませんが、TextFieldの実装を追っていくと、最終的にはここにたどり着きます。要するに、TextFieldlabelLocalContentColorをprovideしており、label = {...}内に記述されたText

3.それ以外の場合はLocalContentColorを参照する

の規則に則って赤色になるわけです。おしまい。

おわりに

カンカクでは、Jetpack ComposeやCompositionLocalに興味のあるAndroidエンジニアを募集しています。 気になる方は以下のリンクよりご気軽にご応募ください!