Deep dive into Jetpack Compose layouts

Video Statistics and Information

Video
Captions Word Cloud
Reddit Comments
Captions
[MUSIC PLAYING] NICK BUTCHER: Hi, and welcome to A deep dive into Jetpack Compose Layouts. My name is Nick Butcher. And I'm joined by George Mount. Jetpack Compose offers a brand-new way to describe your UI declaratively in code. It offers new composables for arranging your UI and modifiers for configuring components and adding behaviors. But this talk is not about these basic building blocks. If you're not familiar with these yet, then please go check out our documentation first. The goal of this talk is to explain the composed layout model that powers these composables and modifiers, to deep dive into how they work under the hood and their capabilities so that you have a better understanding of how the layouts and modifiers that you use work to understand how and when to build your own custom layouts to achieve the exact design your app calls for. The goals of the Compose layout system are that layouts are easy to create, especially custom layouts, that the layout system is powerful, enabling you to create whatever your apps need, and that they are highly performant. Let's see how Compose's layout model achieves these goals Jetpack Compose transforms state into UI. But how does it do this? It's a three-step process-- composition, then layout, then drawing. Composition executes your composable functions, which can emit UI, creating a UI tree. For example, executing this SearchResult composable yields a tree like this. This is a pretty simple example, but your composables can contain logic and control flow, producing a different tree in different states. In the layout stage, this tree is walked, and each piece of UI is measured and placed on the screen in 2D space. That is, each node determines their width and height and x, y coordinates. In the drawing stage, the UI tree is walked again, and all elements render. Today, we went to a deep dive into the layout stage and see how it works. There are two phases to the layout stage, measurement and placement. These are roughly equivalent to on measure on layout in the view world. But in Compose, these phases are intertwined. So for now, we'll consider it as a single layout phase. Laying out each node in the UI tree is a three-step process. Each node must measure any children, then decide its own size, then place its children. So applying this to our example, the UI tree is laid out in a single pass. First, the root layout, the row, is asked to measure. It, in turn, asks its first child, the image, to measure. The image is a leaf node with no children. So it measures itself and reports the size. It also returns instructions for how to place any children. The leaf node, this is usually empty. But all layouts return these placement instructions at the same time as setting their size. The row then measures its second child, the column. The column measures its children. So the first text measures and reports its size and placement instructions. Then, the same for the second text. Now that the column has measured its children, it can determine its own size and placement logic. Finally, now that all of its children are sized, the root row can then determine its size and placement instructions. Once all elements are measured in size, the tree is walked again, and all placement instructions are executed in the placement phase. So now that we understand the steps involved, let's take a look at how this is implemented. Going back to the composition stage, we represented our UI tree with these higher-level composables that we work with, rows, columns, text, and so on. But each of these composables are actually themselves constructed from lower-level composables. For example, a text functionality is built up from a number of lower-level building blocks. Examining the UI tree, we can see that all composables that place elements on the screen contain one or more layout composables. This layout composable is the fundamental building block of Compose UI. The layout composable emits layout nodes. And in Compose, the UI tree, or the composition, is a tree of layout nodes. Over to George to take a closer look at this. GEORGE MOUNT: Thanks, Nick. So let's examine the layout composable to see how this works. This is the function signature. The content is a slot for any child composables. For the purposes of layout, content contains the child layouts. It accepts a modifier parameter for applying modifiers to the layout. We'll come back to this later. Lastly, it accepts a measure policy, which is how the layout measures and places items. Typically, you implement this functional interface in line to implement your custom layout's behavior, like this. Here in MyCustomLayout composable, we call the Layout function and provide the measure policy as a trailing lambda, implementing the required measure function. This function receives a Constraints object, telling the layout how large or small it can be. Constraints is a simple class, modeling the minimum and maximum width and height that the layout can be. For example, Constraints can express that the layout can be as large as it likes, not applying any restrictions. Or it can express that the layout should be an exact size. The measure function also receives a list of measurables. This is a representation of the child elements passed in. The measurable type exposes functions for measuring items. As we covered earlier, laying out each element is a three-step process. Each element must measure any children, then decide its own size, then place its children. Let's see how we implement this. First, let's measure any children. The measurable type exposes a measure method to do this which accepts size constraints. In the simplest case, where we don't apply any custom measurement logic, we can just map over the list of measurables, measuring each one. This produces a list of placeables. Placeables are the measured children, and have a size. We can use the placeables to calculate how big our layout should be. We can then report our size, how large this layout wants to be, by calling the Layout method. The Layout method requires a placement block, which we typically implement as a trailing lambda, allowing you to finally place each item where you want it to be. There's also a placeRelative method, which will automatically auto-mirror coordinates horizontally in right-to-left locales. API design prevents you from trying to place an element that hasn't been measured. The place method is only available on placeables, which are returned from the measure method. In the view world, it was up to you when you called on measure and on layout. And ordering was not strongly enforced. We need to settle bugs and differences in behavior. NICK BUTCHER: While this might seem a little complex at first, it's really quite powerful and easy to use once you get the hang of it. Let's walk through building a few example layouts to illustrate this. Compose ships a column component for laying elements out vertically. To understand how it works under the hood and how it uses the layout composable, let's implement our own column. We'll create MyColumn composable function that accepts a modifier and a content block for the items that want to be laid out in a vertical sequence. We'll use the Layout composable. And as before, we'll measure each item we're given without further restricting their size they can be. Our columns' height will then be the sum of the measured heights of all items. And its width will be that of the widest item it contains. We report our size, and then place each item by tracking the y position and incrementing it by each placed item's height. That was a pretty simple example. The actual column composable is far more powerful, supporting layout weights, alignments, and more. But hopefully, this illustrated the basics of placement. GEORGE MOUNT: Let's look at a different example, building a regular grid. We'll create a vertical grid composable function which accepts a parameter for the number of columns in the grid defaulted to 2. Again, we'll use the Layout composable to implement this. Each column will use an equal portion of the layout's maximum width for its width. We use this to construct a new Constraints object for the items. We use the copy method to keep the incoming height constraints, but specify the exact width. We can then measure each item with these constraints and then go on to place them into the grid. This concept of creating new constraints for child content is how you implement custom measurement logic. The ability to create different constraints to measure children is key to this model. There is no negotiation between the parent and child. The parent passes a range of allowable sizes, expressed as constraints. And once the child chooses its size from this, the parent must accept and handle it. This design has some nice properties. It means that we can measure the entire UI tree in a single pass, and forbid multiple measurement cycles. This was a problem in the view world, as nested hierarchies that performed multiple measurement passes could lead to quadratic number of measurements on leaf views. Compose's design prevents this. In fact, if you measure an item twice, then Compose throws an exception. Because of the stronger performance guarantees, it opens up new possibilities, such as animating layout. NICK BUTCHER: While creating generic layouts are useful, another use case for the Layout composable is creating a single specific layout that your app design calls for. For example, in their Jetsnack sample app, our design called for a custom bottom navigation design where the selected item displays a label. Otherwise, only an icon. Furthermore, the design called for the item's size and position to animate based on selection. We created this using a custom layout to get precise control over animating this layout change. Unlike the previous examples, this composable exposes two specific slots for the icon and the text label. It also accepts an animation progress value that callers need to provide. This is a float between 0 and 1. When it's 0, the item is unselected. So only renders the icon centered in its bounds. When progress is 1, then it renders both the icon and the label. Between these values, we animate the text size and both the text and icon's position to keep the pair centered. In our custom layout, we wrap the given icon and text in boxes. This is purely so that we can apply a layer ID modifier to each. We do this to be able to identify the measurables in our measure block. It's more robust than relying on their ordering. We retrieve each measurable and measure them. We extracted the placement logic to another function for readability. And finally, here, we perform the maths to place the text and icon depending upon the animation progress value. This example is possible because layouts in Compose are so performant that we can animate measurement or placement or drive them with a gesture. This was hard to achieve in the view system, where animating layouts was discouraged due to performance concerns. Hopefully, these examples have given you both an understanding of how the custom layouts work as well as some ideas of where they can be useful. I'd suggest going custom if you need to build a design that might be hard to achieve with the standard layouts. You can build most things with enough rows and columns. But sometimes, that proves hard to maintain or evolve. Custom layouts also give you very precise control over measurement or placement logic. This makes it easier to expose controls to callers. For example, to animate or drive layout with a gesture. We're working on new APIs for animating placement. So in the future, you might be able to achieve this without having to write your own layouts. Lastly, going custom gives you complete control over performance characteristics. More on this later. The standard layouts are powerful and flexible, but they also need to cater for many use cases. Sometimes, going custom when you know the exact details of what you need to implement can be the right tool for the job. GEORGE MOUNT: So we've looked at the layout composable and how to build custom layouts. But if you've built any UI with Compose, then you know that the modifier plays a key role in layout, configuring size and position. But how do they participate in the layout model we've explored? Previously, we showed that the layout composable accepts a modifier chain as a parameter. Modifiers decorate the element they are attached to and can participate in the measurement and placement before the layout's own measurement and placement. Let's examine how they do that. There are many different types of modifiers that affect different behaviors, such as drawing modifiers, pointer input modifiers, and focus modifiers. Of particular interest to us is the layout modifier. It offers a measure method, which is nearly identical to the Layout composable, with the exception that it only acts on a single measurable rather than a list of measurables. This is because modifiers are applied to a single item. In this measure method, the modifier can alter constraints or implement custom placement logic, just like a layout. This means you don't always need to write a custom layout. If you want to only act on a single item, you could instead use a modifier. For example, let's examine how the PaddingModifier works. The factoring function builds on the modifier chain, creating a PaddingModifier object, which captures the desired padding values. The PaddingModifier class is a layout modifier. It alters measurement by shrinking the outer constraints by the padding size, and then measures its content. Then it places the content, offset by the desired padding. You can, of course, write your own layout modifiers, or use Modifier.layout, which makes it simple to add custom measurement or placement logic to any composable directly from the modifier chain without having to create custom layout. For example, here we're implementing custom measurement and placement directly in the modifier chain. NICK BUTCHER: While layouts receive a single modified parameter, this models a chain of modifiers that are applied in order. Let's walk through an example of this and see how it interacts with the layout model. Consider wanting to create a fixed-size blue box and center it within its parent. We can do that with this chain of modifiers. Let's see why they produce this behavior and what's going on under the hood. We can easily size and draw the box. But this places it in the top-left corner of its parent. To center it, we need to use a layout modifier which affects placement. wrapContentSize is just such a modifier. It allows content to measure at its desired size, and then uses its align parameter to place it. Align defaults to center, so we can omit it. Unfortunately, this isn't quite enough to achieve the result we want. Our box is still in the top left. Because most layouts wrap their content, we need to make measurement take up the entire space in order to center a box within it. We can do this by adding a fillMaxSize layout modifier before the wrapContentSize. Let's examine how these modifiers achieve this. To be concrete, imagine that our box is being placed in a container that can be up to 200 by 300 pixels in size. These constraints will be passed into the first modifier in the chain. fillMaxSize essentially creates a new set of constraints, and sets both the min and max width and height to be equal to the maximum incoming width and height so that it fills to the max. So exactly 200 by 300 in our example. These constraints get passed down the chain to measure the next element. So the wrapContentSize modifier receives them. It creates new constraints which relax the incoming constraints, letting the content measure at its desired size. So 0 to 200 by 0 to 300 again. This might look like it's just undoing the fillMax step, but remember that we're using this modifier to center the item, not to size it. These constraints are passed down the chain to the size modifier, which creates an exact size constraint to measure the item with. So it specifies that it should be exactly 50 by 50. Finally, these constraints are then passed to the box's layout. It measures and returns its resolved size of 50 by 50 back up the modifier chain. So the size modifier resolves its size also to 50 by 50 and creates placement instructions. Then wrapContent resolves its size and creates placement instructions to center its content. That is, because this modifier knows that its size is 200 by 300 and that the next element is 50 by 50, it uses the centering alignment to create placement instructions to center it. Finally, fillMaxSize resolves its size and placement. Hopefully, this example helps you to understand how the modified chain is executed. It works exactly like the tree of layouts, except each modifier has a single child, the next element in the chain. Constraints are passed down, which subsequent elements use to measure themselves. And then, resolved sizes come back up, and placement instructions are created. Hopefully, this also illustrates how the order of modifiers matters. This design makes it easy to combine different measurement and layout policies together using modifiers to compose functionality. GEORGE MOUNT: Now that we've covered the fundamentals of the layout model, there are a number of advanced features that it supports that are good to be aware of. You might not always need them, but they can allow you to build more advanced functionality. First, let's look at intrinsic measurement. I told you before that Compose uses a single pass layout system. Well, it isn't entirely true. We can't always do layouts in one pass. Sometimes you need to know something about the child sizes before finalizing the constraints. One example is a menu pop-up. The menu looks easy at first. Here we have a column with five menu items. It mostly works, but each menu item is a different size. That's easy to fix. Just make them take up the maximum size that they're allowed. But that doesn't work either. The menu window grows to its maximum size. The solution is to use the maximum intrinsic width to determine how big to be. Here, we've asked column how big it will be if we give each child as much room as it wants. For text, that would be how wide it would be when rendered on a single line. Once the intrinsic size is determined, it is used to set the size of the column. The children can then fill the width of the column. So what happens if we use min instead of max? It asks what the minimum size that the column wants to be. Text's minimum intrinsic width is the width that has one word upon each line. You can see that we end up with a word-wrapped menu. NICK BUTCHER: The modifiers that we've looked at so far have been general purpose. That is, they can be applied to any composable. Sometimes, your layout might want to offer some behavior that requires some information from the child. This is where parentData modifiers are useful. Let's go back to our previous example of the blue box centered in its parent. This time, we know that the box is inside another box. The content within a box runs in a receiver scope called BoxScope. BoxScope defines modifiers that are only available for use within a box. One modifier that it offers is a line which provides the exact functionality that we need to send to the blue box. So if we know that our blue box is within another box, we can instead use the align modifier to position it. Align is a parent data modifier, not a layout modifier like we've seen before, as it simply communicates some information to its parent. It can't be used when we're not within a box. It has information to the parent box that it uses to layout its children. You can, of course, write your own parent data modifiers for your own custom layouts to allow children to communicate some information to the parent that they should use in layout. GEORGE MOUNT: Alignment lines allow us to align by something other than the top, bottom, or center of a layout. The most commonly used alignment line is textBaseline. My designer gave me this design with an icon next to some text. That's pretty easy to do. I'll just use a row with an icon next to the text. The Android Studio preview shows me that I made a mistake. The icon is at the top. And that looks weird. I can fix that. We can center both the icon and the text. Looks good to me. I showed it to my designer, and she pointed something out. There's a misalignment. The icon should ride along the baseline of the text. I can fix this. I can tell the row to align the text to its baseline. First, I use the alignByBaseline modifier for the text. But what about the icon? It doesn't have a baseline or any other alignment lines. We can use the alignBy modifier to tell the icon to align anywhere we want. In this case, I know that the icon's bottom is where I want it to align, so I can just align to that. And that's great to me. Let's bring back the ruler to double check. Yes, perfectly aligned. How do we align with a nested child? It would be troubling if we had to modify a button to have a baseline alignment. And that still wouldn't account for the other custom alignments. Fortunately, the alignments pass through the parents. Here I'm aligning to the baseline of the button. But the button just takes the value from its child. You can even create your own custom alignments in your custom layouts to allow other composables to align to it. NICK BUTCHER: An interesting and powerful provided layout is BoxWithConstraints. In composition, we can conditionally choose what to display using logic and control flow. But sometimes, you might want to make decisions based on how much space is available. For example, choosing a different representation on larger screens. Returning to the three stages of Compose, sizing information isn't available until the layout phase. That is, it isn't generally available at composition time to use to make decisions about what to show. That's where BoxWithConstraints comes in. It's a composable similar to Box, but it defers composition of its content until the layout phase, when layout information is known. Content in a BoxWithConstraints runs in a receiver scope that exposes its constraints determined during the layout phase as either pixel or DPI values. So content within it can use these constraints to choose what content to compose. For example, choosing different presentations based on the max width. So those were some of the advanced layout features that are useful to be aware of. Now, let's turn our attention to performance. We've already covered how the single-pass layout model can prevent spending excessive time in measurement or placement. We also showed that measurement and placement are distinct sub-phases of the layout pass. A consequence of this is that any changes that only affect placement of items, not measurement, can be executed separately. Consider this example from the Jetsnack sample. A product details page has a coordinated scroll effect, where a number of elements move or scale based on scroll. Let's focus on this title area. It scrolls with the main content, pinning at the top of the screen. To implement this, the different elements are separate composables stacked in a box. We hoist the scroll state and pass it into the Body composable, which uses it to make its content vertically scrollable. We can then observe the scroll position from other composables, like Title. But how we observe this matters for performance. For example, a naive implementation might be to use the scroll value to simply offset the content. The problem with this approach is that the scroll is an observable state value, and the scope that you read it in dictates what compose needs to re-execute when the state changes. In this example, we're reading the scroll value in composition, and then using it to create an offset modifier. Each and every time that the scroll value changes, this title composable will have to be recomposed, and the new offset modifier has to be created and executed. Returning to the three stages of Compose, because the scroll state is read in composition, any changes cause recomposition. When you recompose, you also need to run the later stages, layout and drawing. But we're not changing what we're showing, just where it is. We can be more efficient. If we change our implementation to, instead of accepting the raw scroll position, we pass a function that can provide it. Then, we can only call this lambda and read the scroll state at a different time. We can instead use a different form of the offset modifier which accepts a lambda producing the offset. This means that the modifier doesn't need to be recreated when the scroll changes. The scroll state's value is only read during the placement stage. So when the scroll state changes, we only need to run placement and drawing. No recomposition or measurement, improving our performance. In fact, going back to our custom bottom nav example, this exhibits the same issue. And we can apply the same fix, accepting a function which provides the current animation progress so we don't need to rerun composition, only layout. As a rule, you should be suspicious whenever you see a parameter to a composable or modifier that you expect to change frequently, as this could be a cause of over-composition. You should only need to recompose when you change what you show, not where or how you show it. GEORGE MOUNT: BoxWithConstraints allows for composition that depends on layout. But what is it doing? It starts a sub-composition during layout. For performance reasons, we want to avoid composition during layout as much as possible. Prefer using layout that changes based on the size over using BoxWithConstraints. Use BoxWithConstraints when the types of information change with the size. Let's talk about improving layout performance. There are times when layout doesn't need to measure all of its children to know its own size. In this example, I have a card with three children. The icon and the title make up the title bar. And then, there's the body. I know that the size of the icon is fixed, and the title height is the same as the icon. When the card is measured, I only need to measure the body. Its constraints are just 48 DP less height than the layout side. The size of the card is just the size of the body plus 48 DP in height. The system recognizes that only the body was measured, so it is the only child that is important for determining the size of the layout. The icon and text must still be measured. So that can be done in the placement block. Let's suppose that the title is Layout. When the title has changed, the system doesn't have to rerun the measurement of the layout. So the body doesn't get re-measured, saving unnecessary work. NICK BUTCHER: Returning to our stated goals for the layout system, hopefully, you've seen how it's far easier to implement a custom layout. It's as simple as writing a single function. Additionally, modifiers allow you to build and combine layout behaviors, making it even easier to achieve the exact functionality that you need. The layout system supports advanced features, like custom alignment across nested hierarchies, creating custom parent data modifiers just for your layout, automatic right-to-left support, or deferring composition until layout information is known. And we've seen how the single-pass layout model or the potential to skip re-measurement and only run replacement enables you to write highly performant layout logic that you can even animate or drive with a gesture. We hope that understanding the layout system helps you to build exactly the layouts your design calls for and create excellent apps that your users love. To learn more, check out our documentation, learning pathway, and samples. Thanks for watching. [MUSIC PLAYING]
Info
Channel: Android Developers
Views: 17,867
Rating: undefined out of 5
Keywords: Android Developer Summit, Android Dev Summit, Android Dev Summit 2021, Android developer, Android development, new in android, new in android development, type: Conference Talk (Full production), pr_pr: Android
Id: zMKMwh9gZuI
Channel Id: undefined
Length: 28min 24sec (1704 seconds)
Published: Wed Oct 27 2021
Related Videos
Note
Please note that this website is currently a work in progress! Lots of interesting data and statistics to come.