[안드로이드] 커스텀 뷰 작성

hee09·2021년 10월 21일
1

안드로이드

목록 보기
8/20
post-thumbnail
post-custom-banner

View의 생명주기

커스텀 뷰

안드로이드 화면 구성의 기본 규칙은 액티비티가 실행되면서 다양한 뷰를 출력하는 것입니다. 하지만 화면을 구성하기 위한 모든 뷰를 라이브러리에서 제공하지는 않습니다. 따라서 라이브러리에서 제공하는 뷰가 없다면 개발자가 직접 만들어야 합니다.


기본 작성 방법

커스텀 뷰를 만드는 방법은 다음 세 가지로 나누어 볼 수 있습니다.

  • API에서 제공하는 뷰를 그대로 이용하면서 약간 변형시킨 뷰
    기존의 뷰를 변형하여 커스텀 뷰를 작성할 때는 대상이 되는 뷰를 직접 상속받아 변경하고자 하는 부분만 수정하여 작성하면 됩니다.

아래 예시는 TextView를 상속받아서 데이터에 따라 텍스트 색상이 변경되는 CustomView의 코드입니다.

// 라이브러리에서 제공하는 뷰를 그대로 이용하며 변형시키기 위해 AppCompatTextView를 상속
class CustomTextView: AppCompatTextView {
    var value: Int = 0
        set(value) {
            field = value
            customTextColor()
        }

    // 생성자
    // 기본적으로 자바 코드로 생성해서 이용한다면 생성자는 하나만 정의해도 되지만
    // 레이아웃 XML 파일에 등록해 사용하려면 아래처럼 생성자 3개를 모두 정의해줘야 한다
    constructor(context: Context): super(context)
    constructor(context: Context, attrs: AttributeSet): super(context, attrs)
    constructor(context: Context, attrs: AttributeSet, defStyleArr: Int)
            : super(context, attrs, defStyleArr)

    // 프로퍼티 value의 값을 가지고 뷰의 TextColor 변경
    private fun customTextColor() {
        when {
            value < 0 -> this.setTextColor(Color.GREEN)
            value < 30 -> this.setTextColor(Color.BLUE)
            value < 60 -> this.setTextColor(Color.YELLOW)
            else -> this.setTextColor(Color.RED)
        }
    }
}
  • 여러 뷰를 합쳐서 한번에 출력하기 위한 뷰
    여러 뷰가 결합된 형태로 반복해서 사용된다면 하나의 뷰로 만들어 사용하는 게 편리할 때 사용합니다. 이러한 커스텀 뷰를 만든다면 ViewGroup이나 LinearLayout 같은 Layout 클래스를 상속받아 작성합니다.

여러 뷰를 합쳐서 한번에 출력하기 위한 뷰 예시

  • 기본 API에 전혀 존재하지 않는 뷰
    화면에 무언가 출력해야 하는데 관련된 뷰가 라이브러리에 전혀 없을 때도 있습니다. 뷰의 화면이나 뷰에서의 업무처리 등이 라이브러리 뷰와는 완전히 다른 경우입니다. 이런 뷰를 만들려면 모든 뷰의 최상위 클래스인 View를 상속받아 작성합니다.

기본적인 접근 방법

정리하자면 기본적인 접근 방법은 아래와 같습니다.

  1. 기존 View 클래스나 또는 View 클래스의 하위 클래스(Widget, Layout 등)를 확장합니다.

  2. 상속받은 클래스의 메소드들을 오버라이드합니다. 오버라이드 할 메소드의 대부분은 on으로 시작하며, 그 예로는 onDraw(), onMeasure(), onKeyDown()이 있습니다.

  3. 필요한 메소드들을 전부 오버라이드한 후 확장한 클래스를 사용하면 됩니다.


완전히 커스터마이징된 컴포넌트

