Study Record

[안드로이드] 꺽은선 그래프 직접 그리기 - canvas 본문

안드로이드

[안드로이드] 꺽은선 그래프 직접 그리기 - canvas

초코초코초코 2021. 12. 10. 22:09
728x90

안드로이드 스튜디오에서 직접 그래프를 그려보자!

 

먼저, 그래프를 그리기 위한 새로운 클래스를 만든다.

class MyCanvas @JvmOverloads constructor(context: Context,
                                         attrs: AttributeSet? = null, defStyleAttr: Int = 0)
    : View(context, attrs, defStyleAttr){

    // 뷰의 내용이 렌더링 될때 호출 된다.
    override fun onDraw(canvas: Canvas?) {
        super.onDraw(canvas)
        // 그리고 싶은대로 그린다.
    }
}

 

onDraw() 함수 안에 그래프를 만들 코드를 작성해주면 된다.

 

기본적으로 canvas 로 그림을 그리기 위해서 Paint() 객체로 그림을 그릴 선 혹은 원, 글씨 등의 타입을 정해줘야 한다.

그래프에 그릴 직선과 원, 점선, 글자가 필요하다.

// 원을 그릴 때 사용할 Paint 객체
private var circlePoint = Paint().apply {
        color = Color.parseColor("#684923")   // 선 색깔
        style = Paint.Style.STROKE            // 채우지는 않고 외곽선만 그린다.
        strokeWidth = 10f                     // 외곽선 너비
}
// 글자를 그릴 때 사용할 Paint 객체
private var textPoint = Paint().apply {
        textAlign = Paint.Align.CENTER        // 글자 가운데 정렬
        textSize = 15f
        color = Color.parseColor("#A3A3A3")
}
// 직선 그릴때 사용할 Paint 객체
private var linePoint = Paint().apply {
        strokeWidth = 3f
        color = Color.parseColor("#684923")
}
// 점선 그릴때 사용할 Paint 객체
private var dottedLinePoint = Paint().apply {
        strokeWidth = 2f
        color = Color.parseColor("#684923")
}

 

canvas 에 그림은 좌표값으로 그림을 그린다. 좌표값은 뷰의 왼쪽 상단이 (0,0)로 시작한다. 다음 그림과 같다.

canvas로 그릴 수 있는 함수들은 다음과 같다. 

class MyCanvas @JvmOverloads constructor(context: Context,
                                         attrs: AttributeSet? = null, defStyleAttr: Int = 0)
    : View(context, attrs, defStyleAttr){

    // 뷰의 내용이 렌더링 될때 호출 된다.
    override fun onDraw(canvas: Canvas?) {
        super.onDraw(canvas)
        // 그리고 싶은대로 그린다.
        // 글자 그리기 - text : 쓰고 싶은 글자(String) , startX : x좌표 , startY: Y좌표
	canvas.drawText(text, startX, startY, Paint())

	// 원 그리기 - startX : x좌표 , startY: Y좌표 , circleRadius : 반지름
	canvas.drawCircle(startX, startY, circleRadius, Paint())

	// 선 그리기 - startX, endX : x좌표 , startY, endY : Y좌표
	canvas.drawLine(startX, startY, endX, endY, Point())
    }
}

canvas.drawText()로 글자를 그릴 때, 주의해야할 점이 하나 있다. 앞으로 그릴 글자의 위치는 좌표값이 설정되는 부분이 글자가 차지하는 부분의 정 가운데가 되길 원하지만 실제로는 그렇지 않다.

 

출처 - https://m.blog.naver.com/PostView.naver?isHttpsRedirect=true&blogId=shadowbug&logNo=220494425245

canvas.drawText("Program", startX, startY, Paint())라면 위 그림의 (x = startX, y = startY) 좌표에 그려진다. 위의 Paint() 객체를 만들때 설정에서 "textAlign = Paint.Align.CENTER" 부분이 있었다. 이 설정은 수평적(가로)으로 가운데로 정렬해줄 수 있다. 따라서 고려해줄건 수직적(세로)으로 가운데 정렬을 해줘야 한다.

 

