Как я создал трекер бюджета с помощью Jetpack Compose

Как я создал трекер бюджета с помощью Jetpack Compose

3 февраля 2023 г.

Я всегда с нетерпением жду возможности узнать что-то новое, и на этот раз я решил изучить Jetpack Compose.

Обычно я использую Google Sheets для отслеживания своих доходов/расходов, поэтому я решил создать приложение с помощью Compose, которое будет делать то же самое.

В этой статье я покажу ошибки, которые я допустил, и то, как я создал некоторые из настраиваемых компонентов, которые использовались в проекте.

Главный фоновый баннер

Personal Finance Tracker home header

Я искал вдохновения и наткнулся на несколько других приложений, в которых использовался этот изогнутый фон, поэтому я решил использовать его для домашнего заголовка.

Этот компонент довольно прост, у нас есть изогнутый фон и остальное содержимое над ним. Что делает это немного сложнее, так это тот факт, что высота фона не статична, я сделал ее такой, чтобы она адаптировалась к содержимому, отображаемому над ним.

Сначала я начал с создания нестандартной формы с нужной мне кривизной. Для этого я расширил GenericShape и использовал cubicTo, чтобы нарисовать его. Я пришел к этим числам методом проб и ошибок, вы можете изменить их, если хотите, чтобы ваша фигура выглядела по-другому.

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

После того, как у меня была пользовательская форма, я создал компонент заголовка.

@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, ...)
        }
    }
}

Далее:

  1. Я определил некоторые переменные, чтобы указать, насколько большим должно быть расстояние между компонентами, при необходимости вы можете настроить их.
  2. Затем я рассчитал contentTopPadding, это то, что делает блок адаптируемым к разным размерам содержимого.
  3. После этого я добавил столбец, который рисует изогнутый фон, его дочерние элементы рисуются внутри поля (Добро пожаловать обратно и Доходы/Расходы).
  4. Ниже я добавил блок, в котором размещается содержимое, показанное под изогнутым фоном (меню «Сетка»).

На изображении ниже вы можете видеть, что я добавил еще один компонент, и размер блока изменился правильно.

Personal Finance Tracker home header

Анимация кнопки выбора

Personal Finance Tracker category type filter

Это простой фильтр, который использует поле на заднем плане, чтобы указать, какой параметр выбран в данный момент.

Здесь происходит 3 вещи:

  1. Ширина поля изменяется
  2. Положение поля меняется
  3. Радиус угла изменяется (посмотрите на внутренние углы, когда выбран только 1 вариант)

Мы используем animateDpState, чтобы легко анимировать значения dp. К сожалению, это невозможно для угловых форм, поэтому нам приходится создавать новую форму каждый раз, когда изменяется радиус.

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,
        )
    }
}

Затем нам просто нужно определить компоненты, а Compose обрабатывает всю анимацию.

Мне понравилось, как получилась эта кнопка, учитывая ее простоту.

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

Вертикальная/горизонтальная прокрутка

Personal Finance Tracker expenses by category by month

У меня есть таблица, на которой показано, сколько я тратил по категориям по месяцам. Это довольно легко визуализировать, когда вы используете большой экран, но для мобильных устройств мне пришлось придумать другой подход.

Мне не нравится ландшафтный режим, поэтому я даже не рассматривал его здесь как вариант. В итоге я сделал все прокручиваемым, но месяцы и категории были фиксированными.

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, ...
}

Если вы еще раз посмотрите на видео выше, вы увидите, что всякий раз, когда я прокручиваю основной контент, другие части также прокручиваются. Я обнаружил, что можно использовать один и тот же ScrollState несколько раз, я понятия не имел, что это сработает, но с помощью одного и того же ScrollState я могу синхронизировать все компоненты.< /p>

Если вы хотите увидеть, как я создал весь этот компонент, вы можете взглянуть на этот файл.

Весь код для этого проекта доступен в этом репозитории.


ПС:

Я далеко не эксперт в Compose. В этой статье показано, как я создавал эти компоненты, используя знания, которые у меня были на тот момент, а это означает, что это не обязательно самый производительный код или самый простой способ сделать определенную вещь. Если вы знаете, что можно улучшить, сообщите мне об этом в комментариях.


Также опубликовано здесь

Фото на обложке Маркус Винклер на Unsplash


Оригинал
PREVIOUS ARTICLE
NEXT ARTICLE