요즘 한참 코딩 테스트 문제들을 푸느라 다른 공부들을 안해왔었는데, 문득 과거 면접 주제 중 하나였던 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()
)
}
}
}
결과적으로 아래와 같은 화면이 되었을 것이다.
이제 이 상태에서 텍스트필드에 값을 넣어줘보며 애니메이션을 테스트해보자.