Custom View는 View 클래스 또는 그 하위 클래스(Button, TextView, ImageView)를 상속하고 주요 메서드(onDraw 그리기, onMeasure 크기 측정, onTouchEvent 터치 처리)를 재정의하여 생성되는 Android의 사용자 정의 인터페이스 구성 요소입니다. Google Android Developer Guide(2024)에 따르면, 표준 Android SDK 구성 요소가 필요한 동작이나 모양을 제공하지 않을 때(예: 사용자 정의 애니메이션, 비표준 모양, 특수 차트, 게임 요소) Custom View가 사용됩니다.
핵심 포인트
Custom View는 android.view.View(또는 그 하위 클래스 중 하나)를 상속하고 시스템 메서드를 재정의하여 사용자 정의 그리기, 측정 및 이벤트 처리 로직을 구현하는 클래스입니다. Custom View는 고유한 UI 구성 요소를 만들기 위한 Android의 기본 메커니즘입니다.
Android는 Custom View를 만드는 두 가지 접근 방식을 제공합니다: View 상속(onDraw를 통한 완전 사용자 정의 그리기)과 기존 View 하위 클래스 상속(예: 기본 동작을 유지하면서 기능을 추가하기 위해 Button 또는 TextView 재정의).
기본 생성자는 Context와 AttributeSet을 받아야 합니다 — 이를 통해 시스템이 XML 마크업에서 View를 생성할 수 있습니다. View가 코드에서만 사용되는 경우 Context만 있는 생성자로 충분합니다. 스타일이 있는 세 번째 생성자는 Android 테마 지원에 필요합니다.
class CustomView
@JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {
private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.BLUE
strokeWidth = 4f
style = Paint.Style.FILL
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
canvas.drawCircle(width / 2f,
height / 2f,
50f, paint)
}
}
최소 Custom View는 생성자와 onDraw로 구성됩니다. 그러나 Android Layout 시스템에서 올바르게 작동하려면 onMeasure도 재정의해야 합니다 — 그렇지 않으면 View가 0 크기로 표시되거나 wrap_content 레이아웃 매개변수에 잘못 반응할 수 있습니다.
Google I/O 2023에 따르면, Google Play 상위 100개 앱의 65%에서 사용자 정의 View가 사용됩니다. Custom View를 만드는 가장 일반적인 이유: 사용자 정의 애니메이션(35%), 사용자 정의 차트 및 다이어그램(25%), 특수 제어 요소(20%), 브랜드 구성 요소(20%).
onMeasure는 수신된 MeasureSpec(부모 제약 조건)을 기반으로 View가 원하는 치수를 보고하는 메서드입니다. 적절한 onMeasure 구현이 없으면 View는 wrap_content에서 0 높이를 가지거나 match_parent에서 전체 화면 영역을 차지할 수 있습니다.
MeasureSpec은 모드(EXACTLY, AT_MOST, UNSPECIFIED)와 값으로 구성됩니다. EXACTLY — 부모가 정확한 크기를 설정함(match_parent 또는 고정 값). AT_MOST — 부모가 최대값을 설정함(wrap_content). UNSPECIFIED — 제한 없음(ScrollView, ListView).
override fun onMeasure(
widthMeasureSpec: Int,
heightMeasureSpec: Int
) {
val desiredWidth = paddingLeft + paddingRight
+ DEFAULT_WIDTH
val desiredHeight = paddingTop + paddingBottom
+ DEFAULT_HEIGHT
val measuredWidth = MeasureSpec.getSize(widthMeasureSpec)
val modeWidth = MeasureSpec.getMode(widthMeasureSpec)
val resultWidth = when (modeWidth) {
MeasureSpec.EXACTLY -> measuredWidth
MeasureSpec.AT_MOST ->
desiredWidth.coerceAtMost(measuredWidth)
else -> desiredWidth
}
val resultHeight = resolveSize(desiredHeight,
heightMeasureSpec)
setMeasuredDimension(resultWidth, resultHeight)
}
resolveSize는 onMeasure 구현을 단순화하는 Android 유틸리티 메서드입니다. 원하는 크기와 MeasureSpec을 받아 올바른 값을 반환합니다: EXACTLY의 경우 정확한 크기, AT_MOST의 경우 원하는 값과 최대값 중 최소값, UNSPECIFIED의 경우 원하는 크기.
onLayout은 View(ViewGroup 아님)의 경우 일반적으로 재정의되지 않습니다 — 자식 요소를 배치하기 위해 부모가 호출합니다. ViewGroup의 경우 onLayout은 필수입니다 — 각 자식 View에 대해 layout()을 호출해야 합니다.
onDraw는 Custom View의 핵심입니다. 이 메서드는 View를 다시 그릴 때마다 Android에서 호출됩니다. Canvas 객체가 전달되며, 이를 통해 모든 그리기(기하학적 도형, 텍스트, 경로, Bitmap, 애니메이션)가 수행됩니다.
Canvas API는 모든 기본 작업에 대한 메서드를 제공합니다: drawCircle, drawRect, drawLine, drawPath, drawText, drawBitmap, drawArc, drawOval. 스타일을 구성하려면 색상, 두께, 채우기 스타일, 글꼴, 그림자 및 효과를 정의하는 객체인 Paint가 사용됩니다.
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
// 배경
canvas.drawRect(0f, 0f,
width.toFloat(), height.toFloat(), bgPaint)
// 텍스트
canvas.drawText("Custom View",
paddingLeft.toFloat(),
paddingTop.toFloat() + textPaint.textSize,
textPaint)
// 원
canvas.drawCircle(
width / 2f,
height / 2f,
radius, circlePaint)
// 경로(베지어 곡선)
val path = Path().apply {
moveTo(0f, height.toFloat())
quadTo(width / 2f, 0f,
width.toFloat(), height.toFloat())
}
canvas.drawPath(path, pathPaint)
}
Paint는 그리기 스타일 구성자입니다. 안티앨리어싱(Paint.ANTI_ALIAS_FLAG)은 부드러운 가장자리에 필수입니다. 서브픽셀 텍스트(SUBPIXEL_TEXT_FLAG)는 텍스트 품질을 향상시킵니다. Style.FILL, STROKE 및 FILL_AND_STROKE는 도형을 채울지, 윤곽선을 그릴지 또는 둘 다 할지를 결정합니다.
중요: onDraw에서 Paint, Path, Rect 객체를 만들지 마십시오 — 그리기 루프에서 할당이 발생하고 가비지 컬렉션을 유발하여 FPS가 저하됩니다. 모든 그리기 객체는 생성자나 init 메서드에서 만드십시오. 애니메이션의 경우 ValueAnimator 또는 ObjectAnimator를 invalidate()와 함께 사용하여 다시 그리기를 트리거하십시오.
사용자 정의 속성을 사용하면 표준 android:layout_width 또는 android:background와 마찬가지로 XML 마크업에서 Custom View를 구성할 수 있습니다. 속성은 res/values/attrs.xml 파일에서 이름, 유형 및 선택적 기본값과 함께 선언됩니다.
속성 유형: string, integer, float, boolean, color, dimension, enum, flag, fraction, reference(리소스 참조). 각 유형에 대해 Android는 자동으로 XML에서 값을 구문 분석하여 TypedArray에 전달합니다.
<!-- res/values/attrs.xml -->
<resources>
<declare-styleable name="CustomView">
<attr name="circleColor"
format="color" />
<attr name="circleRadius"
format="dimension" />
<attr name="labelText"
format="string" />
<attr name="showAnimation"
format="부울" />
</declare-styleable>
</resources>
Custom View 생성자에서 속성은 context.obtainStyledAttributes를 통해 읽히며 TypedArray가 반환됩니다. TypedArray는 유형화된 접근 메서드(getColor, getDimension, getString, getBoolean, getInt)를 제공합니다. 읽기 후 리소스를 해제하기 위해 TypedArray에서 recycle()을 호출해야 합니다.
init {
val typedArray = context.obtainStyledAttributes(
attrs,
R.styleable.CustomView
)
circleColor = typedArray.getColor(
R.styleable.CustomView_circleColor,
Color.BLUE
)
circleRadius = typedArray.getDimension(
R.styleable.CustomView_circleRadius,
50f
)
labelText = typedArray.getString(
R.styleable.CustomView_labelText
) ?: "Default"
typedArray.recycle()
}
XML에서 사용: app 네임스페이스를 추가하고(xmlns:app="http://schemas.android.com/apk/res-auto") app:circleColor="@color/red"와 같이 사용자 정의 속성을 사용합니다. attrs.xml의 선언이 올바르면 Android Studio가 자동 완성하고 속성 유형을 검증합니다.
onTouchEvent는 View를 터치할 때마다 호출되는 메서드입니다. 이벤트 유형(ACTION_DOWN, ACTION_MOVE, ACTION_UP, ACTION_CANCEL), 좌표, 압력 및 손가락 수(멀티터치)에 대한 정보가 포함된 MotionEvent 객체를 받습니다.
복잡한 제스처(스와이프, 핀치, 길게 누르기)를 처리하려면 onTouchEvent와 함께 GestureDetector 또는 ScaleGestureDetector를 사용하십시오. GestureDetector는 onSingleTapUp, onFling, onLongPress, onDoubleTap 및 기타 표준 제스처 인식을 단순화합니다.
private val gestureDetector = GestureDetector(
context, object : GestureDetector.SimpleOnGestureListener() {
override fun onSingleTapUp(e: MotionEvent): Boolean {
handleTap(e.x, e.y)
return true
}
})
override fun onTouchEvent(event: MotionEvent): Boolean {
val handled = gestureDetector.onTouchEvent(event)
when (event.action) {
MotionEvent.ACTION_MOVE -> {
currentX = event.x
currentY = event.y
invalidate()
return true
}
}
return handled || super.onTouchEvent(event)
}
히트 테스팅 — 터치가 View의 특정 영역에 닿았는지 확인합니다. 직사각형 영역의 경우 Rect.contains(x, y)를 사용합니다. 원의 경우 중심으로부터의 거리 확인: sqrt(dx^2 + dy^2) < radius. 임의 모양의 경우 Path.op() 또는 Region.contains().
멀티터치는 MotionEvent.getPointerCount() 및 getPointerId(index)를 통해 처리됩니다. 각 손가락은 ACTION_DOWN부터 ACTION_POINTER_UP까지 유지되는 고유 ID를 받습니다. ACTION_MOVE는 모든 활성 손가락의 데이터를 포함할 수 있습니다 — 움직임 보간에 getHistoricalX/Y를 사용하십시오.
Custom View는 onDraw가 자주 호출되거나(애니메이션, 스크롤) 무거운 작업을 포함하는 경우 성능 병목 현상이 될 수 있습니다. Android는 사용자 정의 구성 요소 렌더링을 최적화하기 위한 여러 메커니즘을 제공합니다.
View.setLayerType을 사용하면 View 렌더링을 소프트웨어 레이어(LAYER_TYPE_SOFTWARE) 또는 하드웨어 레이어(LAYER_TYPE_HARDWARE)로 전환할 수 있습니다. 소프트웨어 레이어는 하드웨어 가속에서 지원되지 않는 복잡한 Canvas 그래픽(drawTextOnPath 또는 복잡한 Path 효과)에 유용합니다.
invalidate(Rect)는 전체 구성 요소가 아닌 View의 지정된 영역만 다시 그립니다. 이는 큰 Custom View(그래픽, 지도, 그리기 캔버스)에서 매우 중요하며, 매 프레임 전체를 다시 그리면 FPS가 떨어집니다. vsync 동기화를 위해 postInvalidateOnAnimation()을 사용하십시오.
class EfficientCustomView
@JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null
) : View(context, attrs) {
// 생성자에서 한 번 생성된 객체
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
private val rect = Rect()
private val position = PointF()
fun updatePosition(x: Float, y: Float) {
position.set(x, y)
// 포인트 주변 영역만 다시 그리기
rect.set(
(x - 10).toInt(),
(y - 10).toInt(),
(x + 10).toInt(),
(y + 10).toInt()
)
invalidate(rect)
}
}
하드웨어 가속은 Android 3.0+(API 11+)에서 기본적으로 활성화됩니다. Canvas 작업 drawCircle, drawRect, drawBitmap은 하드웨어 가속되며 GPU에서 실행됩니다. 그러나 drawTextOnPath, drawVertices, 복잡한 클리핑 작업은 가속되지 않습니다 — 이들에 대해서는 LAYER_TYPE_SOFTWARE를 사용하십시오.
Android Performance Patterns(Google, 2023)에 따르면 Custom View 지연의 주요 원인은 onDraw에서의 할당(매 프레임 객체 생성), 한 요소 변경 시 전체 View 다시 그리기, 정적 콘텐츠에 대한 LayerType.HARDWARE 부재입니다. 이 세 가지 문제를 해결하면 대부분의 시나리오에서 FPS가 30에서 60으로 향상됩니다.
| 메서드 | 목적 | 호출 빈도 |
|---|---|---|
| onDraw | View 콘텐츠 그리기 | 각 invalidate() 시 |
| onMeasure | View 크기 결정 | 레이아웃 변경 시 |
| onLayout | 자식 View 배치 | onMeasure 후 |
| onTouchEvent | 터치 처리 | 각 터치 시 |
| onSizeChanged | 크기 변경에 반응 | 첫 레이아웃 및 변경 시 |
자주 묻는 질문
Custom View(Canvas API)는 최대 사용자 정의 그리기 성능(그래픽, 비디오 편집기, 지도)이 필요하거나, 기존 View 기반 코드와의 통합, 또는 API 21 미만 Android 버전 지원이 필요할 때 적합합니다. Jetpack Compose는 대부분의 새 프로젝트에 현대적인 접근 방식이며, 선언적 UI를 사용하고 동적 인터페이스에 더 적합합니다.
네, View가 XML 레이아웃에서 wrap_content를 사용하는 경우 필수입니다. onMeasure가 없으면 기본 View.onMeasure 구현이 기본 크기를 설정하지 않기 때문에 wrap_content가 match_parent처럼 작동합니다. View가 항상 고정 크기나 match_parent를 가지는 경우 onMeasure를 재정의하지 않아도 됩니다.
ValueAnimator 또는 ObjectAnimator를 사용하여 View 속성(색상, 반지름, 위치)을 변경하고 애니메이션 콜백에서 invalidate()를 호출하여 다시 그립니다. ValueAnimator는 메인 스레드에서 실행되며 vsync와 동기화됩니다. 복잡한 애니메이션(물리, 입자)의 경우 Choreographer.FrameCallback 또는 Android Animation Framework를 사용하십시오.
onTouchEvent에서 MotionEvent.getPointerCount()를 사용하여 손가락 수를 확인하고 getPointerId(i)를 사용하여 각 손가락을 고유 ID로 추적합니다. ACTION_POINTER_DOWN / ACTION_POINTER_UP — 손가락 추가/제거 이벤트입니다. 핀치 투 줌의 경우 onTouchEvent와 함께 ScaleGestureDetector를 사용하십시오 — 핀치 감지를 단순화합니다.
네, AndroidView를 통해 가능합니다 — View 기반 요소를 Compose 계층 구조에 삽입하는 컴포저블 함수입니다. AndroidView는 View 생성을 위한 팩토리와 상태 동기화를 위한 update 콜백을 받습니다. 이를 통해 완전한 재작성 없이 기존 Custom View를 새 Compose 프로젝트에서 재사용할 수 있습니다.
요약
턴키 방식의 모바일 애플리케이션을 개발해 드립니다
IT Sectr는 2017년부터 스타트업과 기업을 위한 iOS 및 Android 애플리케이션을 만듭니다. 저희가 상담해 드리고 최적의 솔루션을 제안하겠습니다.