Jetpack Compose로 가로 막대 차트 만들기 (Canvas에 그리기, 기존 뷰에 추가, 애니메이션,커스텀 위젯)

jibmin jung·2022년 6월 3일
0
post-thumbnail

요즘 경진대회에 출품하기 위해 만들고 있는 프로젝트에서 막대그래프를 넣을 일이 생겨서,
jetpack compose의 canvas를 이용해서 만들어봤습니다.

완성한 모습

1. Composable 만들기

//ProportionBarWidget.kt
@Composable
fun ProportionBar() {
    Canvas() {
    	//DrawScope
    }
}

먼저 컴포저블 함수를 만들고 ProportionBar라고 이름 붙혀주겠습니다.
내부에는 그림(차트)를 그릴 수 있게 Canvas()를 만들겠습니다.

2. Canvas 안에서 그림 그리는 법

//ProportionBarWidget.kt
@Composable
fun ProportionBar() {
    Canvas() {
    	//DrawScope
        drawRect(
                    color = 색상,
                    topLeft = Offset(시작 지점 x 오프셋, 시작 지점 y 오프셋),
                    size = Size(가로,세로)
                )
    }
}

DrawScope 안에서
drawRect() 같은 함수를 이용해서 그릴 수 있습니다.

여러 모양에 따라 함수들이 있는데요, 저는 사각형을 그리기 위해 drawRect를 써보겠습니다.

@Preview(widthDp = 300, heightDp = 200, showBackground = true)
@Composable
fun PreviewTest(){
    Canvas(modifier = Modifier.fillMaxSize()){
        drawRect(
            color = Color.Black,
            topLeft = Offset(40f, 80f),
            size = Size(100f, 150f)
        )
    }
}

  • PreviewTest 컴포저블의 프리뷰입니다.
  • 배경 모서리 (0,0)에서 오프셋(40,80)만큼 떨어져서, 크기(100,150)으로 검정색 사각형을 그렸습니다.
@Preview(widthDp = 300, heightDp = 200, showBackground = true)
@Composable
fun PreviewTest(){
    val colors = listOf(Color.Black,Color.Red,Color.Blue,Color.Green)
    Canvas(modifier = Modifier.fillMaxSize()){
        var start = 30f
        var end = 0f
        for (data in 1..3){
        	//끝점을 시작점으로부터 data*50만큼 떨어뜨리기
            end = start + data*50
            drawRect(
                color = colors[data],
                topLeft = Offset(start, 80f),
                size = Size(end-start, 150f)
            )
            //다음 시작점은 현재의 끝점
            start = end
        }
    }
}


저는 사각형을 옆으로 쌓아나가기 위해 for문으로 사각형을 이어그려주었습니다.

앞뒤 라운딩 처리하기

모서리가 둥근 한 개의 사각형은 drawRoundRect를 이용해서 그릴 수 있습니다.

@Preview(widthDp = 300, heightDp = 200, showBackground = true)
@Composable
fun PreviewTest(){
    val colors = listOf(Color.Black,Color.Red,Color.Blue,Color.Green)
    Canvas(modifier = Modifier.fillMaxSize()){
        var start = 30f
        var end = 0f
        for (data in 1..3){
            end = start + data*50
            drawRoundRect(
                color = colors[data],
                topLeft = Offset(start, 80f),
                size = Size(end-start, 150f),
                cornerRadius = CornerRadius(25f)
            )
            start = end
        }
    }
}


그러나 제가 원하는 것은 전체의 앞과 뒤만 라운딩 처리하고 싶기 때문에,
clipPath라는 것을 이용했습니다.
clipPath는 DrawScope의 확장함수로써 path, clipOp, drawing block을 받습니다.
넘겨주는 path와 겹치는 부분 안쪽으로 그리는 영역을 줄여주는 역할을 할 수 있습니다.
예를 들면, 원형 path를 넘겨주고 이미지를 그리면 path의 모양을 갖는 원형 이미지를 얻을 수 있습니다.

@Preview(widthDp = 300, heightDp = 200, showBackground = true)
@Composable
fun PreviewTest(){
    val colors = listOf(Color.Black,Color.Red,Color.Blue,Color.Green)
    Canvas(modifier = Modifier.fillMaxSize()){
        val path = Path().apply {
            addRoundRect( //모서리가 둥근 사각형을 path 에 추가
                RoundRect(
                    Rect(
                        offset = Offset(30f, 80f),
                        size = Size(290f, 150f)
                    ),
                    CornerRadius(45f)
                )
            )
        }
        clipPath(path){ //path 안쪽에 그리기
            var start = 30f
            var end = 0f
            for (data in 1..3){
                end = start + data*50
                drawRect(
                    color = colors[data],
                    topLeft = Offset(start, 80f),
                    size = Size(end-start, 150f)
                )
                start = end
            }
        }
    }
}

그리는 것은 이렇게 완성입니다!
이제 데이터와 색상을 받아서, 데이터에 맞게 그릴 수 있도록 해줍니다.

3. 데이터 인자 추가하기

다시 ProportionBar() 함수로 돌아와서, 데이터 인자를 만들어줍니다.
필요한 것은

  • 차트에 들어갈 변량들
  • 변량들의 색상
  • 차트 두께
  • 모서리 라운딩 반경
  • 기타 옵션을 위한 Modifier
@Composable
fun ProportionBar(
    data: List<Number>,
    colors: List<Color>,
    strokeWidth: Float,
    cornerRadius: CornerRadius = CornerRadius(strokeWidth),
    animate: Boolean,
    modifier: Modifier
) {
	Canvas(modifier = modifier){
    }
}

