Jetpack Compose Tutorial for Beginners, Modifier

This article is simultaneously published on my WeChat official account. You can follow by scanning the QR code at the bottom of the article or searching for Guo Lin on WeChat. Articles are updated every working day.

Hello everyone, the Jetpack Compose tutorial written for beginners has been updated again.

In the last article, we learned the basic controls and layout of Compose. If you haven’t read the previous article, please refer to the Jetpack Compose tutorial for beginners, basic controls and layout .

In fact, in the last article, there is a knowledge point that has been repeatedly appearing, but I have not explained it, and that is Modifier. The reason why I didn't talk about it is because this thing is so important that it needs to be explained in a separate article.

As long as you use Compose, you must not bypass Modifier. It can even be said that any Composable function should have a Modifier parameter, if not, then it means that there is something wrong with the Composable function.

When I first started learning Compose, I always had doubts about the usage and scenarios of Modifier. The main reason is that, in addition to the Modifier parameter, the Composable function provided by Google generally has many other parameters. Sometimes certain functions are completed through Modifier parameters, and sometimes they need to be completed through other parameters. I have not been able to find a reasonable rule, which has led to a lack of understanding of this area.

This is also one of the purposes of my writing this article. I hope that through this article, I can understand all the knowledge points that I have not been able to figure out before, and share it for your reference.

The role of Modifier

Let’s answer the question just raised at the beginning of this article. When we use Compose to write the interface, what functions should be completed using Modifier parameters, and what functions should be completed using other parameters?

To answer this question, in fact, you only need to figure out what the Modifier can do. Except for what the Modifier can do, the rest should naturally be done with other parameters.

According to the official documents I consulted, Compose has very clear regulations on what Modifier can do. Modifier is mainly responsible for the following four categories of functions:

  • Modify the size, layout, behavior, and style of Compose controls.
  • Adds additional information to Compose controls, such as accessibility labels.
  • Handle user input
  • Add upper-level interactive functions, such as making controls clickable, scrollable, and draggable.

Why can one parameter do so many things? Because Modifier is a very special parameter, it can connect an infinite number of APIs through chain calls, so as to achieve various effects you want.

Next, we will demonstrate the specific usage of Modifier for these 4 categories, so that everyone can have a better grasp of Modifier.

Modify size, layout, behavior and style

In the previous article, we simply used Modifier.fillMaxSize() to make the layout fill the full screen. So next, let's take a look at what else Modifier can do.

First create a new Compose project. If you don’t know how to create a Compose project, please refer to the previous article first.

In the new Compose project, we modify the code of MainActivity as follows:

class MainActivity : ComponentActivity() {
    
    
    override fun onCreate(savedInstanceState: Bundle?) {
    
    
        super.onCreate(savedInstanceState)
        setContent {
    
    
            ComposeTestTheme {
    
    
                // A surface container using the 'background' color from the theme
                Surface(
                    modifier = Modifier.fillMaxSize(),
                    color = MaterialTheme.colors.background
                ) {
    
    
                    IconImage()
                }
            }
        }
    }
}

@Composable
fun IconImage() {
    
    
    Image(
        painter = painterResource(id = R.drawable.icon),
        contentDescription = "Icon Image",
    )
}

An IconImage() function is defined here, and an Image() is placed inside to display a picture. Let's use this simple example to demonstrate.

First run the program directly, you will see the following effect:

insert image description here

The pixels of this picture are 500*500, and the resolution of my mobile phone is obviously larger than this number of pixels, but this picture can fill the full screen horizontally. Therefore, in the absence of any Modifier specification, Image uses the effect of fillMaxSize() by default.

Next, we modify the default style by manually specifying the Modifier:

@Composable
fun IconImage() {
    
    
    Image(
        painter = painterResource(id = R.drawable.icon),
        contentDescription = "Icon Image",
        modifier = Modifier.wrapContentSize()
    )
}

Modifier.wrapContentSize() is called here, so that the Image can determine the size of the control according to its own content. Run the program again, the effect is as shown in the figure below:

insert image description here

The wrapContentSize() function also provides the ability to specify the alignment of the control. For example, here I want the image to be vertically centered and horizontally aligned to the left, so I can write like this:

