OSDN Git Service

Thumbnails for PhotoSetPage
authorJohn Reck <jreck@google.com>
Tue, 26 Feb 2013 23:19:30 +0000 (15:19 -0800)
committerJohn Reck <jreck@google.com>
Wed, 27 Feb 2013 01:33:36 +0000 (17:33 -0800)
Change-Id: I8d62b4ca0d0902ca2a18b087a344d35d17a97fa7

AndroidManifest.xml
res/layout/photo_set.xml
src/com/android/photos/PhotoSetFragment.java
src/com/android/photos/drawables/AutoThumbnailDrawable.java [new file with mode: 0644]
src/com/android/photos/views/GalleryThumbnailView.java [new file with mode: 0644]

index 3cd30cb..8145f23 100644 (file)
@@ -7,7 +7,7 @@
 
     <original-package android:name="com.android.gallery3d" />
 
-    <uses-sdk android:minSdkVersion="10" android:targetSdkVersion="16" />
+    <uses-sdk android:minSdkVersion="14" android:targetSdkVersion="17" />
 
     <permission android:name="com.android.gallery3d.permission.GALLERY_PROVIDER"
             android:protectionLevel="signatureOrSystem" />
index 2bb97bb..f6ff637 100644 (file)
@@ -5,7 +5,7 @@
     android:paddingLeft="8dp"
     android:paddingRight="8dp" >
 
-    <ListView
+    <com.android.photos.views.GalleryThumbnailView
         android:id="@id/android:list"
         android:layout_width="match_parent"
         android:layout_height="match_parent"
index e9bfce5..a3406be 100644 (file)
@@ -18,40 +18,41 @@ package com.android.photos;
 
 import android.app.Fragment;
 import android.app.LoaderManager.LoaderCallbacks;
+import android.content.Context;
 import android.content.Loader;
 import android.database.Cursor;
 import android.os.Bundle;
-import android.provider.MediaStore.Files.FileColumns;
+import android.util.DisplayMetrics;
 import android.view.LayoutInflater;
 import android.view.View;
 import android.view.ViewGroup;
 import android.widget.CursorAdapter;
-import android.widget.ListView;
-import android.widget.SimpleCursorAdapter;
+import android.widget.ImageView;
+import android.widget.ImageView.ScaleType;
 
 import com.android.gallery3d.R;
 import com.android.photos.data.PhotoSetLoader;
