그래프 라이브러리 2. 그래프 축 그리기
Last updated: Jun 30, 2024
이번에 부스트캠프 그룹 프로젝트를 하면서 그래프 라이브러리를 구현한 과정을 정리해보았다. 이번 포스팅에서는 그 중에서 핵심 부분인 그래프 축 그리는 부분에 대해 설명하고자 한다. (project repo, library repo)
고려해야 할 것
구현하기 전에 축을 그릴때 어떤 걸 고려해야될지 간단하게 적어 보았다.
- 눈금의 개수
- 눈금당 범위
- 눈금당 간격 (px)
- 축 길이 및 여백
처음에 이렇게 생각하고 구현을 했으나, 테스트를 하다보니 점점 고려해야될 부분이 많아지기 시작했다. 우선 어떤 순서로 그릴지 정해봤다.
그리는 순서
처음에는 다음과 같이 구현하였다.
- 축을 그릴 수 있는 길이 측정
- 눈금당 범위 계산
- 눈금당 간격 및 개수 계산
- 축 그리기
- 눈금 그리기
- 라벨 그리기
그래프를 눈금 비율에 맞춰 그리기 위해 거듭 수정을 거쳐, 최종적으로는 다음과 같이 그린다.
- 축을 그릴 수 있는 길이 측정
- 축 위에서 실제로 라벨이 그려지는 범위 측정
- 각 눈금당 간격 및 개수 계산
- 각 눈금당 범위 계산
- 축 그리기
- 최소, 최대 눈금 그리기
- 최소 또는 최대 눈금에서부터 눈금당 간격 만큼씩 눈금 그리기
- 라벨 그리기
- 라벨 그리는중 겹칠 시, 라벨을 기울여서 다시 그리기
- 라벨을 기울여서 그린 후 잘려 보일때, 필요한 여백 계산 후 다시 그리기
거의 과정이 2배나 추가됐다! 이제 위 단계들을 구체적으로 어떻게 구현했는지 알아보자.
그리기
0. 사전 설정
Canvas의 모든 View는 다 px단위로 계산한다. 하지만 우리가 흔히 안드로이드 개발을 할때는 디스플레이 픽셀 밀도와 독립적인 dp단위를 사용한다. 왜냐하면 같은 크기의 두 화면에서 픽셀 수가 다를 수 있기 때문인데, Canvas에서 그냥 px 단위로 계산하게 되면 휴대폰마다 View 내부 내용들이 동일하게 보이지 않고 깨질 수가 있다. 이를 용이하게 처리하기 위해 Px 클래스와 Dp 클래스를 만들고, 이 둘을 서로 변환할 수 있게끔 만들어주었다.
data class Px(val value: Float)
data class Dp(val value: Float)
fun Px.toDp(context: Context): Dp {
val density = context.resources.displayMetrics.density
return Dp(value / density)
}
fun Dp.toPx(context: Context): Px {
val density = context.resources.displayMetrics.density
return Px(value * density)
}
휴대폰 디스플레이의 밀도를 가져오려면 context가 무조건 필요하기때문에 함수 파라미터로 Context를 추가해주었다.

추가로 그리기 전에 알아야하는게 있다. Canvas의 좌표는 좌측 상단에서 부터 0,0 으로 시작하고, x는 오른쪽으로, y는 아래로 갈 수록 좌표값이 올라간다.
1. 축

축에는 여백이 필요하다
축을 처음 그리기 전에는, 축을 그릴 수 있는 길이를 측정해야된다. View가 있으면 단순히 왼쪽 끝, 오른쪽 끝에서 선을 그으면 그만 아니냐고 할 수 있지만, 눈금도 그려야하고, 라벨도 붙여야 한다. 이를 위해 우선 각 축의 여백 시작, 끝을 Dp로 지정해주었다. 우선은 초기값으로 32dp를 설정해두었다.
// Margin: Empty space from the view to the graph on the outside. This includes the other side as well (Like horizontal & vertical margins)
var xAxisMarginStart: Dp = Dp(32F)
set(value) {
field = value
invalidate()
}
var xAxisMarginEnd: Dp = Dp(32F)
set(value) {
field = value
invalidate()
}
var yAxisMarginStart: Dp = Dp(32F)
set(value) {
field = value
invalidate()
}
var yAxisMarginEnd: Dp = Dp(32F)
set(value) {
field = value
invalidate()
}
어느 축의 어떤 쪽이 시작과 끝인지 약간 헷갈릴 수는 있으나, 그림으로 정리해보면 다음과 같다. Start는 무조건 x축과 y축이 교차하는 0,0 지점, 그리고 End는 각 축이 끝나는 지점으로 설정하였다.

