Overview

안드로이드 앱을 개발하다 보면 일반적인 뷰로는 내가 원하는 결과를 만들 수 없는 경우가 발생합니다. 이런 경우 직접 ‘커스텀 뷰’를 만들어야 합니다. 오늘은 커스텀 뷰에 대해 정리해보겠습니다.

1. CustomView

athena

위 그림에서 최상단에 위치하고 있는 뷰는 사용자 인터페이스를 구축하고 유저의 모든 입력 이벤트를 처리하는 기본적인 클래스입니다. 스크린의 직사각형 영역을 차지하며 해당 자식 요소들과 함께 측정, 배치, 그리는 역할을 합니다. ViewGroup은 하위(자식) 뷰를 포함하고 자체 레이아웃 속성을 정의할 수 있습니다.


커스텀 뷰는 아래와 같을 때 도움이 될 수 있습니다.

  • 현재 일반적인 안드로이드 구성 요소로는 원하는 작용이나 애니메이션 또는 UI를 만들 수 없을 때
  • 코드 재사용성을 위해
  • nested view 등으로 성능 저하가 예상될 때

커스텀 뷰의 핵심은 onMeasure, onDraw, onLayout 입니다. 도화지 크기를 선택하고(onMeasure), 어느 위치에(onLayout) 어떤 그림을 그릴지(onDraw) 설정해주면 커스텀 뷰는 완성됩니다.

뷰는 포커스를 얻게 되면 레이아웃의 루트 노드에서 시작하여 전위 순회로 그려집니다. 따라서 부모가 자식들보다 먼저 그려지고, 형제들은 트리에 나타난 순서대로 그려집니다.

athena

2. Constructor

뷰는 최대 4개의 생성자를 가집니다.

  • View(Context context) 코드에서 동적으로 뷰를 생성할 때 사용할 수 있는 간단한 생성자입니다. 파라미터 context를 통해 현재 실행중인 뷰의 리소스 등에 액세스 할 수 있습니다.
  • View(Context context, AttributeSet attrs) : xml에서 생성할 때
  • View(Context context, AttributeSet attrs, int defStyleAttr) : ThemeStyle과 함께 뷰를 생성할 때
  • View(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) : ThemeStyle 또는 Style로 xml에서 뷰를 생성할 때

코틀린에서는 @JvmOverloads 어노테이션을 통해 생성자를 간편하게 선언할 수 있습니다.

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

JvmOverloads를 이용하면 1개의 파라미터를 가지는 Constructor부터 n-parameter 생성자까지 만들어줍니다. API<21의 디바이스에서 JvmOverloads 어노테이션으로 4-parameter 생성자를 만들게 되면 StackOverflow를 야기할 수 있습니다.

(https://stackoverflow.com/questions/64236570/call-requires-api-level-21-current-min-is-19-android-view-view)

athena

3. onMeasure

이 메소드에서는 해당 커스텀 뷰의 사이즈를 지정해줘야 합니다. xml에서 유저가 설정한 width, height의 정보가 파라미터로 넘어옵니다. 우리는 MeasureSpec.getMode(~)를 통해 MATCH_PARENT, WRAP_CONTENT 또는 100dp와 같이 지정된 값인지 알 수 있습니다.

onMeasure은 여러 번 호출될 수 있습니다. 예를 들어 부모가 자식들의 각 크기를 측정한 뒤, 자식들의 크기의 합이 너무 크거나 작다면 다시 measure() 메소드를 호출하여 구체적인 값을 구합니다.

child view를 가지는 커스텀 뷰라면 child의 사이즈를 측정해서 자신의 사이즈를 재야 할 수도 있는데, 이 메소드에서 설정해주면 됩니다.

val widthMode = MeasureSpec.getMode(widthMeasureSpec)
val heightMode = MeasureSpec.getMode(heightMeasureSpec)
val widthSize = MeasureSpec.getSize(widthMeasureSpec)
val heightSize = MeasureSpec.getSize(heightMeasureSpec)

파라미터로 넘겨오는 widthMeasureSpec과 heightMeasureSpec은 뷰의 모드와 사이즈를 조합한 값입니다. 따라서 위와 같은 코드를 통해 모드와 사이즈를 알아낼 수 있습니다.

모드는 총 3가지로 나뉩니다.

  • MeasureSpec.AT_MOST

    wrap_content. 해당 값보다 더 클 수는 없습니다. 측정 과정이 다시 발생할 수 있습니다.

  • MeasureSpec.EXACTLY

    match_parent, 500dp와 같이 정해져 있는 값입니다. 측정 과정은 다시 발생하지 않습니다.

  • MeasureSpec.UNSPECIFIED

    정해져 있지 않은 값입니다. 원하는 값을 설정할 수 있습니다. 측정 과정이 다시 발생할 수 있습니다.

onMeasure()이 끝나면 setMeasuredDimension()을 통해 값을 설정해주어야 합니다.

override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        var width: Int
        var height: Int

        val measureWidthMode = MeasureSpec.getMode(widthMeasureSpec)
        val measureHeightMode = MeasureSpec.getMode(heightMeasureSpec)
        val measureWidth = MeasureSpec.getSize(widthMeasureSpec)
        val measureHeight = MeasureSpec.getSize(heightMeasureSpec)

        val mDefaultWidth = 500
        val mDefaultHeight = getTextHeight()

        width = when (measureWidthMode) {
            MeasureSpec.AT_MOST -> { 
                min(mDefaultWidth, measureWidth)
            }
            MeasureSpec.EXACTLY -> { 
                measureWidth
            }
            else -> { 
                mDefaultWidth
            }
        }

        height = when (measureHeightMode) {
            MeasureSpec.AT_MOST -> {
                min(mDefaultHeight, measureHeight)
            }
            MeasureSpec.EXACTLY -> {
                measureHeight
            }
            else -> {
                mDefaultHeight
            }
        }

        setMeasuredDimension(width, height)

    }

