public class GestureController implements View.OnTouchListener {

    private static final float ZOOM_GESTURE_MIN_SPAN_DP = 20f;
    private static final float FLING_COEFFICIENT = 0.9f;

    // Control constants converted to pixels
    private final float mZoomGestureMinSpan;
    private final int mTouchSlop, mMinimumVelocity, mMaximumVelocity;

    private final List<OnStateChangeListener> mStateListeners = new ArrayList<>();
    private OnGestureListener mGestureListener;

    private final AnimationEngine mAnimationEngine;

    // Various gesture detectors
    private final GestureDetector mGestureDetector;
    private final ScaleGestureDetector mScaleDetector;
    private final RotationGestureDetector mRotateDetector;

    private boolean mIsScrollDetected;
    private boolean mIsScaleDetected;
    private float mPivotX = Float.NaN, mPivotY = Float.NaN;
    private boolean mIsStateChangedDuringTouch;
    private boolean mIsRestrictZoomRequested;
    private boolean mIsRestrictRotationRequested;
    private boolean mIsAnimatingInBounds;

    private final OverScroller mFlingScroller;
    private final FloatScroller mStateScroller;

    private final MovementBounds mFlingBounds = new MovementBounds();
    private final State mPrevState = new State();
    private final State mStateStart = new State();
    private final State mStateEnd = new State();

    private final Settings mSettings;
    private final State mState = new State();
    private final StateController mStateController;

    public GestureController(@NonNull View view) {
        Context context = view.getContext();
        DisplayMetrics metrics = context.getResources().getDisplayMetrics();
        mZoomGestureMinSpan = TypedValue.applyDimension(
                TypedValue.COMPLEX_UNIT_DIP, ZOOM_GESTURE_MIN_SPAN_DP, metrics);

        mSettings = new Settings();
        mStateController = new StateController(mSettings);

        mAnimationEngine = new LocalAnimationEngine(view);
        InternalGesturesListener internalListener = new InternalGesturesListener();
        mGestureDetector = new GestureDetector(context, internalListener);
        mGestureDetector.setIsLongpressEnabled(false);
        mScaleDetector = new ScaleGestureDetectorFixed(context, internalListener);
        mRotateDetector = new RotationGestureDetector(context, internalListener);

        mFlingScroller = new OverScroller(context);
        mStateScroller = new FloatScroller();

        final ViewConfiguration configuration = ViewConfiguration.get(context);
        mTouchSlop = configuration.getScaledTouchSlop();
        mMinimumVelocity = configuration.getScaledMinimumFlingVelocity();
        mMaximumVelocity = configuration.getScaledMaximumFlingVelocity();
    }

    /**
     * Sets listener for basic touch events.
     * <p/>
     * See also {@link OnGestureListener}
     */
    @SuppressWarnings("unused") // Public API
    public void setOnGesturesListener(@Nullable OnGestureListener listener) {
        mGestureListener = listener;
    }

    /**
     * Adds listener for state changes.
     * <p/>
     * See also {@link OnStateChangeListener}
     */
    public void addOnStateChangeListener(OnStateChangeListener listener) {
        mStateListeners.add(listener);
    }

    /**
     * Removes listener for state changes.
     * <p/>
     * See also {@link #addOnStateChangeListener(OnStateChangeListener)}
     */
    @SuppressWarnings("unused") // Public API
    public void removeOnStateChangeListener(OnStateChangeListener listener) {
        mStateListeners.remove(listener);
    }

    /**
     * Sets whether long press is enabled or not. Long press is disabled by default.
     * <p/>
     * See also {@link OnGestureListener#onLongPress(android.view.MotionEvent)}
     */
    @SuppressWarnings("unused") // Public API
    public void setLongPressEnabled(boolean enabled) {
        mGestureDetector.setIsLongpressEnabled(enabled);
    }

    /**
     * Returns settings that can be updated.
     * <p/>
     * Note: call {@link #updateState()}, {@link #resetState()} or {@link #animateKeepInBounds()}
     * after settings was changed to correctly apply state restrictions.
     */
    public Settings getSettings() {
        return mSettings;
    }

    /**
     * Returns current state. In most cases you should not modify state directly,
     * use one of the methods provided in {@link StateController} instead.
     * <p/>
     * If current state was changed outside {@link GestureController}
     * you should call {@link GestureController#updateState()} or {@link #animateKeepInBounds()}
     * to properly apply changes.
     */
    public State getState() {
        return mState;
    }

    /**
     * Returns state controller that can be used externally.
     */
    public StateController getStateController() {
        return mStateController;
    }

    /**
     * Applies state restrictions and notifies
     * {@link OnStateChangeListener} listeners.
     */
    public void updateState() {
        boolean reset = mStateController.updateState(mState);
        if (reset) {
            notifyStateReset();
        } else {
            notifyStateUpdated();
        }
    }

    /**
     * Resets to initial state (default position, min zoom level) and notifies
     * {@link OnStateChangeListener} listeners.
     * <p/>
     * Should be called when image size is changed.
     * <p/>
     * See {@link Settings#setImage(int, int)}.
     */
    public void resetState() {
        stopAllAnimations();
        boolean reset = mStateController.resetState(mState);
        if (reset) {
            notifyStateReset();
        } else {
            notifyStateUpdated();
        }
    }

    /**
     * Sets pivot point for zooming when keeping image in bounds.
     *
     * @see #animateStateTo(State)
     * @see #animateKeepInBounds()
     */
    public void setPivot(float pivotX, float pivotY) {
        mPivotX = pivotX;
        mPivotY = pivotY;
    }

    /**
     * Animates current state to provided end state.
     *
     * @return {@code true} if animation started, {@code false} otherwise. Animation may
     * not be started if end state is {@code null} or equals to current state (after bounds
     * restrictions are applied).
     */
    public boolean animateStateTo(@Nullable State endState) {
        return animateStateTo(endState, true);
    }

    /**
     * Animates to correct position withing bounds.
     *
     * @return {@code true} if animation started, {@code false} otherwise. Animation may
     * not be started if image already withing bounds.
     */
    public boolean animateKeepInBounds() {
        return animateStateTo(mState, true);
    }

    private boolean animateStateTo(@Nullable State endState, boolean keepInBounds) {
        if (endState == null) {
            return false;
        }

        State endStateFixed = null;
        if (keepInBounds) {
            endStateFixed = mStateController.restrictStateBoundsCopy(
                    endState, mPrevState, mPivotX, mPivotY, false, false, true);
        }
        if (endStateFixed == null) {
            endStateFixed = endState;
        }

        if (endStateFixed.equals(mState)) {
            return false; // Nothing to animate
        }

        stopAllAnimations();

        mIsAnimatingInBounds = keepInBounds;
        mStateStart.set(mState);
        mStateEnd.set(endStateFixed);
        mStateScroller.startScroll(0f, 1f);
        mAnimationEngine.start();

        return true;
    }

    public boolean isAnimatingState() {
        return !mStateScroller.isFinished();
    }

    public boolean isAnimatingFling() {
        return !mFlingScroller.isFinished();
    }

    @SuppressWarnings("unused") // Public API
    public boolean isAnimating() {
        return isAnimatingState() || isAnimatingFling();
    }

    public void stopStateAnimation() {
        if (isAnimatingState()) {
            mStateScroller.forceFinished();
            onStateAnimationFinished(true);
        }
    }

    public void stopFlingAnimation() {
        if (isAnimatingFling()) {
            mFlingScroller.forceFinished(true);
            onFlingAnimationFinished(true);
        }
    }

    public void stopAllAnimations() {
        stopStateAnimation();
        stopFlingAnimation();
    }

    @SuppressWarnings("UnusedParameters") // Public API (can be overridden)
    protected void onStateAnimationFinished(boolean forced) {
        mIsAnimatingInBounds = false;
    }

    protected void onFlingAnimationFinished(boolean forced) {
        if (!forced) {
            animateKeepInBounds();
        }
    }

    protected void notifyStateUpdated() {
        mPrevState.set(mState);
        for (OnStateChangeListener listener : mStateListeners) {
            listener.onStateChanged(mState);
        }
    }

    protected void notifyStateReset() {
        for (OnStateChangeListener listener : mStateListeners) {
            listener.onStateReset(mPrevState, mState);
        }
        notifyStateUpdated();
    }


    // -------------------
    //  Gestures handling
    // -------------------

    @Override
    public boolean onTouch(@NonNull View view, @NonNull MotionEvent event) {
        MotionEvent viewportEvent = MotionEvent.obtain(event);
        viewportEvent.offsetLocation(-view.getPaddingLeft(), -view.getPaddingTop());

        boolean result = mGestureDetector.onTouchEvent(viewportEvent);
        result |= mScaleDetector.onTouchEvent(viewportEvent);
        result |= mRotateDetector.onTouchEvent(viewportEvent);

        if (mIsStateChangedDuringTouch) {
            mIsStateChangedDuringTouch = false;

            mStateController.restrictStateBounds(
                    mState, mPrevState, mPivotX, mPivotY, true, true, false);

            if (!mState.equals(mPrevState)) {
                notifyStateUpdated();
            }
        }

        if (mIsRestrictZoomRequested || mIsRestrictRotationRequested) {
            mIsRestrictZoomRequested = false;
            mIsRestrictRotationRequested = false;

            State restrictedState = mStateController.restrictStateBoundsCopy(
                    mState, mPrevState, mPivotX, mPivotY, true, false, true);
            animateStateTo(restrictedState, false);
        }

        if (viewportEvent.getActionMasked() == MotionEvent.ACTION_UP
                || viewportEvent.getActionMasked() == MotionEvent.ACTION_CANCEL) {
            onUpOrCancel(viewportEvent);
        }

        viewportEvent.recycle();

        return result;
    }

