/* * This file provided by Facebook is for non-commercial testing and evaluation * purposes only. Facebook reserves all rights not expressly granted. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL * FACEBOOK BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN * ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ package com.facebook.samples.zoomable; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import android.graphics.Matrix; import android.graphics.PointF; import android.graphics.RectF; import android.support.annotation.IntDef; import android.view.MotionEvent; import com.facebook.common.logging.FLog; import com.facebook.samples.gestures.TransformGestureDetector; /** * Zoomable controller that calculates transformation based on touch events. */ public class DefaultZoomableController implements ZoomableController, TransformGestureDetector.Listener { @IntDef(flag=true, value={ LIMIT_NONE, LIMIT_TRANSLATION_X, LIMIT_TRANSLATION_Y, LIMIT_SCALE, LIMIT_ALL }) @Retention(RetentionPolicy.SOURCE) public @interface LimitFlag {} public static final int LIMIT_NONE = 0; public static final int LIMIT_TRANSLATION_X = 1; public static final int LIMIT_TRANSLATION_Y = 2; public static final int LIMIT_SCALE = 4; public static final int LIMIT_ALL = LIMIT_TRANSLATION_X | LIMIT_TRANSLATION_Y | LIMIT_SCALE; private static final float EPS = 1e-3f; private static final Class<?> TAG = DefaultZoomableController.class; private static final RectF IDENTITY_RECT = new RectF(0, 0, 1, 1); private TransformGestureDetector mGestureDetector; private Listener mListener = null; private boolean mIsEnabled = false; private boolean mIsRotationEnabled = false; private boolean mIsScaleEnabled = true; private boolean mIsTranslationEnabled = true; private float mMinScaleFactor = 1.0f; private float mMaxScaleFactor = 2.0f; // View bounds, in view-absolute coordinates private final RectF mViewBounds = new RectF(); // Non-transformed image bounds, in view-absolute coordinates private final RectF mImageBounds = new RectF(); // Transformed image bounds, in view-absolute coordinates private final RectF mTransformedImageBounds = new RectF(); private final Matrix mPreviousTransform = new Matrix(); private final Matrix mActiveTransform = new Matrix(); private final Matrix mActiveTransformInverse = new Matrix(); private final float[] mTempValues = new float[9]; private final RectF mTempRect = new RectF(); private boolean mWasTransformCorrected; public static DefaultZoomableController newInstance() { return new DefaultZoomableController(TransformGestureDetector.newInstance()); } public DefaultZoomableController(TransformGestureDetector gestureDetector) { mGestureDetector = gestureDetector; mGestureDetector.setListener(this); } /** Rests the controller. */ public void reset() { FLog.v(TAG, "reset"); mGestureDetector.reset(); mPreviousTransform.reset(); mActiveTransform.reset(); onTransformChanged(); } /** Sets the zoomable listener. */ @Override public void setListener(Listener listener) { mListener = listener; } /** Sets whether the controller is enabled or not. */ @Override public void setEnabled(boolean enabled) { mIsEnabled = enabled; if (!enabled) { reset(); } } /** Gets whether the controller is enabled or not. */ @Override public boolean isEnabled() { return mIsEnabled; } /** Sets whether the rotation gesture is enabled or not. */ public void setRotationEnabled(boolean enabled) { mIsRotationEnabled = enabled; } /** Gets whether the rotation gesture is enabled or not. */ public boolean isRotationEnabled() { return mIsRotationEnabled; } /** Sets whether the scale gesture is enabled or not. */ public void setScaleEnabled(boolean enabled) { mIsScaleEnabled = enabled; } /** Gets whether the scale gesture is enabled or not. */ public boolean isScaleEnabled() { return mIsScaleEnabled; } /** Sets whether the translation gesture is enabled or not. */ public void setTranslationEnabled(boolean enabled) { mIsTranslationEnabled = enabled; } /** Gets whether the translations gesture is enabled or not. */ public boolean isTranslationEnabled() { return mIsTranslationEnabled; } /** * Sets the minimum scale factor allowed. * <p> Hierarchy's scaling (if any) is not taken into account. */ public void setMinScaleFactor(float minScaleFactor) { mMinScaleFactor = minScaleFactor; } /** Gets the minimum scale factor allowed. */ public float getMinScaleFactor() { return mMinScaleFactor; } /** * Sets the maximum scale factor allowed. * <p> Hierarchy's scaling (if any) is not taken into account. */ public void setMaxScaleFactor(float maxScaleFactor) { mMaxScaleFactor = maxScaleFactor; } /** Gets the maximum scale factor allowed. */ public float getMaxScaleFactor() { return mMaxScaleFactor; } /** Gets the current scale factor. */ @Override public float getScaleFactor() { return getMatrixScaleFactor(mActiveTransform); } /** Sets the image bounds, in view-absolute coordinates. */ @Override public void setImageBounds(RectF imageBounds) { if (!imageBounds.equals(mImageBounds)) { mImageBounds.set(imageBounds); onTransformChanged(); } } /** Gets the non-transformed image bounds, in view-absolute coordinates. */ public RectF getImageBounds() { return mImageBounds; } /** Gets the transformed image bounds, in view-absolute coordinates */ private RectF getTransformedImageBounds() { return mTransformedImageBounds; } /** Sets the view bounds. */ @Override public void setViewBounds(RectF viewBounds) { mViewBounds.set(viewBounds); } /** Gets the view bounds. */ public RectF getViewBounds() { return mViewBounds; } /** * Returns true if the zoomable transform is identity matrix. */ @Override public boolean isIdentity() { return isMatrixIdentity(mActiveTransform, 1e-3f); } /** * Returns true if the transform was corrected during the last update. * * We should rename this method to `wasTransformedWithoutCorrection` and just return the * internal flag directly. However, this requires interface change and negation of meaning. */ @Override public boolean wasTransformCorrected() { return mWasTransformCorrected; } /** * Gets the matrix that transforms image-absolute coordinates to view-absolute coordinates. * The zoomable transformation is taken into account. * * Internal matrix is exposed for performance reasons and is not to be modified by the callers. */ @Override public Matrix getTransform() { return mActiveTransform; } /** * Gets the matrix that transforms image-relative coordinates to view-absolute coordinates. * The zoomable transformation is taken into account. */ public void getImageRelativeToViewAbsoluteTransform(Matrix outMatrix) { outMatrix.setRectToRect(IDENTITY_RECT, mTransformedImageBounds, Matrix.ScaleToFit.FILL); } /** * Maps point from view-absolute to image-relative coordinates. * This takes into account the zoomable transformation. */ public PointF mapViewToImage(PointF viewPoint) { float[] points = mTempValues; points[0] = viewPoint.x; points[1] = viewPoint.y; mActiveTransform.invert(mActiveTransformInverse); mActiveTransformInverse.mapPoints(points, 0, points, 0, 1); mapAbsoluteToRelative(points, points, 1); return new PointF(points[0], points[1]); } /** * Maps point from image-relative to view-absolute coordinates. * This takes into account the zoomable transformation. */ public PointF mapImageToView(PointF imagePoint) { float[] points = mTempValues; points[0] = imagePoint.x; points[1] = imagePoint.y; mapRelativeToAbsolute(points, points, 1); mActiveTransform.mapPoints(points, 0, points, 0, 1); return new PointF(points[0], points[1]); } /** * Maps array of 2D points from view-absolute to image-relative coordinates. * This does NOT take into account the zoomable transformation. * Points are represented by a float array of [x0, y0, x1, y1, ...]. * * @param destPoints destination array (may be the same as source array) * @param srcPoints source array * @param numPoints number of points to map */ private void mapAbsoluteToRelative(float[] destPoints, float[] srcPoints, int numPoints) { for (int i = 0; i < numPoints; i++) { destPoints[i * 2 + 0] = (srcPoints[i * 2 + 0] - mImageBounds.left) / mImageBounds.width(); destPoints[i * 2 + 1] = (srcPoints[i * 2 + 1] - mImageBounds.top) / mImageBounds.height(); } } /** * Maps array of 2D points from image-relative to view-absolute coordinates. * This does NOT take into account the zoomable transformation. * Points are represented by float array of [x0, y0, x1, y1, ...]. * * @param destPoints destination array (may be the same as source array) * @param srcPoints source array * @param numPoints number of points to map */ private void mapRelativeToAbsolute(float[] destPoints, float[] srcPoints, int numPoints) { for (int i = 0; i < numPoints; i++) { destPoints[i * 2 + 0] = srcPoints[i * 2 + 0] * mImageBounds.width() + mImageBounds.left; destPoints[i * 2 + 1] = srcPoints[i * 2 + 1] * mImageBounds.height() + mImageBounds.top; } } /** * Zooms to the desired scale and positions the image so that the given image point corresponds * to the given view point. * * @param scale desired scale, will be limited to {min, max} scale factor * @param imagePoint 2D point in image's relative coordinate system (i.e. 0 <= x, y <= 1) * @param viewPoint 2D point in view's absolute coordinate system */ public void zoomToPoint(float scale, PointF imagePoint, PointF viewPoint) { FLog.v(TAG, "zoomToPoint"); calculateZoomToPointTransform(mActiveTransform, scale, imagePoint, viewPoint, LIMIT_ALL); onTransformChanged(); } /** * Calculates the zoom transformation that would zoom to the desired scale and position the image * so that the given image point corresponds to the given view point. * * @param outTransform the matrix to store the result to * @param scale desired scale, will be limited to {min, max} scale factor * @param imagePoint 2D point in image's relative coordinate system (i.e. 0 <= x, y <= 1) * @param viewPoint 2D point in view's absolute coordinate system * @param limitFlags whether to limit translation and/or scale. * @return whether or not the transform has been corrected due to limitation */ protected boolean calculateZoomToPointTransform( Matrix outTransform, float scale, PointF imagePoint, PointF viewPoint, @LimitFlag int limitFlags) { float[] viewAbsolute = mTempValues; viewAbsolute[0] = imagePoint.x; viewAbsolute[1] = imagePoint.y; mapRelativeToAbsolute(viewAbsolute, viewAbsolute, 1); float distanceX = viewPoint.x - viewAbsolute[0]; float distanceY = viewPoint.y - viewAbsolute[1]; boolean transformCorrected = false; outTransform.setScale(scale, scale, viewAbsolute[0], viewAbsolute[1]); transformCorrected |= limitScale(outTransform, viewAbsolute[0], viewAbsolute[1], limitFlags); outTransform.postTranslate(distanceX, distanceY); transformCorrected |= limitTranslation(outTransform, limitFlags); return transformCorrected; } /** Sets a new zoom transformation. */ public void setTransform(Matrix newTransform) { FLog.v(TAG, "setTransform"); mActiveTransform.set(newTransform); onTransformChanged(); } /** Gets the gesture detector. */ protected TransformGestureDetector getDetector() { return mGestureDetector; } /** Notifies controller of the received touch event. */ @Override public boolean onTouchEvent(MotionEvent event) { FLog.v(TAG, "onTouchEvent: action: ", event.getAction()); if (mIsEnabled) { return mGestureDetector.onTouchEvent(event); } return false; } /* TransformGestureDetector.Listener methods */ @Override public void onGestureBegin(TransformGestureDetector detector) { FLog.v(TAG, "onGestureBegin"); mPreviousTransform.set(mActiveTransform); // We only received a touch down event so far, and so we don't know yet in which direction a // future move event will follow. Therefore, if we can't scroll in all directions, we have to // assume the worst case where the user tries to scroll out of edge, which would cause // transformation to be corrected. mWasTransformCorrected = !canScrollInAllDirection(); } @Override public void onGestureUpdate(TransformGestureDetector detector) { FLog.v(TAG, "onGestureUpdate"); boolean transformCorrected = calculateGestureTransform(mActiveTransform, LIMIT_ALL); onTransformChanged(); if (transformCorrected) { mGestureDetector.restartGesture(); } // A transformation happened, but was it without correction? mWasTransformCorrected = transformCorrected; } @Override public void onGestureEnd(TransformGestureDetector detector) { FLog.v(TAG, "onGestureEnd"); } /** * Calculates the zoom transformation based on the current gesture. * * @param outTransform the matrix to store the result to * @param limitTypes whether to limit translation and/or scale. * @return whether or not the transform has been corrected due to limitation */ protected boolean calculateGestureTransform( Matrix outTransform, @LimitFlag int limitTypes) { TransformGestureDetector detector = mGestureDetector; boolean transformCorrected = false; outTransform.set(mPreviousTransform); if (mIsRotationEnabled) { float angle = detector.getRotation() * (float) (180 / Math.PI); outTransform.postRotate(angle, detector.getPivotX(), detector.getPivotY()); } if (mIsScaleEnabled) { float scale = detector.getScale(); outTransform.postScale(scale, scale, detector.getPivotX(), detector.getPivotY()); } transformCorrected |= limitScale(outTransform, detector.getPivotX(), detector.getPivotY(), limitTypes); if (mIsTranslationEnabled) { outTransform.postTranslate(detector.getTranslationX(), detector.getTranslationY()); } transformCorrected |= limitTranslation(outTransform, limitTypes); return transformCorrected; } private void onTransformChanged() { mActiveTransform.mapRect(mTransformedImageBounds, mImageBounds); if (mListener != null && isEnabled()) { mListener.onTransformChanged(mActiveTransform); } } /** * Keeps the scaling factor within the specified limits. * * @param pivotX x coordinate of the pivot point * @param pivotY y coordinate of the pivot point * @param limitTypes whether to limit scale. * @return whether limiting has been applied or not */ private boolean limitScale( Matrix transform, float pivotX, float pivotY, @LimitFlag int limitTypes) { if (!shouldLimit(limitTypes, LIMIT_SCALE)) { return false; } float currentScale = getMatrixScaleFactor(transform); float targetScale = limit(currentScale, mMinScaleFactor, mMaxScaleFactor); if (targetScale != currentScale) { float scale = targetScale / currentScale; transform.postScale(scale, scale, pivotX, pivotY); return true; } return false; } /** * Limits the translation so that there are no empty spaces on the sides if possible. * * <p> The image is attempted to be centered within the view bounds if the transformed image is * smaller. There will be no empty spaces within the view bounds if the transformed image is * bigger. This applies to each dimension (horizontal and vertical) independently. * * @param limitTypes whether to limit translation along the specific axis. * @return whether limiting has been applied or not */ private boolean limitTranslation(Matrix transform, @LimitFlag int limitTypes) { if (!shouldLimit(limitTypes, LIMIT_TRANSLATION_X | LIMIT_TRANSLATION_Y)) { return false; } RectF b = mTempRect; b.set(mImageBounds); transform.mapRect(b); float offsetLeft = shouldLimit(limitTypes, LIMIT_TRANSLATION_X) ? getOffset(b.left, b.right, mViewBounds.left, mViewBounds.right, mImageBounds.centerX()) : 0; float offsetTop = shouldLimit(limitTypes, LIMIT_TRANSLATION_Y) ? getOffset(b.top, b.bottom, mViewBounds.top, mViewBounds.bottom, mImageBounds.centerY()) : 0; if (offsetLeft != 0 || offsetTop != 0) { transform.postTranslate(offsetLeft, offsetTop); return true; } return false; } /** * Checks whether the specified limit flag is present in the limits provided. * * <p> If the flag contains multiple flags together using a bitwise OR, this only checks that at * least one of the flags is included. * * @param limits the limits to apply * @param flag the limit flag(s) to check for * @return true if the flag (or one of the flags) is included in the limits */ private static boolean shouldLimit(@LimitFlag int limits, @LimitFlag int flag) { return (limits & flag) != LIMIT_NONE; } /** * Returns the offset necessary to make sure that: * - the image is centered within the limit if the image is smaller than the limit * - there is no empty space on left/right if the image is bigger than the limit */ private float getOffset( float imageStart, float imageEnd, float limitStart, float limitEnd, float limitCenter) { float imageWidth = imageEnd - imageStart, limitWidth = limitEnd - limitStart; float limitInnerWidth = Math.min(limitCenter - limitStart, limitEnd - limitCenter) * 2; // center if smaller than limitInnerWidth if (imageWidth < limitInnerWidth) { return limitCenter - (imageEnd + imageStart) / 2; } // to the edge if in between and limitCenter is not (limitLeft + limitRight) / 2 if (imageWidth < limitWidth) { if (limitCenter < (limitStart + limitEnd) / 2) { return limitStart - imageStart; } else { return limitEnd - imageEnd; } } // to the edge if larger than limitWidth and empty space visible if (imageStart > limitStart) { return limitStart - imageStart; } if (imageEnd < limitEnd) { return limitEnd - imageEnd; } return 0; } /** * Limits the value to the given min and max range. */ private float limit(float value, float min, float max) { return Math.min(Math.max(min, value), max); } /** * Gets the scale factor for the given matrix. * This method assumes the equal scaling factor for X and Y axis. */ private float getMatrixScaleFactor(Matrix transform) { transform.getValues(mTempValues); return mTempValues[Matrix.MSCALE_X]; } /** * Same as {@code Matrix.isIdentity()}, but with tolerance {@code eps}. */ private boolean isMatrixIdentity(Matrix transform, float eps) { // Checks whether the given matrix is close enough to the identity matrix: // 1 0 0 // 0 1 0 // 0 0 1 // Or equivalently to the zero matrix, after subtracting 1.0f from the diagonal elements: // 0 0 0 // 0 0 0 // 0 0 0 transform.getValues(mTempValues); mTempValues[0] -= 1.0f; // m00 mTempValues[4] -= 1.0f; // m11 mTempValues[8] -= 1.0f; // m22 for (int i = 0; i < 9; i++) { if (Math.abs(mTempValues[i]) > eps) { return false; } } return true; } /** * Returns whether the scroll can happen in all directions. I.e. the image is not on any edge. */ private boolean canScrollInAllDirection() { return mTransformedImageBounds.left < mViewBounds.left - EPS && mTransformedImageBounds.top < mViewBounds.top - EPS && mTransformedImageBounds.right > mViewBounds.right + EPS && mTransformedImageBounds.bottom > mViewBounds.bottom + EPS; } @Override public int computeHorizontalScrollRange() { return (int)mTransformedImageBounds.width(); } @Override public int computeHorizontalScrollOffset() { return (int)(mViewBounds.left - mTransformedImageBounds.left); } @Override public int computeHorizontalScrollExtent() { return (int)mViewBounds.width(); } @Override public int computeVerticalScrollRange() { return (int)mTransformedImageBounds.height(); } @Override public int computeVerticalScrollOffset() { return (int)(mViewBounds.top - mTransformedImageBounds.top); } @Override public int computeVerticalScrollExtent() { return (int)mViewBounds.height(); } }