[Android] 커스텀 뷰 그리기

undefined·2024년 6월 23일
0

Android

목록 보기
5/6

이번 프로젝트를 하면서 커스텀 뷰를 그릴 일이 생겼다. 커스텀뷰를 이렇게 제대로(?) 그려본 건 처음이라서 뷰를 그리면서 찾아봤던 내용들, 고민했던 내용들을 정리해두려고 한다.

뷰는 어떻게 그려질까

뷰가 실질적으로 그려지는 때는 onDraw()가 호출되는 때 이다.
onDraw는 파라미터로 canvas를 받는데, 이 canvas위에 내가 그리고 싶은 요소들을 잘 배치하면 된다.

좌표

  • 뷰는 기본적으로 (0,0)부터 그려지기 시작해서, 오른쪽, 하단 방향으로 그려진다.
    예를 들어서 뷰의 정 가운데를 기준으로 바깥쪽으로 향하는 화살표를 그린다고 해 보자.
    그럼 뷰를 그릴 포인트를 뷰의 가운데로 옮기고, 그 점에서 r만큼 떨어진 선을 그리면 된다.
         canvas.apply {
             save()
             translate(centerX, centerY) // 뷰를 그릴 포인트를 옮김
             /* ... */
             restore()
         }
    물론 그냥 영역을 지정해서 그려도 되지만, 나의 경우에는 특정 위치를 기반으로 연산해서 그려야 했기 때문에 translate를 사용했다.

각도

  • 뷰를 그리다 보면 각도를 사용 할 일이 많은데, 특이한 점은, 각도의 시작이 시계의 3시 방향이 0도 이고 시계방향으로 각도가 증가한다.
    예를 들어서 직선이 바닥을 향한 반원 모양을 그린다고 하면
         canvas.drawArc(
             left,
             top,
             right,
             bottom,
             /*여기가 시작점:9시방향*/180f,
             /*시작점으로부터 몇 도 만큼 그릴건지*/180f,
             false,
             outerGrayPaint!!
         )
    위 처럼 180도를 더해야 9시방향부터 뷰가 그려진다.

애니메이션

  • 뷰가 특정 값에 따라 변경되는 경우, 뷰를 업데이트 해주어야 한다.
    뷰를 invalidate()를 통해 업데이트 해도 좋지만, 부자연스럽게 끊기는 것 처럼 보일 수 있다.
    나의 경우 값이 지속적으로 업데이트되고, 이 상태가 뷰에 반영되어야 했기 때문에 자연스러운 표현이 필요하다고 생각해서 애니메이션을 사용했다.
  1. 먼저 ValueAnimator를 초기화 하고, 값 변경 시 뷰를 업데이트 하도록 listener를 구현했다.

     init {
         initPaint()
    
         animator = ValueAnimator.ofFloat(0f, 0f)
         animator.setDuration(500)
         animator.addUpdateListener { animation ->
             arrowAngle = animation.animatedValue as Float
             invalidate()
         }
     }
  2. 이후 값이 변경될 때 마다 animator의 값을 갱신해주었다.

     fun updateAngle(targetAngle: Float) {
         if (animator.isRunning) {
             animator.cancel() // 기존 애니메이션 취소하고
         }
    
         animator.setFloatValues(currentAngle, targetAngle) // 값(시작 값, 종료 값)을 갱신해준다음
         animator.start() //애니메이션을 다시 시작한다
     }

유의할 점

이번에 뷰를 만들면서 기억하면 좋겠다 싶은 것들이 몇가지 있었다.

onDraw는 최대한 가볍게

일단 onDraw는 엄청 많이 호출된다. 그래서 여기서 객체를 생성하는 건 지양해야 한다.
android studio에서도 아마 워닝을 띄워줄텐데, 불필요하게 객체를 많이 생성하게 될 수 있으니 주의하자.

BUTT과 SQUARE

