Bye XML, it was nice knowing you (pt. 2)
This article will focus on the basics of Compose and example of implementation of specific UI design (just looks — no state management nor architecture).
Not everything is explicitly defined for a reason. You should follow along with your own code and not just copy mine :)
If you really don’t want to code along or are just stuck, here is the full project.
Part 1 here and part 3 here.
In the first part of this series, we’ve learned what is Composable function, how do we write one, how to use basic layouts like Row
and Column
and what is Modifier.
In this part, we will learn about Material theme, how to write custom Modifiers and how to manage state.
The best way to learn is to just start and see where we’ll end up. Remember that it’s not about the result but the mistakes we’ve made along the way :)
Back to the design
Last time we started creating this header, but we only managed to do the location drop-down button in the top left corner of this image.
Our code might look like this:
So to sum up, we have HomeScreen
component with just a header and in the Header
component we just have a LocationSelector
component.
Next, we’ll try to do the location value below the selector. Right away, we can see that it’s pretty much the same. There is an icon with text next to it, but the color is some custom pink, and the text is big and bold.
If we zoom out a little bit and look at the whole design, we can see that the pink color is the most prominent, and in the Android world, we would call it a primary color. Before, we would define such colors in XML file and assign them in the theme where Views from the Material library would access and use them. Let’s see how we can achieve this in Compose world.
Material theme
If you created your project in Android Studio with Compose template, you might already have a theme defined. MaterialTheme
is a Composable function like any other but instead of emitting UI, it provides some values to components inside it. How exactly this works is not important right now, so let’s focus on the theme itself.
If you are interested, read something about CompositionLocals
(previously Ambients
)
The MaterialTheme
function takes 4 arguments: colors
, typography
, shapes
and content
. Colors are created with either lightColors()
or darkColors()
function (depends if isSystemInDarkTheme()
is false
or true
) but in our case, the light colors will suffice.
Our design doesn’t have many colors, so we’ll end up with something like this:
Note that both lightColors()
and darkColors()
do the same. The only difference is that the default colors have different values, so if you define everything, you can just use Colors()
constructor.
We will skip typography for simplicity, and I eyeballed the shapes to be RoundedCornerShape
with 10, 12 and 16 dp
size for small, medium, and large.
Now when we have both colors and shapes, we can wrap our HomeScreen
component with the material theme.
With the theme defined, we can access its values from our components by calling MaterialTheme.colors.primary
or MaterialTheme.shapes.medium
. Take the MaterialTheme
function as a “theme context provider,” which will contain all of the theme-related values for styling your components. You can even nest these themes and, for example, define a custom theme for each screen in your app.
Modifiers…again!
Now we know everything needed for that LocationValue
component, and it is pretty much the same as the LocationSelector
but with the icon on the left (now with our new primary color as a tint).
There is not similar text style in default Typography values, so I’ll leave it up to you if you want to define your own text style in MaterialTheme
or if you’ll just hardcode the values here.
We’re not doing anything fancy here, so I’ll stick to the latter option and add 28 sp
font size, FontWeight.W500
and FontFamily.SansSerif
as parameters to the Text
component.
The last thing missing in our header is that notification bell, and it should be pretty straightforward, right? Firstly, we need a white background with padding, the icon, and then the top right notification dot.
We know how to add the icon, but what about the rest? The answer is Modifiers.
Advanced modifier usage
Let’s get one thing out of the way, “the order of modifiers in the chain does matter.” For example, there is a significant difference between Modifier.padding(8.dp).background(Color.White)
and Modifier.background(Color.White).padding(8.dp)
. The first one will apply the padding first and then the background, and the second one will first draw the background and then apply the padding. That means that in the first example, the padding will be outside of the background and in the second example inside the background.
In our case, we’ll need background, padding, and size modifiers (in this order). We can use our defined surface color and medium shape from the Material theme for the background. I’ll leave the padding and size values up to you :)
With that done, let’s get to the harder part.
As in general with programming, there are multiple paths how to achieve one thing, and feel free to experiment for a bit before reading on.
For me, the best approach was to define a custom modifier where we’ll leverage the existing modifier drawWithContent
. The way to do this is actually pretty simple. First, we define our withNotificationDot
function with Modifier interface as a receiver (optionally with parameter dotRadius: Dp
). In the function body, we’ll just call (and return) composed
function, which is our gateway into the Modifier chain. This function takes lambda factory
parameter, where we will implement our custom modifier behavior.
First, let’s think of what we need. We certainly need the color of the dot and the color of its outer ring, but that’s easy; we’ll just use the primary and surface color from the Material theme as before. Then we’ll need the radius of the dot, and (I’ll spoil the surprise a little) because we’ll draw it on Canvas, we’ll need it in pixels rather than dp
. For this conversion, we’ll need density. Finally, we’ll need dimensions of the component to draw the dot in the right place. Luckily both density and size are included in DrawScope
which we’ll use.
drawWithContent
Now let’s get down to business and call drawWithContent
. We can see that it takes one lambda parameter onDraw
with ContentDrawScope
receiver, which is just DrawScope
with one extra method. This receiver will let us decide when/where we want to draw the component itself. We’ll use this functionality to draw the dot on top of the icon.
Now when we are inside the onDraw
function, we can just draw the two circles (dot and outline). I’ll encourage you again to experiment for a little bit before looking at the solution.
Finished? OK, let’s see the code :)
That wasn’t so hard, was it? If we wanted to do this in the View system, we would’ve needed at least 2 XML files (one for the dot drawable and one for the layout where we would use it).
Finishing touches
Now when we have all the components we need, we can finally put them together. Now the question is, what layout will we use? It certainly can’t be just Column
or Row
. One option is to use Compose version of Constraint Layout, but I would advise against that. Even in compose docs, there is a note about this:
In the View system,
ConstraintLayout
was the recommended way to create large and complex layouts, as a flat view hierarchy was better for performance than nested views are. However, this is not a concern in Compose, which is able to efficiently handle deep layout hierarchies.
So I would say that sometimes less is more, and I would be more inclined to rather use a combination of rows and columns, which will be more than enough in most cases. But if you know that ConstraintLayout
is what you really need, then don’t hesitate to use it.
I think that using a combination of Row
and Column
is the right way, so let’s use that. The location selector and value are in one column next to the notification bell. I’ll leave the implementation up to you, but I’ll give you a hint: you will probably need fillMaxWidth()
modifier and SpaceBetween
arrangement.
Moving on: Search
With the header out of the way, let’s get to the second part, the search bar. This will be pretty simple (only ~50 lines of code), but we will get to a new concept: State.
The first thing we will do is to add an OutlinedTextField
which is pretty much exactly what is in the design, the only difference being the corner radius. Unfortunately, at the time of writing (compose beta 4), there is still no way to customize the shape of the outline, so the default radius will have to suffice (you can follow this issue).
When we call the OutlinedTextField
function, we can notice that two parameters are required: value
and onValueChange
. If we just pass an empty string and empty lambda and run the app, we will see that nothing happens. To understand this, we will have to sidetrack again.
State
This is one of the most important concepts in Compose (and declarative UI frameworks in general) so research a bit more if everything is not clear (I would recommend this video).
One of the biggest improvements from the View system is that you hold the state, not the View you use. I can recall at least a dozen times where I needed to intercept TextField changes, but it’s not an easy nor intuitive thing to do in the View system. We hold the state in Compose so we can decide what is and what is not a valid state. But enough praise, and let’s get to the detail.
In the previous article, I talked about Composable functions being able to remember the previous invocation, but I didn’t go into details. The way Compose is exposing this functionality is with remember {}
function. When called, the remember function will execute the calculation
lambda and remember the result. Then on every recomposition, it will return the same object and won’t recalculate again unless one of the key parameters changes (This is similar behavior to useState
in ReactJS).
OK, so we can remember something between recompositions, so let’s jump back to our search text field and use the remember
function like this:
We save our initial state to searchValue
and whenever the text field registers a change, we just mutate it. Seems like this could work, but when we run the code and try to type into the text field, we can see that nothing changes. Why is that?
There are a couple of problems with this. The first one is that we are indeed mutating the variable searchValue
but only in the local function scope, so if the function recomposes, the variable will be overwritten by the cached value in remember{}
. The second and more important problem is that our function is not recomposed at all because the Compose runtime doesn’t know that something changed. remember
itself won’t save us here, so we’ll have to reach for some additional tool - State
.
Compose documentation describes State
interface as a “value holder where reads to the value property during the execution of a Composable function will subscribe it to changes of that value,” and that looks like exactly what we need. We can instantiate this state using mutableStateOf
(similarly to mutableListOf
from Kotlin standard library) for basic objects or mutableStateListOf
for lists. So let’s update our code:
When we run this, we get exactly the behavior we wanted. Let’s sum up what exactly is happening when we type something into the text field:
onValueChange
is called- we call the setter of
searchValue
with new value - Compose runtime will recompose our function because it knows that
OutlinedTextField
is readingsearchValue
state and it has been updated OutlinedTextField
is called with the updated value
If you want to know more about the State and recomposition, I suggest reading this article by Zach Klippenstein.
Summary
This article is getting a little bit too long, so I’ll end it on this. The rest of the Search component should be pretty straightforward, but you can look into my implementation if you get stuck on something.
In the next part, we’ll focus on LazyRow
/ LazyColumn
, the equivalent of RecyclerView
.