@Composable
fun IconImage() {
    
    
    Image(
        painter = painterResource(id = R.drawable.icon),
        contentDescription = "Icon Image",
        modifier = Modifier
            .wrapContentSize(align = Alignment.CenterStart)
    )
}

Re-run the program, the effect is as shown in the figure below:

insert image description here

In addition, we can also crop and add borders to the picture very easily, the code is as follows:

@Composable
fun IconImage() {
    
    
    Image(
        painter = painterResource(id = R.drawable.icon),
        contentDescription = "Icon Image",
        modifier = Modifier
            .wrapContentSize(align = Alignment.CenterStart)
            .border(5.dp, Color.Magenta, CircleShape)
            .clip(CircleShape)
    )
}

Here the image is cropped into a circle and a 5dp border is added to it. Re-run the program, the effect is as shown in the figure below:

insert image description here

We can also use Modifier to modify the behavior of the control, such as offset, rotation, and so on. For example, the following code is used to rotate the image 180 degrees:

@Composable
fun IconImage() {
    
    
    Image(
        painter = painterResource(id = R.drawable.icon),
        contentDescription = "Icon Image",
        modifier = Modifier
            .wrapContentSize(align = Alignment.CenterStart)
            .border(5.dp, Color.Magenta, CircleShape)
            .clip(CircleShape)
            .rotate(180f)
    )
}

The running effect is shown in the figure below:

insert image description here

In this way, we use the Modifier to modify the size, layout, behavior, and style of the Compose control. To put it bluntly, the appearance of the Compose control is controlled by the Modifier.

Of course, the functions provided by Modifier to modify the appearance of controls are far more than these, and you can slowly explore more content according to your needs. But the most important thing is that you need to know that when you need to modify the appearance of the Compose control, it is right to find Modifier.

Add additional information to Compose controls

I personally feel that most domestic developers are not very interested in Accessibility and Test. Although there are some articles that will explain how to use Accessibility, the target application scenarios are basically to do some automation scripts, or even rogue software. Maybe few people really pay attention to what Accessibility is used for.

In fact, the main purpose of Accessibility is to combine Talkback to provide pronunciation assistance for those with visual impairments, so as to ensure that even if their eyes are invisible or unclear, they can use mobile phones and various apps normally.

However, most developers may not be very interested in such functions, and they don’t like to read such articles, so there are relatively few people who write them.

Since this is one of the 4 major application scenarios of Modifier, I still want to explain it. In fact, if you dig deeper into this content, you can write a separate article or even several articles, but I don’t plan to dig deeper here. My goal is to let everyone have a general understanding. If you are interested or in need, you can learn more by yourself.

In the Jetpack Compose tutorial written for beginners, why learn Compose? In this article, I mentioned the concept of reorganization. Reorganization is actually the process of combining layer-by-layer Composable functions into an interface according to the current Compose code structure.

Inside Compose, a tree structure is used to store each Composable function node in a reorganization process.

insert image description here

In the picture above, the left side is the interface effect we see rendered by Compose, and the right side is a schematic diagram of its internal storage structure.

But it should be pointed out that although it seems that there is only one tree here, there are actually two in the source code implementation. One is the reorganization tree we see now, and the other is the semantic tree we cannot see.

The semantic tree does not participate in drawing and rendering work at all, so it is completely invisible, it only serves Accessibility and Test. Accessibility needs to be pronounced according to the node content of the semantic tree, and Test needs to find the node to be tested according to the semantic tree to execute the test logic.

It may sound a bit complicated, but the good news is that in most cases, we don't need to do anything specifically for the semantic tree. As long as we use some standard Composable functions to write the interface, they have already handled this work for us internally.

But if you use some low-level API to draw your own interface, then these things have to be done by yourself.

There is such an example on the official website of Android developers. For example, you use the underlying API to draw a calendar interface yourself, as shown in the following figure:

insert image description here

Then when the user selects the 17th day, the system will not pronounce that you selected the 17th, but may only pronounce that you selected the calendar at most. For those users who have visual impairment, it is completely impossible to use your App.

So at this time, we need to manually add additional information to the Compose control to help the semantic tree work properly.

So how do you add additional information to the Compose control? The answer is obvious, because this is part of the Modifier application scenario.

Modifier mainly provides two functions to allow developers to add additional information, namely Modifier.semantics() and Modifier.clearAndSetSemantics().

