Foggy day

Android - 최고의 커스텀 뷰 가이드 본문

Android

Android - 최고의 커스텀 뷰 가이드

jinhan38 2020. 12. 27. 01:44

 

이글은 아래 링크의 글을 번역한 글입니다.

 

vladsonkin.com/ultimate-guide-to-android-custom-view/

 

Ultimate Guide To Android Custom View - vladsonkin.com

Android has lots of standard views to cover all our needs in the app. But sometimes, the designers come up with some new UI elements, and the only way to implement it is by creating an Android Custom View. If this new UI element looks like some improved st

vladsonkin.com

 

 

안드로이드는 앱에서 요구하는 기능들을 충족시킬만한 많은 View들을 가지고 있습니다. 하지만 가끔씩 디자이너들이 새로운 UI들을 제시하는데 이를 구현할 수 있는 유일한 방법은 커스텀뷰를 만드는 것입니다.

 

새로운 UI가 표준적인 view에서 약간 개선된 정도이거나 기존의 view들을 여러개 포함하고 있다면, 기존의 뷰들을 상속받아서 커스텀 뷰를 만들 수 있습니다. 커스텀 뷰를 만드는 것은 아주 간단합니다. 기존의 것들을 사용하고 이것들을 수정하면 됩니다.

 

하지만 운이 나쁘다면 새로운 커스텀 뷰를 처음부터 만들어야 할 수도 있습니다. 이 글에서는 커스텀 애니메이션 Loader와 라이프사이클, 생성자, 속성, 애니메이션 등 몇 가지 주제를 다루었습니다.

 

 

 

안드로이드 뷰 라이프사이클

 안드로이드 뷰들은 생명주기를 가지고 있습니다. 이는 공식문서에서는 찾아볼 수 없습니다. 라이프사이클의 중요한 부분들은 아래와 같습니다.

 

activity가 foreground에 도달했을 때, 안드로이드는 root view 정보를 얻어와 위에서부터 아래로 뷰를 그립니다. 뷰를 그리는 작업은 3단계로 진행되는데 : 

 

1. onMeasure(), 뷰의 사이즈를 구해오는 곳

2. onLayout(), 뷰의 정확한 위치를 구해오는 곳

3. onDraw(), 위에서 얻어온 사이즈와 위치를 이용해 뷰를 그리는 곳

 

note: 이러한 절차들을 거치기 때문에 레이아웃을 최대한 평평하게(depth가 깊지 않게) 유지하는 것을 추천합니다. 그래야 시스템이 viewGroups의 사이즈와 위치값을 계산하는 리소스를 절약할 수 있습니다.

 

그러면 커스텀 뷰를 만들고 생명주기가 작동하는 것을 확인하겠습니다. 커스텀뷰를 만드는 작업은 생성자로부터 시작합니다.

 

 

 

안드로이드 커스텀 뷰 생성자

로딩클래스를 만들고 View를 상속받으세요.

class LoadingView : View { 
   
}

 

안드로이드 스튜디오가 밑줄을 긋고, 생성자를 만들라고 요청할 것입니다.

뷰에는 여러 생성자가 있습니다.

 

1. constructor(context : Context)

이 생성자는 프로그래밍 방식으로 코드로 View를 만들 때 사용됩니다.

2. constructor(context : Context, @Nullable attrs: AttributeSet?)

이 생성자는 xml에서 View를 만들 때 사용됩니다.

3. constructor(ontext : Context, @Nullable attrs: AttributeSet?, defStyleAttr : Int)

이 생성자는 xml로 view를 생성하고 테마속성을 이용할 때 사용됩니다.

4. constructor(ontext : Context, @Nullable attrs: AttributeSet?, defStyleAttr : Int, defStyleRes : Int)

이 생성자는 3번 생성자와 유사하지만, 이에 더해 style resource를 사용할 수 있습니다.

 

첫번째와 두번째 생성자로 시작하고, 만약 테마나 스타일을 사용한다면 3번째와 4번째 생성자를 사용하는 것을 추천드립니다.

 

class LoadingView : View {
  constructor(context: Context) : super(context)
  constructor(context: Context, @Nullable attrs: AttributeSet?) : super(context, attrs)
}

 

 

 

이제 생명주기를 살펴보겠습니다.

 

onMeasure()

onMeasure에서 안드로이드 시스템은 뷰의 사이즈를 계산합니다. 기본적으로 제공된 너비와 높이를 기준으로 측정되고, 대부분 충분합니다. 만약에 커스텀뷰를 만든다면 다음과 같습니다.

 

<com.jinhanexample.animation.customLoadingView.LoadingView
android:layout_width="100dp"
android:layout_height="100dp"
android:layout_marginTop="24dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />

디폴트 onMeasure함수는 정확히 100x100 사이즈를 계산할 것이기 때문에 이 함수를 따로 구현할 필요가 없습니다. 

하지만 이러한 상황이 아니라면 onMeasure함수를 재정의 하여 다음 두가지 작업을 수행할 수 있습니다.

(view의 사이즈를 wrapContent로 지정할 경우 재정의가 사이즈 필요합니다.)

 

