Monday, November 11, 2013

Help, my spinner is too wide!


When dealing with the Spinner widget, especially when using NAVIGATION_MODE_LIST in your Actionbar, you might have stumbled over its weird sizing behavior. Namely, it's much wider than it needs to be. Here's an example of what a spinner in the Actionbar might look like:

The spinner is apparently much wider than it needs to be.
In this post I will explain what the reason for this behavior is, highlight the responsible bit of code from the Android framework and present a method for fixing it.

The reason


First of all, the size of the spinner is not arbitrary. When measuring itself during the layout process, the spinner reserves enough space to display its widest entry. This is useful to ensure the spinner won't change it's size when selecting a different entry. But if your spinner contains both very short and very long items, selecting short items looks very ugly.

When opening the dropdown menu, you can see that the spinner is just as wide as its widest item.
 The responsible code can be found in the Android framework's Spinner class.

int measureContentWidth(SpinnerAdapter adapter, Drawable background) {
    if (adapter == null) {
        return 0;
    }

    int width = 0;
    View itemView = null;
    int itemType = 0;
    final int widthMeasureSpec =
        MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED);
    final int heightMeasureSpec =
        MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED);

    // Make sure the number of items we'll measure is capped. If it's a huge data set
    // with wildly varying sizes, oh well.
    int start = Math.max(0, getSelectedItemPosition());
    final int end = Math.min(adapter.getCount(), start + MAX_ITEMS_MEASURED);
    final int count = end - start;
    start = Math.max(0, start - (MAX_ITEMS_MEASURED - count));
    for (int i = start; i < end; i++) {
        final int positionType = adapter.getItemViewType(i);
        if (positionType != itemType) {
            itemType = positionType;
            itemView = null;
        }
        itemView = adapter.getView(i, itemView, this);
        if (itemView.getLayoutParams() == null) {
            itemView.setLayoutParams(new ViewGroup.LayoutParams(
                ViewGroup.LayoutParams.WRAP_CONTENT,
                ViewGroup.LayoutParams.WRAP_CONTENT));
        }
        itemView.measure(widthMeasureSpec, heightMeasureSpec);
        width = Math.max(width, itemView.getMeasuredWidth());
    }

    // Add background padding to measured width
    if (background != null) {
        background.getPadding(mTempRect);
        width += mTempRect.left + mTempRect.right;
    }

    return width;
}

This method is called in the Spinner's onMeasure() method and will compute the width of the widest item (if there aren't too many). You can see that in line 26 the adapter's getView() method is called for every position. My solution exploits this line of code to make the Spinner think, all items are just as wide as the currently selected one.

The solution


As a reminder, the Spinner is backed by an adapter of type SpinnerAdapter which exends Adapter. The two important methods are
  1. getView() which is used to inflate the currently selected item and
  2. getDropDownView() which is used to inflate all of the dropdown items.
The often used BaseAdapter will implement getDropDownView() by just calling getView() with the same parameters (optionally using an alternative layout). We will override both methods to achieve our goal.

@Override
public View getView(final int position, final View convertView,
  final ViewGroup parent) {
 int selectedItemPosition = position;
 if (parent instanceof AdapterView) {
  selectedItemPosition = ((AdapterView) parent)
    .getSelectedItemPosition();
 } else if (parent instanceof IcsAbsSpinner) {
  selectedItemPosition = ((IcsAbsSpinner) parent)
    .getSelectedItemPosition();
 }
 return makeLayout(selectedItemPosition, convertView, parent,
   R.layout.spinner_title_item);
}

@Override
public View getDropDownView(final int position, final View convertView,
  final ViewGroup parent) {
 return makeLayout(position, convertView, parent,
   R.layout.spinner_dropdown_item);
}

private View makeLayout(final int position, final View convertView,
  final ViewGroup parent, final int layout) {
 TextView tv;
 if (convertView != null) {
  tv = (TextView) convertView;
 } else {
  tv = (TextView) LayoutInflater.from(context).inflate(layout,
    parent, false);
 }

 tv.setText(names[position]);

 return tv;
}

The makeLayout() method is pretty straight forward. It will be called by both getDropDownView() and getView() to inflate the passed layout and set the text for the passed position. The interesting bit happens in getView(). The parent parameter of type ViewGroup is the actual Spinner (which extends AdapterView). We ask for the currently selected item and always return the item for this position, no matter what the actual parameter was. Since the same item will be returned every time, the spinner will be its size.

Warning! If you're using ActionbarSherlock, there's a little pitfall. The Spinner that it uses is a custom implementation and doesn't extend AdapterView. Instead you will need an additional test for IscAbsSpinner.

The result


The spinner shrinks to the selected items's size.

No comments:

Post a Comment