Prerequisite
컴포즈로 시계 만들기
위 영상과 같은 시계 UI를 만들려면 어떻게 해야할까?
복잡하다고 느낄수록 잘게 나누면 답이 보일 때가 있다. 시계를 구성하는 요소를 정리하면 다음과 같다.
- 시계 배경에 해당하는 커다란 원
- 시침, 분침, 초침
- 눈금 및 시간을 나타내는 텍스트
우선 각 요소의 스타일을 다음과 같이 정리할 수 있다.
data class ClockStyle(
val hourHandWidth: Dp = 5.dp, // 시침 두께
val minuteHandWidth: Dp = 5.dp, // 분침 두께
val secondHandWidth: Dp = 3.dp, // 초침 두께
val hourHandLength: Dp = 80.dp, // 시침 길이
val minuteHandLength: Dp = 120.dp, // 분침 길이
val secondHandLength: Dp = 120.dp, // 초침 길이
val hourHandColor: Color = Color.Black, // 시침 색상
val minuteHandColor: Color = Color.Black, // 분침 색상
val secondHandColor: Color = Color.Red, // 초침 색상
val textColor: Color = Color.Black, // 1~12 텍스트 색상
val textSize: Dp = 20.dp, // 1~12 텍스트 크기
val hourGradationWidth: Dp = 2.dp, // 시간 눈금 두께
val minuteGradationWidth: Dp = 1.dp, // 분 눈금 두께
val hourGradationColor: Color = Color.Black, // 시간 눈금 색상
val minuteGradationColor: Color = Color.Black, // 분 눈금 색상
val hourGradationLength: Dp = 20.dp, // 시간 눈금 길이
val minuteGradationLength: Dp = 10.dp, // 분 눈금 길이
val shadowRadius:Dp = 15.dp, // 배경 그림자 크기
val shadowColor:Color = Color.Black.copy(alpha = 0.5f), // 배경 그림자 색상
val centerCircleSize:Dp = 3.dp, // 중심점 크기
val centerCircleColor:Color = Color.Black // 중심점 색상
)
시계 배경 그리기
다음과 같이 원형의 시계 배경을 한번 그려보자
@Composable
fun Clock(
modifier: Modifier = Modifier,
clockStyle: ClockStyle = ClockStyle()
) {
Canvas(modifier = modifier) {
// 시계 배경
val radius: Float = size.minDimension / 2.0f // 원의 반지름
drawContext.canvas.nativeCanvas.apply {
this.drawCircle(
center.x,
center.y,
radius,
Paint().apply {
color = android.graphics.Color.WHITE
setShadowLayer(
clockStyle.shadowRadius.toPx(),
0f,
0f,
clockStyle.shadowColor.toArgb()
)
}
)
}
// 중심점
drawCircle(
radius = clockStyle.centerCircleSize.toPx(),
color = clockStyle.centerCircleColor
)
}
}
@Preview
@Composable
private fun ClockPreview() {
Surface(color = Color.White) {
Clock(
modifier = Modifier.size(300.dp),
clockStyle = ClockStyle()
)
}
}
원을 그리기 위해 컴포즈의 drawCircle() 함수를 호출 할 수도 있지만, 그림자를 아직 지원하지 않으므로 네이티브 캔버스(Android View 캔버스)에 직접 접근하여 또 다른 drawCircle() 함수를 호출한다.
원을 그리기 위해서는 원의 중심점과 원의 반지름 값이 필요한데, 캔버스에 꽉차게 그리기 위해 캔버스 중심점과 캔버스 최소 사이즈의 절반에 해당하는 길이를 원의 반지름으로 결정했다.
drawCircle을 호출할 때 마지막 파라미터 인 Paint의 setShadowLayer를 호출하면 배경에 그림자를 그릴 수 있다.
시계 중심점을 그리기 위해 지정한 사이즈로 drawCircle() 한번 더 호출 한다.
시계 눈금 그리기
@Composable
fun Clock(
modifier: Modifier = Modifier,
clockStyle: ClockStyle = ClockStyle()
) {
Canvas(modifier = modifier) {
...
// 시계 눈금 그리기
val gradationCount = 60 // 시계눈금 갯수
repeat(gradationCount) { index ->
val angleInDegree = (index * 360 / gradationCount).toDouble()
val angleInRadian = Math.toRadians(angleInDegree)
val isHourGradation = index % 5 == 0
val length = if (isHourGradation) {
clockStyle.hourGradationLength.toPx()
} else {
clockStyle.minuteGradationLength.toPx()
}
val start = Offset(
x = (center.x + (radius - length) * cos(angleInRadian)).toFloat(),
y = (center.y + (radius - length) * sin(angleInRadian)).toFloat()
)
val end = Offset(
x = (center.x + radius * cos(angleInRadian)).toFloat(),
y = (center.y + radius * sin(angleInRadian)).toFloat()
)
val gradationColor: Color = if (isHourGradation) {
clockStyle.hourGradationColor
} else {
clockStyle.minuteGradationColor
}
val gradationWidth: Dp = if (isHourGradation) {
clockStyle.hourGradationWidth
} else {
clockStyle.minuteGradationWidth
}
drawLine(
color = gradationColor,
start = start,
end = end,
strokeWidth = gradationWidth.toPx()
)
}
}
}
그리고자 하는 시계의 눈금은 60개다. 정확히 6° 간격으로 눈금을 하나씩 그리면 60개를 그릴 수 있다. (60 * 6° = 360° )
눈금을 그리기 위해 필요한 각도(degree)가 정해졌으니 반지름(radius)만 정해지면 중심점을 기준으로 원형을 그리며 시계 눈금을 그릴 수 있다. 눈금은 선분이며, 선분은 두개의 점을 잇는 직선이다. drawLine() 함수 호출을 통해 직선을 그릴 수 있다.
이제 두 점을 계산해야하는데 어떻게 하면 두 점의 위치를 알 수 있을까? 다음 그림을 살펴보자
2시에 해당 하는 눈금을 그린다고 가정하면, 두 점 (x1, y1), (x2, y2)를 직선으로 이으면 된다. 두점은 동심원인 빨간원과 파란원의 둘레에 해당하는 정점이다. 이 점들의 좌표를 계산하기 위해서 삼각함수, 원의 radius(반지름)와 radian(호도)이 있으면 된다. 각도(degree)를 호도로 변경하는 함수를 Math 클래스에서 제공한다. 자세한 내용은 이전 포스팅을 참조하자.
시(hour)에 해당하는 눈금(Index, 1~12)은 특별히 굵은 선으로 그렸다.
시(hour) 텍스트 그리기
@Composable
fun Clock(
modifier: Modifier = Modifier,
clockStyle: ClockStyle = ClockStyle()
) {
Canvas(modifier = modifier) {
...
repeat(gradationCount) { index ->
val angleInDegree = (index * 360 / gradationCount).toDouble()
val angleInRadian = Math.toRadians(angleInDegree)
//시,분 눈금 그리기
...
// 1~12시 텍스트 그리기
drawContext.canvas.nativeCanvas.apply {
if (index % 5 == 0) {
var hourText = (index / 5 + 3) % 12
if (hourText == 0) {
hourText = 12
}
val textSize = clockStyle.textSize.toPx()
val textRadius = radius - length - textSize
val x = textRadius * cos(angleInRadian) + center.x
val y = textRadius * sin(angleInRadian) + center.y + textSize / 2f
drawText(
"$hourText",
x.toFloat(),
y.toFloat(),
Paint().apply {
this.color = clockStyle.textColor.toArgb()
this.textSize = textSize
this.textAlign = Paint.Align.CENTER
}
)
}
}
}
}
}
텍스트는 컴포즈 캔버스에서 지원하지 않으므로 네이티브 캔버스에 접근해서 drawText()를 호출한다. 시계 눈금을 그릴 때와 동일한 접근 방식으로 텍스트의 좌표를 계산할 수 있다. 주의해야 할 점은 각도를 0° 부터 고려하면서 텍스트를 그리면 3시 방향부터 텍스트가 그려지기 시작하므로 이를 유의하고 그려야 한다.
시침, 분침, 초침 그리기
@Composable
fun Clock(
modifier: Modifier = Modifier,
clockStyle: ClockStyle = ClockStyle(),
hour: Int,
minute: Int,
second: Int
) {
Canvas(modifier = modifier) {
...
// 시침 그리기(Hour hand)
val hourAngleIncrement = 360.0/12.0
val hourHandInDegree = (
-90 // 12시부터 시작하도록 90도 꺾음
+ hour * hourAngleIncrement
+ hourAngleIncrement * minute.toDouble() / TimeUnit.HOURS.toMinutes(1)
+ hourAngleIncrement * second.toDouble() / TimeUnit.HOURS.toSeconds(1)
)
val hourHandInRadian = Math.toRadians(hourHandInDegree)
val hourHandStart = Offset(
x = center.x,
y = center.y
)
val hourHandEnd = Offset(
x = (center.x + clockStyle.hourHandLength.toPx() * cos(hourHandInRadian)).toFloat(),
y = (center.y + clockStyle.hourHandLength.toPx() * sin(hourHandInRadian)).toFloat()
)
drawLine(
color = clockStyle.hourHandColor,
start = hourHandStart,
end = hourHandEnd,
strokeWidth = clockStyle.hourHandWidth.toPx(),
cap = StrokeCap.Round
)
// 분침 그리기(Minute hand)
...
// 초침 그리기 (Second hand)
...
}
}
시침, 분침, 초침을 그리는 원리는 동일하므로 시침을 그리는 방법에 대해서만 설명한다. 이미 눈치 챘겠지만 시침을 그리는 특별한 방법은 없다. 중심점(center)과 시침의 길이를 반지름의 갖는 가상의 원을 생각하며 적절한 angle을 대입해 정점을 구한 뒤 두개의 점을 직선으로 그으면 된다. 이해하기 어렵다면 시계 눈금 그리기부터 다시 읽어보도록 하자.
마찬가지로 시침을 그릴 때 주의 해야할 것은 angle을 0° 기준으로 계산할 시 3시방향부터 시작하므로, 12시를 기준으로 시작하기 위해 angle을 -90° 꺾고 계산한다.
시침이 올바르게 그려졌다면, 분침과 초침도 같은 방법으로 그리도록 한다.
전체 코드
import android.graphics.Paint import androidx.compose.foundation.Canvas import androidx.compose.foundation.layout.size import androidx.compose.material3.Surface import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.nativeCanvas import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch import java.time.LocalTime import java.util.concurrent.TimeUnit import kotlin.math.cos import kotlin.math.sin @Composable fun Clock( modifier: Modifier = Modifier, clockStyle: ClockStyle = ClockStyle(), hour: Int, minute: Int, second: Int ) { Canvas(modifier = modifier) { // 시계 배경 val radius: Float = size.minDimension / 2.0f // 원의 반지름 drawContext.canvas.nativeCanvas.apply { this.drawCircle( center.x, center.y, radius, Paint().apply { color = android.graphics.Color.WHITE setShadowLayer( clockStyle.shadowRadius.toPx(), 0f, 0f, clockStyle.shadowColor.toArgb() ) } ) } // 중심점 drawCircle( radius = clockStyle.centerCircleSize.toPx(), color = clockStyle.centerCircleColor ) // 시계 눈금 val gradationCount = 60 // 시계눈금 갯수 repeat(gradationCount) { index -> val angleInDegree = (index * 360 / gradationCount).toDouble() val angleInRadian = Math.toRadians(angleInDegree) val isHourGradation = index % 5 == 0 val length = if (isHourGradation) { clockStyle.hourGradationLength.toPx() } else { clockStyle.minuteGradationLength.toPx() } val start = Offset( x = (center.x + (radius - length) * cos(angleInRadian)).toFloat(), y = (center.y + (radius - length) * sin(angleInRadian)).toFloat() ) val end = Offset( x = (center.x + radius * cos(angleInRadian)).toFloat(), y = (center.y + radius * sin(angleInRadian)).toFloat() ) val gradationColor: Color = if (isHourGradation) { clockStyle.hourGradationColor } else { clockStyle.minuteGradationColor } val gradationWidth: Dp = if (isHourGradation) { clockStyle.hourGradationWidth } else { clockStyle.minuteGradationWidth } //시,분 눈금 그리기 drawLine( color = gradationColor, start = start, end = end, strokeWidth = gradationWidth.toPx() ) // 1~12시 텍스트 그리기 drawContext.canvas.nativeCanvas.apply { if (index % 5 == 0) { var hourText = (index / 5 + 3) % 12 if (hourText == 0) { hourText = 12 } val textSize = clockStyle.textSize.toPx() val textRadius = radius - length - textSize val x = textRadius * cos(angleInRadian) + center.x val y = textRadius * sin(angleInRadian) + center.y + textSize / 2f drawText( "$hourText", x.toFloat(), y.toFloat(), Paint().apply { this.color = clockStyle.textColor.toArgb() this.textSize = textSize this.textAlign = Paint.Align.CENTER } ) } } } // 시침 그리기(Hour hand) val hourAngleIncrement = 360.0/12.0 val hourHandInDegree = ( -90 // 12시부터 시작하도록 90도 꺾음 + hour * hourAngleIncrement + hourAngleIncrement * minute.toDouble() / TimeUnit.HOURS.toMinutes(1) + hourAngleIncrement * second.toDouble() / TimeUnit.HOURS.toSeconds(1) ) val hourHandInRadian = Math.toRadians(hourHandInDegree) val hourHandStart = Offset( x = center.x, y = center.y ) val hourHandEnd = Offset( x = (center.x + clockStyle.hourHandLength.toPx() * cos(hourHandInRadian)).toFloat(), y = (center.y + clockStyle.hourHandLength.toPx() * sin(hourHandInRadian)).toFloat() ) drawLine( color = clockStyle.hourHandColor, start = hourHandStart, end = hourHandEnd, strokeWidth = clockStyle.hourHandWidth.toPx(), cap = StrokeCap.Round ) // 분침 그리기(Minute hand) val minuteAngleIncrement = 360.0/60.0 val minuteHandInDegree = ( -90 // 90도 꺾음 + minuteAngleIncrement * minute.toDouble() + minuteAngleIncrement * second.toDouble() / TimeUnit.MINUTES.toSeconds(1) ) val minuteHandInRadian = Math.toRadians(minuteHandInDegree) val minuteLineStart = Offset( x = center.x, y = center.y ) val minuteLineEnd = Offset( x = (center.x + clockStyle.minuteHandLength.toPx() * cos(minuteHandInRadian)).toFloat(), y = (center.y + clockStyle.minuteHandLength.toPx() * sin(minuteHandInRadian)).toFloat() ) drawLine( color = clockStyle.minuteHandColor, start = minuteLineStart, end = minuteLineEnd, strokeWidth = clockStyle.minuteHandWidth.toPx(), cap = StrokeCap.Round ) // 초침 그리기 (Second hand) val secondAngleIncrement = 360.0/60.0 val secondInDegree = -90 + secondAngleIncrement * second.toDouble() val secondInRadian = Math.toRadians(secondInDegree) val secondLineStart = Offset( x = center.x, y = center.y ) val secondLineEnd = Offset( x = (center.x + clockStyle.secondHandLength.toPx() * cos(secondInRadian)).toFloat(), y = (center.y + clockStyle.secondHandLength.toPx() * sin(secondInRadian)).toFloat() ) drawLine( color = clockStyle.secondHandColor, start = secondLineStart, end = secondLineEnd, strokeWidth = clockStyle.secondHandWidth.toPx(), cap = StrokeCap.Round ) } } @Preview @Composable private fun ClockPreview() { var currentTime by remember { mutableStateOf(LocalTime.now()) } val coroutineScope = rememberCoroutineScope() coroutineScope.launch(Dispatchers.IO) { while (true){ delay(500) currentTime = LocalTime.now() } } Surface(color = Color.White) { Clock( modifier = Modifier.size(300.dp), clockStyle = ClockStyle(), hour = currentTime.hour, minute = currentTime.minute, second = currentTime.second ) } }
1개의 댓글
리니셜 · 2022년 10월 17일 1:52 오전
와.. 정말 대단하시네요!
항상 블로그 잘 보고 있습니다. 감사합니다.
눈팅만 하다가 이번 포스팅에서는 감탄하다 가게 되네요!