그래프 그리는 View만들기


아래의 사진 처럼 그래프를 그려주는 View를 만드는 방법을 이번 글에서 다루어 보겠습니다.

  • 사용하는 주요 클래스
    • View를 상속하여 CustomView를 만듭니다.
    • Canvas
    • Paint
      • Canvas에 그릴 객체(선, 도형, 글자 등)의 속성(색상, 스타일 등)을 정의하는 클래스입니다.
    • Path
      • 그래프를 그릴 때 사용되는 핵심 클래스입니다.
      • 그릴 선의 정보를 담고 있습니다.

그래프를 그릴 View 클래스 정의


View를 상속하여 CustomView를 만듭니다.

예제에서는 GraphView라는 이름으로 클래스를 정의합니다.

class GraphView(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : View(context, attrs, defStyleAttr) {

    constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0)

    constructor(context: Context) : this(context, null, 0)
}

그래프에 그릴 객체의 속성을 정의하기


    // 그릴 선, 도형의 속성 목록
    private val _mPaints: MutableList<Paints> = mutableListOf()
    private val mPaints: List<Paints> get() = _mPaints

    // 그래프 상단/하단의 여백 크기, 12dp
    private val mVerticalSpace = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 12f, Resources.getSystem().displayMetrics)

    // 그래프 x축 값의 간격, 48dp
    private val mXAxisValueIntervalSpace: Float =
        TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 48f, Resources.getSystem().displayMetrics)

    // 그릴 선, 도형의 Paint객체를 담을 클래스
    // 여러 개의 선을 그릴 때 각각의 선의 속성을 다르게 설정 하기 위해 사용합니다.
    private data class Paints(
        val linePaint: Paint,
        val pointPaint: Paint,
    )

표시할 데이터 관련 로직 정의하기


그래프로 표시할 값을 처리하기 위한 로직을 만들어야 합니다.

    // 각각의 선으로 그리기 위한 값 목록
    private val _mDataList: MutableList<List<Float>> = mutableListOf()
    private val mDataList: List<List<Float>> = _mDataList

    fun setDataList(dataList: List<List<Float>>) {
        _mDataList.clear()
        _mDataList.addAll(dataList)
    }

그래프를 그리는 로직 정의하기


그래프를 그리기 위한 사전 정보를 정의하는 코드입니다.

그래프의 그리기 정보를 담고 있는 DrawInfo 클래스를 정의합니다.

Fragment, Activity에서 GraphView에 그릴 객체의 정보를 설정하기 위해서 setDrawInfo()를 사용합니다.

data class DrawInfo(
    @Dimension(Dimension.DP) val lineThickness: Int = 1,
    @ColorInt val lineColor: Int = Color.WHITE,
    @ColorRes val lineColorResId: Int = NONE_ID,
    @Dimension(Dimension.DP) val pointRadius: Int = 2,
    @ColorInt val pointColor: Int = Color.WHITE,
    @ColorRes val pointColorResId: Int = NONE_ID,
) {

    companion object {
        const val NONE_ID = -1
    }
}
    fun setDrawInfo(drawInfoList: List<DrawInfo>) {
        _mPaints.clear()

        // 그릴 객체의 크기를 dp로 설정하기 위해서 기기 화면의 크기 정보를 가져옵니다.
        val displayMetrics = Resources.getSystem().displayMetrics

        for (drawInfo in drawInfoList) {
            val line = Paints(
                // 선의 속성
                linePaint = Paint().apply {
                    isAntiAlias = true
                    style = Paint.Style.STROKE
                    strokeWidth = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, drawInfo.lineThickness.toFloat(), displayMetrics)
                    color = if (drawInfo.lineColorResId == NONE_ID) drawInfo.lineColor else context.getColor(drawInfo.lineColorResId)
                },
                // 선 위에 그릴 도형(점)의 속성
                pointPaint = Paint().apply {
                    isAntiAlias = true
                    style = Paint.Style.FILL
                    strokeWidth = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, drawInfo.pointRadius.toFloat(), displayMetrics)
                    color = if (drawInfo.pointColorResId == NONE_ID) drawInfo.pointColor else context.getColor(drawInfo.pointColorResId)
                },
            )
            _mPaints.add(line)
        }
    }

GraphView의 너비를 계산하여 지정하는 코드입니다.

  • 데이터가 있으면 데이터의 개수만큼 x축의 간격을 계산하여 너비를 지정합니다.
    • 데이터의 개수만큼 너비가 설정됩니다.
  • 데이터가 없다면 View정의 시 지정한 너비를 그대로 사용합니다.
    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        val width: Int =
            if (mDataList.isEmpty()) widthMeasureSpec else mDataList.first().size * mXAxisValueIntervalSpace.toInt()
        setMeasuredDimension(width,
            heightMeasureSpec)
    }

그래프를 실제로 그리는 코드입니다.

