본문 바로가기
프로그래밍/Jetpack Compose

[Jetpack Compose] Canvas 를 이용하여 원을 그리고 진행률을 나타내보기

by dev_gyu 2025. 5. 4.
728x90

요즘 한참 코딩 테스트 문제들을 푸느라 다른 공부들을 안해왔었는데, 문득 과거 면접 주제 중 하나였던 Canvas 를 이용하여 원을 그리고 그 안에서 진행율을 변화시켜주는 코드를 만들라던 문제가 생각나 이를 주제로 글을 하나 작성해보려고 한다.


# Canvas 란?

들어가기에 앞서 Canvas 에 대해 간단히 설명해보려고 한다.

Canvas 란 Jetpack Compose 에서 직접 그래픽을 그릴 수 있는 도화지라고 보면 된다.

이러한 Canvas 를 통해 개발자는 자신이 희망하는 그래픽을 생성하고, 이를 통해 UI 구성 중 하나로 사용할 수 있다.

 

이러한 Canvas 를 통한 그림 그리기 방법으로는 아래와 같은 방식들이 존재한다.

  • drawLine() - 직선 그리기
  • drawRect() - 사각형 그리기
  • drawCircle() - 원 그리기
  • drawOval() - 타원 그리기
  • drawArc() - 원호(부채꼴) 그리기
  • drawRoundRect() - 모서리가 둥근 사각형
  • drawPath() - 자유로운 도형 (곡선, 꺽인 선 등 유저가 지정한 Path 값)
  • drawPoints() - 점 리스트
  • drawIntoCanvas() - android.graphics.Canvas API 에 접근할 수 있도록 해줌

나는 이러한 방식 중 drawCircle 과 drawArc 를 통해 원형과 원형 프로그레스 (내부 게이지) 를 표현해줄 것이다.

 

# DrawCircle 을 통한 원형 만들기

우선 다음 코드를 구현하자.

