[Android] Canvas로 체중계 만들기 - 1 체중계 원 구현하기
체중계의 모양을 만들었으니 이제 체중계에 따른 눈금을 설정할 차례다.

위와 같이 체중계에 숫자와 눈금을 만들 예정이다.
이미지를 보면 10단위, 5단위, 그리고 1단위마다 눈금의 색과 길이가 다른 것을 볼 수 있다.
3가지 타입을 나눌 수 있도록 LineType의 Sealed class를 만든다.
//sealed class를 사용하면 컴파일 때 확인이 가능해
//when을 사용할 때 else를 사용할 필요가 없다.
sealed class LineType {
data object Normal: LineType()
data object FiveStep: LineType()
data object TenStep: LineType()
}
먼저 중앙에서 시작할 몸무게, 최소 몸무게, 최대 몸무게를 지정해야한다.
컴포저블 함수에 파라미터 값으로 미리 초기값을 지정해놨다.
@Composable
fun Scale(
//...
//최소 몸무게 기본 50kg
minWeight: Int = 50,
//최대 몸무게 100kg
maxWeight: Int = 100,
//초기 몸무게 80kg
initialWeight: Int = 80,
//...
) {
//기타 구현
}
이제 최소 몸무게 ~ 최대 몸무게까지 각 몸무게에 따른 위치와 색상, 길이 정보를 토대로 선을 그리면 된다.
코드는 아래와 같다.
// Draw lines
//최소 몸무게부터 최대 몸무게까지 반복
for(i in minWeight..maxWeight) {
//1. 라디안 구하는 방식
val angleInRad = (i - initialWeight + angle - 90) * (PI / 180f).toFloat()
//몸무게의 단위마다 라인 타입을 지정하기
//예시: 80이면 LineType.TenStep
val lineType = when {
i % 10 == 0 -> LineType.TenStep
i % 5 == 0 -> LineType.FiveStep
else -> LineType.Normal
}
//각 라인 타입에 따른 길이 제한
val lineLength = when(lineType) {
LineType.Normal -> style.normalLineLength.toPx()
LineType.FiveStep -> style.fiveStepLineLength.toPx()
LineType.TenStep -> style.tenStepLineLength.toPx()
}
//각 라인에 따른 색상
val lineColor = when(lineType) {
LineType.Normal -> style.normalLineColor
LineType.FiveStep -> style.fiveStepLineColor
LineType.TenStep -> style.tenStepLineColor
}
//2. 라디안으로 구한 점의 시작점
val lineStart = Offset(
x = (outerRadius - lineLength) * cos(angleInRad) + circleCenter.x,
y = (outerRadius - lineLength) * sin(angleInRad) + circleCenter.y
)
//2. 라디안으로 구한 점의 끝점
val lineEnd = Offset(
x = outerRadius * cos(angleInRad) + circleCenter.x,
y = outerRadius * sin(angleInRad) + circleCenter.y
)
//3. 숫자 채우기
drawContext.canvas.nativeCanvas.apply {
if(lineType is LineType.TenStep) {
val textRadius = (outerRadius - lineLength - 5.dp.toPx() - style.textSize.toPx())
val x = textRadius * cos(angleInRad) + circleCenter.x
val y = textRadius * sin(angleInRad) + circleCenter.y
withRotation(
degrees = angleInRad * (180f / PI.toFloat()) + 90f,
pivotX = x,
pivotY = y
) {
drawText(
abs(i).toString(),
x,
y,
Paint().apply {
textSize = style.textSize.toPx()
textAlign = Paint.Align.CENTER
}
)
}
}
}
drawLine(
color = lineColor,
start = lineStart,
end = lineEnd,
strokeWidth = 1.dp.toPx()
)
}
라디안은 반지름과 호의 길이와 비율로 정의된다.
한 예로 반지름이 r이고 호의 길이도 r인 경우 1 라디안으로 정의된다.
라디안을 구하는 이유는 주로 사용하는 sin, cos, roate 등 float값이 라디안으로 정의되어 있기 때문이다.
그래서 아래의 코드는 특정 각도의 라디안 단위를 구한 것이다.
//어떤 각도에서 중심점 몸무게를 빼고 -90도 한 값의 라디언을 구하는 방식
// (i - initialWeight + angle - 90) : 어떤한 몸무게의 각도
// (PI / 180f).toFloat() : 특정 각도에서 라디안으로 변환하는 공식
val angleInRad = (i - initialWeight + angle - 90) * (PI / 180f).toFloat()