View의 레이아웃이 계산된 후 invalidate()가 호출될 때 onDraw()가 호출됩니다.


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

    private fun drawLinePaths(canvas: Canvas) {
        // 모든 데이터에서 최소값, 최대값을 구합니다.
        val (minValue, maxValue) = calculateMinMaxValue()

        // 그래프 y축이 그려질 최상단 y좌표값
        val graphYAxisTop = mVerticalSpace

        // 그래프 y축의 길이, View의 높이 - 위아래 여백
        val graphYAxisHeight = height - 2 * mVerticalSpace

        // 데이터 값의 범위
        // 최소값: -10, 최대값: 200 -> 210
        val valueLength = maxValue.toFloat() - minValue.toFloat()

        // 그릴 선의 정보
        val linePathList = mutableListOf<Path>()

        // 첫 값이 그려질 x좌표
        val startPointX = mXAxisValueIntervalSpace / 2f

        // 각 선이 그려질 점의 좌표 목록
        val linePoints = mutableListOf<List<PointF>>()

        mDataList.forEach { dataList ->
            val path = Path()
            linePathList.add(path)

            // 첫 값의 좌표이자 이전 값의 좌표
            var lastPoint =
                PointF(startPointX, graphYAxisTop + calculateYPosition(dataList.first().toFloat(), minValue.toFloat
                    (), valueLength) *
                        graphYAxisHeight)

            // 선의 시작 좌표를 첫 값의 좌표로 지정합니다.
            path.moveTo(lastPoint.x, lastPoint.y)

            val points = mutableListOf<PointF>()
            linePoints.add(points)

            dataList.forEachIndexed { i, value ->
                if (i > 0) {
                    val newPoint = PointF(lastPoint.x + xAxisValueInterval,
                        graphYAxisTop + calculateYPosition(value.toFloat(), minValue.toFloat
                            (), valueLength) * graphYAxisHeight)
                    val point1 = PointF(lastPoint.x + xAxisValueInterval / 2, lastPoint.y)
                    val point2 = PointF(point1.x, newPoint.y)

                    // 곡선을 그리기 위해서 좌표값을 설정합니다.
                    path.cubicTo(point1.x, point1.y, point2.x, point2.y, newPoint.x, newPoint.y)
                    lastPoint = newPoint
                }
                points.add(lastPoint)
            }
        }

        // 선과 점을 그립니다.
        mPaints.zip(linePathList).forEachIndexed { i, pair ->
            canvas.drawPath(pair.second, mPaints[i].linePaint)
            drawPoints(linePoints[i], canvas, pair.first.pointPaint)
        }
    }

    // 선 위의 점을 그립니다.
    private fun drawPoints(points: List<PointF>, canvas: Canvas, paint: Paint) {
        points.forEach { pointF ->
            canvas.drawCircle(pointF.x, pointF.y, paint.strokeWidth, paint)
        }
    }

    // 데이터 값에 따라서 그래프에서 그려질 선의 y좌표 위치를 계산합니다.
    // 최소값: 5, 최대값: 10, 값: 7 -> 0.5
    // y축에서 중간에 위치하게 됩니다.
    private fun calculateYPosition(value: Float, minValue: Float, valueLength: Float) =
        (1f - ((value - minValue) / valueLength))

    // 모든 데이터에서 최소값, 최대값을 구합니다.
    private fun calculateMinMaxValue(): Pair<Number, Number> {
        val min: Number = mDataList.minBy { numbers -> numbers.minOf { it } }.minOf { it }
        val max: Number = mDataList.maxBy { numbers -> numbers.maxOf { it } }.maxOf { it }
        return min to max
    }

cubicTo()

fun cubicTo(x1: Float, y1: Float, x2: Float, y2: Float, x3: Float, y3: Float): Unit

  • bezier 곡선을 그립니다.
    • x1 : 첫 번째 제어점의 x좌표
    • y1 : 첫 번째 제어점의 y좌표
    • x2 : 두 번째 제어점의 x좌표
    • y2 : 두 번째 제어점의 y좌표
    • x3 : 끝 점의 x좌표
    • y3 : 끝 점의 y좌표

그림에서 2, 3, 4 점이 (x1, y1), (x2, y2), (x3, y3) 에 해당합니다.

calculateYPosition()

1f - (value - minValue) / (maxValue - minValue)의 계산식을 통해서 데이터 값에 따라서 선의 y좌표 위치를 계산합니다.

사용


ScrollView로 감싸서 사용하도록 합니다.

GraphView의 높이는 200dp로 고정하였습니다.

    <HorizontalScrollView
        android:layout_width="match_parent"
        android:nestedScrollingEnabled="true"
        android:layout_height="wrap_content">

        <FrameLayout
            android:layout_width="wrap_content"
            android:layout_height="wrap_content">

            <toss.next.naversovc.ui.common.view.GraphView
                android:id="@+id/graphView"
                android:layout_width="wrap_content"
                android:layout_height="200dp"
                android:background="#B8B8B8" />

        </FrameLayout>

    </HorizontalScrollView>
    binding.run {
        // 그릴 객체 정보
        graphView.setDrawInfo(listOf(
            DrawInfo(
                lineThickness = 4,
                lineColor = Color.WHITE,
                pointRadius = 8,
                pointColor = Color.WHITE,
            ),
            DrawInfo(
                lineThickness = 4,
                lineColor = Color.YELLOW,
                pointRadius = 8,
                pointColor = Color.YELLOW,
            ),
            DrawInfo(
                lineThickness = 4,
                lineColor = Color.RED,
                pointRadius = 8,
                pointColor = Color.RED,
            ),
        ))

        // 데이터 값
        graphView.setDataList(
            listOf(
                (-50..50 step 10).toList(),
                (50 downTo -50 step 10).toList(),
                listOf(10, 20, 30, 20, 10, 20, 30, 20, 10, 20, 30)
            )
        )
    }

image

활용

시간 별 날씨예보의 기온 그래프와 같은 화면을 그리는 경우 등에 사용할 수 있습니다.

그 외에도 수학적인 그래프나, 값들 간에 비교를 위한 경우에도 사용할 수 있습니다.