Custom layouts and graphics in Compose

Video Statistics and Information

Video
Captions Word Cloud
Reddit Comments
Captions
[MUSIC PLAYING] SIMONA STOJANOVIC: Hi, everyone. My name is Simona Stojanovic. I'm an Android Developer Relations Engineer working on Compose. Unfortunately, it's going to be just me on stage today because Rebecca couldn't make it. So today we're going to combine two Compose UI forces-- custom layouts and graphics-- to build a seemingly complex design in Jetpack Compose in just 20 minutes. So our approach today will be more of a hands on, where we'll get to build a mind-blowing, super fun, and extremely original and creative design of a graph. But we're going to make it pretty and fun. I promise. This is going to be a sleep tracking app called JetLagged. So let's begin with the first part, custom layouts. This time graph will be the custom layout that we want to build. So how do we build this? Let's break it down. The x-axis of our graph shows the hours of the day. The y-axis shows the days. And lastly, the bars represent the sleep hours for each day. Now, there is a bit more. If you tap on a sleep bar, it expands to show a bit more info. So when you break down a design like this, you might think that there's a lot of ways of achieving it. And you might be wondering, why go with the custom layout? Can't I use existing composables instead? And to answer this, let's see how these three graph components work with each other. So day labels need to be positioned at the start of the screen, but beneath the hours header. The hours header needs to be at the top of the screen, but after the day label. Each day bar is differently positioned, taking into account the start offset and the sleep duration. And it needs to be placed in line with the day label when it's collapsed, as well when it's expanded. So TLDR is, we need a lot of coordination here. And every component needs to know the exact info on all of the other elements for its own sizing and placement. So this is a perfect use case for a custom layout because, in Compose, custom layouts give you the full control over manually placing and sizing its layout children. This means that our graph for our custom layout will actually have three responsibilities. Calculating how many day labels and sleep bars are needed-- now, the hours header at the top is only going to be one composable, so nothing to calculate here-- measuring all three components, and placing all three components. Now, let's first define the graphs input. As mentioned, our three components are: the hours header, the day label, and the sleep bar. Now, for simplicity, let's imagine that these come as the following composables-- a single row composable containing all of the hours, a single text composable for one day label, and a single box composable for the one sleep bar that we have. Now, we can actually use this information to define the parameters of our graph. However, to know how many day labels and sleep bars we have, we also need one more parameter, which is the total count of the sleep bars or days, or simply, graph rows. Now, when we multiply the day label and the sleep or composables with this total count, we get two lists of comparables instead. As mentioned, the hours header is just at the top. It comes in as one composable, so again, nothing to calculate here. Now for the second step-- measuring all components. Every custom layout starts by calling the layout composable. And this composable is actually the key core component of the Compose layout system. It accepts composable content for laying out the children and, as well, the policy for measuring and sizing these same children. So all higher level layouts, like rows and columns, are built on top of this composable. Now, for a graph, we'll actually use a new and handy overload of this layout composable, which accepts a list of content. Now, the layout composable is actually the main protagonist of the layout phase in Compose. And this phase consists of two steps-- measurement and placement, in that specific order. Sizes of child elements are actually calculated during the measure pass, while their positions are calculated during the placement pass. The order of these steps is enforced with Kotlin DSL scopes, which are nested in a way that prevents placing something that hasn't been first measured, or doing the placement phase and the measurement phase. Now, during measurement, the layout contents can be accessed as measurables, or components that are now ready to be measured. Now let's think about which component needs to be measured first. Elements that can be independently measured in our graph, meaning that their size isn't impacted by any of the other components that we have in the layout, are the hours header and the day labels. Now, we know this also because we let these composable define their own sizes when we pass them as input. So our layout only needs to take in those sizes as the main source of truth. Now, let's start by measuring the hours header first. Inside layout, all measurables come as lists by default. So we grab the first element from our hours header measurable because we know that there's only one coming in. And then we measure the header as it is, without changing its predefined size with incoming parent constraints. This in return gives us an hour header placeable, a component that is now ready to be placed. Following the same pattern, we take the list of dayLabel Measurables. And we measure each label one by one with incoming parent constraints, keeping its original size. Again, in return, we get a list of placeables that are now ready to be placed. Now for the sleep bars. Let's figure out how to measure these first. Each sleep bar width is based on the total sleep duration for that specific day. Now, we need the duration in the measurement phase to be able to calculate its width properly. We also need to know the hour when the user went to bed as the starting offset of that specific sleep bar. We need this offset in the placement phase to know where to place the bar. So for each sleep bar measurable, we need its duration inside the layout to measure its width properly. But we currently don't have that information. So to solve this, Compose actually offers a very handy API called ParentDataModifier. Now, ParentDataModifier actually enables child composables to pass data to the parent layout, which is then used for actual measurement and sizing of the specific child. Or in our case, every sleep bar actually needs to pass its duration and offset upwards back to the parent. And this will actually help measure its width and position correctly. So let's use this API. So first of all, we create this TimeGraphParentData class, and we override the ParentDataModifier interface. We override its modifyParentData function to pass the duration and offset reft. So now we have the actual package. But how do we ship it back to the parent? For that, we actually create a custom modifier which accepts input from the sleep bar and uses it to then pass the duration and the offset. We then wrap this in a parent data to ship it back to the parent layer. We apply this modifier to each sleep bar when we're passing it as input. And that's all. Now our parent actually has the information that it needs. So when measuring the sleep bar width, we actually pause because we didn't have the duration. And now we can get the duration from the parent data set property, which was previously passed by the modifier. We use it to calculate the bar's width. We then call measure function for each sleep bar, passing in the new constraints. Once measured, we get sleep bar placeables in return. Measurement done. Let's move on to placement. Let's determine which components need to be placed independently. Now, the hours header is independent because once it's measured, it just needs to go at the top of the graph. The sleep bars are placed one after another with a starting offset. And the day labels might seem like they're independent. But do remember, when we tap on them and expand them, they need to stay in line with the matching day label. So the day labels actually have some dependencies for placement. The placement step starts by calling the layout function and entering the placement scope. Now, the placement scope now allows us to use all of the placeables that came as a result of the measuring pass. To start placing children, we first need the x and y-coordinates. xPosition starts where any of the day label ends because they're all the same width. yPosition actually starts where the header height ends. We then take the hours placeable, and place it at the position of xPosition and 0, which is the top of the parent. Next are the sleep bar placeables. Every sleep bar needs to be placed after its starting offset. If you remember, the offset is actually the second piece of information that we were getting from our parent data. We use this offset to calculate each bar's xPosition and then place it. But we're actually not done yet with this specific step. Remember that we said that the day label needs to stay aligned at the top when it expands? So to achieve this, we'll actually place the day labels in the same iteration as the sleep bars because their placement is coupled. And they require the same yPosition. Using the sleep bar index, we find the matching day label. We then place it with the position of 0 and yPosition coordinates. And lastly, we increment the yPosition by the current bar height so that every other element comes beneath it. Placement done. So if we run the app now, this is what we'll get. And we can verify that our graph is looking really good. Great. So now that the custom layout part is done, let's move on to drawing the sleep bar details. The sleep bar currently is drawn as a rounded rectangle with color. But to match our designs, we need its initial state to actually have an emoji representing the sleep score, and the expanded state to show a bit more info. Looking at the expanded state design, the offset and the size of each rectangle is based on the duration of sleep. The sleep periods are connected together with lines. And there is a gradient representing different sleep periods. Now, to draw something custom in Compose, we have different options for different purposes. For example, Modifier.drawBehind draws the content behind the content of the composable. Then we have drawWithContent, which specifies the order of drawing details. drawWithCache caches the content until the state or the size actually changes. We then have the Canvas Composable, which is just a wrapper around Modifier.drawBehind-- and, finally, the graphics layer, which applies effects to composables that is already drawing something on the screen. For us, we will actually use the modifier drawWithCache because we need the brush objects that we're going to create to be cached. Now, to draw the sleep bar, we can use any composable here for drawing. But we chose a spacer because we don't need any other content to be added to the screen. And the spacer actually doesn't render anything. Now we have access to the onDrawBehind function, where we execute the drawing commands. Now, it may look like we don't have a Canvas object here to call the drawing functions on. But inside this onDrawBehind function, we're actually in something called a DrawScope. Now, what is a DrawScope then? DrawScope is Compose's answer to declarative stateless drawing, where you can call drawing functions without needing to maintain your own state. And it's a much nicer API than using the onDraw method in Canvas, as it handles a lot of the complexities for you. Now, if you remember the moments where you needed to save and restore states with Canvas before, those days are luckily over. In the definition of the DrawScope, you can actually see that it has a bunch of methods for drawing. And we can get also this access to the size and the center of the drawing areas, which are particularly useful when adjusting the sizing and the placement of an item. So let's get into it and draw the bar. Now that we know how to use the DrawScope, we can actually get our design back to a rounded rectangle with a gradient. We call the draw Rounded Rect with a gradient brush to get this fading effect. We could also use color here because there are multiple overrides of the draw functions that take in both brushes and colors. Now, running this code, you can see that the sleep bar is now drawn in a rounded box. The next thing to do is to draw the sleep score emoji. And this is actually text. So let's learn how to actually draw text on Canvas. New APIs have been added in Compose 1.3 to cater for this specific purpose. Back to the code-- so to draw the text on Canvas, we actually need to create a text measure. We provide the emoji string that we want to draw. And then we call the Measure function. We call drawText, then, with the result of the measuring string. And this draws the text at 0.0 coordinates of the canvas. We can also customize the text size, alignment, and other properties. But for our individual purposes, we don't really need to because we just want the emoji there. Now that we have the emoji at the top of the bar, this is looking pretty good so far. Now, we have the non-expanded state implemented, but we want to also implement the expanded state. Now, this looks like a pretty complicated design, so let's break this down. The first step is to increase the height of the sleep bar composable on click. Animating the height of something that's on click actually requires us to create an expanded variable and use it for determining this height. Back to the code. First, we created this isExpanded Boolean. And then we toggle its state on click. Now, to use this expanded state with an animation, we can actually create a changing height value. We create the transition objects to coordinate multiple properties that should animate when the expanded state changes. In this case, we're only animating the height of the item. Great. So now the size of the bar increases on expansion, but we actually need a few more bits there. Next step would be to draw the sleep periods with interesting path logic. And this is where things start to get a bit more complicated. We can see that their expanded state shows time spent in each type of sleep. Whether it's awake, REM, light, or deep sleep, they're all connected together. These rects are also rounded. And there is also a vertical gradient across all of the bars and parts. Now, instead of the rounded rect for the whole bar, we want to draw a path that we generate by iterating through each sleep period. This path will be used for both expanded and the non-expanded form. For each sleep period, we actually check if there's a previously period. And we keep track of this variable as we iterate. So if there is a previous period, we call the path.lineTo to draw a line between the start and the end of these periods. Now, the next thing to do is to add the current sleep period as a rectangle to the path that we're going to draw. The rect size and the offset are calculated based on the duration of sleep period. And each bar gets a vertical offset, dependent on which type of sleep it actually is. Then we call path.moveTo to move the path to the start of the next line. We move it to the right of the rect that we just added. And path.moveTo doesn't actually add any lines to the output that will be drawn. It just instructs where the next drawing command will be executed from. This process is then repeated for each sleep period, building the path that we draw. So drawing the line, then a rect, then moving to the point, then drawing a line, then a rect, then moving to the point again. So we have our generated path. And we can now call the drawPath in our previous scope, applying the vertical gradient brush with different colors. Great, so we have our bars drawn. But where are the lines that we generated? The default for drawPath is to actually use draw with a fill style. And for our purposes to get the lines drawn, we actually need to draw the same path with a stroke style. We can actually customize the stroke to set the rounded corners, and run them based on how far the expanded animation has progressed. And there it is. Now it's looking a bit more like hard design. So we have the info shown on the bars. And you can notice that the height animation actually hasn't changed here. The difference here is that we're no longer actually drawing the original rect but the segmented path in the collapsed state. But we're actually still missing that final magic touch. We want to make our background a bit more dynamic by adding these yellow wavy bars to the top. And we will actually use shaders to do this. Now previously to use shaders on Android, we needed Surface View with OpenGL. But now with AGSL, shaders can easily be incorporated into DrawScope, drawing calls. AGSL stands for Android Graphics Shading Language. It's based on GLSL, and it runs on Android T and up. Now, shaders also run on the GPU, so they perform individual pixel calculations in a very efficient way. So we have our shader that now we're taking from online shader tool. Let's see how we can actually integrate this in Compose. On the JetLaggedScreen, we actually want to draw this as a background to the top of our bar. And then what we do is we take the actual shader from one of the online tools, with a few adjustments to make it fit the AGSL language spec. And this is a bit complicated math, so we won't get into this right now. We use modifiers drawWithCache on the column, as we want the shader in shader brush objects to actually be cached. In order to compile a shader, we actually create an instance of the runtime shader, giving our AGSL string as input. And we then create the shader brush with the shader input. We set the resolution of the shader to the width and the size of the drawing area. And then we call drawRect and onDrawBehind with this shader brush. And this will actually draw our background without any movement so far. So we can see the static result of calling this drawRect with our shader. But as we mentioned, there is still no movement. So let's fix that. In order to add movement, we actually need to create the animation and pass the animation time into our shader. We will actually start this by creating a time variable that increments itself using this following function. And this will be updated on every single frame. We then set the uniform variable over shader to take in this time that is being constantly updated. So let's see what this looks like now. Great. So now our shader actually applies movement over time, hopefully making you a bit more peaceful and feeling a bit more relaxed when you see it. And yeah, that's it. So today we've actually learned how to do some pretty custom things in Compose, using custom layouts and its own measuring and placement logic, drawing bars in a very dynamic way. And also, we've learned all about different graphics APIs that are available to you to draw and animate custom things. So in order to take a look at the code, take a look at the JetLagged sample that we have. And for more information on layout and graphics, you can look at the following resources. That's it. Thank you. [APPLAUSE] [MUSIC PLAYING]
Info
Channel: Android Developers
Views: 49,652
Rating: undefined out of 5
Keywords: Jetpack Compose, build solutions, custom screens, Compose Layouts, Compose Graphics, customizable design, custom graph, Compose drawing operations, animations, Sleep Tracker, Sleep Tracker sample app, Android Dev Summit, Android Developers Summit, Android Dev Summit 2022, ADS, ADS 22, ADS ‘22, ADS 2022, Dev Summit, Android developer, android developers, android dev, android devs, android announcements, android announcement, app developer, developer, application developer
Id: xcfEQO0k_gU
Channel Id: undefined
Length: 20min 25sec (1225 seconds)
Published: Tue Oct 25 2022
Related Videos
Note
Please note that this website is currently a work in progress! Lots of interesting data and statistics to come.