[Android] 막대 그래프

uuranus·2024년 10월 19일
0
post-thumbnail

기존 UI

포켓몬 슬립의 마이페이지에는 주간 수면 시간에 대한 막대그래프를 보여준다. 이 막대 그래프를 만들어볼 것이다.

기본 UI

@Composable
fun SleepDurationGraph(
    modifier: Modifier = Modifier,
    xLabelCount: Int,
    xLabel: @Composable (Int) -> Unit,
    bar: @Composable (Int) -> Unit,
    barLabel: @Composable (Int) -> Unit = {},
    yLabelsInfo: List<YLabel>,
    yLabel: @Composable (yLabel: YLabel) -> Unit,
    minYPosition: Int = 0,
    maxYPosition: Int = 100,
) {
    val xLabels = @Composable {
        repeat(xLabelCount) {
            xLabel(it)
        }
    }

    val yLabels = @Composable {
        repeat(yLabelsInfo.size) { index ->
            yLabel(yLabelsInfo[index])
        }
    }

    val durationBar = @Composable {
        repeat(xLabelCount) {
            bar(it)
        }
    }

    val barLabels = @Composable {
        repeat(xLabelCount) {
            barLabel(it)
        }
    }

    val density = LocalDensity.current

    val xAxisHeight = with(density) {
        2.dp.roundToPx()
    }
    
    val groupArea = @Composable {
        GraphArea(
            modifier = Modifier.fillMaxSize(),
            xLabelCount = xLabelCount,
            hideEdgeXTicker = true,
            maxYPosition = maxYPosition,
            minYPosition = minYPosition,
            yAxisWidth = 0,
            xAxisHeight = xAxisHeight,
            yLabelPositions = yLabelsInfo.map { it.position }
        )
    }
}

xLabel, ylabel 정보, 막대 그래프 각각에 대한 정보를 받아와서 준비한다.
GroupArea는 여기에서 어떻게 그렸는지 설명해놓았다.

배치하기

이제 만들어놓은 각 요소들을 배치해야 한다.

 Layout(
    contents = listOf(xLabels, yLabels, groupArea, durationBar, barLabels),
    modifier = modifier
        .padding(start = 16.dp, top = 16.dp, end = 24.dp, bottom = 16.dp)
) { 
    (xLabelsMeasurables, yLabelsMeasurables, graphAreaMeasurable, durationBarMeasurables, barLabelsMeasurables),
    constraints ->

    val adjustedConstraints = constraints.copy(
        minWidth = 0,
        minHeight = 0
    )

    val labelGraphPadding = 12.dp.roundToPx()

    var totalWidth = 0
    var xLabelMaxWidth = 0
    val xLabelPlaceables = xLabelsMeasurables.map { measurable ->
        val placeable = measurable.measure(adjustedConstraints)
        totalWidth += placeable.width
        xLabelMaxWidth = maxOf(xLabelMaxWidth, placeable.width)
        placeable
    }

    var yLabelMaxWidth = 0
    val yLabelsPlaceables = yLabelsMeasurables.map { measurable ->
        val placeable = measurable.measure(adjustedConstraints)
        yLabelMaxWidth = maxOf(yLabelMaxWidth, placeable.width)
        placeable
    }

    val groupAreaWidth = constraints.maxWidth - yLabelMaxWidth - labelGraphPadding
    val groupAreaHeight = constraints.maxHeight - xLabelPlaceables.first().height - labelGraphPadding

    val groupAreaPlaceable = graphAreaMeasurable.first().measure(
        constraints.copy(
            minWidth = groupAreaWidth,
            maxWidth = groupAreaWidth,
            minHeight = groupAreaHeight,
            maxHeight = groupAreaHeight
        )
    )

    val xLabelSpace = (groupAreaWidth - xLabelMaxWidth * xLabelCount) / xLabelCount

    val durationBarPlaceables = durationBarMeasurables.map { measurable ->
        val barParentData = measurable.parentData as TimeGraphParentData
        val barHeight = barParentData.duration

        measurable.measure(
            constraints.copy(
                minWidth = xLabelPlaceables.first().width * 9 / 10,
                maxWidth = xLabelPlaceables.first().width * 9 / 10,
                minHeight = (groupAreaHeight - xAxisHeight) * 
                            (barHeight - minYPosition) / (maxYPosition - minYPosition),
                maxHeight = (groupAreaHeight - xAxisHeight) * 
                            (barHeight - minYPosition) / (maxYPosition - minYPosition)
            )
        )
    }

    val barLabelsPlaceables = barLabelsMeasurables.map { measurable ->
        measurable.measure(adjustedConstraints)
    }

    layout(constraints.maxWidth, constraints.maxHeight) {
        groupAreaPlaceable.place(yLabelMaxWidth + labelGraphPadding, 0)

        val widthBaseline = yLabelMaxWidth + labelGraphPadding + xLabelSpace / 2
        var width = widthBaseline
        val heightBaseline = groupAreaHeight - xAxisHeight

        xLabelPlaceables.forEachIndexed { index, it ->
            it.place(
                width + xLabelMaxWidth / 2 - it.width / 2,
                constraints.maxHeight - it.height
            )

            val durationPlaceable = durationBarPlaceables[index]
            durationPlaceable.place(
                width,
                heightBaseline - durationPlaceable.height
            )

            val barLabelPlaceable = barLabelsPlaceables[index]
            val textHeight = heightBaseline - durationPlaceable.height - 12 - barLabelPlaceable.height

            barLabelPlaceable.place(
                width + durationPlaceable.width / 2 - barLabelPlaceable.width / 2,
                textHeight
            )

            width += xLabelMaxWidth + xLabelSpace
        }

        yLabelsPlaceables.forEachIndexed { index, it ->
            val height = heightBaseline - (groupAreaHeight - xAxisHeight) * 
                         (yLabelsInfo[index].position - minYPosition) / (maxYPosition - minYPosition)
            it.place(
                yLabelMaxWidth / 2 - it.width / 2,
                height + xAxisHeight / 2 - it.height / 2
            )
        }
    }
}

groupArea의 y축 가이드라인이랑 yLabel의 위치가 맞도록 고려해야 잘 계산해줘야 한다.

duration에 길이에 맞추기

  • bar의 길이 같은 경우는 duration에 길이에 맞춰서 전체 그래프 height에 비례해서 설정해줘야한다.
  • modifier를 통해 duration 길이를 전달받아서 측정, 배치 단계에서 사용해주었다.
@Stable
fun Modifier.timeGraphBar(
    duration: Int,
): Modifier {
    return then(
        TimeGraphParentData(
            duration = duration,
        )
    )
}

class TimeGraphParentData(
    val duration: Int,
) : ParentDataModifier {
    override fun Density.modifyParentData(parentData: Any?): TimeGraphParentData =
        this@TimeGraphParentData
}

최종 결과

깃허브 링크

https://github.com/uuranus/compose-animations

profile
Frontend Developer

0개의 댓글