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 listeners = new CopyOnWriteArrayList<>(); + @Nullable + private View element; + @GestureRecognizerState + private int state = POSSIBLE; + + /** + * Sets the view that this gesture recognizer is attached to. This must be called before this + * gesture recognizer can start {@link #onTouchEvent(MotionEvent) accepting touch events}. + */ + public void setElement(@Nullable View element) { + this.element = element; + } + + /** + * Returns the view associated with this gesture recognizer. + */ + public View getElement() { + return element; + } + + /** + * Returns the current state of the gesture recognizer. + */ + @GestureRecognizerState + public int getState() { + return state; + } + + /** + * Forwards touch events from a {@link OnTouchListener} to this gesture recognizer. + */ + public abstract boolean onTouchEvent(MotionEvent event); + + /** + * Adds a listener to this gesture recognizer. + */ + public void addStateChangeListener(GestureStateChangeListener listener) { + if (!listeners.contains(listener)) { + listeners.add(listener); + } + } + + /** + * Removes a listener from this gesture recognizer. + */ + public void removeStateChangeListener(GestureStateChangeListener listener) { + listeners.remove(listener); + } + + /** + * Returns the centroidX position of the current gesture in the local coordinate space of the + * {@link #element}. + */ + public final float getCentroidX() { + array[0] = getUntransformedCentroidX(); + array[1] = getUntransformedCentroidY(); + + inverse.mapPoints(array); + + return array[0]; + } + + /** + * Returns the centroidY position of the current gesture in the local coordinate space of the + * {@link #element}. + */ + public final float getCentroidY() { + array[0] = getUntransformedCentroidX(); + array[1] = getUntransformedCentroidY(); + + inverse.mapPoints(array); + + return array[1]; + } + + /** + * Returns the untransformed centroidX position of the current gesture in the local coordinate + * space of {@link #element}'s parent. + */ + public abstract float getUntransformedCentroidX(); + + /** + * Returns the untransformed centroidY position of the current gesture in the local coordinate + * space of {@link #element}'s parent. + */ + public abstract float getUntransformedCentroidY(); + + /** + * Sets the state of the gesture recognizer and notifies all listeners. + */ + protected void setState(@GestureRecognizerState int state) { + this.state = state; + + for (GestureStateChangeListener listener : listeners) { + listener.onStateChanged(this); + } + + element.removeCallbacks(setStateToPossible); + if (state == RECOGNIZED || state == CANCELLED) { + element.post(setStateToPossible); + } + } + + private final Runnable setStateToPossible = new Runnable() { + @Override + public void run() { + setState(POSSIBLE); + } + }; + + protected boolean isInProgress() { + return state == BEGAN || state == CHANGED; + } + + /** + * Calculates the untransformed centroid of all the active pointers in the given motion event. + * + * @return A point representing the centroid. The caller should read the values immediately as + * the object may be reused in other calculations. + */ + protected PointF calculateUntransformedCentroid(MotionEvent event) { + return calculateUntransformedCentroid(event, Integer.MAX_VALUE); + } + + /** + * Calculates the centroid of the first {@code n} active pointers in the given motion event. + * + * @return A point representing the centroid. The caller should read the values immediately as + * the object may be reused in other calculations. + */ + protected PointF calculateUntransformedCentroid(MotionEvent event, int n) { + int action = MotionEventCompat.getActionMasked(event); + int index = MotionEventCompat.getActionIndex(event); + + float sumX = 0; + float sumY = 0; + int num = 0; + for (int i = 0, count = event.getPointerCount(); i < count && i < n; i++) { + if (action == MotionEvent.ACTION_POINTER_UP && index == i) { + continue; + } + + sumX += calculateUntransformedPoint(event, i).x; + sumY += calculateUntransformedPoint(event, i).y; + num++; + } + + pointF.set(sumX / num, sumY / num); + return pointF; + } + + /** + * Calculates the untransformed x and y of the pointer given by the pointer index in the given + * motion event. + *

+ * 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 states = Lists.newArrayList(POSSIBLE); - public static final String LIBRARY_NAME = "Gestures"; + @Override + public void onStateChanged(GestureRecognizer gestureRecognizer) { + states.add(gestureRecognizer.getState()); + } } diff --git a/library/src/test/java/com/google/android/material/motion/gestures/UnitTests.java b/library/src/test/java/com/google/android/material/motion/gestures/ValueVelocityTrackerTests.java similarity index 63% rename from library/src/test/java/com/google/android/material/motion/gestures/UnitTests.java rename to library/src/test/java/com/google/android/material/motion/gestures/ValueVelocityTrackerTests.java index 8ee34ec..3b4c004 100644 --- a/library/src/test/java/com/google/android/material/motion/gestures/UnitTests.java +++ b/library/src/test/java/com/google/android/material/motion/gestures/ValueVelocityTrackerTests.java @@ -15,23 +15,32 @@ */ package com.google.android.material.motion.gestures; +import android.app.Activity; +import android.content.Context; +import android.view.MotionEvent; + 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; @RunWith(RobolectricTestRunner.class) @Config(constants = BuildConfig.class, sdk = 21) -public class UnitTests { +public class ValueVelocityTrackerTests { + + private ValueVelocityTracker velocityTracker; @Before public void setUp() { - + Context context = Robolectric.setupActivity(Activity.class); + velocityTracker = new ValueVelocityTracker(context, ValueVelocityTracker.ADDITIVE); } - @Test - public void unitTest() { - + @Test(expected = IllegalArgumentException.class) + public void unexpectedMotionActionCrashes() { + velocityTracker.onGestureStart( + MotionEvent.obtain(0, 0, MotionEvent.ACTION_BUTTON_PRESS, 0, 0, 0), 0); } } diff --git a/library/src/test/java/com/google/android/material/motion/gestures/testing/SimulatedGestureRecognizerTests.java b/library/src/test/java/com/google/android/material/motion/gestures/testing/SimulatedGestureRecognizerTests.java new file mode 100644 index 0000000..bc00863 --- /dev/null +++ b/library/src/test/java/com/google/android/material/motion/gestures/testing/SimulatedGestureRecognizerTests.java @@ -0,0 +1,59 @@ +/* + * 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.app.Activity; +import android.view.View; + +import com.google.android.material.motion.gestures.BuildConfig; +import com.google.android.material.motion.gestures.GestureRecognizer; + +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.common.truth.Truth.assertThat; + +@RunWith(RobolectricTestRunner.class) +@Config(constants = BuildConfig.class, sdk = 21) +public class SimulatedGestureRecognizerTests { + + private SimulatedGestureRecognizer gestureRecognizer; + + @Before + public void setUp() { + View element = new View(Robolectric.setupActivity(Activity.class)); + gestureRecognizer = new SimulatedGestureRecognizer(element); + } + + @Test + public void defaultState() { + assertThat(gestureRecognizer.onTouchEvent(null)).isFalse(); + assertThat(gestureRecognizer.getUntransformedCentroidX()).isWithin(0f).of(0f); + assertThat(gestureRecognizer.getUntransformedCentroidY()).isWithin(0f).of(0f); + } + + @Test + public void settableState() { + assertThat(gestureRecognizer.getState()).isEqualTo(GestureRecognizer.POSSIBLE); + + gestureRecognizer.setState(GestureRecognizer.CHANGED); + assertThat(gestureRecognizer.getState()).isEqualTo(GestureRecognizer.CHANGED); + } +} diff --git a/sample/src/main/java/com/google/android/material/motion/gestures/sample/CheckerboardDrawable.java b/sample/src/main/java/com/google/android/material/motion/gestures/sample/CheckerboardDrawable.java new file mode 100644 index 0000000..86cb886 --- /dev/null +++ b/sample/src/main/java/com/google/android/material/motion/gestures/sample/CheckerboardDrawable.java @@ -0,0 +1,83 @@ +/* + * 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.sample; + +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.ColorFilter; +import android.graphics.Paint; +import android.graphics.PixelFormat; +import android.graphics.Rect; +import android.graphics.drawable.Drawable; + +/** + * Draws a checkerboard pattern. + */ +public class CheckerboardDrawable extends Drawable { + public static final int COLS = 10; + public static final int ROWS = 10; + private final Paint gridPaint = new Paint(Paint.ANTI_ALIAS_FLAG | Paint.DITHER_FLAG); + private final Paint backgroundPaint = new Paint(Paint.ANTI_ALIAS_FLAG | Paint.DITHER_FLAG); + + public CheckerboardDrawable() { + gridPaint.setColor(Color.BLACK); + backgroundPaint.setColor(Color.RED); + } + + @Override + public void draw(Canvas canvas) { + Rect bounds = getBounds(); + + canvas.drawRect(bounds, backgroundPaint); + + float cellWidth = (float) bounds.width() / COLS; + float cellHeight = (float) bounds.height() / ROWS; + + for (int i = 0, x = bounds.left; i < COLS + 1; i++, x += cellWidth) { + if (i == 0 || i == COLS) { + gridPaint.setStrokeWidth(10); + } else { + gridPaint.setStrokeWidth(1); + } + canvas.drawLine(x, bounds.top, x, bounds.bottom, gridPaint); + } + for (int i = 0, y = bounds.top; i < ROWS + 1; i++, y += cellHeight) { + if (i == 0 || i == COLS) { + gridPaint.setStrokeWidth(10); + } else { + gridPaint.setStrokeWidth(1); + } + canvas.drawLine(bounds.left, y, bounds.right, y, gridPaint); + } + } + + @Override + public void setAlpha(int alpha) { + gridPaint.setAlpha(alpha); + invalidateSelf(); + } + + @Override + public void setColorFilter(ColorFilter colorFilter) { + gridPaint.setColorFilter(colorFilter); + invalidateSelf(); + } + + @Override + public int getOpacity() { + return PixelFormat.TRANSLUCENT; + } +} diff --git a/sample/src/main/java/com/google/android/material/motion/gestures/sample/MainActivity.java b/sample/src/main/java/com/google/android/material/motion/gestures/sample/MainActivity.java index c509239..872aa48 100644 --- a/sample/src/main/java/com/google/android/material/motion/gestures/sample/MainActivity.java +++ b/sample/src/main/java/com/google/android/material/motion/gestures/sample/MainActivity.java @@ -15,24 +15,110 @@ */ package com.google.android.material.motion.gestures.sample; -import com.google.android.material.motion.gestures.Library; - import android.os.Bundle; import android.support.v7.app.AppCompatActivity; +import android.view.MotionEvent; +import android.view.View; +import android.view.View.OnTouchListener; import android.widget.TextView; +import com.google.android.material.motion.gestures.DragGestureRecognizer; +import com.google.android.material.motion.gestures.GestureRecognizer; +import com.google.android.material.motion.gestures.GestureRecognizer.GestureStateChangeListener; +import com.google.android.material.motion.gestures.RotateGestureRecognizer; +import com.google.android.material.motion.gestures.ScaleGestureRecognizer; + +import java.util.Locale; + /** * Gestures sample Activity. */ public class MainActivity extends AppCompatActivity { + private final DragGestureRecognizer dragGestureRecognizer = new DragGestureRecognizer(); + private final ScaleGestureRecognizer scaleGestureRecognizer = new ScaleGestureRecognizer(); + private final RotateGestureRecognizer rotateGestureRecognizer = new RotateGestureRecognizer(); + + private TextView dragText; + private TextView scaleText; + private TextView rotateText; + @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.main_activity); - TextView text = (TextView) findViewById(R.id.text); - text.setText(Library.LIBRARY_NAME); + View target = findViewById(R.id.target); + dragText = (TextView) findViewById(R.id.drag_text); + scaleText = (TextView) findViewById(R.id.scale_text); + rotateText = (TextView) findViewById(R.id.rotate_text); + + dragGestureRecognizer.setElement(target); + scaleGestureRecognizer.setElement(target); + rotateGestureRecognizer.setElement(target); + + dragGestureRecognizer.addStateChangeListener(stateChangeListener); + scaleGestureRecognizer.addStateChangeListener(stateChangeListener); + rotateGestureRecognizer.addStateChangeListener(stateChangeListener); + + target.setOnTouchListener(onTouchListener); + } + + private final OnTouchListener onTouchListener = new OnTouchListener() { + @Override + public boolean onTouch(View v, MotionEvent event) { + boolean handled = false; + handled |= dragGestureRecognizer.onTouchEvent(event); + handled |= scaleGestureRecognizer.onTouchEvent(event); + handled |= rotateGestureRecognizer.onTouchEvent(event); + return handled; + } + }; + + private final GestureStateChangeListener stateChangeListener = new GestureStateChangeListener() { + @Override + public void onStateChanged(GestureRecognizer gestureRecognizer) { + CharSequence string = createDebugString(gestureRecognizer); + + if (gestureRecognizer == dragGestureRecognizer) { + dragText.setText(string); + } else if (gestureRecognizer == scaleGestureRecognizer) { + scaleText.setText(string); + } else if (gestureRecognizer == rotateGestureRecognizer) { + rotateText.setText(string); + } + } + }; + + private CharSequence createDebugString(GestureRecognizer gestureRecognizer) { + if (gestureRecognizer == dragGestureRecognizer) { + return String.format(Locale.getDefault(), + "Drag\nstate: %d, tx: %.3f, ty: %.3f, cx: %.3f, cy: %.3f, vx: %.3f, vy: %.3f", + dragGestureRecognizer.getState(), + dragGestureRecognizer.getTranslationX(), + dragGestureRecognizer.getTranslationY(), + dragGestureRecognizer.getCentroidX(), + dragGestureRecognizer.getCentroidY(), + dragGestureRecognizer.getVelocityX(), + dragGestureRecognizer.getVelocityY()); + } else if (gestureRecognizer == scaleGestureRecognizer) { + return String.format(Locale.getDefault(), + "Scale\nstate: %d, s: %.3f, cx: %.3f, cy: %.3f, v: %.3f", + scaleGestureRecognizer.getState(), + scaleGestureRecognizer.getScale(), + scaleGestureRecognizer.getCentroidX(), + scaleGestureRecognizer.getCentroidY(), + scaleGestureRecognizer.getVelocity()); + } else if (gestureRecognizer == rotateGestureRecognizer) { + return String.format(Locale.getDefault(), + "Rotate\nstate: %d, r: %.3f, cx: %.3f, cy: %.3f, v: %.3f", + rotateGestureRecognizer.getState(), + rotateGestureRecognizer.getRotation(), + rotateGestureRecognizer.getCentroidX(), + rotateGestureRecognizer.getCentroidY(), + rotateGestureRecognizer.getVelocity()); + } + return null; } } diff --git a/sample/src/main/res/layout/main_activity.xml b/sample/src/main/res/layout/main_activity.xml index 37677cf..319e81a 100644 --- a/sample/src/main/res/layout/main_activity.xml +++ b/sample/src/main/res/layout/main_activity.xml @@ -14,12 +14,33 @@ ~ See the License for the specific language governing permissions and ~ limitations under the License. --> - + android:layout_height="match_parent" + android:orientation="vertical"> + + + - + android:text="Drag\n"/> + + + + +