    protected boolean onDown(@NonNull MotionEvent e) {
        stopFlingAnimation();

        mIsScrollDetected = false;
        mIsScaleDetected = false;

        if (mGestureListener != null) {
            mGestureListener.onDown(e);
        }

        return mSettings.isEnabled();
    }

    protected void onUpOrCancel(@NonNull MotionEvent e) {
        if (!isAnimatingFling() && !mIsAnimatingInBounds) {
            animateKeepInBounds();
        }

        if (mGestureListener != null) {
            mGestureListener.onUpOrCancel(e);
        }
    }

    protected boolean onSingleTapUp(@NonNull MotionEvent e) {
        return mGestureListener != null && mGestureListener.onSingleTapUp(e);
    }

    protected void onLongPress(@NonNull MotionEvent e) {
        if (mGestureListener != null) {
            mGestureListener.onLongPress(e);
        }
    }

    protected boolean onScroll(@NonNull MotionEvent e1, @NonNull MotionEvent e2,
            float dX, float dY) {

        if (!mSettings.isPanEnabled() || isAnimatingState()) {
            return false;
        }

        if (!mIsScrollDetected) {
            mIsScrollDetected = Math.abs(e2.getX() - e1.getX()) > mTouchSlop
                    || Math.abs(e2.getY() - e1.getY()) > mTouchSlop;

            // First scroll event can stutter a bit, so we will ignore it for smoother scrolling
            if (mIsScrollDetected) {
                return true;
            }
        }

        if (mIsScrollDetected) {
            // Only scrolling if we are not zoomed less than min zoom
            if (State.compare(mState.getZoom(), mStateController.getEffectiveMinZoom()) >= 0) {
                mState.translateBy(-dX, -dY);
                mIsStateChangedDuringTouch = true;
            }
        }

        return mIsScrollDetected;
    }

    protected boolean onFling(@NonNull MotionEvent e1, @NonNull MotionEvent e2,
            float vX, float vY) {

        if (!mSettings.isPanEnabled() || isAnimatingState()) {
            return false;
        }

        stopFlingAnimation();

        // Fling bounds including current position
        mFlingBounds.set(mStateController.getMovementBounds(mState));
        mFlingBounds.union(mState.getX(), mState.getY());

        mFlingScroller.fling(
                Math.round(mState.getX()), Math.round(mState.getY()),
                limitFlingVelocity(vX * FLING_COEFFICIENT),
                limitFlingVelocity(vY * FLING_COEFFICIENT),
                Integer.MIN_VALUE, Integer.MAX_VALUE,
                Integer.MIN_VALUE, Integer.MAX_VALUE);
        mAnimationEngine.start();

        return true;
    }

    private int limitFlingVelocity(float velocity) {
        if (Math.abs(velocity) < mMinimumVelocity) {
            return 0;
        } else if (Math.abs(velocity) >= mMaximumVelocity) {
            return (int) Math.signum(velocity) * mMaximumVelocity;
        } else {
            return Math.round(velocity);
        }
    }

    /**
     * @return true if state was changed, false otherwise
     */
    protected boolean onFlingScroll(int dX, int dY) {
        float prevX = mState.getX(), prevY = mState.getY();
        float x = prevX + dX, y = prevY + dY;

        if (mSettings.isRestrictBounds()) {
            PointF pos = mFlingBounds.restrict(x, y);
            x = pos.x;
            y = pos.y;
        }

        mState.translateTo(x, y);
        return !State.equals(prevX, x) || !State.equals(prevY, y);
    }

    protected boolean onSingleTapConfirmed(MotionEvent e) {
        return mGestureListener != null && mGestureListener.onSingleTapConfirmed(e);
    }

    protected boolean onDoubleTapEvent(MotionEvent e) {
        if (!mSettings.isDoubleTapEnabled()) {
            return false;
        }

        if (e.getActionMasked() != MotionEvent.ACTION_UP) {
            return false;
        }

        // ScaleGestureDetector can perform zoom by "double tap & drag" since KITKAT,
        // so we should suppress our double tap in this case
        if (mIsScaleDetected) {
            return false;
        }

        // Let user redefine double tap
        if (mGestureListener != null && mGestureListener.onDoubleTap(e)) {
            return true;
        }

        animateStateTo(mStateController.toggleMinMaxZoom(mState, e.getX(), e.getY()));
        return true;
    }

    protected boolean onScaleBegin(ScaleGestureDetector detector) {
        mIsScaleDetected = mSettings.isZoomEnabled();
        return mIsScaleDetected;
    }

    protected boolean onScale(ScaleGestureDetector detector) {
        if (mSettings.isZoomEnabled() && !isAnimatingState()) {
            if (detector.getCurrentSpan() > mZoomGestureMinSpan) {
                mPivotX = detector.getFocusX();
                mPivotY = detector.getFocusY();
                mState.zoomBy(detector.getScaleFactor(), mPivotX, mPivotY);
                mIsStateChangedDuringTouch = true;
            }
        }

        return true;
    }

    @SuppressWarnings("UnusedParameters") // Public API (can be overridden)
    protected void onScaleEnd(ScaleGestureDetector detector) {
        mIsScaleDetected = false;
        mIsRestrictZoomRequested = true;
    }

    protected boolean onRotationBegin(RotationGestureDetector detector) {
        return mSettings.isRotationEnabled();
    }

    protected boolean onRotate(RotationGestureDetector detector) {
        if (mSettings.isRotationEnabled() && !isAnimatingState()) {
            mPivotX = detector.getFocusX();
            mPivotY = detector.getFocusY();
            mState.rotateBy(detector.getRotationDelta(), mPivotX, mPivotY);
            mIsStateChangedDuringTouch = true;
        }

        return true;
    }

    @SuppressWarnings("UnusedParameters") // Public API (can be overridden)
    protected void onRotationEnd(RotationGestureDetector detector) {
        mIsRestrictRotationRequested = true;
    }

    /**
     * Animation engine implementation to animate state changes
     */
    private class LocalAnimationEngine extends AnimationEngine {
        public LocalAnimationEngine(@NonNull View view) {
            super(view);
        }

        @Override
        public boolean onStep() {
            boolean shouldProceed = false;

            if (isAnimatingFling()) {
                int prevX = mFlingScroller.getCurrX(), prevY = mFlingScroller.getCurrY();

                if (mFlingScroller.computeScrollOffset()) {
                    int dX = mFlingScroller.getCurrX() - prevX;
                    int dY = mFlingScroller.getCurrY() - prevY;

                    if (!onFlingScroll(dX, dY)) {
                        stopFlingAnimation();
                    }

                    shouldProceed = true;
                }

                if (!isAnimatingFling()) {
                    onFlingAnimationFinished(false);
                }
            }

            if (isAnimatingState()) {
                mStateScroller.computeScroll();
                float factor = mStateScroller.getCurr();
                StateController.interpolate(mState, mStateStart, mStateEnd, factor);
                shouldProceed = true;

                if (!isAnimatingState()) {
                    onStateAnimationFinished(false);
                }
            }

            if (shouldProceed) {
                notifyStateUpdated();
            }

            return shouldProceed;
        }
    }


    // -------------------
    //  Listeners
    // -------------------

    /**
     * State changes listener
     */
    public interface OnStateChangeListener {
        void onStateChanged(State state);

        void onStateReset(State oldState, State newState);
    }

    /**
     * Listener for different touch events
     */
    public interface OnGestureListener {
        /**
         * See {@link GestureDetector.OnGestureListener#onDown(MotionEvent)}
         */
        void onDown(@NonNull MotionEvent e);

        /**
         * See {@link GestureDetector.OnGestureListener#onDown(MotionEvent)}
         */
        void onUpOrCancel(@NonNull MotionEvent e);

        /**
         * See {@link GestureDetector.OnGestureListener#onSingleTapUp(MotionEvent)}
         *
         * @return true if event was consumed, false otherwise
         */
        boolean onSingleTapUp(@NonNull MotionEvent e);

        /**
         * See {@link GestureDetector.OnDoubleTapListener#onSingleTapConfirmed(MotionEvent)}
         *
         * @return true if event was consumed, false otherwise
         */
        boolean onSingleTapConfirmed(@NonNull MotionEvent e);

        /**
         * See {@link GestureDetector.OnGestureListener#onLongPress(MotionEvent)}.
         * <p/>
         * Note, that long press is disabled by default, use {@link #setLongPressEnabled(boolean)}
         * to enable it.
         */
        void onLongPress(@NonNull MotionEvent e);

        /**
         * See {@link GestureDetector.OnDoubleTapListener#onDoubleTap(MotionEvent)}
         *
         * @return true if event was consumed, false otherwise
         */
        boolean onDoubleTap(@NonNull MotionEvent e);
    }

    /**
     * Simple implementation of {@link OnGestureListener}
     */
    public static class SimpleOnGestureListener implements OnGestureListener {
        /**
         * {@inheritDoc}
         */
        @Override
        public void onDown(@NonNull MotionEvent e) {
            // no-op
        }

        /**
         * {@inheritDoc}
         */
        @Override
        public void onUpOrCancel(@NonNull MotionEvent e) {
            // no-op
        }

        /**
         * {@inheritDoc}
         */
        @Override
        public boolean onSingleTapUp(@NonNull MotionEvent e) {
            return false;
        }

