OSDN Git Service

Add new filmstrip mode for PhotoView.
authorChih-Chung Chang <chihchung@google.com>
Tue, 3 Apr 2012 04:21:16 +0000 (12:21 +0800)
committerChih-Chung Chang <chihchung@google.com>
Tue, 10 Apr 2012 11:24:53 +0000 (19:24 +0800)
Change-Id: I9da9896303ced8d63a3557d5e6e9bc06fb366cf5

src/com/android/gallery3d/app/PhotoDataAdapter.java
src/com/android/gallery3d/app/SinglePhotoDataAdapter.java
src/com/android/gallery3d/ui/AnimationTime.java
src/com/android/gallery3d/ui/BitmapScreenNail.java
src/com/android/gallery3d/ui/EdgeView.java
src/com/android/gallery3d/ui/GLRootView.java
src/com/android/gallery3d/ui/PhotoView.java
src/com/android/gallery3d/ui/PositionController.java
src/com/android/gallery3d/util/RangeArray.java [new file with mode: 0644]
src/com/android/gallery3d/util/RangeBoolArray.java [new file with mode: 0644]
src/com/android/gallery3d/util/RangeIntArray.java [new file with mode: 0644]

index f4bf5a5..10ed8f3 100644 (file)
@@ -58,13 +58,12 @@ public class PhotoDataAdapter implements PhotoPage.Model {
 
     private static final int MIN_LOAD_COUNT = 8;
     private static final int DATA_CACHE_SIZE = 32;
-    private static final int IMAGE_CACHE_SIZE = 5;
+    private static final int SCREEN_NAIL_MAX = PhotoView.SCREEN_NAIL_MAX;
+    private static final int IMAGE_CACHE_SIZE = 2 * SCREEN_NAIL_MAX + 1;
 
     private static final int BIT_SCREEN_NAIL = 1;
     private static final int BIT_FULL_IMAGE = 2;
 
-    private static final long VERSION_OUT_OF_RANGE = MediaObject.nextVersionNumber();
-
     // sImageFetchSeq is the fetching sequence for images.
     // We want to fetch the current screennail first (offset = 0), the next
     // screennail (offset = +1), then the previous screennail (offset = -1) etc.
@@ -129,9 +128,9 @@ public class PhotoDataAdapter implements PhotoPage.Model {
     private int mCurrentIndex;
 
     // mChanges keeps the version number (of MediaItem) about the previous,
-    // current, and next image. If the version number changes, we invalidate
-    // the model. This is used after a database reload or mCurrentIndex changes.
-    private final long mChanges[] = new long[3];
+    // current, and next image. If the version number changes, we notify the
+    // view. This is used after a database reload or mCurrentIndex changes.
+    private final long mChanges[] = new long[IMAGE_CACHE_SIZE];
 
     private final Handler mMainHandler;
     private final ThreadPool mThreadPool;
@@ -193,7 +192,7 @@ public class PhotoDataAdapter implements PhotoPage.Model {
     }
 
     private long getVersion(int index) {
-        if (index < 0 || index >= mSize) return VERSION_OUT_OF_RANGE;
+        if (index < 0 || index >= mSize) return MediaObject.INVALID_DATA_VERSION;
         if (index >= mContentStart && index < mContentEnd) {
             MediaItem item = mData[index % DATA_CACHE_SIZE];
             if (item != null) return item.getDataVersion();
@@ -201,15 +200,11 @@ public class PhotoDataAdapter implements PhotoPage.Model {
         return MediaObject.INVALID_DATA_VERSION;
     }
 
-    private void fireModelInvalidated() {
-        for (int i = -1; i <= 1; ++i) {
-            long current = getVersion(mCurrentIndex + i);
-            long change = mChanges[i + 1];
-            if (current != change) {
-                mPhotoView.notifyImageInvalidated(i);
-                mChanges[i + 1] = current;
-            }
+    private void fireDataChange() {
+        for (int i = -SCREEN_NAIL_MAX; i <= SCREEN_NAIL_MAX; ++i) {
+            mChanges[i + SCREEN_NAIL_MAX] = getVersion(mCurrentIndex + i);
         }
+        mPhotoView.notifyDataChange(mChanges);
     }
 
     public void setDataListener(DataListener listener) {
@@ -235,10 +230,12 @@ public class PhotoDataAdapter implements PhotoPage.Model {
         if (mDataListener != null) {
             mDataListener.onPhotoAvailable(version, false);
         }
-        for (int i = -1; i <= 1; ++i) {
+
+        for (int i = -SCREEN_NAIL_MAX; i <= SCREEN_NAIL_MAX; ++i) {
             if (version == getVersion(mCurrentIndex + i)) {
                 if (i == 0) updateTileProvider(entry);
-                mPhotoView.notifyImageInvalidated(i);
+                mPhotoView.notifyImageChange(i);
+                break;
             }
         }
         updateImageRequests();
@@ -260,7 +257,7 @@ public class PhotoDataAdapter implements PhotoPage.Model {
             }
             if (version == getVersion(mCurrentIndex)) {
                 updateTileProvider(entry);
-                mPhotoView.notifyImageInvalidated(0);
+                mPhotoView.notifyImageChange(0);
             }
         }
         updateImageRequests();
@@ -275,7 +272,7 @@ public class PhotoDataAdapter implements PhotoPage.Model {
         mReloadTask = new ReloadTask();
         mReloadTask.start();
 
-        mPhotoView.notifyModelInvalidated();
+        fireDataChange();
     }
 
     public void pause() {
@@ -302,12 +299,8 @@ public class PhotoDataAdapter implements PhotoPage.Model {
         return entry == null ? null : entry.screenNail;
     }
 
-    public ScreenNail getPrevScreenNail() {
-        return getImage(mCurrentIndex - 1);
-    }
-
-    public ScreenNail getNextScreenNail() {
-        return getImage(mCurrentIndex + 1);
+    public ScreenNail getScreenNail(int offset) {
+        return getImage(mCurrentIndex + offset);
     }
 
     private void updateCurrentIndex(int index) {
@@ -320,12 +313,12 @@ public class PhotoDataAdapter implements PhotoPage.Model {
         updateImageCache();
         updateImageRequests();
         updateTileProvider();
-        mPhotoView.notifyOnNewImage();
 
         if (mDataListener != null) {
             mDataListener.onPhotoChanged(index, mItemPath);
         }
-        fireModelInvalidated();
+
+        fireDataChange();
     }
 
     public void next() {
@@ -384,7 +377,7 @@ public class PhotoDataAdapter implements PhotoPage.Model {
         mCurrentIndex = indexHint;
         updateSlidingWindow();
         updateImageCache();
-        fireModelInvalidated();
+        fireDataChange();
 
         // We need to reload content if the path doesn't match.
         MediaItem item = getCurrentMediaItem();
@@ -735,7 +728,7 @@ public class PhotoDataAdapter implements PhotoPage.Model {
             updateImageCache();
             updateTileProvider();
             updateImageRequests();
-            fireModelInvalidated();
+            fireDataChange();
             return null;
         }
 
@@ -743,12 +736,8 @@ public class PhotoDataAdapter implements PhotoPage.Model {
             if (mSize == 0) return;
             if (mCurrentIndex >= mSize) {
                 mCurrentIndex = mSize - 1;
-                mPhotoView.notifyOnNewImage();
-                mPhotoView.startSlideInAnimation(PhotoView.TRANS_SLIDE_IN_LEFT);
-            } else {
-                mPhotoView.notifyOnNewImage();
-                mPhotoView.startSlideInAnimation(PhotoView.TRANS_SLIDE_IN_RIGHT);
             }
+            fireDataChange();
         }
     }
 
index ad0d31a..6ef5040 100644 (file)
@@ -118,7 +118,7 @@ public class SinglePhotoDataAdapter extends TileImageViewAdapter
             setScreenNail(bundle.backupImage,
                     bundle.decoder.getWidth(), bundle.decoder.getHeight());
             setRegionDecoder(bundle.decoder);
-            mPhotoView.notifyImageInvalidated(0);
+            mPhotoView.notifyImageChange(0);
         } catch (Throwable t) {
             Log.w(TAG, "fail to decode large", t);
         }
@@ -129,8 +129,7 @@ public class SinglePhotoDataAdapter extends TileImageViewAdapter
             Bitmap backup = future.get();
             if (backup == null) return;
             setScreenNail(backup, backup.getWidth(), backup.getHeight());
-            mPhotoView.notifyOnNewImage();
-            mPhotoView.notifyImageInvalidated(0); // the current image
+            mPhotoView.notifyImageChange(0);
         } catch (Throwable t) {
             Log.w(TAG, "fail to decode thumb", t);
         }
@@ -158,11 +157,7 @@ public class SinglePhotoDataAdapter extends TileImageViewAdapter
         }
     }
 
-    public ScreenNail getNextScreenNail() {
-        return null;
-    }
-
-    public ScreenNail getPrevScreenNail() {
+    public ScreenNail getScreenNail(int offset) {
         return null;
     }
 
index 64ee27c..0636774 100644 (file)
@@ -1,3 +1,4 @@
+
 /*
  * Copyright (C) 2012 The Android Open Source Project
  *
@@ -36,4 +37,9 @@ public class AnimationTime {
     public static long get() {
         return sTime;
     }
+
+    public static long startTime() {
+        sTime = SystemClock.uptimeMillis();
+        return sTime;
+    }
 }
index 5a10068..3481aa1 100644 (file)
@@ -63,6 +63,7 @@ public class BitmapScreenNail implements ScreenNail {
     public void pauseDraw() {
         if (mTexture != null) {
             mTexture.recycle();
+            mTexture = null;
         }
     }
 
index db6a45c..bf97108 100644 (file)
@@ -23,6 +23,7 @@ import android.opengl.Matrix;
 public class EdgeView extends GLView {
     private static final String TAG = "EdgeView";
 
+    public static final int INVALID_DIRECTION = -1;
     public static final int TOP = 0;
     public static final int LEFT = 1;
     public static final int BOTTOM = 2;
index bed2908..0268ef9 100644 (file)
@@ -103,7 +103,6 @@ public class GLRootView extends GLSurfaceView
         setEGLConfigChooser(mEglConfigChooser);
         setRenderer(this);
         getHolder().setFormat(PixelFormat.RGB_565);
-        AnimationTime.update();
 
         // Uncomment this to enable gl error check.
         //setDebugFlags(DEBUG_CHECK_GL_ERROR);
@@ -338,6 +337,7 @@ public class GLRootView extends GLSurfaceView
 
     @Override
     public boolean dispatchTouchEvent(MotionEvent event) {
+        AnimationTime.update();
         int action = event.getAction();
         if (action == MotionEvent.ACTION_CANCEL
                 || action == MotionEvent.ACTION_UP) {
index 4bd4f3a..b2a4be4 100644 (file)
@@ -29,41 +29,52 @@ import android.view.animation.AccelerateInterpolator;
 import com.android.gallery3d.R;
 import com.android.gallery3d.app.GalleryActivity;
 import com.android.gallery3d.common.Utils;
+import com.android.gallery3d.data.MediaObject;
+import com.android.gallery3d.util.RangeArray;
+import com.android.gallery3d.util.RangeBoolArray;
+
+import java.util.Arrays;
 
 public class PhotoView extends GLView {
     @SuppressWarnings("unused")
     private static final String TAG = "PhotoView";
 
     public static final int INVALID_SIZE = -1;
+    public static final long INVALID_DATA_VERSION =
+            MediaObject.INVALID_DATA_VERSION;
 
-    private static final int MSG_TRANSITION_COMPLETE = 1;
-    private static final int MSG_SHOW_LOADING = 2;
-    private static final int MSG_CANCEL_EXTRA_SCALING = 3;
+    public static interface Model extends TileImageView.Model {
+        public void next();
+        public void previous();
+        public int getImageRotation();
 
-    private static final long DELAY_SHOW_LOADING = 250; // 250ms;
+        // This amends the getScreenNail() method of TileImageView.Model to get
+        // ScreenNail at previous (negative offset) or next (positive offset)
+        // positions. Returns null if the specified ScreenNail is unavailable.
+        public ScreenNail getScreenNail(int offset);
+    }
+
+    public interface PhotoTapListener {
+        public void onSingleTapUp(int x, int y);
+    }
 
-    private static final int TRANS_NONE = 0;
-    private static final int TRANS_SWITCH_NEXT = 3;
-    private static final int TRANS_SWITCH_PREVIOUS = 4;
+    private static final int MSG_SHOW_LOADING = 1;
+    private static final int MSG_CANCEL_EXTRA_SCALING = 2;
+    private static final int MSG_SWITCH_FOCUS = 3;
 
-    public static final int TRANS_SLIDE_IN_RIGHT = 1;
-    public static final int TRANS_SLIDE_IN_LEFT = 2;
-    public static final int TRANS_OPEN_ANIMATION = 5;
+    private static final long DELAY_SHOW_LOADING = 250; // 250ms;
 
     private static final int LOADING_INIT = 0;
     private static final int LOADING_TIMEOUT = 1;
     private static final int LOADING_COMPLETE = 2;
     private static final int LOADING_FAIL = 3;
 
-    private static final int ENTRY_PREVIOUS = 0;
-    private static final int ENTRY_NEXT = 1;
-
-    private static final int IMAGE_GAP = 96;
-    private static final int SWITCH_THRESHOLD = 256;
+    private static final int MOVE_THRESHOLD = 256;
     private static final float SWIPE_THRESHOLD = 300f;
 
     private static final float DEFAULT_TEXT_SIZE = 20;
     private static float TRANSITION_SCALE_FACTOR = 0.74f;
+    private static final boolean CARD_EFFECT = true;
 
     // Used to calculate the scaling factor for the fading animation.
     private ZInterpolator mScaleInterpolator = new ZInterpolator(0.5f);
@@ -72,12 +83,20 @@ public class PhotoView extends GLView {
     private AccelerateInterpolator mAlphaInterpolator =
             new AccelerateInterpolator(0.9f);
 
-    public interface PhotoTapListener {
-        public void onSingleTapUp(int x, int y);
-    }
+    // We keep this many previous ScreenNails. (also this many next ScreenNails)
+    public static final int SCREEN_NAIL_MAX = 3;
+
+    // The picture entries, the valid index is from -SCREEN_NAIL_MAX to
+    // SCREEN_NAIL_MAX.
+    private final RangeArray<Picture> mPictures =
+            new RangeArray<Picture>(-SCREEN_NAIL_MAX, SCREEN_NAIL_MAX);
+    private final RangeBoolArray mReused =
+            new RangeBoolArray(-SCREEN_NAIL_MAX, SCREEN_NAIL_MAX);
+    private final RangeArray<ScreenNail> mTempScreenNail =
+            new RangeArray<ScreenNail>(-SCREEN_NAIL_MAX, SCREEN_NAIL_MAX);
 
-    // the previous/next image entries
-    private final ScreenNailEntry mScreenNails[] = new ScreenNailEntry[2];
+    private final long mDataVersion[] = new long[2 * SCREEN_NAIL_MAX + 1];
+    private final int mFromIndex[] = new int[2 * SCREEN_NAIL_MAX + 1];
 
     private final GestureRecognizer mGestureRecognizer;
 
@@ -88,8 +107,7 @@ public class PhotoView extends GLView {
     private Model mModel;
     private StringTexture mLoadingText;
     private StringTexture mNoThumbnailText;
-    private int mTransitionMode = TRANS_NONE;
-    private final TileImageView mTileView;
+    private TileImageView mTileView;
     private EdgeView mEdgeView;
     private Texture mVideoPlayIcon;
 
@@ -100,11 +118,9 @@ public class PhotoView extends GLView {
 
     private int mLoadingState = LOADING_COMPLETE;
 
-    private int mImageRotation;
-
-    private Rect mOpenAnimationRect;
     private Point mImageCenter = new Point();
     private boolean mCancelExtraScalingPending;
+    private boolean mFilmMode = false;
 
     public PhotoView(GalleryActivity activity) {
         mTileView = new TileImageView(activity);
@@ -120,148 +136,78 @@ public class PhotoView extends GLView {
                 context.getString(R.string.no_thumbnail),
                 DEFAULT_TEXT_SIZE, Color.WHITE);
 
-        mHandler = new SynchronizedHandler(activity.getGLRoot()) {
-            @Override
-            public void handleMessage(Message message) {
-                switch (message.what) {
-                    case MSG_TRANSITION_COMPLETE: {
-                        onTransitionComplete();
-                        break;
-                    }
-                    case MSG_SHOW_LOADING: {
-                        if (mLoadingState == LOADING_INIT) {
-                            // We don't need the opening animation
-                            mOpenAnimationRect = null;
-
-                            mLoadingSpinner.startAnimation();
-                            mLoadingState = LOADING_TIMEOUT;
-                            invalidate();
-                        }
-                        break;
-                    }
-                    case MSG_CANCEL_EXTRA_SCALING: {
-                        mGestureRecognizer.cancelScale();
-                        mPositionController.setExtraScalingRange(false);
-                        mCancelExtraScalingPending = false;
-                        break;
-                    }
-                    default: throw new AssertionError(message.what);
-                }
-            }
-        };
+        mHandler = new MyHandler(activity.getGLRoot());
 
         mGestureRecognizer = new GestureRecognizer(
                 context, new MyGestureListener());
 
-        for (int i = 0, n = mScreenNails.length; i < n; ++i) {
-            mScreenNails[i] = new ScreenNailEntry();
-        }
-
-        mPositionController = new PositionController(this, context, mEdgeView);
+        mPositionController = new PositionController(context,
+                new PositionController.Listener() {
+                    public void invalidate() {
+                        PhotoView.this.invalidate();
+                    }
+                    public boolean isDown() {
+                        return mGestureRecognizer.isDown();
+                    }
+                    public void onPull(int offset, int direction) {
+                        mEdgeView.onPull(offset, direction);
+                    }
+                    public void onRelease() {
+                        mEdgeView.onRelease();
+                    }
+                    public void onAbsorb(int velocity, int direction) {
+                        mEdgeView.onAbsorb(velocity, direction);
+                    }
+                });
         mVideoPlayIcon = new ResourceTexture(context, R.drawable.ic_control_play);
-    }
-
-
-    public void setModel(Model model) {
-        if (mModel == model) return;
-        mModel = model;
-        mTileView.setModel(model);
-        if (model != null) notifyOnNewImage();
-    }
-
-    public void setPhotoTapListener(PhotoTapListener listener) {
-        mPhotoTapListener = listener;
-    }
-
-    private void setTileViewPosition(int centerX, int centerY, float scale) {
-        TileImageView t = mTileView;
-
-        // Calculate the move-out progress value.
-        RectF bounds = mPositionController.getImageBounds();
-        int left = Math.round(bounds.left);
-        int right = Math.round(bounds.right);
-        int width = getWidth();
-        float progress = calculateMoveOutProgress(left, right, width);
-        progress = Utils.clamp(progress, -1f, 1f);
-
-        // We only want to apply the fading animation if the scrolling movement
-        // is to the right.
-        if (progress < 0) {
-            if (right - left < width) {
-                // If the picture is narrower than the view, keep it at the center
-                // of the view.
-                centerX = mPositionController.getImageWidth() / 2;
+        Arrays.fill(mDataVersion, INVALID_DATA_VERSION);
+        for (int i = -SCREEN_NAIL_MAX; i <= SCREEN_NAIL_MAX; i++) {
+            if (i == 0) {
+                mPictures.put(i, new FullPicture());
             } else {
-                // If the picture is wider than the view (it's zoomed-in), keep
-                // the left edge of the object align the the left edge of the view.
-                centerX = Math.round(width / 2f / scale);
+                mPictures.put(i, new ScreenNailPicture(i));
             }
-            scale *= getScrollScale(progress);
-            t.setAlpha(getScrollAlpha(progress));
-        }
-
-        // set the position of the tile view
-        int inverseX = mPositionController.getImageWidth() - centerX;
-        int inverseY = mPositionController.getImageHeight() - centerY;
-        int rotation = mImageRotation;
-        switch (rotation) {
-            case 0: t.setPosition(centerX, centerY, scale, 0); break;
-            case 90: t.setPosition(centerY, inverseX, scale, 90); break;
-            case 180: t.setPosition(inverseX, inverseY, scale, 180); break;
-            case 270: t.setPosition(inverseY, centerX, scale, 270); break;
-            default: throw new IllegalArgumentException(String.valueOf(rotation));
         }
     }
 
-    public void setPosition(int centerX, int centerY, float scale) {
-        setTileViewPosition(centerX, centerY, scale);
-        layoutScreenNails();
+    public void setModel(Model model) {
+        mModel = model;
+        mTileView.setModel(mModel);
     }
 
-    private void updateScreenNailEntry(int which, ScreenNail screenNail) {
-        if (mTransitionMode == TRANS_SWITCH_NEXT
-                || mTransitionMode == TRANS_SWITCH_PREVIOUS) {
-            // ignore screen nail updating during switching
-            return;
+    class MyHandler extends SynchronizedHandler {
+        public MyHandler(GLRoot root) {
+            super(root);
         }
-        ScreenNailEntry entry = mScreenNails[which];
-        entry.updateScreenNail(screenNail);
-    }
 
-    // -1 previous, 0 current, 1 next
-    public void notifyImageInvalidated(int which) {
-        switch (which) {
-            case -1: {
-                updateScreenNailEntry(
-                        ENTRY_PREVIOUS, mModel.getPrevScreenNail());
-                layoutScreenNails();
-                invalidate();
-                break;
-            }
-            case 1: {
-                updateScreenNailEntry(ENTRY_NEXT, mModel.getNextScreenNail());
-                layoutScreenNails();
-                invalidate();
-                break;
-            }
-            case 0: {
-                // mImageWidth and mImageHeight will get updated
-                mTileView.notifyModelInvalidated();
-                mTileView.setAlpha(1.0f);
-
-                mImageRotation = mModel.getImageRotation();
-                if (((mImageRotation / 90) & 1) == 0) {
-                    mPositionController.setImageSize(
-                            mTileView.mImageWidth, mTileView.mImageHeight);
-                } else {
-                    mPositionController.setImageSize(
-                            mTileView.mImageHeight, mTileView.mImageWidth);
+        @Override
+        public void handleMessage(Message message) {
+            switch (message.what) {
+                case MSG_SHOW_LOADING: {
+                    if (mLoadingState == LOADING_INIT) {
+                        // We don't need the opening animation
+                        mPositionController.setOpenAnimationRect(null);
+
+                        mLoadingSpinner.startAnimation();
+                        mLoadingState = LOADING_TIMEOUT;
+                        invalidate();
+                    }
+                    break;
                 }
-                updateLoadingState();
-                break;
+                case MSG_CANCEL_EXTRA_SCALING: {
+                    mGestureRecognizer.cancelScale();
+                    mPositionController.setExtraScalingRange(false);
+                    mCancelExtraScalingPending = false;
+                    break;
+                }
+                case MSG_SWITCH_FOCUS: {
+                    switchFocus();
+                    break;
+                }
+                default: throw new AssertionError(message.what);
             }
         }
-    }
+    };
 
     private void updateLoadingState() {
         // Possible transitions of mLoadingState:
@@ -276,7 +222,7 @@ public class PhotoView extends GLView {
             mHandler.removeMessages(MSG_SHOW_LOADING);
             mLoadingState = LOADING_FAIL;
             // We don't want the opening animation after loading failure
-            mOpenAnimationRect = null;
+            mPositionController.setOpenAnimationRect(null);
         } else if (mLoadingState != LOADING_INIT) {
             mLoadingState = LOADING_INIT;
             mHandler.removeMessages(MSG_SHOW_LOADING);
@@ -285,242 +231,367 @@ public class PhotoView extends GLView {
         }
     }
 
-    public void notifyModelInvalidated() {
-        if (mModel == null) {
-            updateScreenNailEntry(ENTRY_PREVIOUS, null);
-            updateScreenNailEntry(ENTRY_NEXT, null);
-        } else {
-            updateScreenNailEntry(ENTRY_PREVIOUS, mModel.getPrevScreenNail());
-            updateScreenNailEntry(ENTRY_NEXT, mModel.getNextScreenNail());
+    ////////////////////////////////////////////////////////////////////////////
+    //  Data/Image change notifications
+    ////////////////////////////////////////////////////////////////////////////
+
+    public void notifyDataChange(long[] versions) {
+        // Check if the data version actually changed.
+        boolean changed = false;
+        int N = 2 * SCREEN_NAIL_MAX + 1;
+        for (int i = 0; i < N; i++) {
+            if (versions[i] != mDataVersion[i]) {
+                changed = true;
+                break;
+            }
         }
-        layoutScreenNails();
+        if (!changed) return;
 
-        if (mModel == null) {
-            mTileView.notifyModelInvalidated();
-            mTileView.setAlpha(1.0f);
-            mImageRotation = 0;
-            mPositionController.setImageSize(0, 0);
-            updateLoadingState();
-        } else {
-            notifyImageInvalidated(0);
+        // Remembers those ScreenNail which are reused.
+        for (int i = -SCREEN_NAIL_MAX; i <= SCREEN_NAIL_MAX; i++) {
+            mReused.put(i, false);
         }
-    }
 
-    @Override
-    protected boolean onTouch(MotionEvent event) {
-        mGestureRecognizer.onTouchEvent(event);
-        return true;
-    }
+        // Create the mFromIndex array, which records the index where the picture
+        // come from. The value Integer.MAX_VALUE means it's a new picture.
+        for (int i = 0; i < N; i++) {
+            long v = versions[i];
+            if (v == INVALID_DATA_VERSION) {
+                mFromIndex[i] = Integer.MAX_VALUE;
+                continue;
+            }
 
-    @Override
-    protected void onLayout(
-            boolean changeSize, int left, int top, int right, int bottom) {
-        mTileView.layout(left, top, right, bottom);
-        mEdgeView.layout(left, top, right, bottom);
-        if (changeSize) {
-            mPositionController.setViewSize(getWidth(), getHeight());
-            for (ScreenNailEntry entry : mScreenNails) {
-                entry.updateDrawingSize();
+            // Try to find the same version number in the old array
+            int j;
+            for (j = 0; j < N; j++) {
+                if (mDataVersion[j] == v) {
+                    mReused.put(j - SCREEN_NAIL_MAX, true);
+                    break;
+                }
             }
+            mFromIndex[i] = (j < N) ? j - SCREEN_NAIL_MAX : Integer.MAX_VALUE;
         }
+
+        // Copy the new data version
+        for (int i = 0; i < N; i++) {
+            mDataVersion[i] = versions[i];
+        }
+
+        // Move the boxes
+        mPositionController.moveBox(mFromIndex);
+
+        // Free those ScreenNails that are not reused.
+        for (int i = -SCREEN_NAIL_MAX; i <= SCREEN_NAIL_MAX; i++) {
+            if (!mReused.get(i)) mPictures.get(i).updateScreenNail(null);
+        }
+
+        // Collect the reused ScreenNails, so we don't need to re-upload the
+        // textures.
+        for (int i = -SCREEN_NAIL_MAX; i <= SCREEN_NAIL_MAX; i++) {
+            mTempScreenNail.put(i, mPictures.get(i).releaseScreenNail());
+        }
+
+        // Put back the reused ScreenNails.
+        for (int i = -SCREEN_NAIL_MAX; i <= SCREEN_NAIL_MAX; i++) {
+            int j = mFromIndex[i + SCREEN_NAIL_MAX];
+            if (j != Integer.MAX_VALUE) {
+                ScreenNail s = mTempScreenNail.get(j);
+                mTempScreenNail.put(j, null);
+                mPictures.get(i).updateScreenNail(s);
+            }
+            mPictures.get(i).reload();
+        }
+
+        invalidate();
     }
 
-    private static int gapToSide(int imageWidth, int viewWidth) {
-        return Math.max(0, (viewWidth - imageWidth) / 2);
+    public void notifyImageChange(int index) {
+        mPictures.get(index).reload();
+        invalidate();
     }
 
-    /*
-     * Here is how we layout the screen nails
-     *
-     *  previous            current           next
-     *  ___________       ________________     __________
-     * |  _______  |     |   __________   |   |  ______  |
-     * | |       | |     |  |   right->|  |   | |      | |
-     * | |       |<-------->|<--left   |  |   | |      | |
-     * | |_______| |  |  |  |__________|  |   | |______| |
-     * |___________|  |  |________________|   |__________|
-     *                |  <--> gapToSide()
-     *                |
-     * IMAGE_GAP + Max(previous.gapToSide(), current.gapToSide)
-     */
-    private void layoutScreenNails() {
-        int width = getWidth();
-        int height = getHeight();
+    ////////////////////////////////////////////////////////////////////////////
+    //  Pictures
+    ////////////////////////////////////////////////////////////////////////////
+
+    private interface Picture {
+        void reload();
+        void draw(GLCanvas canvas, Rect r);
 
-        // Use the image width in AC, since we may fake the size if the
-        // image is unavailable
-        RectF bounds = mPositionController.getImageBounds();
-        int left = Math.round(bounds.left);
-        int right = Math.round(bounds.right);
-        int gap = gapToSide(right - left, width);
+        void updateScreenNail(ScreenNail s);
+        // Release the ownership of the ScreenNail from this entry.
+        ScreenNail releaseScreenNail();
+
+        boolean isEnabled();
+    };
 
-        // layout the previous image
-        ScreenNailEntry entry = mScreenNails[ENTRY_PREVIOUS];
+    class FullPicture implements Picture {
+        private int mRotation;
 
-        if (entry.isEnabled()) {
-            entry.layoutRightEdgeAt(left - (
-                    IMAGE_GAP + Math.max(gap, entry.gapToSide())));
+        // This is a temporary hack to switch mode when entering/leaving camera.
+        private volatile boolean mIsNonBitmap;
+
+        public void FullPicture(TileImageView tileView) {
+            mTileView = tileView;
         }
 
-        // layout the next image
-        entry = mScreenNails[ENTRY_NEXT];
-        if (entry.isEnabled()) {
-            entry.layoutLeftEdgeAt(right + (
-                    IMAGE_GAP + Math.max(gap, entry.gapToSide())));
+        @Override
+        public void reload() {
+            // mImageWidth and mImageHeight will get updated
+            mTileView.notifyModelInvalidated();
+            if (CARD_EFFECT) mTileView.setAlpha(1.0f);
+
+            if (mModel == null) {
+                mRotation = 0;
+                mPositionController.setImageSize(0, 0, 0);
+            } else {
+                mRotation = mModel.getImageRotation();
+                int w = mTileView.mImageWidth;
+                int h = mTileView.mImageHeight;
+                mPositionController.setImageSize(0,
+                        getRotated(mRotation, w, h),
+                        getRotated(mRotation, h, w));
+            }
+            updateScreenNail(mModel == null
+                    ? null : mModel.getScreenNail(0));
+            updateLoadingState();
         }
-    }
 
-    @Override
-    protected void render(GLCanvas canvas) {
-        boolean drawScreenNail = (mTransitionMode != TRANS_SLIDE_IN_LEFT
-                && mTransitionMode != TRANS_SLIDE_IN_RIGHT
-                && mTransitionMode != TRANS_OPEN_ANIMATION);
+        @Override
+        public void draw(GLCanvas canvas, Rect r) {
+            if (mLoadingState == LOADING_COMPLETE) {
+                setTileViewPosition(r);
+                PhotoView.super.render(canvas);
+            }
+            renderMessage(canvas, r.centerX(), r.centerY());
 
-        // Draw the next photo
-        if (drawScreenNail) {
-            ScreenNailEntry nextNail = mScreenNails[ENTRY_NEXT];
-            nextNail.draw(canvas, true);
+            boolean isCenter = r.centerX() == getWidth() / 2;
+            if (mIsNonBitmap && !isCenter && !mFilmMode) {
+                setFilmMode(true);
+            } else if (mIsNonBitmap && isCenter && mFilmMode) {
+                setFilmMode(false);
+            }
         }
 
-        // Draw the current photo
-        if (mLoadingState == LOADING_COMPLETE) {
-            super.render(canvas);
+        @Override
+        public void updateScreenNail(ScreenNail s) {
+            mIsNonBitmap = (s != null && !(s instanceof BitmapScreenNail));
+            mTileView.updateScreenNail(s);
         }
 
-        // If the photo is loaded, draw the message/icon at the center of it,
-        // otherwise draw the message/icon at the center of the view.
-        if (mLoadingState == LOADING_COMPLETE) {
-            mTileView.getImageCenter(mImageCenter);
-            renderMessage(canvas, mImageCenter.x, mImageCenter.y);
-        } else {
-            renderMessage(canvas, getWidth() / 2, getHeight() / 2);
+        @Override
+        public ScreenNail releaseScreenNail() {
+            return mTileView.releaseScreenNail();
         }
 
-        // Draw the previous photo
-        if (drawScreenNail) {
-            ScreenNailEntry prevNail = mScreenNails[ENTRY_PREVIOUS];
-            prevNail.draw(canvas, false);
+        @Override
+        public boolean isEnabled() {
+            return true;
         }
-    }
 
-    private void renderMessage(GLCanvas canvas, int x, int y) {
-        // Draw the progress spinner and the text below it
-        //
-        // (x, y) is where we put the center of the spinner.
-        // s is the size of the video play icon, and we use s to layout text
-        // because we want to keep the text at the same place when the video
-        // play icon is shown instead of the spinner.
-        int w = getWidth();
-        int h = getHeight();
-        int s = Math.min(getWidth(), getHeight()) / 6;
+        private void setTileViewPosition(Rect r) {
+            TileImageView t = mTileView;
+
+            // Find out the bitmap coordinates of the center of the view
+            int imageW = mPositionController.getImageWidth();
+            int imageH = mPositionController.getImageHeight();
+            float scale = mPositionController.getImageScale();
+            int viewW = getWidth();
+            int viewH = getHeight();
+            int centerX = (int) (imageW / 2f +
+                    (viewW / 2f - r.exactCenterX()) / scale + 0.5f);
+            int centerY = (int) (imageH / 2f +
+                    (viewH / 2f - r.exactCenterY()) / scale + 0.5f);
+
+            if (CARD_EFFECT && !mFilmMode) {
+                // Calculate the move-out progress value.
+                int left = r.left;
+                int right = r.right;
+                float progress = calculateMoveOutProgress(left, right, viewW);
+                progress = Utils.clamp(progress, -1f, 1f);
+
+                // We only want to apply the fading animation if the scrolling
+                // movement is to the right.
+                if (progress < 0) {
+                    if (right - left < viewW) {
+                        // If the picture is narrower than the view, keep it at
+                        // the center of the view.
+                        centerX = imageW / 2;
+                    } else {
+                        // If the picture is wider than the view (it's
+                        // zoomed-in), keep the left edge of the object align
+                        // the the left edge of the view.
+                        centerX = Math.round(viewW / 2f / scale);
+                    }
+                    scale *= getScrollScale(progress);
+                    t.setAlpha(getScrollAlpha(progress));
+                }
+            }
 
-        if (mLoadingState == LOADING_TIMEOUT) {
-            StringTexture m = mLoadingText;
-            ProgressSpinner r = mLoadingSpinner;
-            r.draw(canvas, x - r.getWidth() / 2, y - r.getHeight() / 2);
-            m.draw(canvas, x - m.getWidth() / 2, y + s / 2 + 5);
-            invalidate(); // we need to keep the spinner rotating
-        } else if (mLoadingState == LOADING_FAIL) {
-            StringTexture m = mNoThumbnailText;
-            m.draw(canvas, x - m.getWidth() / 2, y + s / 2 + 5);
+            // set the position of the tile view
+            int inverseX = imageW - centerX;
+            int inverseY = imageH - centerY;
+            int rotation = mRotation;
+            switch (rotation) {
+                case 0: t.setPosition(centerX, centerY, scale, 0); break;
+                case 90: t.setPosition(centerY, inverseX, scale, 90); break;
+                case 180: t.setPosition(inverseX, inverseY, scale, 180); break;
+                case 270: t.setPosition(inverseY, centerX, scale, 270); break;
+                default:
+                    throw new IllegalArgumentException(String.valueOf(rotation));
+            }
         }
 
-        // Draw the video play icon (in the place where the spinner was)
-        if (mShowVideoPlayIcon
-                && mLoadingState != LOADING_INIT
-                && mLoadingState != LOADING_TIMEOUT) {
-            mVideoPlayIcon.draw(canvas, x - s / 2, y - s / 2, s, s);
-        }
+        private void renderMessage(GLCanvas canvas, int x, int y) {
+            // Draw the progress spinner and the text below it
+            //
+            // (x, y) is where we put the center of the spinner.
+            // s is the size of the video play icon, and we use s to layout text
+            // because we want to keep the text at the same place when the video
+            // play icon is shown instead of the spinner.
+            int w = getWidth();
+            int h = getHeight();
+            int s = Math.min(getWidth(), getHeight()) / 6;
+
+            if (mLoadingState == LOADING_TIMEOUT) {
+                StringTexture m = mLoadingText;
+                ProgressSpinner p = mLoadingSpinner;
+                p.draw(canvas, x - p.getWidth() / 2, y - p.getHeight() / 2);
+                m.draw(canvas, x - m.getWidth() / 2, y + s / 2 + 5);
+                invalidate(); // we need to keep the spinner rotating
+            } else if (mLoadingState == LOADING_FAIL) {
+                StringTexture m = mNoThumbnailText;
+                m.draw(canvas, x - m.getWidth() / 2, y + s / 2 + 5);
+            }
 
-        mPositionController.advanceAnimation();
-    }
+            // Draw a debug indicator showing which picture has focus (index ==
+            // 0).
+            // canvas.fillRect(x - 10, y - 10, 20, 20, 0x80FF00FF);
 
-    private void stopCurrentSwipingIfNeeded() {
-        // Enable fast swiping
-        if (mTransitionMode == TRANS_SWITCH_NEXT) {
-            mTransitionMode = TRANS_NONE;
-            mPositionController.stopAnimation();
-            switchToNextImage();
-        } else if (mTransitionMode == TRANS_SWITCH_PREVIOUS) {
-            mTransitionMode = TRANS_NONE;
-            mPositionController.stopAnimation();
-            switchToPreviousImage();
+            // Draw the video play icon (in the place where the spinner was)
+            if (mShowVideoPlayIcon
+                    && mLoadingState != LOADING_INIT
+                    && mLoadingState != LOADING_TIMEOUT) {
+                mVideoPlayIcon.draw(canvas, x - s / 2, y - s / 2, s, s);
+            }
         }
     }
 
-    private boolean swipeImages(float velocityX, float velocityY) {
-        if (mTransitionMode != TRANS_NONE
-                && mTransitionMode != TRANS_SWITCH_NEXT
-                && mTransitionMode != TRANS_SWITCH_PREVIOUS) return false;
+    private class ScreenNailPicture implements Picture {
+        private int mIndex;
+        private boolean mEnabled;
+        private int mRotation;
+        private ScreenNail mScreenNail;
 
-        // Avoid swiping images if we're possibly flinging to view the
-        // zoomed in picture vertically.
-        PositionController controller = mPositionController;
-        boolean isMinimal = controller.isAtMinimalScale();
-        int edges = controller.getImageAtEdges();
-        if (!isMinimal && Math.abs(velocityY) > Math.abs(velocityX))
-            if ((edges & PositionController.IMAGE_AT_TOP_EDGE) == 0
-                    || (edges & PositionController.IMAGE_AT_BOTTOM_EDGE) == 0)
-                return false;
+        public ScreenNailPicture(int index) {
+            mIndex = index;
+        }
 
-        // If we are at the edge of the current photo and the sweeping velocity
-        // exceeds the threshold, switch to next / previous image.
-        int halfWidth = getWidth() / 2;
-        if (velocityX < -SWIPE_THRESHOLD && (isMinimal
-                || (edges & PositionController.IMAGE_AT_RIGHT_EDGE) != 0)) {
-            stopCurrentSwipingIfNeeded();
-            ScreenNailEntry next = mScreenNails[ENTRY_NEXT];
-            if (next.isEnabled()) {
-                mTransitionMode = TRANS_SWITCH_NEXT;
-                controller.startHorizontalSlide(next.mOffsetX - halfWidth);
-                return true;
+        @Override
+        public void reload() {
+            updateScreenNail(mModel == null ? null
+                    : mModel.getScreenNail(mIndex));
+        }
+
+        @Override
+        public void draw(GLCanvas canvas, Rect r) {
+            if (mScreenNail == null) {
+                return;
             }
-        } else if (velocityX > SWIPE_THRESHOLD && (isMinimal
-                || (edges & PositionController.IMAGE_AT_LEFT_EDGE) != 0)) {
-            stopCurrentSwipingIfNeeded();
-            ScreenNailEntry prev = mScreenNails[ENTRY_PREVIOUS];
-            if (prev.isEnabled()) {
-                mTransitionMode = TRANS_SWITCH_PREVIOUS;
-                controller.startHorizontalSlide(prev.mOffsetX - halfWidth);
-                return true;
+            if (r.left >= getWidth() || r.right <= 0 ||
+                    r.top >= getHeight() || r.bottom <= 0) {
+                mScreenNail.noDraw();
+                return;
             }
-        }
 
-        return false;
-    }
+            boolean applyFadingAnimation =
+                CARD_EFFECT && mIndex > 0 && !mFilmMode;
 
-    private boolean snapToNeighborImage() {
-        if (mTransitionMode != TRANS_NONE) return false;
+            int w = getWidth();
+            int drawW = getRotated(mRotation, r.width(), r.height());
+            int drawH = getRotated(mRotation, r.height(), r.width());
+            int cx = applyFadingAnimation ? w / 2 : r.centerX();
+            int cy = r.centerY();
+            int flags = GLCanvas.SAVE_FLAG_MATRIX;
 
-        PositionController controller = mPositionController;
-        RectF bounds = controller.getImageBounds();
-        int left = Math.round(bounds.left);
-        int right = Math.round(bounds.right);
-        int width = getWidth();
-        int threshold = SWITCH_THRESHOLD + gapToSide(right - left, width);
+            if (applyFadingAnimation) flags |= GLCanvas.SAVE_FLAG_ALPHA;
+            canvas.save(flags);
+            canvas.translate(cx, cy);
+            if (applyFadingAnimation) {
+                float progress = (float) (w / 2 - r.centerX()) / w;
+                progress = Utils.clamp(progress, -1, 1);
+                float alpha = getScrollAlpha(progress);
+                float scale = getScrollScale(progress);
+                canvas.multiplyAlpha(alpha);
+                canvas.scale(scale, scale, 1);
+            }
+            if (mRotation != 0) {
+                canvas.rotate(mRotation, 0, 0, 1);
+            }
+            mScreenNail.draw(canvas, -drawW / 2, -drawH / 2, drawW, drawH);
+            canvas.restore();
+        }
 
-        // If we have moved the picture a lot, switching.
-        ScreenNailEntry next = mScreenNails[ENTRY_NEXT];
-        if (next.isEnabled() && threshold < width - right) {
-            mTransitionMode = TRANS_SWITCH_NEXT;
-            controller.startHorizontalSlide(next.mOffsetX - width / 2);
-            return true;
+        @Override
+        public void updateScreenNail(ScreenNail s) {
+            mEnabled = (s != null);
+            if (mScreenNail == s) return;
+            if (mScreenNail != null) {
+                mScreenNail.pauseDraw();
+            }
+            mScreenNail = s;
+            if (mScreenNail != null) {
+                mRotation = mScreenNail.getRotation();
+            }
+            if (mScreenNail != null) {
+                int w = s.getWidth();
+                int h = s.getHeight();
+                mPositionController.setImageSize(mIndex,
+                        getRotated(mRotation, w, h),
+                        getRotated(mRotation, h, w));
+            }
         }
-        ScreenNailEntry prev = mScreenNails[ENTRY_PREVIOUS];
-        if (prev.isEnabled() && threshold < left) {
-            mTransitionMode = TRANS_SWITCH_PREVIOUS;
-            controller.startHorizontalSlide(prev.mOffsetX - width / 2);
-            return true;
+
+        @Override
+        public ScreenNail releaseScreenNail() {
+            ScreenNail s = mScreenNail;
+            mScreenNail = null;
+            return s;
         }
 
-        return false;
+        @Override
+        public boolean isEnabled() {
+            return mEnabled;
+        }
+    }
+
+    private static int getRotated(int degree, int original, int theother) {
+        return (degree % 180 == 0) ? original : theother;
+    }
+
+    ////////////////////////////////////////////////////////////////////////////
+    //  Gestures Handling
+    ////////////////////////////////////////////////////////////////////////////
+
+    @Override
+    protected boolean onTouch(MotionEvent event) {
+        mGestureRecognizer.onTouchEvent(event);
+        return true;
     }
 
     private class MyGestureListener implements GestureRecognizer.Listener {
         private boolean mIgnoreUpEvent = false;
 
+        // If we have changed the mode in this scaling gesture.
+        private boolean mModeChanged;
+
         @Override
         public boolean onSingleTapUp(float x, float y) {
+            if (mFilmMode) {
+                setFilmMode(false);
+                return true;
+            }
+
             if (mPhotoTapListener != null) {
                 mPhotoTapListener.onSingleTapUp((int) x, (int) y);
             }
@@ -529,9 +600,8 @@ public class PhotoView extends GLView {
 
         @Override
         public boolean onDoubleTap(float x, float y) {
-            if (mTransitionMode != TRANS_NONE) return true;
             PositionController controller = mPositionController;
-            float scale = controller.getCurrentScale();
+            float scale = controller.getImageScale();
             // onDoubleTap happened on the second ACTION_DOWN.
             // We need to ignore the next UP event.
             mIgnoreUpEvent = true;
@@ -545,13 +615,7 @@ public class PhotoView extends GLView {
 
         @Override
         public boolean onScroll(float dx, float dy) {
-            if (mTransitionMode != TRANS_NONE) return true;
-
-            ScreenNailEntry next = mScreenNails[ENTRY_NEXT];
-            ScreenNailEntry prev = mScreenNails[ENTRY_PREVIOUS];
-
-            mPositionController.startScroll(dx, dy, next.isEnabled(),
-                    prev.isEnabled());
+            mPositionController.startScroll(-dx, -dy);
             return true;
         }
 
@@ -559,8 +623,6 @@ public class PhotoView extends GLView {
         public boolean onFling(float velocityX, float velocityY) {
             if (swipeImages(velocityX, velocityY)) {
                 mIgnoreUpEvent = true;
-            } else if (mTransitionMode != TRANS_NONE) {
-                // do nothing
             } else if (mPositionController.fling(velocityX, velocityY)) {
                 mIgnoreUpEvent = true;
             }
@@ -569,38 +631,54 @@ public class PhotoView extends GLView {
 
         @Override
         public boolean onScaleBegin(float focusX, float focusY) {
-            if (mTransitionMode != TRANS_NONE) return false;
             mPositionController.beginScale(focusX, focusY);
+            mModeChanged = false;
             return true;
         }
 
         @Override
         public boolean onScale(float focusX, float focusY, float scale) {
-            if (Float.isNaN(scale) || Float.isInfinite(scale)
-                    || mTransitionMode != TRANS_NONE) return true;
-            boolean outOfRange = mPositionController.scaleBy(
-                    scale, focusX, focusY);
-            if (outOfRange) {
-                if (!mCancelExtraScalingPending) {
-                    mHandler.sendEmptyMessageDelayed(
-                            MSG_CANCEL_EXTRA_SCALING, 700);
-                    mPositionController.setExtraScalingRange(true);
-                    mCancelExtraScalingPending = true;
+            if (Float.isNaN(scale) || Float.isInfinite(scale)) return false;
+            int outOfRange = mPositionController.scaleBy(scale, focusX, focusY);
+
+            // We allow only one mode change in a scaling gesture.
+            if (!mModeChanged) {
+                if ((outOfRange < 0 && !mFilmMode) ||
+                        (outOfRange > 0 && mFilmMode)) {
+                    setFilmMode(!mFilmMode);
+                    mModeChanged = true;
+                    return true;
                 }
+           }
+
+            if (outOfRange != 0 && !mModeChanged) {
+                startExtraScalingIfNeeded();
             } else {
-                if (mCancelExtraScalingPending) {
-                    mHandler.removeMessages(MSG_CANCEL_EXTRA_SCALING);
-                    mPositionController.setExtraScalingRange(false);
-                    mCancelExtraScalingPending = false;
-                }
+                stopExtraScalingIfNeeded();
             }
             return true;
         }
 
+        private void startExtraScalingIfNeeded() {
+            if (!mCancelExtraScalingPending) {
+                mHandler.sendEmptyMessageDelayed(
+                        MSG_CANCEL_EXTRA_SCALING, 700);
+                mPositionController.setExtraScalingRange(true);
+                mCancelExtraScalingPending = true;
+            }
+        }
+
+        private void stopExtraScalingIfNeeded() {
+            if (mCancelExtraScalingPending) {
+                mHandler.removeMessages(MSG_CANCEL_EXTRA_SCALING);
+                mPositionController.setExtraScalingRange(false);
+                mCancelExtraScalingPending = false;
+            }
+        }
+
         @Override
         public void onScaleEnd() {
             mPositionController.endScale();
-            snapToNeighborImage();
         }
 
         @Override
@@ -615,183 +693,224 @@ public class PhotoView extends GLView {
                 mIgnoreUpEvent = false;
                 return;
             }
-            if (!snapToNeighborImage() && mTransitionMode == TRANS_NONE) {
+
+            if (!snapToNeighborImage()) {
                 mPositionController.up();
             }
         }
     }
 
-    public void notifyOnNewImage() {
-        mPositionController.setImageSize(0, 0);
+    private void setFilmMode(boolean enabled) {
+        if (mFilmMode == enabled) return;
+        mFilmMode = enabled;
+        mPositionController.setFilmMode(mFilmMode);
     }
 
-    public void startSlideInAnimation(int direction) {
-        PositionController a = mPositionController;
-        a.stopAnimation();
-        switch (direction) {
-            case TRANS_SLIDE_IN_LEFT:
-            case TRANS_SLIDE_IN_RIGHT: {
-                mTransitionMode = direction;
-                a.startSlideInAnimation(direction);
-                break;
-            }
-            default: throw new IllegalArgumentException(String.valueOf(direction));
+    ////////////////////////////////////////////////////////////////////////////
+    //  Framework events
+    ////////////////////////////////////////////////////////////////////////////
+
+    @Override
+    protected void onLayout(
+            boolean changeSize, int left, int top, int right, int bottom) {
+        mTileView.layout(left, top, right, bottom);
+        mEdgeView.layout(left, top, right, bottom);
+        if (changeSize) {
+            mPositionController.setViewSize(getWidth(), getHeight());
         }
     }
 
-    private void switchToNextImage() {
-        // We update the texture here directly to prevent texture uploading.
-        ScreenNailEntry prevNail = mScreenNails[ENTRY_PREVIOUS];
-        ScreenNailEntry nextNail = mScreenNails[ENTRY_NEXT];
-        mTileView.invalidateTiles();
-        prevNail.updateScreenNail(mTileView.releaseScreenNail());
-        mTileView.updateScreenNail(nextNail.releaseScreenNail());
-        mModel.next();
+    public void pause() {
+        mPositionController.skipAnimation();
+        mTileView.freeTextures();
+        for (int i = -SCREEN_NAIL_MAX; i <= SCREEN_NAIL_MAX; i++) {
+            mPictures.get(i).updateScreenNail(null);
+        }
     }
 
-    private void switchToPreviousImage() {
-        // We update the texture here directly to prevent texture uploading.
-        ScreenNailEntry prevNail = mScreenNails[ENTRY_PREVIOUS];
-        ScreenNailEntry nextNail = mScreenNails[ENTRY_NEXT];
-        mTileView.invalidateTiles();
-        nextNail.updateScreenNail(mTileView.releaseScreenNail());
-        mTileView.updateScreenNail(prevNail.releaseScreenNail());
-        mModel.previous();
+    public void resume() {
+        mTileView.prepareTextures();
     }
 
-    public void notifyTransitionComplete() {
-        mHandler.sendEmptyMessage(MSG_TRANSITION_COMPLETE);
+    ////////////////////////////////////////////////////////////////////////////
+    //  Rendering
+    ////////////////////////////////////////////////////////////////////////////
+
+    @Override
+    protected void render(GLCanvas canvas) {
+        // Draw next photos
+        for (int i = 1; i <= SCREEN_NAIL_MAX; i++) {
+            Rect r = mPositionController.getPosition(i);
+            mPictures.get(i).draw(canvas, r);
+            // In page mode, we draw only one next photo.
+            if (!mFilmMode) break;
+        }
+
+        // Draw current photo
+        mPictures.get(0).draw(canvas, mPositionController.getPosition(0));
+
+        // Draw previous photos
+        for (int i = -1; i >= -SCREEN_NAIL_MAX; i--) {
+            Rect r = mPositionController.getPosition(i);
+            mPictures.get(i).draw(canvas, r);
+            // In page mode, we draw only one previous photo.
+            if (!mFilmMode) break;
+        }
+
+        mPositionController.advanceAnimation();
+        checkFocusSwitching();
     }
 
-    private void onTransitionComplete() {
-        int mode = mTransitionMode;
-        mTransitionMode = TRANS_NONE;
+    ////////////////////////////////////////////////////////////////////////////
+    //  Film mode focus switching
+    ////////////////////////////////////////////////////////////////////////////
 
-        if (mModel == null) return;
-        if (mode == TRANS_SWITCH_NEXT) {
-            switchToNextImage();
-        } else if (mode == TRANS_SWITCH_PREVIOUS) {
-            switchToPreviousImage();
+    // Runs in GL thread.
+    private void checkFocusSwitching() {
+        if (!mFilmMode) return;
+        if (mHandler.hasMessages(MSG_SWITCH_FOCUS)) return;
+        if (switchPosition() != 0) {
+            mHandler.sendEmptyMessage(MSG_SWITCH_FOCUS);
         }
     }
 
-    public boolean isDown() {
-        return mGestureRecognizer.isDown();
+    // Runs in main thread.
+    private void switchFocus() {
+        if (mGestureRecognizer.isDown()) return;
+        switch (switchPosition()) {
+            case -1:
+                switchToPrevImage();
+                break;
+            case 1:
+                switchToNextImage();
+                break;
+        }
     }
 
-    public static interface Model extends TileImageView.Model {
-        public void next();
-        public void previous();
-        public int getImageRotation();
+    // Returns -1 if we should switch focus to the previous picture, +1 if we
+    // should switch to the next, 0 otherwise.
+    private int switchPosition() {
+        Rect curr = mPositionController.getPosition(0);
+        int center = getWidth() / 2;
 
-        // Return null if the specified image is unavailable.
-        public ScreenNail getNextScreenNail();
-        public ScreenNail getPrevScreenNail();
-    }
+        if (curr.left > center && mPictures.get(-1).isEnabled()) {
+            Rect prev = mPositionController.getPosition(-1);
+            int currDist = curr.left - center;
+            int prevDist = center - prev.right;
+            if (prevDist < currDist) {
+                return -1;
+            }
+        } else if (curr.right < center && mPictures.get(1).isEnabled()) {
+            Rect next = mPositionController.getPosition(1);
+            int currDist = center - curr.right;
+            int nextDist = next.left - center;
+            if (nextDist < currDist) {
+                return 1;
+            }
+        }
 
-    private static int getRotated(int degree, int original, int theother) {
-        return ((degree / 90) & 1) == 0 ? original : theother;
+        return 0;
     }
 
-    private class ScreenNailEntry {
-        private boolean mVisible;
-        private boolean mEnabled;
+    ////////////////////////////////////////////////////////////////////////////
+    //  Page mode focus switching
+    //
+    //  We slide image to the next one or the previous one in two cases: 1: If
+    //  the user did a fling gesture with enough velocity.  2 If the user has
+    //  moved the picture a lot.
+    ////////////////////////////////////////////////////////////////////////////
 
-        private int mDrawWidth;
-        private int mDrawHeight;
-        private int mOffsetX;
-        private int mRotation;
+    private boolean swipeImages(float velocityX, float velocityY) {
+        if (mFilmMode) return false;
 
-        private ScreenNail mScreenNail;
+        // Avoid swiping images if we're possibly flinging to view the
+        // zoomed in picture vertically.
+        PositionController controller = mPositionController;
+        boolean isMinimal = controller.isAtMinimalScale();
+        int edges = controller.getImageAtEdges();
+        if (!isMinimal && Math.abs(velocityY) > Math.abs(velocityX))
+            if ((edges & PositionController.IMAGE_AT_TOP_EDGE) == 0
+                    || (edges & PositionController.IMAGE_AT_BOTTOM_EDGE) == 0)
+                return false;
 
-        public void updateScreenNail(ScreenNail screenNail) {
-            mEnabled = (screenNail != null);
-            if (mScreenNail == screenNail) return;
-            if (mScreenNail != null) mScreenNail.pauseDraw();
-            mScreenNail = screenNail;
-            if (mScreenNail != null) {
-                mRotation = mScreenNail.getRotation();
-                updateDrawingSize();
-            }
+        // If we are at the edge of the current photo and the sweeping velocity
+        // exceeds the threshold, slide to the next / previous image.
+        if (velocityX < -SWIPE_THRESHOLD && (isMinimal
+                || (edges & PositionController.IMAGE_AT_RIGHT_EDGE) != 0)) {
+            return slideToNextPicture();
+        } else if (velocityX > SWIPE_THRESHOLD && (isMinimal
+                || (edges & PositionController.IMAGE_AT_LEFT_EDGE) != 0)) {
+            return slideToPrevPicture();
         }
 
-        // Release the ownership of the ScreenNail from this entry.
-        public ScreenNail releaseScreenNail() {
-            ScreenNail s = mScreenNail;
-            mScreenNail = null;
-            return s;
-        }
+        return false;
+    }
 
-        public void layoutRightEdgeAt(int x) {
-            mVisible = x > 0;
-            mOffsetX = x - getRotated(
-                    mRotation, mDrawWidth, mDrawHeight) / 2;
-        }
+    private boolean snapToNeighborImage() {
+        if (mFilmMode) return false;
 
-        public void layoutLeftEdgeAt(int x) {
-            mVisible = x < getWidth();
-            mOffsetX = x + getRotated(
-                    mRotation, mDrawWidth, mDrawHeight) / 2;
-        }
+        Rect r = mPositionController.getPosition(0);
+        int viewW = getWidth();
+        int threshold = MOVE_THRESHOLD + gapToSide(r.width(), viewW);
 
-        public int gapToSide() {
-            return ((mRotation / 90) & 1) != 0
-                ? PhotoView.gapToSide(mDrawHeight, getWidth())
-                : PhotoView.gapToSide(mDrawWidth, getWidth());
+        // If we have moved the picture a lot, switching.
+        if (viewW - r.right > threshold) {
+            return slideToNextPicture();
+        } else if (r.left > threshold) {
+            return slideToPrevPicture();
         }
 
-        public void updateDrawingSize() {
-            if (mScreenNail == null) return;
+        return false;
+    }
 
-            int width = mScreenNail.getWidth();
-            int height = mScreenNail.getHeight();
+    private boolean slideToNextPicture() {
+        Picture next = mPictures.get(1);
+        if (!next.isEnabled()) return false;
+        int currentX = mPositionController.getPosition(1).centerX();
+        int targetX = getWidth() / 2;
+        mPositionController.startHorizontalSlide(targetX - currentX);
+        switchToNextImage();
+        return true;
+    }
 
-            // Calculate the initial scale that will used by PositionController
-            // (usually fit-to-screen)
-            float s = ((mRotation / 90) & 0x01) == 0
-                    ? mPositionController.getMinimalScale(width, height)
-                    : mPositionController.getMinimalScale(height, width);
+    private boolean slideToPrevPicture() {
+        Picture prev = mPictures.get(-1);
+        if (!prev.isEnabled()) return false;
+        int currentX = mPositionController.getPosition(-1).centerX();
+        int targetX = getWidth() / 2;
+        mPositionController.startHorizontalSlide(targetX - currentX);
+        switchToPrevImage();
+        return true;
+    }
 
-            mDrawWidth = Math.round(width * s);
-            mDrawHeight = Math.round(height * s);
-        }
+    private static int gapToSide(int imageWidth, int viewWidth) {
+        return Math.max(0, (viewWidth - imageWidth) / 2);
+    }
 
-        public boolean isEnabled() {
-            return mEnabled;
-        }
+    ////////////////////////////////////////////////////////////////////////////
+    //  Focus switching
+    ////////////////////////////////////////////////////////////////////////////
 
-        public void draw(GLCanvas canvas, boolean applyFadingAnimation) {
-            if (mScreenNail == null) return;
-            if (!mVisible) {
-                mScreenNail.noDraw();
-                return;
-            }
+    private void switchToNextImage() {
+        mModel.next();
+    }
 
-            int w = getWidth();
-            int x = applyFadingAnimation ? w / 2 : mOffsetX;
-            int y = getHeight() / 2;
-            int flags = GLCanvas.SAVE_FLAG_MATRIX;
+    private void switchToPrevImage() {
+        mModel.previous();
+    }
 
-            if (applyFadingAnimation) flags |= GLCanvas.SAVE_FLAG_ALPHA;
-            canvas.save(flags);
-            canvas.translate(x, y);
-            if (applyFadingAnimation) {
-                float progress = (float) (x - mOffsetX) / w;
-                float alpha = getScrollAlpha(progress);
-                float scale = getScrollScale(progress);
-                canvas.multiplyAlpha(alpha);
-                canvas.scale(scale, scale, 1);
-            }
-            if (mRotation != 0) {
-                canvas.rotate(mRotation, 0, 0, 1);
-            }
-            canvas.translate(-x, -y);
-            mScreenNail.draw(canvas, x - mDrawWidth / 2, y - mDrawHeight / 2,
-                    mDrawWidth, mDrawHeight);
-            canvas.restore();
-        }
+    ////////////////////////////////////////////////////////////////////////////
+    //  Opening Animation
+    ////////////////////////////////////////////////////////////////////////////
+
+    public void setOpenAnimationRect(Rect rect) {
+        mPositionController.setOpenAnimationRect(rect);
     }
 
+    ////////////////////////////////////////////////////////////////////////////
+    //  Card deck effect calculation
+    ////////////////////////////////////////////////////////////////////////////
+
     // Returns the scrolling progress value for an object moving out of a
     // view. The progress value measures how much the object has moving out of
     // the view. The object currently displays in [left, right), and the view is
@@ -868,39 +987,15 @@ public class PhotoView extends GLView {
         }
     }
 
-    public void pause() {
-        mPositionController.skipAnimation();
-        mTransitionMode = TRANS_NONE;
-        mTileView.freeTextures();
-        for (ScreenNailEntry entry : mScreenNails) {
-            entry.updateScreenNail(null);
-        }
-    }
-
-    public void resume() {
-        mTileView.prepareTextures();
-    }
+    ////////////////////////////////////////////////////////////////////////////
+    //  Simple public utilities
+    ////////////////////////////////////////////////////////////////////////////
 
-    public void setOpenAnimationRect(Rect rect) {
-        mOpenAnimationRect = rect;
+    public void setPhotoTapListener(PhotoTapListener listener) {
+        mPhotoTapListener = listener;
     }
 
     public void showVideoPlayIcon(boolean show) {
         mShowVideoPlayIcon = show;
     }
-
-    // Returns the opening animation rectangle saved by the previous page.
-    public Rect retrieveOpenAnimationRect() {
-        Rect r = mOpenAnimationRect;
-        mOpenAnimationRect = null;
-        return r;
-    }
-
-    public void openAnimationStarted() {
-        mTransitionMode = TRANS_OPEN_ANIMATION;
-    }
-
-    public boolean isInTransition() {
-        return mTransitionMode != TRANS_NONE;
-    }
 }
index 625505f..d190e04 100644 (file)
@@ -18,352 +18,415 @@ package com.android.gallery3d.ui;
 
 import android.content.Context;
 import android.graphics.Rect;
-import android.graphics.RectF;
-import android.util.FloatMath;
+import android.util.Log;
+import android.widget.Scroller;
 
 import com.android.gallery3d.common.Utils;
-import com.android.gallery3d.data.MediaItem;
 import com.android.gallery3d.util.GalleryUtils;
+import com.android.gallery3d.util.RangeArray;
+import com.android.gallery3d.util.RangeIntArray;
 
 class PositionController {
+    private static final String TAG = "PositionController";
+
     public static final int IMAGE_AT_LEFT_EDGE = 1;
     public static final int IMAGE_AT_RIGHT_EDGE = 2;
     public static final int IMAGE_AT_TOP_EDGE = 4;
     public static final int IMAGE_AT_BOTTOM_EDGE = 8;
 
-    private long mAnimationStartTime = NO_ANIMATION;
+    // Special values for animation time.
     private static final long NO_ANIMATION = -1;
     private static final long LAST_ANIMATION = -2;
 
-    private int mAnimationKind;
-    private float mAnimationDuration;
-    private final static int ANIM_KIND_SCROLL = 0;
-    private final static int ANIM_KIND_SCALE = 1;
-    private final static int ANIM_KIND_SNAPBACK = 2;
-    private final static int ANIM_KIND_SLIDE = 3;
-    private final static int ANIM_KIND_ZOOM = 4;
-    private final static int ANIM_KIND_FLING = 5;
+    private static final int ANIM_KIND_SCROLL = 0;
+    private static final int ANIM_KIND_SCALE = 1;
+    private static final int ANIM_KIND_SNAPBACK = 2;
+    private static final int ANIM_KIND_SLIDE = 3;
+    private static final int ANIM_KIND_ZOOM = 4;
+    private static final int ANIM_KIND_OPENING = 5;
+    private static final int ANIM_KIND_FLING = 6;
 
     // Animation time in milliseconds. The order must match ANIM_KIND_* above.
-    private final static int ANIM_TIME[] = {
+    private static final int ANIM_TIME[] = {
         0,    // ANIM_KIND_SCROLL
         50,   // ANIM_KIND_SCALE
         600,  // ANIM_KIND_SNAPBACK
         400,  // ANIM_KIND_SLIDE
         300,  // ANIM_KIND_ZOOM
+        600,  // ANIM_KIND_OPENING
         0,    // ANIM_KIND_FLING (the duration is calculated dynamically)
     };
 
     // We try to scale up the image to fill the screen. But in order not to
     // scale too much for small icons, we limit the max up-scaling factor here.
     private static final float SCALE_LIMIT = 4;
-    private static final int sHorizontalSlack = GalleryUtils.dpToPixel(12);
 
-    private static final float SCALE_MIN_EXTRA = 0.6f;
+    // For user's gestures, we give a temporary extra scaling range which goes
+    // above or below the usual scaling limits.
+    private static final float SCALE_MIN_EXTRA = 0.7f;
     private static final float SCALE_MAX_EXTRA = 1.4f;
 
-    private PhotoView mViewer;
-    private EdgeView mEdgeView;
-    private int mImageW, mImageH;
-    private int mViewW, mViewH;
-
-    // The X, Y are the coordinate on bitmap which shows on the center of
-    // the view. We always keep the mCurrent{X,Y,Scale} sync with the actual
-    // values used currently.
-    private int mCurrentX, mFromX, mToX;
-    private int mCurrentY, mFromY, mToY;
-    private float mCurrentScale, mFromScale, mToScale;
-
-    // The focus point of the scaling gesture (in bitmap coordinates).
-    private int mFocusBitmapX;
-    private int mFocusBitmapY;
-    private boolean mInScale;
-
-    // The minimum and maximum scale we allow.
-    private float mScaleMin, mScaleMax = SCALE_LIMIT;
+    // Setting this true makes the extra scaling range permanent (until this is
+    // set to false again).
     private boolean mExtraScalingRange = false;
 
-    // This is used by the fling animation
-    private FlingScroller mScroller;
+    // Film Mode v.s. Page Mode: in film mode we show smaller pictures.
+    private boolean mFilmMode = false;
+    private static final float FILM_MODE_SCALE_FACTOR = 0.7f;
 
-    // The bound of the stable region, see the comments above
-    // calculateStableBound() for details.
-    private int mBoundLeft, mBoundRight, mBoundTop, mBoundBottom;
+    // The scaling factor in current mode.
+    private float mScaleFactor = mFilmMode ? FILM_MODE_SCALE_FACTOR : 1.0f;
 
-    // Assume the image size is the same as view size before we know the actual
-    // size of image.
-    private boolean mUseViewSize = true;
+    // In addition to the focused box (index == 0). We also keep information
+    // about this many boxes on each side.
+    private static final int BOX_MAX = PhotoView.SCREEN_NAIL_MAX;
 
-    private RectF mTempRect = new RectF();
-    private float[] mTempPoints = new float[8];
+    public static final int IMAGE_GAP = 96;
+    private static final int HORIZONTAL_SLACK = GalleryUtils.dpToPixel(12);
 
-    public PositionController(PhotoView viewer, Context context,
-            EdgeView edgeView) {
-        mViewer = viewer;
-        mEdgeView = edgeView;
-        mScroller = new FlingScroller();
-    }
+    private Listener mListener;
+    private volatile Rect mOpenAnimationRect;
+    private int mViewW = 640;
+    private int mViewH = 480;;
 
-    public void setImageSize(int width, int height) {
+    // A scaling guesture is in progress.
+    private boolean mInScale;
+    // The focus point of the scaling gesture, relative to the center of the
+    // picture in bitmap pixels.
+    private float mFocusX, mFocusY;
 
-        // If no image available, use view size.
-        if (width == 0 || height == 0) {
-            mUseViewSize = true;
-            mImageW = mViewW;
-            mImageH = mViewH;
-            mCurrentX = mImageW / 2;
-            mCurrentY = mImageH / 2;
-            mCurrentScale = 1;
-            mScaleMin = 1;
-            mViewer.setPosition(mCurrentX, mCurrentY, mCurrentScale);
-            return;
-        }
+    // This is used by the fling animation (page mode).
+    private FlingScroller mPageScroller;
 
-        mUseViewSize = false;
+    // This is used by the fling animation (film mode).
+    private Scroller mFilmScroller;
 
-        float ratio = Math.min(
-                (float) mImageW / width, (float) mImageH / height);
+    // The bound of the stable region that the focused box can stay, see the
+    // comments above calculateStableBound() for details.
+    private int mBoundLeft, mBoundRight, mBoundTop, mBoundBottom;
 
-        // See the comment above translate() for details.
-        mCurrentX = translate(mCurrentX, mImageW, width, ratio);
-        mCurrentY = translate(mCurrentY, mImageH, height, ratio);
-        mCurrentScale = mCurrentScale * ratio;
+    //
+    //  ___________________________________________________________
+    // |   _____       _____       _____       _____       _____   |
+    // |  |     |     |     |     |     |     |     |     |     |  |
+    // |  | Box |     | Box |     | Box*|     | Box |     | Box |  |
+    // |  |_____|.....|_____|.....|_____|.....|_____|.....|_____|  |
+    // |          Gap         Gap         Gap         Gap          |
+    // |___________________________________________________________|
+    //
+    //                       <--  Platform  -->
+    //
+    // The focused box (Box*) centers at mPlatform.mCurrentX
 
-        mFromX = translate(mFromX, mImageW, width, ratio);
-        mFromY = translate(mFromY, mImageH, height, ratio);
-        mFromScale = mFromScale * ratio;
+    private Platform mPlatform = new Platform();
+    private RangeArray<Box> mBoxes = new RangeArray<Box>(-BOX_MAX, BOX_MAX);
+    // The gap at the right of a Box i is at index i. The gap at the left of a
+    // Box i is at index i - 1.
+    private RangeArray<Gap> mGaps = new RangeArray<Gap>(-BOX_MAX, BOX_MAX - 1);
 
-        mToX = translate(mToX, mImageW, width, ratio);
-        mToY = translate(mToY, mImageH, height, ratio);
-        mToScale = mToScale * ratio;
+    // These are only used during moveBox().
+    private RangeArray<Box> mTempBoxes = new RangeArray<Box>(-BOX_MAX, BOX_MAX);
+    private RangeArray<Gap> mTempGaps = new RangeArray<Gap>(-BOX_MAX, BOX_MAX - 1);
 
-        mFocusBitmapX = translate(mFocusBitmapX, mImageW, width, ratio);
-        mFocusBitmapY = translate(mFocusBitmapY, mImageH, height, ratio);
+    // The output of the PositionController. Available throught getPosition().
+    private RangeArray<Rect> mRects = new RangeArray<Rect>(-BOX_MAX, BOX_MAX);
 
-        mImageW = width;
-        mImageH = height;
+    public interface Listener {
+        void invalidate();
+        boolean isDown();
 
-        mScaleMin = getMinimalScale(mImageW, mImageH);
+        // EdgeView
+        void onPull(int offset, int direction);
+        void onRelease();
+        void onAbsorb(int velocity, int direction);
+    }
 
-        // Start animation from the saved rectangle if we have one.
-        Rect r = mViewer.retrieveOpenAnimationRect();
-        if (r != null) {
-            // The animation starts from the specified rectangle; the image
-            // should be scaled and centered as the thumbnail shown in the
-            // rectangle to minimize janky opening animation. Note: The below
-            // implementation depends on how thumbnails are drawn and placed.
-            float size = MediaItem.getTargetSize(
-                    MediaItem.TYPE_MICROTHUMBNAIL);
-            float scale = (size / Math.min(width, height)) * Math.min(
-                    r.width() / size, r.height() / size);
-
-            mCurrentX = Math.round((mViewW / 2f - r.centerX()) / scale) + mImageW / 2;
-            mCurrentY = Math.round((mViewH / 2f - r.centerY()) / scale) + mImageH / 2;
-            mCurrentScale = scale;
-            mViewer.openAnimationStarted();
-            startSnapback();
-        } else if (mAnimationStartTime == NO_ANIMATION) {
-            mCurrentScale = Utils.clamp(mCurrentScale, mScaleMin, mScaleMax);
-        }
-        mViewer.setPosition(mCurrentX, mCurrentY, mCurrentScale);
+    public PositionController(Context context, Listener listener) {
+        mListener = listener;
+        mPageScroller = new FlingScroller();
+        mFilmScroller = new Scroller(context);
+
+        // Initialize the areas.
+        initPlatform();
+        for (int i = -BOX_MAX; i <= BOX_MAX; i++) {
+            mBoxes.put(i, new Box());
+            initBox(i);
+            mRects.put(i, new Rect());
+        }
+        for (int i = -BOX_MAX; i < BOX_MAX; i++) {
+            mGaps.put(i, new Gap());
+            initGap(i);
+        }
     }
 
-    public void zoomIn(float tapX, float tapY, float targetScale) {
-        if (targetScale > mScaleMax) targetScale = mScaleMax;
+    public void setOpenAnimationRect(Rect r) {
+        mOpenAnimationRect = r;
+    }
 
-        // Convert the tap position to image coordinate
-        int tempX = Math.round((tapX - mViewW / 2) / mCurrentScale + mCurrentX);
-        int tempY = Math.round((tapY - mViewH / 2) / mCurrentScale + mCurrentY);
+    public void setViewSize(int viewW, int viewH) {
+        if (viewW == mViewW && viewH == mViewH) return;
 
-        calculateStableBound(targetScale);
-        int targetX = Utils.clamp(tempX, mBoundLeft, mBoundRight);
-        int targetY = Utils.clamp(tempY, mBoundTop, mBoundBottom);
+        mViewW = viewW;
+        mViewH = viewH;
+        initPlatform();
 
-        startAnimation(targetX, targetY, targetScale, ANIM_KIND_ZOOM);
-    }
+        for (int i = -BOX_MAX; i <= BOX_MAX; i++) {
+            setBoxSize(i, viewW, viewH, true);
+        }
 
-    public void resetToFullView() {
-        startAnimation(mImageW / 2, mImageH / 2, mScaleMin, ANIM_KIND_ZOOM);
+        updateScaleAndGapLimit();
+        snapAndRedraw();
     }
 
-    public float getMinimalScale(int w, int h) {
-        return Math.min(SCALE_LIMIT,
-                Math.min((float) mViewW / w, (float) mViewH / h));
-    }
+    public void setImageSize(int index, int width, int height) {
+        if (width == 0 || height == 0) {
+            initBox(index);
+        } else {
+            setBoxSize(index, width, height, false);
+        }
 
-    // Translate a coordinate on bitmap if the bitmap size changes.
-    // If the aspect ratio doesn't change, it's easy:
-    //
-    //         r  = w / w' (= h / h')
-    //         x' = x / r
-    //         y' = y / r
-    //
-    // However the aspect ratio may change. That happens when the user slides
-    // a image before it's loaded, we don't know the actual aspect ratio, so
-    // we will assume one. When we receive the actual bitmap size, we need to
-    // translate the coordinate from the old bitmap into the new bitmap.
-    //
-    // What we want to do is center the bitmap at the original position.
-    //
-    //         ...+--+...
-    //         .  |  |  .
-    //         .  |  |  .
-    //         ...+--+...
-    //
-    // First we scale down the new bitmap by a factor r = min(w/w', h/h').
-    // Overlay it onto the original bitmap. Now (0, 0) of the old bitmap maps
-    // to (-(w-w'*r)/2 / r, -(h-h'*r)/2 / r) in the new bitmap. So (x, y) of
-    // the old bitmap maps to (x', y') in the new bitmap, where
-    //         x' = (x-(w-w'*r)/2) / r = w'/2 + (x-w/2)/r
-    //         y' = (y-(h-h'*r)/2) / r = h'/2 + (y-h/2)/r
-    private static int translate(int value, int size, int newSize, float ratio) {
-        return Math.round(newSize / 2f + (value - size / 2f) / ratio);
+        updateScaleAndGapLimit();
+        startOpeningAnimationIfNeeded();
+        snapAndRedraw();
     }
 
-    public void setViewSize(int viewW, int viewH) {
-        boolean needLayout = mViewW == 0 || mViewH == 0;
+    private void setBoxSize(int i, int width, int height, boolean isViewSize) {
+        Box b = mBoxes.get(i);
 
-        mViewW = viewW;
-        mViewH = viewH;
+        // If we already have image size, we don't want to use the view size.
+        if (isViewSize && !b.mUseViewSize) return;
+        b.mUseViewSize = isViewSize;
 
-        if (mUseViewSize) {
-            mImageW = viewW;
-            mImageH = viewH;
-            mCurrentX = mImageW / 2;
-            mCurrentY = mImageH / 2;
-            mCurrentScale = 1;
-            mScaleMin = 1;
-            mViewer.setPosition(mCurrentX, mCurrentY, mCurrentScale);
+        if (width == b.mImageW && height == b.mImageH) {
             return;
         }
 
-        // In most cases we want to keep the scaling factor intact when the
-        // view size changes. The cases we want to reset the scaling factor
-        // (to fit the view if possible) are (1) the scaling factor is too
-        // small for the new view size (2) the scaling factor has not been
-        // changed by the user.
-        boolean wasMinScale = (mCurrentScale == mScaleMin);
-        mScaleMin = getMinimalScale(mImageW, mImageH);
+        // The ratio of the old size and the new size.
+        float ratio = Math.min(
+                (float) b.mImageW / width, (float) b.mImageH / height);
+
+        b.mCurrentScale *= ratio;
+        b.mFromScale *= ratio;
+        b.mToScale *= ratio;
 
-        if (needLayout || mCurrentScale < mScaleMin || wasMinScale) {
-            mCurrentX = mImageW / 2;
-            mCurrentY = mImageH / 2;
-            mCurrentScale = mScaleMin;
-            mViewer.setPosition(mCurrentX, mCurrentY, mCurrentScale);
+        b.mImageW = width;
+        b.mImageH = height;
+
+        if (i == 0) {
+            mFocusX /= ratio;
+            mFocusY /= ratio;
         }
     }
 
-    public void stopAnimation() {
-        mAnimationStartTime = NO_ANIMATION;
+    private void startOpeningAnimationIfNeeded() {
+        if (mOpenAnimationRect == null) return;
+        Box b = mBoxes.get(0);
+        if (b.mUseViewSize) return;
+
+        // Start animation from the saved rectangle if we have one.
+        Rect r = mOpenAnimationRect;
+        mOpenAnimationRect = null;
+        mPlatform.mCurrentX = r.centerX();
+        b.mCurrentY = r.centerY();
+        b.mCurrentScale = Math.max(r.width() / (float) b.mImageW,
+                r.height() / (float) b.mImageH);
+        startAnimation(mViewW / 2, mViewH / 2, b.mScaleMin, ANIM_KIND_OPENING);
     }
 
-    public void skipAnimation() {
-        if (mAnimationStartTime == NO_ANIMATION) return;
-        mAnimationStartTime = NO_ANIMATION;
-        mCurrentX = mToX;
-        mCurrentY = mToY;
-        mCurrentScale = mToScale;
+    public void setFilmMode(boolean enabled) {
+        if (enabled == mFilmMode) return;
+        mFilmMode = enabled;
+        mScaleFactor = enabled ? FILM_MODE_SCALE_FACTOR : 1.0f;
+
+        updateScaleAndGapLimit();
+        stopAnimation();
+        snapAndRedraw();
     }
 
-    public void beginScale(float focusX, float focusY) {
-        mInScale = true;
-        mFocusBitmapX = Math.round(mCurrentX +
-                (focusX - mViewW / 2f) / mCurrentScale);
-        mFocusBitmapY = Math.round(mCurrentY +
-                (focusY - mViewH / 2f) / mCurrentScale);
+    public void setExtraScalingRange(boolean enabled) {
+        if (mExtraScalingRange == enabled) return;
+        mExtraScalingRange = enabled;
+        if (!enabled) {
+            snapAndRedraw();
+        }
     }
 
-    // Returns true if the result scale is outside the stable range.
-    public boolean scaleBy(float s, float focusX, float focusY) {
+    // This should be called whenever the scale range of boxes or the default
+    // gap size may change. Currently this can happen due to change of view
+    // size, image size, and mode.
+    private void updateScaleAndGapLimit() {
+        for (int i = -BOX_MAX; i <= BOX_MAX; i++) {
+            Box b = mBoxes.get(i);
+            b.mScaleMin = getMinimalScale(b.mImageW, b.mImageH);
+            b.mScaleMax = getMaximalScale(b.mImageW, b.mImageH);
+        }
 
-        // We want to keep the focus point (on the bitmap) the same as when
-        // we begin the scale guesture, that is,
-        //
-        // mCurrentX' + (focusX - mViewW / 2f) / scale = mFocusBitmapX
-        //
-        s *= getTargetScale();
-        int x = Math.round(mFocusBitmapX - (focusX - mViewW / 2f) / s);
-        int y = Math.round(mFocusBitmapY - (focusY - mViewH / 2f) / s);
+        for (int i = -BOX_MAX; i < BOX_MAX; i++) {
+            Gap g = mGaps.get(i);
+            g.mDefaultSize = getDefaultGapSize(i);
+        }
+    }
 
-        startAnimation(x, y, s, ANIM_KIND_SCALE);
-        return (s < mScaleMin || s > mScaleMax);
+    // Returns the default gap size according the the size of the boxes around
+    // the gap and the current mode.
+    private int getDefaultGapSize(int i) {
+        if (mFilmMode) return IMAGE_GAP;
+        Box a = mBoxes.get(i);
+        Box b = mBoxes.get(i + 1);
+        return IMAGE_GAP + Math.max(gapToSide(a), gapToSide(b));
     }
 
-    public void endScale() {
-        mInScale = false;
-        startSnapbackIfNeeded();
+    // Here is how we layout the boxes in the page mode.
+    //
+    //   previous             current             next
+    //  ___________       ________________     __________
+    // |  _______  |     |   __________   |   |  ______  |
+    // | |       | |     |  |   right->|  |   | |      | |
+    // | |       |<-------->|<--left   |  |   | |      | |
+    // | |_______| |  |  |  |__________|  |   | |______| |
+    // |___________|  |  |________________|   |__________|
+    //                |  <--> gapToSide()
+    //                |
+    // IMAGE_GAP + MAX(gapToSide(previous), gapToSide(current))
+    private int gapToSide(Box b) {
+        return (int) ((mViewW - getMinimalScale(b) * b.mImageW) / 2 + 0.5f);
     }
 
-    public void setExtraScalingRange(boolean enabled) {
-        mExtraScalingRange = enabled;
-        if (!enabled) {
-            startSnapbackIfNeeded();
+    // Stop all animations at where they are now.
+    public void stopAnimation() {
+        mPlatform.mAnimationStartTime = NO_ANIMATION;
+        for (int i = -BOX_MAX; i <= BOX_MAX; i++) {
+            mBoxes.get(i).mAnimationStartTime = NO_ANIMATION;
+        }
+        for (int i = -BOX_MAX; i < BOX_MAX; i++) {
+            mGaps.get(i).mAnimationStartTime = NO_ANIMATION;
         }
     }
 
-    public float getCurrentScale() {
-        return mCurrentScale;
+    public void skipAnimation() {
+        if (mPlatform.mAnimationStartTime != NO_ANIMATION) {
+            mPlatform.mCurrentX = mPlatform.mToX;
+            mPlatform.mAnimationStartTime = NO_ANIMATION;
+        }
+        for (int i = -BOX_MAX; i <= BOX_MAX; i++) {
+            Box b = mBoxes.get(i);
+            if (b.mAnimationStartTime == NO_ANIMATION) continue;
+            b.mCurrentY = b.mToY;
+            b.mCurrentScale = b.mToScale;
+            b.mAnimationStartTime = NO_ANIMATION;
+        }
+        for (int i = -BOX_MAX; i < BOX_MAX; i++) {
+            Gap g = mGaps.get(i);
+            if (g.mAnimationStartTime == NO_ANIMATION) continue;
+            g.mCurrentGap = g.mToGap;
+            g.mAnimationStartTime = NO_ANIMATION;
+        }
+        redraw();
     }
 
-    public boolean isAtMinimalScale() {
-        return isAlmostEquals(mCurrentScale, mScaleMin);
+    public void up() {
+        snapAndRedraw();
     }
 
-    private static boolean isAlmostEquals(float a, float b) {
-        float diff = a - b;
-        return (diff < 0 ? -diff : diff) < 0.02f;
+    ////////////////////////////////////////////////////////////////////////////
+    //  Start an animations for the focused box
+    ////////////////////////////////////////////////////////////////////////////
+
+    public void zoomIn(float tapX, float tapY, float targetScale) {
+        Box b = mBoxes.get(0);
+
+        // Convert the tap position to distance to center in bitmap coordinates
+        float tempX = (tapX - mPlatform.mCurrentX) / b.mCurrentScale;
+        float tempY = (tapY - b.mCurrentY) / b.mCurrentScale;
+
+        int x = (int) (mViewW / 2 - tempX * targetScale + 0.5f);
+        int y = (int) (mViewH / 2 - tempY * targetScale + 0.5f);
+
+        calculateStableBound(targetScale);
+        int targetX = Utils.clamp(x, mBoundLeft, mBoundRight);
+        int targetY = Utils.clamp(y, mBoundTop, mBoundBottom);
+        targetScale = Utils.clamp(targetScale, b.mScaleMin, b.mScaleMax);
+
+        startAnimation(targetX, targetY, targetScale, ANIM_KIND_ZOOM);
     }
 
-    public void up() {
-        startSnapback();
-    }
-
-    //             |<--| (1/2) * mImageW
-    // +-------+-------+-------+
-    // |       |       |       |
-    // |       |   o   |       |
-    // |       |       |       |
-    // +-------+-------+-------+
-    // |<----------| (3/2) * mImageW
-    // Slide in the image from left or right.
-    // Precondition: mCurrentScale = 1 (mView{W|H} == mImage{W|H}).
-    // Sliding from left:  mCurrentX = (1/2) * mImageW
-    //              right: mCurrentX = (3/2) * mImageW
-    public void startSlideInAnimation(int direction) {
-        int fromX = (direction == PhotoView.TRANS_SLIDE_IN_LEFT) ?
-                mImageW / 2 : 3 * mImageW / 2;
-        mFromX = Math.round(fromX);
-        mFromY = Math.round(mImageH / 2f);
-        mCurrentX = mFromX;
-        mCurrentY = mFromY;
-        startAnimation(
-                mImageW / 2, mImageH / 2, mCurrentScale, ANIM_KIND_SLIDE);
+    public void resetToFullView() {
+        Box b = mBoxes.get(0);
+        startAnimation(mViewW / 2, mViewH / 2, b.mScaleMin, ANIM_KIND_ZOOM);
+    }
+
+    public void beginScale(float focusX, float focusY) {
+        Box b = mBoxes.get(0);
+        Platform p = mPlatform;
+        mInScale = true;
+        mFocusX = (int) ((focusX - p.mCurrentX) / b.mCurrentScale + 0.5f);
+        mFocusY = (int) ((focusY - b.mCurrentY) / b.mCurrentScale + 0.5f);
+    }
+
+    // Scales the image by the given factor.
+    // Returns an out-of-range indicator:
+    //   1 if the intended scale is too large for the stable range.
+    //   0 if the intended scale is in the stable range.
+    //  -1 if the intended scale is too small for the stable range.
+    public int scaleBy(float s, float focusX, float focusY) {
+        Box b = mBoxes.get(0);
+        Platform p = mPlatform;
+
+        // We want to keep the focus point (on the bitmap) the same as when we
+        // begin the scale guesture, that is,
+        //
+        // (focusX' - currentX') / scale' = (focusX - currentX) / scale
+        //
+        s *= getTargetScale(b);
+        int x = mFilmMode ? p.mCurrentX : (int) (focusX - s * mFocusX + 0.5f);
+        int y = mFilmMode ? b.mCurrentY : (int) (focusY - s * mFocusY + 0.5f);
+        startAnimation(x, y, s, ANIM_KIND_SCALE);
+        if (s < b.mScaleMin) return -1;
+        if (s > b.mScaleMax) return 1;
+        return 0;
+    }
+
+    public void endScale() {
+        mInScale = false;
+        snapAndRedraw();
     }
 
     public void startHorizontalSlide(int distance) {
-        scrollBy(distance, 0, ANIM_KIND_SLIDE);
+        Box b = mBoxes.get(0);
+        Platform p = mPlatform;
+        startAnimation(getTargetX(p) + distance, getTargetY(b),
+                b.mCurrentScale, ANIM_KIND_SLIDE);
     }
 
-    private void scrollBy(float dx, float dy, int type) {
-        startAnimation(getTargetX() + Math.round(dx / mCurrentScale),
-                getTargetY() + Math.round(dy / mCurrentScale),
-                mCurrentScale, type);
+    public void startScroll(float dx, float dy) {
+        boolean hasPrev = hasPrevImages();
+        boolean hasNext = hasNextImages();
+
+        Box b = mBoxes.get(0);
+        Platform p = mPlatform;
+
+        int x = getTargetX(p) + (int) (dx + 0.5f);
+        int y = getTargetY(b) + (int) (dy + 0.5f);
+
+        if (mFilmMode) {
+            scrollToFilm(x, y, hasPrev, hasNext);
+        } else {
+            scrollToPage(x, y, hasPrev, hasNext);
+        }
     }
 
-    public void startScroll(float dx, float dy, boolean hasNext,
-            boolean hasPrev) {
-        int x = getTargetX() + Math.round(dx / mCurrentScale);
-        int y = getTargetY() + Math.round(dy / mCurrentScale);
+    private void scrollToPage(int x, int y, boolean hasPrev, boolean hasNext) {
+        Box b = mBoxes.get(0);
 
-        calculateStableBound(mCurrentScale);
+        calculateStableBound(b.mCurrentScale);
 
         // Vertical direction: If we have space to move in the vertical
         // direction, we show the edge effect when scrolling reaches the edge.
         if (mBoundTop != mBoundBottom) {
             if (y < mBoundTop) {
-                mEdgeView.onPull(mBoundTop - y, EdgeView.TOP);
+                mListener.onPull(mBoundTop - y, EdgeView.BOTTOM);
             } else if (y > mBoundBottom) {
-                mEdgeView.onPull(y - mBoundBottom, EdgeView.BOTTOM);
+                mListener.onPull(y - mBoundBottom, EdgeView.TOP);
             }
         }
 
@@ -371,23 +434,51 @@ class PositionController {
 
         // Horizontal direction: we show the edge effect when the scrolling
         // tries to go left of the first image or go right of the last image.
-        if (!hasPrev && x < mBoundLeft) {
-            int pixels = Math.round((mBoundLeft - x) * mCurrentScale);
-            mEdgeView.onPull(pixels, EdgeView.LEFT);
-            x = mBoundLeft;
-        } else if (!hasNext && x > mBoundRight) {
-            int pixels = Math.round((x - mBoundRight) * mCurrentScale);
-            mEdgeView.onPull(pixels, EdgeView.RIGHT);
+        if (!hasPrev && x > mBoundRight) {
+            int pixels = x - mBoundRight;
+            mListener.onPull(pixels, EdgeView.LEFT);
             x = mBoundRight;
+        } else if (!hasNext && x < mBoundLeft) {
+            int pixels = mBoundLeft - x;
+            mListener.onPull(pixels, EdgeView.RIGHT);
+            x = mBoundLeft;
+        }
+
+        startAnimation(x, y, b.mCurrentScale, ANIM_KIND_SCROLL);
+    }
+
+    private void scrollToFilm(int x, int y, boolean hasPrev, boolean hasNext) {
+        Box b = mBoxes.get(0);
+
+        // Horizontal direction: we show the edge effect when the scrolling
+        // tries to go left of the first image or go right of the last image.
+        int cx = mViewW / 2;
+        if (!hasPrev && x > cx) {
+            int pixels = x - cx;
+            mListener.onPull(pixels, EdgeView.LEFT);
+            x = cx;
+        } else if (!hasNext && x < cx) {
+            int pixels = cx - x;
+            mListener.onPull(pixels, EdgeView.RIGHT);
+            x = cx;
         }
 
-        startAnimation(x, y, mCurrentScale, ANIM_KIND_SCROLL);
+        startAnimation(x, y, b.mCurrentScale, ANIM_KIND_SCROLL);
     }
 
     public boolean fling(float velocityX, float velocityY) {
+        int vx = (int) (velocityX + 0.5f);
+        int vy = (int) (velocityY + 0.5f);
+        return mFilmMode ? flingFilm(vx, vy) : flingPage(vx, vy);
+    }
+
+    private boolean flingPage(int velocityX, int velocityY) {
+        Box b = mBoxes.get(0);
+        Platform p = mPlatform;
+
         // We only want to do fling when the picture is zoomed-in.
-        if (viewWiderThanScaledImage(mCurrentScale) &&
-            viewHigherThanScaledImage(mCurrentScale)) {
+        if (viewWiderThanScaledImage(b.mCurrentScale) &&
+            viewTallerThanScaledImage(b.mCurrentScale)) {
             return false;
         }
 
@@ -402,202 +493,443 @@ class PositionController {
             (velocityY < 0 && (edges & IMAGE_AT_BOTTOM_EDGE) != 0)) {
             velocityY = 0;
         }
-        if (isAlmostEquals(velocityX, 0) && isAlmostEquals(velocityY, 0)) {
-            return false;
-        }
 
-        mScroller.fling(mCurrentX, mCurrentY,
-                Math.round(-velocityX / mCurrentScale),
-                Math.round(-velocityY / mCurrentScale),
+        if (velocityX == 0 && velocityY == 0) return false;
+
+        mPageScroller.fling(p.mCurrentX, b.mCurrentY, velocityX, velocityY,
                 mBoundLeft, mBoundRight, mBoundTop, mBoundBottom);
-        int targetX = mScroller.getFinalX();
-        int targetY = mScroller.getFinalY();
-        mAnimationDuration = mScroller.getDuration();
-        startAnimation(targetX, targetY, mCurrentScale, ANIM_KIND_FLING);
+        int targetX = mPageScroller.getFinalX();
+        int targetY = mPageScroller.getFinalY();
+        ANIM_TIME[ANIM_KIND_FLING] = mPageScroller.getDuration();
+        startAnimation(targetX, targetY, b.mCurrentScale, ANIM_KIND_FLING);
         return true;
     }
 
-    private void startAnimation(
-            int targetX, int targetY, float scale, int kind) {
-        mAnimationKind = kind;
-        if (targetX == mCurrentX && targetY == mCurrentY
-                && scale == mCurrentScale) {
-            onAnimationComplete();
-            return;
+    private boolean flingFilm(int velocityX, int velocityY) {
+        boolean hasPrev = hasPrevImages();
+        boolean hasNext = hasNextImages();
+
+        Box b = mBoxes.get(0);
+        Platform p = mPlatform;
+
+        // If we are already at the edge, don't start the fling.
+        int cx = mViewW / 2;
+        if ((!hasPrev && p.mCurrentX >= cx) || (!hasNext && p.mCurrentX <= cx)) {
+            return false;
         }
 
-        mFromX = mCurrentX;
-        mFromY = mCurrentY;
-        mFromScale = mCurrentScale;
+        if (velocityX == 0) return false;
 
-        mToX = targetX;
-        mToY = targetY;
-        mToScale = Utils.clamp(scale, SCALE_MIN_EXTRA * mScaleMin,
-                SCALE_MAX_EXTRA * mScaleMax);
+        mFilmScroller.fling(p.mCurrentX, 0, velocityX, 0,
+                Integer.MIN_VALUE, Integer.MAX_VALUE, 0, 0);
+        int targetX = mFilmScroller.getFinalX();
+        ANIM_TIME[ANIM_KIND_FLING] = mFilmScroller.getDuration();
+        startAnimation(targetX, b.mCurrentY, b.mCurrentScale, ANIM_KIND_FLING);
+        return true;
+    }
 
-        // If the scaled height is smaller than the view height,
-        // force it to be in the center.
-        // (We do for height only, not width, because the user may
-        // want to scroll to the previous/next image.)
-        if (!mInScale && viewHigherThanScaledImage(mToScale)) {
-            mToY = mImageH / 2;
+    ////////////////////////////////////////////////////////////////////////////
+    //  Redraw
+    //
+    //  If a method changes box positions directly, redraw()
+    //  should be called.
+    //
+    //  If a method may also cause a snapback to happen, snapAndRedraw() should
+    //  be called.
+    //
+    //  If a method starts an animation to change the position of focused box,
+    //  startAnimation() should be called.
+    //
+    //  If time advances to change the box position, advanceAnimation() should
+    //  be called.
+    ////////////////////////////////////////////////////////////////////////////
+    private void redraw() {
+        layoutAndSetPosition();
+        mListener.invalidate();
+    }
+
+    private void snapAndRedraw() {
+        mPlatform.startSnapback();
+        for (int i = -BOX_MAX; i <= BOX_MAX; i++) {
+            mBoxes.get(i).startSnapback();
+        }
+        for (int i = -BOX_MAX; i < BOX_MAX; i++) {
+            mGaps.get(i).startSnapback();
         }
+        redraw();
+    }
 
-        mAnimationStartTime = AnimationTime.get();
-        if (mAnimationKind != ANIM_KIND_FLING) {
-            mAnimationDuration = ANIM_TIME[mAnimationKind];
+    private void startAnimation(int targetX, int targetY, float targetScale,
+            int kind) {
+        boolean changed = false;
+        changed |= mPlatform.doAnimation(targetX, kind);
+        changed |= mBoxes.get(0).doAnimation(targetY, targetScale, kind);
+        if (changed) redraw();
+    }
+
+    public boolean advanceAnimation() {
+        boolean changed = false;
+        changed |= mPlatform.advanceAnimation();
+        for (int i = -BOX_MAX; i <= BOX_MAX; i++) {
+            changed |= mBoxes.get(i).advanceAnimation();
         }
-        advanceAnimation();
+        for (int i = -BOX_MAX; i < BOX_MAX; i++) {
+            changed |= mGaps.get(i).advanceAnimation();
+        }
+        if (changed) redraw();
+        return changed;
     }
 
-    public void advanceAnimation() {
-        if (mAnimationStartTime == NO_ANIMATION) {
-            return;
-        } else if (mAnimationStartTime == LAST_ANIMATION) {
-            onAnimationComplete();
-            return;
+    ////////////////////////////////////////////////////////////////////////////
+    //  Layout
+    ////////////////////////////////////////////////////////////////////////////
+
+    // Returns the display width of this box.
+    private int widthOf(Box b) {
+        return (int) (b.mImageW * b.mCurrentScale + 0.5f);
+    }
+
+    // Returns the display height of this box.
+    private int heightOf(Box b) {
+        return (int) (b.mImageH * b.mCurrentScale + 0.5f);
+    }
+
+    // Returns the display width of this box, using the given scale.
+    private int widthOf(Box b, float scale) {
+        return (int) (b.mImageW * scale + 0.5f);
+    }
+
+    // Returns the display height of this box, using the given scale.
+    private int heightOf(Box b, float scale) {
+        return (int) (b.mImageH * scale + 0.5f);
+    }
+
+    // Convert the information in mPlatform and mBoxes to mRects, so the user
+    // can get the position of each box by getPosition().
+    //
+    // Note the loop index goes from inside-out because each box's X coordinate
+    // is relative to its anchor box (except the focused box).
+    private void layoutAndSetPosition() {
+        // layout box 0 (focused box)
+        convertBoxToRect(0);
+        for (int i = 1; i <= BOX_MAX; i++) {
+            // layout box i and -i
+            convertBoxToRect(i);
+            convertBoxToRect(-i);
         }
+        //dumpState();
+    }
 
-        long now = AnimationTime.get();
-        float progress;
-        if (mAnimationDuration == 0) {
-            progress = 1;
-        } else {
-            progress = (now - mAnimationStartTime) / mAnimationDuration;
+    private void dumpState() {
+        for (int i = -BOX_MAX; i < BOX_MAX; i++) {
+            Log.d(TAG, "Gap " + i + ": " + mGaps.get(i).mCurrentGap);
         }
 
-        if (progress >= 1) {
-            progress = 1;
-            mCurrentX = mToX;
-            mCurrentY = mToY;
-            mCurrentScale = mToScale;
-            mAnimationStartTime = LAST_ANIMATION;
-        } else {
-            float f = 1 - progress;
-            switch (mAnimationKind) {
-                case ANIM_KIND_SCROLL:
-                case ANIM_KIND_FLING:
-                    progress = 1 - f;  // linear
-                    break;
-                case ANIM_KIND_SCALE:
-                    progress = 1 - f * f;  // quadratic
-                    break;
-                case ANIM_KIND_SNAPBACK:
-                case ANIM_KIND_ZOOM:
-                case ANIM_KIND_SLIDE:
-                    progress = 1 - f * f * f * f * f; // x^5
-                    break;
+        dumpRect(0);
+        for (int i = 1; i <= BOX_MAX; i++) {
+            dumpRect(i);
+            dumpRect(-i);
+        }
+
+        for (int i = -BOX_MAX; i <= BOX_MAX; i++) {
+            for (int j = i + 1; j <= BOX_MAX; j++) {
+                if (Rect.intersects(mRects.get(i), mRects.get(j))) {
+                    Log.d(TAG, "rect " + i + " and rect " + j + "intersects!");
+                }
             }
-            if (mAnimationKind == ANIM_KIND_FLING) {
-                flingInterpolate(progress);
+        }
+    }
+
+    private void dumpRect(int i) {
+        StringBuilder sb = new StringBuilder();
+        Rect r = mRects.get(i);
+        sb.append("Rect " + i + ":");
+        sb.append("(");
+        sb.append(r.centerX());
+        sb.append(",");
+        sb.append(r.centerY());
+        sb.append(") [");
+        sb.append(r.width());
+        sb.append("x");
+        sb.append(r.height());
+        sb.append("]");
+        Log.d(TAG, sb.toString());
+    }
+
+    private void convertBoxToRect(int i) {
+        Box b = mBoxes.get(i);
+        Rect r = mRects.get(i);
+        int y = b.mCurrentY;
+        int w = widthOf(b);
+        int h = heightOf(b);
+        if (i == 0) {
+            int x = mPlatform.mCurrentX;
+            r.left = x - w / 2;
+            r.right = r.left + w;
+        } else if (i > 0) {
+            Rect a = mRects.get(i - 1);
+            Gap g = mGaps.get(i - 1);
+            r.left = a.right + g.mCurrentGap;
+            r.right = r.left + w;
+        } else {  // i < 0
+            Rect a = mRects.get(i + 1);
+            Gap g = mGaps.get(i);
+            r.right = a.left - g.mCurrentGap;
+            r.left = r.right - w;
+        }
+        r.top = y - h / 2;
+        r.bottom = r.top + h;
+    }
+
+    // Returns the position of a box.
+    public Rect getPosition(int index) {
+        return mRects.get(index);
+    }
+
+    ////////////////////////////////////////////////////////////////////////////
+    //  Box management
+    ////////////////////////////////////////////////////////////////////////////
+
+    // Initialize the platform to be at the view center.
+    private void initPlatform() {
+        mPlatform.mCurrentX = mViewW / 2;
+        mPlatform.mAnimationStartTime = NO_ANIMATION;
+    }
+
+    // Initialize a box to have the size of the view.
+    private void initBox(int index) {
+        Box b = mBoxes.get(index);
+        b.mImageW = mViewW;
+        b.mImageH = mViewH;
+        b.mUseViewSize = true;
+        b.mScaleMin = getMinimalScale(b.mImageW, b.mImageH);
+        b.mScaleMax = getMaximalScale(b.mImageW, b.mImageH);
+        b.mCurrentY = mViewH / 2;
+        b.mCurrentScale = b.mScaleMin;
+        b.mAnimationStartTime = NO_ANIMATION;
+    }
+
+    // Initialize a gap. This can only be called after the boxes around the gap
+    // has been initialized.
+    private void initGap(int index) {
+        Gap g = mGaps.get(index);
+        g.mDefaultSize = getDefaultGapSize(index);
+        g.mCurrentGap = g.mDefaultSize;
+        g.mAnimationStartTime = NO_ANIMATION;
+    }
+
+    private void initGap(int index, int size) {
+        Gap g = mGaps.get(index);
+        g.mDefaultSize = getDefaultGapSize(index);
+        g.mCurrentGap = size;
+        g.mAnimationStartTime = NO_ANIMATION;
+    }
+
+    private void debugMoveBox(int fromIndex[]) {
+        StringBuilder s = new StringBuilder("moveBox:");
+        for (int i = 0; i < fromIndex.length; i++) {
+            int j = fromIndex[i];
+            if (j == Integer.MAX_VALUE) {
+                s.append(" N");
             } else {
-                linearInterpolate(progress);
+                s.append(" ");
+                s.append(fromIndex[i]);
             }
         }
-        mViewer.setPosition(mCurrentX, mCurrentY, mCurrentScale);
-        mViewer.invalidate();
+        Log.d(TAG, s.toString());
     }
 
-    private void onAnimationComplete() {
-        mAnimationStartTime = NO_ANIMATION;
-        if (mViewer.isInTransition()) {
-            mViewer.notifyTransitionComplete();
-        } else {
-            if (startSnapbackIfNeeded()) mViewer.invalidate();
-        }
-    }
-
-    private void flingInterpolate(float progress) {
-        mScroller.computeScrollOffset(progress);
-        int oldX = mCurrentX;
-        int oldY = mCurrentY;
-        mCurrentX = mScroller.getCurrX();
-        mCurrentY = mScroller.getCurrY();
-
-        // Check if we hit the edges; show edge effects if we do.
-        if (oldX > mBoundLeft && mCurrentX == mBoundLeft) {
-            int v = Math.round(-mScroller.getCurrVelocityX() * mCurrentScale);
-            mEdgeView.onAbsorb(v, EdgeView.LEFT);
-        } else if (oldX < mBoundRight && mCurrentX == mBoundRight) {
-            int v = Math.round(mScroller.getCurrVelocityX() * mCurrentScale);
-            mEdgeView.onAbsorb(v, EdgeView.RIGHT);
-        }
-
-        if (oldY > mBoundTop && mCurrentY == mBoundTop) {
-            int v = Math.round(-mScroller.getCurrVelocityY() * mCurrentScale);
-            mEdgeView.onAbsorb(v, EdgeView.TOP);
-        } else if (oldY < mBoundBottom && mCurrentY == mBoundBottom) {
-            int v = Math.round(mScroller.getCurrVelocityY() * mCurrentScale);
-            mEdgeView.onAbsorb(v, EdgeView.BOTTOM);
-        }
-    }
-
-    // Interpolates mCurrent{X,Y,Scale} given the progress in [0, 1].
-    private void linearInterpolate(float progress) {
-        // To linearly interpolate the position on view coordinates, we do the
-        // following steps:
-        // (1) convert a bitmap position (x, y) to view coordinates:
-        //     from: (x - mFromX) * mFromScale + mViewW / 2
-        //     to: (x - mToX) * mToScale + mViewW / 2
-        // (2) interpolate between the "from" and "to" coordinates:
-        //     (x - mFromX) * mFromScale * (1 - p) + (x - mToX) * mToScale * p
-        //     + mViewW / 2
-        //     should be equal to
-        //     (x - mCurrentX) * mCurrentScale + mViewW / 2
-        // (3) The x-related terms in the above equation can be removed because
-        //     mFromScale * (1 - p) + ToScale * p = mCurrentScale
-        // (4) Solve for mCurrentX, we have mCurrentX =
-        // (mFromX * mFromScale * (1 - p) + mToX * mToScale * p) / mCurrentScale
-        float fromX = mFromX * mFromScale;
-        float toX = mToX * mToScale;
-        float currentX = fromX + progress * (toX - fromX);
-
-        float fromY = mFromY * mFromScale;
-        float toY = mToY * mToScale;
-        float currentY = fromY + progress * (toY - fromY);
-
-        mCurrentScale = mFromScale + progress * (mToScale - mFromScale);
-        mCurrentX = Math.round(currentX / mCurrentScale);
-        mCurrentY = Math.round(currentY / mCurrentScale);
-    }
-
-    // Returns true if redraw is needed.
-    private boolean startSnapbackIfNeeded() {
-        if (mAnimationStartTime != NO_ANIMATION) return false;
-        if (mInScale) return false;
-        if (mAnimationKind == ANIM_KIND_SCROLL && mViewer.isDown()) {
-            return false;
+    // Move the boxes: it may indicate focus change, box deleted, box appearing,
+    // box reordered, etc.
+    //
+    // Each element in the fromIndex array indicates where each box was in the
+    // old array. If the value is Integer.MAX_VALUE (pictured as N below), it
+    // means the box is new.
+    //
+    // For example:
+    // N N N N N N N -- all new boxes
+    // -3 -2 -1 0 1 2 3 -- nothing changed
+    // -2 -1 0 1 2 3 N -- focus goes to the next box
+    // N-3 -2 -1 0 1 2 -- focuse goes to the previous box
+    // -3 -2 -1 1 2 3 N -- the focused box was deleted.
+    public void moveBox(int fromIndex[]) {
+        //debugMoveBox(fromIndex);
+        RangeIntArray from = new RangeIntArray(fromIndex, -BOX_MAX, BOX_MAX);
+
+        // 1. Get the absolute X coordiates for the boxes.
+        layoutAndSetPosition();
+        for (int i = -BOX_MAX; i <= BOX_MAX; i++) {
+            Box b = mBoxes.get(i);
+            Rect r = mRects.get(i);
+            b.mAbsoluteX = r.centerX();
         }
-        return startSnapback();
-    }
 
-    private boolean startSnapback() {
-        boolean needAnimation = false;
-        float scale = mCurrentScale;
+        // 2. copy boxes and gaps to temporary storage.
+        for (int i = -BOX_MAX; i <= BOX_MAX; i++) {
+            mTempBoxes.put(i, mBoxes.get(i));
+            mBoxes.put(i, null);
+        }
+        for (int i = -BOX_MAX; i < BOX_MAX; i++) {
+            mTempGaps.put(i, mGaps.get(i));
+            mGaps.put(i, null);
+        }
+
+        // 3. move back boxes that are used in the new array.
+        for (int i = -BOX_MAX; i <= BOX_MAX; i++) {
+            int j = from.get(i);
+            if (j == Integer.MAX_VALUE) continue;
+            mBoxes.put(i, mTempBoxes.get(j));
+            mTempBoxes.put(j, null);
+        }
 
-        float scaleMin = mExtraScalingRange ?
-                mScaleMin * SCALE_MIN_EXTRA : mScaleMin;
-        float scaleMax = mExtraScalingRange ?
-                mScaleMax * SCALE_MAX_EXTRA : mScaleMax;
+        // 4. move back gaps if both boxes around it are kept together.
+        for (int i = -BOX_MAX; i < BOX_MAX; i++) {
+            int j = from.get(i);
+            if (j == Integer.MAX_VALUE) continue;
+            int k = from.get(i + 1);
+            if (k == Integer.MAX_VALUE) continue;
+            if (j + 1 == k) {
+                mGaps.put(i, mTempGaps.get(j));
+                mTempGaps.put(j, null);
+            }
+        }
 
-        if (mCurrentScale < scaleMin || mCurrentScale > scaleMax) {
-            needAnimation = true;
-            scale = Utils.clamp(mCurrentScale, scaleMin, scaleMax);
+        // 5. recycle the boxes that are not used in the new array.
+        int k = -BOX_MAX;
+        for (int i = -BOX_MAX; i <= BOX_MAX; i++) {
+            if (mBoxes.get(i) != null) continue;
+            while (mTempBoxes.get(k) == null) {
+                k++;
+            }
+            mBoxes.put(i, mTempBoxes.get(k++));
+            initBox(i);
         }
 
-        calculateStableBound(scale, sHorizontalSlack);
-        int x = Utils.clamp(mCurrentX, mBoundLeft, mBoundRight);
-        int y = Utils.clamp(mCurrentY, mBoundTop, mBoundBottom);
+        // 6. Now give the recycled box a reasonable absolute X position.
+        //
+        // First try to find the first and the last box which the absolute X
+        // position is known.
+        int first, last;
+        for (first = -BOX_MAX; first <= BOX_MAX; first++) {
+            if (from.get(first) != Integer.MAX_VALUE) break;
+        }
+        for (last = BOX_MAX; last >= -BOX_MAX; last--) {
+            if (from.get(last) != Integer.MAX_VALUE) break;
+        }
+        // If there is no box has known X position at all, make the focused one
+        // as known.
+        if (first > BOX_MAX) {
+            mBoxes.get(0).mAbsoluteX = mPlatform.mCurrentX;
+            first = last = 0;
+        }
+        // Now for those boxes between first and last, just assign the same
+        // position as the previous box. (We can do better, but this should be
+        // rare). For the boxes before first or after last, we will use a new
+        // default gap size below.
+        for (int i = first + 1; i < last; i++) {
+            if (from.get(i) != Integer.MAX_VALUE) continue;
+            mBoxes.get(i).mAbsoluteX = mBoxes.get(i - 1).mAbsoluteX;
+        }
 
-        if (mCurrentX != x || mCurrentY != y || mCurrentScale != scale) {
-            needAnimation = true;
+        // 7. recycle the gaps that are not used in the new array.
+        k = -BOX_MAX;
+        for (int i = -BOX_MAX; i < BOX_MAX; i++) {
+            if (mGaps.get(i) != null) continue;
+            while (mTempGaps.get(k) == null) {
+                k++;
+            }
+            mGaps.put(i, mTempGaps.get(k++));
+            Box a = mBoxes.get(i);
+            Box b = mBoxes.get(i + 1);
+            int wa = widthOf(a);
+            int wb = widthOf(b);
+            if (i >= first && i < last) {
+                int g = b.mAbsoluteX - a.mAbsoluteX - wb / 2 - (wa - wa / 2);
+                initGap(i, g);
+            } else {
+                initGap(i);
+            }
         }
 
-        if (needAnimation) {
-            startAnimation(x, y, scale, ANIM_KIND_SNAPBACK);
+        // 8. offset the Platform position
+        int dx = mBoxes.get(0).mAbsoluteX - mPlatform.mCurrentX;
+        mPlatform.mCurrentX += dx;
+        mPlatform.mFromX += dx;
+        mPlatform.mToX += dx;
+        mPlatform.mFlingOffset += dx;
+
+        snapAndRedraw();
+    }
+
+    ////////////////////////////////////////////////////////////////////////////
+    //  Public utilities
+    ////////////////////////////////////////////////////////////////////////////
+
+    public float getMinimalScale(int imageW, int imageH) {
+        float s = Math.min(mScaleFactor * mViewW / imageW,
+                mScaleFactor * mViewH / imageH);
+        return Math.min(SCALE_LIMIT, s);
+    }
+
+    public float getMaximalScale(int imageW, int imageH) {
+        return mFilmMode ? getMinimalScale(imageW, imageH) : SCALE_LIMIT;
+    }
+
+    public boolean isAtMinimalScale() {
+        Box b = mBoxes.get(0);
+        return isAlmostEqual(b.mCurrentScale, b.mScaleMin);
+    }
+
+    public int getImageWidth() {
+        Box b = mBoxes.get(0);
+        return b.mImageW;
+    }
+
+    public int getImageHeight() {
+        Box b = mBoxes.get(0);
+        return b.mImageH;
+    }
+
+    public float getImageScale() {
+        Box b = mBoxes.get(0);
+        return b.mCurrentScale;
+    }
+
+    public int getImageAtEdges() {
+        Box b = mBoxes.get(0);
+        Platform p = mPlatform;
+        calculateStableBound(b.mCurrentScale);
+        int edges = 0;
+        if (p.mCurrentX <= mBoundLeft) {
+            edges |= IMAGE_AT_RIGHT_EDGE;
         }
+        if (p.mCurrentX >= mBoundRight) {
+            edges |= IMAGE_AT_LEFT_EDGE;
+        }
+        if (b.mCurrentY <= mBoundTop) {
+            edges |= IMAGE_AT_BOTTOM_EDGE;
+        }
+        if (b.mCurrentY >= mBoundBottom) {
+            edges |= IMAGE_AT_TOP_EDGE;
+        }
+        return edges;
+    }
+
+    ////////////////////////////////////////////////////////////////////////////
+    //  Private utilities
+    ////////////////////////////////////////////////////////////////////////////
+
+    private float getMinimalScale(Box b) {
+        return getMinimalScale(b.mImageW, b.mImageH);
+    }
 
-        return needAnimation;
+    private float getMaxmimalScale(Box b) {
+        return getMaximalScale(b.mImageW, b.mImageH);
+    }
+
+    private static boolean isAlmostEqual(float a, float b) {
+        float diff = a - b;
+        return (diff < 0 ? -diff : diff) < 0.02f;
     }
 
     // Calculates the stable region of mCurrent{X/Y}, where "stable" means
@@ -615,111 +947,431 @@ class PositionController {
     // An extra parameter "horizontalSlack" (which has the value of 0 usually)
     // is used to extend the stable region by some pixels on each side
     // horizontally.
-    private void calculateStableBound(float scale) {
-        calculateStableBound(scale, 0f);
-    }
+    private void calculateStableBound(float scale, int horizontalSlack) {
+        Box b = mBoxes.get(0);
 
-    private void calculateStableBound(float scale, float horizontalSlack) {
-        // The number of pixels between the center of the view
-        // and the edge when the edge is aligned.
-        mBoundLeft = (int) FloatMath.ceil((mViewW - horizontalSlack) / (2 * scale));
-        mBoundRight = mImageW - mBoundLeft;
-        mBoundTop = (int) FloatMath.ceil(mViewH / (2 * scale));
-        mBoundBottom = mImageH - mBoundTop;
+        // The width and height of the box in number of view pixels
+        int w = widthOf(b, scale);
+        int h = heightOf(b, scale);
+
+        // When the edge of the view is aligned with the edge of the box
+        mBoundLeft = (mViewW - horizontalSlack) - w / 2;
+        mBoundRight = mViewW - mBoundLeft;
+        mBoundTop = mViewH - h / 2;
+        mBoundBottom = mViewH - mBoundTop;
 
         // If the scaled height is smaller than the view height,
         // force it to be in the center.
-        if (viewHigherThanScaledImage(scale)) {
-            mBoundTop = mBoundBottom = mImageH / 2;
+        if (viewTallerThanScaledImage(scale)) {
+            mBoundTop = mBoundBottom = mViewH / 2;
         }
 
         // Same for width
         if (viewWiderThanScaledImage(scale)) {
-            mBoundLeft = mBoundRight = mImageW / 2;
+            mBoundLeft = mBoundRight = mViewW / 2;
+        }
+    }
+
+    private void calculateStableBound(float scale) {
+        calculateStableBound(scale, 0);
+    }
+
+    private boolean hasNextImages() {
+        for (int i = 1; i <= BOX_MAX; i++) {
+            if (!mBoxes.get(i).mUseViewSize) return true;
+        }
+        return false;
+    }
+
+    private boolean hasPrevImages() {
+        for (int i = -1; i >= -BOX_MAX; i--) {
+            if (!mBoxes.get(i).mUseViewSize) return true;
         }
+        return false;
     }
 
-    private boolean viewHigherThanScaledImage(float scale) {
-        return FloatMath.floor(mImageH * scale) <= mViewH;
+    private boolean viewTallerThanScaledImage(float scale) {
+        return mViewH >= heightOf(mBoxes.get(0), scale);
     }
 
     private boolean viewWiderThanScaledImage(float scale) {
-        return FloatMath.floor(mImageW * scale) <= mViewW;
+        return mViewW >= widthOf(mBoxes.get(0), scale);
     }
 
-    private boolean useCurrentValueAsTarget() {
-        return mAnimationStartTime == NO_ANIMATION ||
-                mAnimationKind == ANIM_KIND_SNAPBACK ||
-                mAnimationKind == ANIM_KIND_FLING;
+    private float getTargetScale(Box b) {
+        return useCurrentValueAsTarget(b) ? b.mCurrentScale : b.mToScale;
     }
 
-    private float getTargetScale() {
-        return useCurrentValueAsTarget() ? mCurrentScale : mToScale;
+    private int getTargetX(Platform p) {
+        return useCurrentValueAsTarget(p) ? p.mCurrentX : p.mToX;
     }
 
-    private int getTargetX() {
-        return useCurrentValueAsTarget() ? mCurrentX : mToX;
+    private int getTargetY(Box b) {
+        return useCurrentValueAsTarget(b) ? b.mCurrentY : b.mToY;
     }
 
-    private int getTargetY() {
-        return useCurrentValueAsTarget() ? mCurrentY : mToY;
+    private boolean useCurrentValueAsTarget(Animatable a) {
+        return a.mAnimationStartTime == NO_ANIMATION ||
+                a.mAnimationKind == ANIM_KIND_SNAPBACK ||
+                a.mAnimationKind == ANIM_KIND_FLING;
     }
 
-    public RectF getImageBounds() {
-        float points[] = mTempPoints;
+    // Returns the index of the anchor box.
+    private int anchorIndex(int i) {
+        if (i > 0) return i - 1;
+        if (i < 0) return i + 1;
+        throw new IllegalArgumentException();
+    }
+
+    ////////////////////////////////////////////////////////////////////////////
+    //  Animatable: an thing which can do animation.
+    ////////////////////////////////////////////////////////////////////////////
+    private abstract static class Animatable {
+        public long mAnimationStartTime;
+        public int mAnimationKind;
+        public int mAnimationDuration;
+
+        // This should be overidden in subclass to change the animation values
+        // give the progress value in [0, 1].
+        protected abstract boolean interpolate(float progress);
+        public abstract boolean startSnapback();
+
+        // Returns true if the animation values changes, so things need to be
+        // redrawn.
+        public boolean advanceAnimation() {
+            if (mAnimationStartTime == NO_ANIMATION) {
+                return false;
+            }
+            if (mAnimationStartTime == LAST_ANIMATION) {
+                mAnimationStartTime = NO_ANIMATION;
+                return startSnapback();
+            }
+
+            float progress;
+            if (mAnimationDuration == 0) {
+                progress = 1;
+            } else {
+                long now = AnimationTime.get();
+                progress =
+                    (float) (now - mAnimationStartTime) / mAnimationDuration;
+            }
+
+            if (progress >= 1) {
+                progress = 1;
+            } else {
+                progress = applyInterpolationCurve(mAnimationKind, progress);
+            }
 
-        /*
-         * (p0,p1)----------(p2,p3)
-         *   |                  |
-         *   |                  |
-         * (p4,p5)----------(p6,p7)
-         */
-        points[0] = points[4] = -mCurrentX;
-        points[1] = points[3] = -mCurrentY;
-        points[2] = points[6] = mImageW - mCurrentX;
-        points[5] = points[7] = mImageH - mCurrentY;
+            boolean done = interpolate(progress);
 
-        RectF rect = mTempRect;
-        rect.set(Float.POSITIVE_INFINITY, Float.POSITIVE_INFINITY,
-                Float.NEGATIVE_INFINITY, Float.NEGATIVE_INFINITY);
+            if (done) {
+                mAnimationStartTime = LAST_ANIMATION;
+            }
 
-        float scale = mCurrentScale;
-        float offsetX = mViewW / 2;
-        float offsetY = mViewH / 2;
-        for (int i = 0; i < 4; ++i) {
-            float x = points[i + i] * scale + offsetX;
-            float y = points[i + i + 1] * scale + offsetY;
-            if (x < rect.left) rect.left = x;
-            if (x > rect.right) rect.right = x;
-            if (y < rect.top) rect.top = y;
-            if (y > rect.bottom) rect.bottom = y;
+            return true;
         }
-        return rect;
-    }
 
-    public int getImageWidth() {
-        return mImageW;
+        private static float applyInterpolationCurve(int kind, float progress) {
+            float f = 1 - progress;
+            switch (kind) {
+                case ANIM_KIND_SCROLL:
+                case ANIM_KIND_FLING:
+                    progress = 1 - f;  // linear
+                    break;
+                case ANIM_KIND_SCALE:
+                    progress = 1 - f * f;  // quadratic
+                    break;
+                case ANIM_KIND_SNAPBACK:
+                case ANIM_KIND_ZOOM:
+                case ANIM_KIND_SLIDE:
+                case ANIM_KIND_OPENING:
+                    progress = 1 - f * f * f * f * f; // x^5
+                    break;
+            }
+            return progress;
+        }
     }
 
-    public int getImageHeight() {
-        return mImageH;
+    ////////////////////////////////////////////////////////////////////////////
+    //  Platform: captures the global X movement.
+    ////////////////////////////////////////////////////////////////////////////
+    private class Platform extends Animatable {
+        public int mCurrentX, mFromX, mToX;
+        public int mFlingOffset;
+
+        @Override
+        public boolean startSnapback() {
+            if (mAnimationStartTime != NO_ANIMATION) return false;
+            if (mAnimationKind == ANIM_KIND_SCROLL
+                    && mListener.isDown()) return false;
+
+            Box b = mBoxes.get(0);
+            float scaleMin = mExtraScalingRange ?
+                b.mScaleMin * SCALE_MIN_EXTRA : b.mScaleMin;
+            float scaleMax = mExtraScalingRange ?
+                b.mScaleMax * SCALE_MAX_EXTRA : b.mScaleMax;
+            float scale = Utils.clamp(b.mCurrentScale, scaleMin, scaleMax);
+            int x = mCurrentX;
+            if (mFilmMode) {
+                if (!hasNextImages()) x = Math.max(x, mViewW / 2);
+                if (!hasPrevImages()) x = Math.min(x, mViewW / 2);
+            } else {
+                calculateStableBound(scale, HORIZONTAL_SLACK);
+                x = Utils.clamp(x, mBoundLeft, mBoundRight);
+            }
+            if (mCurrentX != x) {
+                return doAnimation(x, ANIM_KIND_SNAPBACK);
+            }
+            return false;
+        }
+
+        // Starts an animation for the platform.
+        public boolean doAnimation(int targetX, int kind) {
+            if (mCurrentX == targetX) return false;
+            mAnimationKind = kind;
+            mFromX = mCurrentX;
+            mToX = targetX;
+            mAnimationStartTime = AnimationTime.startTime();
+            mAnimationDuration = ANIM_TIME[kind];
+            mFlingOffset = 0;
+            advanceAnimation();
+            return true;
+        }
+
+        @Override
+        protected boolean interpolate(float progress) {
+            if (mAnimationKind == ANIM_KIND_FLING) {
+                return mFilmMode
+                        ? interpolateFlingFilm(progress)
+                        : interpolateFlingPage(progress);
+            } else {
+                return interpolateLinear(progress);
+            }
+        }
+
+        private boolean interpolateFlingFilm(float progress) {
+            mFilmScroller.computeScrollOffset();
+            mCurrentX = mFilmScroller.getCurrX() + mFlingOffset;
+
+            int dir = EdgeView.INVALID_DIRECTION;
+            if (mCurrentX < mViewW / 2) {
+                if (!hasNextImages()) {
+                    dir = EdgeView.RIGHT;
+                }
+            } else if (mCurrentX > mViewW / 2) {
+                if (!hasPrevImages()) {
+                    dir = EdgeView.LEFT;
+                }
+            }
+            if (dir != EdgeView.INVALID_DIRECTION) {
+                int v = (int) (mFilmScroller.getCurrVelocity() + 0.5f);
+                mListener.onAbsorb(v, dir);
+                mFilmScroller.forceFinished(true);
+                mCurrentX = mViewW / 2;
+            }
+            return mFilmScroller.isFinished();
+        }
+
+        private boolean interpolateFlingPage(float progress) {
+            mPageScroller.computeScrollOffset(progress);
+            Box b = mBoxes.get(0);
+            calculateStableBound(b.mCurrentScale);
+
+            int oldX = mCurrentX;
+            mCurrentX = mPageScroller.getCurrX();
+
+            // Check if we hit the edges; show edge effects if we do.
+            if (oldX > mBoundLeft && mCurrentX == mBoundLeft) {
+                int v = (int) (-mPageScroller.getCurrVelocityX() + 0.5f);
+                mListener.onAbsorb(v, EdgeView.RIGHT);
+            } else if (oldX < mBoundRight && mCurrentX == mBoundRight) {
+                int v = (int) (mPageScroller.getCurrVelocityX() + 0.5f);
+                mListener.onAbsorb(v, EdgeView.LEFT);
+            }
+
+            return progress >= 1;
+        }
+
+        private boolean interpolateLinear(float progress) {
+            // Other animations
+            if (progress >= 1) {
+                mCurrentX = mToX;
+                return true;
+            } else {
+                mCurrentX = (int) (mFromX + progress * (mToX - mFromX));
+                return (mCurrentX == mToX);
+            }
+        }
     }
 
-    public int getImageAtEdges() {
-        calculateStableBound(mCurrentScale);
-        int edges = 0;
-        if (mCurrentX <= mBoundLeft) {
-            edges |= IMAGE_AT_LEFT_EDGE;
+    ////////////////////////////////////////////////////////////////////////////
+    //  Box: represents a rectangular area which shows a picture.
+    ////////////////////////////////////////////////////////////////////////////
+    private class Box extends Animatable {
+        // Size of the bitmap
+        public int mImageW, mImageH;
+
+        // This is true if we assume the image size is the same as view size
+        // until we know the actual size of image. This is also used to
+        // determine if there is an image ready to show.
+        public boolean mUseViewSize;
+
+        // The minimum and maximum scale we allow for this box.
+        public float mScaleMin, mScaleMax;
+
+        // The X/Y value indicates where the center of the box is on the view
+        // coordinate. We always keep the mCurrent{X,Y,Scale} sync with the
+        // actual values used currently. Note that the X values are implicitly
+        // defined by Platform and Gaps.
+        public int mCurrentY, mFromY, mToY;
+        public float mCurrentScale, mFromScale, mToScale;
+
+        // The absolute X coordinate of the center of the box. This is only used
+        // during moveBox().
+        public int mAbsoluteX;
+
+        @Override
+        public boolean startSnapback() {
+            if (mAnimationStartTime != NO_ANIMATION) return false;
+            if (mAnimationKind == ANIM_KIND_SCROLL
+                    && mListener.isDown()) return false;
+            if (mInScale && this == mBoxes.get(0)) return false;
+
+            int y;
+            float scale;
+
+            if (this == mBoxes.get(0)) {
+                float scaleMin = mExtraScalingRange ?
+                    mScaleMin * SCALE_MIN_EXTRA : mScaleMin;
+                float scaleMax = mExtraScalingRange ?
+                    mScaleMax * SCALE_MAX_EXTRA : mScaleMax;
+                scale = Utils.clamp(mCurrentScale, scaleMin, scaleMax);
+                if (mFilmMode) {
+                    y = mViewH / 2;
+                } else {
+                    calculateStableBound(scale, HORIZONTAL_SLACK);
+                    y = Utils.clamp(mCurrentY, mBoundTop, mBoundBottom);
+                }
+            } else {
+                y = mViewH / 2;
+                scale = mScaleMin;
+            }
+
+            if (mCurrentY != y || mCurrentScale != scale) {
+                return doAnimation(y, scale, ANIM_KIND_SNAPBACK);
+            }
+            return false;
         }
-        if (mCurrentX >= mBoundRight) {
-            edges |= IMAGE_AT_RIGHT_EDGE;
+
+        private boolean doAnimation(int targetY, float targetScale, int kind) {
+            targetScale = Utils.clamp(targetScale,
+                    SCALE_MIN_EXTRA * mScaleMin,
+                    SCALE_MAX_EXTRA * mScaleMax);
+
+            // If the scaled height is smaller than the view height, force it to be
+            // in the center.  (We do this for height only, not width, because the
+            // user may want to scroll to the previous/next image.)
+            if (!mInScale && viewTallerThanScaledImage(targetScale)) {
+                targetY = mViewH / 2;
+            }
+
+            if (mCurrentY == targetY && mCurrentScale == targetScale) {
+                return false;
+            }
+
+            // Now starts an animation for the box.
+            mAnimationKind = kind;
+            mFromY = mCurrentY;
+            mFromScale = mCurrentScale;
+            mToY = targetY;
+            mToScale = targetScale;
+            mAnimationStartTime = AnimationTime.startTime();
+            mAnimationDuration = ANIM_TIME[kind];
+            advanceAnimation();
+            return true;
         }
-        if (mCurrentY <= mBoundTop) {
-            edges |= IMAGE_AT_TOP_EDGE;
+
+        @Override
+        protected boolean interpolate(float progress) {
+            if (mAnimationKind == ANIM_KIND_FLING) {
+                // Currently a Box can only be flung in page mode.
+                return interpolateFlingPage(progress);
+            } else {
+                return interpolateLinear(progress);
+            }
         }
-        if (mCurrentY >= mBoundBottom) {
-            edges |= IMAGE_AT_BOTTOM_EDGE;
+
+        private boolean interpolateFlingPage(float progress) {
+            mPageScroller.computeScrollOffset(progress);
+            calculateStableBound(mCurrentScale);
+
+            int oldY = mCurrentY;
+            mCurrentY = mPageScroller.getCurrY();
+
+            // Check if we hit the edges; show edge effects if we do.
+            if (oldY > mBoundTop && mCurrentY == mBoundTop) {
+                int v = (int) (-mPageScroller.getCurrVelocityY() + 0.5f);
+                mListener.onAbsorb(v, EdgeView.BOTTOM);
+            } else if (oldY < mBoundBottom && mCurrentY == mBoundBottom) {
+                int v = (int) (mPageScroller.getCurrVelocityY() + 0.5f);
+                mListener.onAbsorb(v, EdgeView.TOP);
+            }
+
+            return progress >= 1;
+        }
+
+        private boolean interpolateLinear(float progress) {
+            if (progress >= 1) {
+                mCurrentY = mToY;
+                mCurrentScale = mToScale;
+                return true;
+            } else {
+                mCurrentY = (int) (mFromY + progress * (mToY - mFromY));
+                mCurrentScale = mFromScale + progress * (mToScale - mFromScale);
+                return (mCurrentY == mToY && mCurrentScale == mToScale);
+            }
+        }
+    }
+
+    ////////////////////////////////////////////////////////////////////////////
+    //  Gap: represents a rectangular area which is between two boxes.
+    ////////////////////////////////////////////////////////////////////////////
+    private class Gap extends Animatable {
+        // The default gap size between two boxes. The value may vary for
+        // different image size of the boxes and for different modes (page or
+        // film).
+        public int mDefaultSize;
+
+        // The gap size between the two boxes.
+        public int mCurrentGap, mFromGap, mToGap;
+
+        @Override
+        public boolean startSnapback() {
+            if (mAnimationStartTime != NO_ANIMATION) return false;
+            return doAnimation(mDefaultSize);
+        }
+
+        // Starts an animation for a gap.
+        public boolean doAnimation(int targetSize) {
+            if (mCurrentGap == targetSize) return false;
+            mAnimationKind = ANIM_KIND_SNAPBACK;
+            mFromGap = mCurrentGap;
+            mToGap = targetSize;
+            mAnimationStartTime = AnimationTime.startTime();
+            mAnimationDuration = ANIM_TIME[mAnimationKind];
+            advanceAnimation();
+            return true;
+        }
+
+        @Override
+        protected boolean interpolate(float progress) {
+            if (progress >= 1) {
+                mCurrentGap = mToGap;
+                return true;
+            } else {
+                mCurrentGap = (int) (mFromGap + progress * (mToGap - mFromGap));
+                return (mCurrentGap == mToGap);
+            }
         }
-        return edges;
     }
 }
diff --git a/src/com/android/gallery3d/util/RangeArray.java b/src/com/android/gallery3d/util/RangeArray.java
new file mode 100644 (file)
index 0000000..8e61348
--- /dev/null
@@ -0,0 +1,52 @@
+/*
+ * 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.gallery3d.util;
+
+// This is an array whose index ranges from min to max (inclusive).
+public class RangeArray<T> {
+    private T[] mData;
+    private int mOffset;
+
+    public RangeArray(int min, int max) {
+        mData = (T[]) new Object[max - min + 1];
+        mOffset = min;
+    }
+
+    // Wraps around an existing array
+    public RangeArray(T[] src, int min, int max) {
+        if (max - min + 1 != src.length) {
+            throw new AssertionError();
+        }
+        mData = src;
+        mOffset = min;
+    }
+
+    public void put(int i, T object) {
+        mData[i - mOffset] = object;
+    }
+
+    public T get(int i) {
+        return mData[i - mOffset];
+    }
+
+    public int indexOf(T object) {
+        for (int i = 0; i < mData.length; i++) {
+            if (mData[i] == object) return i + mOffset;
+        }
+        return Integer.MAX_VALUE;
+    }
+}
diff --git a/src/com/android/gallery3d/util/RangeBoolArray.java b/src/com/android/gallery3d/util/RangeBoolArray.java
new file mode 100644 (file)
index 0000000..035fc40
--- /dev/null
@@ -0,0 +1,49 @@
+/*
+ * 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.gallery3d.util;
+
+// This is an array whose index ranges from min to max (inclusive).
+public class RangeBoolArray {
+    private boolean[] mData;
+    private int mOffset;
+
+    public RangeBoolArray(int min, int max) {
+        mData = new boolean[max - min + 1];
+        mOffset = min;
+    }
+
+    // Wraps around an existing array
+    public RangeBoolArray(boolean[] src, int min, int max) {
+        mData = src;
+        mOffset = min;
+    }
+
+    public void put(int i, boolean object) {
+        mData[i - mOffset] = object;
+    }
+
+    public boolean get(int i) {
+        return mData[i - mOffset];
+    }
+
+    public int indexOf(boolean object) {
+        for (int i = 0; i < mData.length; i++) {
+            if (mData[i] == object) return i + mOffset;
+        }
+        return Integer.MAX_VALUE;
+    }
+}
diff --git a/src/com/android/gallery3d/util/RangeIntArray.java b/src/com/android/gallery3d/util/RangeIntArray.java
new file mode 100644 (file)
index 0000000..9dbb99f
--- /dev/null
@@ -0,0 +1,49 @@
+/*
+ * 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.gallery3d.util;
+
+// This is an array whose index ranges from min to max (inclusive).
+public class RangeIntArray {
+    private int[] mData;
+    private int mOffset;
+
+    public RangeIntArray(int min, int max) {
+        mData = new int[max - min + 1];
+        mOffset = min;
+    }
+
+    // Wraps around an existing array
+    public RangeIntArray(int[] src, int min, int max) {
+        mData = src;
+        mOffset = min;
+    }
+
+    public void put(int i, int object) {
+        mData[i - mOffset] = object;
+    }
+
+    public int get(int i) {
+        return mData[i - mOffset];
+    }
+
+    public int indexOf(int object) {
+        for (int i = 0; i < mData.length; i++) {
+            if (mData[i] == object) return i + mOffset;
+        }
+        return Integer.MAX_VALUE;
+    }
+}