PhotoView.java revision b29a27f475a2c449abdda8d4e03d30914feed8c6
1/*
2 * Copyright (C) 2010 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *      http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17package com.android.gallery3d.ui;
18
19import android.content.Context;
20import android.graphics.Bitmap;
21import android.graphics.Color;
22import android.graphics.Point;
23import android.graphics.Rect;
24import android.graphics.RectF;
25import android.os.Message;
26import android.view.MotionEvent;
27import android.view.animation.AccelerateInterpolator;
28
29import com.android.gallery3d.R;
30import com.android.gallery3d.app.GalleryActivity;
31import com.android.gallery3d.common.Utils;
32
33public class PhotoView extends GLView {
34    @SuppressWarnings("unused")
35    private static final String TAG = "PhotoView";
36
37    public static final int INVALID_SIZE = -1;
38
39    private static final int MSG_TRANSITION_COMPLETE = 1;
40    private static final int MSG_SHOW_LOADING = 2;
41    private static final int MSG_CANCEL_EXTRA_SCALING = 3;
42
43    private static final long DELAY_SHOW_LOADING = 250; // 250ms;
44
45    private static final int TRANS_NONE = 0;
46    private static final int TRANS_SWITCH_NEXT = 3;
47    private static final int TRANS_SWITCH_PREVIOUS = 4;
48
49    public static final int TRANS_SLIDE_IN_RIGHT = 1;
50    public static final int TRANS_SLIDE_IN_LEFT = 2;
51    public static final int TRANS_OPEN_ANIMATION = 5;
52
53    private static final int LOADING_INIT = 0;
54    private static final int LOADING_TIMEOUT = 1;
55    private static final int LOADING_COMPLETE = 2;
56    private static final int LOADING_FAIL = 3;
57
58    private static final int ENTRY_PREVIOUS = 0;
59    private static final int ENTRY_NEXT = 1;
60
61    private static final int IMAGE_GAP = 96;
62    private static final int SWITCH_THRESHOLD = 256;
63    private static final float SWIPE_THRESHOLD = 300f;
64
65    private static final float DEFAULT_TEXT_SIZE = 20;
66    private static float TRANSITION_SCALE_FACTOR = 0.74f;
67
68    // Used to calculate the scaling factor for the fading animation.
69    private ZInterpolator mScaleInterpolator = new ZInterpolator(0.5f);
70
71    // Used to calculate the alpha factor for the fading animation.
72    private AccelerateInterpolator mAlphaInterpolator =
73            new AccelerateInterpolator(0.9f);
74
75    public interface PhotoTapListener {
76        public void onSingleTapUp(int x, int y);
77    }
78
79    // the previous/next image entries
80    private final ScreenNailEntry mScreenNails[] = new ScreenNailEntry[2];
81
82    private final GestureRecognizer mGestureRecognizer;
83
84    private PhotoTapListener mPhotoTapListener;
85
86    private final PositionController mPositionController;
87
88    private Model mModel;
89    private StringTexture mLoadingText;
90    private StringTexture mNoThumbnailText;
91    private int mTransitionMode = TRANS_NONE;
92    private final TileImageView mTileView;
93    private EdgeView mEdgeView;
94    private Texture mVideoPlayIcon;
95
96    private boolean mShowVideoPlayIcon;
97    private ProgressSpinner mLoadingSpinner;
98
99    private SynchronizedHandler mHandler;
100
101    private int mLoadingState = LOADING_COMPLETE;
102
103    private int mImageRotation;
104
105    private Rect mOpenAnimationRect;
106    private Point mImageCenter = new Point();
107    private boolean mCancelExtraScalingPending;
108
109    public PhotoView(GalleryActivity activity) {
110        mTileView = new TileImageView(activity);
111        addComponent(mTileView);
112        Context context = activity.getAndroidContext();
113        mEdgeView = new EdgeView(context);
114        addComponent(mEdgeView);
115        mLoadingSpinner = new ProgressSpinner(context);
116        mLoadingText = StringTexture.newInstance(
117                context.getString(R.string.loading),
118                DEFAULT_TEXT_SIZE, Color.WHITE);
119        mNoThumbnailText = StringTexture.newInstance(
120                context.getString(R.string.no_thumbnail),
121                DEFAULT_TEXT_SIZE, Color.WHITE);
122
123        mHandler = new SynchronizedHandler(activity.getGLRoot()) {
124            @Override
125            public void handleMessage(Message message) {
126                switch (message.what) {
127                    case MSG_TRANSITION_COMPLETE: {
128                        onTransitionComplete();
129                        break;
130                    }
131                    case MSG_SHOW_LOADING: {
132                        if (mLoadingState == LOADING_INIT) {
133                            // We don't need the opening animation
134                            mOpenAnimationRect = null;
135
136                            mLoadingSpinner.startAnimation();
137                            mLoadingState = LOADING_TIMEOUT;
138                            invalidate();
139                        }
140                        break;
141                    }
142                    case MSG_CANCEL_EXTRA_SCALING: {
143                        mGestureRecognizer.cancelScale();
144                        mPositionController.setExtraScalingRange(false);
145                        mCancelExtraScalingPending = false;
146                        break;
147                    }
148                    default: throw new AssertionError(message.what);
149                }
150            }
151        };
152
153        mGestureRecognizer = new GestureRecognizer(
154                context, new MyGestureListener());
155
156        for (int i = 0, n = mScreenNails.length; i < n; ++i) {
157            mScreenNails[i] = new ScreenNailEntry();
158        }
159
160        mPositionController = new PositionController(this, context, mEdgeView);
161        mVideoPlayIcon = new ResourceTexture(context, R.drawable.ic_control_play);
162    }
163
164
165    public void setModel(Model model) {
166        if (mModel == model) return;
167        mModel = model;
168        mTileView.setModel(model);
169        if (model != null) notifyOnNewImage();
170    }
171
172    public void setPhotoTapListener(PhotoTapListener listener) {
173        mPhotoTapListener = listener;
174    }
175
176    private void setTileViewPosition(int centerX, int centerY, float scale) {
177        TileImageView t = mTileView;
178
179        // Calculate the move-out progress value.
180        RectF bounds = mPositionController.getImageBounds();
181        int left = Math.round(bounds.left);
182        int right = Math.round(bounds.right);
183        int width = getWidth();
184        float progress = calculateMoveOutProgress(left, right, width);
185        progress = Utils.clamp(progress, -1f, 1f);
186
187        // We only want to apply the fading animation if the scrolling movement
188        // is to the right.
189        if (progress < 0) {
190            if (right - left < width) {
191                // If the picture is narrower than the view, keep it at the center
192                // of the view.
193                centerX = mPositionController.getImageWidth() / 2;
194            } else {
195                // If the picture is wider than the view (it's zoomed-in), keep
196                // the left edge of the object align the the left edge of the view.
197                centerX = Math.round(width / 2f / scale);
198            }
199            scale *= getScrollScale(progress);
200            t.setAlpha(getScrollAlpha(progress));
201        }
202
203        // set the position of the tile view
204        int inverseX = mPositionController.getImageWidth() - centerX;
205        int inverseY = mPositionController.getImageHeight() - centerY;
206        int rotation = mImageRotation;
207        switch (rotation) {
208            case 0: t.setPosition(centerX, centerY, scale, 0); break;
209            case 90: t.setPosition(centerY, inverseX, scale, 90); break;
210            case 180: t.setPosition(inverseX, inverseY, scale, 180); break;
211            case 270: t.setPosition(inverseY, centerX, scale, 270); break;
212            default: throw new IllegalArgumentException(String.valueOf(rotation));
213        }
214    }
215
216    public void setPosition(int centerX, int centerY, float scale) {
217        setTileViewPosition(centerX, centerY, scale);
218        layoutScreenNails();
219    }
220
221    private void updateScreenNailEntry(int which, ScreenNail screenNail) {
222        if (mTransitionMode == TRANS_SWITCH_NEXT
223                || mTransitionMode == TRANS_SWITCH_PREVIOUS) {
224            // ignore screen nail updating during switching
225            return;
226        }
227        ScreenNailEntry entry = mScreenNails[which];
228        entry.updateScreenNail(screenNail);
229    }
230
231    // -1 previous, 0 current, 1 next
232    public void notifyImageInvalidated(int which) {
233        switch (which) {
234            case -1: {
235                updateScreenNailEntry(
236                        ENTRY_PREVIOUS, mModel.getPrevScreenNail());
237                layoutScreenNails();
238                invalidate();
239                break;
240            }
241            case 1: {
242                updateScreenNailEntry(ENTRY_NEXT, mModel.getNextScreenNail());
243                layoutScreenNails();
244                invalidate();
245                break;
246            }
247            case 0: {
248                // mImageWidth and mImageHeight will get updated
249                mTileView.notifyModelInvalidated();
250                mTileView.setAlpha(1.0f);
251
252                mImageRotation = mModel.getImageRotation();
253                if (((mImageRotation / 90) & 1) == 0) {
254                    mPositionController.setImageSize(
255                            mTileView.mImageWidth, mTileView.mImageHeight);
256                } else {
257                    mPositionController.setImageSize(
258                            mTileView.mImageHeight, mTileView.mImageWidth);
259                }
260                updateLoadingState();
261                break;
262            }
263        }
264    }
265
266    private void updateLoadingState() {
267        // Possible transitions of mLoadingState:
268        //        INIT --> TIMEOUT, COMPLETE, FAIL
269        //     TIMEOUT --> COMPLETE, FAIL, INIT
270        //    COMPLETE --> INIT
271        //        FAIL --> INIT
272        if (mModel.getLevelCount() != 0 || mModel.getScreenNail() != null) {
273            mHandler.removeMessages(MSG_SHOW_LOADING);
274            mLoadingState = LOADING_COMPLETE;
275        } else if (mModel.isFailedToLoad()) {
276            mHandler.removeMessages(MSG_SHOW_LOADING);
277            mLoadingState = LOADING_FAIL;
278            // We don't want the opening animation after loading failure
279            mOpenAnimationRect = null;
280        } else if (mLoadingState != LOADING_INIT) {
281            mLoadingState = LOADING_INIT;
282            mHandler.removeMessages(MSG_SHOW_LOADING);
283            mHandler.sendEmptyMessageDelayed(
284                    MSG_SHOW_LOADING, DELAY_SHOW_LOADING);
285        }
286    }
287
288    public void notifyModelInvalidated() {
289        if (mModel == null) {
290            updateScreenNailEntry(ENTRY_PREVIOUS, null);
291            updateScreenNailEntry(ENTRY_NEXT, null);
292        } else {
293            updateScreenNailEntry(ENTRY_PREVIOUS, mModel.getPrevScreenNail());
294            updateScreenNailEntry(ENTRY_NEXT, mModel.getNextScreenNail());
295        }
296        layoutScreenNails();
297
298        if (mModel == null) {
299            mTileView.notifyModelInvalidated();
300            mTileView.setAlpha(1.0f);
301            mImageRotation = 0;
302            mPositionController.setImageSize(0, 0);
303            updateLoadingState();
304        } else {
305            notifyImageInvalidated(0);
306        }
307    }
308
309    @Override
310    protected boolean onTouch(MotionEvent event) {
311        mGestureRecognizer.onTouchEvent(event);
312        return true;
313    }
314
315    @Override
316    protected void onLayout(
317            boolean changeSize, int left, int top, int right, int bottom) {
318        mTileView.layout(left, top, right, bottom);
319        mEdgeView.layout(left, top, right, bottom);
320        if (changeSize) {
321            mPositionController.setViewSize(getWidth(), getHeight());
322            for (ScreenNailEntry entry : mScreenNails) {
323                entry.updateDrawingSize();
324            }
325        }
326    }
327
328    private static int gapToSide(int imageWidth, int viewWidth) {
329        return Math.max(0, (viewWidth - imageWidth) / 2);
330    }
331
332    /*
333     * Here is how we layout the screen nails
334     *
335     *  previous            current           next
336     *  ___________       ________________     __________
337     * |  _______  |     |   __________   |   |  ______  |
338     * | |       | |     |  |   right->|  |   | |      | |
339     * | |       |<-------->|<--left   |  |   | |      | |
340     * | |_______| |  |  |  |__________|  |   | |______| |
341     * |___________|  |  |________________|   |__________|
342     *                |  <--> gapToSide()
343     *                |
344     * IMAGE_GAP + Max(previous.gapToSide(), current.gapToSide)
345     */
346    private void layoutScreenNails() {
347        int width = getWidth();
348        int height = getHeight();
349
350        // Use the image width in AC, since we may fake the size if the
351        // image is unavailable
352        RectF bounds = mPositionController.getImageBounds();
353        int left = Math.round(bounds.left);
354        int right = Math.round(bounds.right);
355        int gap = gapToSide(right - left, width);
356
357        // layout the previous image
358        ScreenNailEntry entry = mScreenNails[ENTRY_PREVIOUS];
359
360        if (entry.isEnabled()) {
361            entry.layoutRightEdgeAt(left - (
362                    IMAGE_GAP + Math.max(gap, entry.gapToSide())));
363        }
364
365        // layout the next image
366        entry = mScreenNails[ENTRY_NEXT];
367        if (entry.isEnabled()) {
368            entry.layoutLeftEdgeAt(right + (
369                    IMAGE_GAP + Math.max(gap, entry.gapToSide())));
370        }
371    }
372
373    @Override
374    protected void render(GLCanvas canvas) {
375        boolean drawScreenNail = (mTransitionMode != TRANS_SLIDE_IN_LEFT
376                && mTransitionMode != TRANS_SLIDE_IN_RIGHT
377                && mTransitionMode != TRANS_OPEN_ANIMATION);
378
379        // Draw the next photo
380        if (drawScreenNail) {
381            ScreenNailEntry nextNail = mScreenNails[ENTRY_NEXT];
382            nextNail.draw(canvas, true);
383        }
384
385        // Draw the current photo
386        if (mLoadingState == LOADING_COMPLETE) {
387            super.render(canvas);
388        }
389
390        // If the photo is loaded, draw the message/icon at the center of it,
391        // otherwise draw the message/icon at the center of the view.
392        if (mLoadingState == LOADING_COMPLETE) {
393            mTileView.getImageCenter(mImageCenter);
394            renderMessage(canvas, mImageCenter.x, mImageCenter.y);
395        } else {
396            renderMessage(canvas, getWidth() / 2, getHeight() / 2);
397        }
398
399        // Draw the previous photo
400        if (drawScreenNail) {
401            ScreenNailEntry prevNail = mScreenNails[ENTRY_PREVIOUS];
402            prevNail.draw(canvas, false);
403        }
404    }
405
406    private void renderMessage(GLCanvas canvas, int x, int y) {
407        // Draw the progress spinner and the text below it
408        //
409        // (x, y) is where we put the center of the spinner.
410        // s is the size of the video play icon, and we use s to layout text
411        // because we want to keep the text at the same place when the video
412        // play icon is shown instead of the spinner.
413        int w = getWidth();
414        int h = getHeight();
415        int s = Math.min(getWidth(), getHeight()) / 6;
416
417        if (mLoadingState == LOADING_TIMEOUT) {
418            StringTexture m = mLoadingText;
419            ProgressSpinner r = mLoadingSpinner;
420            r.draw(canvas, x - r.getWidth() / 2, y - r.getHeight() / 2);
421            m.draw(canvas, x - m.getWidth() / 2, y + s / 2 + 5);
422            invalidate(); // we need to keep the spinner rotating
423        } else if (mLoadingState == LOADING_FAIL) {
424            StringTexture m = mNoThumbnailText;
425            m.draw(canvas, x - m.getWidth() / 2, y + s / 2 + 5);
426        }
427
428        // Draw the video play icon (in the place where the spinner was)
429        if (mShowVideoPlayIcon
430                && mLoadingState != LOADING_INIT
431                && mLoadingState != LOADING_TIMEOUT) {
432            mVideoPlayIcon.draw(canvas, x - s / 2, y - s / 2, s, s);
433        }
434
435        mPositionController.advanceAnimation();
436    }
437
438    private void stopCurrentSwipingIfNeeded() {
439        // Enable fast swiping
440        if (mTransitionMode == TRANS_SWITCH_NEXT) {
441            mTransitionMode = TRANS_NONE;
442            mPositionController.stopAnimation();
443            switchToNextImage();
444        } else if (mTransitionMode == TRANS_SWITCH_PREVIOUS) {
445            mTransitionMode = TRANS_NONE;
446            mPositionController.stopAnimation();
447            switchToPreviousImage();
448        }
449    }
450
451    private boolean swipeImages(float velocityX, float velocityY) {
452        if (mTransitionMode != TRANS_NONE
453                && mTransitionMode != TRANS_SWITCH_NEXT
454                && mTransitionMode != TRANS_SWITCH_PREVIOUS) return false;
455
456        // Avoid swiping images if we're possibly flinging to view the
457        // zoomed in picture vertically.
458        PositionController controller = mPositionController;
459        boolean isMinimal = controller.isAtMinimalScale();
460        int edges = controller.getImageAtEdges();
461        if (!isMinimal && Math.abs(velocityY) > Math.abs(velocityX))
462            if ((edges & PositionController.IMAGE_AT_TOP_EDGE) == 0
463                    || (edges & PositionController.IMAGE_AT_BOTTOM_EDGE) == 0)
464                return false;
465
466        // If we are at the edge of the current photo and the sweeping velocity
467        // exceeds the threshold, switch to next / previous image.
468        int halfWidth = getWidth() / 2;
469        if (velocityX < -SWIPE_THRESHOLD && (isMinimal
470                || (edges & PositionController.IMAGE_AT_RIGHT_EDGE) != 0)) {
471            stopCurrentSwipingIfNeeded();
472            ScreenNailEntry next = mScreenNails[ENTRY_NEXT];
473            if (next.isEnabled()) {
474                mTransitionMode = TRANS_SWITCH_NEXT;
475                controller.startHorizontalSlide(next.mOffsetX - halfWidth);
476                return true;
477            }
478        } else if (velocityX > SWIPE_THRESHOLD && (isMinimal
479                || (edges & PositionController.IMAGE_AT_LEFT_EDGE) != 0)) {
480            stopCurrentSwipingIfNeeded();
481            ScreenNailEntry prev = mScreenNails[ENTRY_PREVIOUS];
482            if (prev.isEnabled()) {
483                mTransitionMode = TRANS_SWITCH_PREVIOUS;
484                controller.startHorizontalSlide(prev.mOffsetX - halfWidth);
485                return true;
486            }
487        }
488
489        return false;
490    }
491
492    private boolean snapToNeighborImage() {
493        if (mTransitionMode != TRANS_NONE) return false;
494
495        PositionController controller = mPositionController;
496        RectF bounds = controller.getImageBounds();
497        int left = Math.round(bounds.left);
498        int right = Math.round(bounds.right);
499        int width = getWidth();
500        int threshold = SWITCH_THRESHOLD + gapToSide(right - left, width);
501
502        // If we have moved the picture a lot, switching.
503        ScreenNailEntry next = mScreenNails[ENTRY_NEXT];
504        if (next.isEnabled() && threshold < width - right) {
505            mTransitionMode = TRANS_SWITCH_NEXT;
506            controller.startHorizontalSlide(next.mOffsetX - width / 2);
507            return true;
508        }
509        ScreenNailEntry prev = mScreenNails[ENTRY_PREVIOUS];
510        if (prev.isEnabled() && threshold < left) {
511            mTransitionMode = TRANS_SWITCH_PREVIOUS;
512            controller.startHorizontalSlide(prev.mOffsetX - width / 2);
513            return true;
514        }
515
516        return false;
517    }
518
519    private class MyGestureListener implements GestureRecognizer.Listener {
520        private boolean mIgnoreUpEvent = false;
521
522        @Override
523        public boolean onSingleTapUp(float x, float y) {
524            if (mPhotoTapListener != null) {
525                mPhotoTapListener.onSingleTapUp((int) x, (int) y);
526            }
527            return true;
528        }
529
530        @Override
531        public boolean onDoubleTap(float x, float y) {
532            if (mTransitionMode != TRANS_NONE) return true;
533            PositionController controller = mPositionController;
534            float scale = controller.getCurrentScale();
535            // onDoubleTap happened on the second ACTION_DOWN.
536            // We need to ignore the next UP event.
537            mIgnoreUpEvent = true;
538            if (scale <= 1.0f || controller.isAtMinimalScale()) {
539                controller.zoomIn(x, y, Math.max(1.5f, scale * 1.5f));
540            } else {
541                controller.resetToFullView();
542            }
543            return true;
544        }
545
546        @Override
547        public boolean onScroll(float dx, float dy) {
548            if (mTransitionMode != TRANS_NONE) return true;
549
550            ScreenNailEntry next = mScreenNails[ENTRY_NEXT];
551            ScreenNailEntry prev = mScreenNails[ENTRY_PREVIOUS];
552
553            mPositionController.startScroll(dx, dy, next.isEnabled(),
554                    prev.isEnabled());
555            return true;
556        }
557
558        @Override
559        public boolean onFling(float velocityX, float velocityY) {
560            if (swipeImages(velocityX, velocityY)) {
561                mIgnoreUpEvent = true;
562            } else if (mTransitionMode != TRANS_NONE) {
563                // do nothing
564            } else if (mPositionController.fling(velocityX, velocityY)) {
565                mIgnoreUpEvent = true;
566            }
567            return true;
568        }
569
570        @Override
571        public boolean onScaleBegin(float focusX, float focusY) {
572            if (mTransitionMode != TRANS_NONE) return false;
573            mPositionController.beginScale(focusX, focusY);
574            return true;
575        }
576
577        @Override
578        public boolean onScale(float focusX, float focusY, float scale) {
579            if (Float.isNaN(scale) || Float.isInfinite(scale)
580                    || mTransitionMode != TRANS_NONE) return true;
581            boolean outOfRange = mPositionController.scaleBy(
582                    scale, focusX, focusY);
583            if (outOfRange) {
584                if (!mCancelExtraScalingPending) {
585                    mHandler.sendEmptyMessageDelayed(
586                            MSG_CANCEL_EXTRA_SCALING, 700);
587                    mPositionController.setExtraScalingRange(true);
588                    mCancelExtraScalingPending = true;
589                }
590            } else {
591                if (mCancelExtraScalingPending) {
592                    mHandler.removeMessages(MSG_CANCEL_EXTRA_SCALING);
593                    mPositionController.setExtraScalingRange(false);
594                    mCancelExtraScalingPending = false;
595                }
596            }
597            return true;
598        }
599
600        @Override
601        public void onScaleEnd() {
602            mPositionController.endScale();
603            snapToNeighborImage();
604        }
605
606        @Override
607        public void onDown() {
608        }
609
610        @Override
611        public void onUp() {
612            mEdgeView.onRelease();
613
614            if (mIgnoreUpEvent) {
615                mIgnoreUpEvent = false;
616                return;
617            }
618            if (!snapToNeighborImage() && mTransitionMode == TRANS_NONE) {
619                mPositionController.up();
620            }
621        }
622    }
623
624    public boolean jumpTo(int index) {
625        if (mTransitionMode != TRANS_NONE) return false;
626        mModel.jumpTo(index);
627        return true;
628    }
629
630    public void notifyOnNewImage() {
631        mPositionController.setImageSize(0, 0);
632    }
633
634    public void startSlideInAnimation(int direction) {
635        PositionController a = mPositionController;
636        a.stopAnimation();
637        switch (direction) {
638            case TRANS_SLIDE_IN_LEFT:
639            case TRANS_SLIDE_IN_RIGHT: {
640                mTransitionMode = direction;
641                a.startSlideInAnimation(direction);
642                break;
643            }
644            default: throw new IllegalArgumentException(String.valueOf(direction));
645        }
646    }
647
648    private void switchToNextImage() {
649        // We update the texture here directly to prevent texture uploading.
650        ScreenNailEntry prevNail = mScreenNails[ENTRY_PREVIOUS];
651        ScreenNailEntry nextNail = mScreenNails[ENTRY_NEXT];
652        mTileView.invalidateTiles();
653        prevNail.updateScreenNail(mTileView.releaseScreenNail());
654        mTileView.updateScreenNail(nextNail.releaseScreenNail());
655        mModel.next();
656    }
657
658    private void switchToPreviousImage() {
659        // We update the texture here directly to prevent texture uploading.
660        ScreenNailEntry prevNail = mScreenNails[ENTRY_PREVIOUS];
661        ScreenNailEntry nextNail = mScreenNails[ENTRY_NEXT];
662        mTileView.invalidateTiles();
663        nextNail.updateScreenNail(mTileView.releaseScreenNail());
664        mTileView.updateScreenNail(prevNail.releaseScreenNail());
665        mModel.previous();
666    }
667
668    public void notifyTransitionComplete() {
669        mHandler.sendEmptyMessage(MSG_TRANSITION_COMPLETE);
670    }
671
672    private void onTransitionComplete() {
673        int mode = mTransitionMode;
674        mTransitionMode = TRANS_NONE;
675
676        if (mModel == null) return;
677        if (mode == TRANS_SWITCH_NEXT) {
678            switchToNextImage();
679        } else if (mode == TRANS_SWITCH_PREVIOUS) {
680            switchToPreviousImage();
681        }
682    }
683
684    public boolean isDown() {
685        return mGestureRecognizer.isDown();
686    }
687
688    public static interface Model extends TileImageView.Model {
689        public void next();
690        public void previous();
691        public void jumpTo(int index);
692        public int getImageRotation();
693
694        // Return null if the specified image is unavailable.
695        public ScreenNail getNextScreenNail();
696        public ScreenNail getPrevScreenNail();
697    }
698
699    private static int getRotated(int degree, int original, int theother) {
700        return ((degree / 90) & 1) == 0 ? original : theother;
701    }
702
703    private class ScreenNailEntry {
704        private boolean mVisible;
705        private boolean mEnabled;
706
707        private int mDrawWidth;
708        private int mDrawHeight;
709        private int mOffsetX;
710        private int mRotation;
711
712        private ScreenNail mScreenNail;
713
714        public void updateScreenNail(ScreenNail screenNail) {
715            mEnabled = (screenNail != null);
716            if (mScreenNail == screenNail) return;
717            if (mScreenNail != null) mScreenNail.pauseDraw();
718            mScreenNail = screenNail;
719            if (mScreenNail != null) {
720                mRotation = mScreenNail.getRotation();
721                updateDrawingSize();
722            }
723        }
724
725        // Release the ownership of the ScreenNail from this entry.
726        public ScreenNail releaseScreenNail() {
727            ScreenNail s = mScreenNail;
728            mScreenNail = null;
729            return s;
730        }
731
732        public void layoutRightEdgeAt(int x) {
733            mVisible = x > 0;
734            mOffsetX = x - getRotated(
735                    mRotation, mDrawWidth, mDrawHeight) / 2;
736        }
737
738        public void layoutLeftEdgeAt(int x) {
739            mVisible = x < getWidth();
740            mOffsetX = x + getRotated(
741                    mRotation, mDrawWidth, mDrawHeight) / 2;
742        }
743
744        public int gapToSide() {
745            return ((mRotation / 90) & 1) != 0
746                ? PhotoView.gapToSide(mDrawHeight, getWidth())
747                : PhotoView.gapToSide(mDrawWidth, getWidth());
748        }
749
750        public void updateDrawingSize() {
751            if (mScreenNail == null) return;
752
753            int width = mScreenNail.getWidth();
754            int height = mScreenNail.getHeight();
755
756            // Calculate the initial scale that will used by PositionController
757            // (usually fit-to-screen)
758            float s = ((mRotation / 90) & 0x01) == 0
759                    ? mPositionController.getMinimalScale(width, height)
760                    : mPositionController.getMinimalScale(height, width);
761
762            mDrawWidth = Math.round(width * s);
763            mDrawHeight = Math.round(height * s);
764        }
765
766        public boolean isEnabled() {
767            return mEnabled;
768        }
769
770        public void draw(GLCanvas canvas, boolean applyFadingAnimation) {
771            if (mScreenNail == null) return;
772            if (!mVisible) {
773                mScreenNail.noDraw();
774                return;
775            }
776
777            int w = getWidth();
778            int x = applyFadingAnimation ? w / 2 : mOffsetX;
779            int y = getHeight() / 2;
780            int flags = GLCanvas.SAVE_FLAG_MATRIX;
781
782            if (applyFadingAnimation) flags |= GLCanvas.SAVE_FLAG_ALPHA;
783            canvas.save(flags);
784            canvas.translate(x, y);
785            if (applyFadingAnimation) {
786                float progress = (float) (x - mOffsetX) / w;
787                float alpha = getScrollAlpha(progress);
788                float scale = getScrollScale(progress);
789                canvas.multiplyAlpha(alpha);
790                canvas.scale(scale, scale, 1);
791            }
792            if (mRotation != 0) {
793                canvas.rotate(mRotation, 0, 0, 1);
794            }
795            canvas.translate(-x, -y);
796            mScreenNail.draw(canvas, x - mDrawWidth / 2, y - mDrawHeight / 2,
797                    mDrawWidth, mDrawHeight);
798            canvas.restore();
799        }
800    }
801
802    // Returns the scrolling progress value for an object moving out of a
803    // view. The progress value measures how much the object has moving out of
804    // the view. The object currently displays in [left, right), and the view is
805    // at [0, viewWidth].
806    //
807    // The returned value is negative when the object is moving right, and
808    // positive when the object is moving left. The value goes to -1 or 1 when
809    // the object just moves out of the view completely. The value is 0 if the
810    // object currently fills the view.
811    private static float calculateMoveOutProgress(int left, int right,
812            int viewWidth) {
813        // w = object width
814        // viewWidth = view width
815        int w = right - left;
816
817        // If the object width is smaller than the view width,
818        //      |....view....|
819        //                   |<-->|      progress = -1 when left = viewWidth
820        // |<-->|                        progress = 1 when left = -w
821        // So progress = 1 - 2 * (left + w) / (viewWidth + w)
822        if (w < viewWidth) {
823            return 1f - 2f * (left + w) / (viewWidth + w);
824        }
825
826        // If the object width is larger than the view width,
827        //             |..view..|
828        //                      |<--------->| progress = -1 when left = viewWidth
829        //             |<--------->|          progress = 0 between left = 0
830        //          |<--------->|                          and right = viewWidth
831        // |<--------->|                      progress = 1 when right = 0
832        if (left > 0) {
833            return -left / (float) viewWidth;
834        }
835
836        if (right < viewWidth) {
837            return (viewWidth - right) / (float) viewWidth;
838        }
839
840        return 0;
841    }
842
843    // Maps a scrolling progress value to the alpha factor in the fading
844    // animation.
845    private float getScrollAlpha(float scrollProgress) {
846        return scrollProgress < 0 ? mAlphaInterpolator.getInterpolation(
847                     1 - Math.abs(scrollProgress)) : 1.0f;
848    }
849
850    // Maps a scrolling progress value to the scaling factor in the fading
851    // animation.
852    private float getScrollScale(float scrollProgress) {
853        float interpolatedProgress = mScaleInterpolator.getInterpolation(
854                Math.abs(scrollProgress));
855        float scale = (1 - interpolatedProgress) +
856                interpolatedProgress * TRANSITION_SCALE_FACTOR;
857        return scale;
858    }
859
860
861    // This interpolator emulates the rate at which the perceived scale of an
862    // object changes as its distance from a camera increases. When this
863    // interpolator is applied to a scale animation on a view, it evokes the
864    // sense that the object is shrinking due to moving away from the camera.
865    private static class ZInterpolator {
866        private float focalLength;
867
868        public ZInterpolator(float foc) {
869            focalLength = foc;
870        }
871
872        public float getInterpolation(float input) {
873            return (1.0f - focalLength / (focalLength + input)) /
874                (1.0f - focalLength / (focalLength + 1.0f));
875        }
876    }
877
878    public void pause() {
879        mPositionController.skipAnimation();
880        mTransitionMode = TRANS_NONE;
881        mTileView.freeTextures();
882        for (ScreenNailEntry entry : mScreenNails) {
883            entry.updateScreenNail(null);
884        }
885    }
886
887    public void resume() {
888        mTileView.prepareTextures();
889    }
890
891    public void setOpenAnimationRect(Rect rect) {
892        mOpenAnimationRect = rect;
893    }
894
895    public void showVideoPlayIcon(boolean show) {
896        mShowVideoPlayIcon = show;
897    }
898
899    // Returns the opening animation rectangle saved by the previous page.
900    public Rect retrieveOpenAnimationRect() {
901        Rect r = mOpenAnimationRect;
902        mOpenAnimationRect = null;
903        return r;
904    }
905
906    public void openAnimationStarted() {
907        mTransitionMode = TRANS_OPEN_ANIMATION;
908    }
909
910    public boolean isInTransition() {
911        return mTransitionMode != TRANS_NONE;
912    }
913}
914