그래서 축을 실제로 그리는 범위는 View의 가로길이 - 축의 Margin Start - 축의 Margin End
가 된다. View의 width에서 나오는 값은 px이고, 각 축의 margin 단위는 dp이니 이를 꼭 변환해서 처리해준다. (연산의 편리성을 위해 각 단위의 사칙연산 오버로딩을 추가해두었다)
// Calculate available axis space
val availableSpace: Dp =
Px(width.toFloat()).toDp(context) - xAxisMarginStart - xAxisMarginEnd
추가적으로, 축이 끝나는 지점까지 눈금이 있으면 이상해 보이기 때문에, 이를 위해 axis별로 padding 값을 추가해주고, 라벨이 실제로 그려질 범위를 계산한다.
val availableLabelSpace: Dp = availableSpace - xAxisPadding
2. 눈금
눈금 개수, 간격
처음에는 눈금 간격의 기본값을 dp로 정해두고, 해당 간격으로 눈금을 몇개 그릴 수 있는지 계산한 뒤, 해당 눈금 개수에 맞춰서 눈금 단위를 계산하는 방식으로 구현을 했었다. 완벽할 줄 알았던 이 로직에는 치명적인 문제점이 있었다. 눈금 간격의 기본값을 정해두는 바람에 View의 크기가 작아지면 View의 크기에 비해 눈금 간격의 기본값이 컸었고, 결과적으로 작지만 충분히 눈금을 그릴 수 있는 View의 상태임에도 불구하고 눈금이 매우 적게 나와 그래프 눈금으로서 별 효과가 없는 현상이 일어났다.

