This codelab is part of the Advanced Android Development training course, developed by the Google Developers Training team. You will get the most value out of this course if you work through the codelabs in sequence.

For complete details about the course, see the Advanced Android Development overview.

Introduction

By extending View directly, you can create an interactive UI element of any size and shape by overriding the onDraw() method for the View to draw it. After you create a custom view, you can add it to different layouts in the same way you would add any other View. This lesson shows you how to create a custom view from scratch by extending View directly.

What you should already know

You should be able to:

What you'll learn

What you'll do

The CustomFanController app demonstrates how to create a custom view subclass from scratch by extending the View class. The app displays a circular UI element that resembles a physical fan control, with settings for off (0), low (1), medium (2), and high (3). You can modify the subclass to change the number of settings, and use standard XML attributes to define its appearance.

In this task you will:

All the code to draw the custom view is provided in this task. (You learn more about onDraw() and drawing on a Canvas object with a Paint object in another lesson.)

1.1 Create an app with an ImageView placeholder

  1. Create an app with the title CustomFanController using the Empty Activity template, and make sure the Generate Layout File option is selected.
  2. Open activity_main.xml. The "Hello World" TextView appears centered within a ConstraintLayout. Click the Text tab to edit the XML code, and delete the app:layout_constraintBottom_toBottomOf attribute from the TextView.
  3. Add or change the following TextView attributes, leaving the other layout attributes (such as layout_constraintTop_toTopOf) the same:

TextView attribute

Value

android:id

"@+id/customViewLabel"

android:textAppearance

"@style/Base.TextAppearance.AppCompat.Display1"

android:padding

"16dp"

android:layout_marginLeft

"8dp"

android:layout_marginStart

"8dp"

android:layout_marginEnd

"8dp"

android:layout_marginRight

"8dp"

android:layout_marginTop

"24dp"

android:text

"Fan Control"

  1. Add an ImageView as a placeholder, with the following attributes:

ImageView attribute

Value

android:id

"@+id/dialView"

android:layout_width

"200dp"

android:layout_height

"200dp"

android:background

"@android:color/darker_gray"

app:layout_constraintTop_toBottomOf

"@+id/customViewLabel"

app:layout_constraintLeft_toLeftOf

"parent"

app:layout_constraintRight_toRightOf

"parent"

android:layout_marginLeft

"8dp"

android:layout_marginRight

"8dp"

android:layout_marginTop

"8dp"

  1. Extract string and dimension resources in both UI elements.

The layout should look like the figure below.

In the above figure:

  1. Component Tree with layout elements in activity_main.xml
  2. ImageView to be replaced with a custom view
  3. ImageView attributes

1.2 Extend View and initialize the view

  1. Create a new Java class called DialView, whose superclass is android.view.View.
  2. Click the red bulb for the new DialView class, and choose Create constructor matching super. Select the first three constructors in the popup menu (the fourth constructor requires API 21 and is not needed for this example).
  3. At the top of DialView define the member variables you need in order to draw the custom view:
private static int SELECTION_COUNT = 4; // Total number of selections.
private float mWidth;                   // Custom view width.
private float mHeight;                  // Custom view height.
private Paint mTextPaint;               // For text in the view.
private Paint mDialPaint;               // For dial circle in the view.
private float mRadius;                  // Radius of the circle.
private int mActiveSelection;           // The active selection.
// String buffer for dial labels and float for ComputeXY result.
private final StringBuffer mTempLabel = new StringBuffer(8);
private final float[] mTempResult = new float[2];

The SELECTION_COUNT defines the total number of selections for this custom view. The code is designed so that you can change this value to create a control with more or fewer selections.

The mTempLabel and mTempResult member variables provide temporary storage for the result of calculations, and are used to reduce the memory allocations while drawing.

  1. As in the previous app, use a separate method to initialize the view. This init() helper initializes the above instance variables:
private void init() {
    mTextPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
    mTextPaint.setColor(Color.BLACK);
    mTextPaint.setStyle(Paint.Style.FILL_AND_STROKE);
    mTextPaint.setTextAlign(Paint.Align.CENTER);
    mTextPaint.setTextSize(40f);
    mDialPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
    mDialPaint.setColor(Color.GRAY);
    // Initialize current selection. 
    mActiveSelection = 0;
    // TODO: Set up onClick listener for this view.
}

