A Practical Guide to Android Custom Views

举报
yd_223002268 发表于 2026/08/22 15:24:18 2026/08/22
【摘要】 A Practical Guide to Android Custom ViewsFrom a blank View to a reusable, interactive component — everything you need to know to build your own custom views in Android. Table of ContentsWhy Custom...

A Practical Guide to Android Custom Views

From a blank View to a reusable, interactive component — everything you need to know to build your own custom views in Android.


Table of Contents

  1. Why Custom Views?
  2. The Core Lifecycle Methods
  3. Step-by-Step: Building a Circular Progress View
  4. Custom Attributes
  5. Handling Touch Events
  6. Performance Best Practices
  7. Common Pitfalls
  8. Conclusion

1. Why Custom Views?

The Android SDK ships with a rich set of built-in widgets — TextView, Button, RecyclerView, etc. But sometimes none of them fit your design or interaction needs. That’s where custom views come in.

You should consider writing a custom view when:

  • You need a unique visual appearance that can’t be achieved by composing existing views.
  • You need custom drawing (shapes, charts, graphs, gauges).
  • You need specialized touch handling (gestures, dragging, multi-touch).
  • You want a reusable component shared across projects.

Rule of thumb: If you can achieve the result by composing existing views or using a ShapeableImageView / MaterialCardView, do that first. Reach for a custom view only when composition falls short.


2. The Core Lifecycle Methods

Every custom view revolves around three key overridable methods. Understanding them is non-negotiable.

onMeasure(int widthMeasureSpec, int heightMeasureSpec)

Called when the parent layout asks your view how big it wants to be. The MeasureSpec encodes a size and a mode:

Mode Meaning
EXACTLY The parent has decided an exact size for you (e.g., layout_width="100dp" or match_parent in a constrained parent).
AT_MOST You can be as big as you want up to the given size (e.g., wrap_content in a constrained parent).
UNSPECIFIED No constraints — be as big as you want.

You must call setMeasuredDimension(width, height) before returning.

onSizeChanged(int w, int h, int oldw, int oldh)

Called whenever the view’s size changes. This is the perfect place to cache calculations (center point, radius, scaled bitmap) so you don’t recompute them every frame in onDraw.

onDraw(Canvas canvas)

Where the magic happens. The Canvas is your drawing surface; Paint is your brush. Draw shapes, text, paths, bitmaps here.

Critical: onDraw is called frequently (every animation frame, every invalidation). Keep it lean. Never allocate objects inside onDraw.


3. Step-by-Step: Building a Circular Progress View

Let’s build a reusable circular progress indicator — the kind you see in loading spinners and progress rings.

3.1 Create the class

class CircularProgressView @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {

    private var progress = 0f          // 0f..1f
    private var strokeWidth = 24f.px   // dp to px
    private var startAngle = -90f      // begin at top

    private val backgroundPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
        style = Paint.Style.STROKE
        this.strokeWidth = this@CircularProgressView.strokeWidth
        color = Color.parseColor("#E0E0E0")
    }

    private val progressPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
        style = Paint.Style.STROKE
        this.strokeWidth = this@CircularProgressView.strokeWidth
        color = Color.parseColor("#6200EE")
        strokeCap = Paint.Cap.ROUND
    }

    private val bounds = RectF()
}

A few notes:

  • @JvmOverloads lets you instantiate the view from Java with fewer arguments.
  • Paint.ANTI_ALIAS_FLAG smooths jagged edges.
  • strokeCap = Paint.Cap.ROUND gives the progress arc rounded ends.
  • .px is an extension property: val Float.px get() = this * resources.displayMetrics.density.

3.2 Measure the view

We want a square view by default, but we respect the parent’s constraints:

override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
    val defaultSize = 200.dp  // preferred size in px

    val width = resolveSize(defaultSize, widthMeasureSpec)
    val height = resolveSize(defaultSize, heightMeasureSpec)

    // Force square: pick the smaller dimension
    val size = minOf(width, height)
    setMeasuredDimension(size, size)
}

resolveSize handles the MeasureSpec mode logic for you — use it instead of reinventing the wheel.

3.3 Cache geometry on size change

override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
    super.onSizeChanged(w, h, oldw, oldh)
    val inset = strokeWidth / 2
    bounds.set(inset, inset, w - inset, h - inset)
}

We inset by half the stroke width so the thick line isn’t clipped at the edges.

3.4 Draw

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

    // Background ring
    canvas.drawArc(bounds, 0f, 360f, false, backgroundPaint)

    // Progress arc
    val sweepAngle = 360f * progress
    canvas.drawArc(bounds, startAngle, sweepAngle, false, progressPaint)
}

3.5 Expose a public API

fun setProgress(value: Float) {
    progress = value.coerceIn(0f, 1f)
    invalidate()   // trigger a redraw
}

invalidate() schedules a redraw on the next frame. Call it whenever a property that affects drawing changes.

3.6 Use it in XML

<com.yourpackage.CircularProgressView
    android:id="@+id/progressView"
    android:layout_width="120dp"
    android:layout_height="120dp" />
val progressView = findViewById<CircularProgressView>(R.id.progressView)
progressView.setProgress(0.75f)

4. Custom Attributes

Hardcoded colors and sizes are bad practice. Let’s expose them via XML attributes.

4.1 Define attributes in res/values/attrs.xml

<resources>
    <declare-styleable name="CircularProgressView">
        <attr name="progressColor" format="color" />
        <attr name="trackColor" format="color" />
        <attr name="strokeWidth" format="dimension" />
        <attr name="progress" format="float" />
    </declare-styleable>
