Textコンポーザブルはいかにしてカラーを決定するか
ℹ️ Android Advent Calendar 2023 14日目
Jetpack Compose使っていますか?使っていますね?いいですよね。大好きです。
Composeで開発していると、必ずと言って良いほど使っているTextコンポーザブル。普段の実装で何気なく使っているコンポーネントのテキストカラーについて思いを馳せてみます。
BasicTextの内部までは深ぼっていかないのでご了承ください🙋🏻♀️
Textコンポーザブルの実装をのぞく👀
さっそく本題ですが、Textコンポーザブルはいかにして色を決定するか、答えを実装に求めてみます。
今回見ていくコードは、下記のBOMに含まれるコードとします。
[libraries]
compose-bom = "androidx.compose:compose-bom:2023.10.01"
Textコンポーザブルのインタフェースについて、カラーに関係のある部分だけに絞ってのぞいて見ます👀
@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ってなんぞ
さきほどのコードで出てきたtakeOrElse
はColor
の拡張関数です。
名前からも推測できますが、Colorが指定されていればそのColorを返し、指定されていなければblock
の実行結果を返すというものです。
"指定されている"という判定は、Color.Unspecified
かどうかを見ています。これはColor
の拡張プロパティとして定義されているものです。未定義、という状態を表せるのはおもしろいですね。
コードは下記のように実装されています。
inline fun Color.takeOrElse(block: () -> Color): Color = if (isSpecified) this else block()
すでに今回の記事のタイトルについては解決してしまいました。あとは蛇足です、ここからが本番です。
LocalContentColor
ってなんぞ
Color
とTextStyle
はわかる。LocalContentColor
っていつ使われるの?という方はこちらの節を読んでください。知ってるよという人は気になる節に飛んでもらってOKです。
まず、LocalContentColor
は下記のような実装になっています。
val LocalContentColor = compositionLocalOf { Color.Black }
カラーのためのCompositionLocal
のようです。CompositionLocal
の詳しい説明はCompositionLocal でローカルにスコープ設定されたデータというドキュメントにおまかせすることにします。
一部だけ抜粋すると、下記のような記述があります。
Compose には CompositionLocal が用意されています。これを使用して、ツリーをスコープとする名前付きオブジェクトを作成し、データを UI ツリーに流し込むための暗黙の方法としてそれを使用できます。
LocalContentColor
の場合は、特定のスコープでカラー情報を流し込めるわけです。
これだけだといまいち使い方が想像できませんが、実はこれ、おそらくよく使っていたりします。
Scaffold
やSurface
、Button
など。これを使っていると知らずのうちに活用しているんです。
Surface
の実装をのぞく👀
ここではSurface
の実装を抜粋してのぞいてみることにします。Scaffold
も内部的にはSurface
でレイアウトをラップしているので一石二鳥です🪨🦅🦅
@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
では、パラメータで指定されたcontentColor
をLocalContentColor
として設定しています。
つまり、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
という背景色に適したコンテンツのカラーを返してくれるものです。
実装は下記の様になっています。
@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
とかの状態によってテキストの色変わってるな…?」ってみんな思ったと思います。わかります。さっそく抜粋してのぞいてみましょう👀
@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
はカラーのみではなく、シェイプやタイポグラフィ、アルファなどさまざまなところで活用されています。コンポーネントの内部実装をのぞいてみると、より理解が深まるし、なによりわくわくするのでオススメです。
得た知識をすぐ活用できなくても問題ありません、鞘から抜かなくても刀を差していることが大事なのです。
たのしく開発していきましょう🙌🏻
Discussion