mattias | niklewski.com

A Custom List Item Layout

Recently I implemented my first custom Android layout, and I thought it might be an interesting experience to share. In short, it was a lot less work than I expected and it taught me useful things about ViewGroup, the heart of Android's widget framework.

Curious Optimizations

I remember reading Android Layout Tricks #1 on the developer's blog a couple of years ago and thinking "hey, that's premature optimization if I ever saw it".

Much has changed since 2009. Hardware acceleration is standard now, and the Android team has put lots of effort into enabling smooth, 60 fps drawing. Is there still a market for layout tricks? Yes! Creating working, fast, maintainable layouts is an art. Learning more about the classes that make up the view framework is a great way to practice this art.

This will take a few paragraphs to explain though, even if we make it a TV chef-style introduction with lots of cut corners. So snuggle down in your chair and get ready.

We're going to build a somewhat generic ListItemLayout that meets the following requirements.

The straight forward solution would be to use two nested LinearLayouts. But in the spirit of the "layout tricks" post, we will do it with a custom layout so there is no nesting. By the way, if you think you can do this with a single RelativeLayout, try it out. It's harder than it looks.

Here's an example of the kind of XML we aim for.

<com.custom.ListItemLayout
    android:id="@+id/custom"
    android:layout_height="wrap_content"
    android:layout_width="match_parent"
    android:padding="8dp" >

    <ImageView
        android:id="@+id/image"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginRight="8dp"
        android:src="@drawable/ic_menu_star" />

    <TextView
        android:id="@+id/text1"
        android:layout_height="wrap_content"
        android:layout_width="wrap_content"
        android:text="Primary text" />

    <TextView
        android:id="@+id/text2"
        android:layout_height="wrap_content"
        android:layout_width="wrap_content"
        android:text="Details" />

</com.custom.ListItemLayout>

ViewGroup

ViewGroup is a magic class that enables nesting of Views. It has the same responsibilities as any other view, but in addition it has a bunch of child views to manage.

A custom layout is just a subclass of ViewGroup.

public class ListItemLayout extends ViewGroup {

    public ListItemLayout(Context context) {
        this(context, null);
    }

    public ListItemLayout(Context context, AttributeSet attrs) {
        super(context, attrs);
    }

    ...

The first constructor is for creating a view from Java. The second one makes the view inflatable from XML. There is a third version (not shown here) that provides style extension and can be skipped.

Layout Params

Child views give their parent hints about their desired position via layout parameters. By convention these have XML-names prefixed with layout_ and should be very familiar.

<FrameLayout ... >
    <ImageView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center"
        ... />

When the XML parser creates a view, it asks the parent view for a LayoutParams object and populates it with the layout params. The default ViewGroup.LayoutParams class supports only two values: height and width. ViewGroup subclasses are meant to extend it by adding parameters of their own. FrameLayout.LayoutParams for instance adds just a single one: layout_gravity.

To keep things simple, ListItemLayout does not define a new type of LayoutParams. Instead it uses the built-in MarginLayoutParams, which is like the base version but includes support for margins.

There's a cluster of four methods that must be implemented to make this happen.

/**
 * Called when children's XML is parsed.
 */
@Override
public MarginLayoutParams generateLayoutParams(AttributeSet attrs) {
    return new MarginLayoutParams(getContext(), attrs);
}

/**
 * Someone called addView() but forgot to specify layout params.
 */
@Override
protected MarginLayoutParams generateDefaultLayoutParams() {
    return new MarginLayoutParams(MarginLayoutParams.WRAP_CONTENT,
                                    MarginLayoutParams.WRAP_CONTENT);
}

/**
 * These two methods are used to convert layout params of an incorrect
 * type.
 */
protected boolean checkLayoutParams(ViewGroup.LayoutParams p) {
    return p instanceof MarginLayoutParams;
}

@Override
protected MarginLayoutParams generateLayoutParams(
        ViewGroup.LayoutParams p) {
    return new MarginLayoutParams(p);
}

Great, now the margins we specify in XML will be available from the child's getLayoutParams().

We still need a way to decide which child should go on the left. Since children are parsed and added to a ViewGroup in XML document order, let's use the convention that the first child is assumed to be the image, while remaining children are treated as texts. To make our intent clear, we name them "image view" and "text views" respectively, but they can in fact be any type of view, including other ViewGroups.

Let's make an iterable for the text views, so we can use the abbreviated for syntax to loop over them.

private View imageView() {
    // convention, use first child as the image
    return getChildAt(0);
}

private Iterable<View> textViews = new Iterable<View> () {
    // the remaining views are assumed to be text lines
    @Override
    public Iterator<View> iterator() {
        return new Iterator<View>() {
            private int current = 1;

            @Override
            public boolean hasNext() {
                return current < getChildCount();
            }

            @Override
            public View next() {
                if (current >= getChildCount())
                    throw new NoSuchElementException();
                return getChildAt(current++);
            }

            @Override
            public void remove() {
                throw new UnsupportedOperationException();
            }
        };
    }
};

Measure

Layout on Android is a two-step process. First comes the measuring step, where a view is handed a pair of MeasureSpec's, one for the width and one for the height, and from them must determine its preferred size. A MeasureSpec specifies one of three constraints:

EXACTLY n
The view must be exactly n pixels.
AT_MOST n
The view can be any size up to n pixels.
UNSPECIFIED
The view may choose its own size.

A view has complete freedom in how the measurement is done. For instance, in response to onMeasure(AT_MOST 200, UNSPECIFIED), a TextView may run a word-wrap algorithm. An ImageView may scale the image while preserving its aspect ratio, etc.

ViewGroups typically handle onMeasure() by measuring each child view over one or several passes. A LinearLayout, for instance does a first pass to figure out how big each child wants to be, and a second pass to distribute or reclaim any excess space.

The only absolute requirement is that before onMeasure() returns, it must call setMeasuredDimensions() to report its preferred size.

Compared to LinearLayout, our implementation of onMeasure() is simple. First a couple of utility methods to fetch the measured size of a child, including margins.

private int widthWithMargins(View child) {
    MarginLayoutParams lp = (MarginLayoutParams)child.getLayoutParams();
    return child.getMeasuredWidth() + lp.leftMargin + lp.rightMargin;
}

private int heightWithMargins(View child) {
    MarginLayoutParams lp = (MarginLayoutParams)child.getLayoutParams();
    return child.getMeasuredHeight() + lp.topMargin + lp.bottomMargin;
}

Now for onMeasure() itself. Start by reserving room for the image. Then run a single pass over the texts to sum up their total height.

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    int widthUsed = 0;
    int heightUsed = 0;

