본문 바로가기

Android/차트그리기

1. Android Line Chart 직접 그리기

프로젝트를 진행하다보면 간단한 차트를 표시해야 할 때가 있는데요, 이를 위해 차트라이브러리를 사용하자니 라이브러리 사용법도 공부해야 하고 라이센스도 확인해야 하는 등 배보다 배꼽이 더 커지는 경우가 있습니다.

그래서 간단한 차트는 직접 그려보는것이 더 좋겠단 생각이 들어 하나씩 그려보려고 합니다.

 

첫번째로, 다음과 같이 흔히 볼수 있는 Line Chart를 그려보고자 합니다.

먼저 다음과 같이 LineChartView라는 Custom View를 생성하고, onDraw함수를 override해 줍니다.

이 onDraw() 함수내에서 원하는 그림을 그리면 화면에 나타나게 됩니다.

class LineChartView(context: Context?, attrs: AttributeSet?) : View(context, attrs) {

    override fun onDraw(canvas: Canvas?) {
        super.onDraw(canvas)
    }

}

 

onDraw()함수에서 차트를 그리려면 우선 View가 그려질 영역의 크기가 어떻게 되는지부터 확인해야 하는데요, onDraw()에 전달되는 canvas파라메터로부터 그 크기를 확인할수 있습니다.

자 그럼 캔버스의 크기를 확인하여 그 크기만큼 사각형을 그려보도록 하겠습니다.

private val paint = Paint().apply {
    isAntiAlias = true
    strokeCap = Paint.Cap.ROUND
}

override fun onDraw(canvas: Canvas?) {
    super.onDraw(canvas)
    canvas ?: return

    paint.style = Paint.Style.STROKE
    paint.strokeWidth = 1f
    paint.color = Color.RED
    canvas.drawRect(0f, 0f, canvas.width.toFloat(), canvas.height.toFloat(), paint)
    
    // canvas의 width, height는 View의 width, height와 같기 때문에 다음과 같이 해도 동일합니다.
    // canvas.drawRect(0f, 0f, width.toFloat(), height.toFloat(), paint)
}

이 코드는 다음과 같이 붉은색 사각형을 표시하게 됩는데요, canvas의 크기가 LineChartView의 크기와 일치하는 것을 확인할수 있습니다.

 

이제  Line Chart를 차근차근 그려보도록 하겠습니다.

간단하게 다음과 같이 LineChartView의 중앙을 가로지르는 라인을 그릴수 있는데요, 이 라인을 기준점(0)으로 하여 전달된 차트 데이터목록을 그릴 예정입니다.

override fun onDraw(canvas: Canvas?) {
    super.onDraw(canvas)
    canvas ?: return

    paint.style = Paint.Style.STROKE
    paint.strokeWidth = 1f
    paint.color = Color.BLACK
    val cy = height.toFloat() / 2f
    canvas.drawLine(0f, cy, width.toFloat(), cy, paint)
}

 

차트데이터는 다음과 같이 간단히 Int 배열로 전달받도록 하며, 값이 설정되는 시점에 linePath를 구성하도록 하였습니다.

private var dataList: ArrayList<Int>? = null
private val linePath = Path()

fun setDataList(dataList: ArrayList<Int>) {
    if (dataList.isEmpty()) {
        return
    }
    this.dataList = dataList
    // 이때는 아직 View의 Layout이 아직 결정되기 전일수도 있으므로 listener를 등록한다.
    addOnLayoutChangeListener(object : OnLayoutChangeListener {
        override fun onLayoutChange(
            v: View?,
            left: Int,
            top: Int,
            right: Int,
            bottom: Int,
            oldLeft: Int,
            oldTop: Int,
            oldRight: Int,
            oldBottom: Int
        ) {
            removeOnLayoutChangeListener(this)

            var x = 0f // 각 값이 위치할 x좌표
            val space = width / (dataList.count() - 1) // 한 화면에 모두 표시하기 위해 데이터간 거리를 width기준으로 계산
            linePath.reset()
            dataList.forEach { value ->
                if (linePath.isEmpty) {
                    linePath.moveTo(x, value.toFloat())
                } else {
                    linePath.lineTo(x, value.toFloat())
                }
                x += space // 다음 값을 표시할 x좌표 설정
            }
            invalidate() // onDraw()가 다시 불릴수 있도록 갱신 요청
        }
    })
    requestLayout()
}

iOS와는 달리, Android는 onDraw()에서 Path객체를 매번 생성하여 계산을 하게 되면 성능 저하가 있을수 있으므로 미리 계산을 끝내고, onDraw()에서는 계산이 끝난 path만 그리도록 처리합니다.

 

