[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]