content: package com.alexvasilkov.gestures;
  
  import android.annotation.SuppressLint;
  import android.content.Context;
  import android.graphics.Point;
  import android.graphics.PointF;
  import android.graphics.RectF;
  import android.view.GestureDetector;
  import android.view.MotionEvent;
  import android.view.ScaleGestureDetector;
  import android.view.View;
  import android.view.ViewConfiguration;
  import android.view.ViewParent;
  import android.widget.OverScroller;
  
  import androidx.annotation.NonNull;
  import androidx.annotation.Nullable;
  
  import com.alexvasilkov.gestures.internal.AnimationEngine;
  import com.alexvasilkov.gestures.internal.ExitController;
  import com.alexvasilkov.gestures.internal.MovementBounds;
  import com.alexvasilkov.gestures.internal.detectors.RotationGestureDetector;
  import com.alexvasilkov.gestures.internal.detectors.ScaleGestureDetectorFixed;
  import com.alexvasilkov.gestures.utils.FloatScroller;
  import com.alexvasilkov.gestures.utils.GravityUtils;
  import com.alexvasilkov.gestures.utils.MathUtils;
  
  import java.util.ArrayList;
  import java.util.List;
  
  import static java.lang.Float.isNaN;
  
  /**
   * Handles touch events to update view's position state ({@link State}) based on current
   * setup ({@link Settings}).<br>
   * Settings can be obtained and altered through {@link #getSettings()}.<br>
   * Note, that some settings are required in order to correctly update state, see {@link Settings}.
   * <p>
   * This class implements {@link View.OnTouchListener} to delegate touches from view to controller.
   * <p>
   * State can also be manipulated directly with {@link #getState()}, {@link #updateState()}
   * and {@link #resetState()}. You can also access {@link #getStateController()} for some additional
   * stuff.
   * <p>
   * State can be animated with {@link #animateStateTo(State)} method.<br>
   * See also {@link #stopFlingAnimation()}, {@link #stopStateAnimation()}
   * and {@link #stopAllAnimations()} methods.
   * <p>
   * All state changes will be passed to {@link OnStateChangeListener OnStateChangeListener}.
   * See {@link #addOnStateChangeListener(OnStateChangeListener) addOnStateChangeListener} and
   * {@link #removeOnStateChangeListener(OnStateChangeListener) removeOnStateChangeListener} methods.
   * <p>
   * Additional touch events can be listened with {@link OnGestureListener OnGestureListener} and
   * {@link SimpleOnGestureListener SimpleOnGestureListener} using
   * {@link #setOnGesturesListener(OnGestureListener) setOnGesturesListener} method.
   * <p>
   * State source changes (whether state is being changed by user or by animation) can be
   * listened with {@link OnStateSourceChangeListener} using
   * {@link #setOnStateSourceChangeListener(OnStateSourceChangeListener)} method.
   */
  public class GestureController implements View.OnTouchListener {
  
      private static final float FLING_COEFFICIENT = 0.9f;
  
      // Temporary objects
      private static final PointF tmpPointF = new PointF();
      private static final Point tmpPoint = new Point();
      private static final RectF tmpRectF = new RectF();
      private static final float[] tmpPointArr = new float[2];
  
      // Control constants converted to pixels
      private final int touchSlop;
      private final int minVelocity;
      private final int maxVelocity;
  
      private OnGestureListener gestureListener;
      private OnStateSourceChangeListener sourceListener;
      private final List<OnStateChangeListener> stateListeners = new ArrayList<>();
  
      private final AnimationEngine animationEngine;
  
      // Various gesture detectors
      private final GestureDetector gestureDetector;
      private final ScaleGestureDetector scaleDetector;
      private final RotationGestureDetector rotateDetector;
  
      private boolean isInterceptTouchCalled;
      private boolean isInterceptTouchDisallowed;
      private boolean isScrollDetected;
      private boolean isScaleDetected;
      private boolean isRotationDetected;
  
      private float pivotX = Float.NaN;
      private float pivotY = Float.NaN;
      private float endPivotX = Float.NaN;
      private float endPivotY = Float.NaN;
  
      private boolean isStateChangedDuringTouch;
      private boolean isRestrictZoomRequested;
      private boolean isRestrictRotationRequested;
      private boolean isAnimatingInBounds;
  
      private StateSource stateSource = StateSource.NONE;
  
      private final OverScroller flingScroller;
      private final FloatScroller stateScroller;
  
      private final MovementBounds flingBounds;
      private final State stateStart = new State();
      private final State stateEnd = new State();
  
      private final View targetView;
      private final Settings settings;
      private final State state = new State();
      private final State prevState = new State();
      private final StateController stateController;
      private final ExitController exitController;
  
      public GestureController(@NonNull View view) {
          final Context context = view.getContext();
  
          targetView = view;
          settings = new Settings();
          stateController = new StateController(settings);
  
          animationEngine = new LocalAnimationEngine(view);
          InternalGesturesListener internalListener = new InternalGesturesListener();
          gestureDetector = new GestureDetector(context, internalListener);
          scaleDetector = new ScaleGestureDetectorFixed(context, internalListener);
          rotateDetector = new RotationGestureDetector(context, internalListener);
  
          exitController = new ExitController(view, this);
  
          flingScroller = new OverScroller(context);
          stateScroller = new FloatScroller();
  
          flingBounds = new MovementBounds(settings);
  
          final ViewConfiguration configuration = ViewConfiguration.get(context);
          touchSlop = configuration.getScaledTouchSlop();
          minVelocity = configuration.getScaledMinimumFlingVelocity();
          maxVelocity = configuration.getScaledMaximumFlingVelocity();
      }
  
      /**
       * Sets listener for basic touch events.
       *
       * @param listener Gestures listener
       * @see GestureController.OnGestureListener
       */
      @SuppressWarnings({ "unused", "WeakerAccess" }) // Public API
      public void setOnGesturesListener(@Nullable OnGestureListener listener) {
          gestureListener = listener;
      }
  
      /**
       * Sets listener for state source changes.
       *
       * @param listener State's source changes listener
       * @see OnStateSourceChangeListener
       */
      @SuppressWarnings({ "unused", "WeakerAccess" }) // Public API
      public void setOnStateSourceChangeListener(@Nullable OnStateSourceChangeListener listener) {
          sourceListener = listener;
      }
  
      /**
       * Adds listener for state changes.
       *
       * @param listener State changes listener
       * @see OnStateChangeListener
       */
      public void addOnStateChangeListener(@NonNull OnStateChangeListener listener) {
          stateListeners.add(listener);
      }
  
      /**
       * Removes listener for state changes.
       *
       * @param listener State changes listener to be removed
       * @see #addOnStateChangeListener(OnStateChangeListener)
       */
      @SuppressWarnings({ "unused", "WeakerAccess" }) // Public API
      public void removeOnStateChangeListener(@NonNull OnStateChangeListener listener) {
          stateListeners.remove(listener);
      }
  
      /**
       * 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.
       *
       * @return Gesture view's settings
       */
      @NonNull
      public Settings getSettings() {
          return settings;
      }
  
      /**
       * Current state.
       * <p>
       * If this state is changed from outside you should call
       * {@link GestureController#updateState()} or {@link #animateKeepInBounds()}
       * to properly apply changes.
       *
       * @return Current state
       */
      @NonNull
      public State getState() {
          return state;
      }
  
      /**
       * @return State controller to get computed min/max zoom levels or calculate movement area
       */
      @SuppressWarnings("WeakerAccess") // Public API
      @NonNull
      public StateController getStateController() {
          return stateController;
      }
  
      /**
       * Applies state restrictions and notifies {@link OnStateChangeListener} listeners.
       */
      public void updateState() {
          // Applying zoom patch (needed in case if image size is changed)
          stateController.applyZoomPatch(state);
          stateController.applyZoomPatch(prevState);
          stateController.applyZoomPatch(stateStart);
          stateController.applyZoomPatch(stateEnd);
          exitController.applyZoomPatch();
  
          boolean reset = stateController.updateState(state);
          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 = stateController.resetState(state);
          if (reset) {
              notifyStateReset();
          } else {
              notifyStateUpdated();
          }
      }
  
      /**
       * Sets pivot point for zooming when keeping image in bounds.
       *
       * @param pivotX Pivot point's X coordinate
       * @param pivotY Pivot point's Y coordinate
       * @see #animateKeepInBounds()
       * @see #animateStateTo(State)
       */
      public void setPivot(float pivotX, float pivotY) {
          this.pivotX = pivotX;
          this.pivotY = pivotY;
      }
  
      /**
       * Animates to correct position withing the bounds.
       *
       * @return {@code true} if animation started, {@code false} otherwise. Animation may
       * not be started if image already withing the bounds.
       */
      @SuppressWarnings({ "WeakerAccess", "UnusedReturnValue" }) // Public API
      public boolean animateKeepInBounds() {
          return animateStateTo(state, true);
      }
  
      /**
       * Animates current state to provided end state.
       *
       * @param endState 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).
       */
      @SuppressWarnings("UnusedReturnValue")
      public boolean animateStateTo(@Nullable State endState) {
          return animateStateTo(endState, true);
      }
  
      private boolean animateStateTo(@Nullable State endState, boolean keepInBounds) {
          if (endState == null) {
              return false;
          }
  
          stopAllAnimations();
  
          // Ensure we have a correct pivot point
          if (isNaN(pivotX) || isNaN(pivotY)) {
              GravityUtils.getDefaultPivot(settings, tmpPoint);
              pivotX = tmpPoint.x;
              pivotY = tmpPoint.y;
          }
  
          State endStateRestricted = null;
          if (keepInBounds) {
              endStateRestricted = stateController.restrictStateBoundsCopy(
                      endState, prevState, pivotX, pivotY, false, false, true);
          }
          if (endStateRestricted == null) {
              endStateRestricted = endState;
          }
  
          if (endStateRestricted.equals(state)) {
              return false; // Nothing to animate
          }
  
          isAnimatingInBounds = keepInBounds;
          stateStart.set(state);
          stateEnd.set(endStateRestricted);
  
          // Computing new position of pivot point for correct state interpolation
          tmpPointArr[0] = pivotX;
          tmpPointArr[1] = pivotY;
          MathUtils.computeNewPosition(tmpPointArr, stateStart, stateEnd);
          endPivotX = tmpPointArr[0];
          endPivotY = tmpPointArr[1];
  
          stateScroller.setDuration(settings.getAnimationsDuration());
          stateScroller.startScroll(0f, 1f);
          animationEngine.start();
  
          notifyStateSourceChanged();
  
          return true;
      }
  
      @SuppressWarnings("WeakerAccess") // Public API
      public boolean isAnimatingState() {
          return !stateScroller.isFinished();
      }
  
      @SuppressWarnings("WeakerAccess") // Public API
      public boolean isAnimatingFling() {
          return !flingScroller.isFinished();
      }
  
      @SuppressWarnings({ "unused", "WeakerAccess" }) // Public API
      public boolean isAnimating() {
          return isAnimatingState() || isAnimatingFling();
      }
  
      @SuppressWarnings("WeakerAccess") // Public API
      public void stopStateAnimation() {
          if (isAnimatingState()) {
              stateScroller.forceFinished();
              onStateAnimationFinished(true);
          }
      }
  
      @SuppressWarnings("WeakerAccess") // Public API
      public void stopFlingAnimation() {
          if (isAnimatingFling()) {
              flingScroller.forceFinished(true);
              onFlingAnimationFinished(true);
          }
      }
  
      public void stopAllAnimations() {
          stopStateAnimation();
          stopFlingAnimation();
      }
  
      @SuppressWarnings({ "UnusedParameters", "WeakerAccess" }) // Public API (can be overridden)
      protected void onStateAnimationFinished(boolean forced) {
          isAnimatingInBounds = false;
          pivotX = Float.NaN;
          pivotY = Float.NaN;
          endPivotX = Float.NaN;
          endPivotY = Float.NaN;
          notifyStateSourceChanged();
      }
  
      @SuppressWarnings("WeakerAccess") // Public API (can be overridden)
      protected void onFlingAnimationFinished(boolean forced) {
          if (!forced) {
              animateKeepInBounds();
          }
          notifyStateSourceChanged();
      }
  
      @SuppressWarnings("WeakerAccess") // Public API (can be overridden)
      protected void notifyStateUpdated() {
          prevState.set(state);
          for (OnStateChangeListener listener : stateListeners) {
              listener.onStateChanged(state);
          }
      }
  
      @SuppressWarnings("WeakerAccess") // Public API (can be overridden)
      protected void notifyStateReset() {
          exitController.stopDetection();
          for (OnStateChangeListener listener : stateListeners) {
              listener.onStateReset(prevState, state);
          }
          notifyStateUpdated();
      }
  
      private void notifyStateSourceChanged() {
          StateSource type = StateSource.NONE;
          if (isAnimating()) {
              type = StateSource.ANIMATION;
          } else if (isScrollDetected || isScaleDetected || isRotationDetected) {
              type = StateSource.USER;
          }
  
          if (stateSource != type) {
              stateSource = type;
              if (sourceListener != null) {
                  sourceListener.onStateSourceChanged(type);
              }
          }
      }
  
  
      // -------------------
      //  Gestures handling
      // -------------------
  
      public boolean onInterceptTouch(@NonNull View view, @NonNull MotionEvent event) {
          isInterceptTouchCalled = true;
          return onTouchInternal(view, event);
      }
  
      @SuppressLint("ClickableViewAccessibility") // performClick is called in gestures callbacks
      @Override
      public boolean onTouch(@NonNull View view, @NonNull MotionEvent event) {
          if (!isInterceptTouchCalled) { // Preventing duplicate events
              onTouchInternal(view, event);
          }
          isInterceptTouchCalled = false;
          return settings.isEnabled();
      }
  
      protected boolean onTouchInternal(@NonNull View view, @NonNull MotionEvent event) {
          MotionEvent viewportEvent = MotionEvent.obtain(event);
          viewportEvent.offsetLocation(-view.getPaddingLeft(), -view.getPaddingTop());
  
          gestureDetector.setIsLongpressEnabled(view.isLongClickable());
  
          boolean result = gestureDetector.onTouchEvent(viewportEvent);
          scaleDetector.onTouchEvent(viewportEvent);
          rotateDetector.onTouchEvent(viewportEvent);
          result = result || isScaleDetected || isRotationDetected;
  
          notifyStateSourceChanged();
  
          if (exitController.isExitDetected()) {
              if (!state.equals(prevState)) {
                  notifyStateUpdated();
              }
          }
  
          if (isStateChangedDuringTouch) {
              isStateChangedDuringTouch = false;
  
              stateController.restrictStateBounds(
                      state, prevState, pivotX, pivotY, true, true, false);
  
              if (!state.equals(prevState)) {
                  notifyStateUpdated();
              }
          }
  
          if (isRestrictZoomRequested || isRestrictRotationRequested) {
              isRestrictZoomRequested = false;
              isRestrictRotationRequested = false;
  
              if (!exitController.isExitDetected()) {
                  State restrictedState = stateController.restrictStateBoundsCopy(
                          state, prevState, pivotX, pivotY, true, false, true);
                  animateStateTo(restrictedState, false);
              }
          }
  
          if (viewportEvent.getActionMasked() == MotionEvent.ACTION_UP
                  || viewportEvent.getActionMasked() == MotionEvent.ACTION_CANCEL) {
              onUpOrCancel(viewportEvent);
              notifyStateSourceChanged();
          }
  
          if (!isInterceptTouchDisallowed && shouldDisallowInterceptTouch(viewportEvent)) {
              isInterceptTouchDisallowed = true;
  
              final ViewParent parent = view.getParent();
              if (parent != null) {
                  parent.requestDisallowInterceptTouchEvent(true);
              }
          }
  
          viewportEvent.recycle();
  
          return result;
      }
  
      protected boolean shouldDisallowInterceptTouch(MotionEvent event) {
          if (exitController.isExitDetected()) {
              return true;
          }
  
          switch (event.getActionMasked()) {
              case MotionEvent.ACTION_DOWN:
              case MotionEvent.ACTION_MOVE: {
                  // If view can be panned then parent should not intercept touch events.
                  // We should check it on DOWN event since parent may quickly take control over us
                  // in case of a very fast MOVE action.
                  stateController.getMovementArea(state, tmpRectF);
                  final boolean isPannable = State.compare(tmpRectF.width(), 0f) > 0
                          || State.compare(tmpRectF.height(), 0f) > 0;
  
                  if (settings.isPanEnabled() && (isPannable || !settings.isRestrictBounds())) {
                      return true;
                  }
                  break;
              }
              case MotionEvent.ACTION_POINTER_DOWN: {
                  // If view can be zoomed or rotated then parent should not intercept touch events.
                  return settings.isZoomEnabled() || settings.isRotationEnabled();
              }
              default:
          }
  
          return false;
      }
  
      protected boolean onDown(@NonNull MotionEvent event) {
          isInterceptTouchDisallowed = false;
  
          stopFlingAnimation();
  
          if (gestureListener != null) {
              gestureListener.onDown(event);
          }
  
          return false;
      }
  
      protected void onUpOrCancel(@NonNull MotionEvent event) {
          isScrollDetected = false;
          isScaleDetected = false;
          isRotationDetected = false;
  
          exitController.onUpOrCancel();
  
          if (!isAnimatingFling() && !isAnimatingInBounds) {
              animateKeepInBounds();
          }
  
          if (gestureListener != null) {
              gestureListener.onUpOrCancel(event);
          }
      }
  
      @SuppressWarnings("WeakerAccess") // Public API (can be overridden)
      protected boolean onSingleTapUp(@NonNull MotionEvent event) {
          // If double tap is not enabled then it should be safe to propagate click event from here
          if (!settings.isDoubleTapEnabled()) {
              targetView.performClick();
          }
          return gestureListener != null && gestureListener.onSingleTapUp(event);
      }
  
      @SuppressWarnings("WeakerAccess") // Public API (can be overridden)
      protected void onLongPress(@NonNull MotionEvent event) {
          if (settings.isEnabled()) {
              targetView.performLongClick();
  
              if (gestureListener != null) {
                  gestureListener.onLongPress(event);
              }
          }
      }
  
      protected boolean onScroll(@NonNull MotionEvent e1, @NonNull MotionEvent e2,
              float dx, float dy) {
  
          if (!settings.isPanEnabled() || isAnimatingState()) {
              return false;
          }
  
          if (isNaN(dx) || isNaN(dy)) {
              return false; // Invalid scroll, nothing we can do
          }
  
          boolean scrollConsumed = exitController.onScroll(-dx, -dy);
          if (scrollConsumed) {
              return true;
          }
  
          if (!isScrollDetected) {
              isScrollDetected = Math.abs(e2.getX() - e1.getX()) > touchSlop
                      || Math.abs(e2.getY() - e1.getY()) > touchSlop;
  
              // First scroll event can stutter a bit, so we will ignore it for smoother scrolling
              if (isScrollDetected) {
                  // By returning false here we give children views a chance to intercept this scroll
                  return false;
              }
          }
  
          if (isScrollDetected) {
              state.translateBy(-dx, -dy);
              isStateChangedDuringTouch = true;
          }
  
          return isScrollDetected;
      }
  
      protected boolean onFling(@NonNull MotionEvent e1, @NonNull MotionEvent e2,
              float vx, float vy) {
  
          if (!settings.isPanEnabled() || !settings.isFlingEnabled() || isAnimatingState()) {
              return false;
          }
  
          boolean flingConsumed = exitController.onFling();
          if (flingConsumed) {
              return true;
          }
  
          stopFlingAnimation();
  
          // Fling bounds including current position
          flingBounds.set(state).extend(state.getX(), state.getY());
  
          flingScroller.fling(
                  Math.round(state.getX()), Math.round(state.getY()),
                  limitFlingVelocity(vx * FLING_COEFFICIENT),
                  limitFlingVelocity(vy * FLING_COEFFICIENT),
                  Integer.MIN_VALUE, Integer.MAX_VALUE,
                  Integer.MIN_VALUE, Integer.MAX_VALUE);
          animationEngine.start();
  
          notifyStateSourceChanged();
  
          return true;
      }
  
      private int limitFlingVelocity(float velocity) {
          if (Math.abs(velocity) < minVelocity) {
              return 0;
          } else if (Math.abs(velocity) >= maxVelocity) {
              return (int) Math.signum(velocity) * maxVelocity;
          } else {
              return Math.round(velocity);
          }
      }
  
      /**
       * @param dx Current X offset in the fling scroll
       * @param dy Current Y offset in the fling scroll
       * @return true if state was changed, false otherwise.
       */
      @SuppressWarnings("WeakerAccess") // Public API (can be overridden)
      protected boolean onFlingScroll(int dx, int dy) {
          float prevX = state.getX();
          float prevY = state.getY();
          float toX = prevX + dx;
          float toY = prevY + dy;
  
          if (settings.isRestrictBounds()) {
              flingBounds.restrict(toX, toY, tmpPointF);
              toX = tmpPointF.x;
              toY = tmpPointF.y;
          }
  
          state.translateTo(toX, toY);
          return !State.equals(prevX, toX) || !State.equals(prevY, toY);
      }
  
      @SuppressWarnings("WeakerAccess") // Public API (can be overridden)
      protected boolean onSingleTapConfirmed(MotionEvent event) {
          // If double tap is enabled we should propagate click only if we aren't in a double tap now
          if (settings.isDoubleTapEnabled()) {
              targetView.performClick();
          }
          return gestureListener != null && gestureListener.onSingleTapConfirmed(event);
      }
  
      protected boolean onDoubleTapEvent(MotionEvent event) {
          if (!settings.isDoubleTapEnabled()) {
              return false;
          }
  
          if (event.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 (isScaleDetected) {
              return false;
          }
  
          // Let user redefine double tap
          if (gestureListener != null && gestureListener.onDoubleTap(event)) {
              return true;
          }
  
          animateStateTo(stateController.toggleMinMaxZoom(state, event.getX(), event.getY()));
          return true;
      }
  
      protected boolean onScaleBegin(ScaleGestureDetector detector) {
          isScaleDetected = settings.isZoomEnabled();
          if (isScaleDetected) {
              exitController.onScaleBegin();
          }
          return isScaleDetected;
      }
  
      @SuppressWarnings("WeakerAccess") // Public API (can be overridden)
      protected boolean onScale(ScaleGestureDetector detector) {
          if (!settings.isZoomEnabled() || isAnimatingState()) {
              return false; // Ignoring scroll if animation is in progress
          }
  
          final float scaleFactor = detector.getScaleFactor();
          if (isNaN(scaleFactor) || isNaN(detector.getFocusX()) || isNaN(detector.getFocusY())) {
              return false; // Invalid scale, nothing we can do
          }
  
          boolean scaleConsumed = exitController.onScale(scaleFactor);
          if (scaleConsumed) {
              return true;
          }
  
          pivotX = detector.getFocusX();
          pivotY = detector.getFocusY();
          state.zoomBy(scaleFactor, pivotX, pivotY);
          isStateChangedDuringTouch = true;
  
          return true;
      }
  
      @SuppressWarnings({ "UnusedParameters", "WeakerAccess" }) // Public API (can be overridden)
      protected void onScaleEnd(ScaleGestureDetector detector) {
          if (isScaleDetected) {
              exitController.onScaleEnd();
          }
          isScaleDetected = false;
          isRestrictZoomRequested = true;
      }
  
      protected boolean onRotationBegin(RotationGestureDetector detector) {
          isRotationDetected = settings.isRotationEnabled();
          if (isRotationDetected) {
              exitController.onRotationBegin();
          }
          return isRotationDetected;
      }
  
      @SuppressWarnings("WeakerAccess") // Public API (can be overridden)
      protected boolean onRotate(RotationGestureDetector detector) {
          if (!settings.isRotationEnabled() || isAnimatingState()) {
              return false;
          }
  
          boolean rotateConsumed = exitController.onRotate();
          if (rotateConsumed) {
              return true;
          }
  
          pivotX = detector.getFocusX();
          pivotY = detector.getFocusY();
          state.rotateBy(detector.getRotationDelta(), pivotX, pivotY);
          isStateChangedDuringTouch = true;
  
          return true;
      }
  
      @SuppressWarnings({ "UnusedParameters", "WeakerAccess" }) // Public API (can be overridden)
      protected void onRotationEnd(RotationGestureDetector detector) {
          if (isRotationDetected) {
              exitController.onRotationEnd();
          }
          isRotationDetected = false;
          isRestrictRotationRequested = true;
      }
  
      /**
       * Animation engine implementation to animate state changes.
       */
      private class LocalAnimationEngine extends AnimationEngine {
          LocalAnimationEngine(@NonNull View view) {
              super(view);
          }
  
          @Override
          public boolean onStep() {
              boolean shouldProceed = false;
  
              if (isAnimatingFling()) {
                  int prevX = flingScroller.getCurrX();
                  int prevY = flingScroller.getCurrY();
  
                  if (flingScroller.computeScrollOffset()) {
                      int dx = flingScroller.getCurrX() - prevX;
                      int dy = flingScroller.getCurrY() - prevY;
  
                      if (!onFlingScroll(dx, dy)) {
                          stopFlingAnimation();
                      }
  
                      shouldProceed = true;
                  }
  
                  if (!isAnimatingFling()) {
                      onFlingAnimationFinished(false);
                  }
              }
  
              if (isAnimatingState()) {
                  stateScroller.computeScroll();
                  float factor = stateScroller.getCurr();
  
                  MathUtils.interpolate(
                          state,
                          stateStart, pivotX, pivotY,
                          stateEnd, endPivotX, endPivotY,
                          factor
                  );
  
                  shouldProceed = true;
  
                  if (!isAnimatingState()) {
                      onStateAnimationFinished(false);
                  }
              }
  
              if (shouldProceed) {
                  notifyStateUpdated();
              }
  
              return shouldProceed;
          }
      }
  
  
      // -------------------
      //  Listeners
      // -------------------
  
      /**
       * State changes listener.
       */
      @SuppressWarnings("unused")
      public interface OnStateChangeListener {
          void onStateChanged(State state);
  
          void onStateReset(State oldState, State newState);
      }
  
      /**
       * State source changes listener.
       *
       * @see StateSource
       */
      @SuppressWarnings("WeakerAccess") // Public API
      public interface OnStateSourceChangeListener {
          void onStateSourceChanged(StateSource source);
      }
  
      /**
       * Source of state changes. Values: {@link #NONE}, {@link #USER}, {@link #ANIMATION}.
       */
      @SuppressWarnings("WeakerAccess") // Public API
      public enum StateSource {
          NONE, USER, ANIMATION
      }
  
      /**
       * Listener for different touch events.
       */
      @SuppressWarnings({ "WeakerAccess", "EmptyMethod", "SameReturnValue", "unused" }) // Public API
      public interface OnGestureListener {
          /**
           * @param event Motion event
           * @see GestureDetector.OnGestureListener#onDown(MotionEvent)
           */
          void onDown(@NonNull MotionEvent event);
  
          /**
           * @param event Motion event
           */
          void onUpOrCancel(@NonNull MotionEvent event);
  
          /**
           * @param event Motion event
           * @return true if event was consumed, false otherwise.
           * @see GestureDetector.OnGestureListener#onSingleTapUp(MotionEvent)
           */
          boolean onSingleTapUp(@NonNull MotionEvent event);
  
          /**
           * @param event Motion event
           * @return true if event was consumed, false otherwise.
           * @see GestureDetector.OnDoubleTapListener#onSingleTapConfirmed(MotionEvent)
           */
          boolean onSingleTapConfirmed(@NonNull MotionEvent event);
  
          /**
           * Note, that long press is disabled by default, use {@link View#setLongClickable(boolean)}
           * to enable it.
           *
           * @param event Motion event
           * @see GestureDetector.OnGestureListener#onLongPress(MotionEvent)
           */
          void onLongPress(@NonNull MotionEvent event);
  
          /**
           * @param event Motion event
           * @return true if event was consumed, false otherwise.
           * @see GestureDetector.OnDoubleTapListener#onDoubleTap(MotionEvent)
           */
          boolean onDoubleTap(@NonNull MotionEvent event);
      }
  
      /**
       * Simple implementation of {@link GestureController.OnGestureListener}.
       */
      @SuppressWarnings("WeakerAccess") // Public API
      public static class SimpleOnGestureListener implements OnGestureListener {
          /**
           * {@inheritDoc}
           */
          @Override
          public void onDown(@NonNull MotionEvent event) {
              // no-op
          }
  
          /**
           * {@inheritDoc}
           */
          @Override
          public void onUpOrCancel(@NonNull MotionEvent event) {
              // no-op
          }
  
          /**
           * {@inheritDoc}
           */
          @Override
          public boolean onSingleTapUp(@NonNull MotionEvent event) {
              return false;
          }
  
          /**
           * {@inheritDoc}
           */
          @Override
          public boolean onSingleTapConfirmed(@NonNull MotionEvent event) {
              return false;
          }
  
          /**
           * {@inheritDoc}
           */
          @Override
          public void onLongPress(@NonNull MotionEvent event) {
              // no-op
          }
  
          /**
           * {@inheritDoc}
           */
          @Override
          public boolean onDoubleTap(@NonNull MotionEvent event) {
              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 event) {
              return GestureController.this.onSingleTapConfirmed(event);
          }
  
          @Override
          public boolean onDoubleTap(@NonNull MotionEvent event) {
              return false;
          }
  
          @Override
          public boolean onDoubleTapEvent(@NonNull MotionEvent event) {
              return GestureController.this.onDoubleTapEvent(event);
          }
  
          @Override
          public boolean onDown(@NonNull MotionEvent event) {
              return GestureController.this.onDown(event);
          }
  
          @Override
          public void onShowPress(@NonNull MotionEvent event) {
              // No-op
          }
  
          @Override
          public boolean onSingleTapUp(@NonNull MotionEvent event) {
              return GestureController.this.onSingleTapUp(event);
          }
  
          @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 event) {
              GestureController.this.onLongPress(event);
          }
  
          @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);
          }
  
      }
  
  }
  