override fun onDraw(canvas: Canvas?) {
    super.onDraw(canvas)
    canvas ?: return

    // 1. 기준이 되는 x축 라인 그리기
    paint.style = Paint.Style.STROKE
    paint.strokeWidth = 1f
    paint.color = Color.BLACK
    val cy = height.toFloat() / 2f
    canvas.drawLine(0f, cy, width.toFloat(), cy, paint)

    // 2. 각 데이터를 선으로 이어서 그리기
    paint.color = Color.RED
    paint.strokeWidth = 2f
    canvas.drawPath(linePath, paint)
}

데이터는 다음과 같이 전달하였습니다.

chartView.setDataList(arrayListOf(0, 30, 45, 150, 60, -30, -15, -21, 30))

 

그런데 차트가 뭔가 좀 이상합니다. 중앙의 x축을 기준으로 그려지는게 아니라 View상단에 거꾸로 뒤집혀서 그려져 있습니다.

이는 다음 두가지를 처리해 주지 않았기 때문입니다.

1. 전달된 dataList의 값을 LineChartView에 그려진 검은 라인(x축)을 기준으로 offset을 더해주어야 함.

2. UIView의 draw에서 원점(0, 0)은 좌하단이 아니라  좌상단이므로 값을 x축을 기준으로 반전시켜 주어야 함.

 

두가지 이슈를 다음과 같이 해결하여 그려보겠습니다.

fun setDataList(dataList: ArrayList<Int>) {
    if (dataList.isEmpty()) {
        return
    }
    this.dataList = dataList
    // 이때는 아직 View의 Layout이 아직 결정되기 전일수도 있으므로 listener를 등록한다.
    addOnLayoutChangeListener(object : OnLayoutChangeListener {
        override fun onLayoutChange(
            v: View?,
            left: Int,
            top: Int,
            right: Int,
            bottom: Int,
            oldLeft: Int,
            oldTop: Int,
            oldRight: Int,
            oldBottom: Int
        ) {
            removeOnLayoutChangeListener(this)

            // 값이 가운데 위치한 x축을 기준으로 표시되도록 변환시켜 줌
            val mappedDataList = dataList.map { value -> height / 2 -  value }

            var x = 0f // 각 값이 위치할 x좌표
            val space = width / (mappedDataList.count() - 1) // 한 화면에 모두 표시하기 위해 데이터간 거리를 width기준으로 계산
            linePath.reset()
            mappedDataList.forEach { value ->
                if (linePath.isEmpty) {
                    linePath.moveTo(x, value.toFloat())
                } else {
                    linePath.lineTo(x, value.toFloat())
                }
                x += space // 다음 값을 표시할 x좌표 설정
            }
            invalidate() // onDraw()가 다시 불릴수 있도록 갱신 요청
        }
    })
    requestLayout()
}

이제 잘 그려지는데요, 각 값의 위치가 잘 구분이 되지 않으니 점을 찍어주겠습니다.

private val dotList = ArrayList<PointF>()

override fun onDraw(canvas: Canvas?) {
    super.onDraw(canvas)

    ......
    
    // 3. 라인위에 점 표시하기
    paint.color = Color.BLUE
    paint.strokeWidth = 10f
    dotList.forEach { dot ->
        canvas.drawPoint(dot.x, dot.y, paint)
    }
}

fun setDataList(dataList: ArrayList<Int>) {
    if (dataList.isEmpty()) {
        return
    }
    this.dataList = dataList
    // 이때는 아직 View의 Layout이 아직 결정되기 전일수도 있으므로 listener를 등록한다.
    addOnLayoutChangeListener(object : OnLayoutChangeListener {
        override fun onLayoutChange(
            v: View?,
            left: Int,
            top: Int,
            right: Int,
            bottom: Int,
            oldLeft: Int,
            oldTop: Int,
            oldRight: Int,
            oldBottom: Int
        ) {
            removeOnLayoutChangeListener(this)

            // 값이 가운데 위치한 x축을 기준으로 표시되도록 변환시켜 줌
            val mappedDataList = dataList.map { value -> height / 2 - value }

            var x = 0f // 각 값이 위치할 x좌표
            val space =
                width / (mappedDataList.count() - 1) // 한 화면에 모두 표시하기 위해 데이터간 거리를 width기준으로 계산
            linePath.reset()
            dotList.clear()
            mappedDataList.forEach { value ->
                if (linePath.isEmpty) {
                    linePath.moveTo(x, value.toFloat())
                } else {
                    linePath.lineTo(x, value.toFloat())
                }
                dotList.add(PointF(x, value.toFloat())) // 점찍을 좌표 저장
                x += space // 다음 값을 표시할 x좌표 설정
            }
            invalidate() // onDraw()가 다시 불릴수 있도록 갱신 요청
        }
    })
    requestLayout()
}

