CompositionLocal でローカルにスコープ設定されたデータ

CompositionLocal は、Composition を通じてデータを暗黙的に伝えるためのツールです。このページでは、CompositionLocal の詳細、独自の CompositionLocal を作成する方法、CompositionLocal がユースケースに適した解決手段かどうかを判断する方法について説明します。

CompositionLocal のご紹介

通常、Compose では、データは UI ツリーを通じて各コンポーズ可能な関数にパラメータとして 流れ落ちます。こうすると、コンポーザブルの依存関係が明示的になります。しかし、色や型スタイルなど、幅広く頻繁に使用されるデータの場合は、大変扱いにくいものになります。次の例をご覧ください。

@Composable
fun MyApp() {
    // Theme information tends to be defined near the root of the application
    val colors = colors()
}

// Some composable deep in the hierarchy
@Composable
fun SomeTextLabel(labelText: String) {
    Text(
        text = labelText,
        color = colors.onPrimary // ← need to access colors here
    )
}

ほとんどのコンポーザブルに明示的なパラメータ依存関係として色を渡す必要がなくなるよう、Compose には CompositionLocal が用意されています。これを使用して、ツリーをスコープとする名前付きオブジェクトを作成し、データを UI ツリーに流し込むための暗黙の方法としてそれを使用できます。

CompositionLocal 要素は通常、UI ツリーの特定のノードで値が設定されます。その値は、コンポーズ可能な関数のパラメータとして CompositionLocal を宣言しなくても、コンポーズ可能な子孫で使用できます。

CompositionLocal は、マテリアル テーマが内部で使用するものです。MaterialTheme は、3 つの CompositionLocal インスタンス(colorSchemetypographyshapes)を提供して、後から Composition の子孫部分で取得できるようにするオブジェクトです。具体的には、MaterialTheme colorSchemeshapestypography 属性からアクセスできる、LocalColorSchemeLocalShapesLocalTypography プロパティです。

@Composable
fun MyApp() {
    // Provides a Theme whose values are propagated down its `content`
    MaterialTheme {
        // New values for colorScheme, typography, and shapes are available
        // in MaterialTheme's content lambda.

        // ... content here ...
    }
}

// Some composable deep in the hierarchy of MaterialTheme
@Composable
fun SomeTextLabel(labelText: String) {
    Text(
        text = labelText,
        // `primary` is obtained from MaterialTheme's
        // LocalColors CompositionLocal
        color = MaterialTheme.colorScheme.primary
    )
}

CompositionLocal インスタンスのスコープは Composition の一部であるため、ツリーの各レベルで異なる値を指定できます。CompositionLocalcurrent 値は、Composition の該当部分の祖先が設定する最も近い値に対応します。

CompositionLocal に新しい値を設定するには、CompositionLocalProvider と、CompositionLocal キーを value に関連付ける provides 中置関数を使用します。CompositionLocalProvidercontent ラムダは、CompositionLocalcurrent プロパティにアクセスすると、設定された値を取得します。新しい値が設定されると、Compose が CompositionLocal を読み取る Composition の一部を再コンポーズします。

たとえば、LocalContentColor CompositionLocal には、テキストやアイコンに使用される推奨のコンテンツ色が含まれています。これにより、現在の背景色と対照的な色になります。次の例では、CompositionLocalProvider を使用して、Composition の各部分で異なる値を設定しています。

@Composable
fun CompositionLocalExample() {
    MaterialTheme {
        // Surface provides contentColorFor(MaterialTheme.colorScheme.surface) by default
        // This is to automatically make text and other content contrast to the background
        // correctly.
        Surface {
            Column {
                Text("Uses Surface's provided content color")
                CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.primary) {
                    Text("Primary color provided by LocalContentColor")
                    Text("This Text also uses primary as textColor")
                    CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.error) {
                        DescendantExample()
                    }
                }
            }
        }
    }
}

@Composable
fun DescendantExample() {
    // CompositionLocalProviders also work across composable functions
    Text("This Text uses the error color now")
}

図 1. CompositionLocalExample コンポーザブルのプレビュー。

最後の例では、CompositionLocal インスタンスを Material コンポーザブルが内部で使用していました。CompositionLocal の現在の値にアクセスするには、その current プロパティを使用します。次の例では、Android アプリで一般的に使用されている LocalContext CompositionLocal の現在の Context 値を使ってテキストの書式設定を行っています。