1. 뷰의 사이즈 계산

2. 1번에서 얻은 사이즈를 이용해 setMeasuredDimension(int, int) 함수 호출

 

 

onLayout()

onLayout() 단계에서 안드로이드는 onMeasure 단계에서 가져온 측정값으로 각각의 뷰에 사이즈와 위치를 할당합니다. 

onMeasure함수처럼 대부분 디폴트로 설정만으로도 잘 구현되고, xml에서 위치값을 가져옵니다.

 

 

onDraw()

onDraw 함수는 커스텀 뷰에서 중요한 역할을 하며, 아래 두개의 object를 이용해 뷰를 그릴 수 있습니다.

 

1. canvas. canvas는 onDraw함수로부터 인자로 제공하며 화면에 뷰를 그리는 역할을 합니다.

2. Paint. Paint object를 이용해 뷰의 style을 지정합니다.

 

 

다시 예제로 돌아와서, 우리가 만드는 원형 로딩뷰를 만들기 위해서는 테두리가 있는 원을 그려야합니다.

class LoadingView : View {

    constructor(context: Context) : super(context) {}

    constructor(context: Context, @Nullable attrs: AttributeSet?) : super(context, attrs) {}

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

        val circleRadius = 100F
        val paint = Paint().apply {
            color = ContextCompat.getColor(context, R.color.teal_700)
            style = Paint.Style.STROKE
            strokeWidth = 20F
        }

        canvas.drawCircle(width / 2F, height / 2F, circleRadius, paint)
    }
}

여기서 Canvas는 drawCircle함수를 이용해 원을 그리는 역할을 하고, Paint는 원의 스타일을 설정해줍니다.

 

커스텀 뷰를 다른 레이아웃에 추가해보겠습니다.

<?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=".animation.customLoadingView.CustomLoadingViewActivity">

    <com.jinhanexample.animation.customLoadingView.LoadingView
        android:layout_width="100dp"
        android:layout_height="100dp"
        android:layout_marginTop="24dp"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

 

결과를 확인해보면 아래와 같은 이미지가 나타납니다.

 

onDraw()함수는 여러번 호출되기 때문에 Paint object를 onDraw함수 내부가 아닌 다른 곳으로 이동시켜 재사용 하는것을 추천합니다. 예를들면

class LoadingView : View {
    private lateinit var paint: Paint
    private var circleRadius = 100F

    constructor(context: Context) : super(context) {
        init(context, null)
    }

    constructor(context: Context, @Nullable attrs: AttributeSet?) : super(context, attrs) {
        init(context, attrs)
    }

    private fun init(context: Context, attributeSet: AttributeSet?) {
        paint = Paint().apply {
            color = ContextCompat.getColor(context, R.color.teal_700)
            style = Paint.Style.STROKE
            strokeWidth = 20F
        }
    }

    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)
        canvas.drawCircle(width / 2F, height / 2F, circleRadius, paint)
    }
}

 

위에서 Paint 객체를 만드는 init함수를 생성자에 넣어 재사용할 수 있도록 만들었습니다.  

 

 

 

Custom View Update

라이프 사이클 다이어그램에 있는 invalidate()와 requestLayout()함수를 봤을 것입니다. 이 두 함수는 어떤 것들이 변경되었을 때 뷰를 다시 그리는 역할을 합니다. 유일한 차이점은 requestLayout()은 뷰의 사이즈와 위치를 다시 계산한다는 것입니다. 

 

만약에 업데이트가 뷰의 사이즈에 영향을 주지 않는다면 invalidate()를 호출하고, 영향을 준다면 requestLayout()를 호출하세요.

 

뷰의 라이프 사이클을 살펴보고, 간단한 원형 뷰를 그려보았습니다. 원형 이미지가 있다면 이미지뷰를 사용해서 훨씬 쉽게 같은 결과를 얻을 수 있습니다. 하지만 커스텀뷰는 더 많은 작업을 할 수 있게 해주며 그것들 중 하나는 애니메이션입니다.

 

 

Custom View Animations

커스텀 뷰의 애니메이션을 구현하기 위해서는 각각의 변화들을 단계별로 처리해야 합니다. 각각의 변화마다 invalidate()함수를 호출하고, 뷰가 다시 그립니다. 이것을 반복하면 애니메이션이 완성됩니다.

 

ValueAnimator:

package com.jinhanexample.animation.customLoadingView

import android.animation.ValueAnimator
import android.content.Context
import android.graphics.Canvas
import android.graphics.Paint
import android.util.AttributeSet
import android.view.View
import android.view.animation.AccelerateDecelerateInterpolator
import androidx.annotation.Nullable
import androidx.core.content.ContextCompat
import androidx.core.view.isVisible
import com.jinhanexample.R

class LoadingView : View {

    constructor(context: Context) : super(context) {
        init(context, null)
    }

    constructor(context: Context, @Nullable attrs: AttributeSet?) : super(context, attrs) {
        init(context, attrs)
    }

    private lateinit var paint: Paint
    private var circleRadius = 100F
    private var valueAnimator: ValueAnimator? = null