뭔가 그럴듯 해 보입니다. 그런데 가장자리에 있는 점이 잘려보이네요.

그리고 값이 LineChartView의 높이/2 보다 큰 값이 전달되면 화면밖에 그려지는 문제가 있습니다.

 

이를 해결하기 위해 다음과 같이 chart data값을 보정하도록 합니다.

1. 가장자리에 위치하는 점이 잘려보이지 않도록 좌/우 점의 반지름만큼의 공간을 확보해 줍니다.

2. Chart가 View영역을 벗어나지 않도록 data목록 중 가장 큰값이 "height / 2 - 점의 반지름"이 되는 비율을 구하여 data값들을 모두 변환시켜줍니다.

 

이를 모두 반영한 전체 LineChartView의 소스는 다음과 같습니다.

class LineChartView(context: Context?, attrs: AttributeSet?) : View(context, attrs) {

    companion object {
        private val POINT_RADIUS = 5f
    }

    private var dataList: ArrayList<Int>? = null
    private val paint = Paint().apply {
        isAntiAlias = true
        strokeCap = Paint.Cap.ROUND
    }
    private val linePath = Path()
    private val dotList = ArrayList<PointF>()

    override fun onDraw(canvas: Canvas?) {
        super.onDraw(canvas)
        canvas ?: return

        // 1. 기준이 되는 x축 라인 그리기
        paint.style = Paint.Style.STROKE
        paint.strokeWidth = 1f
        paint.color = Color.BLACK
        val cy = height.toFloat() / 2f
        canvas.drawLine(0f, cy, width.toFloat(), cy, paint)

        // 2. 각 데이터를 선으로 이어서 그리기
        paint.color = Color.RED
        paint.strokeWidth = 2f
        canvas.drawPath(linePath, paint)

        // 3. 라인위에 점 표시하기
        paint.color = Color.BLUE
        paint.strokeWidth = POINT_RADIUS * 2
        dotList.forEach { dot ->
            canvas.drawPoint(dot.x, dot.y, paint)
        }
    }

    fun setDataList(dataList: ArrayList<Int>) {
        if (dataList.isEmpty()) {
            return
        }
        this.dataList = dataList
        // 이때는 아직 View의 Layout이 아직 결정되기 전일수도 있으므로 listener를 등록한다.
        addOnLayoutChangeListener(object : OnLayoutChangeListener {
            override fun onLayoutChange(
                v: View?,
                left: Int,
                top: Int,
                right: Int,
                bottom: Int,
                oldLeft: Int,
                oldTop: Int,
                oldRight: Int,
                oldBottom: Int
            ) {
                removeOnLayoutChangeListener(this)

                val maxValue = dataList.maxOrNull() ?: return
                var scale = 1f
                val halfHeight = height / 2
                // 값이 뷰 영역을 벗어나지 않고 화면내에 그려질수 있도록 비율 계산
                if (maxValue > halfHeight - POINT_RADIUS) {
                    scale = (halfHeight - POINT_RADIUS) / maxValue
                }

                // 값이 가운데 위치한 x축을 기준으로 표시되도록 변환시켜 줌
                val mappedDataList = dataList.map { value -> halfHeight -  value * scale }

                var x = POINT_RADIUS // 각 값이 위치할 x좌표
                val space = (width - POINT_RADIUS * 2) / (mappedDataList.count() - 1) // 한 화면에 모두 표시하기 위해 데이터간 거리를 width기준으로 계산
                linePath.reset()
                dotList.clear()
                mappedDataList.forEach { value ->
                    if (linePath.isEmpty) {
                        linePath.moveTo(x, value)
                    } else {
                        linePath.lineTo(x, value)
                    }
                    dotList.add(PointF(x, value))
                    x += space // 다음 값을 표시할 x좌표 설정
                }
                invalidate() // onDraw()가 다시 불릴수 있도록 갱신 요청
            }
        })
        requestLayout()
    }
}

 

데이터를 다음과 같이 화면을 벗어나는 값을 설정하여, 값 보정전과 후가 어떻게 다르게 표시되는지 확인해 보았습니다.

chartView.setDataList(arrayListOf(0, 30, 45, 150, 900, -30, -15, -21, 30))

우측의 화면에는 점도 잘리지 않고 화면밖으로 벗어나지도 않도록 잘 표시되고 있는것을 확인할수 있습니다.

 

자... 이렇게 아주 간단한 Line Chart를 그려보았는데요, 여기에 추가로 x/y 축 기준선이나 글자등을 추가로 그려주면 좀 더 멋있는 차트가 될 수 있을 것 같습니다.

반응형