@Composable
fun FruitText(fruitSize: Int) {
    // Get `resources` from the current value of LocalContext
    val resources = LocalContext.current.resources
    val fruitText = remember(resources, fruitSize) {
        resources.getQuantityString(R.plurals.fruit_title, fruitSize)
    }
    Text(text = fruitText)
}

独自の CompositionLocal を作成する

CompositionLocal は、Composition を通じてデータを暗黙的に伝えるためのツールです。

パラメータが横断的であり、実装の中間レイヤがその存在を意識すべきでない場合も、CompositionLocal を活用できます。中間レイヤに意識させると、コンポーザブルの有用性が限定的になるためです。たとえば、Android 権限のクエリには、内部で CompositionLocal が使用されます。メディア ピッカー コンポーザブルは、権限で保護されたデバイス上のコンテンツにアクセスするための新しい機能を追加できます。このとき、API を変更して、メディア ピッカーの呼び出し元に、環境から使用される追加されたコンテキストを認識させる必要はありません。

ただし、CompositionLocal が常に最適な解決手段だとは限りません。CompositionLocal の乱用はおすすめしません。次のようなデメリットがあるためです。

CompositionLocal を使用すると、コンポーザブルの動作を理解することが難しくなります。暗黙的な依存関係ができるため、それを使用するコンポーザブルの呼び出し元で、すべての CompositionLocal の値が満たされていることを確認する必要があります。

さらに、この依存関係は Composition のどの部分でも変更される可能性があるため、この依存関係の信頼できる明確な情報源がなくなります。したがって、問題が起きたときにアプリをデバッグするのが難しくなります。Composition をたどって current 値が設定された場所を確認する必要があるためです。IDE の Find usagesCompose Layout Inspector などのツールを使用すれば、この問題を軽減するのに十分な情報を得ることができます。

CompositionLocal を使用するかどうかを判断する

CompositionLocal がユースケースに適した解決手段になる条件には、次のものがあります。

CompositionLocal に適切なデフォルト値が設定されている。デフォルト値が存在しない場合、デベロッパーが CompositionLocal の値が設定されていない状況に陥ることが極めて困難になるよう保証する必要があります。デフォルト値を指定しない場合に、テストの作成時に問題やフラストレーションが発生したり、その CompositionLocal を使用するコンポーザブルのプレビューに、常にそれを明示的に指定することが必要になったりします。

スコープがツリーやサブ階層だとは言えないコンセプトには、CompositionLocal を使用しないでくださいCompositionLocal は、少数のインスタンスではなく、どの子孫にも使用される可能性がある場合に意味があります。

ユースケースがこういった要件を満たしていない場合は、CompositionLocal を作成する前に、考慮すべき代替手段を確認してください。

不適切な利用の例として、特定の画面の ViewModel を保持する CompositionLocal を作成して、その画面のすべてのコンポーザブルが ViewModel への参照を取得してロジックを実行できるようにするというものがあります。特定の UI ツリーにあるすべてのコンポーザブルが ViewModel を認識する必要はないので、この方法は適切ではありません。コンポーザブルには、状態が下に流れ、イベントが登ってくるというパターンに沿って、必要な情報のみを渡すことをおすすめします。このやり方ならば、コンポーザブルは再利用しやすく、テストも容易になります。

CompositionLocal の作成

CompositionLocal を作成するための API は次の 2 つです。

  • compositionLocalOf: 再コンポーズ中に提供された値を変更すると、current 値を読み取るコンテンツのみが無効になります。

  • staticCompositionLocalOf: compositionLocalOf とは異なり、staticCompositionLocalOf の読み取りを Compose が追跡しません。この値を変更すると、Composition で current 値が読み取られた場所だけではなく、CompositionLocal が設定された場所で、content ラムダ全体が再コンポーズされます。

CompositionLocal に設定された値が変わる可能性が非常に低いか、まったく変更されない場合は、staticCompositionLocalOf を使用してパフォーマンスを向上させます。

たとえば、アプリの設計システムは、UI コンポーネントにシャドウを使ってコンポーザブルにエレベーションを設定しているという点で独特です。アプリのエレベーションが UI ツリー全体に伝わる必要があるため、CompositionLocal を使用しています。CompositionLocal 値はシステムテーマに基づいて条件付きで導出されるため、次のように compositionLocalOf API を使用します。

// LocalElevations.kt file

