I always look forward to learning new things, and this time Jetpack Compose was the thing I decided to learn.
Usually, I use Google Sheets to keep track of my income/expenses so I decided to build an app, using Compose, that would do the same.
In this article, I’ll show the mistakes I made and how I built some of the customized components that were used in the project.
I was looking for inspiration and came across some other apps that used this curved background, so I decided to use it for the home header.
This component is pretty simple, we basically have the curved background and the rest of the content above it. What makes this a little bit more complicated is the fact that the background height is not static, I made it so it adapts to the content that’s being displayed above it.
First I started by creating a custom shape that has the curvature I wanted. To do that, I extended GenericShape
and used cubicTo
do draw it. I arrived at these numbers by trial and error, you can change them if you want your shape to look different.
class SemiOvalShape : Shape by GenericShape(builder = { size, _ ->
lineTo(size.width, 0f)
relativeLineTo(0f, size.height * 0.8f)
cubicTo(
x1 = size.width * .7f,
y1 = size.height,
x2 = size.width * .3f,
y2 = size.height,
x3 = 0f,
y3 = size.height * 0.8f
)
})
After I had the custom shape, I created the header component.
@Composable
private fun HomeHeader(
...
) {
Box {
val density = LocalDensity.current
// How big the curved section of the box is
val backgroundBottomPadding = 96.dp
// How much spacing you have between the content inside your box and the content placed below it
val bottomSpacer = 16.dp
// Set to 264.dp initilly just so the calculatation below doesn't result in a negative number
var boxHeight by remember { mutableStateOf(264.dp) }
val contentTopPadding by remember {
derivedStateOf { boxHeight - backgroundBottomPadding + bottomSpacer }
}
Column(
modifier = Modifier
.fillMaxWidth()
// Code that draws the curved background
.background(remember {
Brush.verticalGradient(listOf(Purple40, Purple20))
}, remember {
SemiOvalShape()
})
.padding(horizontal = 16.dp)
.onSizeChanged {
boxHeight = with(density) { it.height.toDp() }
}
) {
... // The content that's shown inside the box goes here (Welcome Back, Income/Expenses)
Spacer(modifier = Modifier.height(backgroundBottomPadding))
}
Box(
modifier = Modifier
.padding(top = contentTopPadding)
.fillMaxWidth()
) {
... // The content that's shown below the box goes here (Add Expense, Add Income, ...)
}
}
}
Next:
I defined some variables to set how big the spacing between components should be, you can tweak them if needed.
Then, I calculated contentTopPadding
, this is what makes the box adaptable to different content sizes.
After that I added the Column that draws the curved background, its children are drawn inside the box (Welcome back and Income/Expenses).
Below that, I added the Box that hosts the content shown below the curved background (Grid menu).
In the image below you can see I added another component and the box resized itself correctly.
This is a simple filter that uses a box in the background to indicate which option is currently selected.
There are 3 things happening here:
We use animateDpState
to easily animate dp values. Unfortunately, that’s not possible for corner shapes so we have to create a new shape every time the radius changes.
val selectionOffsetX by animateDpAsState(
targetValue = if (selected == CategoryType.EXPENSE) halfBoxWidth else 0.dp,
)
val selectionWidth by animateDpAsState(
targetValue = if (selected == null) boxWidth else halfBoxWidth,
)
val leftCornerRadius by animateDpAsState(
targetValue = when (selected) {
CategoryType.EXPENSE -> 0.dp
else -> 4.dp
}
)
val rightCornerRadius by animateDpAsState(
targetValue = when (selected) {
CategoryType.INCOME -> 0.dp
else -> 4.dp
}
)
val selectionShape by remember {
derivedStateOf {
RoundedCornerShape(
topStart = leftCornerRadius,
bottomStart = leftCornerRadius,
topEnd = rightCornerRadius,
bottomEnd = rightCornerRadius,
)
}
}
Then, we just need to define the components, and Compose handles for all the animation.
I liked the way this button turned out, given its simplicity.
Box(modifier = modifier.width(boxWidth)) {
// this is the background that moves to hightlight what's currently selected
Box(
modifier = Modifier
.offset(x = selectionOffsetX)
.width(selectionWidth)
.height(buttonHeight)
.background(Purple20, selectionShape)
)
Row {
Button(
modifier = Modifier
...
.clickable(
// remove ripple from button
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = { onSelected(...) }
)
)
Button(
modifier = Modifier
...
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = { onSelected(...) }
)
)
}
}
I have a sheet that shows how much I spent by category by month, that’s pretty easy to visualize when you’re using a big screen but for mobile devices, I had to come up with a different approach.
I don’t like landscape mode so I didn’t even consider that as an option here. What I ended up doing was making everything scrollable, but the months and categories are fixed.
val verticalScroll = rememberScrollState()
val horizontalScroll = rememberScrollState()
Row(
modifier = Modifier.horizontalScroll(horizontalScroll)
) {
... // Oct/22, Sep/22, ...
}
Column(
modifier = Modifier
.verticalScroll(verticalScroll)
) {
// Food, Gym, Car, Subscriptions
}
Column(
modifier = Modifier
.verticalScroll(verticalScroll)
.horizontalScroll(horizontalScroll)
) {
// 563, 212, 525, 222, 662, 12, 661, ...
}
If you take another look at the video above, you can see that whenever I scroll the main content, the other parts are also scrolled. I discovered that it’s possible to use the same ScrollState
multiple times, I had no idea this would work but by using the same ScrollState
I can get all components to be synchronized.
If you want to see how I built this whole component, you can take a look at this file.
All the code for this project is available in this repository.
PS:
I’m far from being an expert in Compose. This article shows how I built these components using the knowledge I had at the time, meaning that this is not necessarily the most performant code nor the simplest way to do a certain thing. If you know of anything that could be improved, please let me know in the comments.
Also published here.
Cover photo by Markus Winkler on Unsplash