    View image = imageView();
    // Image goes to the left: measure and reserve horizontal space
    measureChildWithMargins(image, widthMeasureSpec, widthUsed,
            heightMeasureSpec, heightUsed);
    widthUsed += widthWithMargins(image);

    // Use remaining space to stack the text views vertically.
    int textWidth = 0;
    for (View child : textViews) {
        measureChildWithMargins(child, widthMeasureSpec, widthUsed,
                heightMeasureSpec, heightUsed);
        heightUsed += heightWithMargins(child);
        textWidth = Math.max(textWidth, widthWithMargins(child));
    }
    widthUsed += textWidth;

    // handle the case where the image is taller than the texts combined
    heightUsed = Math.max(heightWithMargins(image), heightUsed);

    widthUsed += getPaddingLeft() + getPaddingRight();
    heightUsed += getPaddingTop() + getPaddingBottom();

    setMeasuredDimension(
            resolveSize(widthUsed, widthMeasureSpec),
            resolveSize(heightUsed, heightMeasureSpec));
}

The variables widthUsed and heightUsed contain the accumulated size. The total width will be the width of the image plus the width of the widest text. The total height is either the image's height or the combined heigh of all texts, whichever is larger.

The method measureChildWithMargins() in ViewGroup does the hard work of reconciling the current MeasureSpecs with the layout_height and layout_width of the child.

For instance, imagine that the image view claimed 100px of horizontal space, so widthUsed=100. If one of the text views has a width of wrap_content and our MeasureSpec says EXACTLY 300px, then measureChildWithMargins calls the child's onMeasure with a widthMeasureSpec of AT_MOST 200px. There are such 9 combinations in total. Have a look at getChildMeasureSpec() to see how each one is handled.

resolveSize() is another useful method from View. It either stretches or clips the measured size to respect the current MeasureSpec. Our view might look bad if there is not enough space, but at least we won't violate the constraints set by our parent.

That's it. The measurement stage is complete.

Layout

The second step of the layout process is implementing onLayout(). At this point, the view can no longer influence its own placement. For a ViewGroup, what remains is to position the child views.

@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
    int x = getPaddingLeft();
    int y = getPaddingTop();

    int innerHeight = getHeight() - getPaddingTop() - getPaddingBottom();

    // center image vertically
    View image = imageView();
    int dy = Math.max(0, (innerHeight - heightWithMargins(image)) / 2);
    placeChild(image, x, y + dy);

    x += widthWithMargins(image);

    // center texts as a group
    int textHeight = 0;
    for (View child : textViews) {
        textHeight += heightWithMargins(child);
    }

    y += Math.max(0, (innerHeight - textHeight) / 2);

    for (View child : textViews) {
        placeChild(child, x, y);
        y += heightWithMargins(child);
    }
}

If onMeasure() did its job properly, each child has called setMeasuredDimension() to its preferred size. We loop through them and call layout() on each one. The only difference to onMeasure() is that we know our final height now, so we take care to vertically center both the image and the texts.

placeChild is a simple helper that saves some typing.

private void placeChild(View child, int left, int top) {
    MarginLayoutParams lp = (MarginLayoutParams)child.getLayoutParams();
    child.layout(
            left + lp.leftMargin,
            top + lp.topMargin,
            left + lp.leftMargin + child.getMeasuredWidth(),
            top + lp.topMargin + child.getMeasuredHeight());
}

Improvements

The flexibility of this layout would be greatly increased by correctly handling children with visibility set to GONE. It's not difficult to account for. Just modify onMeasure and onLayout to treat such children as if they do not exist. The result is available here.

With a fix in place, the texts will automatically stay centered as the visibility changes. Here are some examples of how that might look:

Drawbacks

Writing a custom layout is often unnecessary. A combination of Linear/Frame/RelativeLayouts might get the job done with less effort, perhaps at the cost of some clutter in the view hierarchy.

I recommend trying it anyway, because it opens your eyes to what's possible. For instance, my main gripe with RelativeLayout is that it tends to act more like an UnpredictableLayout when you start to stretch it's limits. Then it is comforting to know that there is a well documented escape hatch.

Besides, there is nothing strange about custom layouts. They are officially encouraged for "unique or otherwise tricky layouts". Sounds like the kind of layout that could make an app fun to use! The next time you need one, the answer could be to write it yourself.