The semantics() function allows adding additional information in the form of key-value pairs to the current Compose control, but it cannot be overridden. Therefore, the clearAndSetSemantics() function is relatively more used, and it will clear some additional information carried by the Compsoe control.

However, the semantics() function also has a particularly important function, that is, it can receive a mergeDescendants parameter. What does this parameter mean? Let's look at the following example:

Button(onClick = {
    
     /*TODO*/ }) {
    
    
    Text(
        text = "This is Button",
        color = Color.White,
        fontSize = 26.sp
    )
}

This is the usage of a Button control, and a Text is nested in the Button to display the text on the Button. But on the semantic tree, Text is a child node of Button, and they are two independent controls. If there are independent controls, Talkback will pronounce them separately, which is obviously not what we want.

At this time, you can use the mergeDescendants parameter to merge the child nodes and the current node on the semantic tree, so that they can be regarded as a whole control, and the Talkback will not have the problem of confusing pronunciation.

But we don’t need to worry about this situation in the above example, because as mentioned earlier, as long as some standard Composable functions are used, Google has already handled these scenarios for us internally. In fact, as long as your Compose controls are clickable (clickable, toggleable), then they will automatically merge all child nodes.

I have talked about so much theoretical knowledge, but I will not enter the actual combat link.

As mentioned earlier, Accessibility is very small in China. I believe that most of my friends don’t know how to open Talkback, so it may not be meaningful to demonstrate this part. But I believe that after seeing this, everyone already has a certain understanding of the concept and function of semantic trees. If you are interested or need it, please study it in depth.

Handle user input

The user input here does not refer to the input of the text input box, which is handled by the TextField control and has little to do with the Modifier.

The user input here refers to that when the user's finger slides on the screen and clicks on various operations, it will be considered as an input from the user, and we need to process this type of input.

In fact, Compose has already provided many upper-level APIs, enabling developers to easily handle various user inputs. We will see specific examples of this later.

But if none of these upper-level APIs can meet your needs, then you may have to use the lower-level APIs for some special customizations, and this is one of the functional areas of Modifier.

Let's look directly at the code below:

@Composable
fun PointerInputEvent() {
    
    
    Box(modifier = Modifier
        .requiredSize(200.dp)
        .background(Color.Blue)
        .pointerInput(Unit) {
    
    
            awaitPointerEventScope {
    
    
                while (true) {
    
    
                    val event = awaitPointerEvent()
                    Log.d("PointerInputEvent", "event: ${
      
      event.type}")
                }
            }
        }
    )
}

A PointerInputEvent function is defined here, which encapsulates a Box, and specifies that its size is 200dp and its color is blue.

The Box in Compose is basically equivalent to the FrameLayout in View. By default, they cannot affect the user's click or other input events.

And here, we call the Modifier.pointerInput() function, using the low-level API to allow Box to process the user's input events.

The pointerInput() function must pass in at least one parameter. The function of this parameter is that when the value of the parameter changes, the pointerInput() function will be re-executed. This is a kind of declarative programming thinking, which we have mentioned before, and will be mentioned repeatedly in the future. And if you don't need to re-execute the pointerInput() function, then just pass in a Unit parameter.

In the code block of the pointerInput() function, the awaitPointerEventScope is called here to start a coroutine scope. We write an infinite loop in the coroutine scope and call the awaitPointerEvent() function to wait for the user input event to arrive.

If the user does not input any event, it will hang and wait until there is a user input event, and then it will enter an endless loop to wait for the next user input event after execution.

Now run the program, the effect is as shown in the figure below:

insert image description here

As you can see, when the finger is pressed and dragged on the screen, we can capture these user input events.

Of course, this way of writing is a bit too low-level, and there are basically not many scenarios where we need to use such a low-level event processing API. Compose provides us with a series of very useful auxiliary APIs, which can easily handle most event processing scenarios.

Observe the following code:

@Composable
fun PointerInputEvent() {
    
    
    Box(modifier = Modifier
        .requiredSize(200.dp)
        .background(Color.Blue)
        .pointerInput(Unit) {
    
    
            detectTapGestures {
    
    
                Log.d("PointerInputEvent", "Tap")
            }
            // Never reach
        }
        .pointerInput(Unit) {
    
    
            detectDragGestures {
    
     change, dragAmount ->
                Log.d("PointerInputEvent", "Dragging")
            }
            // Never reach
        }
    )
}

