Author: Petterp
introduction
The official version of JetPack Compose has been released for several months. During this period, in addition to business-related requirements, I also started the landing experiment of Compose in the actual project, because once I want to access the current project, the problems encountered are actually far greater than those required to create a new project.
What this article wants to solve is that there are too few default Material theme colors in Compose, how to configure your own business color board, or how to customize your own color system, and analyze the relevant implementation methods and principles from point to depth.
problem
Before we start, let's take a look at the colors provided by the default Material theme for creating a Compose project:
For an ordinary application, the default color has basically met the development and use, and the basic theme color matching is enough. But now a problem arises. What if I have other theme colors?
Traditional practice
In the traditional View system, we usually define the color in the color.xml file, which can be read directly when using, getColor(R.xx). Everyone is familiar with this. What about in Compose?
Compose
In Compose, google uniformly puts color values in color.kt under theme, which is actually a global static variable. At first glance, it seems that there is no problem. Where are my business colors? Can't they all be globally exposed?
But smart, you must know that it's OK for me to put it in color.xml according to the old method. It's not impossible, but the attendant problems are as follows:
- How to unify the colors when switching themes?
- In Google's simple, no configuration is often written in color.xml, that is, Google itself does not recommend using it in compose
So what should I do? I'll go to google's simple and see how they solve it:
Simple is really simple 😑 , Google completely follows the Material standard, that is, there will be no other non theme color matching. In fact, what should we do when we develop it. Then I searched some open source projects written by the bigwigs on github and found that they are also implemented according to Material, but it is obvious that this is not in line with reality (national conditions). 🙃
Solution ideas
Write as you like (not recommended)
There is no standard to describe it. Roll up your sleeves directly, roll up the code, think with your left brain, knock with your right hand, and pick it up ⌨️ Is to do, also refers to the hard work in the new era 👷🏻♂️
Since the official didn't write how to solve it, find a way to solve it yourself.
In compose, MutableState is used for data change monitoring, so I can customize a single instance holding class, hold the existing theme configuration, then define a business color class and define the corresponding theme color class object, and finally judge what color board to use according to the theme configuration of the current single instance, When changing a business topic, you only need to change the configuration field of this singleton topic. At the thought of being so simple, I'm really smart. I'll do whatever I say 👨🔧
Create topic enumerationenum class ColorPallet { // Two colors are given by default, and multiple colors can be defined according to requirements DARK, LIGHT }Add theme configuration example
object ColorsManager { /** Use a variable to maintain the current topic */ var pallet by mutableStateOf(ColorPallet.LIGHT) }Add color board
/** Shared color */ val Purple200 = Color(0xFFBB86FC) val Purple500 = Color(0xFF6200EE) val Purple700 = Color(0xFF3700B3) val Teal200 = Color(0xFF03DAC5) /** Business color configuration: if you need to add other theme business colors, you can directly define the following objects. If a color is shared, the default value will be added */ open class CkColor(val homeBackColor: Color, val homeTitleTvColor: Color = Color.Gray) /** Business color template object defined in advance */ private val CkDarkColor = CkColor( homeBackColor = Color.Black ) private val CkLightColor = CkColor( homeBackColor = Color.White ) /** Default color configuration, that is, the default configuration color of Md */ private val DarkColorPalette = darkColors( primary = Purple200, primaryVariant = Purple700, secondary = Teal200 ) private val LightColorPalette = lightColors( primary = Purple500, primaryVariant = Purple700, secondary = Teal200 )Add unified call entry
For practical use, we add an extension function of MaterialTheme.ckColor to use our custom color group:
/** Add expansion */ val MaterialTheme.ckColor: CkColor get() = when (ColorsManager.pallet) { ColorPallet.DARK -> CkDarkColor ColorPallet.LIGHT -> CkLightColor }The final theme is as follows
@Composable fun CkTheme( pallet: ColorPallet = ColorsManager.pallet, content: @Composable() () -> Unit ) { val colors = when (pallet) { ColorPallet.DARK -> DarkColorPalette ColorPallet.LIGHT -> LightColorPalette } MaterialTheme( colors = colors, typography = Typography, shapes = Shapes, content = content ) }design sketch
The effect is also good. It's simple and rough. There's no problem [Looking]. Is there any other way? I still don't believe the official didn't write it. Maybe I was negligent.
Custom color system (official)
Just as I was looking through the official documents, I suddenly saw such small words, which realized Custom color system.
I was so blind that I didn't see this line of words. With an official example, I hurried to learn (copy) the code.
Add color templateenum class StylePallet { // Two colors are given by default, and multiple colors can be defined according to requirements DARK, LIGHT } // For example, the correct way is to put it under color.kt val Blue50 = Color(0xFFE3F2FD) val Blue200 = Color(0xFF90CAF9) val A900 = Color(0xFF0D47A1) /** * The color set of the actual theme. All colors need to be added to it, and the corresponding subclasses are used to override the colors. * For each change, you need to configure the color in [CkColors] below and synchronize [CkDarkColor] and [CkLightColor] * */ @Stable class CkColors( homeBackColor: Color, homeTitleTvColor: Color ) { var homeBackColor by mutableStateOf(homeBackColor) private set var homeTitleTvColor by mutableStateOf(homeTitleTvColor) private set fun update(colors: CkColors) { this.homeBackColor = colors.homeBackColor this.homeTitleTvColor = colors.homeTitleTvColor } fun copy() = CkColors(homeBackColor, homeTitleTvColor) } /** Pre defined color template object */ private val CkDarkColors = CkColors( homeBackColor = A900, homeTitleTvColor = Blue50, ) private val CkLightColors = CkColors( homeBackColor = Blue200, homeTitleTvColor = Color.White, )Add xxLocalProvider
@Composable fun ProvideLcColors(colors: CkColors, content: @Composable () -> Unit) { val colorPalette = remember { colors.copy() } colorPalette.update(colors) CompositionLocalProvider(LocalLcColors provides colorPalette, content = content) }Add LocalLcColors static variable
// Create a static CompositionLocal. Usually, the theme will not change very frequently private val LocalLcColors = staticCompositionLocalOf { CkLightColors }Add theme configuration example
/* Configure palette extension properties for the current theme */ private val StylePallet.colors: Pair<Colors, CkColors> get() = when (this) { StylePallet.DARK -> DarkColorPalette to CkDarkColors StylePallet.LIGHT -> LightColorPalette to CkLightColors } /** CkX-Compose Subject Manager */ object CkXTheme { /** Take out the corresponding Local from CompositionLocal */ val colors: CkColors @Composable get() = LocalLcColors.current /** Use a state to maintain the current topic configuration. The writing here depends on the specific business, If you use the default configuration of dark mode, you do not need this variable, that is, the app only supports dark and bright colors, Then you only need to read the system configuration every time. However, compose itself can quickly switch topics, So maintaining a variable is definitely unavoidable */ var pallet by mutableStateOf(StylePallet.LIGHT) }Final subject code
@Composable fun CkXTheme( pallet: StylePallet = CkXTheme.pallet, content: @Composable () -> Unit ) { val (colorPalette, lcColors) = pallet.colors ProvideLcColors(colors = lcColors) { MaterialTheme( colors = colorPalette, typography = Typography, shapes = Shapes, content = content ) } }analysis
The final effect is consistent with the above, so we won't go into details. Let's mainly analyze why Google wrote this:
We can see that in the above example, the CompositionLocalProvider is mainly used to save the current theme configuration, and the CompositionLocalProvider inherits from CompositionLocal. For example, Shapes and typography in our commonly used MaterialTheme theme are managed by this.
@ Stable is added to the class CkColors, which means that this class is a Stable class for Compose, that is, each change will not cause reorganization. The internal color field is wrapped with mustbaleStateOf to trigger reorganization when the color changes. The internal methods update() and copy() are also added to facilitate management and one-way data changes.
In fact, if we go to see the Colors class. You will find that CkColors in the above example is designed exactly the same way.
Therefore, customizing theme Colors in Compose is actually that we have written our own color matching based on Colors. 😂
In that case, why don't we directly inherit Colors to add color? When I use it, I can force it, so I don't have to create any CompositionLocal by myself?
In fact, it is easy to understand, because copy() and update() in Colors cannot be rewritten, that is, they do not add open, and their internal variables use the internal modification set method. The more important reason is that this is not in line with Md's design, so this is why we need to customize our own color system, or even completely customize our own theme system. The premise is that if you think it's ugly to wrap a layer of MaterialTheme theme in the custom theme, of course, you also need to consider how to solve other incidental problems.
reflect
What we said above is all about use, so have you thought about it? Why does the official custom design system use CompositionLocal?
Some new students may not have used this. In order to better understand it, first of all, let's find out what CompositionLocal does. Let's not talk about its popular concept. We can explain it simply with a small example.
deconstruction
In common development scenarios, we often pass a parameter to other methods, which we call display passing.
Switch the scene. In Compose, we often pass parameters to composable functions, so this method is academically called by Google: data flows down the entire interface tree in the form of parameters and is passed to each composable function, as shown below:
@Composable fun A(message: String) { Column() { B(message) } } @Composable fun B(message: String) { Text(text = message) C(message) } @Composable fun C(message: String) { Text(text = message) }
In the above example, there are three composable functions, in which A needs to receive A message string and pass it to B, and B needs to pass it to C at the same time, which is similar to the infinite doll. At this time, we may feel OK, but what if there are n layers of this doll, but what if there is more than one data? This can be very troublesome.
So is there any other way to solve it? In Compose, the official standard answer is composition local:
That is, CompositionLocal is used to complete the data sharing in the composable tree. CompositionLocal has a hierarchy. It can be limited to a sub tree with a composable as the root node and passed down by default. At the same time, a composable in the sub tree can also overwrite the CompositionLocal, Then the new value will continue to be passed down in the composable.
Composable can combine functions. It is simply understood that @ composable annotation is used.
practice
As shown below, we use CompositionLocal to transform the above code:
val MessageContent = compositionLocalOf { "simple" } @Composable fun A(message: String) { Column() { // provides is equivalent to writing data CompositionLocalProvider(MessageContent provides message) { B() } } } @Composable fun B() { Text(text = MessageContent.current) CompositionLocalProvider(MessageContent provides "Make temporary changes to values") { C() } } @Composable fun C() { Text(text = MessageContent.current) }
Firstly, A CompositionLocal named MessageContent is defined, and its default value is "simple". Method A receives A message field and writes it to MessageContent. Then, in B, we can obtain the data written to CompositionLocal in method A, without adding fields in the method parameters.
Similarly, method B can also change this CompositionLocal, so that C will get another value.
And when we use CompositionLocal.current to obtain data, this current will return the value closest to the current component, so we can also use it to do some basic implementation of implicit separation.
extend
Accordingly, let's talk about how to create CompositionLocal:
- compositionLocalOf : changing the supplied value during reorganization will only cause it to be read current The content of the value is invalid.
- staticCompositionLocalOf : unlike compositionLocalOf, Compose does not track reads of static compositionLocalOf. Changing this value will cause the entire contentlambda that provides CompositionLocal to be reorganized, not just where the current value is read in the composition.
summary
We have learned about the function of CompositionLocal above. Imagine that if we don't use it and let us implement a color system ourselves, we may fall into the arbitrary writing method at the beginning.
First of all, can that writing be used? Of course, it can be used, but there will be many problems in practice. For example, the change of theme will lead to and do not comply with the design of Compose, and if some of our businesses may maintain a theme color under certain circumstances, how can we solve it at this time?
If it is method 1, it may enter the hard coding stage, that is, using complex business logic to complete it; But what if you use CompositionLocal? Will this problem still exist? You only need to write a new color configuration, and then write the current theme configuration again after the logic is completed. Will there be complex logic entanglement?
This is why Google chooses to use CompositionLocal to customize the color system and the configuration of the whole theme system that can be manipulated by users, that is, implicitly, for users, it can be done without perception.
depth analysis
After reading the wonderful functions and actual scenarios of CompositionLocal, we might as well think about how CompositionLocal is implemented. It is called knowing what it is and why. Without going deep into the source code, it is often difficult to understand the specific implementation, so the parsing of this part may be slightly complex. If you feel obscure, you might as well take a look at Android developers first- Thoroughly explain the implementation principle of Jetpack Compose , it may be simpler to understand some of the following terms, because this article does not talk about the implementation principle of compose, so please refer to the link above.
CompositionLocal
To get back to business, let's take a look at the source code. The corresponding comments and codes are as follows:
sealed class CompositionLocal<T> constructor(defaultFactory: () -> T) { // Default value internal val defaultValueHolder = LazyValueHolder(defaultFactory) // Write latest data @Composable internal abstract fun provided(value: T): State<T> // Returns the value provided by the nearest CompositionLocalProvider @OptIn(InternalComposeApi::class) inline val current: T @ReadOnlyComposable @Composable //Go straight to the code here get() = currentComposer.consume(this) }
We know the current used to obtain data, so we can directly follow here.
currentComposer.consume()@InternalComposeApi override fun <T> consume(key: CompositionLocal<T>): T = // currentCompositionLocalScope() gets the current CompositionLocal scope map provided by the parent composable item resolveCompositionLocal(key, currentCompositionLocalScope())
This code is mainly used to parse local composable items to obtain data. Let's first look at the currentCompositionLocalScope()
currentCompositionLocalScope()private fun currentCompositionLocalScope(): CompositionLocalMap { //If a composable item is currently being inserted and has a data provider if (inserting && hasProvider) { // Get the group closest to the current composable from the insert table (it can be understood as directly getting the currentGroup closest to the index) var current = writer.parent // If this group is not empty while (current > 0) { // Take out the key of this group in the slot table and compare it with the current composable key if (writer.groupKey(current) == compositionLocalMapKey && writer.groupObjectKey(current) == compositionLocalMap ) { // Returns the CompositionLocalMap of the specified location return writer.groupAux(current) as CompositionLocalMap } // In an endless loop, keep looking up. If there is no one in the current group, continue to look up until you find one that can match the current group current = writer.parent(current) } } //If the array in the current composable slotTable is not empty if (slotTable.groupsSize > 0) { //From the current slot, retrieve the current graoup closest to composable var current = reader.parent // The default value is - 1. If it exists, it means that there are composable items while (current > 0) { if (reader.groupKey(current) == compositionLocalMapKey && reader.groupObjectKey(current) == compositionLocalMap ) { //Get the current CompositionLocalMap from the providerUpdates array. See - startProviders for insertion return providerUpdates[current] ?: reader.groupAux(current) as CompositionLocalMap } current = reader.parent(current) } } //If not found, the parent composable Provider is returned return parentProvider }
Used to obtain the CompositionLocalMap closest to the current composable.
resolveCompositionLocal()private fun <T> resolveCompositionLocal( key: CompositionLocal<T>, scope: CompositionLocalMap ): T = if (scope.contains(key)) { // If the current parent CompositionLocalMap contains the current local, it is retrieved directly from the map scope.getValueOf(key) } else { // Otherwise, it means that it is currently the top level and has no parent local. The default value is used directly key.defaultValueHolder.value }
Use the current CompositionLocal as the key, and then go to the nearest CompositionLocalMap to find the corresponding value. If there is a direct return, otherwise, use the default value of CompositionLocal.
summaryTherefore, when we use CompositionLocal.current to obtain data, we actually obtain the parent CompositionLocalMap through currentCompositionLocalScope(). Note: why is it a map here? Because all compositionlocals under the current parent composable function are obtained here, the current CompositionLocal needs to be passed in the parameter of the consume method in the source code to judge whether the local we want to obtain currently exists in it. If so, it will be obtained directly, otherwise the default value will be used.
That's the problem. How can our CompositionLocal be saved by the composable tree? With this question, we continue to delve into it.
CompositionLocalProvider
To know how CompositionLocal is saved by the composable tree, you must start from the following.
fun CompositionLocalProvider(vararg values: ProvidedValue<*>, content: @Composable () -> Unit) { currentComposer.startProviders(values) content() currentComposer.endProviders() }
What is the current composer here? And why start first and then end?
Let's click in the currentComposer to have a look.
/** * Composer is the interface that is targeted by the Compose Kotlin compiler plugin and used by * code generation helpers. It is highly recommended that direct calls these be avoided as the * runtime assumes that the calls are generated by the compiler and contain only a minimum amount * of state validation. */ interface Composer 👇 internal class ComposerImpl : Composer
You can see that in the definition of Composer, Composer is provided for the Compose kotlin compiler plug-in. google strongly does not recommend that we call it manually, that is, the start and end here are actually two tags, and the compiler will call it by itself, or for the convenience of the compiler. Then let's look at startProviders()
startProviders@InternalComposeApi override fun startProviders(values: Array<out ProvidedValue<*>>) { //Obtain the compositionlocal map under the current Composable val parentScope = currentCompositionLocalScope() ... val currentProviders = invokeComposableForResult(this) { compositionLocalMapOf(values, parentScope) } val providers: CompositionLocalMap val invalid: Boolean //true if the composable item is being inserted into the tree or it is being called for the first time if (inserting) { //Update the current CompositionLocalMap providers = updateProviderMapGroup(parentScope, currentProviders) invalid = false hasProvider = true } else { //If the current Composable items cannot be skipped (i.e. changed) or the providers are different, update the current Composable group if (!skipping || oldValues != currentProviders) { providers = updateProviderMapGroup(parentScope, currentProviders) invalid = providers != oldScope } else { //Otherwise, skip the current update skipGroup() providers = oldScope invalid = false } } //If this reorganization is invalid and no insertion is in progress, update the CompositionLocalMap of the current group if (invalid && !inserting) { providerUpdates[reader.currentGroup] = providers } // push the current operation to the stack and pop it up later providersInvalidStack.push(providersInvalid.asInt()) ... // The providers data is written to the group and eventually to the slottable slots array start(compositionLocalMapKey, compositionLocalMap, false, providers) }
This method is used to start the data provider. If you read the design principle of compose, you will know that it is actually equivalent to a start tag of group. Its internal content is to first obtain the CompositionLocalMap closest to the current composable, and then use compositionLocalMapOf() Update the currently passed value to the corresponding composition local map and return it, and then update the map to the current group.
Accordingly, as we said, this is a start tag, and naturally there is also an end tag, that is, end. In the above source code, we can know that it is endProviders():
endProvidersoverride fun endProviders() { endGroup() endGroup() // Stack the current operation providersInvalid = providersInvalidStack.pop().asBool() }
Its function is to end the provider's call. As for why end is called twice, it should be to prevent inconsistency caused by writing. If there are leaders who have different understanding, share it in the comment area.
summary
When we use CompositionLocalProvider to bind data to CompositionLocal, it will save it to the CompositionLocalMap closest to the current composable. When we want to use it later, when we use CompositionLocal.current to read data, it will find the corresponding CompositionLocalMap, And take our CompositionLocal as the key. If it exists, it will be returned. Otherwise, it will be returned by default.
Broken thoughts
In fact, this article is not a particularly difficult problem, but it is a problem that composition will encounter in practice. It is very simple to solve this problem, but it is more important to understand the design behind it. I hope that through this article, we can better understand the actual scene and design concept of CompositionLocal. Of course, to understand the specific source code, you still need to understand the basic design of Compose. For this, please refer to the Android Developer link posted at the bottom of the article. In the follow-up, I will continue to follow up the problems that need to be solved in the practical application of Compose and analyze the ideas. If there are errors, I hope I will give you my advice.
If this article is helpful to you, welcome to praise and support. Come on 😃