예를 들어, multi-line을 가지는 CustomTextView를 만든다고 할 때, 위와 같은 방법으로 onMeasure()을 사용할 수 있습니다. 따라서 multiline을 예상하여 height를 계산해야 합니다. 이는 getTextHeight() 함수를 구현하여 얻어오고, 뷰의 mode에 따라 알맞는 값을 선택해 setMeasuredDimension()을 호출해주었습니다.

protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        setMeasuredDimension(getDefaultSize(getSuggestedMinimumWidth(), widthMeasureSpec),
                getDefaultSize(getSuggestedMinimumHeight(), heightMeasureSpec));
    }

다음은 View의 onMeasure() 함수입니다. onMeasure()을 오버라이드하는 경우 setMeasuredDimension()을 호출해야 함을 알 수 있습니다. 실제로 이를 부르지 않는 경우 java.lang.IllegalStateException: ~ #onMeasure() did not set the measured dimension by calling setMeasuredDimension() 에러를 확인할 수 있습니다.

앞에서 뷰는 전위순회 방식으로 그려진다고 하였습니다. 그렇다면 자식의 사이즈를 wrap_content하는 부모일 경우 아직 자식의 뷰를 측정하지 않았는데 어떻게 자신의 사이즈를 측정할 수 있을까요?

measureChildren(int widthMeasureSpec, int heightMeasureSpec)

메소드를 사용하면 됩니다. 해당 뷰의 모든 자식들에게 MeasureSpec 사항과 해당 패딩을 모두 고려하여 자체 측정을 요청합니다.

4. onLayout

뷰의 위치를 설정해주는 함수입니다. 뷰의 child들의 크기와 위치를 할당해야 할 때 호출됩니다. 즉, child를 가지는 뷰라면 해당 메소드를 오버라이드 해주어야 합니다. 이때 파라미터로 넘어오는 값들은 어플리케이션 전체를 기준으로 넘어오는 위치값임을 알아야 합니다.

override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
    children?.forEachIndexed { index, view ->
            view.layout(x, y x + view.measuredWidth, y + view.measuredHeight)
      }
}

이 메소드는 일반적으로 View가 현재 범위 내에서 더 이상 맞지 않는다고 판단되면 자체적으로 호출되며, requestLayout()을 호출하여 레이아웃을 시작할 수도 있습니다.

4. onDraw

뷰에 그림을 그리는 메소드입니다. Paint 클래스를 통해 도형을 그릴 수도 있고, canvas에 텍스트를 추가할 수도 있습니다.

onDraw()에서는 많은 시간이 소요되거나 여러 번 호출될 수 있기 때문에(초당 60번) 되도록 객체 선언, 할당을 피하고 기존 객체를 재사용하는 것이 좋습니다. 이보다 가비지 컬렉터가 더 빨라서 GC 관련된 drop이 없을 수도 있지만, 이 동작 역시 별도의 스레드에서 진행되므로 배터리 소모를 야기할 수 있습니다. 또한, onDraw에서 초기화되는 객체들은 주로 drawing object인데, 이들은 많은 소멸자를 호출하기 때문에 성능에 영향을 줄 수 있습니다.

override fun onDraw(canvas: Canvas?) {
        canvas?.let { canvas ->
            canvas.drawColor(resources.getColor(R.color.gray100_trans))

            val xPos = width / 2
            val yPos = height / 2 - ((textPaint.descent()+textPaint.ascent())/2)
            val staticLayout = StaticLayout(mText, textPaint, measuredWidth, alignment, spacingMultiplier, spacingAddition, false)

            canvas.save()
            canvas.translate(xPos.toFloat(), 0f)
            staticLayout.draw(this)
            canvas.restore()
        }
    }

multiline 텍스트를 화면에 그려야 한다면 위와 같이 onDraw()를 구성할 수 있습니다. canvas.save()를 호출하면 현재 캔버스의 설정을 스택에 저장하게 됩니다. 그다음 캔버스의 설정(translate 등)을 변경한 후 원하는 것을 그리고, 그리기가 끝났다면 canvas.restore()을 통해 이전 구성으로 캔버스를 복원할 수 있습니다.

Conclusion

사실 커스텀 뷰는 개인의 조건에 맞게 onMeasure(), onLayout(), onDraw()를 작성해야하기 때문에 구글에 의존하기에는 어려운 듯합니다. 그래서 커스텀 뷰의 과정을 잘 이해하는 것이 중요하다고 생각합니다. 저 역시 이번 기회를 통해 커스텀 뷰의 개념을 다시 한번 정립하는 시간을 가질 수 있었습니다. 저의 짧은 글이 다른 사람들에게 도움이 될 수 있길 바라며 이만 마치겠습니다. 감사합니다.

참고자료

https://developer.android.com/guide/topics/ui/how-android-draws.html


전희수 | APPS실 MA팀
브랜디, 오직 예쁜 옷만