좌표값이 설정되는 부분에 글자가 수직적(세로)으로 가운데 정렬을 하기 위해서 canvas.drawText() 가 그려질 글자의 높이(height)를 가져와 y좌표에 "세로 값(height) / 2"를 더하면 된다.  다음은 수직적으로 가운데 정렬된 모양을 갖는 좌표값을 찾는 예시 코드이다.

//val textPaint = Paint()
val exampleText = "Program"
val rect = Rect()
textPoint.getTextBounds(exampleText, 0, exampleText.size, rect)
val textHeight = rect.height()        // 높이 값

// y좌표 = Y좌표 + 글자높이 / 2
val y = baseY + (textHeight / 2)

 

앞으로 그려볼 그래프의 결과는 다음과 같다.

여기서 x 좌표와 y 좌표가 만나는 지점에 원을 그려줘야 한다. 중요한 것은 원이 없다면 그냥 x좌표값과 y좌표값을 이용해서 직선을 그리기만 하면 된다. 하지만, 원 모양으로 데이터 부분이 표시되어야 한다면, 원이 그려지는 만큼 직선이 그려지면 안된다. 이 때, 생각한 것이 학창시절 배웠던 비례식을 생각해봤다.

위의 그림처럼 두 원이 있을 때, 그 사이의 삼각형을 만들고 위의 비례식을 참고하면,

DistanceY = ( Y * R ) / h 

DistanceX = ( X * R ) / h

이렇게 두 길이를 알 수 있다. 따라서 우리가 구하고 싶은 값은 아래 그림의 노란색 점과 초록색 점을 구할 수 있다.

물론, 위의 예시는 그래프가 올라가는 방향일 때의 경우이다. 그래프가 내려가는 방향일때는

노란색 점 = ( X1 + DistanceX, Y1 + DistanceY)

초록색 점 = ( X1 + DistanceX, Y1 - DistanceY)

이렇게 될 것이다. 

 

이제 고려해야 할 개념적인 부분은 끝났다. 아래 그림은 그래프를 만들 때, 사용했던 변수의 이름과 정의이다.

 

개발한 코드

MainActivity.kt

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        val graph = findViewById<MyCanvas>(R.id.my_graph)

        graph.setSize()
        graph.draw()
    }
}

 

activity_main.xml

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <com.example.graph.MyCanvas
        android:id="@+id/my_graph"
        android:layout_centerInParent="true"
        android:layout_width="350dp"
        android:layout_height="300dp"/>

</RelativeLayout>

 

MyCanvas 클래스

