Tags

, , , , , , , , , , , , , , ,

A long list of anything would feel boring. Each and every item in the list has equal priority. Even if the sort order changes to show difference in priority, the physical appearance of the individual views are still the same. Think of browsing through 100+ bookmarks. Clearly a user would navigate to only 5-10 bookmarks often. Isn’t there a better way to showcase these top bookmarks? How about featuring the top friends that you talk to in a chat application, among 200+ contacts? The new design concept emerging these days to mitigate this issue has a grid of featured items with a big list following it.

Grid in a ListView

How do we implement this in Android? If the number of entries is fixed, we could use a LinearLayout for the grid. The ListView could follow it. However if they both have different parents, the scrolling is a major issue, as ListView has its own scrolling. The alternative approach would be add the LinearLayout as a header to the ListView. This way, the grid of entries would scroll with the list. But usually, though the number of entries is fixed, the entries themselves aren’t fixed. They are shown as a result of a DB query or from a data set returned by the server. In such a case, GridView would be better as it is backed by an adapter. The problem here is that, both GridView and ListView are scrollable themselves, and it is not a good approach to embed a scrollable container inside another scrollable container. How do we make one of the views — the GridView, actually — “unscrollable”?

    @Override
    public int getColumnWidth() {
        // This method will be called from onMeasure() too.
        // It's better to use getMeasuredWidth(), as it is safe in this case.
        final int totalHorizontalSpacing = mNumColumns > 0 ? (mNumColumns - 1) * mHorizontalSpacing : 0;
        return (getMeasuredWidth() - getPaddingLeft() - getPaddingRight() - totalHorizontalSpacing) / mNumColumns;
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        // Sets the padding for this view.
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);

        final int measuredWidth = getMeasuredWidth();
        final int childWidth = getColumnWidth();
        int childHeight = 0;

        // If there's an adapter, use it to calculate the height of this view.
        final ListAdapter adapter = getAdapter();
        final int count;

        // There shouldn't be any inherent size (due to padding) if there are no child views.
        if (adapter == null || (count = adapter.getCount()) == 0) {
            setMeasuredDimension(0, 0);
            return;
        }

        // Get the first child from the adapter.
        final View child = adapter.getView(0, null, this);
        if (child != null) {
            // Set a default LayoutParams on the child, if it doesn't have one on its own.
            AbsListView.LayoutParams params = (AbsListView.LayoutParams) child.getLayoutParams();
            if (params == null) {
                params = new AbsListView.LayoutParams(AbsListView.LayoutParams.WRAP_CONTENT,
                                                      AbsListView.LayoutParams.WRAP_CONTENT);
                child.setLayoutParams(params);
            }

            // Measure the exact width of the child, and the height based on the width.
            // Note: the child takes care of calculating its height.
            int childWidthSpec = MeasureSpec.makeMeasureSpec(childWidth, MeasureSpec.EXACTLY);
            int childHeightSpec = MeasureSpec.makeMeasureSpec(0,  MeasureSpec.UNSPECIFIED);
            child.measure(childWidthSpec, childHeightSpec);
            childHeight = child.getMeasuredHeight();
        }

        // Number of rows required to 'mTotal' items.
        final int rows = (int) Math.ceil((double) mTotal / mNumColumns);
        final int childrenHeight = childHeight * rows;
        final int totalVerticalSpacing = rows > 0 ? (rows - 1) * mVerticalSpacing : 0;

        // Total height of this view.
        final int measuredHeight = childrenHeight + getPaddingTop() + getPaddingBottom() + totalVerticalSpacing;
        setMeasuredDimension(measuredWidth, measuredHeight);
    }

That’s it! We basically see if there is an adapter. If so, we ask the first child to measure itself, given some constraints. Based on the height of the first child, we calculate the number of rows required, and hence the total height of the view. This will be set as the measured dimension for the view. One thing to note here is that, the vertical spacing and the horizontal spacing aren’t available as getters in pre-ICS versions. It’s better to get them in the constructor and store them as class variables.

There is a weird case in Firefox for Android, that doesn’t allow the scroll from the GridView to be passed to the ListView. I couldn’t reproduce the actual scenario in a sample app. In case, you ever land in a similar scenario, here’s the recipe to solve it.

    // In your ListView.
    
    @Override
    public boolean onInterceptTouchEvent(MotionEvent event) {
        switch(event.getAction() & MotionEvent.ACTION_MASK) {
            case MotionEvent.ACTION_DOWN: {
                // Store the event by obtaining a copy.
                // Android doesn't like storing the event as such.
                mMotionEvent = MotionEvent.obtain(event);
                break;
            }

            case MotionEvent.ACTION_MOVE: {
                // If there is a move, make sure its more than the touch slop.
                // The user's intention would have been to tap on the featured items.
                if ((mMotionEvent != null) &&
                    (Math.abs(event.getY() - mMotionEvent.getY()) > mTouchSlop)) {
                    // The user is scrolling. Pass the last event to this view,
                    // and make this view scroll.
                    onTouchEvent(mMotionEvent);

                    // By returning true here, this ListView will receive 
                    // all further touch events.
                    return true;
                }
                break;
            }

            default: {
                mMotionEvent = null;
                break;
            }
        }

        // Do default interception.
        return super.onInterceptTouchEvent(event);
    }

That’s all folks! Show the most important contacts, top news articles, top photos as featured ones easily.

Advertisement