diff --git a/.arcconfig b/.arcconfig index c3f8319..e664e0a 100644 --- a/.arcconfig +++ b/.arcconfig @@ -6,6 +6,7 @@ ], "arcanist_configuration": "HookConphig", "phabricator.uri": "http://codereview.cc/", + "repository.callsign": "MDMGESTURESANDROID", "arc.land.onto.default": "develop", "arc.feature.start.default": "origin/develop" } diff --git a/CHANGELOG.md b/CHANGELOG.md index e69de29..77ab660 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -0,0 +1,167 @@ +# 1.0.0 + +## New features + +New gesture recognizers for drag, scale, and rotate. + +## Source changes + +* [Move gesture recognizers out from direct-manipulation-android into its own repo.](https://github.com/material-motion/gestures-android/commit/25c61043d739ab1440cff59624bb137c9cbd6514) (Mark Wei) + +## API changes + +Auto-generated by running: + + apidiff origin/stable release-candidate android library + +## DragGestureRecognizer + +*new* class: `DragGestureRecognizer` + +*new* constructor: `DragGestureRecognizer()` + +*new* field: `int dragSlop` + +*new* method: `float getTranslationX()` + +*new* method: `float getTranslationY()` + +*new* method: `float getUntransformedCentroidX()` + +*new* method: `float getUntransformedCentroidY()` + +*new* method: `float getVelocityX()` + +*new* method: `float getVelocityY()` + +*new* method: `boolean onTouchEvent(MotionEvent)` + +*new* method: `void setElement(View)` + + +## GestureRecognizer + +*new* abstract class: `GestureRecognizer` + +*new* constructor: `GestureRecognizer()` + +*new* static final field: `int BEGAN` + +*new* static final field: `int CANCELLED` + +*new* static final field: `int CHANGED` + +*new* static final field: `int POSSIBLE` + +*new* static final field: `int RECOGNIZED` + +*new* method: `void addStateChangeListener(GestureStateChangeListener)` + +*new* final method: `float getCentroidX()` + +*new* final method: `float getCentroidY()` + +*new* method: `View getElement()` + +*new* method: `int getState()` + +*new* static method: `void getTransformationMatrix(View, Matrix, Matrix)` + +*new* abstract method: `float getUntransformedCentroidX()` + +*new* abstract method: `float getUntransformedCentroidY()` + +*new* abstract method: `boolean onTouchEvent(MotionEvent)` + +*new* method: `void removeStateChangeListener(GestureStateChangeListener)` + +*new* method: `void setElement(View)` + + +## GestureRecognizerState + +*new* annotation: `@GestureRecognizerState` + + +## GestureStateChangeListener + +*new* interface: `GestureStateChangeListener` + +*new* method: `void onStateChanged(GestureRecognizer)` + + +## Library + +*removed* class: `Library` + +*removed* constructor: `Library()` + +*removed* static final field: `String LIBRARY_NAME` + + +## RotateGestureRecognizer + +*new* class: `RotateGestureRecognizer` + +*new* constructor: `RotateGestureRecognizer()` + +*new* field: `float rotateSlop` + +*new* method: `float getRotation()` + +*new* method: `float getUntransformedCentroidX()` + +*new* method: `float getUntransformedCentroidY()` + +*new* method: `float getVelocity()` + +*new* method: `boolean onTouchEvent(MotionEvent)` + +*new* method: `void setElement(View)` + + +## ScaleGestureRecognizer + +*new* class: `ScaleGestureRecognizer` + +*new* constructor: `ScaleGestureRecognizer()` + +*new* field: `int scaleSlop` + +*new* method: `float getScale()` + +*new* method: `float getUntransformedCentroidX()` + +*new* method: `float getUntransformedCentroidY()` + +*new* method: `float getVelocity()` + +*new* method: `boolean onTouchEvent(MotionEvent)` + +*new* method: `void setElement(View)` + + +## AccumulationType + +*new* annotation: `@AccumulationType` + + +## SimulatedGestureRecognizer + +*new* class: `SimulatedGestureRecognizer` + +*new* constructor: `SimulatedGestureRecognizer(View)` + +*new* method: `float getUntransformedCentroidX()` + +*new* method: `float getUntransformedCentroidY()` + +*new* method: `boolean onTouchEvent(MotionEvent)` + +*new* method: `void setState(int)` + + + +## Non-source changes + +* [Automatic changelog preparation for release.](https://github.com/material-motion/gestures-android/commit/d0a177c370d004378e2d1b417de3f16885a4f344) (Mark Wei) diff --git a/library/build.gradle b/library/build.gradle index 2697a7a..d579a88 100644 --- a/library/build.gradle +++ b/library/build.gradle @@ -36,6 +36,7 @@ android { dependencies { // If you are developing any dependencies locally, also list them in local.dependencies. + compile 'com.android.support:support-compat:24.2.1' testCompile 'com.google.truth:truth:0.28' testCompile 'junit:junit:4.12' diff --git a/library/src/main/java/com/google/android/material/motion/gestures/DragGestureRecognizer.java b/library/src/main/java/com/google/android/material/motion/gestures/DragGestureRecognizer.java new file mode 100644 index 0000000..5ed26de --- /dev/null +++ b/library/src/main/java/com/google/android/material/motion/gestures/DragGestureRecognizer.java @@ -0,0 +1,195 @@ +/* + * Copyright 2016-present The Material Motion Authors. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.material.motion.gestures; + +import android.content.Context; +import android.graphics.PointF; +import android.support.annotation.Nullable; +import android.support.v4.view.MotionEventCompat; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewConfiguration; + +import static com.google.android.material.motion.gestures.ValueVelocityTracker.ADDITIVE; + +/** + * A gesture recognizer that generates translation events. + */ +public class DragGestureRecognizer extends GestureRecognizer { + + /** + * Touch slop for drag. Amount of pixels that the centroid needs to move in either axes. + */ + public int dragSlop = UNSET_SLOP; + + private float initialCentroidX; + private float initialCentroidY; + private float currentCentroidX; + private float currentCentroidY; + + private ValueVelocityTracker centroidXVelocityTracker; + private ValueVelocityTracker centroidYVelocityTracker; + + @Override + public void setElement(@Nullable View element) { + super.setElement(element); + + if (element == null) { + return; + } + + if (dragSlop == UNSET_SLOP) { + Context context = element.getContext(); + this.dragSlop = ViewConfiguration.get(context).getScaledTouchSlop(); + } + + centroidXVelocityTracker = new ValueVelocityTracker(element.getContext(), ADDITIVE); + centroidYVelocityTracker = new ValueVelocityTracker(element.getContext(), ADDITIVE); + } + + public boolean onTouchEvent(MotionEvent event) { + PointF centroid = calculateUntransformedCentroid(event); + float centroidX = centroid.x; + float centroidY = centroid.y; + + int action = MotionEventCompat.getActionMasked(event); + if (action == MotionEvent.ACTION_DOWN) { + initialCentroidX = centroidX; + initialCentroidY = centroidY; + currentCentroidX = centroidX; + currentCentroidY = centroidY; + + centroidXVelocityTracker.onGestureStart(event, centroidX); + centroidYVelocityTracker.onGestureStart(event, centroidY); + + if (dragSlop == 0) { + setState(BEGAN); + } + } + if (action == MotionEvent.ACTION_POINTER_DOWN + || action == MotionEvent.ACTION_POINTER_UP) { + float adjustX = centroidX - currentCentroidX; + float adjustY = centroidY - currentCentroidY; + + initialCentroidX += adjustX; + initialCentroidY += adjustY; + currentCentroidX += adjustX; + currentCentroidY += adjustY; + + centroidXVelocityTracker.onGestureAdjust(-adjustX); + centroidYVelocityTracker.onGestureAdjust(-adjustY); + } + if (action == MotionEvent.ACTION_MOVE) { + if (!isInProgress()) { + float deltaX = centroidX - initialCentroidX; + float deltaY = centroidY - initialCentroidY; + if (Math.abs(deltaX) > dragSlop || Math.abs(deltaY) > dragSlop) { + float adjustX = Math.signum(deltaX) * Math.min(Math.abs(deltaX), dragSlop); + float adjustY = Math.signum(deltaY) * Math.min(Math.abs(deltaY), dragSlop); + + initialCentroidX += adjustX; + initialCentroidY += adjustY; + currentCentroidX += adjustX; + currentCentroidY += adjustY; + + setState(BEGAN); + } + } + + if (isInProgress()) { + currentCentroidX = centroidX; + currentCentroidY = centroidY; + + setState(CHANGED); + } + + centroidXVelocityTracker.onGestureMove(event, centroidX); + centroidYVelocityTracker.onGestureMove(event, centroidY); + } + if (action == MotionEvent.ACTION_UP + || action == MotionEvent.ACTION_CANCEL) { + initialCentroidX = centroidX; + initialCentroidY = centroidY; + currentCentroidX = centroidX; + currentCentroidY = centroidY; + + centroidXVelocityTracker.onGestureEnd(event, centroidX); + centroidYVelocityTracker.onGestureEnd(event, centroidY); + + if (isInProgress()) { + if (action == MotionEvent.ACTION_UP) { + setState(RECOGNIZED); + } else { + setState(CANCELLED); + } + } + } + + return true; + } + + /** + * Returns the translationX of the drag gesture. + *
+ * This reports the total translation over time since the {@link #BEGAN beginning} of the + * gesture. This is not a delta value from the last {@link #CHANGED update}. + */ + public float getTranslationX() { + return currentCentroidX - initialCentroidX; + } + + /** + * Returns the translationY of the drag gesture. + *
+ * This reports the total translation over time since the {@link #BEGAN beginning} of the + * gesture. This is not a delta value from the last {@link #CHANGED update}. + */ + public float getTranslationY() { + return currentCentroidY - initialCentroidY; + } + + /** + * Returns the positional velocityX of the drag gesture. + *
+ * Only read this when the state is {@link #RECOGNIZED} or {@link #CANCELLED}. + * + * @return The velocity in pixels per second. + */ + public float getVelocityX() { + return centroidXVelocityTracker.getCurrentVelocity(); + } + + /** + * Returns the positional velocityY of the drag gesture. + *
+ * Only read this when the state is {@link #RECOGNIZED} or {@link #CANCELLED}. + * + * @return The velocity in pixels per second. + */ + public float getVelocityY() { + return centroidYVelocityTracker.getCurrentVelocity(); + } + + @Override + public float getUntransformedCentroidX() { + return currentCentroidX; + } + + @Override + public float getUntransformedCentroidY() { + return currentCentroidY; + } +} diff --git a/library/src/main/java/com/google/android/material/motion/gestures/GestureRecognizer.java b/library/src/main/java/com/google/android/material/motion/gestures/GestureRecognizer.java new file mode 100644 index 0000000..3b65261 --- /dev/null +++ b/library/src/main/java/com/google/android/material/motion/gestures/GestureRecognizer.java @@ -0,0 +1,298 @@ +/* + * Copyright 2016-present The Material Motion Authors. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.material.motion.gestures; + +import android.graphics.Matrix; +import android.graphics.PointF; +import android.support.annotation.IntDef; +import android.support.annotation.Nullable; +import android.support.v4.view.MotionEventCompat; +import android.view.MotionEvent; +import android.view.View; +import android.view.View.OnTouchListener; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; + +/** + * A gesture recognizer generates continuous or discrete events from a stream of device input + * events. When attached to an element, any interactions with that element will be interpreted by + * the gesture recognizer and turned into gesture events. The output is often a linear + * transformation of translation, rotation, and/or scale. + *
+ * To use an instance of this class, first set the element with {@link #setElement(View)} then + * forward all touch events from the element's parent to {@link #onTouchEvent(MotionEvent)}. + */ +public abstract class GestureRecognizer { + + /** + * A listener that receives {@link GestureRecognizer} events. + */ + public interface GestureStateChangeListener { + + /** + * Notifies every time on {@link GestureRecognizerState state} change. + *
+ * Implementations should query the provided gesture recognizer for its current state and
+ * properties.
+ *
+ * @param gestureRecognizer the gesture recognizer where the event originated from.
+ */
+ void onStateChanged(GestureRecognizer gestureRecognizer);
+ }
+
+ /**
+ * The gesture recognizer has not yet recognized its gesture, but may be evaluating touch
+ * events. This is the default state.
+ */
+ public static final int POSSIBLE = 0;
+ /**
+ * The gesture recognizer has received touch objects recognized as a continuous gesture.
+ */
+ public static final int BEGAN = 1;
+ /**
+ * The gesture recognizer has received touches recognized as a change to a continuous gesture.
+ */
+ public static final int CHANGED = 2;
+ /**
+ * The gesture recognizer has received touches recognized as the end of a continuous gesture. At
+ * the next cycle of the run loop, the gesture recognizer resets its state to {@link
+ * #POSSIBLE}.
+ */
+ public static final int RECOGNIZED = 3;
+ /**
+ * The gesture recognizer has received touches resulting in the cancellation of a continuous
+ * gesture. At the next cycle of the run loop, the gesture recognizer resets its state to {@link
+ * #POSSIBLE}.
+ */
+ public static final int CANCELLED = 4;
+
+ /**
+ * The state of the gesture recognizer.
+ */
+ @IntDef({POSSIBLE, BEGAN, CHANGED, RECOGNIZED, CANCELLED})
+ @Retention(RetentionPolicy.SOURCE)
+ public @interface GestureRecognizerState {
+
+ }
+
+ protected static final int UNSET_SLOP = -1;
+
+ /* Temporary variables. */
+ private final Matrix matrix = new Matrix();
+ private final float[] array = new float[2];
+ private final PointF pointF = new PointF();
+
+ /**
+ * Inverse transformation matrix that is updated on a untransformed point calculation. Use this
+ * to convert untransformed points back to the element's local coordinate system.
+ */
+ private final Matrix inverse = new Matrix();
+
+ private final List
+ * An untransformed coordinate represents the location of a pointer that is not transformed by
+ * the element's transformation matrix. {@code calculateUntransformedPoint(event, 0).x} is not
+ * necessarily equal to {@code event.getRawX()}.
+ *
+ * @return A point representing the untransformed x and y. The caller should read the values
+ * immediately as the object may be reused in other calculations.
+ */
+ protected PointF calculateUntransformedPoint(MotionEvent event, int pointerIndex) {
+ array[0] = event.getX(pointerIndex);
+ array[1] = event.getY(pointerIndex);
+
+ getTransformationMatrix(element, matrix, inverse);
+ matrix.mapPoints(array);
+ pointF.set(array[0], array[1]);
+
+ return pointF;
+ }
+
+ /**
+ * Calculates the transformation matrices that can convert from local to untransformed
+ * coordinate spaces.
+ *
+ * @param matrix This output matrix can convert from local to untransformed coordinate space.
+ * @param inverse This output matrix can convert from untransformed to local coordinate space.
+ */
+ public static void getTransformationMatrix(View element, Matrix matrix, Matrix inverse) {
+ matrix.reset();
+ matrix.postScale(
+ element.getScaleX(), element.getScaleY(), element.getPivotX(), element.getPivotY());
+ matrix.postRotate(element.getRotation(), element.getPivotX(), element.getPivotY());
+ matrix.postTranslate(element.getTranslationX(), element.getTranslationY());
+
+ // Save the inverse matrix.
+ matrix.invert(inverse);
+ }
+}
diff --git a/library/src/main/java/com/google/android/material/motion/gestures/RotateGestureRecognizer.java b/library/src/main/java/com/google/android/material/motion/gestures/RotateGestureRecognizer.java
new file mode 100644
index 0000000..1a3c429
--- /dev/null
+++ b/library/src/main/java/com/google/android/material/motion/gestures/RotateGestureRecognizer.java
@@ -0,0 +1,215 @@
+/*
+ * Copyright 2016-present The Material Motion Authors. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.material.motion.gestures;
+
+import android.graphics.PointF;
+import android.support.annotation.Nullable;
+import android.support.annotation.VisibleForTesting;
+import android.support.v4.view.MotionEventCompat;
+import android.view.MotionEvent;
+import android.view.View;
+
+import static com.google.android.material.motion.gestures.ValueVelocityTracker.ADDITIVE;
+
+/**
+ * A gesture recognizer that generates scale events.
+ */
+public class RotateGestureRecognizer extends GestureRecognizer {
+
+ /**
+ * Touch slop for rotate. Amount of radians that the angle needs to change.
+ */
+ public float rotateSlop = UNSET_SLOP;
+
+ private float currentCentroidX;
+ private float currentCentroidY;
+
+ private float initialAngle;
+ private float currentAngle;
+
+ private ValueVelocityTracker angleVelocityTracker;
+
+ @Override
+ public void setElement(@Nullable View element) {
+ super.setElement(element);
+
+ if (element == null) {
+ return;
+ }
+
+ if (rotateSlop == UNSET_SLOP) {
+ this.rotateSlop = (float) (Math.PI / 180);
+ }
+
+ angleVelocityTracker = new ValueVelocityTracker(element.getContext(), ADDITIVE);
+ }
+
+ public boolean onTouchEvent(MotionEvent event) {
+ PointF centroid = calculateUntransformedCentroid(event, 2);
+ float centroidX = centroid.x;
+ float centroidY = centroid.y;
+ float angle = calculateAngle(event);
+
+ int action = MotionEventCompat.getActionMasked(event);
+ int pointerCount = event.getPointerCount();
+ if (action == MotionEvent.ACTION_POINTER_DOWN && pointerCount == 2) {
+ currentCentroidX = centroidX;
+ currentCentroidY = centroidY;
+
+ initialAngle = angle;
+ currentAngle = angle;
+
+ angleVelocityTracker.onGestureStart(event, angle);
+
+ if (rotateSlop == 0) {
+ setState(BEGAN);
+ }
+ }
+ if (action == MotionEvent.ACTION_POINTER_DOWN && pointerCount > 2
+ || action == MotionEvent.ACTION_POINTER_UP && pointerCount > 2) {
+ float adjustX = centroidX - currentCentroidX;
+ float adjustY = centroidY - currentCentroidY;
+
+ currentCentroidX += adjustX;
+ currentCentroidY += adjustY;
+
+ float adjustAngle = angle - currentAngle;
+
+ initialAngle += adjustAngle;
+ currentAngle += adjustAngle;
+
+ angleVelocityTracker.onGestureAdjust(-adjustAngle);
+ }
+ if (action == MotionEvent.ACTION_MOVE && pointerCount >= 2) {
+ currentCentroidX = centroidX;
+ currentCentroidY = centroidY;
+
+ if (!isInProgress()) {
+ float deltaAngle = angle - initialAngle;
+ if (Math.abs(deltaAngle) > rotateSlop) {
+ float adjustAngle = Math.signum(deltaAngle) * rotateSlop;
+
+ initialAngle += adjustAngle;
+ currentAngle += adjustAngle;
+
+ setState(BEGAN);
+ }
+ }
+
+ if (isInProgress()) {
+ currentAngle = angle;
+
+ setState(CHANGED);
+ }
+
+ angleVelocityTracker.onGestureMove(event, angle);
+ }
+ if (action == MotionEvent.ACTION_POINTER_UP && pointerCount == 2
+ || action == MotionEvent.ACTION_CANCEL && pointerCount >= 2) {
+ currentCentroidX = centroidX;
+ currentCentroidY = centroidY;
+
+ initialAngle = 0;
+ currentAngle = 0;
+
+ angleVelocityTracker.onGestureEnd(event, angle);
+
+ if (isInProgress()) {
+ if (action == MotionEvent.ACTION_POINTER_UP) {
+ setState(RECOGNIZED);
+ } else {
+ setState(CANCELLED);
+ }
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Returns the rotation of the rotate gesture in radians.
+ *
+ * This reports the total rotation over time since the {@link #BEGAN beginning} of the gesture.
+ * This is not a delta value from the last {@link #CHANGED update}.
+ */
+ public float getRotation() {
+ return currentAngle - initialAngle;
+ }
+
+ /**
+ * Returns the angular velocity of the angle gesture.
+ *
+ * Only read this when the state is {@link #RECOGNIZED} or {@link #CANCELLED}.
+ *
+ * @return The velocity in radians per second.
+ */
+ public float getVelocity() {
+ return angleVelocityTracker.getCurrentVelocity();
+ }
+
+ @Override
+ public float getUntransformedCentroidX() {
+ return currentCentroidX;
+ }
+
+ @Override
+ public float getUntransformedCentroidY() {
+ return currentCentroidY;
+ }
+
+ /**
+ * Calculates the angle between the first two pointers in the given motion event.
+ *
+ * Angle is calculated from finger 0 to finger 1.
+ */
+ private float calculateAngle(MotionEvent event) {
+ int action = MotionEventCompat.getActionMasked(event);
+ int pointerIndex = MotionEventCompat.getActionIndex(event);
+ int pointerCount = event.getPointerCount();
+ if (pointerCount < 2) {
+ return 0;
+ }
+ if (action == MotionEvent.ACTION_POINTER_UP && pointerCount == 2) {
+ return 0;
+ }
+
+ int i0 = 0;
+ int i1 = 1;
+ if (action == MotionEvent.ACTION_POINTER_UP) {
+ if (pointerIndex == 0) {
+ i0++;
+ i1++;
+ } else if (pointerIndex == 1) {
+ i1++;
+ }
+ }
+
+ PointF point = calculateUntransformedPoint(event, i0);
+ float x0 = point.x;
+ float y0 = point.y;
+
+ point = calculateUntransformedPoint(event, i1);
+ float x1 = point.x;
+ float y1 = point.y;
+
+ return angle(x0, y0, x1, y1);
+ }
+
+ @VisibleForTesting
+ static float angle(float x0, float y0, float x1, float y1) {
+ return (float) Math.atan2(y1 - y0, x1 - x0);
+ }
+}
diff --git a/library/src/main/java/com/google/android/material/motion/gestures/ScaleGestureRecognizer.java b/library/src/main/java/com/google/android/material/motion/gestures/ScaleGestureRecognizer.java
new file mode 100644
index 0000000..8f5ce77
--- /dev/null
+++ b/library/src/main/java/com/google/android/material/motion/gestures/ScaleGestureRecognizer.java
@@ -0,0 +1,217 @@
+/*
+ * Copyright 2016-present The Material Motion Authors. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.material.motion.gestures;
+
+import android.content.Context;
+import android.graphics.PointF;
+import android.support.annotation.Nullable;
+import android.support.annotation.VisibleForTesting;
+import android.support.v4.view.MotionEventCompat;
+import android.view.MotionEvent;
+import android.view.View;
+import android.view.ViewConfiguration;
+
+import static com.google.android.material.motion.gestures.ValueVelocityTracker.MULTIPLICATIVE;
+
+/**
+ * A gesture recognizer that generates scale events.
+ */
+public class ScaleGestureRecognizer extends GestureRecognizer {
+
+ /**
+ * Touch slop for scale. Amount of pixels that the span needs to change.
+ */
+ public int scaleSlop = UNSET_SLOP;
+
+ private float currentCentroidX;
+ private float currentCentroidY;
+
+ private float initialSpan;
+ private float currentSpan;
+
+ private ValueVelocityTracker spanVelocityTracker;
+
+ @Override
+ public void setElement(@Nullable View element) {
+ super.setElement(element);
+
+ if (element == null) {
+ return;
+ }
+
+ if (scaleSlop == UNSET_SLOP) {
+ Context context = element.getContext();
+ this.scaleSlop = ViewConfiguration.get(context).getScaledTouchSlop();
+ }
+
+ spanVelocityTracker = new ValueVelocityTracker(element.getContext(), MULTIPLICATIVE);
+ }
+
+ public boolean onTouchEvent(MotionEvent event) {
+ PointF centroid = calculateUntransformedCentroid(event);
+ float centroidX = centroid.x;
+ float centroidY = centroid.y;
+ float span = calculateAverageSpan(event, centroidX, centroidY);
+
+ int action = MotionEventCompat.getActionMasked(event);
+ int pointerCount = event.getPointerCount();
+ if (action == MotionEvent.ACTION_POINTER_DOWN && pointerCount == 2) {
+ currentCentroidX = centroidX;
+ currentCentroidY = centroidY;
+
+ initialSpan = span;
+ currentSpan = span;
+
+ spanVelocityTracker.onGestureStart(event, span);
+
+ if (scaleSlop == 0) {
+ setState(BEGAN);
+ }
+ }
+ if (action == MotionEvent.ACTION_POINTER_DOWN && pointerCount > 2
+ || action == MotionEvent.ACTION_POINTER_UP && pointerCount > 2) {
+ float adjustX = centroidX - currentCentroidX;
+ float adjustY = centroidY - currentCentroidY;
+
+ currentCentroidX += adjustX;
+ currentCentroidY += adjustY;
+
+ float adjustSpan = span / currentSpan;
+
+ initialSpan *= adjustSpan;
+ currentSpan *= adjustSpan;
+
+ spanVelocityTracker.onGestureAdjust(1 / adjustSpan);
+ }
+ if (action == MotionEvent.ACTION_MOVE && pointerCount >= 2) {
+ currentCentroidX = centroidX;
+ currentCentroidY = centroidY;
+
+ if (!isInProgress()) {
+ float deltaSpan = span - initialSpan;
+ if (Math.abs(deltaSpan) > scaleSlop) {
+ float adjustSpan = 1 + Math.signum(deltaSpan) * (scaleSlop / initialSpan);
+
+ initialSpan *= adjustSpan;
+ currentSpan *= adjustSpan;
+
+ setState(BEGAN);
+ }
+ }
+
+ if (isInProgress()) {
+ currentSpan = span;
+
+ setState(CHANGED);
+ }
+
+ spanVelocityTracker.onGestureMove(event, span);
+ }
+ if (action == MotionEvent.ACTION_POINTER_UP && pointerCount == 2
+ || action == MotionEvent.ACTION_CANCEL && pointerCount >= 2) {
+ currentCentroidX = centroidX;
+ currentCentroidY = centroidY;
+
+ initialSpan = 0;
+ currentSpan = 0;
+
+ spanVelocityTracker.onGestureEnd(event, span);
+
+ if (isInProgress()) {
+ if (action == MotionEvent.ACTION_POINTER_UP) {
+ setState(RECOGNIZED);
+ } else {
+ setState(CANCELLED);
+ }
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Returns the scale of the pinch gesture.
+ *
+ * This reports the total scale over time since the {@link #BEGAN beginning} of the gesture.
+ * This is not a delta value from the last {@link #CHANGED update}.
+ */
+ public float getScale() {
+ return initialSpan > 0 ? currentSpan / initialSpan : 1;
+ }
+
+ /**
+ * Returns the scalar velocity of the scale gesture.
+ *
+ * Only read this when the state is {@link #RECOGNIZED} or {@link #CANCELLED}.
+ *
+ * @return The velocity in pixels per second.
+ */
+ public float getVelocity() {
+ return spanVelocityTracker.getCurrentVelocity();
+ }
+
+ @Override
+ public float getUntransformedCentroidX() {
+ return currentCentroidX;
+ }
+
+ @Override
+ public float getUntransformedCentroidY() {
+ return currentCentroidY;
+ }
+
+ /**
+ * Calculates the average span of all the active pointers in the given motion event.
+ *
+ * The average span is twice the average distance of all pointers to the given centroid.
+ */
+ private float calculateAverageSpan(MotionEvent event, float centroidX, float centroidY) {
+ int action = MotionEventCompat.getActionMasked(event);
+ int index = MotionEventCompat.getActionIndex(event);
+
+ float sum = 0;
+ int num = 0;
+ for (int i = 0, count = event.getPointerCount(); i < count; i++) {
+ if (action == MotionEvent.ACTION_POINTER_UP && index == i) {
+ continue;
+ }
+
+ sum += calculateDistance(event, i, centroidX, centroidY);
+ num++;
+ }
+
+ float averageDistance = sum / num;
+ return averageDistance * 2;
+ }
+
+ /**
+ * Calculates the distance between the pointer given by the pointer index and the given
+ * centroid.
+ */
+ private float calculateDistance(
+ MotionEvent event, int pointerIndex, float centroidX, float centroidY) {
+ PointF untransformedPoint = calculateUntransformedPoint(event, pointerIndex);
+
+ return dist(centroidX, centroidY, untransformedPoint.x, untransformedPoint.y);
+ }
+
+ @VisibleForTesting
+ static float dist(float x0, float y0, float x1, float y1) {
+ float dx = x1 - x0;
+ float dy = y1 - y0;
+ return (float) Math.sqrt(dx * dx + dy * dy);
+ }
+}
diff --git a/library/src/main/java/com/google/android/material/motion/gestures/ValueVelocityTracker.java b/library/src/main/java/com/google/android/material/motion/gestures/ValueVelocityTracker.java
new file mode 100644
index 0000000..82bf794
--- /dev/null
+++ b/library/src/main/java/com/google/android/material/motion/gestures/ValueVelocityTracker.java
@@ -0,0 +1,168 @@
+/*
+ * Copyright 2016-present The Material Motion Authors. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.material.motion.gestures;
+
+import android.content.Context;
+import android.support.annotation.IntDef;
+import android.support.annotation.Nullable;
+import android.support.v4.view.MotionEventCompat;
+import android.view.MotionEvent;
+import android.view.VelocityTracker;
+import android.view.ViewConfiguration;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+
+/**
+ * A velocity tracker for any arbitrary value. Uses a {@link VelocityTracker} under the hood which
+ * is fed specially crafted {@link MotionEvent}s.
+ */
+class ValueVelocityTracker {
+
+ /**
+ * A type of value that is accumulated as a additive sum.
+ */
+ public static final int ADDITIVE = 0;
+
+ /**
+ * A type of value that is accumulated as a multiplicative product.
+ */
+ public static final int MULTIPLICATIVE = 1;
+
+ /**
+ * A type that describes how a value is accumulated.
+ */
+ @IntDef({ADDITIVE, MULTIPLICATIVE})
+ @Retention(RetentionPolicy.SOURCE)
+ public @interface AccumulationType {
+
+ }
+
+ private static final int PIXELS_PER_SECOND = 1000;
+ private static final float DONT_CARE = 0f;
+
+ private final float maximumFlingVelocity;
+ @AccumulationType
+ private final int type;
+
+ @Nullable
+ private VelocityTracker velocityTracker;
+ private float adjust;
+ private float currentVelocity;
+
+ public ValueVelocityTracker(Context context, @AccumulationType int type) {
+ this.maximumFlingVelocity = ViewConfiguration.get(context).getScaledMaximumFlingVelocity();
+ this.type = type;
+ }
+
+ /**
+ * Returns the velocity calculated in the most recent {@link #onGestureEnd(MotionEvent,
+ * float)}.
+ */
+ public float getCurrentVelocity() {
+ return currentVelocity;
+ }
+
+ /**
+ * Processes the start of a gesture.
+ *
+ * Must be balanced with a call to {@link #onGestureEnd(MotionEvent, float)} to end the
+ * gesture.
+ */
+ public void onGestureStart(MotionEvent event, float value) {
+ velocityTracker = VelocityTracker.obtain();
+ if (type == ADDITIVE) {
+ adjust = 0f;
+ } else {
+ adjust = 1f;
+ }
+ currentVelocity = 0f;
+
+ addValueMovement(event, value);
+ }
+
+ /**
+ * Processes the adjustment of a gesture. Call this if you do not want the value to jump
+ * discontinuously on additional fingers entering and exiting the gesture.
+ *
+ * May be called multiple times during a gesture.
+ */
+ public void onGestureAdjust(float adjust) {
+ this.adjust = adjust;
+ }
+
+ /**
+ * Processes the movement of a gesture.
+ *
+ * May be called multiple times during a gesture.
+ */
+ public void onGestureMove(MotionEvent event, float value) {
+ addValueMovement(event, value);
+ }
+
+ /**
+ * Processes the end of a gesture.
+ *
+ * Must be balanced with a previous call to {@link #onGestureStart(MotionEvent, float)}.
+ */
+ public void onGestureEnd(MotionEvent event, float value) {
+ addValueMovement(event, value);
+
+ velocityTracker.computeCurrentVelocity(PIXELS_PER_SECOND, maximumFlingVelocity);
+ currentVelocity = velocityTracker.getXVelocity();
+
+ velocityTracker.recycle();
+ velocityTracker = null;
+ }
+
+ private void addValueMovement(MotionEvent event, float value) {
+ int valueMovementAction;
+
+ int action = MotionEventCompat.getActionMasked(event);
+ switch (action) {
+ case MotionEvent.ACTION_DOWN:
+ case MotionEvent.ACTION_MOVE:
+ case MotionEvent.ACTION_UP:
+ case MotionEvent.ACTION_CANCEL:
+ valueMovementAction = action;
+ break;
+ case MotionEvent.ACTION_POINTER_DOWN:
+ valueMovementAction = MotionEvent.ACTION_DOWN;
+ break;
+ case MotionEvent.ACTION_POINTER_UP:
+ valueMovementAction = MotionEvent.ACTION_UP;
+ break;
+ default:
+ throw new IllegalArgumentException("Unexpected action for event: " + event);
+ }
+ velocityTracker.addMovement(
+ MotionEvent.obtain(
+ event.getDownTime(),
+ event.getEventTime(),
+ valueMovementAction,
+ apply(value, adjust),
+ DONT_CARE,
+ event.getMetaState()));
+ }
+
+ private float apply(float value, float adjust) {
+ if (type == ADDITIVE) {
+ return value + adjust;
+ } else {
+ return value * adjust;
+ }
+ }
+}
diff --git a/library/src/main/java/com/google/android/material/motion/gestures/testing/SimulatedGestureRecognizer.java b/library/src/main/java/com/google/android/material/motion/gestures/testing/SimulatedGestureRecognizer.java
new file mode 100644
index 0000000..5bd6ef3
--- /dev/null
+++ b/library/src/main/java/com/google/android/material/motion/gestures/testing/SimulatedGestureRecognizer.java
@@ -0,0 +1,51 @@
+/*
+ * Copyright 2016-present The Material Motion Authors. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.material.motion.gestures.testing;
+
+import android.view.MotionEvent;
+import android.view.View;
+
+import com.google.android.material.motion.gestures.GestureRecognizer;
+
+/**
+ * A no-op gesture recognizer for testing that exposes {@link #setState(int)}.
+ */
+public class SimulatedGestureRecognizer extends GestureRecognizer {
+
+ public SimulatedGestureRecognizer(View element) {
+ setElement(element);
+ }
+
+ @Override
+ public boolean onTouchEvent(MotionEvent event) {
+ return false;
+ }
+
+ @Override
+ public float getUntransformedCentroidX() {
+ return 0;
+ }
+
+ @Override
+ public float getUntransformedCentroidY() {
+ return 0;
+ }
+
+ @Override
+ public void setState(@GestureRecognizerState int state) {
+ super.setState(state);
+ }
+}
diff --git a/library/src/test/java/com/google/android/material/motion/gestures/DragGestureRecognizerTests.java b/library/src/test/java/com/google/android/material/motion/gestures/DragGestureRecognizerTests.java
new file mode 100644
index 0000000..f6b0dea
--- /dev/null
+++ b/library/src/test/java/com/google/android/material/motion/gestures/DragGestureRecognizerTests.java
@@ -0,0 +1,268 @@
+/*
+ * Copyright 2016-present The Material Motion Authors. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.material.motion.gestures;
+
+import android.app.Activity;
+import android.content.Context;
+import android.view.MotionEvent;
+import android.view.View;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.Robolectric;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.annotation.Config;
+
+import static com.google.android.material.motion.gestures.GestureRecognizer.BEGAN;
+import static com.google.android.material.motion.gestures.GestureRecognizer.CANCELLED;
+import static com.google.android.material.motion.gestures.GestureRecognizer.CHANGED;
+import static com.google.android.material.motion.gestures.GestureRecognizer.POSSIBLE;
+import static com.google.android.material.motion.gestures.GestureRecognizer.RECOGNIZED;
+import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+@RunWith(RobolectricTestRunner.class)
+@Config(constants = BuildConfig.class, sdk = 21)
+public class DragGestureRecognizerTests {
+
+ private static final float E = 0.0001f;
+
+ private View element;
+ private DragGestureRecognizer dragGestureRecognizer;
+
+ private long eventDownTime;
+ private long eventTime;
+
+ @Before
+ public void setUp() {
+ Context context = Robolectric.setupActivity(Activity.class);
+ element = new View(context);
+ dragGestureRecognizer = new DragGestureRecognizer();
+ dragGestureRecognizer.setElement(element);
+ dragGestureRecognizer.dragSlop = 0;
+
+ eventDownTime = 0;
+ eventTime = -16;
+ }
+
+ @Test
+ public void defaultState() {
+ assertThat(dragGestureRecognizer.getState()).isEqualTo(POSSIBLE);
+ assertThat(dragGestureRecognizer.getElement()).isEqualTo(element);
+ assertThat(dragGestureRecognizer.getUntransformedCentroidX()).isWithin(0).of(0f);
+ assertThat(dragGestureRecognizer.getUntransformedCentroidY()).isWithin(0).of(0f);
+ assertThat(dragGestureRecognizer.getTranslationX()).isWithin(0).of(0f);
+ assertThat(dragGestureRecognizer.getTranslationY()).isWithin(0).of(0f);
+ assertThat(dragGestureRecognizer.getVelocityX()).isWithin(0).of(0f);
+ assertThat(dragGestureRecognizer.getVelocityY()).isWithin(0).of(0f);
+ }
+
+ @Test
+ public void smallMovementIsNotRecognized() {
+ dragGestureRecognizer.dragSlop = 24;
+
+ TrackingGestureStateChangeListener listener = new TrackingGestureStateChangeListener();
+ dragGestureRecognizer.addStateChangeListener(listener);
+ assertThat(dragGestureRecognizer.getState()).isEqualTo(POSSIBLE);
+ assertThat(listener.states.toArray()).isEqualTo(new Integer[]{POSSIBLE});
+
+ dragGestureRecognizer.onTouchEvent(createMotionEvent(MotionEvent.ACTION_DOWN, 0, 0));
+ assertThat(dragGestureRecognizer.getState()).isEqualTo(POSSIBLE);
+ assertThat(listener.states.toArray()).isEqualTo(new Integer[]{POSSIBLE});
+
+ // Move 1 pixel. Should not change the state.
+ dragGestureRecognizer.onTouchEvent(createMotionEvent(MotionEvent.ACTION_MOVE, 1, 0));
+ assertThat(dragGestureRecognizer.getState()).isEqualTo(POSSIBLE);
+ assertThat(listener.states.toArray()).isEqualTo(new Integer[]{POSSIBLE});
+ }
+
+ @Test
+ public void largeHorizontalMovementIsRecognized() {
+ dragGestureRecognizer.dragSlop = 24;
+
+ TrackingGestureStateChangeListener listener = new TrackingGestureStateChangeListener();
+ dragGestureRecognizer.addStateChangeListener(listener);
+ assertThat(dragGestureRecognizer.getState()).isEqualTo(POSSIBLE);
+ assertThat(listener.states.toArray()).isEqualTo(new Integer[]{POSSIBLE});
+
+ dragGestureRecognizer.onTouchEvent(createMotionEvent(MotionEvent.ACTION_DOWN, 0, 0));
+ assertThat(dragGestureRecognizer.getState()).isEqualTo(POSSIBLE);
+ assertThat(listener.states.toArray()).isEqualTo(new Integer[]{POSSIBLE});
+
+ // Move 100 pixel right. Should change the state.
+ dragGestureRecognizer.onTouchEvent(createMotionEvent(MotionEvent.ACTION_MOVE, 100, 0));
+ assertThat(dragGestureRecognizer.getState()).isEqualTo(CHANGED);
+ assertThat(listener.states.toArray()).isEqualTo(new Integer[]{POSSIBLE, BEGAN, CHANGED});
+
+ // Move 1 pixel. Should still change the state.
+ dragGestureRecognizer.onTouchEvent(createMotionEvent(MotionEvent.ACTION_MOVE, 101, 0));
+ assertThat(dragGestureRecognizer.getState()).isEqualTo(CHANGED);
+ assertThat(listener.states.toArray()).isEqualTo(new Integer[]{POSSIBLE, BEGAN, CHANGED, CHANGED});
+ }
+
+ @Test
+ public void largeVerticalMovementIsRecognized() {
+ dragGestureRecognizer.dragSlop = 24;
+
+ TrackingGestureStateChangeListener listener = new TrackingGestureStateChangeListener();
+ dragGestureRecognizer.addStateChangeListener(listener);
+ assertThat(dragGestureRecognizer.getState()).isEqualTo(POSSIBLE);
+ assertThat(listener.states.toArray()).isEqualTo(new Integer[]{POSSIBLE});
+
+ dragGestureRecognizer.onTouchEvent(createMotionEvent(MotionEvent.ACTION_DOWN, 0, 0));
+ assertThat(dragGestureRecognizer.getState()).isEqualTo(POSSIBLE);
+ assertThat(listener.states.toArray()).isEqualTo(new Integer[]{POSSIBLE});
+
+ // Move 100 pixel right. Should change the state.
+ dragGestureRecognizer.onTouchEvent(createMotionEvent(MotionEvent.ACTION_MOVE, 0, 100));
+ assertThat(dragGestureRecognizer.getState()).isEqualTo(CHANGED);
+ assertThat(listener.states.toArray()).isEqualTo(new Integer[]{POSSIBLE, BEGAN, CHANGED});
+
+ // Move 1 pixel. Should still change the state.
+ dragGestureRecognizer.onTouchEvent(createMotionEvent(MotionEvent.ACTION_MOVE, 0, 101));
+ assertThat(dragGestureRecognizer.getState()).isEqualTo(CHANGED);
+ assertThat(listener.states.toArray()).isEqualTo(new Integer[]{POSSIBLE, BEGAN, CHANGED, CHANGED});
+ }
+
+ @Test
+ public void completedGestureIsRecognized() {
+ TrackingGestureStateChangeListener listener = new TrackingGestureStateChangeListener();
+ dragGestureRecognizer.addStateChangeListener(listener);
+ dragGestureRecognizer.onTouchEvent(createMotionEvent(MotionEvent.ACTION_DOWN, 0, 0));
+ dragGestureRecognizer.onTouchEvent(createMotionEvent(MotionEvent.ACTION_MOVE, 100, 0));
+ dragGestureRecognizer.onTouchEvent(createMotionEvent(MotionEvent.ACTION_UP, 100, 0));
+
+ assertThat(dragGestureRecognizer.getState()).isEqualTo(POSSIBLE);
+ assertThat(listener.states.toArray())
+ .isEqualTo(new Integer[]{POSSIBLE, BEGAN, CHANGED, RECOGNIZED, POSSIBLE});
+ }
+
+ @Test
+ public void cancelledGestureIsNotRecognized() {
+ TrackingGestureStateChangeListener listener = new TrackingGestureStateChangeListener();
+ dragGestureRecognizer.addStateChangeListener(listener);
+ dragGestureRecognizer.onTouchEvent(createMotionEvent(MotionEvent.ACTION_DOWN, 0, 0));
+ dragGestureRecognizer.onTouchEvent(createMotionEvent(MotionEvent.ACTION_MOVE, 100, 0));
+ dragGestureRecognizer.onTouchEvent(createMotionEvent(MotionEvent.ACTION_CANCEL, 100, 0));
+
+ assertThat(dragGestureRecognizer.getState()).isEqualTo(POSSIBLE);
+ assertThat(listener.states.toArray())
+ .isEqualTo(new Integer[]{POSSIBLE, BEGAN, CHANGED, CANCELLED, POSSIBLE});
+ }
+
+ @Test
+ public void noMovementIsNotRecognized() {
+ dragGestureRecognizer.dragSlop = 24;
+
+ TrackingGestureStateChangeListener listener = new TrackingGestureStateChangeListener();
+ dragGestureRecognizer.addStateChangeListener(listener);
+ dragGestureRecognizer.onTouchEvent(createMotionEvent(MotionEvent.ACTION_DOWN, 0, 0));
+ dragGestureRecognizer.onTouchEvent(createMotionEvent(MotionEvent.ACTION_UP, 0, 0));
+
+ assertThat(dragGestureRecognizer.getState()).isEqualTo(POSSIBLE);
+ assertThat(listener.states.toArray()).isEqualTo(new Integer[]{POSSIBLE});
+ }
+
+ @Test
+ public void irrelevantMotionIsIgnored() {
+ TrackingGestureStateChangeListener listener = new TrackingGestureStateChangeListener();
+ dragGestureRecognizer.addStateChangeListener(listener);
+
+ dragGestureRecognizer.onTouchEvent(createMotionEvent(MotionEvent.ACTION_HOVER_MOVE, 0, 0));
+
+ assertThat(dragGestureRecognizer.getState()).isEqualTo(POSSIBLE);
+ assertThat(listener.states.toArray()).isEqualTo(new Integer[]{POSSIBLE});
+ }
+
+ @Test
+ public void multitouchHasCorrectCentroidAndTranslation() {
+ TrackingGestureStateChangeListener listener = new TrackingGestureStateChangeListener();
+ dragGestureRecognizer.addStateChangeListener(listener);
+
+ // First finger down. Centroid is at finger location and translation is 0.
+ dragGestureRecognizer.onTouchEvent(createMotionEvent(MotionEvent.ACTION_DOWN, 0, 0));
+ assertThat(dragGestureRecognizer.getUntransformedCentroidX()).isWithin(E).of(0);
+ assertThat(dragGestureRecognizer.getUntransformedCentroidY()).isWithin(E).of(0);
+ assertThat(dragGestureRecognizer.getTranslationX()).isWithin(E).of(0);
+ assertThat(dragGestureRecognizer.getTranslationY()).isWithin(E).of(0);
+
+ // Second finger down. Centroid is in between fingers and translation is 0.
+ dragGestureRecognizer.onTouchEvent(
+ createMultiTouchMotionEvent(MotionEvent.ACTION_POINTER_DOWN, 1, 0, 0, 100, 100));
+ assertThat(dragGestureRecognizer.getUntransformedCentroidX()).isWithin(E).of(50);
+ assertThat(dragGestureRecognizer.getUntransformedCentroidY()).isWithin(E).of(50);
+ assertThat(dragGestureRecognizer.getTranslationX()).isWithin(E).of(0);
+ assertThat(dragGestureRecognizer.getTranslationY()).isWithin(E).of(0);
+
+ // Second finger moves [dx, dy]. Centroid and translation moves [dx/2, dy/2].
+ float dx = 505;
+ float dy = 507;
+ dragGestureRecognizer.onTouchEvent(
+ createMultiTouchMotionEvent(MotionEvent.ACTION_MOVE, 1, 0, 0, 100 + dx, 100 + dy));
+ assertThat(dragGestureRecognizer.getUntransformedCentroidX()).isWithin(E).of(50 + dx / 2);
+ assertThat(dragGestureRecognizer.getUntransformedCentroidY()).isWithin(E).of(50 + dy / 2);
+ assertThat(dragGestureRecognizer.getTranslationX()).isWithin(E).of(dx / 2);
+ assertThat(dragGestureRecognizer.getTranslationY()).isWithin(E).of(dy / 2);
+
+ // Second finger up. Centroid is at first finger location and translation stays the same.
+ dragGestureRecognizer.onTouchEvent(
+ createMultiTouchMotionEvent(MotionEvent.ACTION_POINTER_UP, 1, 0, 0, 100 + dx, 100 + dy));
+ assertThat(dragGestureRecognizer.getUntransformedCentroidX()).isWithin(E).of(0);
+ assertThat(dragGestureRecognizer.getUntransformedCentroidY()).isWithin(E).of(0);
+ assertThat(dragGestureRecognizer.getTranslationX()).isWithin(E).of(dx / 2);
+ assertThat(dragGestureRecognizer.getTranslationY()).isWithin(E).of(dy / 2);
+
+ // Finger up. Centroid is at first finger location and translation is reset.
+ dragGestureRecognizer.onTouchEvent(
+ createMotionEvent(MotionEvent.ACTION_UP, 0, 0));
+ assertThat(dragGestureRecognizer.getUntransformedCentroidX()).isWithin(E).of(0);
+ assertThat(dragGestureRecognizer.getUntransformedCentroidY()).isWithin(E).of(0);
+ assertThat(dragGestureRecognizer.getTranslationX()).isWithin(E).of(0);
+ assertThat(dragGestureRecognizer.getTranslationY()).isWithin(E).of(0);
+
+ assertThat(listener.states.toArray()).isEqualTo(new Integer[]{POSSIBLE, BEGAN, CHANGED, RECOGNIZED, POSSIBLE});
+ }
+
+ private MotionEvent createMotionEvent(int action, float x, float y) {
+ return MotionEvent.obtain(eventDownTime, eventTime += 16, action, x, y, 0);
+ }
+
+ private MotionEvent createMultiTouchMotionEvent(
+ int action, int index, float x0, float y0, float x1, float y1) {
+ MotionEvent event = mock(MotionEvent.class);
+
+ when(event.getDownTime()).thenReturn(eventDownTime);
+ when(event.getEventTime()).thenReturn(eventTime += 16);
+
+ when(event.getPointerCount()).thenReturn(2);
+ when(event.getAction()).thenReturn(action | (index << MotionEvent.ACTION_POINTER_INDEX_SHIFT));
+ when(event.getActionMasked()).thenReturn(action);
+ when(event.getActionIndex()).thenReturn(index);
+
+ when(event.getRawX()).thenReturn(x0);
+ when(event.getRawY()).thenReturn(y0);
+
+ when(event.getX(0)).thenReturn(x0);
+ when(event.getY(0)).thenReturn(y0);
+
+ when(event.getX(1)).thenReturn(x1);
+ when(event.getY(1)).thenReturn(y1);
+
+ return event;
+ }
+}
diff --git a/library/src/test/java/com/google/android/material/motion/gestures/GestureRecognizerTests.java b/library/src/test/java/com/google/android/material/motion/gestures/GestureRecognizerTests.java
new file mode 100644
index 0000000..0898a9a
--- /dev/null
+++ b/library/src/test/java/com/google/android/material/motion/gestures/GestureRecognizerTests.java
@@ -0,0 +1,90 @@
+/*
+ * Copyright 2016-present The Material Motion Authors. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.material.motion.gestures;
+
+import android.app.Activity;
+import android.content.Context;
+import android.view.MotionEvent;
+import android.view.View;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.Robolectric;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.annotation.Config;
+
+import static com.google.android.material.motion.gestures.GestureRecognizer.BEGAN;
+import static com.google.android.material.motion.gestures.GestureRecognizer.CHANGED;
+import static com.google.android.material.motion.gestures.GestureRecognizer.POSSIBLE;
+import static com.google.common.truth.Truth.assertThat;
+
+@RunWith(RobolectricTestRunner.class)
+@Config(constants = BuildConfig.class, sdk = 21)
+public class GestureRecognizerTests {
+
+ private View element;
+ private GestureRecognizer gestureRecognizer;
+
+ @Before
+ public void setUp() {
+ Context context = Robolectric.setupActivity(Activity.class);
+ element = new View(context);
+ gestureRecognizer = new DragGestureRecognizer();
+ gestureRecognizer.setElement(element);
+ }
+
+ @Test
+ public void removedListenerDoesNotGetEvents() {
+ TrackingGestureStateChangeListener listener = new TrackingGestureStateChangeListener();
+ gestureRecognizer.addStateChangeListener(listener);
+ assertThat(listener.states.toArray()).isEqualTo(new Integer[]{POSSIBLE});
+
+ gestureRecognizer.setState(BEGAN);
+ assertThat(listener.states.toArray()).isEqualTo(new Integer[]{POSSIBLE, BEGAN});
+
+ gestureRecognizer.removeStateChangeListener(listener);
+ gestureRecognizer.setState(CHANGED);
+ assertThat(listener.states.toArray()).isEqualTo(new Integer[]{POSSIBLE, BEGAN});
+
+ }
+
+ @Test
+ public void addingSameListenerTwiceDoesNotSendTwoEvents() {
+ TrackingGestureStateChangeListener listener = new TrackingGestureStateChangeListener();
+
+ gestureRecognizer.addStateChangeListener(listener);
+ gestureRecognizer.addStateChangeListener(listener);
+
+ assertThat(listener.states.toArray()).isEqualTo(new Integer[]{POSSIBLE});
+
+ gestureRecognizer.setState(BEGAN);
+
+ assertThat(listener.states.toArray()).isEqualTo(new Integer[]{POSSIBLE, BEGAN});
+ }
+
+ @Test
+ public void canSetNullElement() {
+ gestureRecognizer.setElement(null);
+ }
+
+ @Test(expected = NullPointerException.class)
+ public void cannotPassEventsToNullElement() {
+ gestureRecognizer.setElement(null);
+ gestureRecognizer.onTouchEvent(MotionEvent.obtain(0, 0, MotionEvent.ACTION_DOWN, 0, 0, 0));
+ gestureRecognizer.onTouchEvent(MotionEvent.obtain(0, 0, MotionEvent.ACTION_MOVE, 200, 200, 0));
+ }
+}
diff --git a/library/src/test/java/com/google/android/material/motion/gestures/RotateGestureRecognizerTests.java b/library/src/test/java/com/google/android/material/motion/gestures/RotateGestureRecognizerTests.java
new file mode 100644
index 0000000..3e57710
--- /dev/null
+++ b/library/src/test/java/com/google/android/material/motion/gestures/RotateGestureRecognizerTests.java
@@ -0,0 +1,416 @@
+/*
+ * Copyright 2016-present The Material Motion Authors. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.material.motion.gestures;
+
+import android.app.Activity;
+import android.content.Context;
+import android.view.MotionEvent;
+import android.view.View;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.Robolectric;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.annotation.Config;
+
+import static com.google.android.material.motion.gestures.GestureRecognizer.BEGAN;
+import static com.google.android.material.motion.gestures.GestureRecognizer.CANCELLED;
+import static com.google.android.material.motion.gestures.GestureRecognizer.CHANGED;
+import static com.google.android.material.motion.gestures.GestureRecognizer.POSSIBLE;
+import static com.google.android.material.motion.gestures.GestureRecognizer.RECOGNIZED;
+import static com.google.android.material.motion.gestures.RotateGestureRecognizer.angle;
+import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+@RunWith(RobolectricTestRunner.class)
+@Config(constants = BuildConfig.class, sdk = 21)
+public class RotateGestureRecognizerTests {
+
+ private static final float E = 0.0001f;
+
+ private View element;
+ private RotateGestureRecognizer rotateGestureRecognizer;
+
+ private long eventDownTime;
+ private long eventTime;
+
+ @Before
+ public void setUp() {
+ Context context = Robolectric.setupActivity(Activity.class);
+ element = new View(context);
+ rotateGestureRecognizer = new RotateGestureRecognizer();
+ rotateGestureRecognizer.setElement(element);
+ rotateGestureRecognizer.rotateSlop = 0;
+
+ eventDownTime = 0;
+ eventTime = -16;
+ }
+
+ @Test
+ public void defaultState() {
+ assertThat(rotateGestureRecognizer.getState()).isEqualTo(POSSIBLE);
+ assertThat(rotateGestureRecognizer.getElement()).isEqualTo(element);
+ assertThat(rotateGestureRecognizer.getUntransformedCentroidX()).isWithin(0).of(0f);
+ assertThat(rotateGestureRecognizer.getUntransformedCentroidY()).isWithin(0).of(0f);
+ assertThat(rotateGestureRecognizer.getRotation()).isWithin(0).of(0f);
+ assertThat(rotateGestureRecognizer.getVelocity()).isWithin(0).of(0f);
+ }
+
+ @Test
+ public void smallMovementIsNotRecognized() {
+ rotateGestureRecognizer.rotateSlop = (float) (Math.PI / 4); // 45 degrees.
+
+ TrackingGestureStateChangeListener listener = new TrackingGestureStateChangeListener();
+ rotateGestureRecognizer.addStateChangeListener(listener);
+ assertThat(rotateGestureRecognizer.getState()).isEqualTo(POSSIBLE);
+ assertThat(listener.states.toArray()).isEqualTo(new Integer[]{POSSIBLE});
+
+ // First finger down.
+ rotateGestureRecognizer.onTouchEvent(createMotionEvent(MotionEvent.ACTION_DOWN, 0, 0));
+ assertThat(rotateGestureRecognizer.getState()).isEqualTo(POSSIBLE);
+ assertThat(listener.states.toArray()).isEqualTo(new Integer[]{POSSIBLE});
+
+ // Second finger down.
+ rotateGestureRecognizer.onTouchEvent(createMultiTouchMotionEvent(MotionEvent.ACTION_POINTER_DOWN, 1, 0, 0, 100, 0));
+ assertThat(rotateGestureRecognizer.getState()).isEqualTo(POSSIBLE);
+ assertThat(listener.states.toArray()).isEqualTo(new Integer[]{POSSIBLE});
+
+ // Move second finger up less than 45 degrees. Should not change the state.
+ rotateGestureRecognizer.onTouchEvent(createMultiTouchMotionEvent(MotionEvent.ACTION_MOVE, 1, 0, 0, 100, 99));
+ assertThat(rotateGestureRecognizer.getState()).isEqualTo(POSSIBLE);
+ assertThat(listener.states.toArray()).isEqualTo(new Integer[]{POSSIBLE});
+ }
+
+ @Test
+ public void largeCounterClockwiseMovementIsRecognized() {
+ rotateGestureRecognizer.rotateSlop = (float) (Math.PI / 4); // 45 degrees.
+
+ TrackingGestureStateChangeListener listener = new TrackingGestureStateChangeListener();
+ rotateGestureRecognizer.addStateChangeListener(listener);
+ assertThat(rotateGestureRecognizer.getState()).isEqualTo(POSSIBLE);
+ assertThat(listener.states.toArray()).isEqualTo(new Integer[]{POSSIBLE});
+
+ // First finger down.
+ rotateGestureRecognizer.onTouchEvent(createMotionEvent(MotionEvent.ACTION_DOWN, 0, 0));
+ assertThat(rotateGestureRecognizer.getState()).isEqualTo(POSSIBLE);
+ assertThat(listener.states.toArray()).isEqualTo(new Integer[]{POSSIBLE});
+
+ // Second finger down.
+ rotateGestureRecognizer.onTouchEvent(createMultiTouchMotionEvent(MotionEvent.ACTION_POINTER_DOWN, 1, 0, 0, 100, 0));
+ assertThat(rotateGestureRecognizer.getState()).isEqualTo(POSSIBLE);
+ assertThat(listener.states.toArray()).isEqualTo(new Integer[]{POSSIBLE});
+
+ // Move second finger up more than 45 degrees. Should change the state.
+ rotateGestureRecognizer.onTouchEvent(createMultiTouchMotionEvent(MotionEvent.ACTION_MOVE, 1, 0, 0, 100, 101));
+ assertThat(rotateGestureRecognizer.getState()).isEqualTo(CHANGED);
+ assertThat(listener.states.toArray()).isEqualTo(new Integer[]{POSSIBLE, BEGAN, CHANGED});
+
+ // Move second finger 1 pixel. Should still change the state.
+ rotateGestureRecognizer.onTouchEvent(createMultiTouchMotionEvent(MotionEvent.ACTION_MOVE, 1, 0, 0, 100, 102));
+ assertThat(rotateGestureRecognizer.getState()).isEqualTo(CHANGED);
+ assertThat(listener.states.toArray()).isEqualTo(new Integer[]{POSSIBLE, BEGAN, CHANGED, CHANGED});
+ }
+
+ @Test
+ public void largeClockwiseMovementIsRecognized() {
+ rotateGestureRecognizer.rotateSlop = (float) (Math.PI / 4); // 45 degrees.
+
+ TrackingGestureStateChangeListener listener = new TrackingGestureStateChangeListener();
+ rotateGestureRecognizer.addStateChangeListener(listener);
+ assertThat(rotateGestureRecognizer.getState()).isEqualTo(POSSIBLE);
+ assertThat(listener.states.toArray()).isEqualTo(new Integer[]{POSSIBLE});
+
+ // First finger down.
+ rotateGestureRecognizer.onTouchEvent(createMotionEvent(MotionEvent.ACTION_DOWN, 0, 0));
+ assertThat(rotateGestureRecognizer.getState()).isEqualTo(POSSIBLE);
+ assertThat(listener.states.toArray()).isEqualTo(new Integer[]{POSSIBLE});
+
+ // Second finger down.
+ rotateGestureRecognizer.onTouchEvent(createMultiTouchMotionEvent(MotionEvent.ACTION_POINTER_DOWN, 1, 0, 0, 100, 0));
+ assertThat(rotateGestureRecognizer.getState()).isEqualTo(POSSIBLE);
+ assertThat(listener.states.toArray()).isEqualTo(new Integer[]{POSSIBLE});
+
+ // Move second finger down more than 45 degrees. Should change the state.
+ rotateGestureRecognizer.onTouchEvent(createMultiTouchMotionEvent(MotionEvent.ACTION_MOVE, 1, 0, 0, 100, -101));
+ assertThat(rotateGestureRecognizer.getState()).isEqualTo(CHANGED);
+ assertThat(listener.states.toArray()).isEqualTo(new Integer[]{POSSIBLE, BEGAN, CHANGED});
+
+ // Move second finger 1 pixel. Should still change the state.
+ rotateGestureRecognizer.onTouchEvent(createMultiTouchMotionEvent(MotionEvent.ACTION_MOVE, 1, 0, 0, 100, -102));
+ assertThat(rotateGestureRecognizer.getState()).isEqualTo(CHANGED);
+ assertThat(listener.states.toArray()).isEqualTo(new Integer[]{POSSIBLE, BEGAN, CHANGED, CHANGED});
+ }
+
+ @Test
+ public void completedGestureIsRecognized() {
+ TrackingGestureStateChangeListener listener = new TrackingGestureStateChangeListener();
+ rotateGestureRecognizer.addStateChangeListener(listener);
+ rotateGestureRecognizer.onTouchEvent(createMotionEvent(MotionEvent.ACTION_DOWN, 0, 0));
+ rotateGestureRecognizer.onTouchEvent(createMultiTouchMotionEvent(MotionEvent.ACTION_POINTER_DOWN, 1, 0, 0, 100, 100));
+ rotateGestureRecognizer.onTouchEvent(createMultiTouchMotionEvent(MotionEvent.ACTION_POINTER_DOWN, 2, 0, 0, 100, 100, 200, 200));
+ rotateGestureRecognizer.onTouchEvent(createMultiTouchMotionEvent(MotionEvent.ACTION_MOVE, 1, 0, 0, 200, 100, 200, 200));
+ rotateGestureRecognizer.onTouchEvent(createMultiTouchMotionEvent(MotionEvent.ACTION_POINTER_UP, 2, 0, 0, 200, 100, 200, 200));
+ rotateGestureRecognizer.onTouchEvent(createMultiTouchMotionEvent(MotionEvent.ACTION_POINTER_UP, 1, 0, 0, 200, 100));
+ rotateGestureRecognizer.onTouchEvent(createMotionEvent(MotionEvent.ACTION_UP, 0, 0));
+
+ assertThat(rotateGestureRecognizer.getState()).isEqualTo(POSSIBLE);
+ assertThat(listener.states.toArray())
+ .isEqualTo(new Integer[]{POSSIBLE, BEGAN, CHANGED, RECOGNIZED, POSSIBLE});
+ }
+
+ @Test
+ public void cancelledOneFingerGestureIsNotRecognized() {
+ TrackingGestureStateChangeListener listener = new TrackingGestureStateChangeListener();
+ rotateGestureRecognizer.addStateChangeListener(listener);
+ rotateGestureRecognizer.onTouchEvent(createMotionEvent(MotionEvent.ACTION_DOWN, 0, 0));
+ rotateGestureRecognizer.onTouchEvent(createMotionEvent(MotionEvent.ACTION_MOVE, 100, 0));
+ rotateGestureRecognizer.onTouchEvent(createMotionEvent(MotionEvent.ACTION_CANCEL, 100, 0));
+
+ assertThat(rotateGestureRecognizer.getState()).isEqualTo(POSSIBLE);
+ assertThat(listener.states.toArray()).isEqualTo(new Integer[]{POSSIBLE});
+ }
+
+ @Test
+ public void cancelledTwoFingerGestureIsNotRecognized() {
+ TrackingGestureStateChangeListener listener = new TrackingGestureStateChangeListener();
+ rotateGestureRecognizer.addStateChangeListener(listener);
+ rotateGestureRecognizer.onTouchEvent(createMotionEvent(MotionEvent.ACTION_DOWN, 0, 0));
+ rotateGestureRecognizer.onTouchEvent(createMultiTouchMotionEvent(MotionEvent.ACTION_POINTER_DOWN, 1, 0, 0, 100, 100));
+ rotateGestureRecognizer.onTouchEvent(createMultiTouchMotionEvent(MotionEvent.ACTION_MOVE, 1, 0, 0, 200, 100));
+ rotateGestureRecognizer.onTouchEvent(createMultiTouchMotionEvent(MotionEvent.ACTION_CANCEL, 0, 0, 0, 200, 100));
+
+ assertThat(rotateGestureRecognizer.getState()).isEqualTo(POSSIBLE);
+ assertThat(listener.states.toArray())
+ .isEqualTo(new Integer[]{POSSIBLE, BEGAN, CHANGED, CANCELLED, POSSIBLE});
+ }
+
+ @Test
+ public void noMovementIsNotRecognized() {
+ rotateGestureRecognizer.rotateSlop = (float) (Math.PI / 4); // 45 degrees.
+
+ TrackingGestureStateChangeListener listener = new TrackingGestureStateChangeListener();
+ rotateGestureRecognizer.addStateChangeListener(listener);
+ rotateGestureRecognizer.onTouchEvent(createMotionEvent(MotionEvent.ACTION_DOWN, 0, 0));
+ rotateGestureRecognizer.onTouchEvent(createMultiTouchMotionEvent(MotionEvent.ACTION_POINTER_DOWN, 1, 0, 0, 100, 100));
+ rotateGestureRecognizer.onTouchEvent(createMultiTouchMotionEvent(MotionEvent.ACTION_POINTER_UP, 1, 0, 0, 100, 100));
+ rotateGestureRecognizer.onTouchEvent(createMotionEvent(MotionEvent.ACTION_UP, 0, 0));
+
+ assertThat(rotateGestureRecognizer.getState()).isEqualTo(POSSIBLE);
+ assertThat(listener.states.toArray()).isEqualTo(new Integer[]{POSSIBLE});
+ }
+
+ @Test
+ public void irrelevantMotionIsIgnored() {
+ TrackingGestureStateChangeListener listener = new TrackingGestureStateChangeListener();
+ rotateGestureRecognizer.addStateChangeListener(listener);
+
+ rotateGestureRecognizer.onTouchEvent(createMotionEvent(MotionEvent.ACTION_HOVER_MOVE, 0, 0));
+
+ assertThat(rotateGestureRecognizer.getState()).isEqualTo(POSSIBLE);
+ assertThat(listener.states.toArray()).isEqualTo(new Integer[]{POSSIBLE});
+ }
+
+ @Test
+ public void oneFingerDoesNotAffectRotate() {
+ TrackingGestureStateChangeListener listener = new TrackingGestureStateChangeListener();
+ rotateGestureRecognizer.addStateChangeListener(listener);
+
+ rotateGestureRecognizer.onTouchEvent(createMotionEvent(MotionEvent.ACTION_DOWN, 0, 0));
+ assertThat(rotateGestureRecognizer.getRotation()).isWithin(E).of(0f);
+
+ rotateGestureRecognizer.onTouchEvent(createMotionEvent(MotionEvent.ACTION_MOVE, 100, 100));
+ assertThat(rotateGestureRecognizer.getRotation()).isWithin(E).of(0f);
+ }
+
+ @Test
+ public void multitouchHasCorrectCentroidAndRotation() {
+ TrackingGestureStateChangeListener listener = new TrackingGestureStateChangeListener();
+ rotateGestureRecognizer.addStateChangeListener(listener);
+
+ // First finger down. Centroid is at finger location and rotation is 0.
+ rotateGestureRecognizer.onTouchEvent(createMotionEvent(MotionEvent.ACTION_DOWN, 0, 0));
+ assertThat(rotateGestureRecognizer.getUntransformedCentroidX()).isWithin(E).of(0);
+ assertThat(rotateGestureRecognizer.getUntransformedCentroidY()).isWithin(E).of(0);
+ assertThat(rotateGestureRecognizer.getRotation()).isWithin(E).of(0);
+
+ // Second finger down. Centroid is in between fingers and rotation is 1.
+ rotateGestureRecognizer.onTouchEvent(
+ createMultiTouchMotionEvent(MotionEvent.ACTION_POINTER_DOWN, 1, 0, 0, 100, 100));
+ assertThat(rotateGestureRecognizer.getUntransformedCentroidX()).isWithin(E).of(50);
+ assertThat(rotateGestureRecognizer.getUntransformedCentroidY()).isWithin(E).of(50);
+ assertThat(rotateGestureRecognizer.getRotation()).isWithin(E).of(0);
+
+ // Second finger moves [dx, dy]. Centroid moves [dx/2, dy/2], rotation is calculated correctly.
+ float dx = 5;
+ float dy = 507;
+ rotateGestureRecognizer.onTouchEvent(
+ createMultiTouchMotionEvent(MotionEvent.ACTION_MOVE, 1, 0, 0, 100 + dx, 100 + dy));
+ assertThat(rotateGestureRecognizer.getUntransformedCentroidX()).isWithin(E).of(50 + dx / 2);
+ assertThat(rotateGestureRecognizer.getUntransformedCentroidY()).isWithin(E).of(50 + dy / 2);
+ assertThat(rotateGestureRecognizer.getRotation()).isWithin(E).of(
+ angle(0, 0, 100 + dx, 100 + dy) - angle(0, 0, 100, 100));
+
+ // Second finger up. State is now reset.
+ rotateGestureRecognizer.onTouchEvent(
+ createMultiTouchMotionEvent(MotionEvent.ACTION_POINTER_UP, 1, 0, 0, 100 + dx, 100 + dy));
+ assertThat(rotateGestureRecognizer.getUntransformedCentroidX()).isWithin(E).of(0);
+ assertThat(rotateGestureRecognizer.getUntransformedCentroidY()).isWithin(E).of(0);
+ assertThat(rotateGestureRecognizer.getRotation()).isWithin(E).of(0);
+
+ assertThat(listener.states.toArray()).isEqualTo(
+ new Integer[]{POSSIBLE, BEGAN, CHANGED, RECOGNIZED, POSSIBLE});
+ }
+
+ @Test
+ public void thirdFingerDoesNotAffectRotation() {
+ // First finger.
+ rotateGestureRecognizer.onTouchEvent(createMotionEvent(MotionEvent.ACTION_DOWN, 0, 0));
+ assertThat(rotateGestureRecognizer.getRotation()).isWithin(E).of(0);
+
+ // Second finger on horizontal line.
+ rotateGestureRecognizer.onTouchEvent(createMultiTouchMotionEvent(MotionEvent.ACTION_POINTER_DOWN, 1, 0, 0, 100, 0));
+ assertThat(rotateGestureRecognizer.getRotation()).isWithin(E).of(0);
+
+ // Third finger also on horizontal line.
+ rotateGestureRecognizer.onTouchEvent(createMultiTouchMotionEvent(MotionEvent.ACTION_POINTER_DOWN, 2, 0, 0, 100, 0, 200, 0));
+ assertThat(rotateGestureRecognizer.getRotation()).isWithin(E).of(0);
+
+ // Move third finger.
+ rotateGestureRecognizer.onTouchEvent(createMultiTouchMotionEvent(MotionEvent.ACTION_MOVE, 2, 0, 0, 100, 0, 200, 200));
+ assertThat(rotateGestureRecognizer.getRotation()).isWithin(E).of(0);
+ }
+
+ @Test
+ public void rotationIsStableOnFirstFingerUp() {
+ TrackingGestureStateChangeListener listener = new TrackingGestureStateChangeListener();
+ rotateGestureRecognizer.addStateChangeListener(listener);
+
+ // First finger down.
+ rotateGestureRecognizer.onTouchEvent(createMotionEvent(MotionEvent.ACTION_DOWN, 0, 0));
+ assertThat(rotateGestureRecognizer.getRotation()).isWithin(E).of(0);
+
+ // Second finger down on horizontal line.
+ rotateGestureRecognizer.onTouchEvent(createMultiTouchMotionEvent(MotionEvent.ACTION_POINTER_DOWN, 1, 0, 0, 100, 0));
+ assertThat(rotateGestureRecognizer.getRotation()).isWithin(E).of(0);
+
+ // Third finger also down on horizontal line.
+ rotateGestureRecognizer.onTouchEvent(createMultiTouchMotionEvent(MotionEvent.ACTION_POINTER_DOWN, 2, 0, 0, 100, 0, 200, 0));
+ assertThat(rotateGestureRecognizer.getRotation()).isWithin(E).of(0);
+
+ // Move second finger 45 degrees.
+ rotateGestureRecognizer.onTouchEvent(createMultiTouchMotionEvent(MotionEvent.ACTION_MOVE, 1, 0, 0, 100, 100, 200, 0));
+ assertThat(rotateGestureRecognizer.getRotation()).isWithin(E).of((float) (Math.PI / 4));
+
+ // First finger up. Rotation stays the same.
+ rotateGestureRecognizer.onTouchEvent(createMultiTouchMotionEvent(MotionEvent.ACTION_POINTER_UP, 0, 0, 0, 100, 100, 200, 0));
+ assertThat(rotateGestureRecognizer.getRotation()).isWithin(E).of((float) (Math.PI / 4));
+ }
+
+ @Test
+ public void rotationIsStableOnSecondFingerUp() {
+ // First finger down.
+ rotateGestureRecognizer.onTouchEvent(createMotionEvent(MotionEvent.ACTION_DOWN, 0, 0));
+ assertThat(rotateGestureRecognizer.getRotation()).isWithin(E).of(0);
+
+ // Second finger down on horizontal line.
+ rotateGestureRecognizer.onTouchEvent(createMultiTouchMotionEvent(MotionEvent.ACTION_POINTER_DOWN, 1, 0, 0, 100, 0));
+ assertThat(rotateGestureRecognizer.getRotation()).isWithin(E).of(0);
+
+ // Third finger also down on horizontal line.
+ rotateGestureRecognizer.onTouchEvent(createMultiTouchMotionEvent(MotionEvent.ACTION_POINTER_DOWN, 2, 0, 0, 100, 0, 200, 0));
+ assertThat(rotateGestureRecognizer.getRotation()).isWithin(E).of(0);
+
+ // Move second finger 45 degrees.
+ rotateGestureRecognizer.onTouchEvent(createMultiTouchMotionEvent(MotionEvent.ACTION_MOVE, 1, 0, 0, 100, 100, 200, 0));
+ assertThat(rotateGestureRecognizer.getRotation()).isWithin(E).of((float) (Math.PI / 4));
+
+ // Second finger up. Rotation stays the same.
+ rotateGestureRecognizer.onTouchEvent(createMultiTouchMotionEvent(MotionEvent.ACTION_POINTER_UP, 1, 0, 0, 100, 100, 200, 0));
+ assertThat(rotateGestureRecognizer.getRotation()).isWithin(E).of((float) (Math.PI / 4));
+ }
+
+ @Test
+ public void nonZeroVelocity() {
+ rotateGestureRecognizer.onTouchEvent(createMotionEvent(MotionEvent.ACTION_DOWN, 0, 0));
+ rotateGestureRecognizer.onTouchEvent(createMultiTouchMotionEvent(MotionEvent.ACTION_POINTER_DOWN, 1, 0, 0, 10, 0));
+
+ float move = 0;
+ rotateGestureRecognizer.onTouchEvent(createMultiTouchMotionEvent(MotionEvent.ACTION_MOVE, 1, 0, 0, 10, 0 + (move += 10)));
+ rotateGestureRecognizer.onTouchEvent(createMultiTouchMotionEvent(MotionEvent.ACTION_MOVE, 1, 0, 0, 10, 0 + (move += 10)));
+
+ rotateGestureRecognizer.onTouchEvent(createMultiTouchMotionEvent(MotionEvent.ACTION_POINTER_UP, 1, 0, 0, 10 + move, 0));
+ rotateGestureRecognizer.onTouchEvent(createMotionEvent(MotionEvent.ACTION_UP, 0, 0));
+
+ assertThat(rotateGestureRecognizer.getVelocity()).isGreaterThan(0f);
+ }
+
+ private MotionEvent createMotionEvent(int action, float x, float y) {
+ return MotionEvent.obtain(eventDownTime, eventTime += 16, action, x, y, 0);
+ }
+
+ private MotionEvent createMultiTouchMotionEvent(
+ int action, int index, float x0, float y0, float x1, float y1) {
+ MotionEvent event = mock(MotionEvent.class);
+
+ when(event.getDownTime()).thenReturn(eventDownTime);
+ when(event.getEventTime()).thenReturn(eventTime += 16);
+
+ when(event.getPointerCount()).thenReturn(2);
+ when(event.getAction()).thenReturn(action | (index << MotionEvent.ACTION_POINTER_INDEX_SHIFT));
+ when(event.getActionMasked()).thenReturn(action);
+ when(event.getActionIndex()).thenReturn(index);
+
+ when(event.getRawX()).thenReturn(x0);
+ when(event.getRawY()).thenReturn(y0);
+
+ when(event.getX(0)).thenReturn(x0);
+ when(event.getY(0)).thenReturn(y0);
+
+ when(event.getX(1)).thenReturn(x1);
+ when(event.getY(1)).thenReturn(y1);
+
+ return event;
+ }
+
+ private MotionEvent createMultiTouchMotionEvent(
+ int action, int index, float x0, float y0, float x1, float y1, float x2, float y2) {
+ MotionEvent event = mock(MotionEvent.class);
+
+ when(event.getDownTime()).thenReturn(eventDownTime);
+ when(event.getEventTime()).thenReturn(eventTime += 16);
+
+ when(event.getPointerCount()).thenReturn(3);
+ when(event.getAction()).thenReturn(action | (index << MotionEvent.ACTION_POINTER_INDEX_SHIFT));
+ when(event.getActionMasked()).thenReturn(action);
+ when(event.getActionIndex()).thenReturn(index);
+
+ when(event.getRawX()).thenReturn(x0);
+ when(event.getRawY()).thenReturn(y0);
+
+ when(event.getX(0)).thenReturn(x0);
+ when(event.getY(0)).thenReturn(y0);
+
+ when(event.getX(1)).thenReturn(x1);
+ when(event.getY(1)).thenReturn(y1);
+
+ when(event.getX(2)).thenReturn(x2);
+ when(event.getY(2)).thenReturn(y2);
+
+ return event;
+ }
+}
diff --git a/library/src/test/java/com/google/android/material/motion/gestures/ScaleGestureRecognizerTests.java b/library/src/test/java/com/google/android/material/motion/gestures/ScaleGestureRecognizerTests.java
new file mode 100644
index 0000000..34b7c07
--- /dev/null
+++ b/library/src/test/java/com/google/android/material/motion/gestures/ScaleGestureRecognizerTests.java
@@ -0,0 +1,348 @@
+/*
+ * Copyright 2016-present The Material Motion Authors. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.material.motion.gestures;
+
+import android.app.Activity;
+import android.content.Context;
+import android.view.MotionEvent;
+import android.view.View;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.Robolectric;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.annotation.Config;
+
+import static com.google.android.material.motion.gestures.GestureRecognizer.BEGAN;
+import static com.google.android.material.motion.gestures.GestureRecognizer.CANCELLED;
+import static com.google.android.material.motion.gestures.GestureRecognizer.CHANGED;
+import static com.google.android.material.motion.gestures.GestureRecognizer.POSSIBLE;
+import static com.google.android.material.motion.gestures.GestureRecognizer.RECOGNIZED;
+import static com.google.android.material.motion.gestures.ScaleGestureRecognizer.dist;
+import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+@RunWith(RobolectricTestRunner.class)
+@Config(constants = BuildConfig.class, sdk = 21)
+public class ScaleGestureRecognizerTests {
+
+ private static final float E = 0.0001f;
+
+ private View element;
+ private ScaleGestureRecognizer scaleGestureRecognizer;
+
+ private long eventDownTime;
+ private long eventTime;
+
+ @Before
+ public void setUp() {
+ Context context = Robolectric.setupActivity(Activity.class);
+ element = new View(context);
+ scaleGestureRecognizer = new ScaleGestureRecognizer();
+ scaleGestureRecognizer.setElement(element);
+ scaleGestureRecognizer.scaleSlop = 0;
+
+ eventDownTime = 0;
+ eventTime = -16;
+ }
+
+ @Test
+ public void defaultState() {
+ assertThat(scaleGestureRecognizer.getState()).isEqualTo(POSSIBLE);
+ assertThat(scaleGestureRecognizer.getElement()).isEqualTo(element);
+ assertThat(scaleGestureRecognizer.getUntransformedCentroidX()).isWithin(0).of(0f);
+ assertThat(scaleGestureRecognizer.getUntransformedCentroidY()).isWithin(0).of(0f);
+ assertThat(scaleGestureRecognizer.getScale()).isWithin(0).of(1f);
+ assertThat(scaleGestureRecognizer.getVelocity()).isWithin(0).of(0f);
+ }
+
+ @Test
+ public void smallMovementIsNotRecognized() {
+ scaleGestureRecognizer.scaleSlop = 24;
+
+ TrackingGestureStateChangeListener listener = new TrackingGestureStateChangeListener();
+ scaleGestureRecognizer.addStateChangeListener(listener);
+ assertThat(scaleGestureRecognizer.getState()).isEqualTo(POSSIBLE);
+ assertThat(listener.states.toArray()).isEqualTo(new Integer[]{POSSIBLE});
+
+ // First finger down.
+ scaleGestureRecognizer.onTouchEvent(createMotionEvent(MotionEvent.ACTION_DOWN, 0, 0));
+ assertThat(scaleGestureRecognizer.getState()).isEqualTo(POSSIBLE);
+ assertThat(listener.states.toArray()).isEqualTo(new Integer[]{POSSIBLE});
+
+ // Second finger down.
+ scaleGestureRecognizer.onTouchEvent(createMultiTouchMotionEvent(MotionEvent.ACTION_POINTER_DOWN, 1, 0, 0, 100, 100));
+ assertThat(scaleGestureRecognizer.getState()).isEqualTo(POSSIBLE);
+ assertThat(listener.states.toArray()).isEqualTo(new Integer[]{POSSIBLE});
+
+ // Move second finger 1 pixel. Should not change the state.
+ scaleGestureRecognizer.onTouchEvent(createMultiTouchMotionEvent(MotionEvent.ACTION_MOVE, 1, 0, 0, 101, 100));
+ assertThat(scaleGestureRecognizer.getState()).isEqualTo(POSSIBLE);
+ assertThat(listener.states.toArray()).isEqualTo(new Integer[]{POSSIBLE});
+ }
+
+ @Test
+ public void largeHorizontalMovementIsRecognized() {
+ scaleGestureRecognizer.scaleSlop = 24;
+
+ TrackingGestureStateChangeListener listener = new TrackingGestureStateChangeListener();
+ scaleGestureRecognizer.addStateChangeListener(listener);
+ assertThat(scaleGestureRecognizer.getState()).isEqualTo(POSSIBLE);
+ assertThat(listener.states.toArray()).isEqualTo(new Integer[]{POSSIBLE});
+
+ // First finger down.
+ scaleGestureRecognizer.onTouchEvent(createMotionEvent(MotionEvent.ACTION_DOWN, 0, 0));
+ assertThat(scaleGestureRecognizer.getState()).isEqualTo(POSSIBLE);
+ assertThat(listener.states.toArray()).isEqualTo(new Integer[]{POSSIBLE});
+
+ // Second finger down.
+ scaleGestureRecognizer.onTouchEvent(createMultiTouchMotionEvent(MotionEvent.ACTION_POINTER_DOWN, 1, 0, 0, 100, 100));
+ assertThat(scaleGestureRecognizer.getState()).isEqualTo(POSSIBLE);
+ assertThat(listener.states.toArray()).isEqualTo(new Integer[]{POSSIBLE});
+
+ // Move second finger 100 pixel right. Should change the state.
+ scaleGestureRecognizer.onTouchEvent(createMultiTouchMotionEvent(MotionEvent.ACTION_MOVE, 1, 0, 0, 200, 100));
+ assertThat(scaleGestureRecognizer.getState()).isEqualTo(CHANGED);
+ assertThat(listener.states.toArray()).isEqualTo(new Integer[]{POSSIBLE, BEGAN, CHANGED});
+
+ // Move second finger 1 pixel. Should still change the state.
+ scaleGestureRecognizer.onTouchEvent(createMultiTouchMotionEvent(MotionEvent.ACTION_MOVE, 1, 0, 0, 201, 100));
+ assertThat(scaleGestureRecognizer.getState()).isEqualTo(CHANGED);
+ assertThat(listener.states.toArray()).isEqualTo(new Integer[]{POSSIBLE, BEGAN, CHANGED, CHANGED});
+ }
+
+ @Test
+ public void largeVerticalMovementIsRecognized() {
+ scaleGestureRecognizer.scaleSlop = 24;
+
+ TrackingGestureStateChangeListener listener = new TrackingGestureStateChangeListener();
+ scaleGestureRecognizer.addStateChangeListener(listener);
+ assertThat(scaleGestureRecognizer.getState()).isEqualTo(POSSIBLE);
+ assertThat(listener.states.toArray()).isEqualTo(new Integer[]{POSSIBLE});
+
+ // First finger down.
+ scaleGestureRecognizer.onTouchEvent(createMotionEvent(MotionEvent.ACTION_DOWN, 0, 0));
+ assertThat(scaleGestureRecognizer.getState()).isEqualTo(POSSIBLE);
+ assertThat(listener.states.toArray()).isEqualTo(new Integer[]{POSSIBLE});
+
+ // Second finger down.
+ scaleGestureRecognizer.onTouchEvent(createMultiTouchMotionEvent(MotionEvent.ACTION_POINTER_DOWN, 1, 0, 0, 100, 100));
+ assertThat(scaleGestureRecognizer.getState()).isEqualTo(POSSIBLE);
+ assertThat(listener.states.toArray()).isEqualTo(new Integer[]{POSSIBLE});
+
+ // Move second finger 100 pixel down. Should change the state.
+ scaleGestureRecognizer.onTouchEvent(createMultiTouchMotionEvent(MotionEvent.ACTION_MOVE, 1, 0, 0, 100, 200));
+ assertThat(scaleGestureRecognizer.getState()).isEqualTo(CHANGED);
+ assertThat(listener.states.toArray()).isEqualTo(new Integer[]{POSSIBLE, BEGAN, CHANGED});
+
+ // Move second finger 1 pixel. Should still change the state.
+ scaleGestureRecognizer.onTouchEvent(createMultiTouchMotionEvent(MotionEvent.ACTION_MOVE, 1, 0, 0, 100, 201));
+ assertThat(scaleGestureRecognizer.getState()).isEqualTo(CHANGED);
+ assertThat(listener.states.toArray()).isEqualTo(new Integer[]{POSSIBLE, BEGAN, CHANGED, CHANGED});
+ }
+
+ @Test
+ public void completedGestureIsRecognized() {
+ TrackingGestureStateChangeListener listener = new TrackingGestureStateChangeListener();
+ scaleGestureRecognizer.addStateChangeListener(listener);
+ scaleGestureRecognizer.onTouchEvent(createMotionEvent(MotionEvent.ACTION_DOWN, 0, 0));
+ scaleGestureRecognizer.onTouchEvent(createMultiTouchMotionEvent(MotionEvent.ACTION_POINTER_DOWN, 1, 0, 0, 100, 100));
+ scaleGestureRecognizer.onTouchEvent(createMultiTouchMotionEvent(MotionEvent.ACTION_POINTER_DOWN, 2, 0, 0, 100, 100, 200, 200));
+ scaleGestureRecognizer.onTouchEvent(createMultiTouchMotionEvent(MotionEvent.ACTION_MOVE, 1, 0, 0, 200, 100, 200, 200));
+ scaleGestureRecognizer.onTouchEvent(createMultiTouchMotionEvent(MotionEvent.ACTION_POINTER_UP, 2, 0, 0, 200, 100, 200, 200));
+ scaleGestureRecognizer.onTouchEvent(createMultiTouchMotionEvent(MotionEvent.ACTION_POINTER_UP, 1, 0, 0, 200, 100));
+ scaleGestureRecognizer.onTouchEvent(createMotionEvent(MotionEvent.ACTION_UP, 0, 0));
+
+ assertThat(scaleGestureRecognizer.getState()).isEqualTo(POSSIBLE);
+ assertThat(listener.states.toArray())
+ .isEqualTo(new Integer[]{POSSIBLE, BEGAN, CHANGED, RECOGNIZED, POSSIBLE});
+ }
+
+ @Test
+ public void cancelledOneFingerGestureIsNotRecognized() {
+ TrackingGestureStateChangeListener listener = new TrackingGestureStateChangeListener();
+ scaleGestureRecognizer.addStateChangeListener(listener);
+ scaleGestureRecognizer.onTouchEvent(createMotionEvent(MotionEvent.ACTION_DOWN, 0, 0));
+ scaleGestureRecognizer.onTouchEvent(createMotionEvent(MotionEvent.ACTION_MOVE, 100, 0));
+ scaleGestureRecognizer.onTouchEvent(createMotionEvent(MotionEvent.ACTION_CANCEL, 100, 0));
+
+ assertThat(scaleGestureRecognizer.getState()).isEqualTo(POSSIBLE);
+ assertThat(listener.states.toArray()).isEqualTo(new Integer[]{POSSIBLE});
+ }
+
+ @Test
+ public void cancelledTwoFingerGestureIsNotRecognized() {
+ TrackingGestureStateChangeListener listener = new TrackingGestureStateChangeListener();
+ scaleGestureRecognizer.addStateChangeListener(listener);
+ scaleGestureRecognizer.onTouchEvent(createMotionEvent(MotionEvent.ACTION_DOWN, 0, 0));
+ scaleGestureRecognizer.onTouchEvent(createMultiTouchMotionEvent(MotionEvent.ACTION_POINTER_DOWN, 1, 0, 0, 100, 100));
+ scaleGestureRecognizer.onTouchEvent(createMultiTouchMotionEvent(MotionEvent.ACTION_MOVE, 1, 0, 0, 200, 100));
+ scaleGestureRecognizer.onTouchEvent(createMultiTouchMotionEvent(MotionEvent.ACTION_CANCEL, 0, 0, 0, 200, 100));
+
+ assertThat(scaleGestureRecognizer.getState()).isEqualTo(POSSIBLE);
+ assertThat(listener.states.toArray())
+ .isEqualTo(new Integer[]{POSSIBLE, BEGAN, CHANGED, CANCELLED, POSSIBLE});
+ }
+
+ @Test
+ public void noMovementIsNotRecognized() {
+ scaleGestureRecognizer.scaleSlop = 24;
+
+ TrackingGestureStateChangeListener listener = new TrackingGestureStateChangeListener();
+ scaleGestureRecognizer.addStateChangeListener(listener);
+ scaleGestureRecognizer.onTouchEvent(createMotionEvent(MotionEvent.ACTION_DOWN, 0, 0));
+ scaleGestureRecognizer.onTouchEvent(createMultiTouchMotionEvent(MotionEvent.ACTION_POINTER_DOWN, 1, 0, 0, 100, 100));
+ scaleGestureRecognizer.onTouchEvent(createMultiTouchMotionEvent(MotionEvent.ACTION_POINTER_UP, 1, 0, 0, 100, 100));
+ scaleGestureRecognizer.onTouchEvent(createMotionEvent(MotionEvent.ACTION_UP, 0, 0));
+
+ assertThat(scaleGestureRecognizer.getState()).isEqualTo(POSSIBLE);
+ assertThat(listener.states.toArray()).isEqualTo(new Integer[]{POSSIBLE});
+ }
+
+ @Test
+ public void irrelevantMotionIsIgnored() {
+ TrackingGestureStateChangeListener listener = new TrackingGestureStateChangeListener();
+ scaleGestureRecognizer.addStateChangeListener(listener);
+
+ scaleGestureRecognizer.onTouchEvent(createMotionEvent(MotionEvent.ACTION_HOVER_MOVE, 0, 0));
+
+ assertThat(scaleGestureRecognizer.getState()).isEqualTo(POSSIBLE);
+ assertThat(listener.states.toArray()).isEqualTo(new Integer[]{POSSIBLE});
+ }
+
+ @Test
+ public void oneFingerDoesNotAffectScale() {
+ TrackingGestureStateChangeListener listener = new TrackingGestureStateChangeListener();
+ scaleGestureRecognizer.addStateChangeListener(listener);
+
+ scaleGestureRecognizer.onTouchEvent(createMotionEvent(MotionEvent.ACTION_DOWN, 0, 0));
+ assertThat(scaleGestureRecognizer.getScale()).isWithin(E).of(1f);
+
+ scaleGestureRecognizer.onTouchEvent(createMotionEvent(MotionEvent.ACTION_MOVE, 100, 100));
+ assertThat(scaleGestureRecognizer.getScale()).isWithin(E).of(1f);
+ }
+
+ @Test
+ public void multitouchHasCorrectCentroidAndScale() {
+ TrackingGestureStateChangeListener listener = new TrackingGestureStateChangeListener();
+ scaleGestureRecognizer.addStateChangeListener(listener);
+
+ // First finger down. Centroid is at finger location and scale is 1.
+ scaleGestureRecognizer.onTouchEvent(createMotionEvent(MotionEvent.ACTION_DOWN, 0, 0));
+ assertThat(scaleGestureRecognizer.getUntransformedCentroidX()).isWithin(E).of(0);
+ assertThat(scaleGestureRecognizer.getUntransformedCentroidY()).isWithin(E).of(0);
+ assertThat(scaleGestureRecognizer.getScale()).isWithin(E).of(1);
+
+ // Second finger down. Centroid is in between fingers and scale is 1.
+ scaleGestureRecognizer.onTouchEvent(
+ createMultiTouchMotionEvent(MotionEvent.ACTION_POINTER_DOWN, 1, 0, 0, 100, 100));
+ assertThat(scaleGestureRecognizer.getUntransformedCentroidX()).isWithin(E).of(50);
+ assertThat(scaleGestureRecognizer.getUntransformedCentroidY()).isWithin(E).of(50);
+ assertThat(scaleGestureRecognizer.getScale()).isWithin(E).of(1);
+
+ // Second finger moves [dx, dy]. Centroid moves [dx/2, dy/2], scale is calculated correctly.
+ float dx = 505;
+ float dy = 507;
+ scaleGestureRecognizer.onTouchEvent(
+ createMultiTouchMotionEvent(MotionEvent.ACTION_MOVE, 1, 0, 0, 100 + dx, 100 + dy));
+ assertThat(scaleGestureRecognizer.getUntransformedCentroidX()).isWithin(E).of(50 + dx / 2);
+ assertThat(scaleGestureRecognizer.getUntransformedCentroidY()).isWithin(E).of(50 + dy / 2);
+ assertThat(scaleGestureRecognizer.getScale()).isWithin(E).of(
+ dist(0, 0, 100 + dx, 100 + dy) / dist(0, 0, 100, 100));
+
+ // Second finger up. State is now reset.
+ scaleGestureRecognizer.onTouchEvent(
+ createMultiTouchMotionEvent(MotionEvent.ACTION_POINTER_UP, 1, 0, 0, 100 + dx, 100 + dy));
+ assertThat(scaleGestureRecognizer.getUntransformedCentroidX()).isWithin(E).of(0);
+ assertThat(scaleGestureRecognizer.getUntransformedCentroidY()).isWithin(E).of(0);
+ assertThat(scaleGestureRecognizer.getScale()).isWithin(E).of(1);
+
+ assertThat(listener.states.toArray()).isEqualTo(
+ new Integer[]{POSSIBLE, BEGAN, CHANGED, RECOGNIZED, POSSIBLE});
+ }
+
+ @Test
+ public void nonZeroVelocity() {
+ scaleGestureRecognizer.onTouchEvent(createMotionEvent(MotionEvent.ACTION_DOWN, 0, 0));
+ scaleGestureRecognizer.onTouchEvent(createMultiTouchMotionEvent(MotionEvent.ACTION_POINTER_DOWN, 1, 0, 0, 10, 0));
+
+ float move = 0;
+ scaleGestureRecognizer.onTouchEvent(createMultiTouchMotionEvent(MotionEvent.ACTION_MOVE, 1, 0, 0, 10 + (move += 10), 0));
+ scaleGestureRecognizer.onTouchEvent(createMultiTouchMotionEvent(MotionEvent.ACTION_MOVE, 1, 0, 0, 10 + (move += 10), 0));
+
+ scaleGestureRecognizer.onTouchEvent(createMultiTouchMotionEvent(MotionEvent.ACTION_POINTER_UP, 1, 0, 0, 10 + move, 0));
+ scaleGestureRecognizer.onTouchEvent(createMotionEvent(MotionEvent.ACTION_UP, 0, 0));
+
+ assertThat(scaleGestureRecognizer.getVelocity()).isGreaterThan(0f);
+ }
+
+ private MotionEvent createMotionEvent(int action, float x, float y) {
+ return MotionEvent.obtain(eventDownTime, eventTime += 16, action, x, y, 0);
+ }
+
+ private MotionEvent createMultiTouchMotionEvent(
+ int action, int index, float x0, float y0, float x1, float y1) {
+ MotionEvent event = mock(MotionEvent.class);
+
+ when(event.getDownTime()).thenReturn(eventDownTime);
+ when(event.getEventTime()).thenReturn(eventTime += 16);
+
+ when(event.getPointerCount()).thenReturn(2);
+ when(event.getAction()).thenReturn(action | (index << MotionEvent.ACTION_POINTER_INDEX_SHIFT));
+ when(event.getActionMasked()).thenReturn(action);
+ when(event.getActionIndex()).thenReturn(index);
+
+ when(event.getRawX()).thenReturn(x0);
+ when(event.getRawY()).thenReturn(y0);
+
+ when(event.getX(0)).thenReturn(x0);
+ when(event.getY(0)).thenReturn(y0);
+
+ when(event.getX(1)).thenReturn(x1);
+ when(event.getY(1)).thenReturn(y1);
+
+ return event;
+ }
+
+ private MotionEvent createMultiTouchMotionEvent(
+ int action, int index, float x0, float y0, float x1, float y1, float x2, float y2) {
+ MotionEvent event = mock(MotionEvent.class);
+
+ when(event.getDownTime()).thenReturn(eventDownTime);
+ when(event.getEventTime()).thenReturn(eventTime += 16);
+
+ when(event.getPointerCount()).thenReturn(3);
+ when(event.getAction()).thenReturn(action | (index << MotionEvent.ACTION_POINTER_INDEX_SHIFT));
+ when(event.getActionMasked()).thenReturn(action);
+ when(event.getActionIndex()).thenReturn(index);
+
+ when(event.getRawX()).thenReturn(x0);
+ when(event.getRawY()).thenReturn(y0);
+
+ when(event.getX(0)).thenReturn(x0);
+ when(event.getY(0)).thenReturn(y0);
+
+ when(event.getX(1)).thenReturn(x1);
+ when(event.getY(1)).thenReturn(y1);
+
+ when(event.getX(2)).thenReturn(x2);
+ when(event.getY(2)).thenReturn(y2);
+
+ return event;
+ }
+}
diff --git a/library/src/main/java/com/google/android/material/motion/gestures/Library.java b/library/src/test/java/com/google/android/material/motion/gestures/TrackingGestureStateChangeListener.java
similarity index 53%
rename from library/src/main/java/com/google/android/material/motion/gestures/Library.java
rename to library/src/test/java/com/google/android/material/motion/gestures/TrackingGestureStateChangeListener.java
index e3951cc..d4efab2 100644
--- a/library/src/main/java/com/google/android/material/motion/gestures/Library.java
+++ b/library/src/test/java/com/google/android/material/motion/gestures/TrackingGestureStateChangeListener.java
@@ -15,10 +15,21 @@
*/
package com.google.android.material.motion.gestures;
+import com.google.android.material.motion.gestures.GestureRecognizer.GestureStateChangeListener;
+import com.google.common.collect.Lists;
+
+import java.util.List;
+
+import static com.google.android.material.motion.gestures.GestureRecognizer.POSSIBLE;
+
/**
- * Gestures library class.
+ * A GestureStateChangeListener that tracks the state changes. Useful for tests.
*/
-public class Library {
+public class TrackingGestureStateChangeListener implements GestureStateChangeListener {
+ List