필자가 회사에서 업무를 진행하다 보면, 가끔 일반적인 슬라이더(Slider) 대신 원형 다이얼 형태의 컨트롤이 필요한 경우가 생긴다. 시계 타이머나 온도 조절기, 음량 노브 같은 UI가 대표적인 예시이다.
최근 안드로이드 관련 기술 블로그를 확인하다가 Sina Samaki의 "How to create dials in Jetpack Compose" 라는 글을 보게 되었다. 원글에서는 ChromaDial 라이브러리를 소개하고 있었는데, 라이브러리를 추가하지 않고도 Canvas 만으로 충분히 구현이 가능해 보여서 직접 한 번 만들어 보고자 한다.
이번 글에서는 Jetpack Compose의 Canvas를 활용해 기본 다이얼부터 범위 제한·스냅·멀티턴·그라데이션·바퀴별 오버레이까지 6가지 형태의 다이얼을 직접 구현하는 방법을 알아볼 예정이다.
Dial을 구성하는 4가지 요소
본격적으로 들어가기 전에, Dial이 어떤 요소로 구성되는지를 먼저 정리해보자.
- Track: 원형 경로 자체.
drawArc()로 그린다. 배경 트랙과 활성 트랙으로 분리하여 진행 상태를 표시한다. - Thumb: 사용자가 드래그하는 핸들. 삼각함수(
sin,cos)로 원 위의 위치를 계산한다. - Tick Marks: 일정 간격으로 표시되는 눈금. 스냅 포인트 역할도 같이 한다.
- Value Label: 현재 값을 텍스트로 표시. 보통 중앙에 둔다.
가장 까다로운 부분: 좌표 ↔ 각도 변환
Dial을 만들 때 가장 먼저 직면하는 문제가 좌표계 변환이다. Compose의 Canvas는 다음 두 가지 좌표 규약을 가지고 있어서 헷갈리기 쉽다.
drawArc의startAngle은 3시 방향이 0도- 일반적인 다이얼 UX는 12시 방향이 0도인 경우가 많음
그래서 두 좌표계 사이를 변환하는 작은 보정이 필요하다. 각도를 좌표로 변환할 때는 -90도 보정을 해주면 된다.
// 각도 → 좌표 (12시 방향 = 0도 기준)
val angleRad = Math.toRadians((degree - 90f).toDouble())
val thumbX = center.x + radius * cos(angleRad).toFloat()
val thumbY = center.y + radius * sin(angleRad).toFloat()
반대로 터치 좌표에서 각도를 얻을 때는 atan2를 사용한 뒤 +90도 보정을 해준다.
private fun calculateAngle(
centerX: Float,
centerY: Float,
touchX: Float,
touchY: Float
): Float {
val angle = Math.toDegrees(
atan2(
(touchY - centerY).toDouble(),
(touchX - centerX).toDouble()
)
).toFloat()
// 3시 방향(0도) → 12시 방향(0도)으로 변환
return (angle + 90f + 360f) % 360f
}
처음에는 이 보정값을 계속 잊어버려서 "왜 자꾸 다이얼이 90도씩 돌아가 있지?" 하고 한참을 헤맸었다. 이 두 줄만 머릿속에 잘 새겨두면 나머지는 비교적 쉬운 편이다.
1. Basic Dial - 기본 0°~360° 다이얼
먼저 가장 단순한 형태부터 만들어 보자. 0°에서 360°까지 한 바퀴를 도는 다이얼이다.
Canvas(modifier = Modifier.fillMaxSize()) {
val strokeWidth = size.minDimension * 0.06f
val thumbRadius = size.minDimension * 0.055f
val padding = thumbRadius + strokeWidth / 2f + 4f
val dialSize = Size(
size.width - padding * 2,
size.height - padding * 2
)
val topLeft = Offset(padding, padding)
// Canvas의 startAngle은 3시 방향이 0도이므로 12시 기준으로 보정
val canvasStartAngle = startDegrees - 90f
// 배경 트랙
drawArc(
color = trackColor,
startAngle = canvasStartAngle,
sweepAngle = sweepDegrees,
useCenter = false,
topLeft = topLeft,
size = dialSize,
style = Stroke(width = strokeWidth, cap = StrokeCap.Round)
)
// 활성 트랙 (현재 값까지)
if (degree > 0f) {
drawArc(
color = activeTrackColor,
startAngle = canvasStartAngle,
sweepAngle = degree,
useCenter = false,
topLeft = topLeft,
size = dialSize,
style = Stroke(width = strokeWidth, cap = StrokeCap.Round)
)
}
}
padding을 따로 빼서 계산한 이유는, Thumb이 원의 가장자리에 위치할 때 잘리지 않도록 여유 공간을 확보하기 위해서이다. 처음에는 이걸 빼먹어서 Thumb의 반쪽이 잘려나가는 문제를 겪었었다.
드래그 제스처는 detectDragGestures로 받고, 위에서 만든 calculateAngle로 각도를 구한 뒤 콜백으로 넘긴다.
.pointerInput(startDegrees, sweepDegrees, interval) {
detectDragGestures { change, _ ->
change.consume()
val touchAngle = calculateAngle(
centerX = size.width / 2f,
centerY = size.height / 2f,
touchX = change.position.x,
touchY = change.position.y
)
// startDegrees 기준 상대 각도 계산
var relativeDegree = (touchAngle - startDegrees + 360f) % 360f
// sweepDegrees 범위 내로 제한
if (relativeDegree > sweepDegrees) {
val distToStart = relativeDegree
val distToEnd = 360f - relativeDegree + sweepDegrees
relativeDegree = if (distToStart < distToEnd) 0f else sweepDegrees
}
onDegreeChanged(relativeDegree)
}
}
2. Range Dial - 부분 원형 다이얼
꼭 한 바퀴 전체를 사용할 필요는 없다. startDegrees = 225f, sweepDegrees = 270f 같은 식으로 부분 원을 만들 수도 있다.
Dial(
degree = degree,
onDegreeChanged = { degree = it },
startDegrees = 225f, // 7시 반 방향에서 시작
sweepDegrees = 270f, // 3/4 원
// ...
)
위에서 만들어둔 Basic Dial에 두 개의 파라미터만 추가하면 끝이다. 진행률(%) 표시기, 온도계, 게이지 같은 UI에 잘 어울리는 형태이다.
3. Snapping Dial - 일정 간격으로 스냅
시계의 시간 단위처럼 특정 간격으로만 값이 정해져야 하는 경우가 있다. 이때는 interval 파라미터를 추가해서 가장 가까운 배수로 반올림하면 된다.
val snappedDegree = if (interval > 0f) {
val snapped = (relativeDegree / interval).roundToInt() * interval
snapped.coerceIn(0f, sweepDegrees)
} else {
relativeDegree
}
onDegreeChanged(snappedDegree)
여기에 더해 시각적인 눈금까지 그려주면 사용자가 어디서 멈출 수 있는지 한눈에 알 수 있다. 눈금은 각 스냅 포인트마다 작은 선을 그어주는 방식으로 처리했다.
private fun DrawScope.drawTickMarks(
startDegrees: Float,
sweepDegrees: Float,
interval: Float,
tickColor: Color,
center: Offset,
radius: Float,
strokeWidth: Float
) {
val tickCount = (sweepDegrees / interval).roundToInt()
val tickInnerRadius = radius - strokeWidth * 0.8f
val tickOuterRadius = radius + strokeWidth * 0.8f
for (i in 0..tickCount) {
val tickDegree = startDegrees + i * interval
val tickAngleRad = Math.toRadians((tickDegree - 90f).toDouble())
val innerX = center.x + tickInnerRadius * cos(tickAngleRad).toFloat()
val innerY = center.y + tickInnerRadius * sin(tickAngleRad).toFloat()
val outerX = center.x + tickOuterRadius * cos(tickAngleRad).toFloat()
val outerY = center.y + tickOuterRadius * sin(tickAngleRad).toFloat()
drawLine(
color = tickColor,
start = Offset(innerX, innerY),
end = Offset(outerX, outerY),
strokeWidth = 2f,
cap = StrokeCap.Round
)
}
}
4. Multi-Turn Dial - 여러 바퀴 회전
음량 노브처럼 한 바퀴 이상 돌릴 수 있어야 하는 경우도 있다. 이때 핵심은 "바퀴 수를 어떻게 감지할 것인가" 이다.
0°에서 360°로 한 바퀴를 돌고 나면 다시 0°로 돌아오기 때문에, 단순히 현재 각도만으로는 몇 바퀴째인지 알 수 없다. 그래서 이전 각도와의 차이를 추적해서 경계를 통과한 시점을 감지해야 한다.
var turns by remember { mutableIntStateOf(0) }
var prevDegree by remember { mutableFloatStateOf(0f) }
Dial(
degree = degree,
onDegreeChanged = { newDegree ->
// 회전 수 감지 (0→360 또는 360→0 경계 통과 시)
val delta = newDegree - prevDegree
if (delta < -180f) {
// 359° → 1° 처럼 갑자기 줄어들었으면 한 바퀴 더 돈 것
turns = (turns + 1).coerceAtMost(4)
} else if (delta > 180f) {
// 1° → 359° 처럼 갑자기 늘어났으면 한 바퀴 되돌린 것
turns = (turns - 1).coerceAtLeast(0)
}
prevDegree = newDegree
degree = newDegree
}
)
변화량의 절댓값이 180도를 넘어가면 한 바퀴 경계를 지났다고 판단한다. 일반적인 사용에서는 한 프레임에 180도 이상 회전할 수 없기 때문에 안전한 기준이라고 볼 수 있다.
5. Gradient Dial - 진행도에 따라 색이 변하는 다이얼
활성 트랙을 단색이 아니라 그라데이션으로 채우고 싶을 때가 있다. 가장 간단한 방법은 트랙을 작은 호(arc) 단위로 쪼개서 각각 다른 색으로 그리는 것이다.
if (degree > 0f) {
val step = 3f // 3도 단위로 쪼개기
var angle = 0f
while (angle < degree) {
val sweep = minOf(step, degree - angle)
val fraction = (angle / 360f).coerceIn(0f, 1f)
val segmentColor = lerpMultiColor(gradientColors, fraction)
drawArc(
color = segmentColor,
startAngle = -90f + angle,
sweepAngle = sweep + 0.5f, // 0.5도 겹쳐서 빈틈 방지
useCenter = false,
topLeft = topLeft,
size = dialSize,
style = Stroke(width = strokeWidth, cap = StrokeCap.Butt)
)
angle += step
}
}
여러 색상을 보간하는 lerpMultiColor 함수는 따로 작성해줘야 한다. 색상 리스트에서 현재 진행도(0~1)에 해당하는 위치 두 색상을 선형 보간하는 방식이다.
private fun lerpMultiColor(colors: List, fraction: Float): Color {
if (colors.size < 2) return colors.firstOrNull() ?: Color.Black
val clampedFraction = fraction.coerceIn(0f, 1f)
val scaledFraction = clampedFraction * (colors.size - 1)
val index = scaledFraction.toInt().coerceIn(0, colors.size - 2)
val localFraction = scaledFraction - index
return lerp(colors[index], colors[index + 1], localFraction)
}
호 단위로 쪼갤 때 sweepAngle에 약간(0.5도)을 더해서 그려주는 이유는, 호 사이에 미세한 빈틈이 보이는 현상을 방지하기 위해서이다. 처음에는 이 보정 없이 그렸다가 점선처럼 보이는 문제가 있었다.
6. Multi-Turn Overlay Dial - 바퀴마다 다른 색이 쌓이는 다이얼
마지막은 Multi-Turn에 시각적인 깊이를 더한 형태이다. 바퀴를 돌 때마다 다른 색의 트랙이 점점 얇아지면서 안쪽에 겹쳐 쌓이는 효과를 준다.
// 완료된 바퀴들을 오버레이 레이어로 그리기 (점진적으로 얇아짐)
val completedTurns = turns.coerceAtMost(maxTurns)
for (i in 0 until completedTurns) {
val layerWidth = baseStrokeWidth * (1f - i * 0.2f) // 바퀴마다 20%씩 얇아짐
drawArc(
color = turnColors[i % turnColors.size],
startAngle = -90f,
sweepAngle = 360f,
useCenter = false,
topLeft = topLeft,
size = dialSize,
style = Stroke(width = layerWidth, cap = StrokeCap.Round)
)
}
// 현재 바퀴의 진행도 그리기
if (turns < maxTurns && degree > 0f) {
val currentTurnIndex = turns
val layerWidth = baseStrokeWidth * (1f - currentTurnIndex * 0.2f)
drawArc(
color = turnColors[currentTurnIndex % turnColors.size],
startAngle = -90f,
sweepAngle = degree,
useCenter = false,
topLeft = topLeft,
size = dialSize,
style = Stroke(width = layerWidth, cap = StrokeCap.Round)
)
}
이전 바퀴들의 트랙이 동심원처럼 안쪽으로 쌓이게 되어, 사용자가 지금까지 몇 바퀴를 돌렸는지 직관적으로 볼 수 있다.
구현하면서 신경 써야 했던 부분
1. Thumb 잘림 방지를 위한 padding
처음에 padding을 빼먹고 그렸을 때 Thumb의 가장자리가 잘려나가는 문제가 있었다. thumbRadius + strokeWidth / 2f + 4f 정도의 여유를 두면 거의 모든 케이스를 커버할 수 있다.
2. onDegreeChanged는 매우 자주 호출됨
드래그 중에는 초당 수십 번 콜백이 호출된다. 디스크 저장 같은 비싼 연산은 절대 여기서 하면 안 되고, 드래그가 끝난 시점(onDegreeChangeFinished)에 처리하는 게 좋다.
3. 멀티턴 경계 감지 임계값
위에서 사용한 180도 임계값은 일반적인 드래그 속도에서는 안전하지만, 극단적으로 빠른 플링(fling)에서는 한 프레임에 180도 이상 움직일 가능성이 있긴 하다. 안전하게 가려면 임계값을 90~120도 정도로 좁혀도 괜찮을 것 같다.
4. 그라데이션 트랙의 빈틈 보정
호를 작은 단위로 쪼개서 그릴 때는 약간씩 겹쳐서 그려야 빈틈이 보이지 않는다. sweep + 0.5f 정도가 적당했다.
마무리
이번 글에서는 Jetpack Compose의 Canvas를 활용해 다양한 형태의 Dial 컴포넌트를 직접 구현해 보았다.
원형 UI는 처음에는 좌표 변환이 까다로워서 진입 장벽이 있어 보이지만, sin/cos/atan2 세 가지만 잘 활용하면 의외로 다양한 응용이 가능하다는 것을 알게 되었다. 외부 라이브러리에 의존하지 않고도 디자이너가 원하는 거의 모든 형태의 다이얼을 만들 수 있을 것 같다는 자신감도 조금 생긴 것 같다.
다음에는 이 Dial을 응용해서 시간 선택기(Time Picker)나 색상 선택기(Color Picker) 같은 좀 더 실용적인 컴포넌트도 한번 만들어볼 예정이다.
필자도 아직 Canvas의 저수준 그래픽 API에 대해서는 공부 중이기 때문에 작성한 개념이나 설명에 오류가 있을 가능성이 있다는 점을 감안하고 읽어주었으면 좋겠다.
해당 게시글에 사용한 예제는 다음 Github에 올려두었다.
https://github.com/HeeGyeong/ComposeSample
GitHub - HeeGyeong/ComposeSample: This project provides various examples needed to actually use Jetpack Compose.
This project provides various examples needed to actually use Jetpack Compose. - HeeGyeong/ComposeSample
github.com
'Android > Jetpack Compose' 카테고리의 다른 글
| [Compose] iOS의 Large Content Viewer를 안드로이드에 - 접근성을 고려한 아이콘 프리뷰 구현 (0) | 2026.06.21 |
|---|---|
| [Compose] 스티커 캔버스를 만들어보자 - 복합 제스처, Spring 물리, 필오프(Peel-Off) 애니메이션 (0) | 2026.06.14 |
| [Compose] Shared Element Transitions로 화면 간 요소를 부드럽게 전환해보자 (0) | 2026.06.03 |
| [Compose] Material 3의 SwipeToDismissBox를 구현해보자 (0) | 2026.05.25 |
| [Compose] TextMeasurer와 Canvas로 커스텀 텍스트 효과를 만들어보자 - Faded, Warped, Typewriter (0) | 2026.05.02 |