Google is committed to advancing racial equity for Black communities. See how.

Advanced Android 10.1 Part B: Custom view from scratch

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:

  • Create and run apps in Android Studio.
  • Use the Layout Editor to create a UI.
  • Edit a layout in XML.
  • Use touch, text, and click listeners in your code.
  • Create an app with an options menu

What you'll learn

  • Extend View to create a custom view.
  • Draw a simple custom view that is circular in shape.
  • Use listeners to handle user interaction with the custom view.
  • Use a custom view in a layout.

What you'll do

  • Extend View to create a custom view.
  • Initialize the custom view with drawing and painting values.
  • Override onDraw() to draw the view.
  • Use listeners to provide the custom view's behavior.
  • Add the custom view to a layout.

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.

The CustomFanController app shows a custom view for the controller, with settings from 0 (off) to 3 (high).

In this task you will:

  • Create an app with an ImageView as a placeholder for the custom view.
  • Extend View to create the custom view.
  • Initialize the custom view with drawing and painting values.
  • Override onDraw() to draw the dial with an indicator and text labels for the settings: 0 (off), 1 (low), 2 (medium), and 3 (high).
  • Use the View.OnClickListener interface to move the dial indicator to the next selection, and change the dial's color from gray to green for selections 1 through 3 (indicating that the fan power is on).
  • In the layout, replace the ImageView placeholder with the custom view.

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.

The layout of the CustomFanController app with a blank ImageView as a placeholder for the fan controller.

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.

The custom view replaces the ImageView placeholder.

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.

The CustomFanController app custom view controller turns green after switching from 0.

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:

  • Create the attrs.xml file in the values folder to define the custom attributes:
<resources>
    <declare-styleable name="DialView">
        <attr name="fanOnColor" format="reference|color" />
        <attr name="fanOffColor" format="reference|color" />
    </declare-styleable>
</resources>
  • Define color values in the colors.xml file in the values folder:
<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>
  • Specify the fanOnColor and fanOffColor custom attributes with DialView in the layout:
<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" />
  • Create three constructors for DialView, which call the init() method. Include in the init() method the default color settings, and paint the initial "off" state of the DialView with mFanOffColor:
// 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);
  • Use the following code in the init() method to supply the attributes to the custom view. The code uses a typed array for the attributes:
// 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();
  • In the init() method, change the onClick() method for the DialView to use mFanOnColor and mFanOffColor to set the colors when the dial is clicked:
// 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.

The dial's color for the "off" position (left) is gray and for all "on" positions (right) is cyan.

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.

The dial's color for the "off" position (left) is blue and for all "on" positions (right) is red.

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.

Changing the number of DialView selections

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:

  • A custom attribute, selectionIndicators, in the res/values/attrs.xml file
  • A "setter" method, setSelectionCount(int count), in the DialView class. This method sets or updates the value of the selectionIndicators attribute.
  • A simple UI (the options menu) in MainActivity for choosing the number of selections

In DialView.java:

  • Set mSelectionCount to the attribute value in the TypedArray:
mSelectionCount = 
          typedArray.getInt(R.styleable.DialView_selectionIndicators,
          mSelectionCount);
  • Use mSelectionCount in place of the SELECTION_COUNT constant in the onClick(), onDraw(), and computeXYForPosition() methods.
  • Change the computeXYForPosition() method to calculate selection positions for selections greater than 4, and add a parameter called isLabel. The isLabel parameter will be true if drawing the text labels, and false if drawing the dot indicator mark:
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;
}
  • Change the onDraw() code that calls computeXYForPosition() to include the isLabel argument:
//... For text labels:
float[] xyData = computeXYForPosition(i, labelRadius, true);
//... For the indicator mark:
float[] xyData = computeXYForPosition(mActiveSelection, markerRadius, false);
  • Add a "setter" method that sets the selection count, and resets the active selection to zero and the color to the "off" color:
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:

  • Provide the text for a menu item for each dial selection from 3 through 9.
  • Use the android:orderInCategory attribute to order the menu items from 3 through 9. For example, for "Selections: 4," which corresponds to the string resource dial_settings4:
<item
        android:orderInCategory="4"
        android:title="@string/dial_settings4"
        app:showAsAction="never" />

In MainActivity.java:

  • Create an instance of the custom view in MainActivity:
DialView mCustomView;
  • After setting the content view in onCreate(), assign the dialView resource in the layout to mCustomView:
mCustomView = findViewById(R.id.dialView);
  • Use the onOptionsItemSelected() method to call the "setter" method setSelectionCount(). Use item.getOrder() to get the selection count from the android:orderInCategory attribute of the menu items:
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

  • To create a custom view of any size and shape, add a new class that extends View.
  • Override View methods such as onDraw() to define the view's shape and basic appearance.
  • Use invalidate() to force a draw or redraw of the view.
  • To optimize performance, assign any required values for drawing and painting before using them in onDraw(), such as in the constructor or the init() helper method.
  • Add listeners such as View.OnClickListener to the custom view to define the view's interactive behavior.
  • Add the custom view to an XML layout file with attributes to define its appearance, as you would with other UI elements.
  • Create the attrs.xml file in the values folder to define custom attributes. You can then use the custom attributes for the custom view in the XML layout file.

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:

  • Assign homework if required.
  • Communicate to students how to submit homework assignments.
  • Grade the homework assignments.

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:

  • public MyCustomView(Context context)
  • public MyCustomView(Context context, AttributeSet attrs)
  • public static SimpleView newInstance() { return new SimpleView(); }
  • protected void onDraw(Canvas canvas) { super.onDraw(canvas) }

Question 2

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

  • onMeasure()
  • onSizeChanged()
  • invalidate()
  • onDraw()

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?

  • onMeasure()
  • onSizeChanged()
  • invalidate()
  • onDraw()

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?

  • onMeasure()
  • onSizeChanged()
  • invalidate()
  • getVisibility()

Submit your app for grading

Guidance for graders

Check that the app has the following features:

  • The app displays a Phone number field with a clear (X) button on the right side of the field, just like the Last name field.
  • The second version of the EditTextWithClear custom field (Phone number) should use the android:inputType attribute so users can enter values with a numeric keypad.

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