y축 눈금 2~3 개는 더 넣어도 될 것 같다
이를 해결하기 위해 다음과 같이 눈금 개수 로직을 수정하였다.
- 눈금 간격의 기본값은 여전히 존재
- 눈금 그리는 간격의 범위에 따라 눈금 갯수 지정
- 일정 범위 이상이면 자동으로 처리
var xAxisSpacing: Dp = Dp(32F)
var yAxisSpacing: Dp = Dp(32F)
private fun getAvailableLabelCount(availableLabelSpace: Dp, spacing: Dp): Int {
// Number of ticks
// ~ 150dp : 3 (max, min, 50%)
// ~ 250dp : 5 (max, min, 25%, 50%, 75%)
// 250dp ~ : Auto
return when {
availableLabelSpace.value <= 150F -> 3
availableLabelSpace.value <= 250F -> 5
else -> {
(availableLabelSpace / spacing).value.toInt()
}
}
}
val availableLabels = getAvailableLabelCount(availableLabelSpace, xAxisSpacing)
해당 View의 크기에서 그릴 눈금의 수를 구하면, 다음으로 눈금의 단위를 구한다. 눈금의 단위를 구한 후, 눈금의 단위를 기반으로 비율에 맞게 라벨간 간격을 수정한다. 이는 시작과 끝 눈금을 무조건 들어온 데이터의 최소값과 최대값으로 설정한 것과 비슷한 이유인데, 선 그래프를 그릴때 들어온 데이터에서 선을 얼마나 길게 그릴지 비율이 일정해야 눈금과 정확하게 비율에 맞춰서 그릴 수 있기 때문이다. 최대 / 최소 값으로 눈금의 범위를 잡으면 최대 - 최소
값에서 (각 값 - 최소값) / (최대 - 최소)
를 해서 비율을 구할 수 있고, 이에 맞춰서 그래프를 그리면 눈금도 비율에 맞춰서 그렸기 때문에 서로 일치하게 된다.
// unit: 한 눈금당 단위 (밑에서 서술 예정)
// difference: 최대 - 최소 값
val actualSpacing = availableLabelSpace * Dp(unit / difference)
눈금 단위
단순히 Min-Max Normalization에 사용되는 방식을 이용하여 (최대 - 최소 값) / 라벨 개수
로 계산하면 정말 편하겠지만, 이 방법은 구현하기 전부터 문제가 있다는 사실을 알고 있었다. 이는 완벽하게 눈금의 간격도 깔끔하게 처리할 수 있는 방법이긴 하나, 눈금당 단위의 간격이 이상해질 수 있다는 단점이 있다.
예를 들어, 데이터의 범위가 1~10 인 상황에서, 눈금을 4개 그린다면 n-1 개로 축이 나뉘게 되어, 단위 한개당 3씩 깔끔하게 나누어 떨어진다. (1, 4, 7, 10) 하지만 우리가 실제로 받는 데이터들은 저렇게 작은 숫자가 아니고, 깔끔하지가 않다. 만약 데이터의 범위가 25340 ~ 32710 이고 눈금을 6개 그려야한다면 어떻게 될까? 그럼 5칸으로 나뉘게 되어, 단위당 1474로 끊기게 된다. 이를 그래프에 그리게 되면 어떻게 될까? 그래프 눈금은 25340, 26814, 28288, … 이런 식으로 단위의 숫자가 애매하게 되어 버린다. 그나마 눈금의 단위는 비교적 사람이 직관적으로 봤을때 얼마 단위로 올라가는지 바로 계산이 가능한 수준으로 보여주면 좋겠다고 생각을 하여 두번째로 큰 자릿수에서 올림을 한 값을 단위로 설정하게 되었다.
private fun roundToSecondSignificantDigit(number: Float): Float {
if (number == 0F) {
return 0F
}
val digit = floor(log10(number))
val power = 10F.pow(digit)
val result = (number / power).roundToInt() * power
if (result < number) {
return result + power
}
return result
}
val unit = roundToSecondSignificantDigit(difference / (availableLabels - 1).toFloat())
(최대 - 최소 값) / 라벨 개수
로 한 값을 두번째로 큰 자릿수에서 올림을 했기 때문에, 눈금 단위를 비율에 맞게 그리게 되면 딱 나누어 떨어지지 않게 된다. 이를 위해서 최대 최소 눈금을 먼저 그린 후, 최대 또는 최소 눈금에서 단위만큼 더해서 차차 그리고, 남게 되는 부분은 단위 간격과는 다른 간격이 남게끔 그냥 두었다. 하지만 이 남은 간격은 실제 다음 눈금과의 값의 차이와 간격의 비율이 다른 눈금 간격과 동일하기 때문에 문제가 생기지 않는다. 끝자리가 160원으로 끝나는것은 눈금의 시작값이 160원으로 끝나서 해당 방법의 어쩔 수 없는 한계점이다.

// Min, Max Tick
drawAxisTick(canvas, minPointX, tickStartPointY, minPointX, tickEndPointY, xAxisPaint)
drawAxisTick(canvas, maxPointX, tickStartPointY, maxPointX, tickEndPointY, xAxisPaint)
(neededLabels - 1 downTo 1).forEach { idx ->
val tickPointX: Px = maxPointX - actualSpacing.toPx(context) * Px(idx.toFloat()) // X축 눈금 시작 X좌표
if (tickPointX.value >= axisStartPointX.value) {
drawAxisTick(canvas, tickPointX, tickStartPointY, tickPointX, tickEndPointY, xAxisPaint)
// TODO: 라벨 그리기
}
}
(추가) 위의 한계점을 해결 하기 위해 프로젝트가 끝난 후 최소값을 반내림하고, 반내림된 값의 차이만큼 눈금 비율을 조절하는 방법을 구현해보았다. 기존의 코드와 가장 호환성 있게끔 데이터의 최소값을 반내림 한 값으로 변경 한 후, 눈금은 최소값을 반내림한 부분부터 계산을 하고, 실제로 그래프를 그릴 때에는 반내림된 값의 차이만큼의 눈금만큼 줄인 총 축 길이에서 기존 비율에 맞게끔 그리게끔 구현하였다.
val realMinY = chartData.minOf { it.y }
val minY = roundDownToSignificantDigit(realMinY, (maxY - realMinY).toInt().toString().length)
val minToRealMinSpacing = Dp(realMinY - minY) * actualSpacing / Dp(unit)
// 실제 그래프를 그리는 최소 Y값. `minToRealMinSpacing`을 계산하여 반내림 된 값의 차이의 눈금 크기를 계산하고, 이를 그래프를 그릴 최소 Y값에 반영한다.
val minPointY: Px = (axisStartPointY.toDp(context) - (yAxisPadding / Dp(2F)) - minToRealMinSpacing).toPx(context)

