/* * Copyright (C) 2011 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.ui; import com.android.gallery3d.R; import com.android.gallery3d.app.GalleryActivity; import com.android.gallery3d.common.Utils; import com.android.gallery3d.data.Path; import com.android.gallery3d.ui.PositionRepository.Position; import com.android.gallery3d.util.GalleryUtils; import android.content.Context; import android.graphics.Bitmap; import android.graphics.Color; import android.graphics.RectF; import android.os.Message; import android.os.SystemClock; import android.view.GestureDetector; import android.view.MotionEvent; import android.view.ScaleGestureDetector; import android.widget.Scroller; class PositionController { private static final String TAG = "PositionController"; private long mAnimationStartTime = NO_ANIMATION; 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; // Animation time in milliseconds. The order must match ANIM_KIND_* above. private final static int ANIM_TIME[] = { 0, // ANIM_KIND_SCROLL 50, // ANIM_KIND_SCALE 600, // ANIM_KIND_SNAPBACK 400, // ANIM_KIND_SLIDE 300, // ANIM_KIND_ZOOM 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 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; // This is used by the fling animation private FlingScroller mScroller; // The bound of the stable region, see the comments above // calculateStableBound() for details. private int mBoundLeft, mBoundRight, mBoundTop, mBoundBottom; // Assume the image size is the same as view size before we know the actual // size of image. private boolean mUseViewSize = true; private RectF mTempRect = new RectF(); private float[] mTempPoints = new float[8]; public PositionController(PhotoView viewer, Context context, EdgeView edgeView) { mViewer = viewer; mEdgeView = edgeView; mScroller = new FlingScroller(); } public void setImageSize(int width, int height) { // 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; } mUseViewSize = false; float ratio = Math.min( (float) mImageW / width, (float) mImageH / height); // See the comment above translate() for details. mCurrentX = translate(mCurrentX, mImageW, width, ratio); mCurrentY = translate(mCurrentY, mImageH, height, ratio); mCurrentScale = mCurrentScale * ratio; mFromX = translate(mFromX, mImageW, width, ratio); mFromY = translate(mFromY, mImageH, height, ratio); mFromScale = mFromScale * ratio; mToX = translate(mToX, mImageW, width, ratio); mToY = translate(mToY, mImageH, height, ratio); mToScale = mToScale * ratio; mFocusBitmapX = translate(mFocusBitmapX, mImageW, width, ratio); mFocusBitmapY = translate(mFocusBitmapY, mImageH, height, ratio); mImageW = width; mImageH = height; mScaleMin = getMinimalScale(mImageW, mImageH); // Start animation from the saved position if we have one. Position position = mViewer.retrieveSavedPosition(); if (position != null) { // The animation starts from 240 pixels and centers at the image // at the saved position. float scale = 240f / Math.min(width, height); mCurrentX = Math.round((mViewW / 2f - position.x) / scale) + mImageW / 2; mCurrentY = Math.round((mViewH / 2f - position.y) / 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 void zoomIn(float tapX, float tapY, float targetScale) { if (targetScale > mScaleMax) targetScale = mScaleMax; // 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); calculateStableBound(targetScale); int targetX = Utils.clamp(tempX, mBoundLeft, mBoundRight); int targetY = Utils.clamp(tempY, mBoundTop, mBoundBottom); startAnimation(targetX, targetY, targetScale, ANIM_KIND_ZOOM); } public void resetToFullView() { startAnimation(mImageW / 2, mImageH / 2, mScaleMin, ANIM_KIND_ZOOM); } public float getMinimalScale(int w, int h) { return Math.min(SCALE_LIMIT, Math.min((float) mViewW / w, (float) mViewH / h)); } // 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); } public void setViewSize(int viewW, int viewH) { boolean needLayout = mViewW == 0 || mViewH == 0; mViewW = viewW; mViewH = viewH; if (mUseViewSize) { mImageW = viewW; mImageH = viewH; mCurrentX = mImageW / 2; mCurrentY = mImageH / 2; mCurrentScale = 1; mScaleMin = 1; mViewer.setPosition(mCurrentX, mCurrentY, mCurrentScale); 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); if (needLayout || mCurrentScale < mScaleMin || wasMinScale) { mCurrentX = mImageW / 2; mCurrentY = mImageH / 2; mCurrentScale = mScaleMin; mViewer.setPosition(mCurrentX, mCurrentY, mCurrentScale); } } public void stopAnimation() { mAnimationStartTime = NO_ANIMATION; } public void skipAnimation() { if (mAnimationStartTime == NO_ANIMATION) return; mAnimationStartTime = NO_ANIMATION; mCurrentX = mToX; mCurrentY = mToY; mCurrentScale = mToScale; } 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 scaleBy(float s, float focusX, float focusY) { // 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); startAnimation(x, y, s, ANIM_KIND_SCALE); } public void endScale() { mInScale = false; startSnapbackIfNeeded(); } public float getCurrentScale() { return mCurrentScale; } public boolean isAtMinimalScale() { return isAlmostEquals(mCurrentScale, mScaleMin); } private static boolean isAlmostEquals(float a, float b) { float diff = a - b; return (diff < 0 ? -diff : diff) < 0.02f; } 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 startHorizontalSlide(int distance) { scrollBy(distance, 0, 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 hasNext, boolean hasPrev) { int x = getTargetX() + Math.round(dx / mCurrentScale); int y = getTargetY() + Math.round(dy / mCurrentScale); calculateStableBound(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); } else if (y > mBoundBottom) { mEdgeView.onPull(y - mBoundBottom, EdgeView.BOTTOM); } } y = Utils.clamp(y, mBoundTop, mBoundBottom); // 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); x = mBoundRight; } startAnimation(x, y, mCurrentScale, ANIM_KIND_SCROLL); } public boolean fling(float velocityX, float velocityY) { // We only want to do fling when the picture is zoomed-in. if (mImageW * mCurrentScale <= mViewW && mImageH * mCurrentScale <= mViewH) { return false; } calculateStableBound(mCurrentScale); mScroller.fling(mCurrentX, mCurrentY, Math.round(-velocityX / mCurrentScale), Math.round(-velocityY / mCurrentScale), mBoundLeft, mBoundRight, mBoundTop, mBoundBottom); int targetX = mScroller.getFinalX(); int targetY = mScroller.getFinalY(); mAnimationDuration = mScroller.getDuration(); startAnimation(targetX, targetY, mCurrentScale, ANIM_KIND_FLING); return true; } private void startAnimation( int targetX, int targetY, float scale, int kind) { if (targetX == mCurrentX && targetY == mCurrentY && scale == mCurrentScale) return; mFromX = mCurrentX; mFromY = mCurrentY; mFromScale = mCurrentScale; mToX = targetX; mToY = targetY; mToScale = Utils.clamp(scale, 0.6f * mScaleMin, 1.4f * mScaleMax); // 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 (Math.floor(mImageH * mToScale) <= mViewH) { mToY = mImageH / 2; } mAnimationStartTime = SystemClock.uptimeMillis(); mAnimationKind = kind; if (mAnimationKind != ANIM_KIND_FLING) { mAnimationDuration = ANIM_TIME[mAnimationKind]; } if (advanceAnimation()) mViewer.invalidate(); } // Returns true if redraw is needed. public boolean advanceAnimation() { if (mAnimationStartTime == NO_ANIMATION) { return false; } else if (mAnimationStartTime == LAST_ANIMATION) { mAnimationStartTime = NO_ANIMATION; if (mViewer.isInTransition()) { mViewer.notifyTransitionComplete(); return false; } else { return startSnapbackIfNeeded(); } } long now = SystemClock.uptimeMillis(); float progress; if (mAnimationDuration == 0) { progress = 1; } else { progress = (now - mAnimationStartTime) / mAnimationDuration; } 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; } if (mAnimationKind == ANIM_KIND_FLING) { flingInterpolate(progress); } else { linearInterpolate(progress); } } mViewer.setPosition(mCurrentX, mCurrentY, mCurrentScale); return true; } 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; } return startSnapback(); } public boolean startSnapback() { boolean needAnimation = false; float scale = mCurrentScale; if (mCurrentScale < mScaleMin || mCurrentScale > mScaleMax) { needAnimation = true; scale = Utils.clamp(mCurrentScale, mScaleMin, mScaleMax); } calculateStableBound(scale, sHorizontalSlack); int x = Utils.clamp(mCurrentX, mBoundLeft, mBoundRight); int y = Utils.clamp(mCurrentY, mBoundTop, mBoundBottom); if (mCurrentX != x || mCurrentY != y || mCurrentScale != scale) { needAnimation = true; } if (needAnimation) { startAnimation(x, y, scale, ANIM_KIND_SNAPBACK); } return needAnimation; } // Calculates the stable region of mCurrent{X/Y}, where "stable" means // // (1) If the dimension of scaled image >= view dimension, we will not // see black region outside the image (at that dimension). // (2) If the dimension of scaled image < view dimension, we will center // the scaled image. // // We might temporarily go out of this stable during user interaction, // but will "snap back" after user stops interaction. // // The results are stored in mBound{Left/Right/Top/Bottom}. // // 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, float horizontalSlack) { // The number of pixels between the center of the view // and the edge when the edge is aligned. mBoundLeft = (int) Math.ceil((mViewW - horizontalSlack) / (2 * scale)); mBoundRight = mImageW - mBoundLeft; mBoundTop = (int) Math.ceil(mViewH / (2 * scale)); mBoundBottom = mImageH - mBoundTop; // If the scaled height is smaller than the view height, // force it to be in the center. if (Math.floor(mImageH * scale) <= mViewH) { mBoundTop = mBoundBottom = mImageH / 2; } // Same for width if (Math.floor(mImageW * scale) <= mViewW) { mBoundLeft = mBoundRight = mImageW / 2; } } private boolean useCurrentValueAsTarget() { return mAnimationStartTime == NO_ANIMATION || mAnimationKind == ANIM_KIND_SNAPBACK || mAnimationKind == ANIM_KIND_FLING; } private float getTargetScale() { return useCurrentValueAsTarget() ? mCurrentScale : mToScale; } private int getTargetX() { return useCurrentValueAsTarget() ? mCurrentX : mToX; } private int getTargetY() { return useCurrentValueAsTarget() ? mCurrentY : mToY; } public RectF getImageBounds() { float points[] = mTempPoints; /* * (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; RectF rect = mTempRect; rect.set(Float.POSITIVE_INFINITY, Float.POSITIVE_INFINITY, Float.NEGATIVE_INFINITY, Float.NEGATIVE_INFINITY); 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 rect; } public int getImageWidth() { return mImageW; } public int getImageHeight() { return mImageH; } public boolean isAtLeftEdge() { calculateStableBound(mCurrentScale); return mCurrentX <= mBoundLeft; } public boolean isAtRightEdge() { calculateStableBound(mCurrentScale); return mCurrentX >= mBoundRight; } }