Paint styles for rendering the custom view are created in the init() method rather than at render-time with onDraw(). This is to improve performance, because onDraw() is called frequently. (You learn more about onDraw() and drawing on a Canvas object with a Paint object in another lesson.)

  1. Call init() from each constructor.
  2. Because a custom view extends View, you can override View methods such as onSizeChanged() to control its behavior. In this case you want to determine the drawing bounds for the custom view's dial by setting its width and height, and calculating its radius, when the view size changes, which includes the first time it is drawn. Add the following to DialView:
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
    // Calculate the radius from the width and height.
    mWidth = w;
    mHeight = h;
    mRadius = (float) (Math.min(mWidth, mHeight) / 2 * 0.8);
}

The onSizeChanged() method is called when the layout is inflated and when the view has changed. Its parameters are the current width and height of the view, and the "old" (previous) width and height.

1.3 Draw the custom view

To draw the custom view, your code needs to render an outer grey circle to serve as the dial, and a smaller black circle to serve as the indicator. The position of the indicator is based on the user's selection captured in mActiveSelection. Your code must calculate the indicator position before rendering the view. After adding the code to calculate the position, override the onDraw() method to render the view.

The code for drawing this view is provided without explanation because the focus of this lesson is creating and using a custom view. The code uses the Canvas methods drawCircle() and drawText().

  1. Add the following computeXYForPosition() method to DialView to compute the X and Y coordinates for the text label and indicator (0, 1, 2, or 3) of the chosen selection, given the position number and radius:
private float[] computeXYForPosition
                         (final int pos, final float radius) {
    float[] result = mTempResult;
    Double startAngle = Math.PI * (9 / 8d);   // Angles are in radians.
    Double angle = startAngle + (pos * (Math.PI / 4));
    result[0] = (float) (radius * Math.cos(angle)) + (mWidth / 2);
    result[1] = (float) (radius * Math.sin(angle)) + (mHeight / 2);
    return result;
} 

The pos parameter is a position index (starting at 0). The radius parameter is for the outer circle.

You will use the computeXYForPosition() method in the onDraw() method. It returns a two-element array for the position, in which element 0 is the X coordinate, and element 1 is the Y coordinate.

  1. To render the view on the screen, use the following code to override the onDraw() method for the view. It uses drawCircle() to draw a circle for the dial, and to draw the indicator mark. It uses drawText() to place text for labels, using a StringBuffer for the label text.
@Override
protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);
    // Draw the dial.
    canvas.drawCircle(mWidth / 2, mHeight / 2, mRadius, mDialPaint);
    // Draw the text labels.
    final float labelRadius = mRadius + 20;
    StringBuffer label = mTempLabel;
    for (int i = 0; i < SELECTION_COUNT; i++) {
        float[] xyData = computeXYForPosition(i, labelRadius);
        float x = xyData[0];
        float y = xyData[1];
        label.setLength(0);
        label.append(i);
        canvas.drawText(label, 0, label.length(), x, y, mTextPaint);
    }
    // Draw the indicator mark.
    final float markerRadius = mRadius - 35;
    float[] xyData = computeXYForPosition(mActiveSelection, 
                                                    markerRadius);
    float x = xyData[0];
    float y = xyData[1];
    canvas.drawCircle(x, y, 20, mTextPaint);
}

You learn more about drawing on a Canvas object in another lesson.

1.4 Add the custom view to the layout

You can now replace the ImageView with the custom DialView class in the layout, in order to see what it looks like:

  1. In activity_main.xml, change the ImageView tag for the dialView to com.example.customfancontroller.DialView, and delete the android:background attribute.

The DialView class inherits the attributes defined for the original ImageView, so there is no need to change the other attributes.

  1. Run the app.

1.5 Add a click listener

To add behavior to the custom view, add an OnClickListener() to the DialView init() method to perform an action when the user taps the view. Each tap should move the selection indicator to the next position: 0-1-2-3 and back to 0. Also, if the selection is 1 or higher, change the background from gray to green (indicating that the fan power is on):

  1. Add the following after the TODO comment in the init() method:
setOnClickListener(new OnClickListener() {
    @Override
    public void onClick(View v) {
        // Rotate selection to the next valid choice.
        mActiveSelection = (mActiveSelection + 1) % SELECTION_COUNT;
        // Set dial background color to green if selection is >= 1.
        if (mActiveSelection >= 1) {
            mDialPaint.setColor(Color.GREEN);
        } else {
            mDialPaint.setColor(Color.GRAY);
        }
        // Redraw the view.
        invalidate();
    }
});

The invalidate() method of View invalidates the entire view, forcing a call to onDraw() to redraw the view. If something in your custom view changes and the change needs to be displayed, you need to call invalidate().

  1. Run the app. Tap the DialView element to move the indicator from 0 to 1. The dial should turn green. With each tap, the indicator should move to the next position. When the indicator reaches 0, the dial should turn gray.

Task 1 solution code

Android Studio project: CustomFanController

Challenge: Define two custom attributes for the DialView custom view dial colors: fanOnColor for the color when the fan is set to the "on" position, and fanOffColor for the color when the fan is set to the "off" position.

Hints

For this challenge you need to do the following:

<resources>
    <declare-styleable name="DialView">
        <attr name="fanOnColor" format="reference|color" />
        <attr name="fanOffColor" format="reference|color" />
    </declare-styleable>
</resources>
<resources>
    <color name="red1">#FF2222</color>
    <color name="green1">#22FF22</color>
    <color name="blue1">#2222FF</color>
    <color name="cyan1">#22FFFF</color>
    <color name="gray1">#8888AA</color>
    <color name="yellow1">#ffff22</color>
</resources>
<com.example.customfancontroller.DialView
        android:id="@+id/dialView"
        android:layout_width="@dimen/dial_width"
        android:layout_height="@dimen/dial_height"
        android:layout_marginTop="@dimen/standard_margin"
        android:layout_marginRight="@dimen/standard_margin"
        android:layout_marginLeft="@dimen/standard_margin"
        app:fanOffColor="@color/gray1"
        app:fanOnColor="@color/cyan1"
        app:layout_constraintTop_toBottomOf="@+id/customViewLabel"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintLeft_toLeftOf="parent" />
