Topic 12: Custom UI Components
📖 8 min read · 🎯 advanced · 🧭 Prerequisites: collections-in-java-queue, collections-in-java-set
Why this matters
Up until now, you've been building screens using the components Android gives you out of the box — buttons, text fields, image views. That gets you far, but at some point every real app needs something the standard toolkit simply doesn't have: a circular progress ring, a custom rating widget, a profile card styled exactly the way your designer wants it. When that moment comes, you have two choices — hack something together with workarounds, or learn to build your own UI components from scratch. Today we do the second one. By the end of this lesson, you'll know how to draw custom views pixel by pixel and how to combine existing views into reusable composite components you can drop anywhere in your app.
What You'll Learn
- Distinguish between Custom Views and Composite Views and know when to use each
- Build a custom view from scratch using
CanvasandPaintin Java - Create a composite view by inflating an XML layout inside a
ViewGroupsubclass - Expose clean setter APIs on custom components so they integrate naturally into any Activity
The Analogy
Think of Android's built-in views as IKEA furniture — functional, predictable, and assembled by millions of people. A Custom View is like commissioning a craftsman to carve a table from raw wood: you control every curve, every finish, every joint. A Composite View is like hiring an interior designer to bolt three IKEA pieces together with a custom frame so they always travel as one unit. Both approaches produce reusable furniture, but the craftsman route gives you maximum creative control while the assembly route gives you maximum speed when the raw materials already exist.
Chapter 1: The Two Types of Custom UI Components
Android custom UI components fall into two categories, each with a distinct construction method:
- Custom Views — built from scratch (or by extending an existing
View) and rendered manually using theCanvasAPI. - Composite Views — built by combining multiple standard views inside a
ViewGroupsubclass, inflated from an XML layout file.
Choosing between them is straightforward:
| Need | Approach |
|---|---|
| Unique visual rendering (charts, gauges, drawings) | Custom View |
| Reusable grouping of existing widgets | Composite View |
| Pixel-perfect animation or game-like graphics | Custom View |
| Profile cards, list item templates, form rows | Composite View |
Chapter 2: Creating a Custom View — CustomCircleView
A Custom View subclasses View (or any existing view) and overrides onDraw(Canvas) to paint itself. Three constructors are required so the view can be instantiated both from code and from XML layouts.
2.1 CustomCircleView.java
package com.example.customview;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.util.AttributeSet;
import android.view.View;
public class CustomCircleView extends View {
private Paint paint;
private int circleColor = Color.RED;
public CustomCircleView(Context context) {
super(context);
init();
}
public CustomCircleView(Context context, AttributeSet attrs) {
super(context, attrs);
init();
}
public CustomCircleView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init();
}
private void init() {
paint = new Paint();
paint.setColor(circleColor);
paint.setAntiAlias(true);
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
int width = getWidth();
int height = getHeight();
int radius = Math.min(width, height) / 2;
canvas.drawCircle(width / 2, height / 2, radius, paint);
}
public void setCircleColor(int color) {
circleColor = color;
paint.setColor(circleColor);
invalidate(); // schedules a redraw pass
}
}
Key points:
Paint— holds all drawing style information (color, stroke, anti-aliasing).setAntiAlias(true)— smooths the circle edge on high-density screens.invalidate()— tells the rendering system the view is dirty and must be redrawn; always call it after mutating drawing state.Math.min(width, height) / 2— keeps the circle inscribed within whatever dimensions the layout assigns.
2.2 Using CustomCircleView in a Layout
<!-- activity_main.xml -->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:gravity="center"
tools:context=".MainActivity">
<com.example.customview.CustomCircleView
android:id="@+id/custom_circle_view"
android:layout_width="200dp"
android:layout_height="200dp" />
<Button
android:id="@+id/change_color_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Change Color" />
</LinearLayout>
Use the fully qualified class name com.example.customview.CustomCircleView so the layout inflater can find and instantiate the class.
2.3 Wiring It Up in MainActivity.java
package com.example.customview;
import android.graphics.Color;
import android.os.Bundle;
import android.view.View;
import android.widget.Button;
import androidx.appcompat.app.AppCompatActivity;
public class MainActivity extends AppCompatActivity {
private CustomCircleView customCircleView;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
customCircleView = findViewById(R.id.custom_circle_view);
Button changeColorButton = findViewById(R.id.change_color_button);
changeColorButton.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
customCircleView.setCircleColor(Color.BLUE);
}
});
}
}
Pressing the button calls setCircleColor(Color.BLUE), which updates the Paint object and schedules a redraw via invalidate() — the circle flips from red to blue without any Activity restart.
Chapter 3: Creating a Composite View — ProfileView
A Composite View inflates a normal XML layout inside a ViewGroup subclass (commonly LinearLayout, FrameLayout, or ConstraintLayout) and exposes typed setters. The host Activity never needs to know which child views exist inside.
3.1 profile_view.xml
<!-- profile_view.xml -->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="16dp">
<ImageView
android:id="@+id/profile_image"
android:layout_width="64dp"
android:layout_height="64dp"
android:layout_marginEnd="16dp"
android:src="@drawable/ic_profile" />
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:id="@+id/profile_name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Name"
android:textSize="18sp"
android:textStyle="bold" />
<TextView
android:id="@+id/profile_email"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Email"
android:textSize="14sp" />
</LinearLayout>
</LinearLayout>
3.2 ProfileView.java
package com.example.customview;
import android.content.Context;
import android.util.AttributeSet;
import android.view.LayoutInflater;
import android.widget.ImageView;
import android.widget.LinearLayout;
import android.widget.TextView;
public class ProfileView extends LinearLayout {
private ImageView profileImage;
private TextView profileName;
private TextView profileEmail;
public ProfileView(Context context) {
super(context);
init(context);
}
public ProfileView(Context context, AttributeSet attrs) {
super(context, attrs);
init(context);
}
public ProfileView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init(context);
}
private void init(Context context) {
LayoutInflater inflater =
(LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
inflater.inflate(R.layout.profile_view, this, true);
profileImage = findViewById(R.id.profile_image);
profileName = findViewById(R.id.profile_name);
profileEmail = findViewById(R.id.profile_email);
}
public void setProfileImage(int resId) {
profileImage.setImageResource(resId);
}
public void setProfileName(String name) {
profileName.setText(name);
}
public void setProfileEmail(String email) {
profileEmail.setText(email);
}
}
inflater.inflate(R.layout.profile_view, this, true) — the third argument true attaches the inflated view tree directly to this (ProfileView), making the composite self-contained.
3.3 Using ProfileView in a Layout
<!-- activity_main.xml -->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="16dp"
tools:context=".MainActivity">
<com.example.customview.ProfileView
android:id="@+id/profile_view"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</LinearLayout>
3.4 Populating ProfileView from MainActivity.java
package com.example.customview;
import android.os.Bundle;
import androidx.appcompat.app.AppCompatActivity;
public class MainActivity extends AppCompatActivity {
private ProfileView profileView;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
profileView = findViewById(R.id.profile_view);
profileView.setProfileImage(R.drawable.ic_profile);
profileView.setProfileName("John Doe");
profileView.setProfileEmail("john.doe@example.com");
}
}
The Activity only calls three high-level setters — it has zero knowledge of the internal ImageView or TextView IDs. This encapsulation is the core benefit of the Composite View pattern.
Chapter 4: How the View Rendering Pipeline Works
Understanding what happens under the hood helps you avoid common mistakes like calling invalidate() from a background thread or forgetting super.onDraw().
flowchart TD
A[View created / XML inflated] --> B[measure pass\nonMeasure]
B --> C[layout pass\nonLayout]
C --> D[draw pass\nonDraw]
D --> E{State changed?}
E -- invalidate called --> D
E -- no change --> F[View idle]
- measure pass — determines how big the view wants to be.
- layout pass — determines where the view is positioned.
- draw pass —
onDraw(Canvas)executes; pixels are written to the screen. - Calling
invalidate()re-triggers only the draw pass (cheap). CallingrequestLayout()re-triggers all three (more expensive; use only when size or position must change).
🧪 Try It Yourself
Task: Extend CustomCircleView to support a customizable stroke ring around the circle.
- Add a second
Paintobject calledstrokePaintconfigured withPaint.Style.STROKEand a stroke width of8dp(convert to pixels usinggetResources().getDisplayMetrics().density). - In
onDraw, draw the stroke ring after the filled circle so it appears on top. - Add a
setStrokeColor(int color)method that updatesstrokePaintand callsinvalidate().
Starter snippet:
private Paint strokePaint;
private void init() {
// ... existing paint setup ...
strokePaint = new Paint();
strokePaint.setStyle(Paint.Style.STROKE);
strokePaint.setStrokeWidth(8 * getResources().getDisplayMetrics().density);
strokePaint.setColor(Color.BLACK);
strokePaint.setAntiAlias(true);
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
int width = getWidth();
int height = getHeight();
int radius = Math.min(width, height) / 2;
canvas.drawCircle(width / 2, height / 2, radius, paint);
canvas.drawCircle(width / 2, height / 2, radius, strokePaint); // add this
}
Success criterion: Run the app — the circle should display a black ring around its edge. Call setStrokeColor(Color.GREEN) from the button's click listener and confirm the ring turns green without the circle itself changing color.
🔍 Checkpoint Quiz
Q1. What is the primary difference between a Custom View and a Composite View in Android?
A) Custom Views use XML; Composite Views use Java only
B) Custom Views render via Canvas; Composite Views combine existing views inflated from XML
C) Custom Views can only be circles; Composite Views can be any shape
D) There is no difference — they are the same concept with different names
Q2. Given the following snippet, what happens when setCircleColor(Color.GREEN) is called?
public void setCircleColor(int color) {
circleColor = color;
paint.setColor(circleColor);
// invalidate() is missing
}
A) The circle immediately redraws in green
B) The color field updates but the screen does not change until the next system redraw
C) An IllegalStateException is thrown
D) The app crashes with a NullPointerException
Q3. In ProfileView.init(), the call is inflater.inflate(R.layout.profile_view, this, true). What does the third argument true do?
A) Enables animations on the inflated view
B) Attaches the inflated view tree to this as the parent, making it part of the composite
C) Marks the layout as read-only after inflation
D) Forces the layout to use wrap_content for both dimensions
Q4. You need to build a custom radial progress bar that animates smoothly. Which approach is more appropriate, and why?
A1. B — Custom Views draw themselves pixel by pixel on a Canvas; Composite Views inflate existing widget trees and expose a clean API around them.
A2. B — Without invalidate(), the Paint object holds the new color but no redraw is scheduled. The circle will only visually update if the system happens to trigger a draw pass for another reason (e.g., the screen refreshes due to another view changing). Always call invalidate() after mutating drawing state.
A3. B — The boolean attachToRoot parameter tells the inflater to add the inflated hierarchy as a child of the provided parent (this). Passing true is correct for Composite Views because the ViewGroup subclass is the root; passing false would inflate the layout detached and nothing would appear.
A4. A Custom View is the right choice. Smooth animation requires direct Canvas control: you compute the sweep angle each frame, call invalidate(), and the system schedules the next draw. Composite Views are built from pre-existing widgets that have no concept of arc drawing or frame-by-frame canvas control.
🪞 Recap
- Android custom UI splits into Custom Views (Canvas-based, drawn from scratch) and Composite Views (XML-inflated widget groups).
- Override
onDraw(Canvas)to render a Custom View; always callinvalidate()after any state change that affects appearance. - Three constructors —
(Context),(Context, AttributeSet),(Context, AttributeSet, int)— are required so the view works both in code and in XML layouts. - A Composite View inflates its XML layout with
attachToRoot = trueinsideinit(), then exposes typed setters to hide internal child-view details from callers. - Use fully qualified class names (e.g.,
com.example.customview.CustomCircleView) when referencing custom components in XML layout files.
📚 Further Reading
- Android Custom View official docs — authoritative reference on the measure/layout/draw lifecycle
- Canvas and Drawables — full
CanvasAPI surface for advanced drawing - Creating a View Class (Android Training) — step-by-step guide to composite and custom view patterns
- ⬅️ Previous: Collections in Java — Set
- ➡️ Next: Application Architecture Using MVC/MVP/MVVM