네이버앱 홈 화면
카메라 앱
이런 저런 화면을 만들다 보면 위에 보이는 뷰를 만들어야할 때가 온다. 드래그나 스와이프 등의 제스쳐를 통해 뷰를 돌린다거나 숫자를 바꾸는 등의 액션을 줘야 하는데, 아주 약간의 처리만 해주면 쉽게 만들 수 있다.
이번에는 체중계를 만들어 본다. 이 체중계는 사용자의 제스처를 통해 무게를 변경할 수 있다. 제스처 한 번에 원하는 몸무게로 바뀌면 얼마나 좋을까? 소망을 담아 만들어보자.
목표 화면
최종적인 목표는 위와 같이 생겼다. 나란히 있는 타원은 고구마랑 닭가슴살 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
복사