자신만의 뷰를 완전히 커스터마이징하기 위해서는 아래와 같습니다.

  1. 가장 일반적으로 확장할 수 있는 뷰는 View 입니다. 따라서 이 View 클래스를 확장합니다.

  2. XML에 선언된 attribute와 parameter를 받을 수 있는 생성자를 제공할 수 있습니다. 그리고 받은 attribute와 parameter를 직접 사용할 수 있습니다.

  3. 고유한 이벤트 리스너와 프로퍼티 접근자 및 modifier를 만들어서 더 정교한 동작을 만들 수 있습니다.

  4. onMeasure()onDraw() 메소드를 재정의할 수 있습니다. 만약 그 둘을 재정의하지 않는다면 onDraw()는 아무것도 하지 않으며, onMeasuee()는 크기를 100x100으로 잡습니다. 이 두 메소드에 대한 설명은 아래에서 추가로 하겠습니다.

  5. 필요에 따라 다른 onXXX 메소드를 오버라이드하면 됩니다.


주요 메소드

커스텀 뷰 생성 시 생성자

커스텀 뷰를 작성할 때는 생성자 부분을 주의해야 합니다. 작성된 커스텀 뷰를 액티비티에서 이용하려면 자바 코드로 뷰를 직접 생성하거나 레이아웃 XML에 등록해야 합니다. 직접 자바 코드로 생성하는 경우 생성자는 하나만 정의해도 되지만 레이아웃 XML 파일에 등록하여 다른 뷰와 함께 화면을 구성할 때는 생성자를 하나만 정의하면 실행 시 에러가 발생합니다. 결국, 커스텀 뷰를 레이아웃 XML에 등록해서 이용하려면 생성자 3개를 모두 정의해야 합니다.

// 생성자
// 기본적으로 자바 코드로 생성해서 이용한다면 생성자는 하나만 정의해도 되지만
// 레이아웃 XML 파일에 등록해 사용하려면 아래처럼 생성자 3개를 모두 정의해줘야 한다
constructor(context: Context): super(context)
constructor(context: Context, attrs: AttributeSet): super(context, attrs)
constructor(context: Context, attrs: AttributeSet, defStyleArr: Int)
        : super(context, attrs, defStyleArr)
constructor(context: Context, attrs: AttributeSet, defStyleArr: Int, defStyleRes: Int)
        : super(context, attrs, defStyleArr, defStyleRes)

위에서 봤던 예시 코드인데 커스텀 뷰를 작성할 때는 위와 같이 생성자를 생성해야 합니다. 여기서 매개변수 attrs: AttributeSet은 커스텀 속성을 정의하고 추출하여 사용하고자 할 때 쓰는 값인데 아래에서 커스텀 속성을 설명하며 다시 한번 보겠습니다.


  • View(context: Context)

코드에서 View를 동적으로 만들 때 사용하는 간단한 생성자입니다. 여기서 매개 변수 context는 뷰가 실행될 때 현재 테마, 리소스 등을 구성하는데 사용됩니다.


  • View(context: Context, attrs: AttributeSet = null)

XML에서 View를 전개(Inflation) 할 때 호출되는 생성자로, XML 파일에서 지정된 속성을 제공하여 XML 파일에서 View를 구성할 때 호출됩니다. 이 생성자는 기본 스타일인 0을 사용하므로 Context의 테마 및 지정된 AttributeSet의 속성 값만 적용됩니다.


  • View(context: Context, attrs: AttributeSet = null, defStyleAttr: Int)

XML을 통해 전개를 하고 테마 속성에서 클래스별 기본 스타일을 적용합니다. 이 View 생성자는 서브 클래스가 전개할 때 자체 기본 스타일을 사용할 수 있도록 합니다. 예를 들어, Button 클래스의 생성자는 Super(부모) 클래스 생성자를 호출하고 defStyleAttr에 R.attr.buttonStyle을 제공합니다. 이를 통해 테마의 버튼 스타일은 모든 기본 View 속성(특히 배경)과 Button 클래스의 속성을 수정할 수 있습니다.

defStyleAttr 매개 변수는 View의 기본값을 제공하는 Style 리소스에 대한 참조를 포함하는 현재 테마의 속성입니다. 기본값을 찾지 않으려면 0을 지정할 수 있습니다.


  • View(context: Context, attrs: AttributeSet = null, defStyleAttr: Int, defStyleRes: Int)

XML을 전개하고 테마 속성 또는 Style 리소스에서 클래스 별 기본 스타일을 적용합니다. 이 생성자는 서브 클래스가 전개할 때 기본 스타일을 사용할 수 있도록 합니다. 위와 유사합니다.

