Kahibaro
Discord Login Register

35.1 Custom Views

Why Create Custom Views

At some point you will want your app to show something that built in views like TextView, ImageView, or Button cannot do exactly as you want. Custom views let you create reusable visual components with custom appearance and behavior, while still fitting naturally into the Android UI system.

You usually choose a custom view when you want a unique drawing, such as a progress ring, a rating bar with custom shapes, or a timeline. You can also use a custom view to encapsulate a small interactive component that is reused in many screens, so that layout files stay simple and logic stays in one place.

A good rule is to start with standard views and compose them in layouts. Only move to custom drawing when composition becomes too complex or impossible, for example when performance suffers or when you need very fine grained control over drawing.

Use a custom view when you need custom drawing or behavior that you cannot achieve with simple combinations of existing views.

Extending the View Class

The core of a custom view is a Kotlin class that extends View or an existing subclass like TextView. For completely new drawing, you typically extend View. This gives you a blank canvas and the basic lifecycle of measurement, layout, and drawing.

A minimal custom view usually has a primary constructor that calls the superclass constructor with context and attrs. This allows the view to be created from code and from XML, and also allows it to receive style attributes.

A simple example that just draws a colored background can look like this:

class ColorBoxView @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null
) : View(context, attrs) {
    private val paint = Paint().apply {
        color = Color.RED
    }
    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)
        canvas.drawRect(0f, 0f, width.toFloat(), height.toFloat(), paint)
    }
}

The @JvmOverloads annotation lets Java and XML construction work easily by generating multiple constructor overloads. You do not need to implement all three view constructors in Kotlin if you use this pattern.

When extending an existing view such as TextView, you usually keep the existing behavior and add small enhancements, like custom fonts or extra decorative drawing around the text.

Overriding onDraw for Custom Rendering

The heart of a drawing based custom view is the onDraw function. This function receives a Canvas, which you use together with Paint objects to draw shapes, text, or images.

The view automatically clears or prepares the canvas according to its background. Inside onDraw, you do not create heavy objects repeatedly, because that will run many times and can hurt performance. Instead you create paints and reusable paths once and keep them as fields.

A typical pattern is:

  1. Prepare Paint instances in the class initializer or in a setup function.
  2. In onDraw, read the current view size from width and height.
  3. Compute any positions or sizes that depend on the current size.
  4. Use canvas methods such as drawRect, drawCircle, drawLine, drawText, or drawPath.

For example, a simple circular progress indicator:

class CircleProgressView @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null
) : View(context, attrs) {
    private val backgroundPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
        color = Color.LTGRAY
        style = Paint.Style.STROKE
        strokeWidth = 20f
    }
    private val progressPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
        color = Color.BLUE
        style = Paint.Style.STROKE
        strokeCap = Paint.Cap.ROUND
        strokeWidth = 20f
    }
    var progress: Float = 0f
        set(value) {
            field = value.coerceIn(0f, 1f)
            invalidate()
        }
    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)
        val size = min(width, height).toFloat()
        val radius = size / 2f - backgroundPaint.strokeWidth
        val cx = width / 2f
        val cy = height / 2f
        val rect = RectF(
            cx - radius,
            cy - radius,
            cx + radius,
            cy + radius
        )
        canvas.drawArc(rect, 0f, 360f, false, backgroundPaint)
        val sweepAngle = 360f * progress
        canvas.drawArc(rect, -90f, sweepAngle, false, progressPaint)
    }
}

The call to invalidate() in the property setter tells the system that the view needs to be redrawn. On the next frame, Android calls onDraw again with an updated canvas.

Never perform long running work in onDraw. Keep onDraw fast and allocate as little as possible to avoid dropped frames and jank.

Handling Custom Measurement

Every view participates in a measurement pass where its parent asks it how big it wants to be, given constraints. To support flexible layout behavior, a custom view can override onMeasure. This function receives two integer measure specs, one for width and one for height. Each spec encodes a mode and a size.

The three modes are:

  1. EXACTLY where the parent has determined an exact size.
  2. AT_MOST where the view can be up to a maximum size.
  3. UNSPECIFIED where the view can be any size it wants.

You normally read these specs with MeasureSpec.getMode and MeasureSpec.getSize, then compute a desired width and height. After computing them, you call setMeasuredDimension.

A common pattern is to define some default size that is used when the parent uses wrap_content.

override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
    val desiredWidth = 200
    val desiredHeight = 200
    val widthMode = MeasureSpec.getMode(widthMeasureSpec)
    val widthSize = MeasureSpec.getSize(widthMeasureSpec)
    val heightMode = MeasureSpec.getMode(heightMeasureSpec)
    val heightSize = MeasureSpec.getSize(heightMeasureSpec)
    val width = when (widthMode) {
        MeasureSpec.EXACTLY -> widthSize
        MeasureSpec.AT_MOST -> min(desiredWidth, widthSize)
        MeasureSpec.UNSPECIFIED -> desiredWidth
        else -> desiredWidth
    }
    val height = when (heightMode) {
        MeasureSpec.EXACTLY -> heightSize
        MeasureSpec.AT_MOST -> min(desiredHeight, heightSize)
        MeasureSpec.UNSPECIFIED -> desiredHeight
        else -> desiredHeight
    }
    setMeasuredDimension(width, height)
}

