Android Compose의 Canvas API는 다양한 그래픽 효과를 쉽게 구현할 수 있는 강력한 도구입니다.
이번 포스팅에서는 속도계를 구현하는 방법을 공유하려고 합니다. 이 속도계는 속도 값과 원형 게이지, 텍스트 등을 포함하여 실시간 데이터를 시각화할 수 있는 깔끔한 UI를 제공합니다.
Canvas로 속도계 만들기
위의 화면을 그리기 위해서는 기본적인 drawArc, drawCircle, drawText와 rotate 등에 대한 이해도가 필요합니다. 간단하게 설명하자면 호, 원, 텍스트를 그리거나 이를 회전시킨다는 개념입니다.
위의 화면은 가장 바깥의 arc, 눈금을 나타내는 line과 text, 그리고 가운데의 속도 표시를 위한 text(121 km/h), 그리고 그를 감싸는 원 등으로 구성되어있습니다. 그러면 하나씩 만들어보려고합니다.
@Composable
fun Speedometer() {
val speed by remember { mutableFloatStateOf(80f) }
val textMeasurer = rememberTextMeasurer()
Canvas(modifier = Modifier.aspectRatio(1f)) {
drawSpeedometerTicks() // 1. 속도계의 Arc와 눈금 그리기
}
}
1. 속도계의 Arc와 눈금 그리기
호를 그리기 위해서는 drawArc()함수를 사용합니다. 이때 시작할 degree값(startAngle)과 호의 각를 나타내는 sweepAngle을 적절히 설정해줍니다. 여기서는 135도에서 시작하여 270도만큼 그려주었습니다. 또한 useCenter를 false로 사용하여 중심점을 이용하지 않도록 하여 부채꼴 모양으로 그려지는 것을 방지해주었습니다.
fun DrawScope.drawSpeedometerTicks(
textMeasurer: TextMeasurer,
baseColor: Color = Color.Gray,
outerCircleWidth: Float = 10f,
maxHand: Float = size.minDimension / 10f,
minHand: Float = size.minDimension / 20f,
) {
// 가장 밖의 호를 그린다.
drawArc(
baseColor,
startAngle = 135f,
sweepAngle = 270f,
useCenter = false,
topLeft = Offset(outerCircleWidth / 2, outerCircleWidth / 2), // arc의 topleft
size = Size(
(size.minDimension - outerCircleWidth),
(size.minDimension - outerCircleWidth)
), // arc의 크기
style = Stroke(outerCircleWidth) // arc의 stroke는 center를 기준으로 설정됩니다.
)
}
눈금을 확인해보면 20단위마다 그 길이가 길어집니다. 또한 숫자는 20단위마다 나타나는 것을 확인할 수 있습니다. 눈금자를 표현하기 위해서는 drawLine() 함수를 사용하였으며 숫자 표시를 위해 drawText() 함수를 사용하였습니다.
drawLine은 기본적으로 startOffset과 endOffset을 설정해주어야합니다. 간단하게 설명하면 시작점과 끝점을 설정해주면 그 두 점을 잇는 선을 만들어주는 것입니다. 아래에서는 line을 눈금의 개수만큼 그리기 위해 for문을 사용하였으며 rotate를 활용하여 line을 회전시켜주었습니다.(만약 rotate를 사용하지 않는다면 아래의 text를 작성한 것처럼 offset의 좌표를 삼각함수를 이용하여 직접 계산해주어야합니다.)
drawText는 앞서 이야기했던것처럼 rotate를 사용하지 않았습니다. 그 이유는 rotate의 pivot이 canvas의 중앙값이기 때문에 텍스트도 함께 회전되어 나타나기 때문입니다. 그렇기 때문에 삼각함수를 사용하여 직접 offset의 좌표를 계산하였습니다.
fun DrawScope.drawSpeedometerTicks(
textMeasurer: TextMeasurer,
baseColor: Color = Color.Gray,
outerCircleWidth: Float = 10f,
maxHand: Float = size.minDimension / 10f,
minHand: Float = size.minDimension / 20f,
) {
// 가장 밖의 호를 그린다.
....
// 눈금을 그린다.
val innerRadius = size.minDimension / 2f - outerCircleWidth * 3 - maxHand - 60f
for (count in 0..45) {
rotate(-45f + count * 6f) {
drawLine(
baseColor,
start = Offset(outerCircleWidth * 3, size.minDimension / 2f),
end = Offset(
outerCircleWidth * 3 + if (count % 5 == 0) maxHand else minHand,
size.minDimension / 2f
),
strokeWidth = outerCircleWidth
)
}
// 20 단위마다 텍스트 표기를 위함
if (count % 5 == 0) {
// 삼각함수를 이용한 좌표 계산
val radian = Math.toRadians((135f + count * 6f).toDouble())
val textX = size.minDimension / 2f + innerRadius * cos(radian).toFloat()
val textY = size.minDimension / 2f + innerRadius * sin(radian).toFloat()
val text = (20 + count * 4).toString()
drawText(
textMeasurer = textMeasurer,
text = text,
topLeft = Offset(
textX - textMeasurer.measure(text).getLineRight(0) + 10f,
textY - textMeasurer.measure(text).lastBaseline
), // 텍스트 값을 각 눈금 기준으로 동일한 위치에 있도록 옮기기 위함
style = TextStyle(
color = baseColor,
fontSize = 20.sp,
fontWeight = FontWeight.Bold
)
)
}
}
}
위의 과정에 따라 코드를 작성하였을 때 아래와 같은 결과를 얻을 수 있습니다.
2. 속도값 나타내기
위의 결과 화면을 보면 속도를 표현하는 방법은 크게 2가지로 나타납니다.
- 텍스트를 직접 표시
- 게이지를 표시
먼저 속도값 텍스트를 표시하는 방법을 보겠습니다.
@SuppressLint("DefaultLocale")
fun DrawScope.drawSpeedDisplay(
speed: Float,
textMeasurer: TextMeasurer,
outerCircleWidth: Float = 10f,
maxHand: Float = size.minDimension / 10f,
minHand: Float = size.minDimension / 20f,
color: Color = Color.Cyan
) {
// 속도를 텍스트로 변환
val speedStr = String.format("%.0f", speed)
val speedText = buildAnnotatedString {
withStyle(
SpanStyle(
color = color,
fontSize = 52.sp,
fontWeight = FontWeight.ExtraBold,
)
) {
append(speedStr)
}
withStyle(SpanStyle(fontSize = 14.sp, fontWeight = FontWeight.Medium)) {
append("\nkm/h")
}
}
// 텍스트를 감싸는 원형 배경
drawCircle(
Brush.radialGradient(0.25f to Color.DarkGray, 1f to Color.White.copy(0.2f)),
radius = size.width / 6f,
style = Stroke(10f, pathEffect = PathEffect.cornerPathEffect(10f)),
)
...
// 텍스트를 그리기
drawText(
textMeasurer = textMeasurer,
text = speedText,
topLeft = Offset(
0f,
size.height / 2f - textMeasurer.measure(speedText).size.height / 2f
),
style = TextStyle(
color = color,
fontSize = 52.sp,
fontWeight = FontWeight.ExtraBold,
textAlign = TextAlign.Center
),
size = Size(size.width, textMeasurer.measure(speedText).size.height.toFloat())
)
}
속도값과 km/h 단위를 포함한 텍스트가 원형 배경 안에 깔끔하게 표시됩니다. 색상과 스타일을 변경하여 다양한 디자인을 구현할 수 있습니다.
그럼 이제 마지막으로 계기판 위에 게이지를 표시해보겠습니다.
@SuppressLint("DefaultLocale")
fun DrawScope.drawSpeedDisplay(
speed: Float,
textMeasurer: TextMeasurer,
outerCircleWidth: Float = 10f,
maxHand: Float = size.minDimension / 10f,
minHand: Float = size.minDimension / 20f,
color: Color = Color.Cyan
) {
...
// 원형 게이지: drawArc를 사용하여 속도에 따라 동적으로 변화하는 게이지를 그립니다.
if (speed >= 20) {
// 반투명한 색상의 게이지
drawArc(
brush = Brush.sweepGradient(listOf(Color.Black.copy(0.8f), Color.Cyan.copy(0.3f))),
startAngle = 135f,
sweepAngle = min(270f * (speed - 20f) / 180f, 270f),
useCenter = false,
topLeft = Offset(size.minDimension / 6, size.minDimension / 6),
size = Size(
(size.minDimension / 1.5f),
(size.minDimension / 1.5f)
),
style = Stroke(size.minDimension / 3.5f)
)
// 외곽선 스타일
drawArc(
color,
startAngle = 135f,
sweepAngle = min(270f * (speed - 20f) / 180f, 270f),
useCenter = false,
topLeft = Offset(outerCircleWidth / 2, outerCircleWidth / 2),
size = Size(
(size.minDimension - outerCircleWidth),
(size.minDimension - outerCircleWidth)
),
style = Stroke(outerCircleWidth)
)
}
val innerRadius = size.minDimension / 2f - outerCircleWidth * 3 - maxHand - 60f
// 눈금 및 속도 값 표시
for (count in 0..45) {
if (count * 6f <= 270f * (speed - 20f) / 180f) {
rotate(-45f + count * 6f) {
drawLine(
color,
start = Offset(outerCircleWidth * 3, size.minDimension / 2f),
end = Offset(
outerCircleWidth * 3 + if (count % 5 == 0) maxHand else minHand,
size.minDimension / 2f
),
strokeWidth = outerCircleWidth
)
}
if (count % 5 == 0) {
val radian = Math.toRadians((135f + count * 6f).toDouble())
val textX = size.minDimension / 2f + innerRadius * cos(radian).toFloat()
val textY = size.minDimension / 2f + innerRadius * sin(radian).toFloat()
val text = (20 + count * 4).toString()
drawText(
textMeasurer = textMeasurer,
text = text,
topLeft = Offset(
textX - textMeasurer.measure(text).getLineRight(0) + 10f,
textY - textMeasurer.measure(text).lastBaseline
),
style = TextStyle(
color = color,
fontSize = 20.sp,
fontWeight = FontWeight.Bold
)
)
}
}
}
}
완성되었습니다!! 이제 아래와 같은 화면을 볼 수 있습니다~
@Preview
@Composable
fun SpeedometerPreview() {
Surface(color = Color.Black) {
Speedometer()
}
}
Compose의 Canvas를 활용하면 복잡한 그래픽 UI를 간단하게 구현할 수 있습니다. 이번에 소개한 속도계는 실기간 데이터를 시각화하거나 대시보드 UI 등을 구성할때 참고하여 작업할 수 있도록 여러 그래픽 효과를 추가하였습니다. 감사합니다.
전체코드를 확인하고 싶다면 아래의 링크를 참조해주세요.
https://gist.github.com/nighttwo1/7444a96d216d2af118188bd3d3023c03
'Android' 카테고리의 다른 글
Color 알파(alpha)값 계산기 개발기 (0) | 2024.09.13 |
---|---|
[Android] Compose TextField 천단위 콤마(,) 설정하기 (1) | 2024.02.25 |
[Android] Github Pages로 프로젝트 문서 호스팅 (0) | 2024.02.18 |
[Android] Dokka를 활용하여 프로젝트 문서화하기 (0) | 2024.02.08 |
[Mapbox] Pulsing puck을 이용하여 현재 유저 위치 그리기 (0) | 2024.01.23 |