매개변수 defStyleRes는 View의 defStyleAttr가 0 이거나 테마에서 찾을 수 없는 경우에만 기본값을 제공하는 Style 리소스 ID 입니다. 기본값을 찾지 않으려면 0으로 지정합니다.


커스텀 속성

AttributeSet 매개변수를 사용하는 이유는 커스텀 속성을 정의하고 추출하여 사용하고자 할 때 사용합니다. 여기서 말하는 커스텀 속성은 레이아웃 XML 파일에서 커스텀 뷰에 자신이 원하는 속성을 정의하여 사용하고자 할 때 쓰는 것입니다.

XML

<kr.co.lee.customviewexample.CustomTextView
        android:id="@+id/customText"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginTop="20dp"
        custom:customTextValue="0" />

예를 들어, 위의 CustomTextView에 custom이라는 nameSpace의 customTextValue라는 속성을 추가하였는데 이는 안드로이드의 기본 속성이 아니여서 그냥 빌드하면 오류가 발생합니다. 이런 커스텀 속성을 사용하기 위해서는 어떤 이름의 속성을 이용할 것인지 등록해 주어야 합니다. 이 작업은 res/values 폴더 하위에 attrs.xml 파일을 이용하여 속성을 등록해 주는 것입니다.

attrs.xml

<!-- <attr>을 묶어 한번에 관리하기 위한 태그 -->
<declare-styleable name="CustomTextView">
    <!-- attr 태그 하나가 속성 하나를 정의 -->
    <!-- name은 레이아웃 XML에서 사용하고자 하는 속성명, format은 속성값의 타입 -->
    <attr name="customTextValue" format="integer" />
</declare-styleable>

태그의 정의는 다음과 같습니다.

  • <declare-styleable>
    • 여러 <attr>을 묶어 한꺼번에 관리하기 위한 태그
    • name의 값은 개발자가 작성하는 단어입니다. 다만 대부분 정의한 커스텀 뷰 클래스의 이름과 똑같이 만듭니다.
  • <attr>
    • 이 태그 하나가 속성 하나를 정의하게 됩니다.
    • attr안의 name : 레이아웃 XML에서 사용하고자 하는 속성명
    • attr안의 format : 속성값의 타입
      이 속성값으로 integer 이외에 color, string, float, dimension, enum 등 다양하게 지정할 수 있습니다.

이렇게 정의한 속성을 커스텀 뷰의 속성으로 지정한 후 AttributeSet 매개변수를 사용하여 값을 가져온 후 사용하면 됩니다. 하지만 AttributeSet에 직접 접근해서 값을 가져오면 안되고 obtainStyledAttributes() 메소드를 사용해야합니다. 해당 메소드에 AttributeSet을 매개변수로 넘겨주면 속성 값의 배열인 TypedArray를 반환합니다.

init {
    // 속성값 획득하는 부분
    // 반환값은 TypedArray
    context.theme.obtainStyledAttributes(
        attrs,
        R.styleable.MyView,
        0, 0
    ).apply {
        try {
            textColor = getColor(R.styleable.MyView_customTextColor, Color.RED)
        } finally {
            recycle()
        }
    }
}

TypedArray 클래스는 obtainStyledAttributes() 함수로 검색된 커스텀 속성 값의 배열 컨테이너 역할을 수행합니다. 따라서 이 객체를 통해 값을 얻어올 수 있고, 다 사용한 후에는 TypedArray.recycle() 메소드를 사용하여 재활용해야합니다.


Attachment / Detachment

View가 Window에서 연결되거나 분리될 때의 단계입니다. 이 단계에는 적절한 작업을 수행하기 위해 콜백을 받는 몇 가지 방법이 있습니다.

onAttachedToWindow()

View가 Window에 열결되면 호출됩니다. View가 활성화 될 수 있고, 드로잉 할 표면이 있음을 알고있는 단계입니다. 따라서 리소스 할당을 시작하거나 리스너를 설정할 수 있습니다.


onDetachedFromWindow()

View가 Window에서 분리될 때 호출됩니다. 이 시점에서 더 이상 드로잉을 할 표면이 없습니다. 예약된 자원을 정리하거나 정리하는 모든 종류의 작업을 중지해야 하는 곳입니다. 이 메소드는 ViewGroup에서 View 제거를 호출하거나 액티비티가 Destroyed 될 때 호출됩니다.


