커스텀 뷰는 안드로이드에서 제공하는 기본 View나 ViewGroup을 상속받아 개발자가 직접 만드는 View이다. 기본 위젯으로는 표현할 수 없는 독특한 UI나 동작이 필요할 때 사용한다.
커스텀 뷰를 만드는 방법은 크게 세 가지가 있다.
가장 간단한 방법으로, 기존 View의 기능을 일부 수정하거나 확장할 때 사용한다.
class CustomButton(context: Context, attrs: AttributeSet?) : AppCompatButton(context, attrs) {
init {
// 초기화 작업
setBackgroundColor(Color.BLUE)
setTextColor(Color.WHITE)
}
override fun performClick(): Boolean {
// 클릭 동작 커스터마이징
return super.performClick()
}
}
여러 View를 조합해서 새로운 컴포넌트를 만들 때 사용한다.
class CustomToolbar(context: Context, attrs: AttributeSet?) : LinearLayout(context, attrs) {
private lateinit var titleTextView: TextView
private lateinit var backButton: ImageButton
init {
orientation = HORIZONTAL
inflate(context, R.layout.custom_toolbar, this)
titleTextView = findViewById(R.id.toolbar_title)
backButton = findViewById(R.id.toolbar_back)
// 속성 처리
attrs?.let {
val typedArray = context.obtainStyledAttributes(it, R.styleable.CustomToolbar)
val title = typedArray.getString(R.styleable.CustomToolbar_toolbarTitle)
titleTextView.text = title
typedArray.recycle()
}
}
fun setTitle(title: String) {
titleTextView.text = title
}
}
완전히 새로운 UI를 그려야 할 때 사용한다. 가장 복잡하지만 자유도가 높다.
class CircleProgressView(context: Context, attrs: AttributeSet?) : View(context, attrs) {
private var progress = 0f
private var maxProgress = 100f
private var progressColor = Color.BLUE
private var backgroundColor = Color.GRAY
private val paint = Paint().apply {
isAntiAlias = true
style = Paint.Style.STROKE
strokeWidth = 20f
}
init {
// XML 속성 읽기
attrs?.let {
val typedArray = context.obtainStyledAttributes(it, R.styleable.CircleProgressView)
progress = typedArray.getFloat(R.styleable.CircleProgressView_progress, 0f)
progressColor = typedArray.getColor(R.styleable.CircleProgressView_progressColor, Color.BLUE)
typedArray.recycle()
}
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
// View의 크기 결정
val size = 200.dpToPx() // 기본 크기
setMeasuredDimension(size, size)
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
val centerX = width / 2f
val centerY = height / 2f
val radius = (width / 2f) - paint.strokeWidth
// 배경 원 그리기
paint.color = backgroundColor
canvas.drawCircle(centerX, centerY, radius, paint)
// 진행률 원 그리기
paint.color = progressColor
val sweepAngle = (progress / maxProgress) * 360f
canvas.drawArc(
centerX - radius,
centerY - radius,
centerX + radius,
centerY + radius,
-90f,
sweepAngle,
false,
paint
)
}
fun setProgress(value: Float) {
progress = value.coerceIn(0f, maxProgress)
invalidate() // 다시 그리기
}
private fun Int.dpToPx(): Int {
return (this * resources.displayMetrics.density).toInt()
}
}
XML에서 커스텀 뷰의 속성을 설정하려면 attrs.xml 파일에 속성을 정의해야 한다.
res/values/attrs.xml:
<?xml version="1.0" encoding="utf-8"?>
<resources>
<declare-styleable name="CircleProgressView">
<attr name="progress" format="float" />
<attr name="progressColor" format="color" />
<attr name="maxProgress" format="float" />
</declare-styleable>
<declare-styleable name="CustomToolbar">
<attr name="toolbarTitle" format="string" />
</declare-styleable>
</resources>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<com.example.myapp.CustomToolbar
android:id="@+id/custom_toolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:toolbarTitle="My Title" />
<com.example.myapp.CircleProgressView
android:id="@+id/circle_progress"
android:layout_width="200dp"
android:layout_height="200dp"
app:progress="50"
app:progressColor="@color/blue" />
</LinearLayout>
커스텀 뷰를 만들 때 자주 오버라이드하는 메서드들이다.
View의 크기를 측정하고 결정한다.
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
val desiredWidth = 200.dpToPx()
val desiredHeight = 200.dpToPx()
val width = resolveSize(desiredWidth, widthMeasureSpec)
val height = resolveSize(desiredHeight, heightMeasureSpec)
setMeasuredDimension(width, height)
}
실제로 View를 그린다. Canvas 객체를 사용해 원, 선, 텍스트 등을 그릴 수 있다.
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
// 그리기 로직
}
ViewGroup을 만들 때 사용하며, 자식 View들의 위치를 배치한다.
override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
// 자식 View 배치
for (i in 0 until childCount) {
val child = getChildAt(i)
child.layout(...)
}
}
터치 이벤트를 처리한다.
override fun onTouchEvent(event: MotionEvent): Boolean {
when (event.action) {
MotionEvent.ACTION_DOWN -> {
// 터치 시작
return true
}
MotionEvent.ACTION_MOVE -> {
// 터치 이동
return true
}
MotionEvent.ACTION_UP -> {
// 터치 종료
return true
}
}
return super.onTouchEvent(event)
}
fun setProgress(value: Float) {
progress = value
invalidate() // 크기 변화 없이 다시 그리기만
}
Paint 객체 생성 비용이 크므로 멤버 변수로 선언해서 재사용한다.
private val paint = Paint().apply {
isAntiAlias = true
}
onDraw() 내부에서 객체를 생성하면 GC가 자주 발생해 성능이 저하된다.
// 나쁜 예
override fun onDraw(canvas: Canvas) {
val rect = Rect() // 매번 객체 생성
canvas.drawRect(rect, paint)
}
// 좋은 예
private val rect = Rect() // 멤버 변수로 선언
override fun onDraw(canvas: Canvas) {
canvas.drawRect(rect, paint)
}
커스텀 뷰는 처음에는 복잡해 보이지만, onMeasure, onDraw, onLayout 같은 핵심 메서드의 역할을 이해하면 생각보다 어렵지 않다. 기본 View로 표현하기 어려운 독특한 UI가 필요할 때, 또는 성능 최적화가 필요한 복잡한 UI를 만들 때 커스텀 뷰가 좋은 해결책이 될 수 있다.
처음에는 간단한 커스텀 뷰부터 시작해서 점점 복잡한 것을 만들어가는 것을 추천한다. 실제로 만들어보면서 익히는 게 가장 빠른 학습 방법이다.