class MyCanvas @JvmOverloads constructor(context: Context,
                                         attrs: AttributeSet? = null, defStyleAttr: Int = 0)
    : View(context, attrs, defStyleAttr){
    private var mHeight = 0f    // 그래프 Y축 길이
    private var mWidth = 0f     // 그래프 X축 길이
    private var mWidthStep = 0f   // 그래프 X축 요소 사이의 길이
    private var mHeightStep = 0f   // 그래프 Y축 요소 사이 길이
    private var mYNum = 0         // 그래프 Y축 요소 개수
    private var circleRadius = 10f  // 원 반지름
    private var startTextYPoint = 0f
    private var startYPoint = 0f    // 그래프 시작 Y 지점
    private var startXPoint = 0f    // 그래프 시작 X 지점
    private var circlePoint = Paint().apply {
        color = Color.parseColor("#684923")
        style = Paint.Style.STROKE
        strokeWidth = 5f
    }
    private var textPoint = Paint().apply {
        textAlign = Paint.Align.CENTER
        textSize = dpToPx(13f)
        color = Color.parseColor("#A3A3A3")
    }
    private var linePoint = Paint().apply {
        strokeWidth = 3f
        color = Color.parseColor("#684923")
    }
    private var dottedLinePoint = Paint().apply {
        strokeWidth = 2f
        color = Color.parseColor("#684923")
    }
    // 뷰의 내용이 렌더링 될때 호출 된다.
    @SuppressLint("DrawAllocation")
    override fun onDraw(canvas: Canvas?) {
        super.onDraw(canvas)
        // 그리고 싶은대로 그린다.

        val Data =  ArrayList<Int>().apply {
            for(i in 1..7) add(Random().nextInt(5)) // 0~7
        }
        val xData = arrayListOf("월", "화", "수", "목", "금", "토", "일")

        // x축 그리기
        canvas?.drawLine(startXPoint, mHeight, startXPoint + mWidth, mHeight, linePoint)

        // y축 그리기
        canvas?.drawLine(startXPoint, 0f, startXPoint, mHeight, linePoint)

        var dottedStep = mHeight / 100 // 점선에서 점 하나의 길이 , 점은 총 50개로 그린다.
        for(i in 0 until xData.size){
            val x = startXPoint + (i+1)*mWidthStep
            // x축 글자
            canvas?.drawText(xData[i], x, startTextYPoint, textPoint)
            // 데이터 원 그리기
            canvas?.drawCircle(x, startYPoint + (Data[i]*mHeightStep), circleRadius, circlePoint)
            // 세로 점선 그리기
            for(k in 0..99 step(2)) {
                canvas?.drawLine(x, dottedStep * k, x, dottedStep * (k + 1), dottedLinePoint)
            }
        }

        // y축 글자
        dottedStep = mWidth / 100 // 점선에서 점 하나의 길이 , 점은 총 50개로 그린다.
        for(i in 1..mYNum){
            val y = startYPoint + (i-1)*mHeightStep
            val x = startXPoint / 2
            val rect = Rect()
            textPoint.getTextBounds((6-i).toString(), 0, 1, rect)
            val textHeight = rect.height()
            // y 축 글자 그리기
            canvas?.drawText((6-i).toString(), x, y + (textHeight/2), textPoint)
            // 가로 점선 그리기
            for(k in 0..99 step(2)) {
                // 가로 점선 그리기
                canvas?.drawLine(startXPoint + dottedStep * k, y, startXPoint + dottedStep * (k + 1), y, dottedLinePoint)
            }
        }

        // 원과 원사이 직선 그리기
        for(i in 1 until Data.size){
            // 5개면 0~4 Index , 값은 1 ,2, 3, 4 로 4개

            val startX = startXPoint + (i*mWidthStep)
            val startY = startYPoint + (Data[i-1]*mHeightStep)
            val endX = startXPoint + (i + 1) * mWidthStep
            val endY = startYPoint + (Data[i]*mHeightStep)

            val BigDistanceX = mWidthStep
            val BigDistanceY = abs((Data[i-1]*mHeightStep) - (Data[i]*mHeightStep))
            val hypotenuse = sqrt(BigDistanceX * BigDistanceX + BigDistanceY * BigDistanceY)
            val distanceX = (circleRadius * BigDistanceX) / hypotenuse
            val distanceY = (circleRadius * BigDistanceY) / hypotenuse

            if(Data[i - 1] > Data[i]){
                // 올라가는 모양일때
                canvas?.drawLine((startX + distanceX), (startY - distanceY), (endX - distanceX), (endY + distanceY), linePoint)
            } else if(Data[i - 1] < Data[i]){
                // 내려가는 모양일때
                canvas?.drawLine((startX + distanceX), (startY + distanceY), (endX - distanceX), (endY - distanceY), linePoint)
            } else {
                canvas?.drawLine(startX + distanceX, startY, endX - distanceX, endY, linePoint)
            }
        }
    }

    fun setSize(height: Float = 250f, width: Float = 350f, XNum: Int = 8, YNum: Int = 5, textXAreaHeight: Float = 50f) {
        // 전체 단위는  dp 로 받는다.
        // height : y 축 세로 길이 , width : x축 가로 길이, step : 한 요소 사이 길이(width) , YNum : Y축 요소 개수, textAreaHeight : x축  요소의 공간
        mHeight = dpToPx(height)
        startXPoint = dpToPx(30f)
        mWidth = dpToPx(width - 30f)
        mWidthStep = mWidth / XNum
        mYNum = YNum
        startYPoint = dpToPx(20f)
        mHeightStep = (mHeight - startYPoint) / YNum
        startTextYPoint = dpToPx(height + (textXAreaHeight / 2))
    }

    fun draw(){
        invalidate()
    }

    fun dpToPx(dp: Float): Float{
        val dm = this.resources.displayMetrics
        return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, dm)
    }
}

 

< 실행 결과 화면 >

728x90