OnFinishInflate()

이 메소드는 View가 전개가 끝날 때 호출됩니다. 레이아웃의 경우 모든 Child View가 추가된 후에 호출되는 것입니다.


순회(Traversals)

View 계층 구조는 부모 노드(ViewGroup)에서 분기가 있는 리프 노드(Child Views)의 트리 구조와 같기 때문에 순회 단계라고 합니다. 따라서 각 메소드는 부모에서 시작하여 마지막 노드까지 순회하여 제약조건을 정의합니다.

View 계층 구조 예시 이미지

순차적 단계

Measure 단계와 Layout 단계는 항상 위와 같이 순차적으로 진행됩니다.


onMeasure(int, int)

View의 크기를 확인하기 위해 호출됩니다. ViewGroup의 경우 계속해서 각 Child view에 대한 측정을 하고, 그에 대한 결과로 자신의 사이즈를 결정합니다.

/**
@param widthMeasureSpec 부모뷰에 의해 적용된 수평 공간 요구사항
@param heightMeasureSpec 부모뷰에 의해 적용된 수직 공간 요구사항
*.
onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int)

onMeasure 메소드는 값을 반환하지 않고, setMeasuredDimension()을 호출하여 너비와 높이를 명시적으로 결정합니다. 예시 코드는 아래에 있습니다.


MeasureSpec

MeasureSpec은 부모에서 자식으로 전달되는 레이아웃 요구 사항을 캡슐화합니다. 각 MeasureSpec은 너비 또는 높이에 대한 요구 사항을 나타냅니다. MeasureSpec은 크기와 모드로 구성되며, 세 가지 모드가 있습니다.

  • UNSPECIFIED
    아무런 값이 넘어오지 않은 경우로 부모 뷰가 자식 뷰에게 아무런 제약도 가하지 않습니다. 따라서 자식 뷰는 원하는 크기가 될 수 있습니다.

  • EXACTLY
    부모뷰가 자식뷰의 정확한 크기를 결정한 경우입니다. 자식뷰의 사이즈와 관계없이 주어진 경계내에서 사이즈가 결정되며 fill_parent, match_parent가 있습니다.

  • AT_MOST
    자식뷰는 지정된 크기까지 원하는 만큼 커질 수 있는 상태로 wrap_content로 선언된 경우에 해당합니다.

onMeasure 예제코드

override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
    // Witdh와 Height의 Mode, Size 획득하기
    val widthMode = MeasureSpec.getMode(widthMeasureSpec)
    val widthSize = MeasureSpec.getSize(widthMeasureSpec)
    val heightMode = MeasureSpec.getMode(heightMeasureSpec)
    val heightSize = MeasureSpec.getSize(heightMeasureSpec)

    var width = 0
    var height = 0
    // match_parent, fill_parent인 경우
    if(widthMode == MeasureSpec.EXACTLY) {
        width = widthSize
    // wrap_content인 경우
    } else if(widthMode == MeasureSpec.AT_MOST) {
        width = 200
    }

    if(heightMode == MeasureSpec.EXACTLY) {
        height = heightSize
    } else if(heightMode == MeasureSpec.AT_MOST) {
        height = 500
    }
    
    // 뷰의 크기 지정
    setMeasuredDimension(width, height)
}

onLayout(boolean, int, int, int, int)

뷰를 측정하여 화면에 배치한 후에 호출됩니다. 첫 번째 매개변수는 이 뷰가 사이즈와 포지션이 바뀌었는지에 대한 값이고, 두 번째부터 다섯 번째 매개변수는 차례대로 Left, Top, Right, Bottom position 값입니다.


onDraw(canvas)

크기와 위치는 이전 단계에서 계산되므로 이제 View는 그것들을 기준으로 기릴 수 있습니다. onDraw() 메소드는 뷰가 내용을 그릴 때(표현할 때) 호출됩니다. Canvas와 Paint 클래스를 사용하여 그리면 됩니다. Canvas는 onDraw 메소드의 파라미터로 제공되며 그래픽 함수를 제공해주는 클래스입니다. 이 클래스의 함수를 이용하여 뷰의 화면을 그리면 됩니다. Paint 클래스는 그리기 옵션, 색상, 투명도 등의 속성을 지정하는 클래스입니다. 즉, Canvas는 무엇을 그릴지를 결정하는 클래스이고, Paint는 어떻게 그릴지를 결정하는 클래스입니다.

주의할 점은 onDraw함수를 호출시 많은 시간이 소요됩니다. Scroll 또는 Swipe 등을 할 경우 뷰는 다시 onDraw와 onLayout을 다시 호출하게 됩니다(여러번 호출됩니다). 따라서 함수 내에서 객체할당을 피하고 한 번 할당한 객체를 재사용할 것을 권장합니다.

// Paint 클래스 생성
// drawing 객체들은 초기화에 많은 비용이 들고 뷰들은 매우 자주 다시 그려지기에 미리 객체 생성
private val textPaint = Paint().apply {
    textSize = 30.0f
    color = Color.BLUE
}

override fun onDraw(canvas: Canvas?) {
    // Canvas 객체를 통해서 그리기
    canvas?.drawText("예시 텍스트", 0.0f, 0.0f, textPaint)
}

View Update

특정 뷰의 속성이 변경되었을 때 실행되는 두 가지 메소드가 있습니다.


invalidate()

invalidate()는 변경 사항을 보여주고자 하는 특정뷰에 대해 강제로 다시 그리기를 요구하는 메소드입니다. 뷰 모양이 변경되면 invalidate()를 호출해야한다고 간단히 말할 수 있습니다. 예를 들어 뷰의 text 또는 color가 변경되거나, touch event가 발생할 때 onDraw() 함수를 재호출하면서 뷰를 업데이트합니다.

requestLayout()

어떤 시점에서 뷰의 경계가 변경되었다면, View를 다시 측정하기 위해 requestLayout()을 호출하여 Measure 및 Layout 단계를 다시 거칠 수 있습니다.

View에서 메소드를 호출할 때는 항상 UI 스레드내에서 수행해야 합니다. 다른 스레드어 작업하고 있고, 해당 스레드에서 View의 상태를 업데이트 하려는 경우 핸들러를 사용해야 합니다.


Gesture

안드로이드에서는 터치 이벤트를 tapping, pulling, pushing, flinging, zooming과 같은 제스처로 변환하기 위해 GestureDetector를 제공합니다. 이 클래스는 제공된 MotionEvent를 사용하여 다양한 제스처 및 이벤트를 감지합니다.

private val myListener = object : GestureDetector.SimpleOnGestureListener() {
        override fun onDown(e: MotionEvent?): Boolean {
            return true
        }
    }

    private val detector = GestureDetector(context, myListener)

    override fun onTouchEvent(event: MotionEvent?): Boolean {
        return detector.onTouchEvent(event).let { result ->
            if (!result) {
                if (event?.action == MotionEvent.ACTION_UP) {
                    stopScrolling()
                    true
                } else false
            } else true
        }
    }

성능 최적화

  • onDraw()에서 객체 생성 및 할당을 줄이고 작업도 최소한으로 해야합니다.

  • invalidate()와 requestLayout()의 호출을 최대한 자제해야 합니다.

  • ViewGroup의 계층 구조는 최대한 얇게 만들어야 합니다.

  • 복잡한 뷰를 작성한다면 ViewGroup을 상속받아 작성하는 것을 고려해야 합니다. 기존에 제공하는 뷰와 달리 사용자 지정 뷰는 자식 뷰의 크기와 모양에 대해 해당 앱에 대한 특화된 측정을 할 수 있으므로 자식뷰를 measure하는데 소모되는 시간을 줄일 수 있습니다.


참조
깡쌤의 안드로이드 프로그래밍
안드로이드 기초 - Android는 어떻게 View를 구성할까?
안드로이드 developer - custom components
안드로이드 developer - How Android Draws Views
안드로이드 - 뷰가 그려지는 과정
Android에서 View의 생명주기

틀린 부분은 댓글로 남겨주시면 수정하겠습니다..!!

예제 소스는 깃허브링크에 나와있습니다.

profile
되새기기 위해 기록
post-custom-banner

0개의 댓글