Here we use detectTapGestures in the pointerInput() function to listen to the user's click event. Also use detectDragGestures in another pointerInput() function to monitor the user's drag event.

Note that these two events cannot be monitored in the same pointerInput() function, because the detectTapGestures and detectDragGestures functions are blocking, and the following line of code will never be executed after being called.

Re-run the program, the effect is as shown in the figure below:

insert image description here

There are still many things that can be done in the pointerInput() function, but this expansion can write a very long article, so we will call here. The purpose of this article is to explain Modifier, not to spread out infinitely for every knowledge point.

Make controls clickable, scrollable, draggable

In general, using the pointerInput() function to handle user input is relatively low-level, just like handling TouchEvent in the View system.

In fact, we don't always need to use such a low-level API. Modifier provides enough upper-level APIs to handle user input events such as click, scroll, and drag. Using these upper-level APIs can make the developer's work very simple, so let's learn one by one.

Look at clicks first. In fact, some controls are clickable by default, such as Button. And some can't, like Box.

To make a control that is not clickable by default clickable, it is not necessary to use the pointerInput() function, the clickable() function can also do it, and the code will be more concise.

@Composable
fun HighLevelCompose() {
    
    
    val context = LocalContext.current
    Box(modifier = Modifier
        .requiredSize(200.dp)
        .background(Color.Blue)
        .clickable {
    
    
            Toast.makeText(context, "Box is clicked", Toast.LENGTH_SHORT).show()
        }
    )
}

Here we add a clickable() function to the Box, then when the Box is clicked, the code in the closure of the clickable() function will be executed. Results as shown below:

insert image description here

Next is scrolling. In fact, we have demonstrated how to make a control layout scrollable in the last article, let's take a quick look here.

@Composable
fun HighLevelCompose() {
    
    
    val context = LocalContext.current
    Column(modifier = Modifier
        .requiredSize(200.dp)
        .background(Color.Blue)
        .verticalScroll(rememberScrollState())
    ) {
    
    
        repeat(10) {
    
    
            Text(
                text = "Item $it",
                color = Color.White,
                fontSize = 26.sp
            )
        }
    }
}

With the help of the verticalScroll() function, the Column layout can be quickly scrolled in the vertical direction, as shown in the following figure:

insert image description here

Let's look at drag again. The draggable() function allows a control to be dragged in the horizontal or vertical direction, and can monitor the user's drag distance, and then we can offset the control according to the returned drag distance to achieve the drag effect.

@Composable
fun HighLevelCompose() {
    
    
    var offsetX by remember {
    
     mutableStateOf(0f) }
    Box(
        modifier = Modifier
            .offset {
    
     IntOffset(offsetX.roundToInt(), 0) }
            .requiredSize(200.dp)
            .background(Color.Blue)
            .draggable(
                orientation = Orientation.Horizontal,
                state = rememberDraggableState {
    
     delta ->
                    offsetX += delta
                })
    )
}

In order to allow the control to be offset, a knowledge point that we have not yet learned, State is introduced. This knowledge point will be explained in the next article. It doesn’t matter if you still don’t understand it now. For now, you only need to understand the function of the draggable() function.

Run the program, the effect is as shown in the figure below:

insert image description here

However, the draggable() function has a disadvantage. It can only allow the control to be dragged in the horizontal or vertical direction, and cannot be dragged in the horizontal and vertical directions at the same time. So if you have this special need, you can use the lower-level pointerInput() function to achieve:

@Composable
fun HighLevelCompose() {
    
    
    var offsetX by remember {
    
     mutableStateOf(0f) }
    var offsetY by remember {
    
     mutableStateOf(0f) }
    Box(
        modifier = Modifier
            .offset {
    
     IntOffset(offsetX.roundToInt(), offsetY.roundToInt()) }
            .requiredSize(200.dp)
            .background(Color.Blue)
            .pointerInput(Unit) {
    
    
                detectDragGestures {
    
     change, dragAmount ->
                    change.consume()
                    offsetX += dragAmount.x
                    offsetY += dragAmount.y
                }
            }
    )
}

