Tutorial: Enhancing Android UI with Custom Views



As developers, building custom view components is a necessary part of embracing creative UI design. We shouldn't be afraid to implement a designers unique vision just because the framework (or the community) doesn't provide a component that will do the job for us out of the box. Getting our hands dirty in this area is a key to building great apps.

In order to get the most out of this tutorial, I recommend following along with the code examples at https://github.com/devunwired/custom-view-examples.

There are many great advantages to building your own UI components, such as the ability to have full control of how your content is displayed. But one of the best reasons to become an expert at custom view creation is the ability to flatten your view hierarchy.

One custom view can be designed to do the job of several nested framework widgets, and the fewer views you have in your hierarchy, the better your application will perform.

Custom View

Our first example will be a simple widget that displays a pair of overlapping image logos, with a text element on the right and vertically centered. You might use a widget like this to represent the score of a sports matchup, for example.

Custom View

When we build custom views, there are two primary functions we must take into consideration:

  • Measurement
  • Drawing

Let's have a look at measurement first...

View Measurement

Before a view hierarchy can be drawn, the first task of the Android framework will be a measurement pass. In this step, all the views in a hierarchy will be measured top-down; meaning measure starts at the root view and trickles through each child view.

Each view receives a call to onMeasure() when its parent requests that it update its measured size. It is the responsibility of each view to set its own size based on the constraints given by the parent, and store those measurements by calling setMeasuredDimension(). Forgetting to do this will result in an exception.

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    //Get the width measurement
    int widthSize = View.resolveSize(getDesiredWidth(), widthMeasureSpec);

    //Get the height measurement
    int heightSize = View.resolveSize(getDesiredHeight(), heightMeasureSpec);

    //MUST call this to store the measurements
    setMeasuredDimension(widthSize, heightSize);
}

Each view is given two packed-int values in onMeasure(), each know as a MeasureSpec, that the view should inspect to determine how to set its size. A MeasureSpec is simply a size value with a mode flag encoded into its high-order bits.

There are three possible values for a spec's mode: UNSPECIFIED, AT_MOST, and EXACTLY. UNSPECIFIED tells the view to set its dimensions to any desired size. AT_MOST tells the view to set its dimensions to any size less than or equal to the given spec. EXACTLY tells the view to set its dimensions only to the size given.

The video tutorial mentions a MeasureUtils helper class to assist in resolving the appropriate view size. This tutorial has since replaced that utility with the built-in View.resolveSize() method to accomplish the same end.

It may also be important to provide measurements of what your desired size is, for situations where wrap_content will be used to lay out the view. Here is the method we use to compute the desired width for our custom view example. We obtain width values for the three major elements in this view, and return the space that will be required to draw the overlapping logos and text.

private int getDesiredWidth() {
    int leftWidth;
    if (mLeftDrawable == null) {
        leftWidth = 0;
    } else {
        leftWidth = mLeftDrawable.getIntrinsicWidth();
    }

    int rightWidth;
    if (mRightDrawable == null) {
        rightWidth = 0;
    } else {
        rightWidth = mRightDrawable.getIntrinsicWidth();
    }

    int textWidth;
    if (mTextLayout == null) {
        textWidth = 0;
    } else {
        textWidth = mTextLayout.getWidth();
    }

    return (int)(leftWidth * 0.67f)
            + (int)(rightWidth * 0.67f)
            + mSpacing
            + textWidth;
}

Similarly, here is the method our example uses to compute its desired height value. This is governed completely by the image content, so we don't need to pay attention to the text element when measuring in this direction.

TIP: Favor efficiency over flexibility! Don't spend time testing and overriding states you don't need. Unlike the framework widgets, your custom view only needs to suit your application's use case. Place your custom view inside of its final layout, inspect the values the framework gives you for MeasureSpecs, and THEN build the measuring code to handle those specific cases.

View Drawing

A custom view's other primary job is to draw its content. For this, you are given a blank Canvas via the onDraw() method. This Canvas is sized and positioned according to your measured view, so the origin matches up with the top-left of the view bounds. Canvas supports calls to draw shapes, colors, text, bitmaps, and more.

Many framework components such as Drawable images and text Layouts provide their own draw() methods to render their contents onto the Canvas directly; which we have taken advantage of in this example.

@Override
protected void onDraw(Canvas canvas) {
    if (mLeftDrawable != null) {
        mLeftDrawable.draw(canvas);
    }

    if (mTextLayout != null) {
        canvas.save();
        canvas.translate(mTextOrigin.x, mTextOrigin.y);

        mTextLayout.draw(canvas);

        canvas.restore();
    }

    if (mRightDrawable != null) {
        mRightDrawable.draw(canvas);
    }
}