@Composable
fun CircleProgressCanvas(
    modifier: Modifier = Modifier,
    sweepAngleState: State<Float> = remember { mutableFloatStateOf(40f) }
) {
    Canvas(modifier = modifier) {
        val circleStrokeSize = 1.dp.toPx()
        val strokeGap = 10.dp.toPx()
        val circleStroke = Stroke(width = circleStrokeSize)
        val outerRadius = this.size.minDimension / 2f - circleStrokeSize / 2f
        val innerRadius = outerRadius - strokeGap - circleStrokeSize

        drawCircle(
            color = Color.Black,
            style = circleStroke,
            radius = outerRadius
        )

        drawCircle(
            color = Color.Black,
            style = circleStroke,
            radius = innerRadius
        )
}

 

위와 같은 코드를 구현하게 된다면 다음과 같은 그래픽이 생성될 것이다.

 

 

위 코드에서 각 Radius 를 구할 때 size.minDimension / 2 를 베이스로 지정해주고 있는데, 이는 width / 2 나 height / 2 로 지정 시 Canvas 크기 변동에 따라 그래픽이 Canvas 를 벗어날 수 있어서 해둔 설정이다.

또한 circleStrokeSize 의 경우 이름 그대로 원의 외곽선 사이즈인데, Storke 는 그려질 때 2dp 라면 바깥쪽으로 1dp, 안쪽으로 1dp 가 그려지기 때문에 radius 계산 시 / 2 를 해줘야한다.

 

그림을 그렸다면 이 Inline, Outline Circle 의 사이 부분에 게이지를 채워보자.

# DrawArc 를 통한 원호 그리기

 

 

우리의 목표는 위와 같이 원형 내부에 파란색 게이지를 채워주는데, 이를 Rounding 처리해주는 것이다.

이를 위해서는 DrawArc 를 사용하여 원 사이에 배치해주면 된다.

 

Canvas 의 Scope 내에 다음과 같은 코드를 이어서 추가해주자.

// 바깥 원 반지름 - 안쪽 원 반지름을 구하면 게이지 두께가 나온다.
val gaugeThickness = outerRadius - innerRadius
// 링의 정중앙 (그려지는 부분) 은 바깥 원+ 안쪽 원의 사이이므로 바깥 원 + 안쪽 원 반지름 / 2 를 구하면 게이지의 반지름이 나온다.
val gaugeRadius = (innerRadius + outerRadius) / 2f
// 정중앙 - gaugeRadius = 좌측 상단 좌표
val gaugeTopLeft = Offset(
    x = size.width / 2f - gaugeRadius,
    y = size.height / 2f - gaugeRadius
)

drawArc(
    color = Color.Blue,
    startAngle = -90f, // 그려질 시작 지점
    sweepAngle = sweepAngleState.value, // 그려질 최종 지점
    useCenter = false, // 호의 중심에 연결할지
    topLeft = gaugeTopLeft, // 호의 좌상단이 Canvas 에 위치될 지점
    size = Size(gaugeRadius * 2, gaugeRadius * 2), // 호의 사이즈
    style = Stroke(width = gaugeThickness, cap = StrokeCap.Round) // 호의 선 스타일
)

 

기본적으로 Android Canvas 의 경우 degree 가 0f 인 경우 3시 방향을 의미하기에, 0도에 놓기 위해 -90f 를 지정해주었다.

또한 sweepAngle 을 지정하여 startAngle 부터 sweepAngle 만큼 Arc 를 draw 해준다.

그 외의 설명은 내부 주석을 참고하고, 여기까지 따라왔다면 한 번 sweepAngle 을 조정해보자. 나는 40f 로 변경해보겠다.

 

@Composable
private fun GyuTestComposable(){
    val sweepAngle = remember { mutableFloatStateOf(40f) }

    Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
        Column(modifier = Modifier.padding(innerPadding).fillMaxSize()) {
            CircleProgressCanvas(
                modifier = Modifier.fillMaxWidth().height(400.dp),
                sweepAngleState = sweepAngle
            )
        }
    }
}

 

# Progress 에 애니메이션 추가

하지만 위처럼 게이지를 채워줄 때 아무런 효과도 없으니 뭔가 밋밋한 느낌이 든다.

그래서 우리는 Compose 에서 제공하는 animateFloatAsState 를 활용하여 목표 지점까지 값이 증가하는 애니메이션 효과를 지정해줄 것이다.

 

아래와 같이 sweepAngleState 와 sweepAngleSpec 및 TextField 를 추가해주자.

targetValue 는 State.value 가 들어가야하며, animationSpec 은 자신이 지정하고 싶은 애니메이션 타입을 지정해주면 된다. spring, tween 등이 존재하는데 나의 경우 spring 으로 지정해주었다.

@Composable
private fun GyuTestComposable(){
    var sweepAngleState by remember { mutableIntStateOf(360) }
    val sweepAngleSpec = animateFloatAsState(
        targetValue = sweepAngleState.toFloat(),
        animationSpec = spring(
            dampingRatio = DampingRatioLowBouncy, // 목표 지점 도달 시 바운스 효과 크기 설정
            stiffness = 300f // 애니메이션 지속 시간
        )
    )

    Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
        Column(modifier = Modifier.padding(innerPadding).fillMaxSize()) {
            CircleProgressCanvas(
                modifier = Modifier.fillMaxWidth().height(400.dp),
                sweepAngleState = sweepAngleSpec
            )

            TextField(
                value = sweepAngleState.toString(),
                onValueChange = { sweepAngleState = it.toIntOrNull() ?: 0 },
                modifier = Modifier.fillMaxWidth()
            )
        }
    }
}

 

결과적으로 아래와 같은 화면이 되었을 것이다.

 

이제 이 상태에서 텍스트필드에 값을 넣어줘보며 애니메이션을 테스트해보자.

 

728x90