When your view represents something square, like an avatar or circle, you can enforce equal width and height based on the smaller or larger dimension, depending on the design. This enforcement is done in the same onMeasure logic, before calling setMeasuredDimension.

Always call setMeasuredDimension in onMeasure, otherwise the view will not know its final size and may not display correctly.

Enabling Interaction and Custom Touch Handling

Custom views are not only for drawing. They can also respond to touch events and provide custom interaction. To do this, you often override onTouchEvent. Android delivers MotionEvent objects that describe actions such as ACTION_DOWN, ACTION_MOVE, and ACTION_UP. You can check coordinates with event.x and event.y.

In simple cases you can also use standard listeners like setOnClickListener. This is easier when you only care about simple taps. For more complex gestures, such as dragging or custom sliders, you handle the events directly.

Here is a minimal touch example inside a custom view, where a circle moves to the tapped position:

override fun onTouchEvent(event: MotionEvent): Boolean {
    when (event.action) {
        MotionEvent.ACTION_DOWN,
        MotionEvent.ACTION_MOVE -> {
            circleX = event.x
            circleY = event.y
            invalidate()
            return true
        }
    }
    return super.onTouchEvent(event)
}

To make a custom view clickable using the normal click listener, call isClickable = true in the initializer or in code, then rely on setOnClickListener. If you override onTouchEvent, remember to return true when you consume the event. If you return false, the event will be passed up to parent views and your view will not interact as expected.

When touch events change visual state, call invalidate() to cause a redraw. For layout related changes, such as changing size related properties, call requestLayout() so that parents can measure and lay out again.

Exposing Custom Attributes in XML

To make your custom view truly reusable, you should allow configuration from XML, not just from code. Android lets you define custom attributes in a res/values/attrs.xml file. Then you read those attributes in your view constructor that receives an AttributeSet.

An attrs.xml definitions file can look like:

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <declare-styleable name="CircleProgressView">
        <attr name="progressColor" format="color" />
        <attr name="backgroundColor" format="color" />
        <attr name="progress" format="float" />
    </declare-styleable>
</resources>

Inside your custom view, you obtain a TypedArray to read these values, usually in the initialization block or constructor:

init {
    context.theme.obtainStyledAttributes(
        attrs,
        R.styleable.CircleProgressView,
        0,
        0
    ).apply {
        try {
            val pColor = getColor(
                R.styleable.CircleProgressView_progressColor,
                Color.BLUE
            )
            val bgColor = getColor(
                R.styleable.CircleProgressView_backgroundColor,
                Color.LTGRAY
            )
            progress = getFloat(
                R.styleable.CircleProgressView_progress,
                0f
            )
            progressPaint.color = pColor
            backgroundPaint.color = bgColor
        } finally {
            recycle()
        }
    }
}

After this setup, you can use the view in an XML layout with your own attributes:

<com.example.custom.CircleProgressView
    android:layout_width="100dp"
    android:layout_height="100dp"
    app:progressColor="@color/teal_200"
    app:backgroundColor="@android:color/darker_gray"
    app:progress="0.75" />

Notice the use of the app: namespace. You usually declare this in the root layout element like xmlns:app="http://schemas.android.com/apk/res-auto". The res-auto URI lets Android link attributes to your package automatically.

Always call recycle() on a TypedArray after reading custom attributes to avoid leaking internal resources.

Reusability and Good Design Practices

Well designed custom views behave like standard views. They expose simple public properties and methods, they support helpful XML attributes, and they respect layout parameters like wrap_content and match_parent. They also react correctly to visibility changes and do not do work when they are not visible.

For reusability, prefer exposing clear Kotlin properties with backing fields that update internal state and call invalidate() or requestLayout() as needed. For example, when a property affects only the drawing, call invalidate(). When it affects the size, also call requestLayout() so that parents can remeasure.

Custom views should use resources where appropriate, not hard coded values. Colors, dimensions, and strings should usually come from resource files. This makes them easier to style and to adapt for different devices and themes.

To keep performance good, avoid heavy work in hot paths. Do not allocate new objects inside onDraw or in high frequency touch callbacks if you can reuse existing ones. Precompute invariant geometry once when the size changes, which you can detect by overriding onSizeChanged.

Finally, document the expectations of your custom view. Make it clear which attributes it supports, what range of values its properties accept, and whether it supports accessibility and content descriptions. You can then reuse it across many projects and treat it as a small library of your own.

With these patterns, your custom views stay simple to use, efficient to run, and easy to integrate with the rest of the Android view system.

Views: 1

Comments

Please login to add a comment.

Don't have an account? Register now!