본문 바로가기
개발/Android

[Android] CustomView 만들기 - CircleDotsLineView

by du.it.ddu 2020. 11. 22.
반응형

회사에서 새로 개발할 기능의 UI 중, CustomView를 만들 필요가 있을 것 같아 심플하게 미리 구현하며 정리를 하고 싶어졌다.

기존에도 CustomView를 만들기 위해 layout을 만들어서 커스텀 속성 만들고 하는것은 가끔 있었지만, 그런 것 없이 Canvas를 이용해서 그리는 것이 필요했다.


1. View Lifecycle

안드로이드를 개발하면서 Activity, Fragment의 Lifecycle을 고려하면서 개발을 진행했을 것이다.

이와 유사하게 View도 View가 그려지는 과정인 Lifecycle이 존재한다. 이를 이해하는 것이 필요하겠다.

이에 대해선  http://ndquangr.blogspot.com/2013/04/android-view-lifecycle.html 를 참고하면 좋을 것 같다. 아래 그림은 해당 링크에서 복사 하였으며, 문제 시 삭제하겠다.

View가 생성되어 addView에 호출되어 onAttachedToWindow 메서드가 호출되고 크기를 결정하고 레이아웃을 그리고 Canvas위에 그리는 onDraw까지 호출되는 흐름이다.

만약 레이아웃의 업데이트를 위해 invalidate() 혹은 requestLayout()을 호출하면 위 흐름을 어느 부분부터 다시 하게 된다.

invalidate와 requestLayout는 거치는 단계가 다르므로, 호출하는 의도에 따라 적절하게 선택해야 할 것이다.


2. CustomView를 만드는 간단한 과정

- CustomView의 클래스를 만든다.

- attrs.xml에 CustomView에서 사용할 속성들을 정의한다.

- CustomView에서 attrs를 읽어오고 오버라이딩 메서드(주로 onMeasure, onDraw가 될 것이다.)를 구현한다.

이 정도의 과정을 거칠 것이며, 구현할 CustomView가 무엇이고 필요한 메서드가 무엇이냐에 따라 추가적인 단계가 있을 것이다.


3. CircleDotsLineView 구현

1. attrs.xml에 속성 정의

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <declare-styleable name="CircleDotsLineView">
        <attr format="dimension" name="dotSize" />
        <attr format="color" name="dotInnerColor" />
        <attr format="color" name="dotBorderColor" />
        <attr format="dimension" name="dotBorderSize" />
        <attr format="dimension" name="dotSpacing" />
        <attr format="boolean" name="vertical" />
    </declare-styleable>
</resources>

위의 속성정도가 필요하다고 보았다.

dot의 크기와 내부 색깔, border가 필요하다면 border의 Color와 Border의 크기

그리고 dot간의 간격, 세로로 그릴건지 가로로 그릴건지에 대한 플래그 정도였다.

심플한 View이기 때문에 위 정도만 필요했다.

2. CircleDotsLineView 클래스 작성

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

    private var dotSize: Float = 0f
    set(value) {
        field = value
        dotRadius = value / 2
    }

    private var dotSpacing: Float = 0f
    private var dotBorderSize: Float = 0f

    private var dotInnerColor: Int = Color.BLACK
    private var dotBorderColor: Int = Color.TRANSPARENT

    private var isVertical: Boolean = true

    private var innerPaint = Paint()
    private var borderPaint = Paint()

    private var dotRadius: Float = 0f

    private val dotCount: Int
        get() {
            val lineSize = (if (isVertical) measuredHeight else measuredWidth)
            val dotArea = dotSize + dotSpacing
            return (lineSize / dotArea).toInt()
        }

    init {
        init(attrs)
        setPaint()
    }

    private fun init(attrs: AttributeSet?) {
        attrs?.run {
            context.obtainStyledAttributes(this, R.styleable.CircleDotsLineView)
        }?.run {
            dotSize = getDimension(R.styleable.CircleDotsLineView_dotSize, 0f)
            dotSpacing = getDimension(R.styleable.CircleDotsLineView_dotSpacing, 0f)
            dotBorderSize = getDimension(R.styleable.CircleDotsLineView_dotBorderSize, 0f)
            dotInnerColor = getColor(R.styleable.CircleDotsLineView_dotInnerColor, dotInnerColor)
            dotBorderColor = getColor(R.styleable.CircleDotsLineView_dotBorderColor, dotBorderColor)
            isVertical = getBoolean(R.styleable.CircleDotsLineView_vertical, isVertical)

            recycle()
        }
    }

    private fun setPaint() {
        innerPaint.run {
            color = dotInnerColor
            isAntiAlias = true
            style = Paint.Style.FILL
            strokeCap = Paint.Cap.ROUND
            strokeJoin = Paint.Join.ROUND
        }

        borderPaint.run {
            color = dotBorderColor
            isAntiAlias = true
            style = Paint.Style.STROKE
            strokeCap = Paint.Cap.ROUND
            strokeJoin = Paint.Join.ROUND
            strokeWidth = dotBorderSize
        }
    }
    ...
}