data class Elevations(val card: Dp = 0.dp, val default: Dp = 0.dp)

// Define a CompositionLocal global object with a default
// This instance can be accessed by all composables in the app
val LocalElevations = compositionLocalOf { Elevations() }

CompositionLocal に値を設定する

CompositionLocalProvider コンポーザブルは、特定の階層の CompositionLocal インスタンスに値をバインドしますCompositionLocal に新しい値を設定するには、次のように、CompositionLocal キーを value に関連付ける provides 中置関数を使用します。

// MyActivity.kt file

class MyActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        setContent {
            // Calculate elevations based on the system theme
            val elevations = if (isSystemInDarkTheme()) {
                Elevations(card = 1.dp, default = 1.dp)
            } else {
                Elevations(card = 0.dp, default = 0.dp)
            }

            // Bind elevation as the value for LocalElevations
            CompositionLocalProvider(LocalElevations provides elevations) {
                // ... Content goes here ...
                // This part of Composition will see the `elevations` instance
                // when accessing LocalElevations.current
            }
        }
    }
}

CompositionLocal の使用

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

@Composable
fun SomeComposable() {
    // Access the globally defined LocalElevations variable to get the
    // current Elevations in this part of the Composition
    MyCard(elevation = LocalElevations.current.card) {
        // Content
    }
}

考慮すべき代替手段

CompositionLocal は、ユースケースによっては過剰な解決手段となる場合があります。ユースケースが、CompositionLocal を使用すべきかどうかの判断で挙げた基準を満たしていない場合は、別の解決手段が適していると考えられます。

明示的なパラメータを渡す

コンポーザブルの依存関係は明確にすることが推奨されます。コンポーザブルは、必要なものにのみ渡すことをおすすめします。コンポーザブルの分離と再利用を促進するには、各コンポーザブルに保持する情報をできるだけ少なくする必要があります。

@Composable
fun MyComposable(myViewModel: MyViewModel = viewModel()) {
    // ...
    MyDescendant(myViewModel.data)
}

// Don't pass the whole object! Just what the descendant needs.
// Also, don't  pass the ViewModel as an implicit dependency using
// a CompositionLocal.
@Composable
fun MyDescendant(myViewModel: MyViewModel) { /* ... */ }

// Pass only what the descendant needs
@Composable
fun MyDescendant(data: DataToDisplay) {
    // Display data
}

制御の反転

コンポーザブルに不要な依存関係を渡さないようにするもう 1 つの方法は、制御の反転を使用することです。なんらかのロジックを実行するために子孫が依存関係を受け取るのではなく、親がそれを行います。

以下の例では、子孫がデータを読み込むためのリクエストをトリガーする必要があります。

@Composable
fun MyComposable(myViewModel: MyViewModel = viewModel()) {
    // ...
    MyDescendant(myViewModel)
}

@Composable
fun MyDescendant(myViewModel: MyViewModel) {
    Button(onClick = { myViewModel.loadData() }) {
        Text("Load data")
    }
}

状況によって異なりますが、MyDescendant の役割が増えます。また、MyViewModel を依存関係として渡すと、それらが結びつくため MyDescendant の再利用性が低下します。依存関係を子孫に渡さず、ロジックの実行を祖先に委ねる制御の反転の原則を利用することを検討してください。

@Composable
fun MyComposable(myViewModel: MyViewModel = viewModel()) {
    // ...
    ReusableLoadDataButton(
        onLoadClick = {
            myViewModel.loadData()
        }
    )
}

@Composable
fun ReusableLoadDataButton(onLoadClick: () -> Unit) {
    Button(onClick = onLoadClick) {
        Text("Load data")
    }
}

この方法は、子を直接の祖先から切り離すため、ユースケースによっては適している場合があります。祖先コンポーザブルは、下位レベルのコンポーザブルが柔軟になる代わりに、複雑になる傾向があります。

同様に、@Composable コンテンツ ラムダを同じように使用して、同じメリットを得ることもできます

@Composable
fun MyComposable(myViewModel: MyViewModel = viewModel()) {
    // ...
    ReusablePartOfTheScreen(
        content = {
            Button(
                onClick = {
                    myViewModel.loadData()
                }
            ) {
                Text("Confirm")
            }
        }
    )
}

@Composable
fun ReusablePartOfTheScreen(content: @Composable () -> Unit) {
    Column {
        // ...
        content()
    }
}