+import com.android.photos.drawables.AutoThumbnailDrawable;
+import com.android.photos.views.GalleryThumbnailView;
+import com.android.photos.views.GalleryThumbnailView.GalleryThumbnailAdapter;
 
 
 public class PhotoSetFragment extends Fragment implements LoaderCallbacks<Cursor> {
 
     private static final int LOADER_PHOTOSET = 1;
 
-    private ListView mPhotoSetView;
+    private GalleryThumbnailView mPhotoSetView;
     private View mEmptyView;
-    private CursorAdapter mAdapter;
+    private ThumbnailAdapter mAdapter;
 
     @Override
     public View onCreateView(LayoutInflater inflater, ViewGroup container,
             Bundle savedInstanceState) {
         View root = inflater.inflate(R.layout.photo_set, container, false);
-        mPhotoSetView = (ListView) root.findViewById(android.R.id.list);
+        mPhotoSetView = (GalleryThumbnailView) root.findViewById(android.R.id.list);
         mEmptyView = root.findViewById(android.R.id.empty);
         mEmptyView.setVisibility(View.GONE);
-        mAdapter = new SimpleCursorAdapter(getActivity(),
-                android.R.layout.simple_list_item_1, null,
-                new String[] { FileColumns.DATA },
-                new int[] { android.R.id.text1 }, 0);
+        mAdapter = new ThumbnailAdapter(getActivity());
         mPhotoSetView.setAdapter(mAdapter);
         getLoaderManager().initLoader(LOADER_PHOTOSET, null, this);
         updateEmptyStatus();
@@ -79,4 +80,44 @@ public class PhotoSetFragment extends Fragment implements LoaderCallbacks<Cursor
     @Override
     public void onLoaderReset(Loader<Cursor> loader) {
     }
+
+    private static class ThumbnailAdapter extends CursorAdapter implements GalleryThumbnailAdapter {
+
+        public ThumbnailAdapter(Context context) {
+            super(context, null, false);
+        }
+
+        @Override
+        public void bindView(View view, Context context, Cursor cursor) {
+            ImageView iv = (ImageView) view;
+            AutoThumbnailDrawable drawable = (AutoThumbnailDrawable) iv.getDrawable();
+            int width = cursor.getInt(PhotoSetLoader.INDEX_WIDTH);
+            int height = cursor.getInt(PhotoSetLoader.INDEX_HEIGHT);
+            String path = cursor.getString(PhotoSetLoader.INDEX_DATA);
+            drawable.setImage(path, width, height);
+        }
+
+        @Override
+        public View newView(Context context, Cursor cursor, ViewGroup parent) {
+            ImageView iv = new ImageView(context);
+            AutoThumbnailDrawable drawable = new AutoThumbnailDrawable();
+            iv.setImageDrawable(drawable);
+            int padding = (int) Math.ceil(2 * context.getResources().getDisplayMetrics().density);
+            iv.setPadding(padding, padding, padding, padding);
+            return iv;
+        }
+
+        @Override
+        public float getIntrinsicAspectRatio(int position) {
+            Cursor cursor = getItem(position);
+            float width = cursor.getInt(PhotoSetLoader.INDEX_WIDTH);
+            float height = cursor.getInt(PhotoSetLoader.INDEX_HEIGHT);
+            return width / height;
+        }
+
+        @Override
+        public Cursor getItem(int position) {
+            return (Cursor) super.getItem(position);
+        }
+    }
 }
diff --git a/src/com/android/photos/drawables/AutoThumbnailDrawable.java b/src/com/android/photos/drawables/AutoThumbnailDrawable.java
new file mode 100644 (file)
index 0000000..28bf51f
--- /dev/null
@@ -0,0 +1,268 @@
+/*
+ * Copyright (C) 2013 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.photos.drawables;
+
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.graphics.Canvas;
+import android.graphics.ColorFilter;
+import android.graphics.Matrix;
+import android.graphics.Paint;
+import android.graphics.PixelFormat;
+import android.graphics.Rect;
+import android.graphics.drawable.Drawable;
+import android.media.ExifInterface;
+import android.text.TextUtils;
+import android.util.Log;
+
+import java.io.FileDescriptor;
+import java.io.FileInputStream;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+
+public class AutoThumbnailDrawable extends Drawable {
+
+    private static final String TAG = "AutoMipMapDrawable";
+
+    private static ExecutorService sThreadPool = Executors.newSingleThreadExecutor();
+    private static byte[] sTempStorage = new byte[64 * 1024];
+
+    private Bitmap mBitmap;
+    private Paint mPaint = new Paint();
+    private String mDataUri;
+    private boolean mIsQueued;
+    private int mImageWidth, mImageHeight;
+    private BitmapFactory.Options mOptions = new BitmapFactory.Options();
+    private Rect mBounds = new Rect();
+    private Matrix mDrawMatrix = new Matrix();
+    private int mSampleSize = 1;
+
+    public AutoThumbnailDrawable() {
+        mPaint.setAntiAlias(true);
+        mPaint.setFilterBitmap(true);
+        mDrawMatrix.reset();
+        mOptions.inTempStorage = sTempStorage;
+    }
+
+    public void setImage(String dataUri, int width, int height) {
+        if (TextUtils.equals(mDataUri, dataUri)) return;
+        synchronized (this) {
+            mImageWidth = width;
+            mImageHeight = height;
+            mDataUri = dataUri;
+            mBitmap = null;
+            refreshSampleSizeLocked();
+        }
+        invalidateSelf();
+    }
+
+    @Override
+    protected void onBoundsChange(Rect bounds) {
+        super.onBoundsChange(bounds);
+        synchronized (this) {
+            mBounds.set(bounds);
+            if (mBounds.isEmpty()) {
+                mBitmap = null;
+            } else {
+                refreshSampleSizeLocked();
+                updateDrawMatrixLocked();
+            }
+        }
+        invalidateSelf();
+    }
+
+    @Override
+    public void draw(Canvas canvas) {
+        if (mBitmap != null) {
+            canvas.save();
+            canvas.clipRect(mBounds);
+            canvas.concat(mDrawMatrix);
+            canvas.drawBitmap(mBitmap, 0, 0, mPaint);
+            canvas.restore();
+        } else {
+            // TODO: Draw placeholder...?
+        }
+    }
+
+    private void updateDrawMatrixLocked() {
+        if (mBitmap == null || mBounds.isEmpty()) {
+            mDrawMatrix.reset();
+            return;
+        }
+
+        float scale;
+        float dx = 0, dy = 0;
+
+        int dwidth = mBitmap.getWidth();
+        int dheight = mBitmap.getHeight();
+        int vwidth = mBounds.width();
+        int vheight = mBounds.height();
+
+        // Calculates a matrix similar to ScaleType.CENTER_CROP
+        if (dwidth * vheight > vwidth * dheight) {
+            scale = (float) vheight / (float) dheight;
+            dx = (vwidth - dwidth * scale) * 0.5f;
+        } else {
+            scale = (float) vwidth / (float) dwidth;
+            dy = (vheight - dheight * scale) * 0.5f;
+        }
+        if (scale < .8f) {
+            Log.w(TAG, "sample size was too small! Overdrawing! " + scale + ", " + mSampleSize);
+        } else if (scale > 1.5f) {
+            Log.w(TAG, "Potential quality loss! " + scale + ", " + mSampleSize);
+        }
+
+        mDrawMatrix.setScale(scale, scale);
+        mDrawMatrix.postTranslate((int) (dx + 0.5f), (int) (dy + 0.5f));
+    }
+
+    private int calculateSampleSizeLocked(int dwidth, int dheight) {
+        float scale;
+
+        int vwidth = mBounds.width();
+        int vheight = mBounds.height();
+
+        // Inverse of updateDrawMatrixLocked
+        if (dwidth * vheight > vwidth * dheight) {
+            scale = (float) dheight / (float) vheight;
+        } else {
+            scale = (float) dwidth / (float) vwidth;
+        }
+        return (int) (scale + .5f);
+    }
+
+    private void refreshSampleSizeLocked() {
+        if (mBounds.isEmpty()) return;
+
+        int sampleSize = calculateSampleSizeLocked(mImageWidth, mImageHeight);
+        if (sampleSize != mSampleSize || mBitmap == null) {
+            mSampleSize = sampleSize;
+            loadBitmapLocked();
+        }
+    }
+
+    private void loadBitmapLocked() {
+        if (!mIsQueued && !mBounds.isEmpty()) {
+            unscheduleSelf(mUpdateBitmap);
+            sThreadPool.execute(mLoadBitmap);
+            mIsQueued = true;
+        }
+    }
+
+    public float getAspectRatio() {
+        return (float) mImageWidth / (float) mImageHeight;
+    }
+
+    @Override
+    public int getIntrinsicWidth() {
+        return -1;
+    }
+
+    @Override
+    public int getIntrinsicHeight() {
+        return -1;
+    }
+
+    @Override
+    public int getOpacity() {
+        Bitmap bm = mBitmap;
+        return (bm == null || bm.hasAlpha() || mPaint.getAlpha() < 255) ?
+                PixelFormat.TRANSLUCENT : PixelFormat.OPAQUE;
+    }
+
+    @Override
+    public void setAlpha(int alpha) {
+        int oldAlpha = mPaint.getAlpha();
+        if (alpha != oldAlpha) {
+            mPaint.setAlpha(alpha);
+            invalidateSelf();
+        }
+    }
+
+    @Override
+    public void setColorFilter(ColorFilter cf) {
+        mPaint.setColorFilter(cf);
+        invalidateSelf();
+    }
+
+    private final Runnable mLoadBitmap = new Runnable() {
+        @Override
+        public void run() {
+            // TODO: Use bitmap pool
+            String data;
+            int sampleSize;
+            synchronized (this) {
+                data = mDataUri;
+                sampleSize = calculateSampleSizeLocked(mImageWidth, mImageHeight);
+                mSampleSize = sampleSize;
+                mIsQueued = false;
+            }
+            FileInputStream fis = null;
+            try {
+                ExifInterface exif = new ExifInterface(data);
+                if (exif.hasThumbnail()) {
+                    byte[] thumbnail = exif.getThumbnail();
+                    mOptions.inJustDecodeBounds = true;
+                    BitmapFactory.decodeByteArray(thumbnail, 0,
+                            thumbnail.length, mOptions);
+                    int exifThumbSampleSize = calculateSampleSizeLocked(
+                            mOptions.outWidth, mOptions.outHeight);
+                    mOptions.inJustDecodeBounds = false;
+                    mOptions.inSampleSize = exifThumbSampleSize;
+                    mBitmap = BitmapFactory.decodeByteArray(thumbnail, 0,
+                            thumbnail.length, mOptions);
+                    if (mBitmap != null) {
+                        synchronized (this) {
+                            if (TextUtils.equals(data, mDataUri)) {
+                                scheduleSelf(mUpdateBitmap, 0);
+                            }
+                        }
+                        return;
+                    }
+                }
+                fis = new FileInputStream(data);
+                FileDescriptor fd = fis.getFD();
+                mOptions.inSampleSize = sampleSize;
+                mBitmap = BitmapFactory.decodeFileDescriptor(fd, null, mOptions);
+            } catch (Exception e) {
+                Log.d("AsyncBitmap", "Failed to fetch bitmap", e);
+                return;
+            } finally {
+                try {
+                    fis.close();
+                } catch (Exception e) {}
+            }
+            synchronized (this) {
+                if (TextUtils.equals(data, mDataUri)) {
+                    scheduleSelf(mUpdateBitmap, 0);
+                }
+            }
+        }
+    };
+
+    private final Runnable mUpdateBitmap = new Runnable() {
+
+        @Override
+        public void run() {
+            synchronized (AutoThumbnailDrawable.this) {
+                updateDrawMatrixLocked();
+                invalidateSelf();
+            }
+        }
+    };
+
+}
diff --git a/src/com/android/photos/views/GalleryThumbnailView.java b/src/com/android/photos/views/GalleryThumbnailView.java
new file mode 100644 (file)
index 0000000..e5dd6f2
--- /dev/null
@@ -0,0 +1,883 @@
+/*
+ * Copyright (C) 2013 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.photos.views;
+
+import android.content.Context;
+import android.content.res.TypedArray;
+import android.database.DataSetObserver;
+import android.graphics.Canvas;
+import android.support.v4.view.MotionEventCompat;
+import android.support.v4.view.VelocityTrackerCompat;
+import android.support.v4.view.ViewCompat;
+import android.support.v4.widget.EdgeEffectCompat;
+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 android.widget.OverScroller;
+
+import java.util.ArrayList;
+
+public class GalleryThumbnailView extends ViewGroup {
+
+    public interface GalleryThumbnailAdapter extends ListAdapter {
+        /**
+         * @param position Position to get the intrinsic aspect ratio for
+         * @return width / height
+         */
+        float getIntrinsicAspectRatio(int position);
+    }
+
+    private static final String TAG = "GalleryThumbnailView";
+    private static final float ASPECT_RATIO = (float) Math.sqrt(1.5f);
+    private static final int LAND_UNITS = 2;
+    private static final int PORT_UNITS = 3;
+
+    private GalleryThumbnailAdapter mAdapter;
+
+    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 boolean mPopulating;
+    private boolean mInLayout;
+
+    private int mTouchSlop;
+    private int mMaximumVelocity;
+    private int mFlingVelocity;
+    private float mLastTouchX;
+    private float mTouchRemainderX;
+    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 OverScroller mScroller;
+
+    private final EdgeEffectCompat mLeftEdge;
+    private final EdgeEffectCompat mRightEdge;
+
+    private int mLargeColumnWidth;
+    private int mSmallColumnWidth;
+    private int mLargeColumnUnitCount = 8;
+    private int mSmallColumnUnitCount = 10;
+
+    public GalleryThumbnailView(Context context) {
+        this(context, null);
+    }
+
+    public GalleryThumbnailView(Context context, AttributeSet attrs) {
+        this(context, attrs, 0);
+    }
+
+    public GalleryThumbnailView(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 = new OverScroller(context);
+
+        mLeftEdge = new EdgeEffectCompat(context);
+        mRightEdge = new EdgeEffectCompat(context);
+        setWillNotDraw(false);
+        setClipToPadding(false);
+    }
+
+    @Override
+    public void requestLayout() {
+        if (!mPopulating) {
+            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);
+        }
+        if (heightMode != MeasureSpec.EXACTLY) {
+            Log.e(TAG, "onMeasure: must have an exact height or match_parent! " +
+                    "Using fallback spec of EXACTLY " + heightSize);
+        }
+
+        setMeasuredDimension(widthSize, heightSize);
+
+        float portSpaces = mLargeColumnUnitCount / PORT_UNITS;
+        float height = getMeasuredHeight() / portSpaces;
+        mLargeColumnWidth = (int) (height / ASPECT_RATIO);
+        portSpaces++;
+        height = getMeasuredHeight() / portSpaces;
+        mSmallColumnWidth = (int) (height / ASPECT_RATIO);
+    }
+
+    @Override
+    protected void onLayout(boolean changed, int l, int t, int r, int b) {
+        mInLayout = true;
+        populate();
+        mInLayout = false;
+
+        final int width = r - l;
+        final int height = b - t;
+        mLeftEdge.setSize(width, height);
+        mRightEdge.setSize(width, height);
+    }
+
+    private void populate() {
+        if (getWidth() == 0 || getHeight() == 0) {
+            return;
+        }
+
+        // TODO: Handle size changing
+//        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);
+        fillRight(mFirstPosition + getChildCount(), 0);
+        fillLeft(mFirstPosition - 1, 0);
+        mPopulating = false;
+        mDataChanged = false;
+    }
+
+    final void layoutChildren(boolean queryAdapter) {
+// TODO
+//        final int childCount = getChildCount();
+//        for (int i = 0; i < childCount; i++) {
+//            View child = getChildAt(i);
+//
+//            if (child.isLayoutRequested()) {
+//                final int widthSpec = MeasureSpec.makeMeasureSpec(child.getMeasuredWidth(), MeasureSpec.EXACTLY);
+//                final int heightSpec = MeasureSpec.makeMeasureSpec(child.getMeasuredHeight(), MeasureSpec.EXACTLY);
+//                child.measure(widthSpec, heightSpec);
+//                child.layout(child.getLeft(), child.getTop(), child.getRight(), child.getBottom());
+//            }
+//
+//            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);
+//        }
+    }
+
+    /**
+     * Obtain the view and add it to our list of children. The view can be made
+     * fresh, converted from an unused view, or used as is if it was in the
+     * recycle bin.
+     *
+     * @param startPosition Logical position in the list to start from
+     * @param x Left or right edge of the view to add
+     * @param forward If true, align left edge to x and increase position.
+     *                If false, align right edge to x and decrease position.
+     * @return Number of views added
+     */
+    private int makeAndAddColumn(int startPosition, int x, boolean forward) {
+        int columnWidth = mLargeColumnWidth;
+        int addViews = 0;
+        for (int remaining = mLargeColumnUnitCount, i = 0;
+                remaining > 0 && startPosition + i >= 0 && startPosition + i < mItemCount;
+                i += forward ? 1 : -1, addViews++) {
+            if (mAdapter.getIntrinsicAspectRatio(startPosition + i) >= 1f) {
+                // landscape
+                remaining -= LAND_UNITS;
+            } else {
+                // portrait
+                remaining -= PORT_UNITS;
+                if (remaining < 0) {
+                    remaining += (mSmallColumnUnitCount - mLargeColumnUnitCount);
+                    columnWidth = mSmallColumnWidth;
+                }
+            }
+        }
+        int nextTop = 0;
+        for (int i = 0; i < addViews; i++) {
+            int position = startPosition + (forward ? i : -i);
+            View child = obtainView(position, null);
+            if (child.getParent() != this) {
+                if (mInLayout) {
+                    addViewInLayout(child, forward ? -1 : 0, child.getLayoutParams());
+                } else {
+                    addView(child, forward ? -1 : 0);
+                }
+            }
+            int heightSize = (int) (.5f + (mAdapter.getIntrinsicAspectRatio(position) >= 1f
+                    ? columnWidth / ASPECT_RATIO
+                    : columnWidth * ASPECT_RATIO));
+            int heightSpec = MeasureSpec.makeMeasureSpec(heightSize, MeasureSpec.EXACTLY);
+            int widthSpec = MeasureSpec.makeMeasureSpec(columnWidth, MeasureSpec.EXACTLY);
+            child.measure(widthSpec, heightSpec);
+            int childLeft = forward ? x : x - columnWidth;
+            child.layout(childLeft, nextTop, childLeft + columnWidth, nextTop + heightSize);
+            nextTop += heightSize;
+        }
+        return addViews;
+    }
+
+    @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();
+                mLastTouchX = ev.getX();
+                mActivePointerId = MotionEventCompat.getPointerId(ev, 0);
+                mTouchRemainderX = 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 x = MotionEventCompat.getX(ev, index);
+                final float dx = x - mLastTouchX + mTouchRemainderX;
+                final int deltaY = (int) dx;
+                mTouchRemainderX = dx - deltaY;
+
+                if (Math.abs(dx) > 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();
+                mLastTouchX = ev.getX();
+                mActivePointerId = MotionEventCompat.getPointerId(ev, 0);
+                mTouchRemainderX = 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 x = MotionEventCompat.getX(ev, index);
+                final float dx = x - mLastTouchX + mTouchRemainderX;
+                final int deltaX = (int) dx;
+                mTouchRemainderX = dx - deltaX;
+
+                if (Math.abs(dx) > mTouchSlop) {
+                    mTouchMode = TOUCH_MODE_DRAGGING;
+                }
+
+                if (mTouchMode == TOUCH_MODE_DRAGGING) {
+                    mLastTouchX = x;
+
+                    if (!trackMotionScroll(deltaX, 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.getXVelocity(mVelocityTracker,
+                        mActivePointerId);
+                if (Math.abs(velocity) > mFlingVelocity) { // TODO
+                    mTouchMode = TOUCH_MODE_FLINGING;
+                    mScroller.fling(0, 0, (int) velocity, 0,
+                            Integer.MIN_VALUE, Integer.MAX_VALUE, 0, 0);
+                    mLastTouchX = 0;
+                    ViewCompat.postInvalidateOnAnimation(this);
+                } else {
+                    mTouchMode = TOUCH_MODE_IDLE;
+                }
+
+            } break;
+        }
+        return true;
+    }
+
+    /**
+     *
+     * @param deltaX Pixels that content should move by
+     * @return true if the movement completed, false if it was stopped prematurely.
+     */
+    private boolean trackMotionScroll(int deltaX, boolean allowOverScroll) {
+        final boolean contentFits = contentFits();
+        final int allowOverhang = Math.abs(deltaX);
+
+        final int overScrolledBy;
+        final int movedBy;
+        if (!contentFits) {
+            final int overhang;
+            final boolean up;
+            mPopulating = true;
+            if (deltaX > 0) {
+                overhang = fillLeft(mFirstPosition - 1, allowOverhang);
+                up = true;
+            } else {
+                overhang = fillRight(mFirstPosition + getChildCount(), allowOverhang);
+                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 = deltaX > 0 ? mLeftEdge : mRightEdge;
+                    edge.onPull((float) Math.abs(deltaX) / getWidth());
+                    ViewCompat.postInvalidateOnAnimation(this);
+                }
+            }
+        }
+
+        return deltaX == 0 || movedBy != 0;
+    }
+
+    /**
+     * 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 = 0;
+        final int clearBelow = height;
+        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 void offsetChildren(int offset) {
+        final int childCount = getChildCount();
+        for (int i = 0; i < childCount; i++) {
+            final View child = getChildAt(i);
+            child.layout(child.getLeft() + offset, child.getTop(),
+                    child.getRight() + offset, child.getBottom());
+        }
+    }
+
+    private boolean contentFits() {
+        final int childCount = getChildCount();
+        if (childCount == 0) return true;
+        if (childCount != mItemCount) return false;
+
+        return getChildAt(0).getLeft() >= getPaddingLeft() &&
+                getChildAt(childCount - 1).getRight() <= getWidth() - getPaddingRight();
+    }
+
+    private void recycleAllViews() {
+        for (int i = 0; i < getChildCount(); i++) {
+            mRecycler.addScrap(getChildAt(i));
+        }
+
+        if (mInLayout) {
+            removeAllViewsInLayout();
+        } else {
+            removeAllViews();
+        }
+    }
+
+    private int fillRight(int pos, int overhang) {
+        int end = (getRight() - getLeft()) + overhang;
+
+        int nextLeft = getChildCount() == 0 ? 0 : getChildAt(getChildCount() - 1).getRight();
+        while (nextLeft < end && pos < mItemCount) {
+            pos += makeAndAddColumn(pos, nextLeft, true);
+            nextLeft = getChildAt(getChildCount() - 1).getRight();
+        }
+        final int gridRight = getWidth() - getPaddingRight();
+        return getChildAt(getChildCount() - 1).getRight() - gridRight;
+    }
+
+    private int fillLeft(int pos, int overhang) {
+        int end = getPaddingLeft() - overhang;
+
+        int nextRight = getChildAt(0).getLeft();
+        while (nextRight > end && pos >= 0) {
+            pos -= makeAndAddColumn(pos, nextRight, false);
+            nextRight = getChildAt(0).getLeft();
+        }
+
+        mFirstPosition = pos + 1;
+        return getPaddingLeft() - getChildAt(0).getLeft();
+    }
+
+    @Override
+    public void computeScroll() {
+        if (mScroller.computeScrollOffset()) {
+            final int x = mScroller.getCurrX();
+            final int dx = (int) (x - mLastTouchX);
+            mLastTouchX = x;
+            final boolean stopped = !trackMotionScroll(dx, 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 (dx > 0) {
+                            edge = mLeftEdge;
+                        } else {
+                            edge = mRightEdge;
+                        }
+                        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 (!mLeftEdge.isFinished()) {
+            final int restoreCount = canvas.save();
+            final int height = getHeight() - getPaddingTop() - getPaddingBottom();
+
+            canvas.rotate(270);
+            canvas.translate(-height + getPaddingTop(), 0);
+            mLeftEdge.setSize(height, getWidth());
+            if (mLeftEdge.draw(canvas)) {
+                postInvalidateOnAnimation();
+            }
+            canvas.restoreToCount(restoreCount);
+        }
+        if (!mRightEdge.isFinished()) {
+            final int restoreCount = canvas.save();
+            final int width = getWidth();
+            final int height = getHeight() - getPaddingTop() - getPaddingBottom();
+
+            canvas.rotate(90);
+            canvas.translate(-getPaddingTop(), width);
+            mRightEdge.setSize(height, width);
+            if (mRightEdge.draw(canvas)) {
+                postInvalidateOnAnimation();
+            }
+            canvas.restoreToCount(restoreCount);
+        }
+    }
+
+    /**
+     * 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
+     */
+    private 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);
+            }
+            view.setLayoutParams(lp);
+        }
+
+        final LayoutParams sglp = (LayoutParams) lp;
+        sglp.position = position;
+        sglp.viewType = positionViewType;
+
+        return view;
+    }
+
+    public GalleryThumbnailAdapter getAdapter() {
+        return mAdapter;
+    }
+
+    public void setAdapter(GalleryThumbnailAdapter 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
+        removeAllViews();
+
+        // Reset to the top of the grid
+        mFirstPosition = 0;
+
+        // Clear recycler because there could be different view types now
+        mRecycler.clear();
+    }
+
+    @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);
+    }
+
+    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(MATCH_PARENT, height);
+
+            if (this.height == MATCH_PARENT) {
+                Log.w(TAG, "Constructing LayoutParams with height MATCH_PARENT - " +
+                        "impossible! Falling back to WRAP_CONTENT");
+                this.height = WRAP_CONTENT;
+            }
+        }
+
+        public LayoutParams(Context c, AttributeSet attrs) {
+            super(c, attrs);
+
+            if (this.width != MATCH_PARENT) {
+                Log.w(TAG, "Inflation setting LayoutParams width to " + this.width +
+                        " - must be MATCH_PARENT");
+                this.width = MATCH_PARENT;
+            }
+            if (this.height == MATCH_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 != MATCH_PARENT) {
+                Log.w(TAG, "Constructing LayoutParams with width " + this.width +
+                        " - must be MATCH_PARENT");
+                this.width = MATCH_PARENT;
+            }
+            if (this.height == MATCH_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<View>[] mScrapViews;
+        private int mViewTypeCount;
+        private int mMaxScrap;
+
+        private SparseArray<View> 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<View>[] scrapViews = new ArrayList[viewTypeCount];
+            for (int i = 0; i < viewTypeCount; i++) {
+                scrapViews[i] = new ArrayList<View>();
+            }
+            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<View>();
+                }
+                mTransientStateViews.put(lp.position, v);
+                return;
+            }
+
+            final int childCount = getChildCount();
+            if (childCount > mMaxScrap) {
+                mMaxScrap = childCount;
+            }
+
+            ArrayList<View> 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<View> 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) {
+                recycleAllViews();
+            }
+
+            // TODO: consider repopulating in a deferred runnable instead
+            // (so that successive changes may still be batched)
+            requestLayout();
+        }
+
+        @Override
+        public void onInvalidated() {
+        }
+    }
+}