/* * Copyright (C) 2012 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.android.ex.widget; import android.content.Context; import android.content.res.TypedArray; import android.database.DataSetObserver; import android.graphics.Canvas; import android.os.Parcel; import android.os.Parcelable; import android.support.v4.util.SparseArrayCompat; import android.support.v4.view.MotionEventCompat; import android.support.v4.view.VelocityTrackerCompat; import android.support.v4.view.ViewCompat; import android.util.AttributeSet; import android.util.Log; import android.util.SparseArray; import android.view.MotionEvent; import android.view.VelocityTracker; import android.view.View; import android.view.ViewConfiguration; import android.view.ViewGroup; import android.widget.ListAdapter; import java.util.ArrayList; import java.util.Arrays; /** * ListView and GridView just not complex enough? Try StaggeredGridView! * *

StaggeredGridView presents a multi-column grid with consistent column sizes * but varying row sizes between the columns. Each successive item from a * {@link android.widget.ListAdapter ListAdapter} will be arranged from top to bottom, * left to right. The largest vertical gap is always filled first.

* *

Item views may span multiple columns as specified by their {@link LayoutParams}. * The attribute android:layout_span may be used when inflating * item views from xml.

* *

This class is still under development and is not fully functional yet.

*/ public class StaggeredGridView extends ViewGroup { private static final String TAG = "StaggeredGridView"; private static final boolean DEBUG = false; /* * There are a few things you should know if you're going to make modifications * to StaggeredGridView. * * Like ListView, SGV populates from an adapter and recycles views that fall out * of the visible boundaries of the grid. A few invariants always hold: * * - mFirstPosition is the adapter position of the View returned by getChildAt(0). * - Any child index can be translated to an adapter position by adding mFirstPosition. * - Any adapter position can be translated to a child index by subtracting mFirstPosition. * - Views for items in the range [mFirstPosition, mFirstPosition + getChildCount()) are * currently attached to the grid as children. All other adapter positions do not have * active views. * * This means a few things thanks to the staggered grid's nature. Some views may stay attached * long after they have scrolled offscreen if removing and recycling them would result in * breaking one of the invariants above. * * LayoutRecords are used to track data about a particular item's layout after the associated * view has been removed. These let positioning and the choice of column for an item * remain consistent even though the rules for filling content up vs. filling down vary. * * Whenever layout parameters for a known LayoutRecord change, other LayoutRecords before * or after it may need to be invalidated. e.g. if the item's height or the number * of columns it spans changes, all bets for other items in the same direction are off * since the cached information no longer applies. */ private ListAdapter mAdapter; public static final int COLUMN_COUNT_AUTO = -1; private int mColCountSetting = 2; private int mColCount = 2; private int mMinColWidth = 0; private int mItemMargin; private int[] mItemTops; private int[] mItemBottoms; private boolean mFastChildLayout; private boolean mPopulating; private boolean mForcePopulateOnLayout; private boolean mInLayout; private int mRestoreOffset; private final RecycleBin mRecycler = new RecycleBin(); private final AdapterDataSetObserver mObserver = new AdapterDataSetObserver(); private boolean mDataChanged; private int mOldItemCount; private int mItemCount; private boolean mHasStableIds; private int mFirstPosition; private int mTouchSlop; private int mMaximumVelocity; private int mFlingVelocity; private float mLastTouchY; private float mTouchRemainderY; private int mActivePointerId; private static final int TOUCH_MODE_IDLE = 0; private static final int TOUCH_MODE_DRAGGING = 1; private static final int TOUCH_MODE_FLINGING = 2; private int mTouchMode; private final VelocityTracker mVelocityTracker = VelocityTracker.obtain(); private final ScrollerCompat mScroller; private final EdgeEffectCompat mTopEdge; private final EdgeEffectCompat mBottomEdge; private static final class LayoutRecord { public int column; public long id = -1; public int height; public int span; private int[] mMargins; private final void ensureMargins() { if (mMargins == null) { // Don't need to confirm length; // all layoutrecords are purged when column count changes. mMargins = new int[span * 2]; } } public final int getMarginAbove(int col) { if (mMargins == null) { return 0; } return mMargins[col * 2]; } public final int getMarginBelow(int col) { if (mMargins == null) { return 0; } return mMargins[col * 2 + 1]; } public final void setMarginAbove(int col, int margin) { if (mMargins == null && margin == 0) { return; } ensureMargins(); mMargins[col * 2] = margin; } public final void setMarginBelow(int col, int margin) { if (mMargins == null && margin == 0) { return; } ensureMargins(); mMargins[col * 2 + 1] = margin; } @Override public String toString() { String result = "LayoutRecord{c=" + column + ", id=" + id + " h=" + height + " s=" + span; if (mMargins != null) { result += " margins[above, below]("; for (int i = 0; i < mMargins.length; i += 2) { result += "[" + mMargins[i] + ", " + mMargins[i+1] + "]"; } result += ")"; } return result + "}"; } } private final SparseArrayCompat mLayoutRecords = new SparseArrayCompat(); public StaggeredGridView(Context context) { this(context, null); } public StaggeredGridView(Context context, AttributeSet attrs) { this(context, attrs, 0); } public StaggeredGridView(Context context, AttributeSet attrs, int defStyle) { super(context, attrs, defStyle); final ViewConfiguration vc = ViewConfiguration.get(context); mTouchSlop = vc.getScaledTouchSlop(); mMaximumVelocity = vc.getScaledMaximumFlingVelocity(); mFlingVelocity = vc.getScaledMinimumFlingVelocity(); mScroller = ScrollerCompat.from(context); mTopEdge = new EdgeEffectCompat(context); mBottomEdge = new EdgeEffectCompat(context); setWillNotDraw(false); setClipToPadding(false); } /** * Set a fixed number of columns for this grid. Space will be divided evenly * among all columns, respecting the item margin between columns. * The default is 2. (If it were 1, perhaps you should be using a * {@link android.widget.ListView ListView}.) * * @param colCount Number of columns to display. * @see #setMinColumnWidth(int) */ public void setColumnCount(int colCount) { if (colCount < 1 && colCount != COLUMN_COUNT_AUTO) { throw new IllegalArgumentException("Column count must be at least 1 - received " + colCount); } final boolean needsPopulate = colCount != mColCount; mColCount = mColCountSetting = colCount; if (needsPopulate) { populate(); } } public int getColumnCount() { return mColCount; } /** * Set a minimum column width for * @param minColWidth */ public void setMinColumnWidth(int minColWidth) { mMinColWidth = minColWidth; setColumnCount(COLUMN_COUNT_AUTO); } /** * Set the margin between items in pixels. This margin is applied * both vertically and horizontally. * * @param marginPixels Spacing between items in pixels */ public void setItemMargin(int marginPixels) { final boolean needsPopulate = marginPixels != mItemMargin; mItemMargin = marginPixels; if (needsPopulate) { populate(); } } /** * Return the first adapter position with a view currently attached as * a child view of this grid. * * @return the adapter position represented by the view at getChildAt(0). */ public int getFirstPosition() { return mFirstPosition; } @Override public boolean onInterceptTouchEvent(MotionEvent ev) { mVelocityTracker.addMovement(ev); final int action = ev.getAction() & MotionEventCompat.ACTION_MASK; switch (action) { case MotionEvent.ACTION_DOWN: mVelocityTracker.clear(); mScroller.abortAnimation(); mLastTouchY = ev.getY(); mActivePointerId = MotionEventCompat.getPointerId(ev, 0); mTouchRemainderY = 0; if (mTouchMode == TOUCH_MODE_FLINGING) { // Catch! mTouchMode = TOUCH_MODE_DRAGGING; return true; } break; case MotionEvent.ACTION_MOVE: { final int index = MotionEventCompat.findPointerIndex(ev, mActivePointerId); if (index < 0) { Log.e(TAG, "onInterceptTouchEvent could not find pointer with id " + mActivePointerId + " - did StaggeredGridView receive an inconsistent " + "event stream?"); return false; } final float y = MotionEventCompat.getY(ev, index); final float dy = y - mLastTouchY + mTouchRemainderY; final int deltaY = (int) dy; mTouchRemainderY = dy - deltaY; if (Math.abs(dy) > mTouchSlop) { mTouchMode = TOUCH_MODE_DRAGGING; return true; } } } return false; } @Override public boolean onTouchEvent(MotionEvent ev) { mVelocityTracker.addMovement(ev); final int action = ev.getAction() & MotionEventCompat.ACTION_MASK; switch (action) { case MotionEvent.ACTION_DOWN: mVelocityTracker.clear(); mScroller.abortAnimation(); mLastTouchY = ev.getY(); mActivePointerId = MotionEventCompat.getPointerId(ev, 0); mTouchRemainderY = 0; break; case MotionEvent.ACTION_MOVE: { final int index = MotionEventCompat.findPointerIndex(ev, mActivePointerId); if (index < 0) { Log.e(TAG, "onInterceptTouchEvent could not find pointer with id " + mActivePointerId + " - did StaggeredGridView receive an inconsistent " + "event stream?"); return false; } final float y = MotionEventCompat.getY(ev, index); final float dy = y - mLastTouchY + mTouchRemainderY; final int deltaY = (int) dy; mTouchRemainderY = dy - deltaY; if (Math.abs(dy) > mTouchSlop) { mTouchMode = TOUCH_MODE_DRAGGING; } if (mTouchMode == TOUCH_MODE_DRAGGING) { mLastTouchY = y; if (!trackMotionScroll(deltaY, true)) { // Break fling velocity if we impacted an edge. mVelocityTracker.clear(); } } } break; case MotionEvent.ACTION_CANCEL: mTouchMode = TOUCH_MODE_IDLE; break; case MotionEvent.ACTION_UP: { mVelocityTracker.computeCurrentVelocity(1000, mMaximumVelocity); final float velocity = VelocityTrackerCompat.getYVelocity(mVelocityTracker, mActivePointerId); if (Math.abs(velocity) > mFlingVelocity) { // TODO mTouchMode = TOUCH_MODE_FLINGING; mScroller.fling(0, 0, 0, (int) velocity, 0, 0, Integer.MIN_VALUE, Integer.MAX_VALUE); mLastTouchY = 0; ViewCompat.postInvalidateOnAnimation(this); } else { mTouchMode = TOUCH_MODE_IDLE; } } break; } return true; } /** * * @param deltaY Pixels that content should move by * @return true if the movement completed, false if it was stopped prematurely. */ private boolean trackMotionScroll(int deltaY, boolean allowOverScroll) { final boolean contentFits = contentFits(); final int allowOverhang = Math.abs(deltaY); final int overScrolledBy; final int movedBy; if (!contentFits) { final int overhang; final boolean up; mPopulating = true; if (deltaY > 0) { overhang = fillUp(mFirstPosition - 1, allowOverhang); up = true; } else { overhang = fillDown(mFirstPosition + getChildCount(), allowOverhang) + mItemMargin; up = false; } movedBy = Math.min(overhang, allowOverhang); offsetChildren(up ? movedBy : -movedBy); recycleOffscreenViews(); mPopulating = false; overScrolledBy = allowOverhang - overhang; } else { overScrolledBy = allowOverhang; movedBy = 0; } if (allowOverScroll) { final int overScrollMode = ViewCompat.getOverScrollMode(this); if (overScrollMode == ViewCompat.OVER_SCROLL_ALWAYS || (overScrollMode == ViewCompat.OVER_SCROLL_IF_CONTENT_SCROLLS && !contentFits)) { if (overScrolledBy > 0) { EdgeEffectCompat edge = deltaY > 0 ? mTopEdge : mBottomEdge; edge.onPull((float) Math.abs(deltaY) / getHeight()); ViewCompat.postInvalidateOnAnimation(this); } } } return deltaY == 0 || movedBy != 0; } private final boolean contentFits() { if (mFirstPosition != 0 || getChildCount() != mItemCount) { return false; } int topmost = Integer.MAX_VALUE; int bottommost = Integer.MIN_VALUE; for (int i = 0; i < mColCount; i++) { if (mItemTops[i] < topmost) { topmost = mItemTops[i]; } if (mItemBottoms[i] > bottommost) { bottommost = mItemBottoms[i]; } } return topmost >= getPaddingTop() && bottommost <= getHeight() - getPaddingBottom(); } private void recycleAllViews() { for (int i = 0; i < getChildCount(); i++) { mRecycler.addScrap(getChildAt(i)); } if (mInLayout) { removeAllViewsInLayout(); } else { removeAllViews(); } } /** * Important: this method will leave offscreen views attached if they * are required to maintain the invariant that child view with index i * is always the view corresponding to position mFirstPosition + i. */ private void recycleOffscreenViews() { final int height = getHeight(); final int clearAbove = -mItemMargin; final int clearBelow = height + mItemMargin; for (int i = getChildCount() - 1; i >= 0; i--) { final View child = getChildAt(i); if (child.getTop() <= clearBelow) { // There may be other offscreen views, but we need to maintain // the invariant documented above. break; } if (mInLayout) { removeViewsInLayout(i, 1); } else { removeViewAt(i); } mRecycler.addScrap(child); } while (getChildCount() > 0) { final View child = getChildAt(0); if (child.getBottom() >= clearAbove) { // There may be other offscreen views, but we need to maintain // the invariant documented above. break; } if (mInLayout) { removeViewsInLayout(0, 1); } else { removeViewAt(0); } mRecycler.addScrap(child); mFirstPosition++; } final int childCount = getChildCount(); if (childCount > 0) { // Repair the top and bottom column boundaries from the views we still have Arrays.fill(mItemTops, Integer.MAX_VALUE); Arrays.fill(mItemBottoms, Integer.MIN_VALUE); for (int i = 0; i < childCount; i++){ final View child = getChildAt(i); final LayoutParams lp = (LayoutParams) child.getLayoutParams(); final int top = child.getTop() - mItemMargin; final int bottom = child.getBottom(); final LayoutRecord rec = mLayoutRecords.get(mFirstPosition + i); final int colEnd = lp.column + Math.min(mColCount, lp.span); for (int col = lp.column; col < colEnd; col++) { final int colTop = top - rec.getMarginAbove(col - lp.column); final int colBottom = bottom + rec.getMarginBelow(col - lp.column); if (colTop < mItemTops[col]) { mItemTops[col] = colTop; } if (colBottom > mItemBottoms[col]) { mItemBottoms[col] = colBottom; } } } for (int col = 0; col < mColCount; col++) { if (mItemTops[col] == Integer.MAX_VALUE) { // If one was untouched, both were. mItemTops[col] = 0; mItemBottoms[col] = 0; } } } } public void computeScroll() { if (mScroller.computeScrollOffset()) { final int y = mScroller.getCurrY(); final int dy = (int) (y - mLastTouchY); mLastTouchY = y; final boolean stopped = !trackMotionScroll(dy, false); if (!stopped && !mScroller.isFinished()) { ViewCompat.postInvalidateOnAnimation(this); } else { if (stopped) { final int overScrollMode = ViewCompat.getOverScrollMode(this); if (overScrollMode != ViewCompat.OVER_SCROLL_NEVER) { final EdgeEffectCompat edge; if (dy > 0) { edge = mTopEdge; } else { edge = mBottomEdge; } edge.onAbsorb(Math.abs((int) mScroller.getCurrVelocity())); ViewCompat.postInvalidateOnAnimation(this); } mScroller.abortAnimation(); } mTouchMode = TOUCH_MODE_IDLE; } } } @Override public void draw(Canvas canvas) { super.draw(canvas); if (mTopEdge != null) { boolean needsInvalidate = false; if (!mTopEdge.isFinished()) { mTopEdge.draw(canvas); needsInvalidate = true; } if (!mBottomEdge.isFinished()) { final int restoreCount = canvas.save(); final int width = getWidth(); canvas.translate(-width, getHeight()); canvas.rotate(180, width, 0); mBottomEdge.draw(canvas); canvas.restoreToCount(restoreCount); needsInvalidate = true; } if (needsInvalidate) { ViewCompat.postInvalidateOnAnimation(this); } } } public void beginFastChildLayout() { mFastChildLayout = true; } public void endFastChildLayout() { mFastChildLayout = false; populate(); } @Override public void requestLayout() { if (!mPopulating && !mFastChildLayout) { super.requestLayout(); } } @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { int widthMode = MeasureSpec.getMode(widthMeasureSpec); int heightMode = MeasureSpec.getMode(heightMeasureSpec); int widthSize = MeasureSpec.getSize(widthMeasureSpec); int heightSize = MeasureSpec.getSize(heightMeasureSpec); if (widthMode != MeasureSpec.EXACTLY) { Log.e(TAG, "onMeasure: must have an exact width or match_parent! " + "Using fallback spec of EXACTLY " + widthSize); widthMode = MeasureSpec.EXACTLY; } if (heightMode != MeasureSpec.EXACTLY) { Log.e(TAG, "onMeasure: must have an exact height or match_parent! " + "Using fallback spec of EXACTLY " + heightSize); heightMode = MeasureSpec.EXACTLY; } setMeasuredDimension(widthSize, heightSize); if (mColCountSetting == COLUMN_COUNT_AUTO) { final int colCount = widthSize / mMinColWidth; if (colCount != mColCount) { mColCount = colCount; mForcePopulateOnLayout = true; } } } @Override protected void onLayout(boolean changed, int l, int t, int r, int b) { mInLayout = true; populate(); mInLayout = false; mForcePopulateOnLayout = false; final int width = r - l; final int height = b - t; mTopEdge.setSize(width, height); mBottomEdge.setSize(width, height); } private void populate() { if (getWidth() == 0 || getHeight() == 0) { return; } if (mColCount == COLUMN_COUNT_AUTO) { final int colCount = getWidth() / mMinColWidth; if (colCount != mColCount) { mColCount = colCount; } } final int colCount = mColCount; if (mItemTops == null || mItemTops.length != colCount) { mItemTops = new int[colCount]; mItemBottoms = new int[colCount]; final int top = getPaddingTop(); final int offset = top + Math.min(mRestoreOffset, 0); Arrays.fill(mItemTops, offset); Arrays.fill(mItemBottoms, offset); mLayoutRecords.clear(); if (mInLayout) { removeAllViewsInLayout(); } else { removeAllViews(); } mRestoreOffset = 0; } mPopulating = true; layoutChildren(mDataChanged); fillDown(mFirstPosition + getChildCount(), 0); fillUp(mFirstPosition - 1, 0); mPopulating = false; mDataChanged = false; } private void dumpItemPositions() { final int childCount = getChildCount(); Log.d(TAG, "dumpItemPositions:"); Log.d(TAG, " => Tops:"); for (int i = 0; i < mColCount; i++) { Log.d(TAG, " => " + mItemTops[i]); boolean found = false; for (int j = 0; j < childCount; j++) { final View child = getChildAt(j); if (mItemTops[i] == child.getTop() - mItemMargin) { found = true; } } if (!found) { Log.d(TAG, "!!! No top item found for column " + i + " value " + mItemTops[i]); } } Log.d(TAG, " => Bottoms:"); for (int i = 0; i < mColCount; i++) { Log.d(TAG, " => " + mItemBottoms[i]); boolean found = false; for (int j = 0; j < childCount; j++) { final View child = getChildAt(j); if (mItemBottoms[i] == child.getBottom()) { found = true; } } if (!found) { Log.d(TAG, "!!! No bottom item found for column " + i + " value " + mItemBottoms[i]); } } } final void offsetChildren(int offset) { final int childCount = getChildCount(); for (int i = 0; i < childCount; i++) { final View child = getChildAt(i); child.layout(child.getLeft(), child.getTop() + offset, child.getRight(), child.getBottom() + offset); } final int colCount = mColCount; for (int i = 0; i < colCount; i++) { mItemTops[i] += offset; mItemBottoms[i] += offset; } } /** * Measure and layout all currently visible children. * * @param queryAdapter true to requery the adapter for view data */ final void layoutChildren(boolean queryAdapter) { final int paddingLeft = getPaddingLeft(); final int paddingRight = getPaddingRight(); final int itemMargin = mItemMargin; final int colWidth = (getWidth() - paddingLeft - paddingRight - itemMargin * (mColCount - 1)) / mColCount; int rebuildLayoutRecordsBefore = -1; int rebuildLayoutRecordsAfter = -1; Arrays.fill(mItemBottoms, Integer.MIN_VALUE); final int childCount = getChildCount(); for (int i = 0; i < childCount; i++) { View child = getChildAt(i); LayoutParams lp = (LayoutParams) child.getLayoutParams(); final int col = lp.column; final int position = mFirstPosition + i; final boolean needsLayout = queryAdapter || child.isLayoutRequested(); if (queryAdapter) { View newView = obtainView(position, child); if (newView != child) { removeViewAt(i); addView(newView, i); child = newView; } lp = (LayoutParams) child.getLayoutParams(); // Might have changed } final int span = Math.min(mColCount, lp.span); final int widthSize = colWidth * span + itemMargin * (span - 1); if (needsLayout) { final int widthSpec = MeasureSpec.makeMeasureSpec(widthSize, MeasureSpec.EXACTLY); final int heightSpec; if (lp.height == LayoutParams.WRAP_CONTENT) { heightSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED); } else { heightSpec = MeasureSpec.makeMeasureSpec(lp.height, MeasureSpec.EXACTLY); } child.measure(widthSpec, heightSpec); } int childTop = mItemBottoms[col] > Integer.MIN_VALUE ? mItemBottoms[col] + mItemMargin : child.getTop(); if (span > 1) { int lowest = childTop; for (int j = col + 1; j < col + span; j++) { final int bottom = mItemBottoms[j] + mItemMargin; if (bottom > lowest) { lowest = bottom; } } childTop = lowest; } final int childHeight = child.getMeasuredHeight(); final int childBottom = childTop + childHeight; final int childLeft = paddingLeft + col * (colWidth + itemMargin); final int childRight = childLeft + child.getMeasuredWidth(); child.layout(childLeft, childTop, childRight, childBottom); for (int j = col; j < col + span; j++) { mItemBottoms[j] = childBottom; } final LayoutRecord rec = mLayoutRecords.get(position); if (rec != null && rec.height != childHeight) { // Invalidate our layout records for everything before this. rec.height = childHeight; rebuildLayoutRecordsBefore = position; } if (rec != null && rec.span != span) { // Invalidate our layout records for everything after this. rec.span = span; rebuildLayoutRecordsAfter = position; } } // Update mItemBottoms for any empty columns for (int i = 0; i < mColCount; i++) { if (mItemBottoms[i] == Integer.MIN_VALUE) { mItemBottoms[i] = mItemTops[i]; } } if (rebuildLayoutRecordsBefore >= 0 || rebuildLayoutRecordsAfter >= 0) { if (rebuildLayoutRecordsBefore >= 0) { invalidateLayoutRecordsBeforePosition(rebuildLayoutRecordsBefore); } if (rebuildLayoutRecordsAfter >= 0) { invalidateLayoutRecordsAfterPosition(rebuildLayoutRecordsAfter); } for (int i = 0; i < childCount; i++) { final int position = mFirstPosition + i; final View child = getChildAt(i); final LayoutParams lp = (LayoutParams) child.getLayoutParams(); LayoutRecord rec = mLayoutRecords.get(position); if (rec == null) { rec = new LayoutRecord(); mLayoutRecords.put(position, rec); } rec.column = lp.column; rec.height = child.getHeight(); rec.id = lp.id; rec.span = Math.min(mColCount, lp.span); } } } final void invalidateLayoutRecordsBeforePosition(int position) { int endAt = 0; while (endAt < mLayoutRecords.size() && mLayoutRecords.keyAt(endAt) < position) { endAt++; } mLayoutRecords.removeAtRange(0, endAt); } final void invalidateLayoutRecordsAfterPosition(int position) { int beginAt = mLayoutRecords.size() - 1; while (beginAt >= 0 && mLayoutRecords.keyAt(beginAt) > position) { beginAt--; } beginAt++; mLayoutRecords.removeAtRange(beginAt + 1, mLayoutRecords.size() - beginAt); } /** * Should be called with mPopulating set to true * * @param fromPosition Position to start filling from * @param overhang the number of extra pixels to fill beyond the current top edge * @return the max overhang beyond the beginning of the view of any added items at the top */ final int fillUp(int fromPosition, int overhang) { final int paddingLeft = getPaddingLeft(); final int paddingRight = getPaddingRight(); final int itemMargin = mItemMargin; final int colWidth = (getWidth() - paddingLeft - paddingRight - itemMargin * (mColCount - 1)) / mColCount; final int gridTop = getPaddingTop(); final int fillTo = gridTop - overhang; int nextCol = getNextColumnUp(); int position = fromPosition; while (nextCol >= 0 && mItemTops[nextCol] > fillTo && position >= 0) { final View child = obtainView(position, null); LayoutParams lp = (LayoutParams) child.getLayoutParams(); if (child.getParent() != this) { if (mInLayout) { addViewInLayout(child, 0, lp); } else { addView(child, 0); } } final int span = Math.min(mColCount, lp.span); final int widthSize = colWidth * span + itemMargin * (span - 1); final int widthSpec = MeasureSpec.makeMeasureSpec(widthSize, MeasureSpec.EXACTLY); LayoutRecord rec; if (span > 1) { rec = getNextRecordUp(position, span); nextCol = rec.column; } else { rec = mLayoutRecords.get(position); } boolean invalidateBefore = false; if (rec == null) { rec = new LayoutRecord(); mLayoutRecords.put(position, rec); rec.column = nextCol; rec.span = span; } else if (span != rec.span) { rec.span = span; rec.column = nextCol; invalidateBefore = true; } else { nextCol = rec.column; } if (mHasStableIds) { final long id = mAdapter.getItemId(position); rec.id = id; lp.id = id; } lp.column = nextCol; final int heightSpec; if (lp.height == LayoutParams.WRAP_CONTENT) { heightSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED); } else { heightSpec = MeasureSpec.makeMeasureSpec(lp.height, MeasureSpec.EXACTLY); } child.measure(widthSpec, heightSpec); final int childHeight = child.getMeasuredHeight(); if (invalidateBefore || (childHeight != rec.height && rec.height > 0)) { invalidateLayoutRecordsBeforePosition(position); } rec.height = childHeight; final int startFrom; if (span > 1) { int highest = mItemTops[nextCol]; for (int i = nextCol + 1; i < nextCol + span; i++) { final int top = mItemTops[i]; if (top < highest) { highest = top; } } startFrom = highest; } else { startFrom = mItemTops[nextCol]; } final int childBottom = startFrom; final int childTop = childBottom - childHeight; final int childLeft = paddingLeft + nextCol * (colWidth + itemMargin); final int childRight = childLeft + child.getMeasuredWidth(); child.layout(childLeft, childTop, childRight, childBottom); for (int i = nextCol; i < nextCol + span; i++) { mItemTops[i] = childTop - rec.getMarginAbove(i - nextCol) - itemMargin; } nextCol = getNextColumnUp(); mFirstPosition = position--; } int highestView = getHeight(); for (int i = 0; i < mColCount; i++) { if (mItemTops[i] < highestView) { highestView = mItemTops[i]; } } return gridTop - highestView; } /** * Should be called with mPopulating set to true * * @param fromPosition Position to start filling from * @param overhang the number of extra pixels to fill beyond the current bottom edge * @return the max overhang beyond the end of the view of any added items at the bottom */ final int fillDown(int fromPosition, int overhang) { final int paddingLeft = getPaddingLeft(); final int paddingRight = getPaddingRight(); final int itemMargin = mItemMargin; final int colWidth = (getWidth() - paddingLeft - paddingRight - itemMargin * (mColCount - 1)) / mColCount; final int gridBottom = getHeight() - getPaddingBottom(); final int fillTo = gridBottom + overhang; int nextCol = getNextColumnDown(); int position = fromPosition; while (nextCol >= 0 && mItemBottoms[nextCol] < fillTo && position < mItemCount) { final View child = obtainView(position, null); LayoutParams lp = (LayoutParams) child.getLayoutParams(); if (child.getParent() != this) { if (mInLayout) { addViewInLayout(child, -1, lp); } else { addView(child); } } final int span = Math.min(mColCount, lp.span); final int widthSize = colWidth * span + itemMargin * (span - 1); final int widthSpec = MeasureSpec.makeMeasureSpec(widthSize, MeasureSpec.EXACTLY); LayoutRecord rec; if (span > 1) { rec = getNextRecordDown(position, span); nextCol = rec.column; } else { rec = mLayoutRecords.get(position); } boolean invalidateAfter = false; if (rec == null) { rec = new LayoutRecord(); mLayoutRecords.put(position, rec); rec.column = nextCol; rec.span = span; } else if (span != rec.span) { rec.span = span; rec.column = nextCol; invalidateAfter = true; } else { nextCol = rec.column; } if (mHasStableIds) { final long id = mAdapter.getItemId(position); rec.id = id; lp.id = id; } lp.column = nextCol; final int heightSpec; if (lp.height == LayoutParams.WRAP_CONTENT) { heightSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED); } else { heightSpec = MeasureSpec.makeMeasureSpec(lp.height, MeasureSpec.EXACTLY); } child.measure(widthSpec, heightSpec); final int childHeight = child.getMeasuredHeight(); if (invalidateAfter || (childHeight != rec.height && rec.height > 0)) { invalidateLayoutRecordsAfterPosition(position); } rec.height = childHeight; final int startFrom; if (span > 1) { int lowest = mItemBottoms[nextCol]; for (int i = nextCol + 1; i < nextCol + span; i++) { final int bottom = mItemBottoms[i]; if (bottom > lowest) { lowest = bottom; } } startFrom = lowest; } else { startFrom = mItemBottoms[nextCol]; } final int childTop = startFrom + itemMargin; final int childBottom = childTop + childHeight; final int childLeft = paddingLeft + nextCol * (colWidth + itemMargin); final int childRight = childLeft + child.getMeasuredWidth(); child.layout(childLeft, childTop, childRight, childBottom); for (int i = nextCol; i < nextCol + span; i++) { mItemBottoms[i] = childBottom + rec.getMarginBelow(i - nextCol); } nextCol = getNextColumnDown(); position++; } int lowestView = 0; for (int i = 0; i < mColCount; i++) { if (mItemBottoms[i] > lowestView) { lowestView = mItemBottoms[i]; } } return lowestView - gridBottom; } /** * @return column that the next view filling upwards should occupy. This is the bottom-most * position available for a single-column item. */ final int getNextColumnUp() { int result = -1; int bottomMost = Integer.MIN_VALUE; final int colCount = mColCount; for (int i = colCount - 1; i >= 0; i--) { final int top = mItemTops[i]; if (top > bottomMost) { bottomMost = top; result = i; } } return result; } /** * Return a LayoutRecord for the given position * @param position * @param span * @return */ final LayoutRecord getNextRecordUp(int position, int span) { LayoutRecord rec = mLayoutRecords.get(position); if (rec == null) { rec = new LayoutRecord(); rec.span = span; mLayoutRecords.put(position, rec); } else if (rec.span != span) { throw new IllegalStateException("Invalid LayoutRecord! Record had span=" + rec.span + " but caller requested span=" + span + " for position=" + position); } int targetCol = -1; int bottomMost = Integer.MIN_VALUE; final int colCount = mColCount; for (int i = colCount - span; i >= 0; i--) { int top = Integer.MAX_VALUE; for (int j = i; j < i + span; j++) { final int singleTop = mItemTops[j]; if (singleTop < top) { top = singleTop; } } if (top > bottomMost) { bottomMost = top; targetCol = i; } } rec.column = targetCol; for (int i = 0; i < span; i++) { rec.setMarginBelow(i, mItemTops[i + targetCol] - bottomMost); } return rec; } /** * @return column that the next view filling downwards should occupy. This is the top-most * position available. */ final int getNextColumnDown() { int result = -1; int topMost = Integer.MAX_VALUE; final int colCount = mColCount; for (int i = 0; i < colCount; i++) { final int bottom = mItemBottoms[i]; if (bottom < topMost) { topMost = bottom; result = i; } } return result; } final LayoutRecord getNextRecordDown(int position, int span) { LayoutRecord rec = mLayoutRecords.get(position); if (rec == null) { rec = new LayoutRecord(); rec.span = span; mLayoutRecords.put(position, rec); } else if (rec.span != span) { throw new IllegalStateException("Invalid LayoutRecord! Record had span=" + rec.span + " but caller requested span=" + span + " for position=" + position); } int targetCol = -1; int topMost = Integer.MAX_VALUE; final int colCount = mColCount; for (int i = 0; i <= colCount - span; i++) { int bottom = Integer.MIN_VALUE; for (int j = i; j < i + span; j++) { final int singleBottom = mItemBottoms[j]; if (singleBottom > bottom) { bottom = singleBottom; } } if (bottom < topMost) { topMost = bottom; targetCol = i; } } rec.column = targetCol; for (int i = 0; i < span; i++) { rec.setMarginAbove(i, topMost - mItemBottoms[i + targetCol]); } return rec; } /** * Obtain a populated view from the adapter. If optScrap is non-null and is not * reused it will be placed in the recycle bin. * * @param position position to get view for * @param optScrap Optional scrap view; will be reused if possible * @return A new view, a recycled view from mRecycler, or optScrap */ final View obtainView(int position, View optScrap) { View view = mRecycler.getTransientStateView(position); if (view != null) { return view; } // Reuse optScrap if it's of the right type (and not null) final int optType = optScrap != null ? ((LayoutParams) optScrap.getLayoutParams()).viewType : -1; final int positionViewType = mAdapter.getItemViewType(position); final View scrap = optType == positionViewType ? optScrap : mRecycler.getScrapView(positionViewType); view = mAdapter.getView(position, scrap, this); if (view != scrap && scrap != null) { // The adapter didn't use it; put it back. mRecycler.addScrap(scrap); } ViewGroup.LayoutParams lp = view.getLayoutParams(); if (view.getParent() != this) { if (lp == null) { lp = generateDefaultLayoutParams(); } else if (!checkLayoutParams(lp)) { lp = generateLayoutParams(lp); } } final LayoutParams sglp = (LayoutParams) lp; sglp.position = position; sglp.viewType = positionViewType; return view; } public ListAdapter getAdapter() { return mAdapter; } public void setAdapter(ListAdapter adapter) { if (mAdapter != null) { mAdapter.unregisterDataSetObserver(mObserver); } // TODO: If the new adapter says that there are stable IDs, remove certain layout records // and onscreen views if they have changed instead of removing all of the state here. clearAllState(); mAdapter = adapter; mDataChanged = true; mOldItemCount = mItemCount = adapter != null ? adapter.getCount() : 0; if (adapter != null) { adapter.registerDataSetObserver(mObserver); mRecycler.setViewTypeCount(adapter.getViewTypeCount()); mHasStableIds = adapter.hasStableIds(); } else { mHasStableIds = false; } populate(); } /** * Clear all state because the grid will be used for a completely different set of data. */ private void clearAllState() { // Clear all layout records and views mLayoutRecords.clear(); removeAllViews(); // Reset to the top of the grid resetStateForGridTop(); // Clear recycler because there could be different view types now mRecycler.clear(); } /** * Reset all internal state to be at the top of the grid. */ private void resetStateForGridTop() { // Reset mItemTops and mItemBottoms final int colCount = mColCount; if (mItemTops == null || mItemTops.length != colCount) { mItemTops = new int[colCount]; mItemBottoms = new int[colCount]; } final int top = getPaddingTop(); Arrays.fill(mItemTops, top); Arrays.fill(mItemBottoms, top); // Reset the first visible position in the grid to be item 0 mFirstPosition = 0; mRestoreOffset = 0; } /** * Scroll the list so the first visible position in the grid is the first item in the adapter. */ public void setSelectionToTop() { // Clear out the views (but don't clear out the layout records or recycler because the data // has not changed) removeAllViews(); // Reset to top of grid resetStateForGridTop(); // Start populating again populate(); } @Override protected LayoutParams generateDefaultLayoutParams() { return new LayoutParams(LayoutParams.WRAP_CONTENT); } @Override protected LayoutParams generateLayoutParams(ViewGroup.LayoutParams lp) { return new LayoutParams(lp); } @Override protected boolean checkLayoutParams(ViewGroup.LayoutParams lp) { return lp instanceof LayoutParams; } @Override public ViewGroup.LayoutParams generateLayoutParams(AttributeSet attrs) { return new LayoutParams(getContext(), attrs); } @Override public Parcelable onSaveInstanceState() { final Parcelable superState = super.onSaveInstanceState(); final SavedState ss = new SavedState(superState); final int position = mFirstPosition; ss.position = position; if (position >= 0 && mAdapter != null && position < mAdapter.getCount()) { ss.firstId = mAdapter.getItemId(position); } if (getChildCount() > 0) { ss.topOffset = getChildAt(0).getTop() - mItemMargin - getPaddingTop(); } return ss; } @Override public void onRestoreInstanceState(Parcelable state) { SavedState ss = (SavedState) state; super.onRestoreInstanceState(ss.getSuperState()); mDataChanged = true; mFirstPosition = ss.position; mRestoreOffset = ss.topOffset; requestLayout(); } public static class LayoutParams extends ViewGroup.LayoutParams { private static final int[] LAYOUT_ATTRS = new int[] { android.R.attr.layout_span }; private static final int SPAN_INDEX = 0; /** * The number of columns this item should span */ public int span = 1; /** * Item position this view represents */ int position; /** * Type of this view as reported by the adapter */ int viewType; /** * The column this view is occupying */ int column; /** * The stable ID of the item this view displays */ long id = -1; public LayoutParams(int height) { super(FILL_PARENT, height); if (this.height == FILL_PARENT) { Log.w(TAG, "Constructing LayoutParams with height FILL_PARENT - " + "impossible! Falling back to WRAP_CONTENT"); this.height = WRAP_CONTENT; } } public LayoutParams(Context c, AttributeSet attrs) { super(c, attrs); if (this.width != FILL_PARENT) { Log.w(TAG, "Inflation setting LayoutParams width to " + this.width + " - must be MATCH_PARENT"); this.width = FILL_PARENT; } if (this.height == FILL_PARENT) { Log.w(TAG, "Inflation setting LayoutParams height to MATCH_PARENT - " + "impossible! Falling back to WRAP_CONTENT"); this.height = WRAP_CONTENT; } TypedArray a = c.obtainStyledAttributes(attrs, LAYOUT_ATTRS); span = a.getInteger(SPAN_INDEX, 1); a.recycle(); } public LayoutParams(ViewGroup.LayoutParams other) { super(other); if (this.width != FILL_PARENT) { Log.w(TAG, "Constructing LayoutParams with width " + this.width + " - must be MATCH_PARENT"); this.width = FILL_PARENT; } if (this.height == FILL_PARENT) { Log.w(TAG, "Constructing LayoutParams with height MATCH_PARENT - " + "impossible! Falling back to WRAP_CONTENT"); this.height = WRAP_CONTENT; } } } private class RecycleBin { private ArrayList[] mScrapViews; private int mViewTypeCount; private int mMaxScrap; private SparseArray mTransientStateViews; public void setViewTypeCount(int viewTypeCount) { if (viewTypeCount < 1) { throw new IllegalArgumentException("Must have at least one view type (" + viewTypeCount + " types reported)"); } if (viewTypeCount == mViewTypeCount) { return; } ArrayList[] scrapViews = new ArrayList[viewTypeCount]; for (int i = 0; i < viewTypeCount; i++) { scrapViews[i] = new ArrayList(); } mViewTypeCount = viewTypeCount; mScrapViews = scrapViews; } public void clear() { final int typeCount = mViewTypeCount; for (int i = 0; i < typeCount; i++) { mScrapViews[i].clear(); } if (mTransientStateViews != null) { mTransientStateViews.clear(); } } public void clearTransientViews() { if (mTransientStateViews != null) { mTransientStateViews.clear(); } } public void addScrap(View v) { final LayoutParams lp = (LayoutParams) v.getLayoutParams(); if (ViewCompat.hasTransientState(v)) { if (mTransientStateViews == null) { mTransientStateViews = new SparseArray(); } mTransientStateViews.put(lp.position, v); return; } final int childCount = getChildCount(); if (childCount > mMaxScrap) { mMaxScrap = childCount; } ArrayList scrap = mScrapViews[lp.viewType]; if (scrap.size() < mMaxScrap) { scrap.add(v); } } public View getTransientStateView(int position) { if (mTransientStateViews == null) { return null; } final View result = mTransientStateViews.get(position); if (result != null) { mTransientStateViews.remove(position); } return result; } public View getScrapView(int type) { ArrayList scrap = mScrapViews[type]; if (scrap.isEmpty()) { return null; } final int index = scrap.size() - 1; final View result = scrap.get(index); scrap.remove(index); return result; } } private class AdapterDataSetObserver extends DataSetObserver { @Override public void onChanged() { mDataChanged = true; mOldItemCount = mItemCount; mItemCount = mAdapter.getCount(); // TODO: Consider matching these back up if we have stable IDs. mRecycler.clearTransientViews(); if (!mHasStableIds) { // Clear all layout records and recycle the views mLayoutRecords.clear(); recycleAllViews(); // Reset item bottoms to be equal to item tops final int colCount = mColCount; for (int i = 0; i < colCount; i++) { mItemBottoms[i] = mItemTops[i]; } } // TODO: consider repopulating in a deferred runnable instead // (so that successive changes may still be batched) requestLayout(); } @Override public void onInvalidated() { } } static class SavedState extends BaseSavedState { long firstId = -1; int position; int topOffset; SavedState(Parcelable superState) { super(superState); } private SavedState(Parcel in) { super(in); firstId = in.readLong(); position = in.readInt(); topOffset = in.readInt(); } @Override public void writeToParcel(Parcel out, int flags) { super.writeToParcel(out, flags); out.writeLong(firstId); out.writeInt(position); out.writeInt(topOffset); } @Override public String toString() { return "StaggereGridView.SavedState{" + Integer.toHexString(System.identityHashCode(this)) + " firstId=" + firstId + " position=" + position + "}"; } public static final Parcelable.Creator CREATOR = new Parcelable.Creator() { public SavedState createFromParcel(Parcel in) { return new SavedState(in); } public SavedState[] newArray(int size) { return new SavedState[size]; } }; } }