    fun showLoading() {
        isVisible = true
        valueAnimator = ValueAnimator.ofFloat(10F, circleRadius).apply {
            duration = 1000
            interpolator = AccelerateDecelerateInterpolator()
            addUpdateListener { animation ->
                circleRadius = animation.animatedValue as Float
                animation.repeatCount = ValueAnimator.INFINITE
                animation.repeatMode = ValueAnimator.REVERSE
                invalidate()
            }
            start()
        }
    }

    fun hideLoading() {
        isVisible = false
        valueAnimator?.end()
    }

    private fun init(context: Context, attributeSet: AttributeSet?) {
        paint = Paint().apply {
            color = ContextCompat.getColor(context, R.color.teal_700)
            style = Paint.Style.STROKE
            strokeWidth = 20F
        }
    }

    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)
        canvas.drawCircle(width / 2F, height / 2F, circleRadius, paint)
    }
}

circleRadius의 값을 10에서 100까지 1초 내에서 점진적으로 증가시킬 것입니다. ValueAnimator가 이 역할을 수행하며 각각의 변화마다 addUpDateListener에서 invalidate를 호출합니다.

 

 

showLoading()과 hideLoading()을 실행시킬 버튼 두개를 추가하겠습니다.

<?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"
    android:padding="16dp"
    tools:context=".animation.customLoadingView.CustomLoadingViewActivity">

    <Button
        android:id="@+id/showLoading"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Show Loading"
        app:layout_constraintEnd_toStartOf="@+id/hideLoading"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <Button
        android:id="@+id/hideLoading"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="hide Loading"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toEndOf="@+id/showLoading"
        app:layout_constraintTop_toTopOf="parent" />

    <com.jinhanexample.animation.customLoadingView.LoadingView
        android:id="@+id/loading"
        android:layout_width="100dp"
        android:layout_height="100dp"
        android:layout_marginTop="24dp"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/showLoading" />


</androidx.constraintlayout.widget.ConstraintLayout>

 

activity에서 함수 추가

class CustomLoadingViewActivity : AppCompatActivity() {

    private lateinit var ui: ActivityCustomLoadingViewBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        ui = ActivityCustomLoadingViewBinding.inflate(layoutInflater).apply { setContentView(root) }
        ui.showLoading.setOnClickListener { ui.loading.showLoading() }
        ui.hideLoading.setOnClickListener { ui.loading.hideLoading() }
    }
}

 

 

앱을 실행시켜보면 애니메이션Loader를 볼 수 있습니다.

 

 

 

 

 

Android Custom View Custom Attributes

현재는 커스텀뷰의 테두리 컬러, 너비, 반경이 하드코딩 되어있습니다. 이것들을 커스텀 속성으로 동적으로 만들 수 있습니다. 이를 위해서 attr.xml에 원하는 속성을 추가해야합니다.

<?xml version="1.0" encoding="utf-8"?>
<resources>
  <declare-styleable name="LoadingView">
    <attr name="lv_color" format="color"/>
  </declare-styleable>
</resources>

원의 컬러를 담당하는 lv_color 속성을 선언했습니다. 이름이 충돌하는 것을 피하기 위해 속성에 고유한 접두사를 붙이는 것이 좋습니다. 일반적으로 커스텀뷰의 약어가 사용됩니다. 

 

이렇게 attr.xml에 추가한 속성들은 생성자를 통해 전달되기 때문에 init()함수에서 속성을 추가해보겠습니다.

   private fun init(context: Context, attributeSet: AttributeSet?) {

        val typedArray = context.obtainStyledAttributes(attributeSet, R.styleable.LoadingView)
        val loadingColor = typedArray.getColor(
            R.styleable.LoadingView_lv_color,
            ContextCompat.getColor(context, R.color.teal_700)
        )

        paint = Paint().apply {
//            color = ContextCompat.getColor(context, R.color.teal_700)
            color = loadingColor
            style = Paint.Style.STROKE
            strokeWidth = 20F
        }

        typedArray.recycle()
    }

속성 세팅이 끝나면 recycle()함수를 반드시 호출해주세요

(Don’t forget to call recycle() when you’re done with attributes because this data is not needed anymore) 

 

 

 

이제 색상을 변경하고 싶을 떄 커스텀뷰(클래스)를 손볼 필요가 없습니다. xml에 새로 추가한 속성을 사용하세요

    <com.jinhanexample.animation.customLoadingView.LoadingView
        android:id="@+id/loading"
        android:layout_width="100dp"
        android:layout_height="100dp"
        android:layout_marginTop="24dp"
        android:visibility="gone"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/showLoading"
        app:lv_color="@color/brand_purple" />

 

 

 

Android Custom View Summary

이 글에서 안드로이드 커스텀 뷰가 얼마나 강력한지 살펴보았습니다. 원하는 모든 것을 만들어 낼 수 있고, 한계는 오직 당신의 상상력입니다(혹은 디자이너의 상상력).

 

원하는 모든 것을 그려서 애니메이션으로 생동감을 불어넣고, 속성을 이용해 꾸며보세요.