Android Jetpack Compose, Canvas (5)
Search
🥊

Android Jetpack Compose, Canvas (5)

생성일
2023/02/13 13:39
태그
네이버앱 홈 화면
카메라 앱
이런 저런 화면을 만들다 보면 위에 보이는 뷰를 만들어야할 때가 온다. 드래그나 스와이프 등의 제스쳐를 통해 뷰를 돌린다거나 숫자를 바꾸는 등의 액션을 줘야 하는데, 아주 약간의 처리만 해주면 쉽게 만들 수 있다.
이번에는 체중계를 만들어 본다. 이 체중계는 사용자의 제스처를 통해 무게를 변경할 수 있다. 제스처 한 번에 원하는 몸무게로 바뀌면 얼마나 좋을까? 소망을 담아 만들어보자.

목표 화면

최종적인 목표는 위와 같이 생겼다. 나란히 있는 타원은 고구마랑 닭가슴살 80g 아니고, 발자국이다.
체중계에 들어가는 눈금자는 사실 이렇게 생겼다. 이제 이 커다란 동그라미를 체중계 안에 넣어준다. 그 전에 동그라미에 눈금자를 그리고, 숫자를 쓰고, 사용자에게 제스처를 받아 회전을 시키도록하는 것 까지 단계적으로 설명할 예정이다.

준비물

data class ScaleType
sealed class LineType
눈금자의 길이와 색상을 구분하기 위해 ScaleType과 LineType을 준비하자.
data class ScaleStyle( val strokeWidth: Dp = 30.dp, val radius: Dp = 300.dp, val normalLineColor: androidx.compose.ui.graphics.Color = androidx.compose.ui.graphics.Color.LightGray, val fiveStepLineColor: androidx.compose.ui.graphics.Color = androidx.compose.ui.graphics.Color.Green, val tenStepLineColor: androidx.compose.ui.graphics.Color = androidx.compose.ui.graphics.Color.Black, val normalLineLength: Dp = 15.dp, val fiveStepLineLength: Dp = 25.dp, val tenStepLineLength: Dp = 35.dp, val scaleIndicatorColor: androidx.compose.ui.graphics.Color = androidx.compose.ui.graphics.Color.Green, val scaleIndicatorLength: Dp = 80.dp, val textSize: TextUnit = 18.sp ) sealed class LineType { object Normal : LineType() object FiveStep : LineType() object TenStep : LineType() }
Kotlin
복사

drawCircle

동그라미 부터 그려보자
@Composable fun MyScale( modifier: Modifier = Modifier, ) { Canvas( modifier = modifier ) { drawContext.canvas.nativeCanvas.apply { drawCircle( center.x, center.y, radius.toPx(), Paint().apply { color = android.graphics.Color.WHITE setStyle(Paint.Style.FILL) setShadowLayer(30f, 0f, 0f, android.graphics.Color.BLACK) } ) } } }
Kotlin
복사
지난 포스팅에서 관련된 설명을 했기 때문에 바로 눈금자를 그리러 가보자

눈금자 - 1

for (i in 0..360) { drawCircle( color = Color.Cyan, radius = 1.dp.toPx(), center = Offset( x = (radius.toPx() ) * cos(Math.toRadians(i.toDouble())).toFloat() + center.x, y = (radius.toPx() ) * sin(Math.toRadians(i.toDouble())).toFloat() + center.y, ) ) }
Kotlin
복사
360도 모두에 점이 잘 찍혔다. 더 그럴싸한 눈금자를 그리기 위해 drawCircle이 아닌 drawLine으로 바꿔보자
val style = remember { ScaleStyle() } for (i in 0..360) { drawLine( color = style.normalLineColor, start = Offset( x = (radius.toPx() - style.normalLineLength.toPx()) * cos(Math.toRadians(i.toDouble())).toFloat() + center.x, y = (radius.toPx() - style.normalLineLength.toPx()) * sin(Math.toRadians(i.toDouble())).toFloat() + center.y, ), end = Offset( x = radius.toPx() * cos(Math.toRadians(i.toDouble())).toFloat() + center.x, y = radius.toPx() * sin(Math.toRadians(i.toDouble())).toFloat() + center.y , ), strokeWidth = 1.dp.toPx() ) }
Kotlin
복사
동그라미 선에 맞춰 눈금자가 촘촘히 이쁘게 그려졌다.

눈금자 - 2