cornerRadius는 default로 strokeWidth만큼 갖도록 했습니다.

4. 그리기

2번에서 본 방식을 이용해, 그리기 부분을 완성시킵니다.

@Composable
fun ProportionBar(
    data: List<Number>,
    colors: List<Color>,
    strokeWidth: Float,
    cornerRadius: CornerRadius = CornerRadius(strokeWidth),
    modifier: Modifier
) {
    val sumOfData = data.map { it.toFloat() }.sum()
    Canvas(
        modifier = modifier
    ) {
    	//canvas size 폭의 5%, 95% 지점을 시작점과 끝점으로 했습니다.
        val lineStart = size.width * 0.05f
        val lineEnd = size.width * 0.95f
        //차트 길이
        val lineLength = (lineEnd - lineStart)
        //(canvas높이 - 차트 높이) * 0.5 를 하면 차트를 그릴 위쪽 오프셋을 구할 수 있습니다.
        val lineHeightOffset = (size.height - strokeWidth) * 0.5f
        val path = Path().apply {
            addRoundRect(
                RoundRect(
                    Rect(
                        offset = Offset(lineStart, lineHeightOffset),
                        size = Size(lineLength, strokeWidth)
                    ),
                    cornerRadius
                )
            )
        }
        val dataAndColor = data.zip(colors)
        clipPath(
            path
        ) {
        	var dataStart = lineStart
            dataAndColor.forEach { (number, color) ->
            	//끝점은 시작점 + (변량의 비율 * 전체 길이)
                val dataEnd =
                    dataStart + ((number.toFloat() / sumOfData) * lineLength)
                drawRect(
                    color = color,
                    topLeft = Offset(dataStart, lineHeightOffset),
                    size = Size(dataEnd - dataStart, strokeWidth)
                )
                //다음 사각형의 시작점은 현재의 끝점
                dataStart = dataEnd
            }
        }

    }
}

ProportionBar 프리뷰

@Composable
@Preview(widthDp = 300, heightDp = 200, showBackground = true)
fun ProportionBarPreview() {
    ProportionBar(
        data = listOf(1, 2, 0, 3),
        colors = listOf(Color.Black, Color.Red, Color.Green, Color.Blue),
        strokeWidth = 180f,
        modifier = Modifier.fillMaxSize()
    )

}

5. remember 사용

compose관련 영상을 보면 항상 composable 함수는 자주 실행될 수 있다고 합니다.
(re)compose 될 때마다 sumOfData를 구할 필요는 없으므로 remember를 이용해줍니다.

@Composable
fun ProportionBar(
	//인자 생략
) {
    val sumOfData = remember(data) { data.map { it.toFloat() }.sum() }
    Canvas(
        modifier = modifier
    ) {
    	//그리기 생략
    }
}

이렇게하면 key인 data가 변경되지 않았을 때, sumOfData는 이전에 계산한 값을 반환해줍니다.

6. animation 추가

ProportionBar에 animate 인자를 추가해서 true면 표시하고, false면 사라지도록 했습니다.

@Composable
fun ProportionBar(
    data: List<Number>,
    colors: List<Color>,
    strokeWidth: Float,
    cornerRadius: CornerRadius = CornerRadius(strokeWidth),
    animate: Boolean,
    modifier: Modifier
) {
    val animationProgress: Float by animateFloatAsState(
        targetValue = if (animate) 1f else 0f,
        tween(2000)
    )
    val sumOfData = remember(data) { data.map { it.toFloat() }.sum() }
    Canvas(
        modifier = modifier
    ) {
        val lineStart = size.width * 0.05f
        val lineEnd = size.width * 0.95f
        val lineLength = (lineEnd - lineStart) * animationProgress
        
        //생략
    }
}

animate가 true인지 false인지에 따라 변화하는 animationProgress를 animateFloatAsState를 이용해서 만들어 주었습니다.
animate 변수에 따라 animationProgress는 0~1.0 사이에서 변화할 것입니다.
이것을 lineLength에 곱해서 전체 차트의 길이를 애니메이팅 해주었습니다.

scrollView에 scroll 리스너를 달아서 차트가 표시되면 animate를 true, 안보이면 false가 되도록 했습니다.

7. 기존 뷰에 추가

컴포저블로 만든 위젯을 기존 뷰에 추가하는 방법 중 하나는 래퍼클래스를 만드는 것입니다.

class ProportionBarView @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyle: Int = 0
) : AbstractComposeView(context, attrs, defStyle) {

    var data by mutableStateOf<List<Number>>(listOf())
    var colors by mutableStateOf<List<Color>>(listOf())
    var strokeWidth by mutableStateOf<Float>(0f)
    var animate by mutableStateOf<Boolean>(false)
    var modifier by mutableStateOf<Modifier>(Modifier)

    @Composable
    override fun Content() {
        ProportionBar(
            data = data,
            colors = colors,
            strokeWidth = strokeWidth,
            animate = animate,
            modifier = modifier
        )
    }
}

xml에서도 ProportionBarView를 이용해서 기존 뷰처럼 넣어주면 됩니다.
그리고 activity에서

binding.monthlyLogBarChart.apply {
	data = amountOfEachEmotionMap.values.toList()
	colors = colorList
	strokeWidth = 160f
	modifier = Modifier.fillMaxSize()
}

이렇게 데이터를 넣어주어 이용할 수 있습니다.

profile
이것저것 안드로이드

0개의 댓글