Tutorial: Enhancing Android UI with Custom Views

Tutorial: Enhancing Android UI with Custom Views

Tags:

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.

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.

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 NewCircle:

About the Author

If you liked this post you'll probably be interested in these:

10 Comments

Comments

Kiran Joseph | Kings Learning
Posted on Apr 06, 2016 (12 months ago)

Hi Dave, I'm new to custom drawing in Android and I'm trying to create custom view. Try this link: https://drive.google.com/open?id=0B1Djp9Rvwf8SWVZKSHVSTDJOeXM. The link has the html file for the view with animation(slower one is also attached). I tried to create a custom relative layout with 3 circular views and a textview and animate them, but as it will increase the view hierarchy I am stuck with it. Also, I was facing issues with the layout measurement. Since the textview should the last element in the layout, I couldn't get the measurement of the circular views as the measurement of the circles depends on the text in the textview and it is drawn last. Am I using the right way? Please help me with the measurements too. Thanks in advance.

Dave Smith | NewCircle, Inc.
Posted on Apr 16, 2015 (2 years ago)

Since each individual box element is just a View (at least in this example), the simplest solution is probably just to set an OnClickListener on each item that changes the background color of that specific view to whatever value you want when the view is clicked. I'm not sure doing custom drawing is really helpful to you in that case.

Steve Groen | Virtual Art
Posted on Apr 16, 2015 (2 years ago)

Dave- In your closing BoxGrid comments, you suggest I could use onDraw to draw matching Selectors underneath the grid’s smallest squares. My goal is to create a pixel editor. If each Selector’s background color could be edited and recorded in a mPixelArray[32][32], for example, I would I would have the start of a pixel editor. This implies the smallest squares all have a transparent color so the Selector’s color is visible when edited. Would this approach be the most practical for a pixel editor?

Steve Groen | Virtual Art
Posted on Apr 03, 2015 (2 years ago)

Dave- Thanks again for your quick support. I was able to download the GitHub update and create a 32x32 on my first try; believe me, that was best part of my day. The code became easier to understand by expanding “Count” with wisely named new variables and adding the “numColumns” attribute to the child and parent view,. I’m not sure if this fits your purpose but I would like to see you build on your CustomViewGroup. I would like to learn how to change the colors in the matrix with “onXxxxxTap” or “onTouch” or “onDown”. All of us need to work the touch screen and manage colors/attributes. I feel like I’ve taken a big step forward – thanks.

Dave Smith | NewCircle, Inc.
Posted on Mar 31, 2015 (2 years ago)

Steve - That is simple enough to do, and it would be a nice addition so I included the change in the example. There is a new version of the widget that accepts a new XML attribute to configure the grid count per instance… android:numColumns I have also updated the sample to show it's usage, the sample now has a 4x4 small box inside a 2x2 larger box. Hope this helps.

Steve Groen | Virtual Art
Posted on Mar 31, 2015 (2 years ago)

Thanks for your immediate support. I was able to build the package immediately and have been studying the code ever since. As a new student of Android's framework, I find trying to learn all the classes/methods more than a little challenging. Your examples are very instructional but I'm still missing some fundamentals. I would like to use your Custom ViewGroup of a 9x9 matrix as the foundation to a Pixel Editor. I tried to modify your code to create a 32x32 matrix of four, 8x8 children. But somewhere, outside my understanding, I find I can’t just change the COUNT to 8 or replace COUNT with 4/8 or change the number of “Views” or “includes” in the xml layouts. Your code seems to take the child’s (small box) dimensions and raise them to a power of 2. So I can easily create a child of 4x4 or 5x5 and get a matrix (box) of 16x16 or 25x25. I can only get an 8x8 child to a matrix of 64x64 but not 32x32. Is a fix to make a 32x32 matrix easy? If so, I don’t see it. Thanks.

Dave Smith | NewCircle, Inc.
Posted on Mar 20, 2015 (2 years ago)

Steve - Thanks for the reminder, this repo hasn't been updated since before Gradle plugin 1.0. I just pushed a new version to link to the proper build tools.

Steve Groen | Virtual Art
Posted on Mar 20, 2015 (2 years ago)

I get the following errors when importing your GitHub " custom-view-examples" to Android Studio v1.1.0: "Gradle DSL method not found: 'runProguard()' Possible causes:" "The project 'custom-view-examples' may be using a version of Gradle that does not contain the method" "The build file may be missing a Gradle plugin." I'm new Android Studio and smartphone programming and would really like to learn from your examples but I can't even get "Run" enabled. Could you please advise?

Daniel Singh | Inventon Labs
Posted on Oct 06, 2014 (2 years ago)

Very Nice. Need Few more Example sir.

Omkar Sheral | Student
Posted on Sep 29, 2014 (2 years ago)

Nice. Really helpful. Thanks!