안드로이드 커스텀 뷰(Custom View)

aufcl4858·2025년 11월 2일

커스텀 뷰(Custom View)란?

커스텀 뷰는 안드로이드에서 제공하는 기본 View나 ViewGroup을 상속받아 개발자가 직접 만드는 View이다. 기본 위젯으로는 표현할 수 없는 독특한 UI나 동작이 필요할 때 사용한다.

커스텀 뷰 구현 방법

커스텀 뷰를 만드는 방법은 크게 세 가지가 있다.

1. 기존 View 확장

가장 간단한 방법으로, 기존 View의 기능을 일부 수정하거나 확장할 때 사용한다.

class CustomButton(context: Context, attrs: AttributeSet?) : AppCompatButton(context, attrs) {
    
    init {
        // 초기화 작업
        setBackgroundColor(Color.BLUE)
        setTextColor(Color.WHITE)
    }
    
    override fun performClick(): Boolean {
        // 클릭 동작 커스터마이징
        return super.performClick()
    }
}

2. ViewGroup 확장

여러 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
    }
}

3. View를 직접 상속받아 완전히 새로운 View 만들기

완전히 새로운 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>

XML에서 사용하기

<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>

주요 메서드 정리

커스텀 뷰를 만들 때 자주 오버라이드하는 메서드들이다.

onMeasure()

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)
}

onDraw()

실제로 View를 그린다. Canvas 객체를 사용해 원, 선, 텍스트 등을 그릴 수 있다.

override fun onDraw(canvas: Canvas) {
    super.onDraw(canvas)
    // 그리기 로직
}

onLayout()

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(...)
    }
}

onTouchEvent()

터치 이벤트를 처리한다.

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)
}

성능 최적화 팁

1. invalidate() vs requestLayout()

  • invalidate(): 단순히 다시 그릴 때 (onDraw만 호출)
  • requestLayout(): 크기나 위치가 변경될 때 (onMeasure, onLayout, onDraw 모두 호출)
fun setProgress(value: Float) {
    progress = value
    invalidate() // 크기 변화 없이 다시 그리기만
}

2. Paint 객체 재사용

Paint 객체 생성 비용이 크므로 멤버 변수로 선언해서 재사용한다.

private val paint = Paint().apply {
    isAntiAlias = true
}

3. 불필요한 객체 생성 피하기

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를 만들 때 커스텀 뷰가 좋은 해결책이 될 수 있다.

처음에는 간단한 커스텀 뷰부터 시작해서 점점 복잡한 것을 만들어가는 것을 추천한다. 실제로 만들어보면서 익히는 게 가장 빠른 학습 방법이다.

profile
데브누누

0개의 댓글