CustomView를 만드는 이유
Android에는 UI를 구성하는 데 사용할 수 있는 미리 빌드된 기본 클래스인 View 및 ViewGroup과 다양한 서브클래스(위젯 및 레이아웃)가 있습니다. 어플리케이션의 UI 요구사항에 요청하는 위젯의 기능과 UI가 안드로이드에서 기본으로 제공하는 위젯이 제공을 한다면 위젯을 골라서 사용하면 되겠지만, 기본으로 제공하는 위젯이 요구사항에 맞지 않다면 CustomView
를 직접 만들어야 합니다.
CustomView의 장점으로는 매번 레이아웃으로 일일히 만드는 것 보다 CustomView를 만드는 것이 앱의 생산성에 도움이 됩니다. CustomView를 만들기 위해서는 View
및 ViewGroup
혹은 안드로이드에서 제공하는 위젯을 상속받고 메소드와 레이아웃을 상속받아서 원하는 UI를 구축할 수 있습니다.
안드로이드에서 기본으로 제공하는 위젯으로는 Button, TextView, EditText, ListView, CheckBox, RadioButton 등등 여러가지가 존재하고 사용 가능한 레이아웃으로는 LinearLayout, FrameLayout, RelativeLayout 등이 있습니다.
View의 생명주기
- Constructor
- CustomView(Context context)(코드상에서 호출한 경우)
- CustomView(Context context, AttributeSet attrs)(xml에서 생성했다면)
- onFinishInflate(xml에서 생성했다면)
- onAttachedToWindow
- measure
- onMeasure
- onSizeChanged
- layout
- onLayout
- onDraw
먼저, 뷰의 라이프사이클을 알아보기 위해 로그를 찍어보겠습니다.
package com.seosh817.example
import android.content.Context
import android.graphics.Canvas
import android.graphics.Paint
import android.util.AttributeSet
import android.util.Log
import android.view.View
import androidx.core.content.ContextCompat
import kotlin.math.min
class CustomView : View {
constructor(context: Context) : super(context) {
printMethodNameWithMeasuredSize("constructor called from code")
}
constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, R.styleable.CustomView_myCustomViewStyle) {
printMethodNameWithMeasuredSize("constructor called from xml")
}
constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) {
getAttrs(attrs, defStyleAttr)
}
private fun getAttrs(attrs: AttributeSet?, defStyleAttr: Int) {
// ...
}
override fun onFinishInflate() {
super.onFinishInflate()
printMethodNameWithMeasuredSize("onFinishInflate")
}
override fun onAttachedToWindow() {
super.onAttachedToWindow()
printMethodNameWithMeasuredSize("onAttachedToWindow")
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
printMeasureSpec("widthMeasureSpec", MeasureSpec.getMode(widthMeasureSpec), MeasureSpec.getSize(widthMeasureSpec))
printMeasureSpec("heightMeasureSpec", MeasureSpec.getMode(heightMeasureSpec), MeasureSpec.getSize(heightMeasureSpec))
}
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
printMethodNameWithMeasuredSize("onSizeChanged")
}
override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
super.onLayout(changed, left, top, right, bottom)
printMethodNameWithMeasuredSize("onLayout")
}
override fun onDraw(canvas: Canvas?) {
super.onDraw(canvas)
printMethodNameWithMeasuredSize("onDraw")
}
private fun printMeasureSpec(type: String, specMode: Int, specSize: Int) {
when (specMode) {
MeasureSpec.EXACTLY -> {
Log.d(TAG, "onMeasure $type: EXACTLY, specSize: $specSize")
}
MeasureSpec.UNSPECIFIED -> {
Log.d(TAG, "onMeasure $type: UNSPECIFIED, specSize: $specSize")
}
MeasureSpec.AT_MOST -> {
Log.d(TAG, "onMeasure $type: AT_MOST, specSize: $specSize")
}
else -> throw IllegalArgumentException("Illegal specMode")
}
}
private fun printMethodNameWithMeasuredSize(methodName: String) {
Log.i(TAG, "$methodName called, measured size: ($measuredWidth, $measuredHeight)")
}
companion object {
val TAG: String = CustomView::class.java.simpleName
}
}
실행결과
com.seosh817.example I/CustomView: constructor called from xml called, measured size: (0, 0)
com.seosh817.example I/CustomView: onFinishInflate called, measured size: (0, 0)
com.seosh817.example I/CustomView: onAttachedToWindow called, measured size: (0, 0)
com.seosh817.example D/CustomView: onMeasure widthMeasureSpec: EXACTLY, specSize: 263
com.seosh817.example D/CustomView: onMeasure heightMeasureSpec: AT_MOST, specSize: 1884
com.seosh817.example D/CustomView: onMeasure widthMeasureSpec: EXACTLY, specSize: 263
com.seosh817.example D/CustomView: onMeasure heightMeasureSpec: AT_MOST, specSize: 1884
com.seosh817.example I/CustomView: onSizeChanged called, measured size: (263, 1884)
com.seosh817.example I/CustomView: onLayout called, measured size: (263, 1884)
com.seosh817.example D/CustomView: changed: true, left: 0, top: 0, right: 263, bottom: 1884
com.seosh817.example I/CustomView: onDraw called, measured size: (263, 1884)
이제 뷰의 Lifecycle 순서대로 하나씩 알아보도록 하겠습니다.
Constructor
View(Context context)
코드에서 동적으로 생성할 때 호출되는 생성자입니다.
View(Context context, AttributeSet attrs)
Xml에서 View를 inflate 했을 때 호출되는 생성자입니다.
View(Context context, AttributeSet attrs, int defStyleAttr)
자체 ThemeStyle을 사용하고자 할 때 호출되는 생성자입니다. style을 기본값으로 지정하여 View에서 기본값으로 제공하고자 하는 속성들을 정의할 수 있습니다. default값은 0이며, 기본값을 지정하고 싶지 않을 경우에는 값으로 0을 주면 됩니다.
@JvmOverloads
를 이용할 경우는 아래와 같이 구현하면 됩니다.
class CustomView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = R.styleable.CustomView_myCustomViewStyle
) : View(context, attrs, defStyleAttr) {
// ...
}
onFinishInflate
xml을 통해 뷰를 인플레이트 했을 때 호출됩니다.
onAttachWindow
View가 window에 붙었을 때 호출됩니다. 이 함수의 호출 시점에 Surface가 생기며 그리기를 시작합니다.
measure
measure
은 뷰가 얼마나 커야 하는지 크기를 요청합니다. 안드로이드의 View는 반드시 ViewGroup에 속해 있습니다. 그러므로 View는 자신의 크기를 결정하기 위해 자신을 담고있는 부모 뷰의 크기를 알아야 하고 그 안에서 자기가 사용할 영역을 알아야 합니다. 그러므로 크기를 요청하는 단계가 measure
이고 실제 측정은 onMeasure
에서 이루어집니다.
View 객체의 측정된 너비와 높이 값은 부모 뷰가 부여한 제약 조건을 준수해야합니다. 그러면 measure 단계가 모두 종료되고 모든 상위 뷰들과 자식뷰들의 측정값을 모두 수락하는 단계를 거칩니다.
그러므로 부모 뷰는 자식 뷰들을 대상으로 2번 이상의 measure()을 호출할 수 있습니다. 예를 들면, 부모 뷰가 정해지지 않은 자식 뷰의 크기를 측정한 다음, 각 자식 뷰들의 크기가 너무 크거나 작으면 measure()을 다시 호출할 수도 있습니다.
measure의 파라미터에는 MeasureSpec
을 전달합니다.
MeasureSpec
부모뷰에서 자식뷰에게 전달하는 레이아웃 요구사항의 캡슐화 형태입니다.
public static class MeasureSpec {
private static final int MODE_SHIFT = 30;
private static final int MODE_MASK = 0x3 << MODE_SHIFT;
@IntDef({UNSPECIFIED, EXACTLY, AT_MOST})
@Retention(RetentionPolicy.SOURCE)
public @interface MeasureSpecMode {}
public static final int UNSPECIFIED = 0 << MODE_SHIFT;
public static final int EXACTLY = 1 << MODE_SHIFT;
public static final int AT_MOST = 2 << MODE_SHIFT;
public static int makeMeasureSpec(@IntRange(from = 0, to = (1 << MeasureSpec.MODE_SHIFT) - 1) int size,
@MeasureSpecMode int mode) {
if (sUseBrokenMakeMeasureSpec) {
return size + mode;
} else {
return (size & ~MODE_MASK) | (mode & MODE_MASK);
}
}
@UnsupportedAppUsage
public static int makeSafeMeasureSpec(int size, int mode) {
if (sUseZeroUnspecifiedMeasureSpec && mode == UNSPECIFIED) {
return 0;
}
return makeMeasureSpec(size, mode);
}
public static int getMode(int measureSpec) {
//noinspection ResourceType
return (measureSpec & MODE_MASK);
}
public static int getSize(int measureSpec) {
return (measureSpec & ~MODE_MASK);
}
static int adjust(int measureSpec, int delta) {
final int mode = getMode(measureSpec);
int size = getSize(measureSpec);
if (mode == UNSPECIFIED) {
// No need to adjust size for UNSPECIFIED mode.
return makeMeasureSpec(size, UNSPECIFIED);
}
size += delta;
if (size < 0) {
Log.e(VIEW_LOG_TAG, "MeasureSpec.adjust: new size would be negative! (" + size +
") spec: " + toString(measureSpec) + " delta: " + delta);
size = 0;
}
return makeMeasureSpec(size, mode);
}
}
MODE_MASK는 2진수 11을 왼쪽으로 30만큼 시프트 연산한 값이고, MODE_MASK를 통해 AND 연산을 진행하여 int 타입의 MeasureSpec
32비트는 상위 2비트는 SpecMode, 하위 30비트는 SpecSize를 사용하도록 만들었습니다.
메소드 MeasureSpec.getSize()와 MesureSpec.getMode()는 비트연산을 통해 사이즈를 가져옵니다.
MeasureSpec
을 만들때에도 비트연산을 해줄 필요없이 제공하는 메소드를 이용하여 MeasureSpec.makeMeasureSpec()을 사용하여 Size와 Mode를 지정해주면 됩니다.
MeasureSpec은 세 가지의 Mode가 있습니다.
MeasureSpec.EXACTLY
-> 와 layout_height
의 값이 특정한 값으로 설정되었을 때를 의미합니다. 또한, match_parent일 때도 트리거되며, 이 때는 부모 뷰의 크기로 설정하려고 합니다.
MeasureSpec.AT_MOST
-> layout_width와 layout_height의 값이 match_parent 혹은 wrap_content로 설정 되고 뷰가 최대크기를 요구할 때를 의미합니다. 일반적으로 부모 뷰의 크기를 요구하며 부모 뷰의 크기보다 클 수 없습니다. 또한, 뷰는 뷰와 모든 자식 뷰들이 이 크기에 맞게 보장해야합니다. 만약, 부모 뷰의 크기보다 더 크게 설정하거나 최소 크기보다 작다면 측정과정이 다시 발생할 수 있습니다. 또한, MeasureSpec.getSize()는 부모 뷰의 크기를 가집니다. 그러므로, 디폴트 값을 지정해주는 것이 바람직합니다.
MeasureSpec.UNSPECIFIED
-> layout_width와 layout_height의 값이 wrap_content이고 부모 뷰가 자식 뷰에게 어떠한 제약도 가하지 않았을 경우에 해당합니다. 제약이 없으므로 원하는 어떠한 크기로도 설정할 수 있습니다. 일부 레이아웃(부모 뷰)는 자식뷰의 SpecSize의 크기를 결정하기 위해 측정과정이 다시 발생할 수 있습니다. 즉, 자식 뷰 각자가 차지하는 공간에 대해 서로 동의하지 않으면 부모 뷰가 개입하여 두 번째 패스에 대한 규칙을 설정합니다. 예를 들면, 부모 뷰가 ScrollView인 경우가 해당할 수 있습니다.
onMeasure
onMeasure
은 뷰의 크기를 측정합니다. 그러므로 이 메소드는 measure() 메소드에 의해 호출되고 정확한 측정값을 제공하기 위해 재정의해야 합니다. 측정값을 정하기 전에는 너비와 높이가 0입니다.
onMeasure()의 setMeasuredDimension()에서 measuredWidth와 measuredHeight을 결정합니다.
* 그러므로, 메소드를 재정의 할 때 setMeasuredDimension(int, int) 혹은 super.onMeasure(int, int)를 무조건 호출해야 합니다. 호출하지 않으면 IllegalStateException을 던집니다.
여기서 뷰의 최소 너비와 높이를 알려면 getSuggestedMinimumWidth()과 getSuggestedMinimumHeight()을 통해 알 수 있습니다.
뷰의 크기는 적어도 getSuggestedMinimumWidth() 혹은 getSuggestedMinimumHeight() 보다 커야합니다.
그럼 코드를 살펴보겠습니다.
View.onMeasure
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
setMeasuredDimension(getDefaultSize(getSuggestedMinimumWidth(), widthMeasureSpec),
getDefaultSize(getSuggestedMinimumHeight(), heightMeasureSpec));
}
내부적으로 setMeasureDimension()을 호출하고 있고 width와 height을 getDefaultSize 메소드를 통해 설정하고 있습니다.
getDefaultSize에서는 getSuggestedMinimumWidth로 반환된 값과 MeasureSpec(측정된 사이즈)를 넘겨주고 있습니다.
View.getDefaultSize
public static int getDefaultSize(int size, int measureSpec) {
int result = size;
int specMode = MeasureSpec.getMode(measureSpec);
int specSize = MeasureSpec.getSize(measureSpec);
switch (specMode) {
case MeasureSpec.UNSPECIFIED:
result = size;
break;
case MeasureSpec.AT_MOST:
case MeasureSpec.EXACTLY:
result = specSize;
break;
}
return result;
}
getDefaultSize에서는 UNSPECIFIED라면 넘겨받은 size(View.onMeasure에서는 getSuggestedMinimumWidth())로 반환 해주고, AT_MOST와 EXACTLY라면 측정된 값으로 반환해 줍니다.
View.getSuggestedMinimumWidth
protected int getSuggestedMinimumWidth() {
return (mBackground == null) ? mMinWidth : max(mMinWidth, mBackground.getMinimumWidth());
}
mBackground가 null이라면 mMinWidth를 반환해주고 setBackground()로 background를 Drawable로 지정해주었다면 Drawable의 intrinsicWidth과 mMinWidth 중 큰 값을 최소값으로 정해주고 있습니다. 이 최소 크기는 픽셀단위입니다.
ex) NinePatchDrawable.getIntrinsicWidth
@Override
public int getIntrinsicWidth() {
return mBitmapWidth;
}
IntrinsicWidth는 Drawable마다 다르게 구현되어있고 예를들어 NinePatchDrawable같은 경우는 비트맵 너비를 반환해주고 있습니다.
onSizeChanged
onSizeChanged
는 측정 과정이 끝나고 뷰의 크기가 할당 되면 최초 호출되고 그 이후에 뷰의 크기가 변경되면 호출됩니다.
그러므로, 측정 과정이 필요 없다면 onSizeChanged
에서 처리해주면 됩니다.
뷰에 크기가 할당되면 레이아웃 매니저는 크기에 모든 뷰의 패딩이 포함되어 있다고 가정합니다. 뷰의 크기를 계산할 때 패딩 값을 처리해야 합니다.
간단하게 패딩값 처리를 하고 반지름을 구하는 것을 코드로 구현해보겠습니다.
class CustomView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {
var radius: Float = 0f
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
val xPadding = (paddingLeft + paddingRight).toFloat()
val yPadding = (paddingTop + paddingBottom).toFloat()
val viewWidth = width.toFloat() - xPadding
val viewHeight = height.toFloat() - yPadding
radius = min(viewWidth, viewHeight) / 2
}
}
layout
layout
은 View와 모든 자식 뷰들의 위치를 요청합니다.
각 파라미터는 부모에 대한 상대 위치입니다.
onLayout
onLayout
은 layout()메소드에 의해 호출되며 View의 자식들의 크기와 위치를 정합니다.
각 파라미터는 부모에 대한 상대 위치입니다.
만약, requestLayout()이 호출이 되었는데 뷰의 크기는 변경되지 않았다면 onSizeChanged보다 자주 호출될 수 있습니다.
onDraw
onDraw
는 뷰를 그리는 단계입니다.
파라미터로는 Canvas객체가 넘어오고 Canvas객체에 Paint를 이용해서 텍스트, 선, 비트맵 등을 그리기 위한 메서드를 정의합니다.
즉, Canvas는 어떤 도형을 그릴 것인지에 대한 메소드를 제공하고, Paint는 색상, 스타일, 글꼴 등을 정의합니다.
canvas에 원을 그리는 것을 간단하게 아래처럼 구현할 수 있습니다.
class CustomView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {
var circlePaint = Paint().apply {
isAntiAlias = true
style = Paint.Style.FILL
color = ContextCompat.getColor(context, R.color.azure)
}
override fun onDraw(canvas: Canvas?) {
super.onDraw(canvas)
canvas?.drawCircle((width / 2).toFloat(), (height / 2).toFloat(), radius, circlePaint)
}
}
requestLayout
뷰의 크기에 대한 측정부터 다시 시작하고 싶은 경우 requestLayout
을 호출하면 됩니다. (measure부터 다시 시작)
invalidate
측정은 필요하지않고 다시 그리기만 하고 싶은 경우 invalidate
를 호출하면 됩니다. (dispatchDraw부터 다시 시작)
References
https://developer.android.com/training/custom-views/create-view?hl=ko
https://tech.burt.pe.kr/android/view/measure-android-custom-view