내가 반원모양 호를 그리는데 자꾸 내가 설정한 영역보다 선이 삐죽 튀어나와서 왜지 싶었는데, 선 끝 모양을 square로 설정해서였다.
cap을 square로 설정하고 점을 찍으면 네모가 그려진다ㅎㅎ..
선 끝이 직선이고, 내가 원하는 영역 내에만 그리고 싶으면 BUTT을 쓰자.

캐싱을 활용하자

내가 그렸던 뷰는 여러 요소 중에 3가지만 업데이트 됐다. 그래서 바뀌는 부분만 따로 업데이트를 할 수 없을까 싶었는데, 이럴 때 비트맵이나 캔버스를 캐싱해서 사용해보는 것도 좋을 것 같다고 생각했다.
방법은 챗 지피티 선생님께 도움을 받았다.

  • 변경되지 않을 요소들을 어떻게 그릴 지 계산해둔 것? 을 cachedBitmap에 저장한다.(뷰를 그리려면 매번 뷰의 위치와 크기, 스타일 등을 계산해야 하는데, 어차피 안 바뀔 거 한번 계산해둔거 계속 쓴다는거다)

     override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
         super.onSizeChanged(w, h, oldw, oldh)
         // 크기가 변경될 때마다 비트맵을 다시 생성
         cachedBitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
         cachedCanvas = Canvas(cachedBitmap!!)
         // 고정된 요소를 그리는 메서드 호출
         drawStaticElements(cachedCanvas!!)
     }
  • 변경되지 않을 요소들은 이전에 계산해둔 것을 그대로 쓰고, 업데이트 되어야 할 요소만 다시 계산해서 그린다.

     override fun onDraw(canvas: Canvas) {
         super.onDraw(canvas)
         // 캐싱된 비트맵 그리기
         cachedBitmap?.let {
             canvas.drawBitmap(it, 0f, 0f, null)
         }
    
         // 변경되는 요소들 그리기
         drawDynamicElements(canvas)
     }

스니펫

다른건 둘째치고 화살표 그리는게 제일 오래 걸렸는데, 언젠가 또 쓸 일이 있지 않을까 싶어서 남겨둬야겠다.

arrowLength  : 화살표의 길이
arrowHeadOffset : 화살표와 직교하는 포인트까지의 길이(화살표의 헤드가 시작하는 지점까지의 거리
        canvas.apply {
            save()
            translate(centerX, centerY)

            // arrowAngle에 따라 화살표 끝점 계산
            val arrowPointX = (arrowLength * cos(Math.toRadians((arrowAngle + 180).toDouble()))).toFloat()
            val arrowPointY = (arrowLength * sin(Math.toRadians((arrowAngle + 180).toDouble()))).toFloat()
            val arrowHeadX =
                (arrowHeadOffset * cos(Math.toRadians((arrowAngle + 180).toDouble()))).toFloat()
            val arrowHeadY =
                (arrowHeadOffset * sin(Math.toRadians((arrowAngle + 180).toDouble()))).toFloat()

            // 화살표 그리기
            drawLine(0f, 0f, arrowPointX, arrowPointY, arrowPaint!!)

            var dx = arrowHeadX
            var dy = arrowHeadY
            // 방향 벡터 정규화
            val length = Math.sqrt((dx * dx + dy * dy).toDouble()).toFloat()
            dx /= length
            dy /= length

            var px = -dy
            var py = dx

            px *= 20f
            py *= 20f
            // 수직선 끝점 계산
            val perpendicularEndX = arrowHeadX + px
            val perpendicularEndY = arrowHeadY + py

            val perpendicularOtherEndX = arrowHeadX - px
            val perpendicularOtherEndY = arrowHeadY - py

            drawLine(arrowPointX, arrowPointY, perpendicularEndX, perpendicularEndY, arrowPaint!!)
            drawLine(arrowPointX, arrowPointY, perpendicularOtherEndX, perpendicularOtherEndY, arrowPaint!!)

            restore()
        }
image
profile
이것저것 하고 싶은 게 많은 병아리 개발자

0개의 댓글