Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 9a1369b4 authored by Nikita Dubrovsky's avatar Nikita Dubrovsky
Browse files

Ability to start a cursor drag from anywhere in an editable TextView

Bug: 143852764, 145535274
Test: Manual testing and unit tests
  atest FrameworksCoreTests:EditorTouchStateTest
  atest FrameworksCoreTests:EditorCursorDragTest
Change-Id: I6de3da98bd1e9e37a7d81e343514cb1e7ab6816a
parent 963c5ac7
Loading
Loading
Loading
Loading
+109 −23
Original line number Diff line number Diff line
@@ -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;
@@ -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;
@@ -1471,6 +1474,9 @@ public class Editor {
        mTouchState.update(event, viewConfiguration);
        updateFloatingToolbarVisibility(event);

        if (hasInsertionController()) {
            getInsertionController().onTouchEvent(event);
        }
        if (hasSelectionController()) {
            getSelectionController().onTouchEvent(event);
        }
@@ -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();
@@ -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();
@@ -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();
@@ -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) {
@@ -5751,7 +5835,9 @@ public class Editor {
                }
            }

            if (!mIsDraggingCursor) {
                getHandle().hideAfterDelay();
            }

            if (mSelectionModifierCursorController != null) {
                mSelectionModifierCursorController.hide();
@@ -5797,7 +5883,7 @@ public class Editor {

        @Override
        public boolean isCursorBeingModified() {
            return mHandle != null && mHandle.isDragging();
            return mIsDraggingCursor || (mHandle != null && mHandle.isDragging());
        }

        @Override
@@ -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();
@@ -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;
                        }
+27 −6
Original line number Diff line number Diff line
@@ -55,6 +55,8 @@ public class EditorTouchState {
    private int mMultiTapStatus = MultiTapStatus.NONE;
    private boolean mMultiTapInSameArea;

    private boolean mMovedEnoughForDrag;

    public float getLastDownX() {
        return mLastDownX;
    }
@@ -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);
@@ -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";
@@ -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");
@@ -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;
    }
}
+4 −0
Original line number Diff line number Diff line
@@ -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;
+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")));
    }
}
+69 −23

File changed.

Preview size limit exceeded, changes collapsed.

Loading