이제 쉽게 구분 가능하도록 5번 째, 10번 째 선 마다 색상과 사이즈를 다르게 적용해주자
val style = remember { ScaleStyle() } for (i in 0..360) { 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 } drawLine( color = lineColor, start = Offset( x = (radius.toPx() - lineLength) * cos(Math.toRadians(i.toDouble())).toFloat() + center.x, y = (radius.toPx() - lineLength) * sin(Math.toRadians(i.toDouble())).toFloat() + center.y, ), end = Offset( x = radius.toPx() * cos(Math.toRadians(i.toDouble())).toFloat() + center.x, y = radius.toPx() * sin(Math.toRadians(i.toDouble())).toFloat() + center.y , ), strokeWidth = 1.dp.toPx() ) }
Kotlin
복사
아주 예쁜 모습의 눈금자가 그려졌다.

drawText

눈금자 밑에 숫자를 써 넣어줄건데, 10번 째 마다 긴 눈금자 밑에 써준다.
drawContext.canvas.nativeCanvas.apply { if (lineType is LineType.TenStep && i != 360) { val textRadius = radius.toPx() - lineLength - 5.dp.toPx() - style.textSize.toPx() val x = textRadius * cos(Math.toRadians(i.toDouble())).toFloat() + center.x val y = textRadius * sin(Math.toRadians(i.toDouble())).toFloat() + center.y withRotation( degrees = Math.toRadians(i.toDouble()).toFloat() * (180f / PI.toFloat()) + 90f, pivotX = x, pivotY = y ) { drawText( kotlin.math.abs(i).toString(), x, y, Paint().apply { textSize = style.textSize.toPx() textAlign = Paint.Align.CENTER } ) } } }
Kotlin
복사
withRoation으로 degrees 값을 주고 숫자의 방향을 각도에 맞춰 기울여 준다. withRotation 값을 줬을 때랑 아닐 때를 비교해보자.
withRoation 적용X
withRotation 적용O

표시선

현재 값이 무엇인지 표시해줄 수직선을 추가해주자.
drawLine( color = style.scaleIndicatorColor, start = Offset(center.x, center.y - radius.toPx() + lineLength * 4.5f), end = Offset(center.x, center.y), strokeWidth = 1.dp.toPx() )
Kotlin
복사

제스처 적용

