Jetpack Compose Canvas 톺아보기

Sehee Jeong·2022년 2월 14일
2

Jetpack Compose

목록 보기
1/1

Jetpack Compose Canvas API

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)

  • Compose는 그래픽 요소의 상태를 최소화하므로 상태의 프로그래밍 실수를 피할 수 있습니다.
  • 항목을 그릴 때 모든 옵션이 구성 가능한 함수의 예상되는 위치에 있습니다.
  • Compose의 그래픽 API가 효율적인 방법으로 객체를 만들고 해제합니다.

Canvas 에서는 left to right 방향이 X 좌표이고, top to bottom 방향이 Y 좌표로 구성되어 있습니다. 덕분에 좌표를 이용해 스크린 위 원하는 위치에 UI 요소들을 배치할 수 있고, 원하는 것을 더욱 쉽게 시각화하여 그릴 수 있습니다.

Example 1

첫번째로 다음과 같이 반원형 곡선의 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가 더 쉽고 빠르게 구현할 수 있다는 것을 체험할 수 있었습니다. 머리에서 어떻게 그릴지 그려놓은 상태라면, 쉽게 코드로 옮길 수 있는 구조를 제공하는 느낌이었습니다. 🙂


Example 2

두번째로는 다양한 도형을 이용해 로고를 그려보겠습니다.

로고는 원, 사각형, 삼각형, 반원으로 이루어져 있고 도형들이 위치하고 있는 좌표를 특정지을 수 있습니다. 즉, 도형들을 어느 곳에 배치할 것인지만 정한다면 쉽게 그릴 수 있게 됩니다.

  • 로고는 100 x 100 크기를 가지고 있습니다.
  • 원은 (25, 5) 좌표에 위치하고 있습니다.
  • 사각형은 (25, 5) 좌표에서 45도 회전한 곳에 위치하고 있습니다.
  • 삼각형은 (35, 25), (28, 58), (60, 50) 꼭짓점을 이어서 만들 수 있습니다.
  • 호는 360도 지점에서 180도만큼 그리며, 두께는 45pixel 입니다.

이를 코드로 옮기면 아래처럼 표현할 수 있습니다.

@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 를 배치하는 방식과 동일하게 사용하면 됩니다.


End

Canvas 를 쉽게 다룰 수 있게 되면서, 앞으로도 다양한 Custom View 를 만들어보자는 생각이 들었습니다. Compose 는 Hot Reload 가 가능하다는 점에서 코드를 변경했을 때 뷰의 변화를 금방 알아차릴 수 있다는 것이 큰 장점으로 다가왔는데요. 덕분에 도형의 좌표를 변경할 때 실시간으로 위치조정을 하면서 값을 반영할 수 있었고, 편하게 개발할 수 있었습니다.

위 코드는 Gist 에서 확인할 수 있습니다.

Reference

https://medium.com/falabellatechnology/jetpack-compose-canvas-8aee73eab393

profile
android developer @bucketplace

0개의 댓글