paint-brush
How I Built a Budget Tracker with Jetpack Composeby@victorbrndls
236 reads

How I Built a Budget Tracker with Jetpack Compose

by Victor BrandaliseFebruary 2nd, 2023
Read on Terminal Reader
Read this story w/o Javascript
tldt arrow

Too Long; Didn't Read

I use Google Sheets to keep track of my income/expenses so I decided to build an app using Compose that would do same. In this article I’ll be showing the mistakes I made and how I built some of the customized components that were used in the project.
featured image - How I Built a Budget Tracker with Jetpack Compose
Victor Brandalise HackerNoon profile picture


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.


Home Background Banner


Personal Finance Tracker home header


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:

  1. I defined some variables to set how big the spacing between components should be, you can tweak them if needed.

  2. Then, I calculated contentTopPadding, this is what makes the box adaptable to different content sizes.

  3. After that I added the Column that draws the curved background, its children are drawn inside the box (Welcome back and Income/Expenses).

  4. 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.


Personal Finance Tracker home header


Selection Button Animation


Personal Finance Tracker category type filter


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:


  1. The box width changes
  2. The box position changes
  3. The corner radius changes (take a look at the inner corners when only 1 option is selected)


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(...) }
                )
        )
    }
}


Vertical/Horizontal Scroll


Personal Finance Tracker expenses by category by month


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