2021年4月15日木曜日

Jetpack Compose の Composition Local ってなんなの?

Jetpack Compose には Composition Local という仕組みが用意されています。
これを使うとバケツリレー問題に対応することができます。

バケツリレー問題

例えば深いところの Composable で Context が必要だとします。 class MainActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val context: Context = this setContent { MyAppTheme { MyApp(context) } } } } @Composable fun MyApp(context:Context) { TopScreen(context) } @Composable fun TopScreen(context:Context) { MyList(context) } @Composable fun MyList(context:Context) { // use context } この場合 Context を必要としているのは MyList ですが、MyList に Context を渡すために

MyApp -> TopScreen -> MyList

と Context をバケツリレーしています。

しかしこの方法では、途中の MyApp と TopScreen では Context を使わないにもかかわらず、受け渡しのためだけに引数に Context が必要になってしまいます。 その Composable で使わないものが引数にあると、何をその Compose で必要としているのかがわかりににくくなってしまいます。

Composition Local

Composition Local を使うと、深いところの Composable にデータを渡すことができます。
上のコードを Composition Local を使って書き換えるとこのようになります。
後ほどそれぞれを詳しく説明します。 private val MyLocalContext = staticCompositionLocalOf<Context> { error("No current Context") } class MainActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val context: Context = this setContent { MyAppTheme { CompositionLocalProvider(MyLocalContext provides context) { MyApp() } } } } } @Composable fun MyApp() { TopScreen() } @Composable fun TopScreen() { MyList() } @Composable fun MyList() { val context:Context = MyLocalContext.current // use context } 実は Context を受け渡す Composition Local は Jetpack Compose の方で用意されています。それが LocalContext です。
LocalContext を使って書き換えるとこのようになります。
Context が欲しい Composable で LocalContext.current から Context のインスタンスが取得できます。 class MainActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { MyAppTheme { MyApp() } } } } @Composable fun MyApp() { TopScreen() } @Composable fun TopScreen() { MyList() } @Composable fun MyList() { val context:Context = LocalContext.current // use context }

用意されている Composition Local

LocalContext 以外にも LocalDensity, LocalConfiguration, LocalLifecycleOwner, LocalContentColor などが用意されています。


独自の Composition Local を作る

まず、受け渡したい値のホルダーになる ProvidableCompositionLocal を作ります。作るには staticCompositionLocalOf() か compositionLocalOf() を使います。 private val MyLocalColor: ProvidableCompositionLocal<Color> = compositionLocalOf<Color> { error("No current color") } staticCompositionLocalOf() および compositionLocalOf() に渡す lambda はデフォルト値のファクトリーです。値が与えられる前に取得しようとすると、このファクトリーが呼ばれます。

staticCompositionLocalOf() だと StaticProvidableCompositionLocal が生成され、compositionLocalOf() だと DynamicProvidableCompositionLocal が生成されます。

staticCompositionLocalOf() (StaticProvidableCompositionLocal) は滅多に変わらない値に使います。

例えば LocalContext や LocalLifecycleOwner は staticCompositionLocalOf() を使っていますが、LocalConfiguration は compositionLocalOf() を使っています。



ProvidableCompositionLocal の provides() または providesDefault() で 値を指定して ProvidedValue を取得します。
provides() および providesDefault() には infix がついているので、MyLocalColor provides Color.Black のように記述できます。

次に、取得した ProvidedValue を CompositionLocalProvider Composable に渡します。
CompositionLocalProvider の第1引数は vararg values: ProvidedValue<*> なので、複数渡すこともできます。 CompositionLocalProvider(MyLocalColor provides Color.Black) { MyApp() } こうすると、CompositionLocalProvider の content lambda 内の Composable では ProvidableCompositionLocal.current で指定された値を取得することができます。 @Composable fun MyApp() { TopScreen() } @Composable fun TopScreen() { MyList() } @Composable fun MyList() { val color:Color = MyLocalColor.current // Color.Black }

provides() で指定された値を上書きできます。 CompositionLocalProvider(MyLocalColor provides Color.Black) { Column { Text("Hello", color = MyLocalColor.current) // 文字色は Color.Black CompositionLocalProvider(MyLocalColor provides Color.Red) { Text("Hello", color = MyLocalColor.current) // 文字色は Color.Red } } } providesDefault() ではすでに指定された値がある場合は上書きしません。 CompositionLocalProvider(MyLocalColor provides Color.Black) { Column { Text("Hello", color = MyLocalColor.current) // 文字色は Color.Black CompositionLocalProvider(MyLocalColor providesDefault Color.Red) { Text("Hello", color = MyLocalColor.current) // 文字色は Color.Black のまま } } }

用意されている Composable での Composition Local の利用

Jetpack Compose で用意されている Composable では Composition Local がたくさん使われています。
ここでは一部を紹介します。

例えば string resource id を指定して文字列を取得する stringResource() では内部で Resources インスタンスを取得する resources() を使っています。この resources() では LocalContext から Context を取得しています。 @Composable @ReadOnlyComposable private fun resources(): Resources { LocalConfiguration.current return LocalContext.current.resources }

Text Composable ではデフォルトのスタイルとして LocalTextStyle から TextStyle を取得しています。また、文字色の指定がないときは LocalContentColor と LocalContentAlpha が使われます。 @Composable fun Text( text: AnnotatedString, ... style: TextStyle = LocalTextStyle.current ) { val textColor = color.takeOrElse { style.color.takeOrElse { LocalContentColor.current.copy(alpha = LocalContentAlpha.current) } } ... } よって CompositionLocalProvider で LocalTextStyle を上書きすると、Text のデフォルトスタイルが変わります。 CompositionLocalProvider(LocalTextStyle provides MaterialTheme.typography.h3) { Text("Hello") // h3 }

Surface では LocalContentColor を contentColor で上書きしています。
contentColor はデフォルトでは color (背景色) から導出 (color が primary なら onPrimary, color が secondary から onSecondary など) しています。

これにより Surface の color を指定することで、内部に置かれた LocalContentColor を使っている Text などの色が自動で変わるようになっています。 @Composable fun Surface( ... color: Color = MaterialTheme.colors.surface, contentColor: Color = contentColorFor(color), ... content: @Composable () -> Unit ) { ... CompositionLocalProvider( LocalContentColor provides contentColor, LocalAbsoluteElevation provides absoluteElevation ) { Box( ... ) { content() } } }


2021年4月2日金曜日

Compose メモ : Icon + clickable より IconButton を使う

IconButton だと ripple がいい感じになります。 accesibility の処理も入っているし、ボタンの大きさが 48dp になるような指定も入っています。

上が Icon + clickable で 下が IconButton です。 Column(modifier = Modifier.padding(16.dp)) { Icon( imageVector = Icons.Filled.Favorite, contentDescription = "favorite", modifier = Modifier .clickable { } .padding(12.dp) .size(24.dp) ) Spacer(modifier = Modifier.height(48.dp)) IconButton(onClick = { /*TODO*/ }) { Icon( imageVector = Icons.Filled.Favorite, contentDescription = "favorite", modifier = Modifier .size(24.dp) ) } } IconButton は ripple がいい感じです。まるいし。



Compose メモ : 2つの色を重ねた色を作る

fun Color.compositeOver(background: Color): Color を使う。

例えば #232323 のグレーに透明度15%の白を重ねたときの色(不透明のグレー)を作りたい場合は、このようになる。 val gray = Color(0xFF232323) val white150 = Color.White.copy(alpha = 0.15f) val compositeColor = white150.compositeOver(gray) Card の背景は不透明じゃないと影が変になる。デフォルトでは Card の backgroundColor は MaterialTheme の surface なので、MaterialTheme の surface に半透明の色を指定しているときは、こんな感じで compositeOver で合成した色を backgroundColor に指定するとよい。 Card( backgroundColor = MaterialTheme.colors.surface.compositeOver(MaterialTheme.colors.background), ) { }
以下のコードでは、MaterialTheme の surface に透明度15%の白を指定しているので、左では影が変になっている。右は compositeOver を使って作った不透明の色(ベースの色が不透明のグレーだから不透明になる)を backgroundColor に指定しているので、意図通りの表示になっている。 MaterialTheme( colors = lightColors( background = Color(0xFF232323), surface = Color.White.copy(alpha = 0.15f) ) ) { Surface(color = MaterialTheme.colors.background) { Row( modifier = Modifier .padding(top = 8.dp) .fillMaxSize() ) { Card( modifier = Modifier .padding(start = 8.dp, bottom = 8.dp) .size(136.dp) ) { // 左のカード } Card( backgroundColor = MaterialTheme.colors.surface.compositeOver(MaterialTheme.colors.background), modifier = Modifier .padding(start = 8.dp, bottom = 8.dp) .size(136.dp) ) { // 右のカード } } } }



Compose メモ : Card を使う時は bottom に padding を入れないと影が切れる

Card の bottom に padding を入れていないとこのように影が切れてしまう。 Row( modifier = Modifier .horizontalScroll(rememberScrollState()) .padding(top = 16.dp) ) { repeat(10) { Card( modifier = Modifier .padding( start = 8.dp, // bottom padding がない ) .size(136.dp) ) { } } }


このように bottom に padding を入れると切れない。
(top はこのコードのように padding を入れていなくても影が出る) Row( modifier = Modifier .horizontalScroll(rememberScrollState()) .padding(top = 16.dp) ) { repeat(10) { Card( modifier = Modifier .padding( start = 8.dp, bottom = 8.dp, // 追加 ) .size(136.dp) ) { } } }



Compose メモ : baseline を基準とした余白

Modifier .paddingFromBaseline(top = 24.dp, bottom = 16.dp)