Inside the pointerInput() function, we call detectDragGestures to monitor the user's drag gesture, so that the user's drag distance in the horizontal and vertical directions can be obtained synchronously, and the control is offset accordingly.

In addition, remember that since this is the underlying API, you have to do a lot of things yourself, such as after the event is processed, remember to call the consume() function to consume it.

Run the program again, the effect is as shown in the figure below:

insert image description here

So far, we have explained all the four major application scenarios of Modifier, and demonstrated them one by one.

After understanding what Modifier can do, there are still some knowledge points about Modifier's own characteristics. These contents are also very important, so they will be explained together in this article.

understand the current scope

Everyone likes xml very much, and many people still think that using xml to write interfaces is simpler and easier to understand than Compose.

But I don't know if you have found a problem when you use xml to write the interface, that is, it cannot understand the scope of the current code.

What does that mean? For example, we are specifying the alignment of its child elements to a vertical LinearLayout. Since this is a vertical LinearLayout, its child elements must only be aligned in the horizontal direction.

But unfortunately, because xml does not understand the scope of the current code, there will be many unusable options in the alignment candidates it provides us.

insert image description here

In fact, it is for this reason that some code suggestions provided by xml are always immutable, which greatly reduces the value of these suggestions.

However, Compose does not have this problem. It can understand the scope of the current code, which is also one of the important features of Modifier.

For example, here we use Column to implement a vertical arrangement layout. When you want to specify the alignment for child elements, you will find that the parameter type of Modifier.align() automatically changes to Alangment.Horizontal, indicating that it can only be used in the horizontal direction Specifies the alignment.

insert image description here

And if we use Row to change the layout to horizontal arrangement mode, you will find that the parameter type of Modifier.align() of the child element is automatically changed to Alangment.Vertical, indicating that the alignment can only be specified in the vertical direction.

insert image description here

This feature is realized thanks to Kotlin's high-order function function, and xml does not have this ability naturally, so the advantage of Compose here is still obvious.

With the ability to understand the current scope, the interface and parameters provided by Modifier are more precise, safe and concise, which is very helpful for writing code.

Concatenation order matters

As mentioned at the beginning, Modifier is a very special parameter, which can connect an infinite number of APIs through chain calls, so as to achieve various effects you want.

However, Modifier's chain call mode has requirements for the sequence of concatenation, and different concatenation sequences may achieve different effects. This is very different from xml, because xml has no order requirements for specifying attributes, and it doesn't matter whether each attribute is written above or below.

But don't worry, this doesn't make the Modifier harder to use, it just makes it clearer what you're doing. We can quickly understand through an example.

Going back to the example of the IconImage() function at the beginning, now we add a gray background to it by connecting a background() function in series:

@Composable
fun IconImage() {
    
    
    Image(
        painter = painterResource(id = R.drawable.icon),
        contentDescription = "Icon Image",
        modifier = Modifier
            .wrapContentSize()
            .background(Color.Gray)
            .border(5.dp, Color.Magenta, CircleShape)
            .clip(CircleShape)
    )
}

Run the program, the effect is as shown in the figure below:

insert image description here

In fact, the code here has already begun to pay attention.

If you want to add a background color to the picture, the background() function must be called before the border() and clip() functions, so the execution logic of Compose is to first specify a rectangular gray background for the picture, and then set it to The picture is cropped into a circle, and the effect shown in the picture above appears.

If the background() function is called after the border() and clip() functions, the execution logic of Compose will become, first cut the picture into a circle, and then add the background color on the basis of the circle, then this The background color is also rounded so that it is completely invisible.

Let's continue to modify this example, now we want to add some margins to the picture. Adding margins to controls in Compose is implemented with the help of the Modifier.padding() function, as shown below:

@Composable
fun IconImage() {
    
    
    Image(
        painter = painterResource(id = R.drawable.icon),
        contentDescription = "Icon Image",
        modifier = Modifier
            .wrapContentSize()
            .background(Color.Gray)
            .border(5.dp, Color.Magenta, CircleShape)
            .padding(18.dp)
            .clip(CircleShape)
    )
}

Here we call the Modifier.padding() function to add a margin of 18dp to the image. Re-run the program, the effect is as shown in the figure below:

insert image description here

You will find that the increased margin belongs to the inner margin, and the position of the border has not changed, but the margin of the content inside has increased.