Custom Attributes

You may find yourself wanting to provide attributes to your custom view from within the layout XML. We can accomplish this by declaring a style-able block in the project resources. This block must contain all the attributes we would like to read from the layout XML.

When possible, it is most efficient to reuse attributes already defined by the framework, as we have done here. We are utilizing existing text, and drawable attributes, to feed in the content sources and text styling information that the view should apply.

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <declare-styleable name="DoubleImageView">
        <attr name="android:drawableLeft" />
        <attr name="android:drawableRight" />
        <attr name="android:text" />
        <attr name="android:textSize" />
        <attr name="android:textColor" />
        <attr name="android:spacing" />
    </declare-styleable>

</resources>

 

<com.example.customview.widget.DoubleImageView
    android:id="@+id/image1"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:drawableLeft="@drawable/flag_us"
    android:drawableRight="@drawable/flag_uk"
    android:textColor="#FFF"
    android:textSize="32sp"
    android:text="5 - 5"
    android:spacing="15dp"/>

 

During view creation, we use the obtainStyledAttributes() method to extract the values of the attributes named in our style-able block. This method returns a TypedArray instance, which allows us to retrieve each attribute as the appropriate type; whether it be a Drawable, dimension, or color.

DON'T FORGET: TypedArrays are heavyweight objects that should be recycled immediately after all the attributes you need have been extracted.

public DoubleImageView(Context context, AttributeSet attrs, int defStyle) {
    super(context, attrs, defStyle);
    mTextPaint = new TextPaint(Paint.ANTI_ALIAS_FLAG);
    mTextOrigin = new Point(0, 0);

    TypedArray a = context.obtainStyledAttributes(attrs,
            R.styleable.DoubleImageView, 0, defStyle);

    Drawable d = a.getDrawable(R.styleable.DoubleImageView_android_drawableLeft);
    if (d != null) {
        setLeftDrawable(d);
    }

    d = a.getDrawable(R.styleable.DoubleImageView_android_drawableRight);
    if (d != null) {
        setRightDrawable(d);
    }

    int spacing = a.getDimensionPixelSize(
            R.styleable.DoubleImageView_android_spacing, 0);
    setSpacing(spacing);

    int color = a.getColor(R.styleable.DoubleImageView_android_textColor, 0);
    mTextPaint.setColor(color);

    int rawSize = a.getDimensionPixelSize(
            R.styleable.DoubleImageView_android_textSize, 0);
    mTextPaint.setTextSize(rawSize);

    CharSequence text = a.getText(R.styleable.DoubleImageView_android_text);
    setText(text);

    a.recycle();
}

Custom ViewGroup

Now that we've seen how easy it is to build our own custom content into a view, what about building a custom layout manager? Widgets like LinearLayout and RelativeLayout have A LOT of code in them to manage child views, so this must be really hard, right?

Hopefully this next example will convince you that this is not the case. Here we are going to build a ViewGroup that lays out all its child views with equal spacing in a 3x3 grid. This same effect could be accomplished by nesting LinearLayouts inside of LinearLayouts inside of LinearLayouts...creating a hierarchy many many levels deep. However, with just a little bit of effort we can drastically flatten that hierarchy into something much more performant.

Custom View Group

ViewGroup Measurement

Just as with views, ViewGroups are responsible for measuring themselves. For this example we are computing the size of the ViewGroup using the framework's getDefaultSize() method, which essentially returns the size provided by the MeasureSpec in all cases except when an exact size requirement is imposed by the parent.

ViewGroup has one more job during measurement, though; it must also tell all its child views to measure themselves. We want to have each view take up exactly 1/3 of both the containers height and width. This is done by constructing a new MeasureSpec with the computed fraction of the view size and the mode flag set to EXACTLY. This will notify each child view that they must be measured to exactly the size we are giving them.

One method of dispatching these commands it to call the measure() method of every child view, but there are also helper methods inside of ViewGroup to simplify this process. In our example here we are calling measureChildren(), which applies the same spec to every child view for us. Of course, we are still required to mark our own dimensions as well, via setMeasuredDimension(), before we return.

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    int widthSize, heightSize;

    //Get the width based on the measure specs
    widthSize = getDefaultSize(0, widthMeasureSpec);

    //Get the height based on measure specs
    heightSize = getDefaultSize(0, heightMeasureSpec);

    int majorDimension = Math.min(widthSize, heightSize);
    //Measure all child views
    int blockDimension = majorDimension / mColumnCount;
    int blockSpec = MeasureSpec.makeMeasureSpec(blockDimension,
            MeasureSpec.EXACTLY);
    measureChildren(blockSpec, blockSpec);

    //MUST call this to save our own dimensions
    setMeasuredDimension(majorDimension, majorDimension);
}