3. 라벨 (눈금 텍스트)

라벨은 눈금이 라벨 중앙에 오도록 그리면 된다. 여기서 한가지 유의할 점은 canvas.drawText()
를 하게 되면 텍스트를 위 그림에서 빨간 점 기준으로 그린다. (정확히는 baseline 시작 지점)
paint.getTextBounds(label, 0, label.length, bounds)
val textWidth = Px(bounds.width().toFloat())
val textHeight = Px(bounds.height().toFloat())
// Print label text parallel to its axis
val labelStartPointX: Px = startPointX - textWidth / Px(2F)
val labelStartPointY: Px = startPointY + marginTop.toPx(context) + textHeight // Baseline 고려
canvas.drawText(label, labelStartPointX.value, labelStartPointY.value, paint)
a. 축 라벨이 겹칠때
축을 그리다 보니 라벨이 길어 서로 겹치는 경우가 발생했다. 이런 경우 가독성을 위해 라벨을 45도 기울여서 View를 다시 그리도록 처리하였다.
// Print label text diagonally
val labelStartPointX: Px = startPointX
val labelStartPointY: Px = startPointY + marginTop.toPx(context) + textHeight
canvas.save()
canvas.rotate(
45F,
labelStartPointX.value + textHeight.value,
labelStartPointY.value - textHeight.value
)
canvas.drawText(label, labelStartPointX.value, labelStartPointY.value, paint)
canvas.restore()
drawText()
를 할때 각도를 기울인 상태로 그리라는 옵션이 없기 때문에, canvas 자체를 회전시킨 후, drawText()
를 한 후, 다시 canvas를 되둘려야한다. 여기서 신경써주어야하는 부분은 canvas를 어떤 좌표 지점으로 회전시키는지가 중요하다. 원하는 위치에 기울인 상태로 그리려면 라벨의 높이만큼 우측 대각선 위를 기준으로 회전시켜서 그려준후, 다시 canvas 회전을 되돌린다.
b. 축 라벨이 잘려 보일때
라벨을 45도 기울여서 View를 다시 그렸을 경우, 라벨이 그려지는 쪽 여백보다 라벨이 길어질 수도 있다. 이 부분도 추가로 고려하여 자동으로 여백을 수정하게끔 처리하였다.
if ((yAxisMarginStart - halfTickLength - marginTop).toPx(context).value < textWidth.value * ONE_OVER_SQRT_2) {
// Automatically adjusts the graph margin
yAxisMarginStart = Px(
(textWidth.value * ONE_OVER_SQRT_2).roundToInt().toFloat()
).toDp(context) + marginTop + halfTickLength + Dp(8F)
}
라벨을 45도 기울였기 때문에, 실제로의 높이는 라벨의 가로길이의 1/√2
가 된다. Margin 변수들은 커스텀 setter로 값이 변경될 시 invalidate를 하기 때문에 알아서 다시 그려지게 된다.
c. 마지막으로 그린 눈금의 라벨이 기존 라벨과 겹칠때
최소, 최대 눈금 및 라벨을 그리고 두 지점중 하나에서 부터 눈금을 차례대로 그리다 보면, 마지막에 눈금 간격이 다른 눈금이 나올 수도 있다. (위 눈금 단위쪽 내용 참고) 그러다 보니 차이가 매우 적게 날때 라벨이 겹칠 수도 있어서 이 부분만 고려해주면 끝이다.
if (tickPointX.value < boundX.value) {
// Overlapping first tick. Ignore label text
drawAxisTick(canvas, tickPointX, tickStartPointY, tickPointX, tickEndPointY, paint)
return@forEach // 라벨 그리는 forEach 문 안에 넣어준다.
}

최종 결과물
다음 게시글에는 그래프 데이터 그리기에 대해서 포스팅하고자 한다.