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
        )
    }

}
카테고리: Compose

1개의 댓글

리니셜 · 2022년 10월 17일 1:52 오전

와.. 정말 대단하시네요!
항상 블로그 잘 보고 있습니다. 감사합니다.
눈팅만 하다가 이번 포스팅에서는 감탄하다 가게 되네요!

리니셜 에 답글 남기기 응답 취소

Avatar placeholder

이메일은 공개되지 않습니다. 필수 입력창은 * 로 표시되어 있습니다.