Layout

After measurement, ViewGroups are also responsible for setting the BOUNDS of their child views via the onLayout() callback. With our fixed-size grid, this is pretty straightforward. We first determine, based on index, which row & column the view is in. We can then call layout() on the child view to set its left, right, top, and bottom position values.

@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
    int row, col, left, top;
    for (int i=0; i < getChildCount(); i++) {
        row = i / mColumnCount;
        col = i % mColumnCount;
        View child = getChildAt(i);
        left = col * child.getMeasuredWidth();
        top = row * child.getMeasuredHeight();

        child.layout(left,
                top,
                left + child.getMeasuredWidth(),
                top + child.getMeasuredHeight());
    }
}

Notice that inside layout we can use the getMeasuredWidth() and getMeasuredHeight() methods on the view. These will always be valid at this stage since the measurement pass comes before layout, and this is a handy way to set the bounding box of each child.

TIP: Measurement and layout can be as simple or complex as you make it. It is easy to get lost attempting to handle every possible configuration change that may affect how you lay out child views. Stick to writing code for the cases your application will actually encounter.

ViewGroup Drawing

While ViewGroups don't generally draw any content of their own, there are many situations where this can be useful. There are two helpful instances where we can ask ViewGroup to draw.

The first is inside of dispatchDraw() after super has been called. At this stage, child views have been drawn, and we have an opportunity to do additional drawing on top. In our example, we are leveraging this to draw the grid lines over our box views.

@Override
protected void dispatchDraw(Canvas canvas) {
    //Let the framework do its thing
    super.dispatchDraw(canvas);

    //Draw the grid lines
    for (int i=0; i <= getWidth(); i += (getWidth() / mColumnCount)) {
        canvas.drawLine(i, 0, i, getHeight(), mGridPaint);
    }
    for (int i=0; i <= getHeight(); i += (getHeight() / mColumnCount)) {
        canvas.drawLine(0, i, getWidth(), i, mGridPaint);
    }
}

The second is using the same onDraw() callback as we saw before with View. Anything we draw here will be drawn before the child views, and thus will show up underneath them. This can be helpful for drawing any type of dynamic backgrounds or selector states.

If you wish to put code in the onDraw() of a ViewGroup, you must also remember to enable drawing callbacks with setWillNotDraw(false). Otherwise your onDraw() method will never be triggered. This is because ViewGroups have self-drawing disabled by default.

More Custom Attributes

So back to attributes for a moment. What if the attributes we want to feed into the view don't already exist in the platform, and it would be awkward to try and reuse one for a different purpose?

In that case, we can define custom attributes inside of our style-able block. The only difference here is that we must also define the type of data that attribute represents; something we did not need to do for the framework since it already has them pre-defined.

Here, we are defining a dimension and color attribute to provide the styling for the box's grid lines via XML.

<?xml version="1.0" encoding="utf-8"?>
<resources>
    …

    <declare-styleable name="BoxGridLayout">
        <attr name="separatorWidth" format="dimension" />
        <attr name="separatorColor" format="color" />
        <attr name="numColumns" format="integer" />
    </declare-styleable>
</resources>

Now, we can apply these attributes externally in our layouts. Notice that attributes defined in our own application package require a separate namespace that points to our internal APK resources.

Notice also that our custom layout behaves no differently than the other layout widgets in the framework. We can simply add child views to it directly through the XML layout file.

<?xml version="1.0" encoding="utf-8"?>
<com.example.customview.widget.BoxGridLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    app:separatorWidth="1dp"
    app:separatorColor="#CCC"
    app:numColumns="4">

    …
</com.example.customview.widget.BoxGridLayout>

Just for fun, we will even include the layout inside itself, to create the full 9x9 effect that you saw in the earlier screenshot. We have also defined a slightly thicker grid separator to distinguish the major blocks from the minor blocks.

<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <com.example.customview.widget.BoxGridLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:layout_gravity="center"
        app:separatorWidth="2dp"
        app:numColumns="2">

        <include layout="@layout/box_small" />

        <include layout="@layout/box_small" />

        <include layout="@layout/box_small" />

        <include layout="@layout/box_small" />

    </com.example.customview.widget.BoxGridLayout>

</FrameLayout>

Thanks!

I hope that now you can see how simple it is to get started building custom views and layouts. Reduced dependence on the framework widgets leads to better user interfaces and less clutter in your view hierarchy. Your users and your devices will thank you for it.

Be sure to visit the GitHub link to find the full examples shown here, as well as others to help you get comfortable building custom views.

Thanks for your time today, and I hope you learned something new!

Additional Resources from ProTech:

Published September 15, 2014