Jetpack Compose 를 사용하게 되면서 Canvas 를 더욱 쉽게 사용할 수 있게 되었습니다 👏
기존의 Native Canvas API 를 사용할 때에는 View 를 상속 받아 onLayout, onDraw 등의 라이프사이클과 상태를 고려하면서 작업을 진행했어야 했지만, 이제는 Jetpack Compose 를 이용해 View 를 상속받을 필요 없이 굉장히 쉬운 방법으로 UI 를 그릴 수 있습니다. Jetpack Compose Canvas 또한 Composable Function 으로 나타내고 있고, Compose 에서 UI 를 추가하는 방식처럼 Canvas 도 동일한 방식으로 배치할 수 있습니다.
@Composable
fun Icon() {
Canvas(
modifier = Modifier
.size(100.dp)
) {
//draw shapes here
}
}
Canvas 에서 사용되는 Paint 역시 기존 Native Canvas API 로 다루게 될 때 성능 이슈가 종종 발생하곤 했는데, 이 문제 역시 Jetpack Compose API 를 이용해 쉽게 제어가 가능하게 되었습니다. 덕분에 캔버스 안에 그릴 도형이나 선의 위치를 미세하게 배치할 수 있고 다양하게 스타일링이 가능해지게 되었죠!
Compose는 그래픽도 선언적 접근 방식으로 처리하는데, 이러한 접근방식은 여러 이점을 제공합니다. (Android Developers)
Canvas 에서는 left to right 방향이 X 좌표이고, top to bottom 방향이 Y 좌표로 구성되어 있습니다. 덕분에 좌표를 이용해 스크린 위 원하는 위치에 UI 요소들을 배치할 수 있고, 원하는 것을 더욱 쉽게 시각화하여 그릴 수 있습니다.
첫번째로 다음과 같이 반원형 곡선의 ProgressBar 를 그려본다고 가정해봅시다.
기존처럼 View 를 상속하여 만든다면 아래와 같이 구현할 수 있습니다.
class SemiCircleProgressBar @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
) : View(context) {
private val progressBarRectF: RectF
get() = RectF(left.toFloat(), top.toFloat(), right.toFloat(), bottom.toFloat())
// 첫번째로 그려진, Progress Bar PlaceHolder Background 를 설정하는 변수 (즉, gif 상 회색 영역)
private var progressPlaceHolderColor = 0
// 두번째로 그려진, 진척도를 나타내는 Progress Bar Background 를 설정하는 변수 (즉, gif 상 초록색 영역)
private var progressBarColor = 0
private var percent = 0
override fun onDraw(canvas: Canvas) {
// 회색의 호를 그린다
canvas.drawArc(progressBarRectF,
180f,
180f,
false,
getPaint(progressPlaceHolderColor,
10f))
// 그 위에 녹색의 호를 그린다.
canvas.drawArc(progressBarRectF,
180f,
percent.toFloat(),
false,
getPaint(progressBarColor,
10f))
}
private fun getPaint(color: Int, strokeWidth: Float): Paint {
return Paint().apply {
this.color = color
this.style = Paint.Style.STROKE
this.strokeWidth = strokeWidth
this.isAntiAlias = true
this.strokeCap = Paint.Cap.ROUND
}
}
etc ...
fun setPercent(percent: Int) {
this.percent = percent
postInvalidate()
}
// 특정 값으로 색을 채우는 Animation
fun animate(startPercent: Int = percent, endPercent: Int = percent) {
var min = startPercent.coerceAtMost(endPercent)
var max = startPercent.coerceAtLeast(endPercent)
Timer().scheduleAtFixedRate(object : TimerTask() {
override fun run() {
if (startPercent <= endPercent) {
if (min <= max) setPercent(min++)
} else {
if (min <= max) setPercent(max--)
}
}
}, 0, 30)
}
init {
animate(startPercent = 0, endPercent = percent)
}
}
이 때, onDraw 영역에 그려지는 drawArc 의 의미는 아래와 같습니다.
//drawArc: 호를 그리는 함수를 의미한다.
//oval : Rect 안에 원을 그린다.
//startAngle : 시작 각도.
//sweepAngle : 시작 각도부터 시계방향으로 몇도를 그릴지
//useCenter : false로 설정 시 호, true로 설정 시 부채꼴모양으로 그림.
//paint : paint 객체 설정
canvas.drawArc(
oval = progressBarRectF,
startAngle = 180f,
sweepAngle = 180f, // 180도(시작 각도)에서 180도만큼 그린다는 의미.
useCenter = false,
paint = getPaint(
progressPlaceHolderColor,
progressPlaceHolderWidth.toFloat()
)
)
복잡하게 구현된 SemiCircleProgressBar
는 Jetpack Compose Canvas 를 이용한다면 더욱 간편하게 구현할 수 있게 됩니다.
@Composable
fun SemiCircleProgressBar(randomValue: Float) {
val animatedValue = remember { Animatable(0f) }
var percent: Float = randomValue
// 특정 값으로 색을 채우는 Animation
LaunchedEffect(animatedValue) {
animatedValue.animateTo(
targetValue = 1f,
animationSpec = tween(durationMillis = 1000, easing = LinearEasing),
)
percent = randomValue * animatedValue.value
}
val scoreTextPaint = Paint().apply {
textAlign = Paint.Align.CENTER
textSize = 50f
color = Color.Gray.toArgb()
}
Column {
Box(
modifier = Modifier.size(400.dp, 200.dp)
) {
Canvas(modifier = Modifier
.size(400.dp)
.padding(16.dp)
) {
drawArc(
color = Color.LightGray,
startAngle = 180f,
sweepAngle = 180f,
useCenter = false,
size = Size(900.dp.value, 900.dp.value),
style = Stroke(width = 40f, cap = StrokeCap.Round)
)
drawArc(
color = Color.Green,
startAngle = 180f,
sweepAngle = percent,
useCenter = false,
size = Size(900.dp.value, 900.dp.value),
style = Stroke(width = 40f, cap = StrokeCap.Round)
)
drawContext.canvas.nativeCanvas.drawText(percent.toString(), center.x, 200f, scoreTextPaint)
}
}
}
}
Compose 로 Canvas 위에 객체를 그릴 때도 동일하게 drawArc 함수를 이용해 호를 그릴 수 있으며, 필요한 파라미터도 기존 Native Canvas 와 동일합니다. 하지만 기존 Native Canvas API 를 사용했을 때와 비교할 때, Compose가 더 쉽고 빠르게 구현할 수 있다는 것을 체험할 수 있었습니다. 머리에서 어떻게 그릴지 그려놓은 상태라면, 쉽게 코드로 옮길 수 있는 구조를 제공하는 느낌이었습니다. 🙂
두번째로는 다양한 도형을 이용해 로고를 그려보겠습니다.
로고는 원, 사각형, 삼각형, 반원으로 이루어져 있고 도형들이 위치하고 있는 좌표를 특정지을 수 있습니다. 즉, 도형들을 어느 곳에 배치할 것인지만 정한다면 쉽게 그릴 수 있게 됩니다.
이를 코드로 옮기면 아래처럼 표현할 수 있습니다.
@Composable
fun Logo() {
Canvas(
modifier = Modifier
.size(100.dp)
.padding(16.dp)
) {
drawCircle(
color = Color(0xFF0091F2),
radius = 30f,
center = Offset(size.width * .25f, size.height * .05f),
)
rotate(degrees = 45F) {
drawRoundRect(
color = Color(0XFF00C3C3),
size = Size(50f, 50f),
topLeft = Offset(x = size.width * .25f, y = size.height * .05f),
cornerRadius = CornerRadius(2f, 2f)
)
}
drawPath(
path = Path().apply {
moveTo(size.width * .35f, size.height * .25f)
lineTo(size.width * .28f, size.height * .58f)
lineTo(size.width * .60f, size.height * .50f)
close()
},
color = Color(0xFFE97BBC),
)
drawArc(
color = Color(0XFF00C379),
startAngle = 360f,
sweepAngle = 180f,
useCenter = false,
size = Size(size.width - 10f, size.height - 10f),
style = Stroke(width = 45f, cap = StrokeCap.Square)
)
}
}
drawCircle
를 이용해 원을 그리고, drawRoundRect
를 이용해 모서리가 둥근 사각형을 그리고, drawPath
를 이용해 선을 이어 삼각형을 만들어 주었습니다. Text, Image 를 배치하는 방식과 동일하게 사용하면 됩니다.
Canvas 를 쉽게 다룰 수 있게 되면서, 앞으로도 다양한 Custom View 를 만들어보자는 생각이 들었습니다. Compose 는 Hot Reload 가 가능하다는 점에서 코드를 변경했을 때 뷰의 변화를 금방 알아차릴 수 있다는 것이 큰 장점으로 다가왔는데요. 덕분에 도형의 좌표를 변경할 때 실시간으로 위치조정을 하면서 값을 반영할 수 있었고, 편하게 개발할 수 있었습니다.
https://medium.com/falabellatechnology/jetpack-compose-canvas-8aee73eab393