The reason for this phenomenon is that we call the border() function first, and then call the padding() function, so the position of the border has been fixed before setting the margin, which forms the effect of the inner margin.

Then it is obvious that by calling the padding() function first, and then calling the border() function, the effect of the outer margin can be achieved:

@Composable
fun IconImage() {
    
    
    Image(
        painter = painterResource(id = R.drawable.icon),
        contentDescription = "Icon Image",
        modifier = Modifier
            .wrapContentSize()
            .background(Color.Gray)
            .padding(18.dp)
            .border(5.dp, Color.Magenta, CircleShape)
            .clip(CircleShape)
    )
}

Run the program again to see:

insert image description here

With the help of this feature of Modifier, in fact, we only need to adjust the calling order of the padding() function to control the inner and outer margins of the control very easily. In the View system, the work that needs to be done with the help of the two attributes of layout_marging and padding can be realized in Compose with only one padding() function.

Therefore, you will find that there is no concept corresponding to the attribute layout_marging in Compose, because it is not needed.

Add Modifier parameter to Composable function

It was also mentioned at the beginning that any Composable function should have a Modifier parameter. If not, it means that there is something wrong with the Composable function.

I said this sentence, but Google also expressed a type of view. According to the Compose coding specification officially recommended by Google, the first non-mandatory parameter of any Composable function should be a Modifier, like this:

@Composable
fun TestComposable(a: Int, b: String, modifier: Modifier = Modifier) {
    
    
    
}

This specification is very particular, because Modifier is an optional parameter, so it needs to be placed after all mandatory parameters. In this way, the caller can choose to specify the Modifier parameter, or choose not to specify it.

And if the Modifier parameter is placed in front of the mandatory parameter, then the Modifier parameter must be specified first, and then the mandatory parameter can be specified, or the parameter name passing method must be used, and the usage becomes inconvenient.

Now that we understand why the Modifier parameter should be placed in the first non-mandatory parameter position, why should every Composable function have a Modifier parameter?

This is mainly for flexibility considerations.

Still taking the IconImage() function as an example, the function of IconImage() should be to provide an avatar control, so it can control the shape, background, border, margin, etc. of the avatar, but it should not control the alignment of the avatar.

This should be easy to understand. It cannot be said that an avatar control can only be displayed in the center or on the left, right?

The alignment of the control should be determined by its parent layout. The parent layout can decide how to align the avatar control according to its own display requirements. In order to make the IconImage() function have this flexibility, we need to add a Modifier parameter to it. As follows:

@Composable
fun ParentLayout(modifier: Modifier = Modifier) {
    
    
    Column {
    
    
        IconImage(Modifier.align(Alignment.CenterHorizontally))
    }
}

@Composable
fun IconImage(modifier: Modifier = Modifier) {
    
    
    Image(
        painter = painterResource(id = R.drawable.icon),
        contentDescription = "Icon Image",
        modifier = modifier
            .wrapContentSize()
            .background(Color.Gray)
            .padding(18.dp)
            .border(5.dp, Color.Magenta, CircleShape)
            .clip(CircleShape)
    )
}

In addition to adding the Modifier parameter to the IconImage() function, use this parameter when specifying behavior for the internal Image() control instead of creating a new Modifier object.

In this way, wherever we call IconImage(), we can specify its alignment according to actual needs.

This example fully demonstrates that Composable functions with Modifier parameters have higher flexibility. All built-in Composable functions provided by Google follow this specification, so I hope you can too.

Summarize

In this article, we discussed the 4 major application scenarios of the Modifier and the 3 major features of the Modifier. I think the basic explanation of the Modifier is in place.

Of course, every knowledge point here can actually continue to be dug deeper, such as semantic trees, event processing, and so on. But these divergent knowledge cannot be covered in one article. If I have a chance, I may explain it specifically.

In the next article, we are about to start learning one of the most important concepts in Compose, State. After learning this, Compose will become more interesting, so stay tuned.

So see you in the next original article.


Compose is a declarative UI framework based on the Kotlin language. If you want to learn Kotlin and the latest Android knowledge, you can refer to my new book "The First Line of Code 3rd Edition" . Click here for details .

Guess you like

Origin blog.csdn.net/sinyu890807/article/details/132253342