iT邦幫忙

2021 iThome 鐵人賽

DAY 23
0
Mobile Development

認真學 Compose - 對 Jetpack Compose 的問題與探索系列 第 23

D23/ MaterialTheme 怎麼運作的? - CompositionLocal

今天大概會聊到的範圍

  • CompositionLocal
  • CompositionLocalProvider

在上一篇研究  MaterialTheme 的時候,我們知道要使用 theme 內的顏色時,只需要使用 MaterialTheme.colors.xxx 就好了。但這個魔法是怎麼發生的呢?

object MaterialTheme {
    val colors: Colors
        @Composable
        @ReadOnlyComposable
        get() = LocalColors.current

    val typography: Typography
        @Composable
        @ReadOnlyComposable
        get() = LocalTypography.current

    val shapes: Shapes
        @Composable
        @ReadOnlyComposable
        get() = LocalShapes.current
}

可以看到,在 MaterialTheme 中,其實個 property 都各只是一個 getter function,其實他們分別是 LocalColorsLocalShapesLocalTypography

咦?這個命名規則好像在哪裡看過?

val context = LocalContext.current
val owner = LocalLifecycleOwner.current

仔細一看,發現這些 current 都為了實作同一個 property -- CompositionLocal.current

什麼是 CompositionLocal?

在 Compose 的結構中,Composable 要將某個資料傳遞給 child composable 時,通常會透過 state 參數往下傳遞。但是有時,某個比較裡層的 composable 需要某個資料,但不想依賴中間每一層的 composable 都帶著這個資料時,可以怎麼處理呢?

CompositionLocal 就是一個 "隱式的" 資料存放方式,在 CompositionLocal 所控制的 Scope 以下的樹狀結構,都可以取得同一組 CompositionLocal 與其中的資料。

@Composable
fun Screen() {
    MaterialTheme {
        // ... 這裡可能有很多 composable 
        SomeComp()
    } 
}


@Composable
fun SomeComp(){
    SomeInnerComp()
}


@Composable
fun SomeInnerComp() {
    val color = MaterialTheme.color.primaryColor // 即便傳了好幾層還是可以取得 color
}

讓我們再次看看 MaterialTheme 的 source code

@Composable
fun MaterialTheme(
    colors: Colors = MaterialTheme.colors,
    typography: Typography = MaterialTheme.typography,
    shapes: Shapes = MaterialTheme.shapes,
    content: @Composable () -> Unit
) {
    val rememberedColors = remember {
        // Explicitly creating a new object here so we don't mutate the initial [colors]
        // provided, and overwrite the values set in it.
        colors.copy()
    }.apply { updateColorsFrom(colors) }
    val rippleIndication = rememberRipple()
    val selectionColors = rememberTextSelectionColors(rememberedColors)
    CompositionLocalProvider(        // <--- 1. 
        LocalColors provides rememberedColors,    // <--- 2.
        LocalContentAlpha provides ContentAlpha.high,
        LocalIndication provides rippleIndication,
        LocalRippleTheme provides MaterialRippleTheme,
        LocalShapes provides shapes,
        LocalTextSelectionColors provides selectionColors,
        LocalTypography provides typography
    ) {        // <--- 3. 
        ProvideTextStyle(value = typography.body1, content = content)
    }
}

MaterialTheme 中,建立了一個 CompositionLocalProvider [1]。在建立 CompositionLocalProvider 時,會需要透過 "provides" 將資料設定到 LocalXXX 內 [2]。這些 LocalXXX 各自都是自己一個 ProvidableCompositionLocal (繼承於 CompositionLocal)。ProvidableCompositionLocal 繼承於 CompositionLocal,並且可以透過 provides function 提供資料。最後在提供要包在這個 CompositionLocalProvider 中的 content [3]。

MaterialTheme 中包的 content 是一個 ProvideTextStyleProvideTextStyle 中又有另一個 CompositionLocalProvider

@Composable
fun ProvideTextStyle(value: TextStyle, content: @Composable () -> Unit) {
    val mergedStyle = LocalTextStyle.current.merge(value)
    CompositionLocalProvider(LocalTextStyle provides mergedStyle, content = content)
}

這裡要提到另一個概念,CompositionLocal 是有範圍性的。CompositionLocalProvider 所包裝的 content 與其中所包中的每一層 composable 都可以取得對應的 LocalXXX。

定義一個 LocalXXX (定義一個 CompositionLocal)

建立 CompositionLocal 的方法有兩個:compositionLocalOf()stataicCompositionLocalOf()

val LocalData = compositionLocalOf { 
    // factory fucntion to create initial data
}

產生完 LocalData 後,再建立 CompositionLocalProvider 並提供資料

CompositionLocalProvider(
    LocalData provides <data>
) {
    UIComposable()
}

在 provides 資料時,可以 provide 一般資料之外,也可以提供  state。當 provides 時提供的 state 改變時,就會觸發 recompistion。

  • compositionLocalOf:當資料改變時,有使用到該 LocalXXX 的 Composable 會觸發 recomposition
  • stataicCompositionLocalOf:整個 CompositionLocal 所包覆的 composable 和整個 compose tree 都會被 recompose

覆寫 CompositionLocal.current

CompositionLocalProvider 除了可以將資料放進自定義的 CompositionLocal 之外,也可以用來覆寫上層 CompistionLocalProvider 所提供的參數。

@Composable
fun CompositionLocalExample() {
    MaterialTheme { // MaterialTheme sets ContentAlpha.high as default
        Column {
            Text("Uses MaterialTheme's provided alpha")
            CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) {
                Text("Medium value provided for LocalContentAlpha")
                Text("This Text also uses the medium value")
                CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.disabled) {
                    DescendantExample()
                }
            }
        }
    }
}

compose 官方文件所提供的範例

注意不要濫用

雖然 CompositionLocal 可以很方便的提供資料給好幾層之外的 Composable 資料,但同時也很容易被濫用。因為 CompositionLocal 是隱性的傳遞,無法明確知道資料的設定方與取得方分別在哪。所以在使用 CompositionLocal 時可以停看聽:

  • 準備自訂的 CompositionLocal 是否有預設值?是否一定會有值被寫入?
  • CompositionLocal 是否真的可能被整個 View Tree 中的“任何”一個人用到?

如果不符合,也許可以選用別的傳遞方式。

官方文件有提反面例子:我們是否能用 CompositionLocal 存放 ViewModel

我的第一直覺是可以,因為 ViewModel 的確會影響到整個 tree 的 UI。
但是仔細想想,其實不是每一個按鈕、每一個 Text 都需要知道 ViewModel 與其中的各種資料,所以透過 CompostionLocalViewModel 存放起來並不是一個好方法。

取而代之,可以考慮將資料透過參數傳遞,但提供 default 值:

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

發現 CompositionLocal 其實很常在大大小小的地方出現,只是通常都是別人準備好的 CompostionLocal。實際研究後發現它不僅是一個好用的工具,同時也是一個非常重要且有趣的主題!


Reference:


上一篇
D22/ 怎麼在 Compose 中用 Material Theme? - Theme
下一篇
D24 / 什麼時候我的 Composable function 會重新被呼叫 - recompose
系列文
認真學 Compose - 對 Jetpack Compose 的問題與探索30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言