이번 프로젝트를 하면서 커스텀 뷰를 그릴 일이 생겼다. 커스텀뷰를 이렇게 제대로(?) 그려본 건 처음이라서 뷰를 그리면서 찾아봤던 내용들, 고민했던 내용들을 정리해두려고 한다.
뷰가 실질적으로 그려지는 때는 onDraw()가 호출되는 때 이다.
onDraw는 파라미터로 canvas를 받는데, 이 canvas위에 내가 그리고 싶은 요소들을 잘 배치하면 된다.
canvas.apply {
save()
translate(centerX, centerY) // 뷰를 그릴 포인트를 옮김
/* ... */
restore()
}
물론 그냥 영역을 지정해서 그려도 되지만, 나의 경우에는 특정 위치를 기반으로 연산해서 그려야 했기 때문에 translate를 사용했다. canvas.drawArc(
left,
top,
right,
bottom,
/*여기가 시작점:9시방향*/180f,
/*시작점으로부터 몇 도 만큼 그릴건지*/180f,
false,
outerGrayPaint!!
)
위 처럼 180도를 더해야 9시방향부터 뷰가 그려진다.먼저 ValueAnimator를 초기화 하고, 값 변경 시 뷰를 업데이트 하도록 listener를 구현했다.
init {
initPaint()
animator = ValueAnimator.ofFloat(0f, 0f)
animator.setDuration(500)
animator.addUpdateListener { animation ->
arrowAngle = animation.animatedValue as Float
invalidate()
}
}
이후 값이 변경될 때 마다 animator의 값을 갱신해주었다.
fun updateAngle(targetAngle: Float) {
if (animator.isRunning) {
animator.cancel() // 기존 애니메이션 취소하고
}
animator.setFloatValues(currentAngle, targetAngle) // 값(시작 값, 종료 값)을 갱신해준다음
animator.start() //애니메이션을 다시 시작한다
}
이번에 뷰를 만들면서 기억하면 좋겠다 싶은 것들이 몇가지 있었다.
일단 onDraw는 엄청 많이 호출된다. 그래서 여기서 객체를 생성하는 건 지양해야 한다.
android studio에서도 아마 워닝을 띄워줄텐데, 불필요하게 객체를 많이 생성하게 될 수 있으니 주의하자.
내가 반원모양 호를 그리는데 자꾸 내가 설정한 영역보다 선이 삐죽 튀어나와서 왜지 싶었는데, 선 끝 모양을 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()
}