CircleDotsLineView는 View를 상속받는다.

View를 상속받는 클래스들은 아래 3가지 생성자들을 구현 해 주어야 한다.

constructor(context: Context)
constructor(context: Context, attrs: AttributeSet?)
constructor(context: Context, attrs: AttributeSet?, defStyle: Int)

이를 모두 구현하는 것은 귀찮으니, @JvmOverloads 키워드를 사용하면 편리하다.

그리고 뷰 내에서 사용할 속성들을 정의하고 init에서 attrs 내부에 정의된 값들을 읽어 설정한다.

Paint는 onDraw에서 Canvas에서 그리기 위해 사용된다.

주의할 것은, onDraw는 자주 호출될 수 있으므로 객체의 생성같은 반복된 행위를 하지 않는것을 성능상의 이유로 권장하고 있다.


package com.duitddu.android.codelabs.circle.dots.line.view.view

import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.util.AttributeSet
import android.view.View
import com.duitddu.android.codelabs.circle.dots.line.view.R
import kotlin.math.roundToInt

class CircleDotsLineView @JvmOverloads constructor(
    context: Context, attrs: AttributeSet? = null, defStyle: Int = 0
): View(context, attrs, defStyle) {
    ...

    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        val widthDimension = if (isVertical) (dotSize + dotBorderSize).roundToInt() else widthMeasureSpec
        val heightDimension = if (isVertical) heightMeasureSpec else (dotSize + dotBorderSize).roundToInt()

        setMeasuredDimension(widthDimension, heightDimension)
    }

    override fun onDraw(canvas: Canvas?) {
        super.onDraw(canvas)
        val dotCount = dotCount
        val dotsAreaSize = (dotSpacing + dotSize) * dotCount
        val startOffset = if (isVertical) (measuredHeight - dotsAreaSize) / 2 else (measuredWidth - dotsAreaSize) / 2

        val startXPos = if (isVertical) measuredWidth / 2f else startOffset + dotSpacing / 2f + dotRadius
        val startYPos = if (isVertical) startOffset + dotSpacing / 2f + dotRadius else measuredHeight / 2f
        val drawOffset = dotSpacing + dotSize

        for (i in 0 until dotCount) {
            val xPos = if (isVertical) startXPos else startXPos + drawOffset * i
            val yPos = if (isVertical) startYPos + drawOffset * i else startYPos
            canvas?.drawCircle(xPos, yPos, dotRadius, borderPaint)
            canvas?.drawCircle(xPos, yPos, dotRadius, innerPaint)
        }
    }
}

이제 View를 그리는 핵심인 onMeasure와 onDraw 코드를 보자.

onMeasure는 심플하게 했으나, 다양한 고수들의 오픈소스 코드를 참고하는 것도 좋을 것이다.

super.onMeasure를 해도 무관하나, 뷰의 크기를 직접 다루고 싶다면 커스텀해서 사용하면 된다.

onDraw의 경우 canvas위에 그림을 그리는 메서드다.

처음 정의한 Paint를 사용하여 본인이 구현하고자 하는 정책에 따라 위치와 그림의 크기 등을 정의하며 그려주면 된다.

나는 점들을 그려주기 위해 drawCircle을 활용하였으며, 개인적으로 정의한 정책을 통해 그림을 그려주고 있다.


3. 사용 및 결과

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout 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.duitddu.android.codelabs.circle.dots.line.view.view.CircleDotsLineView
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:vertical="false"
        app:dotBorderSize="1dp"
        app:dotSize="16dp"
        app:dotBorderColor="@color/purple_200"
        app:dotInnerColor="@color/teal_700"
        app:dotSpacing="8dp" />

</androidx.constraintlayout.widget.ConstraintLayout>

xml에서는 패키지 경로를 통해 접근하고 app 키워드로 커스텀하게 정의한 속성에 값을 줄 수 있다.

결과는 아래와 같다.

사실 굉장히 심플한 결과긴 하다.

하지만 이런 기본적인 것을 할 줄 알아야 요구사항에 맞는 복잡한 커스텀뷰를 구현할 수 있을 것이다.

github.com/DuItDDu/Android-Codelabs/tree/master/CircleDotsLineView

 

DuItDDu/Android-Codelabs

Android Codelabs. Contribute to DuItDDu/Android-Codelabs development by creating an account on GitHub.

github.com

위 Github 링크에 결과 코드가 저장되어 있으니, 필요하다면 참고하면 좋겠다.

반응형