위 사진은 변환 검색으로 찾았을 시 1라디안 180/PI = (각도) 이며
1라디안은 (각도) / (180/PI) = (각도) (PI/180)가 되는 것을 알 수 있다.
//2. 라디안으로 구한 점의 시작점
val lineStart = Offset(
x = (outerRadius - lineLength) * cos(angleInRad) + circleCenter.x,
y = (outerRadius - lineLength) * sin(angleInRad) + circleCenter.y
)
//2. 라디안으로 구한 점의 끝점
val lineEnd = Offset(
x = outerRadius * cos(angleInRad) + circleCenter.x,
y = outerRadius * sin(angleInRad) + circleCenter.y
)
위 두 공식이 시작점과 끝점의 선을 연결하기 위한 점들이다.
먼저 이걸 활용하기 위해서는 원의 한 점이 특정 각도로 이동했을 때 두 점의 좌표를 구하는 방식을 알아야 한다.

위 사진에서 하늘색 점의 좌표를 구하려면
x의 좌표는 10 sin(45도), y의 좌표는 10 cos(45도) 가 된다. 물론 0,0 좌표점을 시작이기 때문에 특정한 좌표의 원의 값을 구하면
x = (R) * sin(degree) + (x의 중심 좌표)
y = (R) * cos(degree) + (y의 중심 좌표)
여기서 구하는 outerRadius는 아래의 같은 공식이 된다.
그래서 최종적인 코드인 lineStart는 바깥쪽 원의 길이에서 각 타입의 길이를 뺀 원의 특정 라디안 만큼 이동한 점이며,
lineEnd는 바깥쪽 원에서 구한 특정 라디안 만큼 이동한 점이 된다.
위 코드를 그리게 된다면 아래와 같은 결과를 얻을 수 있다.
이제 몸무게의 10 단위마다 숫자를 적으면 완성이다.
먼저 일반적으로 단순히 10의 단위일 때 숫자를 채운다고 생각해보자 그렇다면 아래 코드가 될 것이다.
//3. 숫자 채우기
drawContext.canvas.nativeCanvas.apply {
if (lineType is LineType.TenStep) {
val textRadius =
(outerRadius - lineLength - 5.dp.toPx() - style.textSize.toPx())
val x = textRadius * cos(angleInRad) + circleCenter.x
val y = textRadius * sin(angleInRad) + circleCenter.y
drawText(
abs(i).toString(),
x,
y,
Paint().apply {
textSize = style.textSize.toPx()
textAlign = Paint.Align.CENTER
}
)
}
}
여기서 textRadius는 3.2에서 봤는 라인의 시작점에서 단순 5픽셀을 벌린 후 textSize만큼 더 거리를 둔 것뿐 코드는 비슷하다.

나름 잘 작성이 됐지만 뭔가 살짝 아쉽다. 원으로 되어있지만 정작 글은 가로로 되어 있어 보기가 아쉽다.
글자도 각도마다 살짝씩 회전이 되어있다면 보다 완벽할 것이다.
이를 적용하기 위해서 withRotation을 적용했다.
3가지의 파라미터를 적용해야 한다.
여기서 각도가 라디안 값이 아닌 각도인 점을 참고하자
그래서 다시 구했던 라디안을 각도를 변환하는 공식을 사용
angleInRad * (180f / PI.toFloat()) + 90f
여기서 + 90f를 사용한 이유는 처음 중심점을 3시가 아닌 9시로 시작하고 싶기 때문에 -90을 진행했었다.
이 부분을 다시 채우기 위해 + 90f를 적용
피봇의 위치는 글자의 위치를 지정하면 된다. 그래야 해당 좌표에서 회전을 하기 때문
피봇의 위치는 아까 말했던 원에서 특정 라디안으로의 이동 좌표를 구하면 되기 때문에 textRadius을 이용한 점의 이동 좌표 공식을 그대로 적용하면 된다.
drawContext.canvas.nativeCanvas.apply {
if(lineType is LineType.TenStep) {
val textRadius = (outerRadius - lineLength - 5.dp.toPx() - style.textSize.toPx())
//textRadius부터 특정 라디언에서 떨어진 좌표 x
val x = textRadius * cos(angleInRad) + circleCenter.x
//textRadius부터 특정 라디언에서 떨어진 좌표 y
val y = textRadius * sin(angleInRad) + circleCenter.y
withRotation(
degrees = angleInRad * (180f / PI.toFloat()) + 90f,
pivotX = x,
pivotY = y
) {
drawText(
abs(i).toString(),
x,
y,
Paint().apply {
textSize = style.textSize.toPx()
textAlign = Paint.Align.CENTER
}
)
}
}
}
결과

정상적으로 몸무게가 나오는 것을 볼 수 있다.
이후에는 몸무게를 가리키는 Indicator를 그려볼 예정이다.