        /**
         * {@inheritDoc}
         */
        @Override
        public boolean onSingleTapConfirmed(@NonNull MotionEvent e) {
            return false;
        }

        /**
         * {@inheritDoc}
         */
        @Override
        public void onLongPress(@NonNull MotionEvent e) {
            // no-op
        }

        /**
         * {@inheritDoc}
         */
        @Override
        public boolean onDoubleTap(@NonNull MotionEvent e) {
            return false;
        }
    }


    /**
     * All listeners in one class.
     * It will also allow us to make all methods protected to cleanup public API.
     */
    private class InternalGesturesListener implements
            GestureDetector.OnGestureListener,
            GestureDetector.OnDoubleTapListener,
            ScaleGestureDetector.OnScaleGestureListener,
            RotationGestureDetector.OnRotationGestureListener {

        @Override
        public boolean onSingleTapConfirmed(@NonNull MotionEvent e) {
            return GestureController.this.onSingleTapConfirmed(e);
        }

        @Override
        public boolean onDoubleTap(@NonNull MotionEvent e) {
            return false;
        }

        @Override
        public boolean onDoubleTapEvent(@NonNull MotionEvent e) {
            return GestureController.this.onDoubleTapEvent(e);
        }

        @Override
        public boolean onDown(@NonNull MotionEvent e) {
            return GestureController.this.onDown(e);
        }

        @Override
        public void onShowPress(@NonNull MotionEvent e) {
            // No-op
        }

        @Override
        public boolean onSingleTapUp(@NonNull MotionEvent e) {
            return GestureController.this.onSingleTapUp(e);
        }

        @Override
        public boolean onScroll(@NonNull MotionEvent e1, @NonNull MotionEvent e2,
                float distanceX, float distanceY) {
            return GestureController.this.onScroll(e1, e2, distanceX, distanceY);
        }

        @Override
        public void onLongPress(@NonNull MotionEvent e) {
            GestureController.this.onLongPress(e);
        }

        @Override
        public boolean onFling(@NonNull MotionEvent e1, @NonNull MotionEvent e2,
                float velocityX, float velocityY) {
            return GestureController.this.onFling(e1, e2, velocityX, velocityY);
        }

        @Override
        public boolean onRotate(@NonNull RotationGestureDetector detector) {
            return GestureController.this.onRotate(detector);
        }

        @Override
        public boolean onRotationBegin(@NonNull RotationGestureDetector detector) {
            return GestureController.this.onRotationBegin(detector);
        }

        @Override
        public void onRotationEnd(@NonNull RotationGestureDetector detector) {
            GestureController.this.onRotationEnd(detector);
        }

        @Override
        public boolean onScale(@NonNull ScaleGestureDetector detector) {
            return GestureController.this.onScale(detector);
        }

        @Override
        public boolean onScaleBegin(@NonNull ScaleGestureDetector detector) {
            return GestureController.this.onScaleBegin(detector);
        }

        @Override
        public void onScaleEnd(@NonNull ScaleGestureDetector detector) {
            GestureController.this.onScaleEnd(detector);
        }

    }

} public class GestureControllerForPager extends GestureController {

    private static final float SCROLL_THRESHOLD = 15f;
    private static final float OVERSCROLL_THRESHOLD_FACTOR = 4f;

    /**
     * Because ViewPager will immediately return true from onInterceptTouchEvent() method during
     * settling animation, we will have no chance to prevent it from doing this.
     * But this listener will be called if ViewPager intercepted touch event,
     * so we can try fix this behavior here.
     */
    private static final View.OnTouchListener PAGER_TOUCH_LISTENER = new View.OnTouchListener() {
        private boolean mIsTouchInProgress;

        @Override
        public boolean onTouch(View view, @NonNull MotionEvent e) {
            // ViewPager will steal touch events during settling regardless of
            // requestDisallowInterceptTouchEvent. We will prevent it here.
            if (!mIsTouchInProgress && e.getActionMasked() == MotionEvent.ACTION_DOWN) {
                mIsTouchInProgress = true;
                // Now ViewPager is in drag mode, so it should not intercept DOWN event
                view.dispatchTouchEvent(e);
                mIsTouchInProgress = false;
                return true;
            }

            // User can touch outside of child view, so we will not have a chance to settle
            // ViewPager. If so, this listener should be called and we will be able to settle
            // ViewPager manually.
            settleViewPagerIfFinished((ViewPager) view, e);

            return true; // We should skip view pager touches to prevent some subtle bugs
        }
    };

    private static final int[] TMP_LOCATION = new int[2];
    private static final Matrix TMP_MATRIX = new Matrix();

    private final int mTouchSlop;

    private ViewPager mViewPager;
    private boolean mIsViewPagerDisabled;

    private boolean mIsScrollGestureDetected;
    private boolean mIsSkipViewPager;

    private int mViewPagerX;
    private float mViewPagerSkippedX;
    private boolean mIsViewPagerInterceptedScroll;
    private float mLastViewPagerEventX;

    public GestureControllerForPager(@NonNull View view) {
        super(view);
        mTouchSlop = ViewConfiguration.get(view.getContext()).getScaledTouchSlop();
    }

    /**
     * Enables scroll inside {@link ViewPager}
     * (by enabling cross movement between ViewPager and it's child view)
     */
    public void enableScrollInViewPager(ViewPager pager) {
        mViewPager = pager;
        pager.setOnTouchListener(PAGER_TOUCH_LISTENER);

        // Disabling motion event splitting
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB) {
            pager.setMotionEventSplittingEnabled(false);
        }
    }

    /**
     * Disables ViewPager scroll. Default is false.
     */
    public void disableViewPager(boolean disable) {
        mIsViewPagerDisabled = disable;
    }

    @Override
    public boolean onTouch(@NonNull View view, @NonNull MotionEvent event) {
        if (mViewPager == null) {
            return super.onTouch(view, event);
        } else {
            // Getting motiona event in pager coordinates
            MotionEvent pagerEvent = MotionEvent.obtain(event);
            transformToPagerEvent(pagerEvent, view, mViewPager);

            handleTouch(pagerEvent);

            boolean result = super.onTouch(view, pagerEvent);
            pagerEvent.recycle();
            return result;
        }
    }

    private void handleTouch(MotionEvent event) {
        switch (event.getActionMasked()) {
            case MotionEvent.ACTION_POINTER_DOWN:
                if (event.getPointerCount() == 2) { // on first non-primary pointer
                    // Skipping ViewPager fake dragging if we're not started dragging yet
                    // to allow scale/rotation gestures
                    mIsSkipViewPager = !hasViewPagerX();
                }
                break;
        }
    }

    @Override
    protected boolean onDown(@NonNull MotionEvent e) {
        if (mViewPager == null) {
            return super.onDown(e);
        }

        mViewPager.requestDisallowInterceptTouchEvent(true);

        mIsSkipViewPager = false;
        mIsViewPagerInterceptedScroll = false;
        mIsScrollGestureDetected = false;

        mViewPagerX = computeInitialViewPagerScroll(e);
        mLastViewPagerEventX = e.getX();
        mViewPagerSkippedX = 0f;

        passEventToViewPager(e);
        super.onDown(e);
        return true;
    }

    @Override
    protected void onUpOrCancel(@NonNull MotionEvent e) {
        passEventToViewPager(e);
        super.onUpOrCancel(e);
    }

    @Override
    protected boolean onScroll(@NonNull MotionEvent e1, @NonNull MotionEvent e2,
            float dX, float dY) {

        if (mViewPager == null) {
            return super.onScroll(e1, e2, dX, dY);
        } else {
            if (!mIsScrollGestureDetected) {
                mIsScrollGestureDetected = true;
                // First scroll event can stutter a bit, so we will ignore it for smoother scrolling
                return true;
            }

            // Splitting movement between pager and view
            float fixedDistanceX = -scrollBy(e2, -dX);
            // Skipping vertical movement if ViewPager is dragged
            float fixedDistanceY = hasViewPagerX() ? 0f : dY;

            return super.onScroll(e1, e2, fixedDistanceX, fixedDistanceY);
        }
    }

    @Override
    protected boolean onFling(@NonNull MotionEvent e1, @NonNull MotionEvent e2,
            float vX, float vY) {

        return !hasViewPagerX() && super.onFling(e1, e2, vX, vY);
    }

    @Override
    protected boolean onScaleBegin(@NonNull ScaleGestureDetector detector) {
        return !hasViewPagerX() && super.onScaleBegin(detector);
    }

    @Override
    protected boolean onRotationBegin(@NonNull RotationGestureDetector detector) {
        return !hasViewPagerX() && super.onRotationBegin(detector);
    }

    @Override
    protected boolean onDoubleTapEvent(@NonNull MotionEvent e) {
        return !hasViewPagerX() && super.onDoubleTapEvent(e);
    }

    /**
     * Scrolls ViewPager if view reached bounds. Returns distance at which view can be actually
     * scrolled. Here we will split given distance (dX) into movement of ViewPager and movement
     * of view itself.
     */
    private float scrollBy(@NonNull MotionEvent e, float dX) {
        if (mIsSkipViewPager || mIsViewPagerDisabled) {
            return dX;
        }

        final State state = getState();
        final RectF movBounds = getStateController().getMovementBounds(state).getExternalBounds();

        float dPagerX = splitPagerScroll(dX, state, movBounds);
        dPagerX = skipPagerMovement(dPagerX, state, movBounds);
        float dViewX = dX - dPagerX;

        // Applying pager scroll
        boolean shouldFixViewX = mIsViewPagerInterceptedScroll && mViewPagerX == 0;
        int actualX = performViewPagerScroll(e, dPagerX);
        mViewPagerX += actualX;
        if (shouldFixViewX) { // Adding back scroll not handled by ViewPager
            dViewX += Math.round(dPagerX) - actualX;
        }

        // Returning altered scroll left for image
        return dViewX;
    }

    /**
     * Splits x scroll between viewpager and view
     */
    private float splitPagerScroll(float dX, State state, RectF movBounds) {
        if (getSettings().isPanEnabled()) {
            final float dir = Math.signum(dX);
            final float movementX = Math.abs(dX); // always >= 0, no direction info

            final float viewX = state.getX();
            // available movement distances (always >= 0, no direction info)
            float availableViewX = dir < 0 ? viewX - movBounds.left : movBounds.right - viewX;
            float availablePagerX = dir * mViewPagerX < 0 ? Math.abs(mViewPagerX) : 0;

            // Not available if already overscrolled in same direction
            if (availableViewX < 0) {
                availableViewX = 0;
            }

            float pagerMovementX;
            if (availablePagerX >= movementX) {
                // Only ViewPager is moved
                pagerMovementX = movementX;
            } else if (availableViewX + availablePagerX >= movementX) {
                // Moving pager for full available distance and moving view for remaining distance
                pagerMovementX = availablePagerX;
            } else {
                // Moving view for full available distance and moving pager for remaining distance
                pagerMovementX = movementX - availableViewX;
            }

            return pagerMovementX * dir; // Applying direction
        } else {
            return dX;
        }
    }

    /**
     * Skips part of pager movement to make it harder scrolling pager when image is zoomed
     * or when image is over-scrolled in y direction
     */
    private float skipPagerMovement(float dPagerX, State state, RectF movBounds) {
        float overscrollDist = getSettings().getOverscrollDistanceY() * OVERSCROLL_THRESHOLD_FACTOR;

        float overscrollThreshold = 0f;
        if (state.getY() < movBounds.top) {
            overscrollThreshold = (movBounds.top - state.getY()) / overscrollDist;
        } else if (state.getY() > movBounds.bottom) {
            overscrollThreshold = (state.getY() - movBounds.bottom) / overscrollDist;
        }

        float minZoom = getStateController().getEffectiveMinZoom();
        float zoomThreshold = minZoom == 0f ? 0f : state.getZoom() / minZoom - 1f;

        float pagerThreshold = Math.max(overscrollThreshold, zoomThreshold);
        pagerThreshold = (float) Math.sqrt(Math.max(0f, Math.min(pagerThreshold, 1f)));
        pagerThreshold *= SCROLL_THRESHOLD * mTouchSlop;

        // Resetting skipped amount when starting scrolling in different direction
        if (mViewPagerSkippedX * dPagerX < 0f && mViewPagerX == 0) {
            mViewPagerSkippedX = 0f;
        }

        // Ensuring we have full skipped amount if pager is scrolled
        if (hasViewPagerX()) {
            mViewPagerSkippedX = pagerThreshold * Math.signum(mViewPagerX);
        }

        // Skipping pager movement and accumulating skipped amount, if not passed threshold
        if (Math.abs(mViewPagerSkippedX) < pagerThreshold && dPagerX * mViewPagerSkippedX >= 0) {
            mViewPagerSkippedX += dPagerX;
            // Reverting over-skipped amount
            float over = Math.abs(mViewPagerSkippedX) - pagerThreshold;
            over = Math.max(0f, over) * Math.signum(dPagerX);

            mViewPagerSkippedX -= over;
            return over;
        } else {
            return dPagerX;
        }
    }

    private int computeInitialViewPagerScroll(MotionEvent downEvent) {
        // ViewPager can be in intermediate position, we should compute correct initial scroll
        int scroll = mViewPager.getScrollX();
        int pageWidth = mViewPager.getWidth() + mViewPager.getPageMargin();

        // After state restore ViewPager can return negative scroll, let's fix it
        while (scroll < 0) {
            scroll += pageWidth;
        }

        int touchedItem = (int) ((scroll + downEvent.getX()) / pageWidth);
        return pageWidth * touchedItem - scroll;
    }

    private boolean hasViewPagerX() {
        // Looks like ViewPager has a rounding issue (it may be off by 1 in settled state)
        return mViewPagerX < -1 || mViewPagerX > 1;
    }

    /**
     * Manually scrolls ViewPager and returns actual distance at which pager was scrolled
     */
    private int performViewPagerScroll(@NonNull MotionEvent e, float dPagerX) {
        int scrollBegin = mViewPager.getScrollX();
        mLastViewPagerEventX += dPagerX;
        passEventToViewPager(e);
        return scrollBegin - mViewPager.getScrollX();
    }

    private void passEventToViewPager(@NonNull MotionEvent e) {
        if (mViewPager == null) {
            return;
        }

        MotionEvent fixedEvent = obtainOnePointerEvent(e);
        fixedEvent.setLocation(mLastViewPagerEventX, 0f);

        if (mIsViewPagerInterceptedScroll) {
            mViewPager.onTouchEvent(fixedEvent);
        } else {
            mIsViewPagerInterceptedScroll = mViewPager.onInterceptTouchEvent(fixedEvent);
        }

        // If ViewPager intercepted touch it will settle itself automatically,
        // but if touch was not intercepted we should settle it manually
        if (!mIsViewPagerInterceptedScroll && hasViewPagerX()) {
            settleViewPagerIfFinished(mViewPager, e);
        }

        // Hack: ViewPager has bug when endFakeDrag() does not work properly. But we need to ensure
        // ViewPager is not in fake drag mode after settleViewPagerIfFinished()
        try {
            if (mViewPager != null && mViewPager.isFakeDragging()) {
                mViewPager.endFakeDrag();
            }
        } catch (Exception ignored) {
        }

        fixedEvent.recycle();
    }

    private static MotionEvent obtainOnePointerEvent(@NonNull MotionEvent e) {
        return MotionEvent.obtain(e.getDownTime(), e.getEventTime(), e.getAction(),
                e.getX(), e.getY(), e.getMetaState());
    }

    private static void settleViewPagerIfFinished(ViewPager pager, @NonNull MotionEvent e) {
        if (e.getActionMasked() == MotionEvent.ACTION_UP ||
                e.getActionMasked() == MotionEvent.ACTION_CANCEL) {
            // Hack: if ViewPager is not settled we should force it to do so, fake drag will help
            try {
                // Pager may throw an annoying exception if there are no internal page state items
                pager.beginFakeDrag();
                if (pager.isFakeDragging()) {
                    pager.endFakeDrag();
                }
            } catch (Exception ignored) {
            }
        }
    }

    private static void transformToPagerEvent(MotionEvent event, View view, ViewPager pager) {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB) {
            TMP_MATRIX.reset();
            transformMatrixToPager(TMP_MATRIX, view, pager);
            event.transform(TMP_MATRIX);
        } else {
            view.getLocationOnScreen(TMP_LOCATION);
            event.offsetLocation(TMP_LOCATION[0], TMP_LOCATION[1]);
            pager.getLocationOnScreen(TMP_LOCATION);
            event.offsetLocation(-TMP_LOCATION[0], -TMP_LOCATION[1]);
        }
    }

    /**
     * Inspired by hidden method View#transformMatrixToGlobal().
     */
    @TargetApi(Build.VERSION_CODES.HONEYCOMB)
    private static void transformMatrixToPager(Matrix matrix, View view, ViewPager pager) {
        View parent = (View) view.getParent();
        if (parent != null && parent != pager) {
            transformMatrixToPager(matrix, parent, pager);
        }
        if (parent != null) {
            matrix.preTranslate(-parent.getScrollX(), -parent.getScrollY());
        }

        matrix.preTranslate(view.getLeft(), view.getTop());
        matrix.preConcat(view.getMatrix());
    }

} public class Settings {

    public static final float MAX_ZOOM = 2f;
    public static final float OVERZOOM_FACTOR = 2f;

    /**
     * Viewport area
     */
    private int viewportW, viewportH;

    /**
     * Moving area
     */
    private int movementAreaW, movementAreaH;

    private boolean isMovementAreaSpecified;

    /**
     * Image size
     */
    private int imageW, imageH;

    /**
     * Max zoom level, default value is {@link #MAX_ZOOM}
     */
    private float maxZoom = MAX_ZOOM;

    /**
     * Overzoom factor
     */
    private float overzoomFactor = OVERZOOM_FACTOR;

    /**
     * Overscroll distance
     */
    private float overscrollDistanceX, overscrollDistanceY;

    /**
     * If isFillViewport = true small image will be scaled to fit entire viewport
     * even if it will require zoom level above max zoom level
     */
    private boolean isFillViewport = false;

    /**
     * Image gravity inside viewport area
     */
    private int gravity = Gravity.CENTER;

    /**
     * Initial fitting within viewport area
     */
    private Fit fitMethod = Fit.INSIDE;

    /**
     * Whether panning is enabled or not
     */
    private boolean isPanEnabled = true;

    /**
     * Whether zooming is enabled or not
     */
    private boolean isZoomEnabled = true;

    /**
     * Whether rotation gesture is enabled or not
     */
    private boolean isRotationEnabled = false;

    /**
     * Whether zooming by double tap is enabled or not
     */
    private boolean isDoubleTapEnabled = true;

    /**
     * Counter for gestures disable calls
     */
    private int gesturesDisableCount;

    /**
     * Whether image transformations should be kept in bounds or not
     */
    private boolean isRestrictBounds = true;

    /**
     * Whether image rotation should stick to 90 degrees or can be free
     */
    private boolean isRestrictRotation = false;

    Settings() {
        // Package private constructor
    }

    /**
     * Setting viewport size.
     * <p/>
     * Should only be used when implementing custom {@link GestureView}.
     */
    public Settings setViewport(int w, int h) {
        viewportW = w;
        viewportH = h;
        return this;
    }

    /**
     * Setting movement area size. Viewport area will be used instead if no movement area is
     * specified.
     */
    public Settings setMovementArea(int w, int h) {
        isMovementAreaSpecified = true;
        movementAreaW = w;
        movementAreaH = h;
        return this;
    }

    /**
     * Setting full image size.
     * <p/>
     * Should only be used when implementing custom {@link GestureView}.
     */
    public Settings setImage(int w, int h) {
        imageW = w;
        imageH = h;
        return this;
    }

    /**
     * Setting max zoom level.
     * <p/>
     * Default value is {@link #MAX_ZOOM}.
     */
    public Settings setMaxZoom(float maxZoom) {
        this.maxZoom = maxZoom;
        return this;
    }

    /**
     * Setting overzoom factor. User will be able to "over zoom" up to this factor. Cannot be < 1.
     * <p/>
     * Default value is {@link #OVERZOOM_FACTOR}.
     */
    public Settings setOverzoomFactor(float factor) {
        if (factor < 1f) {
            throw new IllegalArgumentException("Overzoom factor cannot be < 1");
        }
        overzoomFactor = factor;
        return this;
    }

    /**
     * Setting overscroll distance in pixels. User will be able to "over scroll"
     * up to this distance. Cannot be < 0.
     * <p/>
     * Default value is 0.
     */
    public Settings setOverscrollDistance(float distanceX, float distanceY) {
        if (distanceX < 0f || distanceY < 0f) {
            throw new IllegalArgumentException("Overscroll distance cannot be < 0");
        }
        overscrollDistanceX = distanceX;
        overscrollDistanceY = distanceY;
        return this;
    }

    /**
     * Same as {@link #setOverscrollDistance(float, float)} but accepts distance in DP
     */
    public Settings setOverscrollDistance(Context context, float distanceXDp, float distanceYDp) {
        return setOverscrollDistance(toPixels(context, distanceXDp),
                toPixels(context, distanceYDp));
    }

    /**
     * If set to true small image will be scaled to fit entire viewport (or entire movement area
     * if it was set) even if this will require zoom level above max zoom level.
     * <p/>
     * Default value is false.
     */
    public Settings setFillViewport(boolean isFitViewport) {
        this.isFillViewport = isFitViewport;
        return this;
    }

    /**
     * Setting image gravity inside viewport area.
     * <p/>
     * Default value is {@link android.view.Gravity#CENTER}.
     */
    public Settings setGravity(int gravity) {
        this.gravity = gravity;
        return this;
    }

    /**
     * Setting image fitting method within viewport area.
     * <p/>
     * Default value is {@link Fit#INSIDE}.
     */
    public Settings setFitMethod(@NonNull Fit fitMethod) {
        this.fitMethod = fitMethod;
        return this;
    }

    /**
     * Sets whether panning is enabled or not.
     * <p/>
     * Default value is true.
     */
    public Settings setPanEnabled(boolean enabled) {
        isPanEnabled = enabled;
        return this;
    }

    /**
     * Sets whether zooming is enabled or not.
     * <p/>
     * Default value is true.
     */
    public Settings setZoomEnabled(boolean enabled) {
        isZoomEnabled = enabled;
        return this;
    }

    /**
     * Sets whether rotation gesture is enabled or not.
     * <p/>
     * Default value is false.
     */
    public Settings setRotationEnabled(boolean enabled) {
        isRotationEnabled = enabled;
        return this;
    }

    /**
     * Sets whether zooming by double tap is enabled or not.
     * <p/>
     * Default value is true.
     */
    public Settings setDoubleTapEnabled(boolean enabled) {
        isDoubleTapEnabled = enabled;
        return this;
    }

    /**
     * Disable all gestures.<br/>
     * Calls to this method are counted, so if you called it N times
     * you should call {@link #enableGestures()} N times to re-enable all gestures.
     * <p/>
     * Useful when you need temporary disable touch gestures during animation or image loading.
     * <p/>
     * See also {@link #enableGestures()}
     */
    public Settings disableGestures() {
        gesturesDisableCount++;
        return this;
    }

    /**
     * Re-enable all gestures disabled by {@link #disableGestures()} method.<br/>
     * Calls to this method are counted, so if you called {@link #disableGestures()} N times
     * you should call this method N times to re-enable all gestures.
     * <p/>
     * See also {@link #disableGestures()}
     */
    public Settings enableGestures() {
        gesturesDisableCount--;
        return this;
    }

    /**
     * Sets whether image transformations should be kept in bounds or not.
     * <p/>
     * Default value is true.
     */
    public Settings setRestrictBounds(boolean isRestrictBounds) {
        this.isRestrictBounds = isRestrictBounds;
        return this;
    }

    /**
     * Sets whether image rotation should stick to 90 degrees intervals or can be free.
     * Only applied when {@link #isRestrictBounds()} is true as well.
     * <p/>
     * Default value is false.
     */
    public Settings setRestrictRotation(boolean isRestrictRotation) {
        this.isRestrictRotation = isRestrictRotation;
        return this;
    }


    // --------------
    //  Getters
    // --------------

    public int getViewportW() {
        return viewportW;
    }

    public int getViewportH() {
        return viewportH;
    }

    public int getMovementAreaW() {
        return isMovementAreaSpecified ? movementAreaW : viewportW;
    }

    public int getMovementAreaH() {
        return isMovementAreaSpecified ? movementAreaH : viewportH;
    }

    public int getImageW() {
        return imageW;
    }

    public int getImageH() {
        return imageH;
    }

    public float getMaxZoom() {
        return maxZoom;
    }

    public float getOverzoomFactor() {
        return overzoomFactor;
    }

    public float getOverscrollDistanceX() {
        return overscrollDistanceX;
    }

    public float getOverscrollDistanceY() {
        return overscrollDistanceY;
    }

    public boolean isFillViewport() {
        return isFillViewport;
    }

    public int getGravity() {
        return gravity;
    }

    public Fit getFitMethod() {
        return fitMethod;
    }

    public boolean isPanEnabled() {
        return isGesturesEnabled() && isPanEnabled;
    }

    public boolean isZoomEnabled() {
        return isGesturesEnabled() && isZoomEnabled;
    }

    public boolean isRotationEnabled() {
        return isGesturesEnabled() && isRotationEnabled;
    }

    public boolean isDoubleTapEnabled() {
        return isGesturesEnabled() && isDoubleTapEnabled;
    }

    public boolean isGesturesEnabled() {
        return gesturesDisableCount <= 0;
    }

    public boolean isRestrictBounds() {
        return isRestrictBounds;
    }

    public boolean isRestrictRotation() {
        return isRestrictRotation;
    }

    /**
     * Whether at least one of pan, zoom, rotation or double tap are enabled or not
     */
    public boolean isEnabled() {
        return isGesturesEnabled() &&
                (isPanEnabled || isZoomEnabled || isRotationEnabled || isDoubleTapEnabled);
    }


    public boolean hasImageSize() {
        return imageW != 0 && imageH != 0;
    }

    public boolean hasViewportSize() {
        return viewportW != 0 && viewportH != 0;
    }


    public enum Fit {
        /**
         * Fit image width inside viewport area
         */
        HORIZONTAL,

        /**
         * Fit image height inside viewport area
         */
        VERTICAL,

        /**
         * Fit both image width and image height inside viewport area
         */
        INSIDE,

        /**
         * Fit image width or image height inside viewport area, so the entire viewport is filled
         */
        OUTSIDE
    }

    private static float toPixels(Context context, float value) {
        return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, value,
                context.getResources().getDisplayMetrics());
    }

} public class State {

    public static final float EPSILON = 0.0001f;

    private final Matrix matrix = new Matrix();
    private final float[] tmp = new float[9];

    private float x, y;
    private float zoom = 1f;
    private float rotation;

    public float getX() {
        return x;
    }

    public float getY() {
        return y;
    }

    public float getZoom() {
        return zoom;
    }

    /**
     * Rotation in degrees within the range [-180..180].
     */
    public float getRotation() {
        return rotation;
    }

    /**
     * @return {@code true} if {@code x == 0f && y == 0f && zoom == 1f && rotation == 0f}
     */
    @SuppressWarnings("unused") // Public API
    public boolean isEmpty() {
        return x == 0f && y == 0f && zoom == 1f && rotation == 0f;
    }

    /**
     * Applying state to provided matrix. Matrix will contain translation, scale and rotation.
     */
    public void get(Matrix matrix) {
        matrix.set(this.matrix);
    }

    public void translateBy(float dx, float dy) {
        matrix.postTranslate(dx, dy);
        updateFromMatrix(false, false); // only translation is changed
    }

    public void translateTo(float x, float y) {
        matrix.postTranslate(-this.x + x, -this.y + y);
        updateFromMatrix(false, false); // only translation is changed
    }

    public void zoomBy(float factor, float pivotX, float pivotY) {
        matrix.postScale(factor, factor, pivotX, pivotY);
        updateFromMatrix(true, false); // zoom & translation are changed
    }

    public void zoomTo(float zoom, float pivotX, float pivotY) {
        matrix.postScale(zoom / this.zoom, zoom / this.zoom, pivotX, pivotY);
        updateFromMatrix(true, false); // zoom & translation are changed
    }

    public void rotateBy(float angle, float pivotX, float pivotY) {
        matrix.postRotate(angle, pivotX, pivotY);
        updateFromMatrix(false, true); // rotation & translation are changed
    }

    public void rotateTo(float angle, float pivotX, float pivotY) {
        matrix.postRotate(-rotation + angle, pivotX, pivotY);
        updateFromMatrix(false, true); // rotation & translation are changed
    }

    public void set(float x, float y, float zoom, float rotation) {
        // Keeping rotation within the range [-180..180]
        while (rotation < -180f) {
            rotation += 360f;
        }
        while (rotation > 180f) {
            rotation -= 360f;
        }

        this.x = x;
        this.y = y;
        this.zoom = zoom;
        this.rotation = rotation;

        // Note, that order is vital here
        matrix.reset();
        if (zoom != 1f) {
            matrix.postScale(zoom, zoom);
        }
        if (rotation != 0f) {
            matrix.postRotate(rotation);
        }
        matrix.postTranslate(x, y);
    }

    /**
     * Applying state from given matrix. Matrix should contain correct translation/scale/rotation.
     */
    public void set(Matrix matrix) {
        this.matrix.set(matrix);
        updateFromMatrix(true, true);
    }

    public void set(State other) {
        x = other.x;
        y = other.y;
        zoom = other.zoom;
        rotation = other.rotation;
        matrix.set(other.matrix);
    }

    public State copy() {
        State copy = new State();
        copy.set(this);
        return copy;
    }

    /**
     * Applying state from current matrix.
     * <p/>
     * Having matrix:
     * <pre>
     *     | a  b  tx |
     * A = | c  d  ty |
     *     | 0  0  1  |
     *
     * x = tx
     * y = ty
     * scale = sqrt(b^2+d^2)
     * rotation = atan(c/d) = atan(-b/a)
     * </pre>
     * See <a href="http://stackoverflow.com/questions/4361242">here</a>.
     */
    private void updateFromMatrix(boolean updateZoom, boolean updateRotation) {
        matrix.getValues(tmp);
        x = tmp[2];
        y = tmp[5];
        if (updateZoom) {
            zoom = (float) Math.hypot(tmp[1], tmp[4]);
        }
        if (updateRotation) {
            rotation = (float) Math.toDegrees(Math.atan2(tmp[3], tmp[4]));
        }
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) {
            return true;
        }
        if (o == null || getClass() != o.getClass()) {
            return false;
        }

        State state = (State) o;

        return equals(state.x, x) && equals(state.y, y)
                && equals(state.zoom, zoom) && equals(state.rotation, rotation);
    }

    @Override
    public int hashCode() {
        int result = (x != +0.0f ? Float.floatToIntBits(x) : 0);
        result = 31 * result + (y != +0.0f ? Float.floatToIntBits(y) : 0);
        result = 31 * result + (zoom != +0.0f ? Float.floatToIntBits(zoom) : 0);
        result = 31 * result + (rotation != +0.0f ? Float.floatToIntBits(rotation) : 0);
        return result;
    }

    @Override
    public String toString() {
        return "{x=" + x + ",y=" + y + ",zoom=" + zoom + ",rotation=" + rotation + "}";
    }

    /**
     * Compares two float values, allowing small difference (see {@link #EPSILON})
     */
    public static boolean equals(float v1, float v2) {
        return compare(v1, v2) == 0;
    }

    /**
     * Compares two float values, allowing small difference (see {@link #EPSILON})
     */
    public static int compare(float v1, float v2) {
        return v1 > v2 + EPSILON ? 1 : v1 < v2 - EPSILON ? -1 : 0;
    }

} public class ViewPositionAnimator {

    private static final String TAG = "ViewPositionAnimator";

    private static final Matrix TMP_MATRIX = new Matrix();

    private final List<PositionUpdateListener> mListeners = new ArrayList<>();
    private final List<PositionUpdateListener> mListenersToRemove = new ArrayList<>();
    private boolean mIteratingListeners;

    private long mDuration = FloatScroller.DEFAULT_DURATION;

    private final FloatScroller mStateScroller = new FloatScroller();
    private final AnimationEngine mAnimationEngine;

    private final GestureController mToController;
    private final ClipView mToClipView;

    private final State mFromState = new State(), mToState = new State();
    private float mFromPivotX, mFromPivotY, mToPivotX, mToPivotY;
    private final RectF mFromClip = new RectF(), mToClip = new RectF();
    private final RectF mClipRect = new RectF();
    private ViewPosition mFromPos, mToPos;

    private View mFromView;

    private boolean mOrigRestrictBoundsFlag;

    private boolean mIsActivated = false;

    private float mPositionState = 0f;
    private boolean mIsLeaving = true; // Leaving by default
    private boolean mIsAnimating = false;
    private boolean mApplyingPositionState;
    private boolean mApplyingPositionStateScheduled;

    private boolean mIsFromUpdated, mIsToUpdated; // Marks that update for 'From' or 'To' is needed

    private final ViewPositionHolder mFromPosHolder = new ViewPositionHolder();
    private final ViewPositionHolder mToPosHolder = new ViewPositionHolder();

    private final ViewPositionHolder.OnViewPositionChangeListener mFromPositionListener =
            new ViewPositionHolder.OnViewPositionChangeListener() {
                @Override
                public void onViewPositionChanged(@NonNull ViewPosition position) {
                    if (GestureDebug.isDebugAnimator()) {
                        Log.d(TAG, "'From' view position updated: " + position.pack());
                    }

                    mFromPos = position;
                    requestUpdateFromState();
                    applyPositionState();
                }
            };


    public ViewPositionAnimator(@NonNull GestureView to) {
        if (!(to instanceof View)) {
            throw new IllegalArgumentException("Argument 'to' should be an instance of View");
        }

        View toView = (View) to;
        mToClipView = to instanceof ClipView ? (ClipView) to : null;
        mAnimationEngine = new LocalAnimationEngine(toView);

        mToController = to.getController();
        mToController.addOnStateChangeListener(new GestureController.OnStateChangeListener() {
            @Override
            public void onStateChanged(State state) {
                // No-op
            }

            @Override
            public void onStateReset(State oldState, State newState) {
                if (!mIsActivated) {
                    return;
                }

                if (GestureDebug.isDebugAnimator()) {
                    Log.d(TAG, "State reset in listener: " + newState);
                }

                resetToState();
                applyPositionState();
            }
        });

        mToPosHolder.init(toView, new ViewPositionHolder.OnViewPositionChangeListener() {
            @Override
            public void onViewPositionChanged(@NonNull ViewPosition position) {
                if (GestureDebug.isDebugAnimator()) {
                    Log.d(TAG, "'To' view position updated: " + position.pack());
                }

                mToPos = position;
                requestUpdateToState();
                requestUpdateFromState(); // Depends on 'to' position
                applyPositionState();
            }
        });
    }

    /**
     * Starts 'enter' animation from {@code from} view to {@code to}.
     * <p/>
     * Note, if {@code from} view was changed (i.e. during list adapter refresh) you should
     * update to new view using {@link #update(View)} method.
     */
    public void enter(@NonNull View from, boolean withAnimation) {
        if (GestureDebug.isDebugAnimator()) {
            Log.d(TAG, "Entering from view, with animation = " + withAnimation);
        }

        enterInternal(withAnimation);
        updateInternal(from);
    }

    /**
     * Starts 'enter' animation from {@code from} position to {@code to} view.
     * <p/>
     * Note, if {@code from} view position was changed (i.e. during list adapter refresh) you
     * should
     * update to new view using {@link #update(ViewPosition)} method.
     */
    public void enter(@NonNull ViewPosition fromPos, boolean withAnimation) {
        if (GestureDebug.isDebugAnimator()) {
            Log.d(TAG, "Entering from view position, with animation = " + withAnimation);
        }

        enterInternal(withAnimation);
        updateInternal(fromPos);
    }

    /**
     * Updates initial view in case it was changed. You should not call this method if view stays
     * the same since animator should automatically detect view position changes.
     */
    public void update(@NonNull View from) {
        if (mFromView == null) {
            throw new IllegalStateException("Animation was not started using " +
                    "enter(View, boolean) method, cannot update 'from' view");
        }

        if (GestureDebug.isDebugAnimator()) {
            Log.d(TAG, "Updating view");
        }

        updateInternal(from);
    }

    /**
     * Updates position of initial view in case it was changed.
     */
    public void update(@NonNull ViewPosition fromPos) {
        if (mFromPos == null) {
            throw new IllegalStateException("Animation was not started using " +
                    "enter(ViewPosition, boolean) method, cannot update 'from' position");
        }

        if (GestureDebug.isDebugAnimator()) {
            Log.d(TAG, "Updating view position: " + fromPos.pack());
        }

        updateInternal(fromPos);
    }

    /**
     * Starts 'exit' animation from {@code to} view back to {@code from}
     */
    public void exit(boolean withAnimation) {
        if (GestureDebug.isDebugAnimator()) {
            Log.d(TAG, "Exiting, with animation = " + withAnimation);
        }

        if (!mIsActivated) {
            throw new IllegalStateException("You should call enter(...) before calling exit(...)");
        }

        if (!mIsAnimating) {
            resetToState(); // Only resetting if not animating
        }

        // Starting animation from current position or applying initial state without animation
        setState(withAnimation ? mPositionState : 0f, true, withAnimation);
    }

    private void enterInternal(boolean withAnimation) {
        mIsActivated = true;

        // Starting animation from initial position or applying final state without animation
        setState(withAnimation ? 0f : 1f, false, withAnimation);
    }

    private void updateInternal(@NonNull View from) {
        if (!mIsActivated) {
            throw new IllegalStateException(
                    "You should call enter(...) before calling update(...)");
        }

        cleanup();
        resetToState();

        mFromView = from;
        mFromPosHolder.init(from, mFromPositionListener);
        from.setVisibility(View.INVISIBLE); // We don't want to have duplicate view during animation
    }

    private void updateInternal(@NonNull ViewPosition fromPos) {
        if (!mIsActivated) {
            throw new IllegalStateException(
                    "You should call enter(...) before calling update(...)");
        }

        cleanup();
        resetToState();

        mFromPos = fromPos;
    }

    private void cleanup() {
        if (GestureDebug.isDebugAnimator()) {
            Log.d(TAG, "Cleaning up");
        }

        if (mFromView != null) {
            mFromView.setVisibility(View.VISIBLE); // Switching back to visible
        }
        if (mToClipView != null) {
            mToClipView.clipView(null);
        }

        mFromPosHolder.clear();
        mFromView = null;
        mFromPos = null;
        mIsFromUpdated = mIsToUpdated = false;
    }

    /**
     * Adds listener to the set of position updates listeners that will be notified during
     * any position changes.
     */
    public void addPositionUpdateListener(PositionUpdateListener listener) {
        mListeners.add(listener);
        mListenersToRemove.remove(listener);
    }

    /**
     * Removes listener added by {@link #addPositionUpdateListener(PositionUpdateListener)}.
     * <p/>
     * Note, this method may be called inside listener's callback without throwing
     * {@link IndexOutOfBoundsException}.
     */
    public void removePositionUpdateListener(PositionUpdateListener listener) {
        if (mIteratingListeners) {
            mListenersToRemove.add(listener);
        } else {
            mListeners.remove(listener);
        }
    }

    private void ensurePositionUpdateListenersRemoved() {
        mListeners.removeAll(mListenersToRemove);
        mListenersToRemove.clear();
    }

    @SuppressWarnings("unused") // Public API
    public long getDuration() {
        return mDuration;
    }

    @SuppressWarnings("unused") // Public API
    public void setDuration(long duration) {
        mDuration = duration;
    }

    /**
     * @return Current position state within range {@code [0, 1]}, where {@code 0} is for
     * initial (from) position and {@code 1} is for final (to) position.
     */
    public float getPositionState() {
        return mPositionState;
    }

    /**
     * @return Whether animator is in leaving state. Means that animation direction is
     * from final (to) position back to initial (from) position.
     */
    public boolean isLeaving() {
        return mIsLeaving;
    }

    /**
     * Stops current animation and sets position state to particular values.
     * <p/>
     * Note, that once animator reaches {@code state = 0f} and {@code isLeaving = true}
     * it will cleanup all internal stuff. So you will need to call {@link #enter(View, boolean)}
     * or {@link #enter(ViewPosition, boolean)} again in order to continue using animator.
     */
    public void setState(@FloatRange(from = 0f, to = 1f) float state,
            boolean isLeaving, boolean isAnimating) {
        stopAnimation();
        mPositionState = state;
        mIsLeaving = isLeaving;
        if (isAnimating) {
            startAnimationInternal();
        }
        applyPositionState();
    }

    /**
     * Whether view position animation is in progress or not.
     */
    public boolean isAnimating() {
        return mIsAnimating;
    }

    /**
     * Starts animation from current position state ({@link #getPositionState()}) and in current
     * direction ({@link #isLeaving()}).
     */
    private void startAnimationInternal() {
        stopAnimation();

        float durationFraction = mIsLeaving ? mPositionState : 1f - mPositionState;

        mStateScroller.startScroll(mPositionState, mIsLeaving ? 0f : 1f);
        mStateScroller.setDuration((long) (mDuration * durationFraction));
        mAnimationEngine.start();
        onAnimationStarted();
    }

    /**
     * Stops current animation, if any.
     */
    public void stopAnimation() {
        mStateScroller.forceFinished();
        onAnimationStopped();
    }

    private void applyPositionState() {
        if (mApplyingPositionState) {
            // Excluding possible nested calls, scheduling sequential call instead
            mApplyingPositionStateScheduled = true;
            return;
        }
        mApplyingPositionState = true;

        // We do not need to update while 'to' view is fully visible or fully closed
        boolean paused = mIsLeaving ? mPositionState == 0f : mPositionState == 1f;
        mFromPosHolder.pause(paused);
        mToPosHolder.pause(paused);

        // Perform state updates if needed
        if (!mIsToUpdated) {
            updateToState();
        }
        if (!mIsFromUpdated) {
            updateFromState();
        }

        if (GestureDebug.isDebugAnimator()) {
            Log.d(TAG, "Applying state: " + mPositionState + " / " + mIsLeaving
                    + ", 'to' ready = " + mIsToUpdated + ", 'from' ready = " + mIsFromUpdated);
        }

        if (mIsToUpdated && mIsFromUpdated) {
            State state = mToController.getState();

            StateController.interpolate(state, mFromState, mFromPivotX, mFromPivotY,
                    mToState, mToPivotX, mToPivotY, mPositionState);

            mToController.updateState();

            interpolate(mClipRect, mFromClip, mToClip, mPositionState);
            if (mToClipView != null) {
                boolean skipClip = mPositionState == 1f || (mPositionState == 0f && mIsLeaving);
                mToClipView.clipView(skipClip ? null : mClipRect);
            }
        }

        mIteratingListeners = true;
        for (int i = 0, size = mListeners.size(); i < size; i++) {
            if (mApplyingPositionStateScheduled) {
                break; // No need to call listeners anymore
            }
            mListeners.get(i).onPositionUpdate(mPositionState, mIsLeaving);
        }
        mIteratingListeners = false;
        ensurePositionUpdateListenersRemoved();

        if (mPositionState == 0f && mIsLeaving) {
            cleanup();
            mIsActivated = false;
            mToController.resetState(); // Switching to initial state
        }

        mApplyingPositionState = false;

        if (mApplyingPositionStateScheduled) {
            mApplyingPositionStateScheduled = false;
            applyPositionState();
        }
    }

    private void onAnimationStarted() {
        if (mIsAnimating) {
            return;
        }
        mIsAnimating = true;

        if (GestureDebug.isDebugAnimator()) {
            Log.d(TAG, "Animation started");
        }

        // Saving bounds restrictions states
        mOrigRestrictBoundsFlag = mToController.getSettings().isRestrictBounds();
        // Disabling bounds restrictions & any gestures
        mToController.getSettings().setRestrictBounds(false).disableGestures();
        // Stopping all currently playing animations
        mToController.stopAllAnimations();

        // Disabling ViewPager scroll
        if (mToController instanceof GestureControllerForPager) {
            ((GestureControllerForPager) mToController).disableViewPager(true);
        }
    }

    private void onAnimationStopped() {
        if (!mIsAnimating) {
            return;
        }
        mIsAnimating = false;

        if (GestureDebug.isDebugAnimator()) {
            Log.d(TAG, "Animation stopped");
        }

        // Restoring original settings
        mToController.getSettings().setRestrictBounds(mOrigRestrictBoundsFlag).enableGestures();
        mToController.updateState();

        // Enabling ViewPager scroll
        if (mToController instanceof GestureControllerForPager) {
            ((GestureControllerForPager) mToController).disableViewPager(false);
        }
    }

    private void resetToState() {
        if (GestureDebug.isDebugAnimator()) {
            Log.d(TAG, "State reset internal: " + mToController.getState());
        }

        mToState.set(mToController.getState());
        requestUpdateToState();
        requestUpdateFromState();
    }

    private void requestUpdateToState() {
        mIsToUpdated = false;
    }

    private void requestUpdateFromState() {
        mIsFromUpdated = false;
    }

    private void updateToState() {
        if (mIsToUpdated) {
            return;
        }

        Settings settings = mToController == null ? null : mToController.getSettings();

        if (mToPos == null || settings == null || !settings.hasImageSize()) {
            return;
        }

        // Computing 'To' clip by getting current 'To' image rect in 'To' view coordinates
        // (including view paddings which are not part of viewport)
        mToClip.set(0, 0, settings.getImageW(), settings.getImageH());
        mToState.get(TMP_MATRIX);
        TMP_MATRIX.mapRect(mToClip);

        mToPivotX = mToClip.centerX();
        mToPivotY = mToClip.centerY();

        int paddingLeft = mToPos.viewport.left - mToPos.view.left;
        int paddingTop = mToPos.viewport.top - mToPos.view.top;
        mToClip.offset(paddingLeft, paddingTop);

        mIsToUpdated = true;

        if (GestureDebug.isDebugAnimator()) {
            Log.d(TAG, "'To' state updated");
        }
    }

    private void updateFromState() {
        if (mIsFromUpdated) {
            return;
        }

        Settings settings = mToController == null ? null : mToController.getSettings();

        if (mToPos == null || mFromPos == null || settings == null || !settings.hasImageSize()) {
            return;
        }

        // Computing starting zoom level
        float w = settings.getImageW();
        float h = settings.getImageH();
        float zoomW = w == 0f ? 1f : mFromPos.image.width() / w;
        float zoomH = h == 0f ? 1f : mFromPos.image.height() / h;
        float zoom = Math.max(zoomW, zoomH);

        // Computing 'From' image in 'To' viewport coordinates.
        // If 'To' image have different aspect ratio it will be centered within the 'From' image.
        float x = mFromPos.image.centerX() - 0.5f * w * zoom - mToPos.viewport.left;
        float y = mFromPos.image.centerY() - 0.5f * h * zoom - mToPos.viewport.top;

        mFromState.set(x, y, zoom, 0f);
        mFromPivotX = mFromPos.image.centerX() - mToPos.viewport.left;
        mFromPivotY = mFromPos.image.centerY() - mToPos.viewport.top;

        // 'From' clip is a 'From' view rect in coordinates of 'To' view.
        mFromClip.set(0, 0, mFromPos.view.width(), mFromPos.view.height());
        float left = mFromPos.view.left - mToPos.view.left;
        float top = mFromPos.view.top - mToPos.view.top;
        mFromClip.offset(left, top);

        mIsFromUpdated = true;

        if (GestureDebug.isDebugAnimator()) {
            Log.d(TAG, "'From' state updated");
        }
    }

    /**
     * Interpolates from start rect to the end rect by given factor (from 0 to 1),
     * storing result into out rect.
     */
    private static void interpolate(RectF out, RectF start, RectF end, float factor) {
        out.left = StateController.interpolate(start.left, end.left, factor);
        out.top = StateController.interpolate(start.top, end.top, factor);
        out.right = StateController.interpolate(start.right, end.right, factor);
        out.bottom = StateController.interpolate(start.bottom, end.bottom, factor);
    }


    private class LocalAnimationEngine extends AnimationEngine {
        public LocalAnimationEngine(@NonNull View view) {
            super(view);
        }

        @Override
        public boolean onStep() {
            if (!mStateScroller.isFinished()) {
                mStateScroller.computeScroll();
                mPositionState = mStateScroller.getCurr();
                applyPositionState();

                if (mStateScroller.isFinished()) {
                    onAnimationStopped();
                }

                return true;
            }
            return false;
        }
    }


    public interface PositionUpdateListener {
        /**
         * @param state Position state within range {@code [0, 1]}, where {@code 0} is for
         * initial (from) position and {@code 1} is for final (to) position.
         * @param isLeaving {@code false} if transitioning from initial to final position
         * (entering) or {@code true} for reverse transition.
         */
        void onPositionUpdate(float state, boolean isLeaving);
    }

} public class CropUtils {

    /**
     * Crops image drawable into bitmap according to current image position
     */
    public static Bitmap crop(Drawable drawable, State state, Settings settings) {
        if (drawable == null) {
            return null;
        }

        float zoom = state.getZoom();

        // Computing crop size for base zoom level (zoom == 1)
        int w = Math.round(settings.getMovementAreaW() / zoom);
        int h = Math.round(settings.getMovementAreaH() / zoom);

        // Crop area coordinates within viewport
        Rect pos = MovementBounds.getMovementAreaWithGravity(settings);

        Matrix matrix = new Matrix();
        state.get(matrix);
        // Scaling to base zoom level (zoom == 1)
        matrix.postScale(1f / zoom, 1f / zoom, pos.left, pos.top);
        // Positioning crop area
        matrix.postTranslate(-pos.left, -pos.top);

        try {
            // Draw drawable into bitmap
            Bitmap dst = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888);

            Canvas canvas = new Canvas(dst);
            canvas.concat(matrix);
            drawable.draw(canvas);

            return dst;
        } catch (OutOfMemoryError e) {
            return null; // Not enough memory for cropped bitmap
        }
    }

} public interface AnimatorView {

    /**
     * @return {@link ViewPositionAnimator} instance to control animation from other view position.
     */
    ViewPositionAnimator getPositionAnimator();

} public interface ClipView {

    /**
     * Clips view so only part specified in {@code rect} will be drawn.
     * <p/>
     * Pass {@code null} to turn clipping off.
     */
    void clipView(@Nullable RectF rect);

} public interface GestureView {

    /**
     * Returns {@link GestureController} which is a main engine for all gestures interactions.
     * <p/>
     * Use it to apply settings, access and modify image state and so on.
     */
    GestureController getController();

} public class ViewClipHelper implements ClipView {

    private final View mView;
    private final RectF mClipRect = new RectF(), mOldClipRect = new RectF();
    private boolean mIsClipping;

    public ViewClipHelper(@NonNull View view) {
        mView = view;
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public void clipView(@Nullable RectF rect) {
        if (rect == null) {
            if (mIsClipping) {
                mIsClipping = false;
                mView.invalidate();
            }
        } else {
            // Setting previous clip rect
            if (mIsClipping) {
                mOldClipRect.set(mClipRect);
            } else {
                mOldClipRect.set(0, 0, mView.getWidth(), mView.getHeight());
            }

            mIsClipping = true;

            mClipRect.set(rect);

            // Invalidating only updated part
            int left = (int) Math.min(mClipRect.left, mOldClipRect.left);
            int top = (int) Math.min(mClipRect.top, mOldClipRect.top);
            int right = (int) Math.max(mClipRect.right, mOldClipRect.right) + 1;
            int bottom = (int) Math.max(mClipRect.bottom, mOldClipRect.bottom) + 1;
            mView.invalidate(left, top, right, bottom);
        }
    }

    public void onPreDraw(@NonNull Canvas canvas) {
        if (mIsClipping) {
            canvas.save();
            canvas.clipRect(mClipRect);
        }
    }

    public void onPostDraw(@NonNull Canvas canvas) {
        if (mIsClipping) {
            canvas.restore();
        }
    }

}
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Matrix;
import android.graphics.RectF;
import android.graphics.drawable.Drawable;
import android.os.Build;
import android.support.annotation.DrawableRes;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.widget.ImageView;
import com.alexvasilkov.gestures.GestureController;
import com.alexvasilkov.gestures.GestureControllerForPager;
import com.alexvasilkov.gestures.Settings;
import com.alexvasilkov.gestures.State;
import com.alexvasilkov.gestures.animation.ViewPositionAnimator;
import com.alexvasilkov.gestures.internal.CropUtils;
import com.alexvasilkov.gestures.views.interfaces.AnimatorView;
import com.alexvasilkov.gestures.views.interfaces.ClipView;
import com.alexvasilkov.gestures.views.interfaces.GestureView;
import com.alexvasilkov.gestures.views.utils.ViewClipHelper;
package com.alexvasilkov.gestures.views;



/**
 * {@link ImageView} implementation controlled by {@link GestureController}
 * ({@link #getController()}).
 * <p/>
 * View position can be animated with {@link ViewPositionAnimator}
 * ({@link #getPositionAnimator()}).
 */
public class GestureImageView extends ImageView implements GestureView, ClipView, AnimatorView {

    private GestureControllerForPager mController;
    private final ViewClipHelper mClipHelper = new ViewClipHelper(this);
    private final Matrix mImageMatrix = new Matrix();

    private ViewPositionAnimator mPositionAnimator;

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

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

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

        ensureControllerCreated();
        mController.addOnStateChangeListener(new GestureController.OnStateChangeListener() {
            @Override
            public void onStateChanged(State state) {
                applyState(state);
            }

            @Override
            public void onStateReset(State oldState, State newState) {
                applyState(newState);
            }
        });

        setScaleType(ImageView.ScaleType.MATRIX);
    }

    private void ensureControllerCreated() {
        if (mController == null) {
            mController = new GestureControllerForPager(this);
        }
    }

    @Override
    public void draw(@NonNull Canvas canvas) {
        mClipHelper.onPreDraw(canvas);
        super.draw(canvas);
        mClipHelper.onPostDraw(canvas);
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public GestureControllerForPager getController() {
        return mController;
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public ViewPositionAnimator getPositionAnimator() {
        if (mPositionAnimator == null) {
            mPositionAnimator = new ViewPositionAnimator(this);
        }
        return mPositionAnimator;
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public void clipView(@Nullable RectF rect) {
        mClipHelper.clipView(rect);
    }

    /**
     * Crops bitmap as it is seen inside movement area: {@link Settings#setMovementArea(int, int)}.
     * Result will be delivered to provided snapshot listener.
     *
     * @deprecated Use {@link #crop()} method instead.
     */
    @SuppressWarnings({ "deprecation", "unused" }) // Public API
    @Deprecated
    public void getSnapshot(OnSnapshotLoadedListener listener) {
        if (getDrawable() != null) {
            listener.onSnapshotLoaded(crop());
        }
    }

    /**
     * Crops bitmap as it is seen inside movement area: {@link Settings#setMovementArea(int, int)}.
     * <p/>
     * Note, that size of cropped bitmap may vary from size of movement area,
     * since we will crop part of original image at base zoom level (zoom == 1).
     *
     * @return Cropped bitmap or null, if no image is set to this image view or if
     * {@link OutOfMemoryError} error was thrown during cropping.
     */
    @Nullable
    public Bitmap crop() {
        return CropUtils.crop(getDrawable(), mController.getState(), mController.getSettings());
    }

    @Override
    public boolean onTouchEvent(@NonNull MotionEvent event) {
        return mController.onTouch(this, event);
    }

    @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        super.onSizeChanged(w, h, oldw, oldh);
        mController.getSettings().setViewport(w - getPaddingLeft() - getPaddingRight(),
                h - getPaddingTop() - getPaddingBottom());
        mController.updateState();
    }

    @Override
    public void setImageResource(int resId) {
        setImageDrawable(getDrawable(getContext(), resId));
    }

    @Override
    public void setImageDrawable(Drawable dr) {
        super.setImageDrawable(dr);

        // Method setImageDrawable can be called from super constructor,
        // so we have to ensure controller instance is created at this point.
        ensureControllerCreated();

        Settings settings = mController.getSettings();