</resources>

4.2 Read them in the constructor

init {
    context.obtainStyledAttributes(attrs, R.styleable.CircularProgressView).use { typedArray ->
        progressPaint.color = typedArray.getColor(
            R.styleable.CircularProgressView_progressColor,
            Color.parseColor("#6200EE")
        )
        backgroundPaint.color = typedArray.getColor(
            R.styleable.CircularProgressView_trackColor,
            Color.parseColor("#E0E0E0")
        )
        strokeWidth = typedArray.getDimension(
            R.styleable.CircularProgressView_strokeWidth,
            24f.px
        )
        progress = typedArray.getFloat(
            R.styleable.CircularProgressView_progress,
            0f
        )
    }
    backgroundPaint.strokeWidth = strokeWidth
    progressPaint.strokeWidth = strokeWidth
}

The .use { } extension automatically recycles the TypedArrayalways recycle to free native resources.

4.3 Use them in XML

<com.yourpackage.CircularProgressView
    android:layout_width="120dp"
    android:layout_height="120dp"
    app:progressColor="#FF4081"
    app:trackColor="#F5F5F5"
    app:strokeWidth="8dp"
    app:progress="0.6" />

5. Handling Touch Events

To make your view interactive, override onTouchEvent:

override fun onTouchEvent(event: MotionEvent): Boolean {
    when (event.action) {
        MotionEvent.ACTION_DOWN -> {
            // Finger touched the view
            return true   // we want subsequent events
        }
        MotionEvent.ACTION_MOVE -> {
            val x = event.x
            val y = event.y
            // Update state based on touch position
            updateFromTouch(x, y)
            return true
        }
        MotionEvent.ACTION_UP -> {
            // Finger lifted
            performClick()   // accessibility requirement!
            return true
        }
    }
    return super.onTouchEvent(event)
}

override fun performClick(): Boolean {
    super.performClick()   // handles accessibility
    // handle click logic
    return true
}

Important: If your view handles ACTION_UP, you must call performClick() to preserve accessibility (TalkBack) behavior. Lint will warn you if you forget.

For complex gestures, delegate to a GestureDetector:

private val gestureDetector = GestureDetector(context, object : GestureDetector.SimpleOnGestureListener() {
    override fun onScroll(e1: MotionEvent, e2: MotionEvent, distanceX: Float, distanceY: Float): Boolean {
        // Handle dragging
        return true
    }
})

override fun onTouchEvent(event: MotionEvent): Boolean {
    return gestureDetector.onTouchEvent(event)
}

6. Performance Best Practices

Custom views can become performance bottlenecks. Follow these rules:

6.1 Never allocate in onDraw

// BAD — allocates every frame
override fun onDraw(canvas: Canvas) {
    val paint = Paint()   // DON'T
    canvas.drawRect(rect, paint)
}

// GOOD — reuse pre-allocated objects
private val paint = Paint()   // allocated once

override fun onDraw(canvas: Canvas) {
    canvas.drawRect(rect, paint)
}

6.2 Use hardware layers for animations

When animating a complex view, enable a hardware layer to cache the rendered output:

fun startAnimation() {
    setLayerType(LAYER_TYPE_HARDWARE, null)
    // ... animate ...
    // Disable after animation to free GPU memory
    setLayerType(LAYER_TYPE_NONE, null)
}

6.3 Avoid invalidate() when postInvalidateOnAnimation() suffices

For smooth 60fps animations, use ValueAnimator and postInvalidateOnAnimation():

fun animateProgress(target: Float) {
    val animator = ValueAnimator.ofFloat(progress, target).apply {
        duration = 300
        interpolator = DecelerateInterpolator()
        addUpdateListener { 
            progress = it.animatedValue as Float
            postInvalidateOnAnimation()
        }
    }
    animator.start()
}

6.4 Profile with GPU rendering

Enable Developer Options → Profile GPU Rendering to spot janky frames. If your view’s bars exceed the green line, you have overdraw or expensive draw calls.


7. Common Pitfalls

Pitfall Fix
View is invisible / zero size You forgot to call setMeasuredDimension() in onMeasure.
Drawing is clipped at edges Inset your RectF/Path by half the stroke width.
wrap_content doesn’t work Implement proper onMeasure logic respecting AT_MOST mode.
Memory leaks Don’t hold Activity/Fragment references; use WeakReference if needed.
Janky scrolling Allocate Paint/Path once; avoid object creation in onDraw.
Accessibility broken Call performClick() in ACTION_UP; set contentDescription.
RTL layout issues Use canvas.save()/canvas.restore() with layoutDirection checks.

8. Conclusion

Custom views are one of the most powerful tools in an Android developer’s toolkit. The key takeaways:

  1. Understand the lifecycleonMeasure, onSizeChanged, onDraw are your holy trinity.
  2. Respect MeasureSpec — use resolveSize / resolveSizeAndState instead of ignoring constraints.
  3. Keep onDraw allocation-free — pre-create all Paint, Path, and RectF objects.
  4. Expose XML attributes — your view should be configurable from layout files.
  5. Don’t forget accessibilityperformClick(), contentDescription, and touch target sizing matter.

Start simple, measure performance early, and iterate. Once you’re comfortable, explore advanced topics like RenderEffect (API 31+), SurfaceView for high-performance rendering, and Compose’s Canvas for the modern toolkit.

Happy drawing!


Written by [Your Name] · Last updated: 2026-08-22

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。