From 91df3f9e7c95d43d645e158b7d8fd34acc3385d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petar=20=C5=A0egina?= Date: Tue, 15 Aug 2017 16:20:43 +0100 Subject: [PATCH] Expand the animation from the user's last touch point The Smart Select animation now expands from the spot the user last lifted their finger. In order to achieve this, the last up event coordinates need to be tracked in Editor. Since it's possible to trigger Smart Select by having the second of the two taps outside any of the rectangles, the touch point gets moved into the nearest rectangle and the animation starts from that point. Test: manual - try out Smart Select by touching different words at different points Test: manual - try to trigger Smart Select with a double tap where the second tap is outside of the word Test: bit FrameworksCoreTests:android.widget.SelectionActionModeHelperTest Test: bit CtsViewTestCases:android.view.textclassifier.cts.TextClassificationManagerTest Test: bit FrameworksCoreTests:android.widget.TextViewActivityTest Test: bit CtsAccessibilityServiceTestCases:android.accessibilityservice.cts.AccessibilityTextTraversalTest Change-Id: I96844e8307554b010b476673820f98dae09c0cc3 --- core/java/android/widget/Editor.java | 14 +++ .../widget/SelectionActionModeHelper.java | 50 ++++++-- .../android/widget/SmartSelectSprite.java | 55 +++++---- .../widget/SelectionActionModeHelperTest.java | 113 ++++++++++++++++++ 4 files changed, 205 insertions(+), 27 deletions(-) create mode 100644 core/tests/coretests/src/android/widget/SelectionActionModeHelperTest.java diff --git a/core/java/android/widget/Editor.java b/core/java/android/widget/Editor.java index d02d6ff9a9b09..7fa80666d053c 100644 --- a/core/java/android/widget/Editor.java +++ b/core/java/android/widget/Editor.java @@ -260,6 +260,7 @@ public class Editor { private PositionListener mPositionListener; private float mLastDownPositionX, mLastDownPositionY; + private float mLastUpPositionX, mLastUpPositionY; private float mContextMenuAnchorX, mContextMenuAnchorY; Callback mCustomSelectionActionModeCallback; Callback mCustomInsertionActionModeCallback; @@ -1130,6 +1131,14 @@ public class Editor { return handled; } + float getLastUpPositionX() { + return mLastUpPositionX; + } + + float getLastUpPositionY() { + return mLastUpPositionY; + } + private long getLastTouchOffsets() { SelectionModifierCursorController selectionController = getSelectionController(); final int minOffset = selectionController.getMinTouchOffset(); @@ -1371,6 +1380,11 @@ public class Editor { mShowSuggestionRunnable = null; } + if (event.getActionMasked() == MotionEvent.ACTION_UP) { + mLastUpPositionX = event.getX(); + mLastUpPositionY = event.getY(); + } + if (event.getActionMasked() == MotionEvent.ACTION_DOWN) { mLastDownPositionX = event.getX(); mLastDownPositionY = event.getY(); diff --git a/core/java/android/widget/SelectionActionModeHelper.java b/core/java/android/widget/SelectionActionModeHelper.java index 5e70ef0b973f0..2561ffe572ab1 100644 --- a/core/java/android/widget/SelectionActionModeHelper.java +++ b/core/java/android/widget/SelectionActionModeHelper.java @@ -20,6 +20,7 @@ import android.annotation.NonNull; import android.annotation.Nullable; import android.annotation.UiThread; import android.annotation.WorkerThread; +import android.graphics.PointF; import android.graphics.RectF; import android.os.AsyncTask; import android.os.LocaleList; @@ -27,13 +28,13 @@ import android.text.Layout; import android.text.Selection; import android.text.Spannable; import android.text.TextUtils; -import android.util.Pair; import android.view.ActionMode; import android.view.textclassifier.TextClassification; import android.view.textclassifier.TextClassifier; import android.view.textclassifier.TextSelection; import android.widget.Editor.SelectionModifierCursorController; +import com.android.internal.annotations.VisibleForTesting; import com.android.internal.util.Preconditions; import java.util.ArrayList; @@ -45,8 +46,10 @@ import java.util.function.Supplier; /** * Helper class for starting selection action mode * (synchronously without the TextClassifier, asynchronously with the TextClassifier). + * @hide */ @UiThread +@VisibleForTesting(visibility = VisibleForTesting.Visibility.PACKAGE) final class SelectionActionModeHelper { /** @@ -224,15 +227,15 @@ final class SelectionActionModeHelper { rectangle.bottom += textView.getPaddingTop(); } - final RectF firstRectangle = selectionRectangles.get(0); + final PointF touchPoint = new PointF( + mEditor.getLastUpPositionX(), + mEditor.getLastUpPositionY()); - // TODO use the original touch point instead of the hardcoded point generated here - final Pair halfPoint = new Pair<>( - firstRectangle.centerX(), - firstRectangle.centerY()); + final PointF animationStartPoint = + movePointInsideNearestRectangle(touchPoint, selectionRectangles); mSmartSelectSprite.startAnimation( - halfPoint, + animationStartPoint, selectionRectangles, onAnimationEndCallback); } @@ -248,6 +251,39 @@ final class SelectionActionModeHelper { return result; } + /** @hide */ + @VisibleForTesting + public static PointF movePointInsideNearestRectangle(final PointF point, + final List rectangles) { + float bestX = -1; + float bestY = -1; + double bestDistance = Double.MAX_VALUE; + + for (final RectF rectangle : rectangles) { + final float candidateY = rectangle.centerY(); + final float candidateX; + + if (point.x > rectangle.right) { + candidateX = rectangle.right; + } else if (point.x < rectangle.left) { + candidateX = rectangle.left; + } else { + candidateX = point.x; + } + + final double candidateDistance = Math.pow(point.x - candidateX, 2) + + Math.pow(point.y - candidateY, 2); + + if (candidateDistance < bestDistance) { + bestX = candidateX; + bestY = candidateY; + bestDistance = candidateDistance; + } + } + + return new PointF(bestX, bestY); + } + private void invalidateActionMode(@Nullable SelectionResult result) { cancelSmartSelectAnimation(); mTextClassification = result != null ? result.mClassification : null; diff --git a/core/java/android/widget/SmartSelectSprite.java b/core/java/android/widget/SmartSelectSprite.java index e641a9bc5176d..94109d741fd8d 100644 --- a/core/java/android/widget/SmartSelectSprite.java +++ b/core/java/android/widget/SmartSelectSprite.java @@ -30,11 +30,11 @@ import android.content.res.TypedArray; import android.graphics.Canvas; import android.graphics.Paint; import android.graphics.Path; +import android.graphics.PointF; import android.graphics.RectF; import android.graphics.drawable.Drawable; import android.graphics.drawable.ShapeDrawable; import android.graphics.drawable.shapes.Shape; -import android.util.Pair; import android.util.TypedValue; import android.view.View; import android.view.ViewOverlay; @@ -82,16 +82,16 @@ final class SmartSelectSprite { private final float[] mLineCoordinates; - private PolygonShape(final List> points) { + private PolygonShape(final List points) { mLineCoordinates = new float[points.size() * POINTS_PER_LINE]; int index = 0; - Pair currentPoint = points.get(0); - for (final Pair nextPoint : points) { - mLineCoordinates[index] = currentPoint.first; - mLineCoordinates[index + 1] = currentPoint.second; - mLineCoordinates[index + 2] = nextPoint.first; - mLineCoordinates[index + 3] = nextPoint.second; + PointF currentPoint = points.get(0); + for (final PointF nextPoint : points) { + mLineCoordinates[index] = currentPoint.x; + mLineCoordinates[index + 1] = currentPoint.y; + mLineCoordinates[index + 2] = nextPoint.x; + mLineCoordinates[index + 3] = nextPoint.y; index += POINTS_PER_LINE; currentPoint = nextPoint; @@ -342,9 +342,9 @@ final class SmartSelectSprite { final List rectangles, final int color) { final List drawables = new LinkedList<>(); - final Set>> mergedPaths = calculateMergedPolygonPoints(rectangles); + final Set> mergedPaths = calculateMergedPolygonPoints(rectangles); - for (List> path : mergedPaths) { + for (List path : mergedPaths) { // Add the starting point to the end of the polygon so that it ends up closed. path.add(path.get(0)); @@ -361,7 +361,7 @@ final class SmartSelectSprite { return drawables; } - private static Set>> calculateMergedPolygonPoints( + private static Set> calculateMergedPolygonPoints( List rectangles) { final Set> partitions = new HashSet<>(); final LinkedList listOfRects = new LinkedList<>(rectangles); @@ -389,20 +389,20 @@ final class SmartSelectSprite { partitions.add(partition); } - final Set>> result = new HashSet<>(); + final Set> result = new HashSet<>(); for (List partition : partitions) { - final List> points = new LinkedList<>(); + final List points = new LinkedList<>(); final Stack rects = new Stack<>(); for (RectF rect : partition) { - points.add(new Pair<>(rect.right, rect.top)); - points.add(new Pair<>(rect.right, rect.bottom)); + points.add(new PointF(rect.right, rect.top)); + points.add(new PointF(rect.right, rect.bottom)); rects.add(rect); } while (!rects.isEmpty()) { final RectF rect = rects.pop(); - points.add(new Pair<>(rect.left, rect.bottom)); - points.add(new Pair<>(rect.left, rect.top)); + points.add(new PointF(rect.left, rect.bottom)); + points.add(new PointF(rect.left, rect.top)); } result.add(points); @@ -426,7 +426,7 @@ final class SmartSelectSprite { * @see #cancelAnimation() */ public void startAnimation( - final Pair start, + final PointF start, final List destinationRectangles, final Runnable onAnimationEnd) throws IllegalArgumentException { cancelAnimation(); @@ -439,7 +439,7 @@ final class SmartSelectSprite { final RectF centerRectangle = destinationRectangles .stream() - .filter((r) -> r.contains(start.first, start.second)) + .filter((r) -> contains(r, start)) .findFirst() .orElseThrow(() -> new IllegalArgumentException( "Center point is not inside any of the rectangles!")); @@ -452,7 +452,7 @@ final class SmartSelectSprite { startingOffset += rectangle.width(); } - startingOffset += start.first - centerRectangle.left; + startingOffset += start.x - centerRectangle.left; final float centerRectangleHalfHeight = centerRectangle.height() / 2; final float startingOffsetLeft = startingOffset - centerRectangleHalfHeight; @@ -632,6 +632,21 @@ final class SmartSelectSprite { return result; } + /** + * A variant of {@link RectF#contains(float, float)} that also allows the point to reside on + * the right boundary of the rectangle. + * + * @param rectangle the rectangle inside which the point should be to be considered "contained" + * @param point the point which will be tested + * @return whether the point is inside the rectangle (or on it's right boundary) + */ + private static boolean contains(final RectF rectangle, final PointF point) { + final float x = point.x; + final float y = point.y; + return x >= rectangle.left && x <= rectangle.right && y >= rectangle.top + && y <= rectangle.bottom; + } + private void addToOverlay(final Drawable drawable) { mView.getOverlay().add(drawable); mExistingAnimationDrawables.add(drawable); diff --git a/core/tests/coretests/src/android/widget/SelectionActionModeHelperTest.java b/core/tests/coretests/src/android/widget/SelectionActionModeHelperTest.java new file mode 100644 index 0000000000000..d94a017c27fd6 --- /dev/null +++ b/core/tests/coretests/src/android/widget/SelectionActionModeHelperTest.java @@ -0,0 +1,113 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * 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 android.widget; + +import static org.junit.Assert.assertEquals; + +import android.graphics.PointF; +import android.graphics.RectF; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +import java.util.Arrays; +import java.util.List; + +@RunWith(JUnit4.class) +public final class SelectionActionModeHelperTest { + + /* + * The test rectangle set is composed of three 1x1 rectangles as illustrated below. + * + * (0, 0) ____________ (100001, 0) + * |█ █| + * |_█________| + * (0, 2) (100001, 2) + */ + private final List mRectFList = Arrays.asList( + new RectF(0, 0, 1, 1), + new RectF(100000, 0, 100001, 1), + new RectF(1, 1, 2, 2)); + + @Test + public void testMovePointInsideNearestRectangle_pointIsInsideRectangle() { + testMovePointInsideNearestRectangle( + 0.1f /* pointX */, + 0.1f /* pointY */, + 0.1f /* expectedPointX */, + 0.5f /* expectedPointY */); + } + + @Test + public void testMovePointInsideNearestRectangle_pointIsAboveRectangle() { + testMovePointInsideNearestRectangle( + 0.1f /* pointX */, + -1.0f /* pointY */, + 0.1f /* expectedPointX */, + 0.5f /* expectedPointY */); + } + + @Test + public void testMovePointInsideNearestRectangle_pointIsLeftOfRectangle() { + testMovePointInsideNearestRectangle( + -1.0f /* pointX */, + 0.4f /* pointY */, + 0.0f /* expectedPointX */, + 0.5f /* expectedPointY */); + } + + @Test + public void testMovePointInsideNearestRectangle_pointIsRightOfRectangle() { + testMovePointInsideNearestRectangle( + 1.1f /* pointX */, + 0.0f /* pointY */, + 1.0f /* expectedPointX */, + 0.5f /* expectedPointY */); + } + + @Test + public void testMovePointInsideNearestRectangle_pointIsBelowRectangle() { + testMovePointInsideNearestRectangle( + 0.1f /* pointX */, + 1.1f /* pointY */, + 0.1f /* expectedPointX */, + 0.5f /* expectedPointY */); + } + + @Test + public void testMovePointInsideNearestRectangle_pointIsToRightOfTheRightmostRectangle() { + testMovePointInsideNearestRectangle( + 200000.0f /* pointX */, + 0.1f /* pointY */, + 100001.0f /* expectedPointX */, + 0.5f /* expectedPointY */); + } + + private void testMovePointInsideNearestRectangle(final float pointX, final float pointY, + final float expectedPointX, + final float expectedPointY) { + final PointF point = new PointF(pointX, pointY); + final PointF adjustedPoint = + SelectionActionModeHelper.movePointInsideNearestRectangle(point, + mRectFList); + + assertEquals(expectedPointX, adjustedPoint.x, 0.0f); + assertEquals(expectedPointY, adjustedPoint.y, 0.0f); + } + +}