CompositionLocal을 사용한 로컬 범위 지정 데이터

CompositionLocal은 암시적으로 컴포지션을 통해 데이터를 전달하는 도구입니다. 이 페이지에서는 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은 Material 테마에서 내부적으로 사용하는 것입니다. MaterialTheme은 나중에 컴포지션의 하위 부분에서 가져올 수 있는 세 개의 CompositionLocal 인스턴스(colorScheme, typography, shapes)를 제공하는 객체입니다. 이러한 인스턴스는 구체적으로 LocalColorScheme, LocalShapes, LocalTypography 속성으로, MaterialTheme colorScheme, shapes, typography 속성을 통해 액세스할 수 있습니다.

@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 인스턴스는 컴포지션의 일부로 범위가 지정되므로 트리의 여러 수준에서 다양한 값을 제공할 수 있습니다. CompositionLocalcurrent 값은 컴포지션에서 범위가 지정된 부분의 상위 요소가 제공한 가장 가까운 값에 대응합니다.

새 값을 CompositionLocal에 제공하려면 CompositionLocalProviderCompositionLocal 키를 value에 연결하는 provides 중위 함수를 사용합니다. CompositionLocalProvidercontent 람다는 CompositionLocalcurrent 속성에 액세스할 때 제공된 값을 가져옵니다. 새 값이 제공되면 Compose는 CompositionLocal을 읽는 컴포지션의 부분을 재구성합니다.

예를 들어 LocalContentColor CompositionLocal에는 텍스트와 아이콘에 사용되는 기본 콘텐츠 색상이 포함되어 있어 현재 배경색과 대비되도록 합니다. 다음 예에서 CompositionLocalProvider는 컴포지션의 여러 부분에 다양한 값을 제공하는 데 사용됩니다.

@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 인스턴스는 머티리얼 컴포저블에서 내부적으로 사용되었습니다. 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암시적으로 컴포지션을 통해 데이터를 전달하는 도구입니다.

CompositionLocal 사용을 위한 또 다른 주요 신호는 매개변수가 크로스 커팅이고 구현의 중간 레이어가 그 존재를 인식해서는 안 되는 경우입니다. 이러한 중간 레이어가 인식하도록 하면 컴포저블의 유틸리티가 제한될 수 있기 때문입니다. 예를 들어 Android 권한 쿼리는 내부적으로 CompositionLocal에서 제공됩니다. 미디어 선택 도구 컴포저블은 API를 변경하지 않고 미디어 선택 도구 호출자가 환경에서 사용하는 이러한 추가 컨텍스트를 인식하도록 요구하지 않고도 기기에서 권한으로 보호되는 콘텐츠에 액세스하는 새로운 기능을 추가할 수 있습니다.

그러나 CompositionLocal이 항상 최선의 솔루션은 아닙니다. CompositionLocal과도하게 사용하지 않는 것이 좋습니다. 다음과 같은 단점이 있기 때문입니다.

CompositionLocal은 컴포저블의 동작을 추론하기 어렵게 합니다. 암시적 종속 항목을 만들 때 이를 사용하는 컴포저블의 호출자는 모든 CompositionLocal의 값이 충족되는지 확인해야 합니다.

또한 이 종속 항목은 컴포지션의 모든 부분에서 변경될 수 있으므로 종속 항목에 관한 명확한 정보 소스가 없을 수도 있습니다. 따라서 문제가 발생할 때 앱을 디버깅하는 것이 더 어려울 수 있습니다. 컴포지션을 탐색하여 current 값이 제공된 위치를 확인해야 하기 때문입니다. IDE의 Find usagesCompose 레이아웃 검사기와 같은 도구에서는 이 문제를 완화할 정보를 충분히 제공합니다.

CompositionLocal 사용 여부 결정

특정 조건에서는 사용 사례에 CompositionLocal이 적합한 솔루션이 될 수 있습니다.

CompositionLocal에는 적절한 기본값이 있어야 합니다. 기본값이 없으면 개발자가 CompositionLocal의 값이 제공되지 않는 매우 곤란한 상황에 처할 수 있다는 것을 확실하게 해야 합니다. 기본값을 제공하지 않으면 테스트를 만들거나 CompositionLocal을 사용하는 컴포저블을 미리 볼 때 항상 명시적으로 제공되도록 기본값을 요구해야 하는 문제와 불만이 발생할 수 있습니다.

트리 범위 또는 하위 계층 구조 범위로 간주되지 않는 개념에는 CompositionLocal을 사용하지 않습니다. CompositionLocal은 잠재적으로 일부 하위 요소가 아닌 모든 하위 요소에서 사용할 수 있을 때 적합합니다.

사용 사례가 이러한 요구사항을 충족하지 않는다면 CompositionLocal을 만들기 전에 고려할 대안 섹션을 확인하세요.

좋지 않은 방법의 예는 특정 화면의 ViewModel을 보유하는 CompositionLocal을 만들어 이 화면의 모든 컴포저블이 일부 로직을 실행하는 ViewModel을 참조할 수 있도록 하는 것입니다. 이 방법이 좋지 않은 이유는 특정 UI 트리 아래의 모든 컴포저블이 ViewModel에 관해 알 필요는 없기 때문입니다. 상태는 아래로 흐르고 이벤트는 위로 흐르는 패턴에 따라 필요한 정보만 컴포저블에 전달하는 것이 좋습니다. 이 방법을 통해 컴포저블을 재사용하고 테스트하기가 더 쉬워집니다.

CompositionLocal 만들기

CompositionLocal을 만드는 두 가지 API가 있습니다.

  • compositionLocalOf: 재구성 중에 제공된 값을 변경하면 current 값을 읽는 콘텐츠 무효화됩니다.

  • staticCompositionLocalOf: compositionLocalOf와 달리 staticCompositionLocalOf 읽기는 Compose에서 추적하지 않습니다. 값을 변경하면 컴포지션에서 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.currentCompositionLocal에 값을 제공하는 가장 가까운 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
}

컨트롤 역전

불필요한 종속 항목을 컴포저블에 전달하지 않는 또 다른 방법은 컨트롤 역전을 사용하는 것입니다. 하위 요소가 일부 로직을 실행하는 종속 항목을 가져오지 않고 대신 상위 요소가 실행합니다.

다음 예는 하위 요소가 일부 데이터 로드 요청을 트리거해야 하는 경우를 보여줍니다.

@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()
    }
}