// Set default fan on and fan off colors
mFanOnColor = Color.CYAN;
mFanOffColor = Color.GRAY;
// ... Rest of init() code to paint the DialView.
mDialPaint.setColor(mFanOffColor);
// Get the custom attributes (fanOnColor and fanOffColor) if available.
if (attrs! = null) {
    TypedArray typedArray = getContext().obtainStyledAttributes(attrs,
                            R.styleable.DialView,
                            0, 0);
    // Set the fan on and fan off colors from the attribute values.
    mFanOnColor = typedArray.getColor(R.styleable.DialView_fanOnColor,
                    mFanOnColor);
    mFanOffColor = typedArray.getColor(R.styleable.DialView_fanOffColor,
                    mFanOffColor);
    typedArray.recycle();
// Set up onClick listener for the DialView.
setOnClickListener(new OnClickListener() {
    @Override
    public void onClick(View view) {
        // Rotate selection forward to the next valid choice.
        mActiveSelection = (mActiveSelection + 1) % SELECTION_COUNT;
        // Set dial background color if selection is >= 1.
        if (mActiveSelection >= 1) {
            mDialPaint.setColor(mFanOnColor);
        } else {
            mDialPaint.setColor(mFanOffColor);
        }
        // Redraw the view.
        invalidate();
    }
});

Run the app. The dial's color for the "off" position should be gray (as before), and the color for any of the "on" positions should be cyan—defined as the default colors for mFanOnColor and mFanOffColor.

Change the fanOnColor and fanOffColor custom attributes in the layout:

app:fanOffColor="@color/blue1"
app:fanOnColor="@color/red1"

Run the app again. The dial's color for the "off" position should be blue, and the color for any of the "on" positions should be red. Try other combinations of colors that you defined in colors.xml.

You have successfully created custom attributes for DialView that you can change in your layout to suit the color choices for the overall UI that will include the DialView.

Challenge 1 solution code

Android Studio project: CustomFanChallenge

Challenge: Enable the app user to change the number of selections on the circular dial in the DialView, as shown in the figure below.

For four selections (0, 1, 2, and 3) or fewer, the selections should still appear as before along the top half of the circular dial. For more than four selections, the selections should be symmetrical around the dial.

To enable the user to change the number of selections, use the options menu in MainActivity. Note that because you are adding the number of selections as a custom attribute, you can set the initial number of selections in the XML layout file (as you did with colors).

Preliminary steps

Add an options menu to the app. Because this involves also changing the styles.xml file and the code for showing the app bar, you may find it easier to do the following:

  1. Start a new app using the Basic Activity template, which provides a MainActivity with an options menu and a floating action button. Remove the floating action button.
  2. Add a new Activity using the Empty Activity template. Copy the DialView custom view code from the CustomFanController app or CustomFanChallenge app and paste it into the new Activity.
  3. Add the elements of the activity_main.xml layout from the CustomFanController app or CustomFanChallenge app to content_main.xml in the new app.

Hints

The DialView custom view is hardcoded to have 4 selections (0, 1, 2, and 3), which are defined by the integer constant SELECTION_COUNT. However, if you change the code to use an integer variable (mSelectionCount) and expose a method to set the number of selections, then the user can customize the number of selections for the dial. The following code elements are all you need:

In DialView.java:

mSelectionCount = 
          typedArray.getInt(R.styleable.DialView_selectionIndicators,
          mSelectionCount);
private float[] computeXYForPosition(final int pos, final float radius , boolean isLabel) {
    float[] result = mTempResult;
    Double startAngle;
    Double angle;
    if (mSelectionCount > 4) {
        startAngle = Math.PI * (3 / 2d);
        angle= startAngle + (pos * (Math.PI / mSelectionCount));
        result[0] = (float) (radius * Math.cos(angle * 2)) 
                                                 + (mWidth / 2);
        result[1] = (float) (radius * Math.sin(angle * 2)) 
                                                 + (mHeight / 2);
        if((angle > Math.toRadians(360)) && isLabel) {
            result[1] += 20;
        }
    } else {
        startAngle = Math.PI * (9 / 8d);
        angle= startAngle + (pos * (Math.PI / mSelectionCount));
        result[0] = (float) (radius * Math.cos(angle)) 
                                                 + (mWidth / 2);
        result[1] = (float) (radius * Math.sin(angle)) 
                                                + (mHeight / 2);
    }
    return result;
}
//... For text labels:
float[] xyData = computeXYForPosition(i, labelRadius, true);
//... For the indicator mark:
float[] xyData = computeXYForPosition(mActiveSelection, markerRadius, false);
public void setSelectionCount(int count) {
        this.mSelectionCount = count;
        this.mActiveSelection = 0;
        mDialPaint.setColor(mFanOffColor);
        invalidate();
}

In the menu_main.xml file, add menu items for the options menu:

<item
        android:orderInCategory="4"
        android:title="@string/dial_settings4"
        app:showAsAction="never" />

In MainActivity.java:

DialView mCustomView;
mCustomView = findViewById(R.id.dialView);
int n = item.getOrder();
        mCustomView.setSelectionCount(n);
        return super.onOptionsItemSelected(item);

Run the app. You can now change the number of selections on the dial using the options menu, as shown in the previous figure.

Challenge 2 solution code

Android Studio project: CustomFanControllerSettings

The related concept documentation is 10.1 Custom views.

Android developer documentation:

Video:

This section lists possible homework assignments for students who are working through this codelab as part of a course led by an instructor. It's up to the instructor to do the following:

Instructors can use these suggestions as little or as much as they want, and should feel free to assign any other homework they feel is appropriate.

If you're working through this codelab on your own, feel free to use these homework assignments to test your knowledge.

Build and run an app

In the CustomEditText app, add a custom view that enables phone-number entry:

  1. In the layout, add a second version of the EditTextWithClear custom view underneath the first version (the Last name field).
  2. Use XML attributes to define the second version of the custom view as a phone number field that accepts only numeric phone numbers as input.

Answer these questions

Question 1

Which constructor do you need to inflate the layout for a custom view? Choose one:

Question 2

To define how your custom view fits into an overall layout, which method do you override?

Question 3

To calculate the positions, dimensions, and any other values when the custom view is first assigned a size, which method do you override?

Question 4

To indicate that you'd like your view to be redrawn with onDraw(), which method do you call from the UI thread, after an attribute value has changed?

Submit your app for grading

Guidance for graders

Check that the app has the following features:

To see all the codelabs in the Advanced Android Development training course, visit the Advanced Android Development codelabs landing page.