이제 손가락으로 드래그 했을 때 움직이는 제스처를 줘야하는데, Modifier.pointerInput()을 이용하면 된다. angle, dragStartedAngle, oldAngle 세개의 값이 필요하다.
var angle by remember { mutableStateOf(0f) } var dragStartedAngle by remember { mutableStateOf(0f) } var oldAngle by remember { mutableStateOf(angle) } val minWeight = remember { 0 } val maxWeight = remember { 360 } Canvas( modifier = modifier .pointerInput(true) { detectDragGestures( onDragStart = { offset -> dragStartedAngle = -atan2( center.x - offset.x, center.y - offset.y ) * (180f / PI.toFloat()) }, onDragEnd = { oldAngle = angle } ) { change, dragAmount -> val touchAngle = -atan2( center.x - change.position.x, center.y - change.position.y ) * (180f / PI.toFloat()) val newAngle = oldAngle + (touchAngle - dragStartedAngle) angle = newAngle.coerceIn( minimumValue = initialWeight - maxWeight.toFloat(), maximumValue = initialWeight - minWeight.toFloat() ) } } ) { . . .
Kotlin
복사
여기서 마지막 부분에 angle = newAngle.coerceIn 는 minWeight(0)보다 왼쪽으로, maxWeight(360)보다 오른쪽으로 가지 않게 한다.
하지만, 드래그가 작동하지 않는다. 0~360도에 맞춰서 눈금자와 숫자를 그렸기 때문에 Math.toRadians(i.toDouble()).toFloat() 로 사용하던 값을 전부 변경해줘야 한다.
val angleInRad = (i - initialWeight + angle - 90) * ((kotlin.math.PI / 180f ).toFloat())
Kotlin
복사
라디안값을 전부 angleInRad로 대체해주면 된다.

체중계 껍데기

대충 체중계 껍데기 스러운 모양을 만들어 주고, 만들어준 것을 안에 집어 넣어 주기만 하면 된다.
Column(modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.Center) { Column( modifier = Modifier .fillMaxWidth() .height(400.dp) .padding(30.dp) .background( color = androidx.compose.ui.graphics.Color.LightGray, shape = RoundedCornerShape(2.dp) ) ) { Column( modifier = Modifier .fillMaxWidth() .height(130.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { MyScale( modifier = Modifier .width(150.dp) .height(130.dp) .padding(top = 30.dp) .clipToBounds() .offset(y = 270.dp), ) } Row( modifier = Modifier .fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center ) { Canvas( modifier = Modifier .fillMaxWidth() .height(200.dp) ) { rotateRad( Math.toRadians(325f.toDouble()).toFloat(), Offset(center.x, center.y) ) { drawOval( color = androidx.compose.ui.graphics.Color.White, topLeft = Offset(center.x / 3, 0f), size = Size(180f, 400f), style = Stroke(8f) ) } rotateRad( Math.toRadians(35f.toDouble()).toFloat(), Offset(center.x, center.y) ) { drawOval( color = androidx.compose.ui.graphics.Color.White, topLeft = Offset(center.x + center.x / 3, 0f), size = Size(180f, 400f), style = Stroke(8f) ) } } } } }
Kotlin
복사
발자국 drawOval()을 이용해 타원을 그려주고, rotateRad()로 회전 값을 주면 된다.
체중계 삽입 MyScale()로 이름을 지었다. 그대로 넣으면 커다란 동그라미 그대로 화면을 덮어버리는데, 여기서 clipToBounds()를 통해 Canvas size만큼 잘라내 주면 된다. 잘 안보일 수가 있는데 offset() y값을 270.dp 정도 주면 된다.
여기까지 잘 따라 왔다면 잘 작동하는 것을 볼 수 있는데, 드래그 방향과 반대로 움직일 것이다. 한 가지 더 수정해줘야 하는데 다음 값을 수정해주면 된다.
@Composable fun MyScale( modifier: Modifier = Modifier, initialWeight: Int = 80, ) { val minWeight = remember { 0 } val maxWeight = remember { 360 } val style = remember { ScaleStyle() } val radius = style.radius var center by remember { mutableStateOf(Offset.Zero) } var angle by remember { mutableStateOf(0f) } var dragStartedAngle by remember { mutableStateOf(0f) } var oldAngle by remember { mutableStateOf(angle) } Canvas( modifier = Modifier .pointerInput(true) { detectDragGestures( onDragStart = { offset -> dragStartedAngle = -atan2( center.y - offset.y, center.x - offset.x, ) * (180f / PI.toFloat()) }, onDragEnd = { oldAngle = angle } ) { change, _ -> val touchAngle = -atan2( center.y - change.position.y, center.x - change.position.x , ) * (180f / PI.toFloat()) val newAngle = oldAngle + (touchAngle - dragStartedAngle) angle = newAngle.coerceIn( minimumValue = initialWeight - maxWeight.toFloat(), maximumValue = initialWeight - minWeight.toFloat() ) } } .then(modifier) . . .
Kotlin
복사
pointerInput() detectDragGestures() 내부에 보면 -atan2()에 대한 수식이 있다.

-atan2( x = center.y - offset.y, y = center.x - offset.x, )
Kotlin
복사
위와 같이 x 값에 “center.y - offset.y” 값을, y 값에 “center.x - offset.x” 값을 줬다면

-atan2( x = center.x - offset.x, y = center.y - offset.y, )
Kotlin
복사
x 값에 “center.x - offset.x” 값을, y 값에 “center.y - offset.y” 값을 주면 된다.

결과 화면

이번 포스팅에서는 다양한 수식이 등장했지만 자세한 설명은 생략했다. Canvas를 다루다 보면 수학적으로 접근해야 하는 일이 많다. 이 부분에 대해서는 별도 포스팅을 통해 설명하고자 한다.

전체 코드

import android.graphics.Paint import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.rotateRad import androidx.compose.ui.graphics.nativeCanvas import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.core.graphics.withRotation import java.lang.Math.PI import kotlin.math.atan2 import kotlin.math.cos import kotlin.math.sin @Composable fun MyMutableScale() { Column(modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.Center) { Column( modifier = Modifier .fillMaxWidth() .height(400.dp) .padding(30.dp) .background( color = androidx.compose.ui.graphics.Color.LightGray, shape = RoundedCornerShape(2.dp) ) ) { Column( modifier = Modifier .fillMaxWidth() .height(130.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { MyScale( modifier = Modifier .width(150.dp) .height(130.dp) .padding(top = 30.dp) .clipToBounds() .offset(y = 270.dp), ) } Row( modifier = Modifier .fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center ) { Canvas( modifier = Modifier .fillMaxWidth() .height(200.dp) ) { rotateRad( Math.toRadians(325f.toDouble()).toFloat(), Offset(center.x, center.y) ) { drawOval( color = androidx.compose.ui.graphics.Color.White, topLeft = Offset(center.x / 3, 0f), size = Size(180f, 400f), style = Stroke(8f) ) } rotateRad( Math.toRadians(35f.toDouble()).toFloat(), Offset(center.x, center.y) ) { drawOval( color = androidx.compose.ui.graphics.Color.White, topLeft = Offset(center.x + center.x / 3, 0f), size = Size(180f, 400f), style = Stroke(8f) ) } } } } } } @Composable fun MyScale( modifier: Modifier = Modifier, initialWeight: Int = 80, ) { val minWeight = remember { 0 } val maxWeight = remember { 360 } val style = remember { ScaleStyle() } val radius = style.radius var center by remember { mutableStateOf(Offset.Zero) } var angle by remember { mutableStateOf(0f) } var dragStartedAngle by remember { mutableStateOf(0f) } var oldAngle by remember { mutableStateOf(angle) } Canvas( modifier = Modifier .pointerInput(true) { detectDragGestures( onDragStart = { offset -> dragStartedAngle = -atan2( center.y - offset.y, center.x - offset.x, ) * (180f / PI.toFloat()) }, onDragEnd = { oldAngle = angle } ) { change, _ -> val touchAngle = -atan2( center.y - change.position.y, center.x - change.position.x, ) * (180f / PI.toFloat()) val newAngle = oldAngle + (touchAngle - dragStartedAngle) angle = newAngle.coerceIn( minimumValue = initialWeight - maxWeight.toFloat(), maximumValue = initialWeight - minWeight.toFloat() ) } } .then(modifier) ) { center = this.center drawContext.canvas.nativeCanvas.apply { drawCircle( center.x, center.y, radius.toPx(), Paint().apply { color = android.graphics.Color.WHITE setStyle(Paint.Style.FILL) setShadowLayer(30f, 0f, 0f, android.graphics.Color.BLACK) } ) } for (i in 0..360) { val angleInRad = (i - initialWeight + angle - 90) * ((kotlin.math.PI / 180f).toFloat()) 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 } drawLine( color = lineColor, start = Offset( x = (radius.toPx() - lineLength) * cos(angleInRad) + center.x, y = (radius.toPx() - lineLength) * sin(angleInRad) + center.y, ), end = Offset( x = radius.toPx() * cos(angleInRad) + center.x, y = radius.toPx() * sin(angleInRad) + center.y, ), strokeWidth = 1.dp.toPx() ) drawContext.canvas.nativeCanvas.apply { if (lineType is LineType.TenStep && i != 360) { val textRadius = radius.toPx() - lineLength - 5.dp.toPx() - style.textSize.toPx() val x = textRadius * cos(angleInRad) + center.x val y = textRadius * sin(angleInRad) + center.y withRotation( degrees = angleInRad * (180f / PI.toFloat()) + 90f, pivotX = x, pivotY = y ) { drawText( kotlin.math.abs(i).toString(), x, y, Paint().apply { textSize = style.textSize.toPx() textAlign = Paint.Align.CENTER } ) } } } drawLine( color = style.scaleIndicatorColor, start = Offset(center.x, center.y - radius.toPx() + lineLength * 4.5f), end = Offset(center.x, center.y), strokeWidth = 1.dp.toPx() ) } } } data class ScaleStyle( val strokeWidth: Dp = 30.dp, val radius: Dp = 300.dp, val normalLineColor: androidx.compose.ui.graphics.Color = androidx.compose.ui.graphics.Color.LightGray, val fiveStepLineColor: androidx.compose.ui.graphics.Color = androidx.compose.ui.graphics.Color.Green, val tenStepLineColor: androidx.compose.ui.graphics.Color = androidx.compose.ui.graphics.Color.Black, val normalLineLength: Dp = 15.dp, val fiveStepLineLength: Dp = 25.dp, val tenStepLineLength: Dp = 35.dp, val scaleIndicatorColor: androidx.compose.ui.graphics.Color = androidx.compose.ui.graphics.Color.Green, val scaleIndicatorLength: Dp = 80.dp, val textSize: TextUnit = 18.sp ) sealed class LineType { object Normal : LineType() object FiveStep : LineType() object TenStep : LineType() }
Kotlin
복사

참고