Loading core/java/android/widget/Editor.java +109 −23 Original line number Diff line number Diff line Loading @@ -148,8 +148,11 @@ import java.util.Map; public class Editor { private static final String TAG = "Editor"; private static final boolean DEBUG_UNDO = false; // Specifies whether to use or not the magnifier when pressing the insertion or selection // handles. // Specifies whether to allow starting a cursor drag by dragging anywhere over the text. @VisibleForTesting public static boolean FLAG_ENABLE_CURSOR_DRAG = false; // Specifies whether to use the magnifier when pressing the insertion or selection handles. private static final boolean FLAG_USE_MAGNIFIER = true; private static final int DELAY_BEFORE_HANDLE_FADES_OUT = 4000; Loading Loading @@ -204,7 +207,7 @@ public class Editor { private final MetricsLogger mMetricsLogger = new MetricsLogger(); // Cursor Controllers. private InsertionPointCursorController mInsertionPointCursorController; InsertionPointCursorController mInsertionPointCursorController; SelectionModifierCursorController mSelectionModifierCursorController; // Action mode used when text is selected or when actions on an insertion cursor are triggered. private ActionMode mTextActionMode; Loading Loading @@ -1471,6 +1474,9 @@ public class Editor { mTouchState.update(event, viewConfiguration); updateFloatingToolbarVisibility(event); if (hasInsertionController()) { getInsertionController().onTouchEvent(event); } if (hasSelectionController()) { getSelectionController().onTouchEvent(event); } Loading Loading @@ -5179,15 +5185,11 @@ public class Editor { case MotionEvent.ACTION_UP: if (!offsetHasBeenChanged()) { final float deltaX = mLastDownRawX - ev.getRawX(); final float deltaY = mLastDownRawY - ev.getRawY(); final float distanceSquared = deltaX * deltaX + deltaY * deltaY; final ViewConfiguration viewConfiguration = ViewConfiguration.get( mTextView.getContext()); final int touchSlop = viewConfiguration.getScaledTouchSlop(); if (distanceSquared < touchSlop * touchSlop) { ViewConfiguration config = ViewConfiguration.get(mTextView.getContext()); boolean isWithinTouchSlop = EditorTouchState.isDistanceWithin( mLastDownRawX, mLastDownRawY, ev.getRawX(), ev.getRawY(), config.getScaledTouchSlop()); if (isWithinTouchSlop) { // Tapping on the handle toggles the insertion action mode. if (mTextActionMode != null) { stopTextActionMode(); Loading Loading @@ -5237,6 +5239,10 @@ public class Editor { } else { offset = -1; } if (TextView.DEBUG_CURSOR) { logCursor("InsertionHandleView: updatePosition", "x=%f, y=%f, offset=%d, line=%d", x, y, offset, mPreviousLineTouched); } positionAtCursorOffset(offset, false, fromTouchScreen); if (mTextActionMode != null) { invalidateActionMode(); Loading Loading @@ -5716,8 +5722,82 @@ public class Editor { } } private class InsertionPointCursorController implements CursorController { class InsertionPointCursorController implements CursorController { private InsertionHandleView mHandle; private boolean mIsDraggingCursor; public void onTouchEvent(MotionEvent event) { switch (event.getActionMasked()) { case MotionEvent.ACTION_DOWN: mIsDraggingCursor = false; break; case MotionEvent.ACTION_MOVE: if (mIsDraggingCursor) { performCursorDrag(event); } else if (FLAG_ENABLE_CURSOR_DRAG && mTextView.getLayout() != null && mTextView.isFocused() && mTouchState.isMovedEnoughForDrag()) { startCursorDrag(event); } break; case MotionEvent.ACTION_UP: case MotionEvent.ACTION_CANCEL: if (mIsDraggingCursor) { endCursorDrag(event); } break; } } private void positionCursorDuringDrag(MotionEvent event) { int line = mTextView.getLineAtCoordinate(event.getY()); int offset = mTextView.getOffsetAtCoordinate(line, event.getX()); int oldSelectionStart = mTextView.getSelectionStart(); int oldSelectionEnd = mTextView.getSelectionEnd(); if (offset == oldSelectionStart && offset == oldSelectionEnd) { return; } Selection.setSelection((Spannable) mTextView.getText(), offset); updateCursorPosition(); if (mHapticTextHandleEnabled) { mTextView.performHapticFeedback(HapticFeedbackConstants.TEXT_HANDLE_MOVE); } } private void startCursorDrag(MotionEvent event) { if (TextView.DEBUG_CURSOR) { logCursor("InsertionPointCursorController", "start cursor drag"); } mIsDraggingCursor = true; // We don't want the parent scroll/long-press handlers to take over while dragging. mTextView.getParent().requestDisallowInterceptTouchEvent(true); mTextView.cancelLongPress(); // Update the cursor position. positionCursorDuringDrag(event); // Show the cursor handle and magnifier. show(); getHandle().removeHiderCallback(); getHandle().updateMagnifier(event); // TODO(b/146555651): Figure out if suspendBlink() should be called here. } private void performCursorDrag(MotionEvent event) { positionCursorDuringDrag(event); getHandle().updateMagnifier(event); } private void endCursorDrag(MotionEvent event) { if (TextView.DEBUG_CURSOR) { logCursor("InsertionPointCursorController", "end cursor drag"); } mIsDraggingCursor = false; // Hide the magnifier and set the handle to be hidden after a delay. getHandle().dismissMagnifier(); getHandle().hideAfterDelay(); // We're no longer dragging, so let the parent receive events. mTextView.getParent().requestDisallowInterceptTouchEvent(false); } public void show() { getHandle().show(); Loading @@ -5725,15 +5805,19 @@ public class Editor { final long durationSinceCutOrCopy = SystemClock.uptimeMillis() - TextView.sLastCutCopyOrTextChangedTime; // Cancel the single tap delayed runnable. if (mInsertionActionModeRunnable != null && (mTouchState.isMultiTap() || isCursorInsideEasyCorrectionSpan())) { if (mInsertionActionModeRunnable != null) { if (mIsDraggingCursor || mTouchState.isMultiTap() || isCursorInsideEasyCorrectionSpan()) { // Cancel the runnable for showing the floating toolbar. mTextView.removeCallbacks(mInsertionActionModeRunnable); } } // Prepare and schedule the single tap runnable to run exactly after the double tap // timeout has passed. if (!mTouchState.isMultiTap() // If the user recently performed a Cut or Copy action, we want to show the floating // toolbar even for a single tap. if (!mIsDraggingCursor && !mTouchState.isMultiTap() && !isCursorInsideEasyCorrectionSpan() && (durationSinceCutOrCopy < RECENT_CUT_COPY_DURATION_MS)) { if (mTextActionMode == null) { Loading @@ -5751,7 +5835,9 @@ public class Editor { } } if (!mIsDraggingCursor) { getHandle().hideAfterDelay(); } if (mSelectionModifierCursorController != null) { mSelectionModifierCursorController.hide(); Loading Loading @@ -5797,7 +5883,7 @@ public class Editor { @Override public boolean isCursorBeingModified() { return mHandle != null && mHandle.isDragging(); return mIsDraggingCursor || (mHandle != null && mHandle.isDragging()); } @Override Loading Loading @@ -5959,7 +6045,6 @@ public class Editor { case MotionEvent.ACTION_MOVE: final ViewConfiguration viewConfig = ViewConfiguration.get( mTextView.getContext()); final int touchSlop = viewConfig.getScaledTouchSlop(); if (mGestureStayedInTapRegion || mHaventMovedEnoughToStartDrag) { final float deltaX = eventX - mTouchState.getLastDownX(); Loading @@ -5973,6 +6058,7 @@ public class Editor { } if (mHaventMovedEnoughToStartDrag) { // We don't start dragging until the user has moved enough. int touchSlop = viewConfig.getScaledTouchSlop(); mHaventMovedEnoughToStartDrag = distanceSquared <= touchSlop * touchSlop; } Loading core/java/android/widget/EditorTouchState.java +27 −6 Original line number Diff line number Diff line Loading @@ -55,6 +55,8 @@ public class EditorTouchState { private int mMultiTapStatus = MultiTapStatus.NONE; private boolean mMultiTapInSameArea; private boolean mMovedEnoughForDrag; public float getLastDownX() { return mLastDownX; } Loading Loading @@ -88,10 +90,14 @@ public class EditorTouchState { return isMultiTap() && mMultiTapInSameArea; } public boolean isMovedEnoughForDrag() { return mMovedEnoughForDrag; } /** * Updates the state based on the new event. */ public void update(MotionEvent event, ViewConfiguration viewConfiguration) { public void update(MotionEvent event, ViewConfiguration config) { final int action = event.getActionMasked(); if (action == MotionEvent.ACTION_DOWN) { final boolean isMouse = event.isFromSource(InputDevice.SOURCE_MOUSE); Loading @@ -105,11 +111,8 @@ public class EditorTouchState { } else { mMultiTapStatus = MultiTapStatus.TRIPLE_CLICK; } final float deltaX = event.getX() - mLastDownX; final float deltaY = event.getY() - mLastDownY; final int distanceSquared = (int) ((deltaX * deltaX) + (deltaY * deltaY)); int doubleTapSlop = viewConfiguration.getScaledDoubleTapSlop(); mMultiTapInSameArea = distanceSquared < doubleTapSlop * doubleTapSlop; mMultiTapInSameArea = isDistanceWithin(mLastDownX, mLastDownY, event.getX(), event.getY(), config.getScaledDoubleTapSlop()); if (TextView.DEBUG_CURSOR) { String status = isDoubleTap() ? "double" : "triple"; String inSameArea = mMultiTapInSameArea ? "in same area" : "not in same area"; Loading @@ -125,6 +128,7 @@ public class EditorTouchState { } mLastDownX = event.getX(); mLastDownY = event.getY(); mMovedEnoughForDrag = false; } else if (action == MotionEvent.ACTION_UP) { if (TextView.DEBUG_CURSOR) { logCursor("EditorTouchState", "ACTION_UP"); Loading @@ -132,6 +136,23 @@ public class EditorTouchState { mLastUpX = event.getX(); mLastUpY = event.getY(); mLastUpMillis = event.getEventTime(); mMovedEnoughForDrag = false; } else if (action == MotionEvent.ACTION_MOVE) { mMovedEnoughForDrag = !isDistanceWithin(mLastDownX, mLastDownY, event.getX(), event.getY(), config.getScaledTouchSlop()); } } /** * Returns true if the distance between the given coordinates is <= to the specified max. * This is useful to be able to determine e.g. when the user's touch has moved enough in * order to be considered a drag (no longer within touch slop). */ public static boolean isDistanceWithin(float x1, float y1, float x2, float y2, int maxDistance) { float deltaX = x2 - x1; float deltaY = y2 - y1; float distanceSquared = (deltaX * deltaX) + (deltaY * deltaY); return distanceSquared <= maxDistance * maxDistance; } } core/java/android/widget/TextView.java +4 −0 Original line number Diff line number Diff line Loading @@ -10870,6 +10870,10 @@ public class TextView extends View implements ViewTreeObserver.OnPreDrawListener if (mEditor != null) { mEditor.onTouchEvent(event); if (mEditor.mInsertionPointCursorController != null && mEditor.mInsertionPointCursorController.isCursorBeingModified()) { return true; } if (mEditor.mSelectionModifierCursorController != null && mEditor.mSelectionModifierCursorController.isDragAcceleratorActive()) { return true; Loading core/tests/coretests/src/android/widget/EditorCursorDragTest.java 0 → 100644 +176 −0 Original line number Diff line number Diff line /* * Copyright (C) 2019 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 android.widget.espresso.TextViewActions.dragOnText; import static android.widget.espresso.TextViewAssertions.hasInsertionPointerAtIndex; import static androidx.test.espresso.Espresso.onView; import static androidx.test.espresso.action.ViewActions.replaceText; import static androidx.test.espresso.matcher.ViewMatchers.withId; import android.app.Activity; import android.app.Instrumentation; import androidx.test.InstrumentationRegistry; import androidx.test.filters.SmallTest; import androidx.test.rule.ActivityTestRule; import androidx.test.runner.AndroidJUnit4; import com.android.frameworks.coretests.R; import com.google.common.base.Strings; import org.junit.After; import org.junit.Before; import org.junit.Rule; import org.junit.Test; import org.junit.runner.RunWith; @RunWith(AndroidJUnit4.class) @SmallTest public class EditorCursorDragTest { @Rule public ActivityTestRule<TextViewActivity> mActivityRule = new ActivityTestRule<>( TextViewActivity.class); private boolean mOriginalFlagValue; private Instrumentation mInstrumentation; private Activity mActivity; @Before public void before() throws Throwable { mInstrumentation = InstrumentationRegistry.getInstrumentation(); mActivity = mActivityRule.getActivity(); mOriginalFlagValue = Editor.FLAG_ENABLE_CURSOR_DRAG; Editor.FLAG_ENABLE_CURSOR_DRAG = true; } @After public void after() throws Throwable { Editor.FLAG_ENABLE_CURSOR_DRAG = mOriginalFlagValue; } @Test public void testCursorDrag_horizontal_whenTextViewContentsFitOnScreen() throws Throwable { String text = "Hello world!"; onView(withId(R.id.textview)).perform(replaceText(text)); onView(withId(R.id.textview)).check(hasInsertionPointerAtIndex(0)); // Drag left to right. The cursor should end up at the position where the finger is lifted. onView(withId(R.id.textview)).perform(dragOnText(text.indexOf("llo"), text.indexOf("!"))); onView(withId(R.id.textview)).check(hasInsertionPointerAtIndex(11)); // Drag right to left. The cursor should end up at the position where the finger is lifted. onView(withId(R.id.textview)).perform(dragOnText(text.indexOf("!"), text.indexOf("llo"))); onView(withId(R.id.textview)).check(hasInsertionPointerAtIndex(2)); } @Test public void testCursorDrag_horizontal_whenTextViewContentsLargerThanScreen() throws Throwable { String text = "Hello world!" + Strings.repeat("\n", 500) + "012345middle" + Strings.repeat("\n", 10) + "012345last"; onView(withId(R.id.textview)).perform(replaceText(text)); onView(withId(R.id.textview)).check(hasInsertionPointerAtIndex(0)); // Drag left to right. The cursor should end up at the position where the finger is lifted. onView(withId(R.id.textview)).perform(dragOnText(text.indexOf("llo"), text.indexOf("!"))); onView(withId(R.id.textview)).check(hasInsertionPointerAtIndex(11)); // Drag right to left. The cursor should end up at the position where the finger is lifted. onView(withId(R.id.textview)).perform(dragOnText(text.indexOf("!"), text.indexOf("llo"))); onView(withId(R.id.textview)).check(hasInsertionPointerAtIndex(2)); } @Test public void testCursorDrag_diagonal_whenTextViewContentsLargerThanScreen() throws Throwable { StringBuilder sb = new StringBuilder(); for (int i = 1; i <= 9; i++) { sb.append("line").append(i).append("\n"); } sb.append(Strings.repeat("0123456789\n\n", 500)).append("Last line"); String text = sb.toString(); onView(withId(R.id.textview)).perform(replaceText(text)); onView(withId(R.id.textview)).check(hasInsertionPointerAtIndex(0)); // Drag along a diagonal path. onView(withId(R.id.textview)).perform(dragOnText(text.indexOf("line1"), text.indexOf("2"))); onView(withId(R.id.textview)).check(hasInsertionPointerAtIndex(text.indexOf("2"))); // Drag along a steeper diagonal path. onView(withId(R.id.textview)).perform(dragOnText(text.indexOf("line1"), text.indexOf("9"))); onView(withId(R.id.textview)).check(hasInsertionPointerAtIndex(text.indexOf("9"))); // Drag along an almost vertical path. // TODO(b/145833335): Consider whether this should scroll instead of dragging the cursor. onView(withId(R.id.textview)).perform(dragOnText(text.indexOf("ne1"), text.indexOf("9"))); onView(withId(R.id.textview)).check(hasInsertionPointerAtIndex(text.indexOf("9"))); // Drag along a vertical path from line 1 to line 9. // TODO(b/145833335): Consider whether this should scroll instead of dragging the cursor. onView(withId(R.id.textview)).perform(dragOnText(text.indexOf("e1"), text.indexOf("e9"))); onView(withId(R.id.textview)).check(hasInsertionPointerAtIndex(text.indexOf("e9"))); // Drag along a vertical path from line 9 to line 1. // TODO(b/145833335): Consider whether this should scroll instead of dragging the cursor. onView(withId(R.id.textview)).perform(dragOnText(text.indexOf("e9"), text.indexOf("e1"))); onView(withId(R.id.textview)).check(hasInsertionPointerAtIndex(text.indexOf("e1"))); } @Test public void testCursorDrag_vertical_whenTextViewContentsFitOnScreen() throws Throwable { String text = "012first\n\n" + Strings.repeat("012345\n\n", 10) + "012last"; onView(withId(R.id.textview)).perform(replaceText(text)); onView(withId(R.id.textview)).check(hasInsertionPointerAtIndex(0)); // Drag down. Since neither the TextView nor its container require scrolling, the cursor // drag should execute and the cursor should end up at the position where the finger is // lifted. onView(withId(R.id.textview)).perform( dragOnText(text.indexOf("first"), text.indexOf("last"))); onView(withId(R.id.textview)).check(hasInsertionPointerAtIndex(text.length() - 4)); // Drag up. Since neither the TextView nor its container require scrolling, the cursor // drag should execute and the cursor should end up at the position where the finger is // lifted. onView(withId(R.id.textview)).perform( dragOnText(text.indexOf("last"), text.indexOf("first"))); onView(withId(R.id.textview)).check(hasInsertionPointerAtIndex(3)); } @Test public void testCursorDrag_vertical_whenTextViewContentsLargerThanScreen() throws Throwable { String text = "012345first\n\n" + Strings.repeat("0123456789\n\n", 10) + "012345middle" + Strings.repeat("0123456789\n\n", 500) + "012345last"; onView(withId(R.id.textview)).perform(replaceText(text)); int initialCursorPosition = 0; onView(withId(R.id.textview)).check(hasInsertionPointerAtIndex(initialCursorPosition)); // Drag up. // TODO(b/145833335): Consider whether this should scroll instead of dragging the cursor. onView(withId(R.id.textview)).perform( dragOnText(text.indexOf("middle"), text.indexOf("first"))); onView(withId(R.id.textview)).check(hasInsertionPointerAtIndex(text.indexOf("first"))); // Drag down. // TODO(b/145833335): Consider whether this should scroll instead of dragging the cursor. onView(withId(R.id.textview)).perform( dragOnText(text.indexOf("first"), text.indexOf("middle"))); onView(withId(R.id.textview)).check(hasInsertionPointerAtIndex(text.indexOf("middle"))); } } core/tests/coretests/src/android/widget/EditorTouchStateTest.java +69 −23 File changed.Preview size limit exceeded, changes collapsed. Show changes Loading
core/java/android/widget/Editor.java +109 −23 Original line number Diff line number Diff line Loading @@ -148,8 +148,11 @@ import java.util.Map; public class Editor { private static final String TAG = "Editor"; private static final boolean DEBUG_UNDO = false; // Specifies whether to use or not the magnifier when pressing the insertion or selection // handles. // Specifies whether to allow starting a cursor drag by dragging anywhere over the text. @VisibleForTesting public static boolean FLAG_ENABLE_CURSOR_DRAG = false; // Specifies whether to use the magnifier when pressing the insertion or selection handles. private static final boolean FLAG_USE_MAGNIFIER = true; private static final int DELAY_BEFORE_HANDLE_FADES_OUT = 4000; Loading Loading @@ -204,7 +207,7 @@ public class Editor { private final MetricsLogger mMetricsLogger = new MetricsLogger(); // Cursor Controllers. private InsertionPointCursorController mInsertionPointCursorController; InsertionPointCursorController mInsertionPointCursorController; SelectionModifierCursorController mSelectionModifierCursorController; // Action mode used when text is selected or when actions on an insertion cursor are triggered. private ActionMode mTextActionMode; Loading Loading @@ -1471,6 +1474,9 @@ public class Editor { mTouchState.update(event, viewConfiguration); updateFloatingToolbarVisibility(event); if (hasInsertionController()) { getInsertionController().onTouchEvent(event); } if (hasSelectionController()) { getSelectionController().onTouchEvent(event); } Loading Loading @@ -5179,15 +5185,11 @@ public class Editor { case MotionEvent.ACTION_UP: if (!offsetHasBeenChanged()) { final float deltaX = mLastDownRawX - ev.getRawX(); final float deltaY = mLastDownRawY - ev.getRawY(); final float distanceSquared = deltaX * deltaX + deltaY * deltaY; final ViewConfiguration viewConfiguration = ViewConfiguration.get( mTextView.getContext()); final int touchSlop = viewConfiguration.getScaledTouchSlop(); if (distanceSquared < touchSlop * touchSlop) { ViewConfiguration config = ViewConfiguration.get(mTextView.getContext()); boolean isWithinTouchSlop = EditorTouchState.isDistanceWithin( mLastDownRawX, mLastDownRawY, ev.getRawX(), ev.getRawY(), config.getScaledTouchSlop()); if (isWithinTouchSlop) { // Tapping on the handle toggles the insertion action mode. if (mTextActionMode != null) { stopTextActionMode(); Loading Loading @@ -5237,6 +5239,10 @@ public class Editor { } else { offset = -1; } if (TextView.DEBUG_CURSOR) { logCursor("InsertionHandleView: updatePosition", "x=%f, y=%f, offset=%d, line=%d", x, y, offset, mPreviousLineTouched); } positionAtCursorOffset(offset, false, fromTouchScreen); if (mTextActionMode != null) { invalidateActionMode(); Loading Loading @@ -5716,8 +5722,82 @@ public class Editor { } } private class InsertionPointCursorController implements CursorController { class InsertionPointCursorController implements CursorController { private InsertionHandleView mHandle; private boolean mIsDraggingCursor; public void onTouchEvent(MotionEvent event) { switch (event.getActionMasked()) { case MotionEvent.ACTION_DOWN: mIsDraggingCursor = false; break; case MotionEvent.ACTION_MOVE: if (mIsDraggingCursor) { performCursorDrag(event); } else if (FLAG_ENABLE_CURSOR_DRAG && mTextView.getLayout() != null && mTextView.isFocused() && mTouchState.isMovedEnoughForDrag()) { startCursorDrag(event); } break; case MotionEvent.ACTION_UP: case MotionEvent.ACTION_CANCEL: if (mIsDraggingCursor) { endCursorDrag(event); } break; } } private void positionCursorDuringDrag(MotionEvent event) { int line = mTextView.getLineAtCoordinate(event.getY()); int offset = mTextView.getOffsetAtCoordinate(line, event.getX()); int oldSelectionStart = mTextView.getSelectionStart(); int oldSelectionEnd = mTextView.getSelectionEnd(); if (offset == oldSelectionStart && offset == oldSelectionEnd) { return; } Selection.setSelection((Spannable) mTextView.getText(), offset); updateCursorPosition(); if (mHapticTextHandleEnabled) { mTextView.performHapticFeedback(HapticFeedbackConstants.TEXT_HANDLE_MOVE); } } private void startCursorDrag(MotionEvent event) { if (TextView.DEBUG_CURSOR) { logCursor("InsertionPointCursorController", "start cursor drag"); } mIsDraggingCursor = true; // We don't want the parent scroll/long-press handlers to take over while dragging. mTextView.getParent().requestDisallowInterceptTouchEvent(true); mTextView.cancelLongPress(); // Update the cursor position. positionCursorDuringDrag(event); // Show the cursor handle and magnifier. show(); getHandle().removeHiderCallback(); getHandle().updateMagnifier(event); // TODO(b/146555651): Figure out if suspendBlink() should be called here. } private void performCursorDrag(MotionEvent event) { positionCursorDuringDrag(event); getHandle().updateMagnifier(event); } private void endCursorDrag(MotionEvent event) { if (TextView.DEBUG_CURSOR) { logCursor("InsertionPointCursorController", "end cursor drag"); } mIsDraggingCursor = false; // Hide the magnifier and set the handle to be hidden after a delay. getHandle().dismissMagnifier(); getHandle().hideAfterDelay(); // We're no longer dragging, so let the parent receive events. mTextView.getParent().requestDisallowInterceptTouchEvent(false); } public void show() { getHandle().show(); Loading @@ -5725,15 +5805,19 @@ public class Editor { final long durationSinceCutOrCopy = SystemClock.uptimeMillis() - TextView.sLastCutCopyOrTextChangedTime; // Cancel the single tap delayed runnable. if (mInsertionActionModeRunnable != null && (mTouchState.isMultiTap() || isCursorInsideEasyCorrectionSpan())) { if (mInsertionActionModeRunnable != null) { if (mIsDraggingCursor || mTouchState.isMultiTap() || isCursorInsideEasyCorrectionSpan()) { // Cancel the runnable for showing the floating toolbar. mTextView.removeCallbacks(mInsertionActionModeRunnable); } } // Prepare and schedule the single tap runnable to run exactly after the double tap // timeout has passed. if (!mTouchState.isMultiTap() // If the user recently performed a Cut or Copy action, we want to show the floating // toolbar even for a single tap. if (!mIsDraggingCursor && !mTouchState.isMultiTap() && !isCursorInsideEasyCorrectionSpan() && (durationSinceCutOrCopy < RECENT_CUT_COPY_DURATION_MS)) { if (mTextActionMode == null) { Loading @@ -5751,7 +5835,9 @@ public class Editor { } } if (!mIsDraggingCursor) { getHandle().hideAfterDelay(); } if (mSelectionModifierCursorController != null) { mSelectionModifierCursorController.hide(); Loading Loading @@ -5797,7 +5883,7 @@ public class Editor { @Override public boolean isCursorBeingModified() { return mHandle != null && mHandle.isDragging(); return mIsDraggingCursor || (mHandle != null && mHandle.isDragging()); } @Override Loading Loading @@ -5959,7 +6045,6 @@ public class Editor { case MotionEvent.ACTION_MOVE: final ViewConfiguration viewConfig = ViewConfiguration.get( mTextView.getContext()); final int touchSlop = viewConfig.getScaledTouchSlop(); if (mGestureStayedInTapRegion || mHaventMovedEnoughToStartDrag) { final float deltaX = eventX - mTouchState.getLastDownX(); Loading @@ -5973,6 +6058,7 @@ public class Editor { } if (mHaventMovedEnoughToStartDrag) { // We don't start dragging until the user has moved enough. int touchSlop = viewConfig.getScaledTouchSlop(); mHaventMovedEnoughToStartDrag = distanceSquared <= touchSlop * touchSlop; } Loading
core/java/android/widget/EditorTouchState.java +27 −6 Original line number Diff line number Diff line Loading @@ -55,6 +55,8 @@ public class EditorTouchState { private int mMultiTapStatus = MultiTapStatus.NONE; private boolean mMultiTapInSameArea; private boolean mMovedEnoughForDrag; public float getLastDownX() { return mLastDownX; } Loading Loading @@ -88,10 +90,14 @@ public class EditorTouchState { return isMultiTap() && mMultiTapInSameArea; } public boolean isMovedEnoughForDrag() { return mMovedEnoughForDrag; } /** * Updates the state based on the new event. */ public void update(MotionEvent event, ViewConfiguration viewConfiguration) { public void update(MotionEvent event, ViewConfiguration config) { final int action = event.getActionMasked(); if (action == MotionEvent.ACTION_DOWN) { final boolean isMouse = event.isFromSource(InputDevice.SOURCE_MOUSE); Loading @@ -105,11 +111,8 @@ public class EditorTouchState { } else { mMultiTapStatus = MultiTapStatus.TRIPLE_CLICK; } final float deltaX = event.getX() - mLastDownX; final float deltaY = event.getY() - mLastDownY; final int distanceSquared = (int) ((deltaX * deltaX) + (deltaY * deltaY)); int doubleTapSlop = viewConfiguration.getScaledDoubleTapSlop(); mMultiTapInSameArea = distanceSquared < doubleTapSlop * doubleTapSlop; mMultiTapInSameArea = isDistanceWithin(mLastDownX, mLastDownY, event.getX(), event.getY(), config.getScaledDoubleTapSlop()); if (TextView.DEBUG_CURSOR) { String status = isDoubleTap() ? "double" : "triple"; String inSameArea = mMultiTapInSameArea ? "in same area" : "not in same area"; Loading @@ -125,6 +128,7 @@ public class EditorTouchState { } mLastDownX = event.getX(); mLastDownY = event.getY(); mMovedEnoughForDrag = false; } else if (action == MotionEvent.ACTION_UP) { if (TextView.DEBUG_CURSOR) { logCursor("EditorTouchState", "ACTION_UP"); Loading @@ -132,6 +136,23 @@ public class EditorTouchState { mLastUpX = event.getX(); mLastUpY = event.getY(); mLastUpMillis = event.getEventTime(); mMovedEnoughForDrag = false; } else if (action == MotionEvent.ACTION_MOVE) { mMovedEnoughForDrag = !isDistanceWithin(mLastDownX, mLastDownY, event.getX(), event.getY(), config.getScaledTouchSlop()); } } /** * Returns true if the distance between the given coordinates is <= to the specified max. * This is useful to be able to determine e.g. when the user's touch has moved enough in * order to be considered a drag (no longer within touch slop). */ public static boolean isDistanceWithin(float x1, float y1, float x2, float y2, int maxDistance) { float deltaX = x2 - x1; float deltaY = y2 - y1; float distanceSquared = (deltaX * deltaX) + (deltaY * deltaY); return distanceSquared <= maxDistance * maxDistance; } }
core/java/android/widget/TextView.java +4 −0 Original line number Diff line number Diff line Loading @@ -10870,6 +10870,10 @@ public class TextView extends View implements ViewTreeObserver.OnPreDrawListener if (mEditor != null) { mEditor.onTouchEvent(event); if (mEditor.mInsertionPointCursorController != null && mEditor.mInsertionPointCursorController.isCursorBeingModified()) { return true; } if (mEditor.mSelectionModifierCursorController != null && mEditor.mSelectionModifierCursorController.isDragAcceleratorActive()) { return true; Loading
core/tests/coretests/src/android/widget/EditorCursorDragTest.java 0 → 100644 +176 −0 Original line number Diff line number Diff line /* * Copyright (C) 2019 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 android.widget.espresso.TextViewActions.dragOnText; import static android.widget.espresso.TextViewAssertions.hasInsertionPointerAtIndex; import static androidx.test.espresso.Espresso.onView; import static androidx.test.espresso.action.ViewActions.replaceText; import static androidx.test.espresso.matcher.ViewMatchers.withId; import android.app.Activity; import android.app.Instrumentation; import androidx.test.InstrumentationRegistry; import androidx.test.filters.SmallTest; import androidx.test.rule.ActivityTestRule; import androidx.test.runner.AndroidJUnit4; import com.android.frameworks.coretests.R; import com.google.common.base.Strings; import org.junit.After; import org.junit.Before; import org.junit.Rule; import org.junit.Test; import org.junit.runner.RunWith; @RunWith(AndroidJUnit4.class) @SmallTest public class EditorCursorDragTest { @Rule public ActivityTestRule<TextViewActivity> mActivityRule = new ActivityTestRule<>( TextViewActivity.class); private boolean mOriginalFlagValue; private Instrumentation mInstrumentation; private Activity mActivity; @Before public void before() throws Throwable { mInstrumentation = InstrumentationRegistry.getInstrumentation(); mActivity = mActivityRule.getActivity(); mOriginalFlagValue = Editor.FLAG_ENABLE_CURSOR_DRAG; Editor.FLAG_ENABLE_CURSOR_DRAG = true; } @After public void after() throws Throwable { Editor.FLAG_ENABLE_CURSOR_DRAG = mOriginalFlagValue; } @Test public void testCursorDrag_horizontal_whenTextViewContentsFitOnScreen() throws Throwable { String text = "Hello world!"; onView(withId(R.id.textview)).perform(replaceText(text)); onView(withId(R.id.textview)).check(hasInsertionPointerAtIndex(0)); // Drag left to right. The cursor should end up at the position where the finger is lifted. onView(withId(R.id.textview)).perform(dragOnText(text.indexOf("llo"), text.indexOf("!"))); onView(withId(R.id.textview)).check(hasInsertionPointerAtIndex(11)); // Drag right to left. The cursor should end up at the position where the finger is lifted. onView(withId(R.id.textview)).perform(dragOnText(text.indexOf("!"), text.indexOf("llo"))); onView(withId(R.id.textview)).check(hasInsertionPointerAtIndex(2)); } @Test public void testCursorDrag_horizontal_whenTextViewContentsLargerThanScreen() throws Throwable { String text = "Hello world!" + Strings.repeat("\n", 500) + "012345middle" + Strings.repeat("\n", 10) + "012345last"; onView(withId(R.id.textview)).perform(replaceText(text)); onView(withId(R.id.textview)).check(hasInsertionPointerAtIndex(0)); // Drag left to right. The cursor should end up at the position where the finger is lifted. onView(withId(R.id.textview)).perform(dragOnText(text.indexOf("llo"), text.indexOf("!"))); onView(withId(R.id.textview)).check(hasInsertionPointerAtIndex(11)); // Drag right to left. The cursor should end up at the position where the finger is lifted. onView(withId(R.id.textview)).perform(dragOnText(text.indexOf("!"), text.indexOf("llo"))); onView(withId(R.id.textview)).check(hasInsertionPointerAtIndex(2)); } @Test public void testCursorDrag_diagonal_whenTextViewContentsLargerThanScreen() throws Throwable { StringBuilder sb = new StringBuilder(); for (int i = 1; i <= 9; i++) { sb.append("line").append(i).append("\n"); } sb.append(Strings.repeat("0123456789\n\n", 500)).append("Last line"); String text = sb.toString(); onView(withId(R.id.textview)).perform(replaceText(text)); onView(withId(R.id.textview)).check(hasInsertionPointerAtIndex(0)); // Drag along a diagonal path. onView(withId(R.id.textview)).perform(dragOnText(text.indexOf("line1"), text.indexOf("2"))); onView(withId(R.id.textview)).check(hasInsertionPointerAtIndex(text.indexOf("2"))); // Drag along a steeper diagonal path. onView(withId(R.id.textview)).perform(dragOnText(text.indexOf("line1"), text.indexOf("9"))); onView(withId(R.id.textview)).check(hasInsertionPointerAtIndex(text.indexOf("9"))); // Drag along an almost vertical path. // TODO(b/145833335): Consider whether this should scroll instead of dragging the cursor. onView(withId(R.id.textview)).perform(dragOnText(text.indexOf("ne1"), text.indexOf("9"))); onView(withId(R.id.textview)).check(hasInsertionPointerAtIndex(text.indexOf("9"))); // Drag along a vertical path from line 1 to line 9. // TODO(b/145833335): Consider whether this should scroll instead of dragging the cursor. onView(withId(R.id.textview)).perform(dragOnText(text.indexOf("e1"), text.indexOf("e9"))); onView(withId(R.id.textview)).check(hasInsertionPointerAtIndex(text.indexOf("e9"))); // Drag along a vertical path from line 9 to line 1. // TODO(b/145833335): Consider whether this should scroll instead of dragging the cursor. onView(withId(R.id.textview)).perform(dragOnText(text.indexOf("e9"), text.indexOf("e1"))); onView(withId(R.id.textview)).check(hasInsertionPointerAtIndex(text.indexOf("e1"))); } @Test public void testCursorDrag_vertical_whenTextViewContentsFitOnScreen() throws Throwable { String text = "012first\n\n" + Strings.repeat("012345\n\n", 10) + "012last"; onView(withId(R.id.textview)).perform(replaceText(text)); onView(withId(R.id.textview)).check(hasInsertionPointerAtIndex(0)); // Drag down. Since neither the TextView nor its container require scrolling, the cursor // drag should execute and the cursor should end up at the position where the finger is // lifted. onView(withId(R.id.textview)).perform( dragOnText(text.indexOf("first"), text.indexOf("last"))); onView(withId(R.id.textview)).check(hasInsertionPointerAtIndex(text.length() - 4)); // Drag up. Since neither the TextView nor its container require scrolling, the cursor // drag should execute and the cursor should end up at the position where the finger is // lifted. onView(withId(R.id.textview)).perform( dragOnText(text.indexOf("last"), text.indexOf("first"))); onView(withId(R.id.textview)).check(hasInsertionPointerAtIndex(3)); } @Test public void testCursorDrag_vertical_whenTextViewContentsLargerThanScreen() throws Throwable { String text = "012345first\n\n" + Strings.repeat("0123456789\n\n", 10) + "012345middle" + Strings.repeat("0123456789\n\n", 500) + "012345last"; onView(withId(R.id.textview)).perform(replaceText(text)); int initialCursorPosition = 0; onView(withId(R.id.textview)).check(hasInsertionPointerAtIndex(initialCursorPosition)); // Drag up. // TODO(b/145833335): Consider whether this should scroll instead of dragging the cursor. onView(withId(R.id.textview)).perform( dragOnText(text.indexOf("middle"), text.indexOf("first"))); onView(withId(R.id.textview)).check(hasInsertionPointerAtIndex(text.indexOf("first"))); // Drag down. // TODO(b/145833335): Consider whether this should scroll instead of dragging the cursor. onView(withId(R.id.textview)).perform( dragOnText(text.indexOf("first"), text.indexOf("middle"))); onView(withId(R.id.textview)).check(hasInsertionPointerAtIndex(text.indexOf("middle"))); } }
core/tests/coretests/src/android/widget/EditorTouchStateTest.java +69 −23 File changed.Preview size limit exceeded, changes collapsed. Show changes