com.google.android.apps.muzei.util.PanScaleProxyView.java Source code

Java tutorial

Introduction

Here is the source code for com.google.android.apps.muzei.util.PanScaleProxyView.java

Source

/*
 * Copyright 2014 Google Inc.
 *
 * 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.google.android.apps.muzei.util;

import android.content.Context;
import android.graphics.Point;
import android.graphics.PointF;
import android.graphics.RectF;
import android.os.Handler;
import android.os.Parcel;
import android.os.Parcelable;
import android.support.v4.os.ParcelableCompat;
import android.support.v4.os.ParcelableCompatCreatorCallbacks;
import android.support.v4.view.GestureDetectorCompat;
import android.support.v4.view.ScaleGestureDetectorCompat;
import android.util.AttributeSet;
import android.view.GestureDetector;
import android.view.MotionEvent;
import android.view.ScaleGestureDetector;
import android.view.View;
import android.widget.OverScroller;

public class PanScaleProxyView extends View {
    private static final String TAG = LogUtil.makeLogTag(PanScaleProxyView.class);

    /**
     * The current viewport. This rectangle represents the currently visible chart domain
     * and range. The currently visible chart X values are from this rectangle's left to its right.
     * The currently visible chart Y values are from this rectangle's top to its bottom.
     */
    private RectF mCurrentViewport = new RectF(0, 0, 1, 1);

    private Point mSurfaceSizeBuffer = new Point();

    private int mWidth = 1;
    private int mHeight = 1;
    private float mRelativeAspectRatio = 1f;
    private boolean mPanScaleEnabled = true;
    private float mMinViewportWidthOrHeight = 0.01f;

    // State objects and values related to gesture tracking.
    private ScaleGestureDetector mScaleGestureDetector;
    private GestureDetectorCompat mGestureDetector;
    private OverScroller mScroller;
    private Zoomer mZoomer;
    private PointF mZoomFocalPoint = new PointF();
    private RectF mScrollerStartViewport = new RectF(); // Used only for zooms and flings.
    private boolean mDragZoomed = false;
    private boolean mNonSingleTapGesture = false;
    private boolean mNonSingleTapZoomedOut = false;
    private boolean mMotionEventDown;

    private Handler mHandler = new Handler();

    private OnOtherGestureListener mOnOtherGestureListener;
    private OnViewportChangedListener mOnViewportChangedListener;

    public PanScaleProxyView(Context context) {
        this(context, null, 0);
    }

    public PanScaleProxyView(Context context, AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public PanScaleProxyView(Context context, AttributeSet attrs, int defStyle) {
        super(context, attrs, defStyle);

        setWillNotDraw(true);

        // Sets up interactions
        mScaleGestureDetector = new ScaleGestureDetector(context, mScaleGestureListener);
        ScaleGestureDetectorCompat.setQuickScaleEnabled(mScaleGestureDetector, true);
        mGestureDetector = new GestureDetectorCompat(context, mGestureListener);

        mScroller = new OverScroller(context);
        mZoomer = new Zoomer(context);
    }

    @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        super.onSizeChanged(w, h, oldw, oldh);
        mWidth = Math.max(1, w);
        mHeight = Math.max(1, h);
    }

    ////////////////////////////////////////////////////////////////////////////////////////////////
    //
    //     Methods and objects related to gesture handling
    //
    ////////////////////////////////////////////////////////////////////////////////////////////////

    /**
     * Finds the chart point (i.e. within the chart's domain and range) represented by the
     * given pixel coordinates. The "dest" argument is set to the point and
     * this function returns true.
     */
    private void hitTest(float x, float y, PointF dest) {
        dest.set(mCurrentViewport.left + mCurrentViewport.width() * x / mWidth,
                mCurrentViewport.top + mCurrentViewport.height() * y / mHeight);
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        if (event.getActionMasked() == MotionEvent.ACTION_DOWN) {
            mMotionEventDown = true;
            mNonSingleTapGesture = false;
            mNonSingleTapZoomedOut = false;
            if (mOnOtherGestureListener != null) {
                mOnOtherGestureListener.onDown();
            }
        }
        boolean retVal = mScaleGestureDetector.onTouchEvent(event);
        retVal = mGestureDetector.onTouchEvent(event) || retVal;
        if (mMotionEventDown && event.getActionMasked() == MotionEvent.ACTION_UP) {
            mMotionEventDown = false;
            if (mNonSingleTapGesture && mOnOtherGestureListener != null) {
                mOnOtherGestureListener.onUpNonSingleTap(mNonSingleTapZoomedOut);
            }
        }
        return retVal || super.onTouchEvent(event);
    }

    /**
     * The scale listener, used for handling multi-finger scale gestures.
     */
    private final ScaleGestureDetector.OnScaleGestureListener mScaleGestureListener = new ScaleGestureDetector.SimpleOnScaleGestureListener() {
        /**
         * This is the active focal point in terms of the viewport. Could be a local
         * variable but kept here to minimize per-frame allocations.
         */
        private PointF viewportFocus = new PointF();

        @Override
        public boolean onScaleBegin(ScaleGestureDetector detector) {
            if (!mPanScaleEnabled) {
                return false;
            }

            mDragZoomed = true;
            return true;
        }

        @Override
        public boolean onScale(ScaleGestureDetector scaleGestureDetector) {
            if (!mPanScaleEnabled) {
                return false;
            }

            float newWidth = 1 / scaleGestureDetector.getScaleFactor() * mCurrentViewport.width();
            float newHeight = 1 / scaleGestureDetector.getScaleFactor() * mCurrentViewport.height();

            float focusX = scaleGestureDetector.getFocusX();
            float focusY = scaleGestureDetector.getFocusY();
            hitTest(focusX, focusY, viewportFocus);

            mCurrentViewport.set(viewportFocus.x - newWidth * focusX / mWidth,
                    viewportFocus.y - newHeight * focusY / mHeight, 0, 0);
            mCurrentViewport.right = mCurrentViewport.left + newWidth;
            mCurrentViewport.bottom = mCurrentViewport.top + newHeight;
            constrainViewport();
            triggerViewportChangedListener();
            mNonSingleTapGesture = true;
            return true;
        }

        @Override
        public void onScaleEnd(ScaleGestureDetector detector) {
            super.onScaleEnd(detector);
        }
    };

    /**
     * Ensures that current viewport is inside the viewport extremes and original
     * aspect ratio is kept.
     */
    private void constrainViewport() {
        if (mRelativeAspectRatio > 1) {
            if (mCurrentViewport.top < 0) {
                mCurrentViewport.offset(0, -mCurrentViewport.top);
            }
            if (mCurrentViewport.bottom > 1) {
                float requestedHeight = mCurrentViewport.height();
                mCurrentViewport.bottom = 1;
                mCurrentViewport.top = Math.max(0, mCurrentViewport.bottom - requestedHeight);
            }
            if (mCurrentViewport.height() < mMinViewportWidthOrHeight) {
                mCurrentViewport.bottom = (mCurrentViewport.bottom + mCurrentViewport.top) / 2
                        + mMinViewportWidthOrHeight / 2;
                mCurrentViewport.top = mCurrentViewport.bottom - mMinViewportWidthOrHeight;
            }
            float halfWidth = mCurrentViewport.height() / mRelativeAspectRatio / 2;
            float centerX = MathUtil.constrain(halfWidth, 1 - halfWidth,
                    (mCurrentViewport.right + mCurrentViewport.left) / 2);
            mCurrentViewport.left = centerX - halfWidth;
            mCurrentViewport.right = centerX + halfWidth;
        } else {
            if (mCurrentViewport.left < 0) {
                mCurrentViewport.offset(-mCurrentViewport.left, 0);
            }
            if (mCurrentViewport.right > 1) {
                float requestedWidth = mCurrentViewport.width();
                mCurrentViewport.right = 1;
                mCurrentViewport.left = Math.max(0, mCurrentViewport.right - requestedWidth);
            }
            if (mCurrentViewport.width() < mMinViewportWidthOrHeight) {
                mCurrentViewport.right = (mCurrentViewport.right + mCurrentViewport.left) / 2
                        + mMinViewportWidthOrHeight / 2;
                mCurrentViewport.left = mCurrentViewport.right - mMinViewportWidthOrHeight;
            }
            float halfHeight = mCurrentViewport.width() * mRelativeAspectRatio / 2;
            float centerY = MathUtil.constrain(halfHeight, 1 - halfHeight,
                    (mCurrentViewport.bottom + mCurrentViewport.top) / 2);
            mCurrentViewport.top = centerY - halfHeight;
            mCurrentViewport.bottom = centerY + halfHeight;
        }
    }

    /**
     * The gesture listener, used for handling simple gestures such as double touches, scrolls,
     * and flings.
     */
    private final GestureDetector.SimpleOnGestureListener mGestureListener = new GestureDetector.SimpleOnGestureListener() {
        @Override
        public boolean onDown(MotionEvent e) {
            if (!mPanScaleEnabled) {
                return false;
            }

            mDragZoomed = false;
            mScrollerStartViewport.set(mCurrentViewport);
            mScroller.forceFinished(true);
            return true;
        }

        @Override
        public boolean onSingleTapConfirmed(MotionEvent e) {
            if (mOnOtherGestureListener != null) {
                mOnOtherGestureListener.onSingleTapUp();
            }
            return true;
        }

        @Override
        public boolean onDoubleTapEvent(MotionEvent e) {
            if (!mPanScaleEnabled || mDragZoomed || e.getActionMasked() != MotionEvent.ACTION_UP) {
                return false;
            }

            mZoomer.forceFinished(true);
            hitTest(e.getX(), e.getY(), mZoomFocalPoint);
            float startZoom;
            if (mRelativeAspectRatio > 1) {
                startZoom = 1 / mCurrentViewport.height();
            } else {
                startZoom = 1 / mCurrentViewport.width();
            }
            boolean zoomIn = (startZoom < 1.5f);
            mZoomer.startZoom(startZoom, zoomIn ? 2f : 1f);
            triggerViewportChangedListener();
            postAnimateTick();
            mNonSingleTapGesture = true;
            mNonSingleTapZoomedOut = !zoomIn;

            // Workaround for 11952668; blow away the entire scale gesture detector after
            // a double tap
            mScaleGestureDetector = new ScaleGestureDetector(getContext(), mScaleGestureListener);
            ScaleGestureDetectorCompat.setQuickScaleEnabled(mScaleGestureDetector, true);
            return true;
        }

        @Override
        public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
            if (!mPanScaleEnabled) {
                return false;
            }

            // Scrolling uses math based on the viewport (as opposed to math using pixels).
            /**
             * Pixel offset is the offset in screen pixels, while viewport offset is the
             * offset within the current viewport. For additional information on surface sizes
             * and pixel offsets, see the docs for {@link computeScrollSurfaceSize()}. For
             * additional information about the viewport, see the comments for
             * {@link mCurrentViewport}.
             */
            float viewportOffsetX = distanceX * mCurrentViewport.width() / mWidth;
            float viewportOffsetY = distanceY * mCurrentViewport.height() / mHeight;
            computeScrollSurfaceSize(mSurfaceSizeBuffer);
            setViewportTopLeft(mCurrentViewport.left + viewportOffsetX, mCurrentViewport.top + viewportOffsetY);
            mNonSingleTapGesture = true;
            return true;
        }

        @Override
        public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
            if (!mPanScaleEnabled) {
                return false;
            }

            fling((int) -velocityX, (int) -velocityY);
            mNonSingleTapGesture = true;
            return true;
        }
    };

    private void fling(int velocityX, int velocityY) {
        // Flings use math in pixels (as opposed to math based on the viewport).
        computeScrollSurfaceSize(mSurfaceSizeBuffer);
        mScrollerStartViewport.set(mCurrentViewport);
        int startX = (int) (mSurfaceSizeBuffer.x * mScrollerStartViewport.left);
        int startY = (int) (mSurfaceSizeBuffer.y * mScrollerStartViewport.top);
        mScroller.forceFinished(true);
        mScroller.fling(startX, startY, velocityX, velocityY, 0, mSurfaceSizeBuffer.x - mWidth, 0,
                mSurfaceSizeBuffer.y - mHeight, mWidth / 2, mHeight / 2);
        postAnimateTick();
        triggerViewportChangedListener();
    }

    private void postAnimateTick() {
        mHandler.removeCallbacks(mAnimateTickRunnable);
        mHandler.post(mAnimateTickRunnable);
    }

    private Runnable mAnimateTickRunnable = new Runnable() {
        @Override
        public void run() {
            boolean needsInvalidate = false;

            if (mScroller.computeScrollOffset()) {
                // The scroller isn't finished, meaning a fling or programmatic pan operation is
                // currently active.

                computeScrollSurfaceSize(mSurfaceSizeBuffer);
                int currX = mScroller.getCurrX();
                int currY = mScroller.getCurrY();

                float currXRange = currX * 1f / mSurfaceSizeBuffer.x;
                float currYRange = currY * 1f / mSurfaceSizeBuffer.y;
                setViewportTopLeft(currXRange, currYRange);
                needsInvalidate = true;
            }

            if (mZoomer.computeZoom()) {
                // Performs the zoom since a zoom is in progress.
                float newWidth, newHeight;
                if (mRelativeAspectRatio > 1) {
                    newHeight = 1 / mZoomer.getCurrZoom();
                    newWidth = newHeight / mRelativeAspectRatio;
                } else {
                    newWidth = 1 / mZoomer.getCurrZoom();
                    newHeight = newWidth * mRelativeAspectRatio;
                }
                // focalPointOnScreen... 0 = left/top edge of screen, 1 = right/bottom edge of sreen
                float focalPointOnScreenX = (mZoomFocalPoint.x - mScrollerStartViewport.left)
                        / mScrollerStartViewport.width();
                float focalPointOnScreenY = (mZoomFocalPoint.y - mScrollerStartViewport.top)
                        / mScrollerStartViewport.height();
                mCurrentViewport.set(mZoomFocalPoint.x - newWidth * focalPointOnScreenX,
                        mZoomFocalPoint.y - newHeight * focalPointOnScreenY,
                        mZoomFocalPoint.x + newWidth * (1 - focalPointOnScreenX),
                        mZoomFocalPoint.y + newHeight * (1 - focalPointOnScreenY));
                constrainViewport();
                needsInvalidate = true;
            }

            if (needsInvalidate) {
                triggerViewportChangedListener();
                postAnimateTick();
            }
        }
    };

    /**
     * Computes the current scrollable surface size, in pixels. For example, if the entire chart
     * area is visible, this is simply the current view width and height. If the chart
     * is zoomed in 200% in both directions, the returned size will be twice as large horizontally
     * and vertically.
     */
    private void computeScrollSurfaceSize(Point out) {
        out.set((int) (mWidth / mCurrentViewport.width()), (int) (mHeight / mCurrentViewport.height()));
    }

    /**
     * Sets the current viewport (defined by {@link #mCurrentViewport}) to the given
     * X and Y positions. Note that the Y value represents the topmost pixel position, and thus
     * the bottom of the {@link #mCurrentViewport} rectangle. For more details on why top and
     * bottom are flipped, see {@link #mCurrentViewport}.
     */
    private void setViewportTopLeft(float x, float y) {
        /**
         * Constrains within the scroll range. The scroll range is simply the viewport extremes
         * (AXIS_X_MAX, etc.) minus the viewport size. For example, if the extrema were 0 and 10,
         * and the viewport size was 2, the scroll range would be 0 to 8.
         */

        float curWidth = mCurrentViewport.width();
        float curHeight = mCurrentViewport.height();
        x = Math.max(0, Math.min(x, 1 - curWidth));
        y = Math.max(0, Math.min(y, 1 - curHeight));

        mCurrentViewport.set(x, y, x + curWidth, y + curHeight);
        triggerViewportChangedListener();
    }

    ////////////////////////////////////////////////////////////////////////////////////////////////
    //
    //     Methods for programmatically changing the viewport
    //
    ////////////////////////////////////////////////////////////////////////////////////////////////

    /**
     * Returns the current viewport (visible extremes for the chart domain and range.)
     */
    public RectF getCurrentViewport() {
        return new RectF(mCurrentViewport);
    }

    /**
     * Sets the chart's current viewport.
     *
     * @see #getCurrentViewport()
     */
    public void setCurrentViewport(RectF viewport) {
        mCurrentViewport = viewport;
        constrainViewport();
        triggerViewportChangedListener();
    }

    private void triggerViewportChangedListener() {
        if (mOnViewportChangedListener != null) {
            mOnViewportChangedListener.onViewportChanged();
        }
    }

    ////////////////////////////////////////////////////////////////////////////////////////////////
    //
    //     Methods and classes related to view state persistence.
    //
    ////////////////////////////////////////////////////////////////////////////////////////////////

    @Override
    public Parcelable onSaveInstanceState() {
        Parcelable superState = super.onSaveInstanceState();
        SavedState ss = new SavedState(superState);
        ss.viewport = mCurrentViewport;
        return ss;
    }

    @Override
    public void onRestoreInstanceState(Parcelable state) {
        if (!(state instanceof SavedState)) {
            super.onRestoreInstanceState(state);
            return;
        }

        SavedState ss = (SavedState) state;
        super.onRestoreInstanceState(ss.getSuperState());

        mCurrentViewport = ss.viewport;
    }

    public void resetViewport(float relativeAspectRatio) {
        if (relativeAspectRatio <= 0) {
            throw new IllegalArgumentException("Relative aspect ratio should be > 0");
        }

        mRelativeAspectRatio = relativeAspectRatio;

        if (mRelativeAspectRatio > 1) {
            mCurrentViewport.set(0.5f - 0.5f / mRelativeAspectRatio, 0, 0.5f + 0.5f / mRelativeAspectRatio, 1);
        } else {
            mCurrentViewport.set(0, 0.5f - mRelativeAspectRatio / 2f, 1, 0.5f + mRelativeAspectRatio / 2f);
        }

        triggerViewportChangedListener();
    }

    public void setRelativeAspectRatio(float relativeAspectRatio) {
        mRelativeAspectRatio = relativeAspectRatio;
        constrainViewport();
        triggerViewportChangedListener();
    }

    public void setViewport(RectF viewport) {
        mCurrentViewport.set(viewport);
        triggerViewportChangedListener();
    }

    public void setMaxZoom(int maxZoom) {
        mMinViewportWidthOrHeight = 1f / maxZoom;
    }

    public void setOnViewportChangedListener(OnViewportChangedListener onViewportChangedListener) {
        mOnViewportChangedListener = onViewportChangedListener;
    }

    public void setOnOtherGestureListener(OnOtherGestureListener onOtherGestureListener) {
        mOnOtherGestureListener = onOtherGestureListener;
    }

    public void enablePanScale(boolean panScaleEnabled) {
        mPanScaleEnabled = panScaleEnabled;
    }

    /**
     * Persistent state that is saved by PanScaleProxyView.
     */
    public static class SavedState extends BaseSavedState {
        private RectF viewport;

        public SavedState(Parcelable superState) {
            super(superState);
        }

        @Override
        public void writeToParcel(Parcel out, int flags) {
            super.writeToParcel(out, flags);
            out.writeFloat(viewport.left);
            out.writeFloat(viewport.top);
            out.writeFloat(viewport.right);
            out.writeFloat(viewport.bottom);
        }

        @Override
        public String toString() {
            return "PanScaleProxyView.SavedState{" + Integer.toHexString(System.identityHashCode(this))
                    + " viewport=" + viewport.toString() + "}";
        }

        public static final Parcelable.Creator<SavedState> CREATOR = ParcelableCompat
                .newCreator(new ParcelableCompatCreatorCallbacks<SavedState>() {
                    @Override
                    public SavedState createFromParcel(Parcel in, ClassLoader loader) {
                        return new SavedState(in);
                    }

                    @Override
                    public SavedState[] newArray(int size) {
                        return new SavedState[size];
                    }
                });

        SavedState(Parcel in) {
            super(in);
            viewport = new RectF(in.readFloat(), in.readFloat(), in.readFloat(), in.readFloat());
        }
    }

    public static interface OnViewportChangedListener {
        public void onViewportChanged();
    }

    public static interface OnOtherGestureListener {
        public void onDown();

        public void onSingleTapUp();

        public void onUpNonSingleTap(boolean zoomedOut);
    }
}