Merge "modularize the gesture detection mechanism for magnification"

This commit is contained in:
Ryan Lin
2021-01-06 05:51:57 +00:00
committed by Android (Google) Code Review
5 changed files with 453 additions and 49 deletions

View File

@@ -0,0 +1,195 @@
/*
* Copyright (C) 2020 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 com.android.systemui.accessibility;
import android.annotation.DisplayContext;
import android.annotation.NonNull;
import android.content.Context;
import android.graphics.PointF;
import android.os.Handler;
import android.view.Display;
import android.view.MotionEvent;
import android.view.ViewConfiguration;
/**
* Detects single tap and drag gestures using the supplied {@link MotionEvent}s. The {@link
* OnGestureListener} callback will notify users when a particular motion event has occurred. This
* class should only be used with {@link MotionEvent}s reported via touch (don't use for trackball
* events).
*/
class MagnificationGestureDetector {
interface OnGestureListener {
/**
* Called when a tap is completed within {@link ViewConfiguration#getLongPressTimeout()} and
* the offset between {@link MotionEvent}s and the down event doesn't exceed {@link
* ViewConfiguration#getScaledTouchSlop()}.
*
* @return {@code true} if this gesture is handled.
*/
boolean onSingleTap();
/**
* Called when the user is performing dragging gesture. It is started after the offset
* between the down location and the move event location exceed
* {@link ViewConfiguration#getScaledTouchSlop()}.
*
* @param offsetX The X offset in screen coordinate.
* @param offsetY The Y offset in screen coordinate.
* @return {@code true} if this gesture is handled.
*/
boolean onDrag(float offsetX, float offsetY);
/**
* Notified when a tap occurs with the down {@link MotionEvent} that triggered it. This will
* be triggered immediately for every down event. All other events should be preceded by
* this.
*
* @param x The X coordinate of the down event.
* @param y The Y coordinate of the down event.
* @return {@code true} if the down event is handled, otherwise the events won't be sent to
* the view.
*/
boolean onStart(float x, float y);
/**
* Called when the detection is finished. In other words, it is called when up/cancel {@link
* MotionEvent} is received. It will be triggered after single-tap
*
* @param x The X coordinate on the screen of the up event or the cancel event.
* @param y The Y coordinate on the screen of the up event or the cancel event.
* @return {@code true} if the event is handled.
*/
boolean onFinish(float x, float y);
}
private final PointF mPointerDown = new PointF();
private final PointF mPointerLocation = new PointF(Float.NaN, Float.NaN);
private final Handler mHandler;
private final Runnable mCancelTapGestureRunnable;
private final OnGestureListener mOnGestureListener;
private int mTouchSlopSquare;
// Assume the gesture default is a single-tap. Set it to false if the gesture couldn't be a
// single-tap anymore.
private boolean mDetectSingleTap = true;
private boolean mDraggingDetected = false;
/**
* @param context {@link Context} that is from {@link Context#createDisplayContext(Display)}.
* @param handler The handler to post the runnable.
* @param listener The listener invoked for all the callbacks.
*/
MagnificationGestureDetector(@DisplayContext Context context, @NonNull Handler handler,
@NonNull OnGestureListener listener) {
final int touchSlop = ViewConfiguration.get(context).getScaledTouchSlop();
mTouchSlopSquare = touchSlop * touchSlop;
mHandler = handler;
mOnGestureListener = listener;
mCancelTapGestureRunnable = () -> mDetectSingleTap = false;
}
/**
* Analyzes the given motion event and if applicable to trigger the appropriate callbacks on the
* {@link OnGestureListener} supplied.
*
* @param event The current motion event.
* @return {@code True} if the {@link OnGestureListener} consumes the event, else false.
*/
boolean onTouch(MotionEvent event) {
final float rawX = event.getRawX();
final float rawY = event.getRawY();
boolean handled = false;
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
mPointerDown.set(rawX, rawY);
mHandler.postAtTime(mCancelTapGestureRunnable,
event.getDownTime() + ViewConfiguration.getLongPressTimeout());
handled |= mOnGestureListener.onStart(rawX, rawY);
break;
case MotionEvent.ACTION_POINTER_DOWN:
stopSingleTapDetection();
break;
case MotionEvent.ACTION_MOVE:
stopSingleTapDetectionIfNeeded(rawX, rawY);
handled |= notifyDraggingGestureIfNeeded(rawX, rawY);
break;
case MotionEvent.ACTION_UP:
stopSingleTapDetectionIfNeeded(rawX, rawY);
if (mDetectSingleTap) {
handled |= mOnGestureListener.onSingleTap();
}
// Fall through
case MotionEvent.ACTION_CANCEL:
handled |= mOnGestureListener.onFinish(rawX, rawY);
reset();
break;
}
return handled;
}
private void stopSingleTapDetectionIfNeeded(float x, float y) {
if (mDraggingDetected) {
return;
}
if (!isLocationValid(mPointerDown)) {
return;
}
final int deltaX = (int) (mPointerDown.x - x);
final int deltaY = (int) (mPointerDown.y - y);
final int distanceSquare = (deltaX * deltaX) + (deltaY * deltaY);
if (distanceSquare > mTouchSlopSquare) {
mDraggingDetected = true;
stopSingleTapDetection();
}
}
private void stopSingleTapDetection() {
mHandler.removeCallbacks(mCancelTapGestureRunnable);
mDetectSingleTap = false;
}
private boolean notifyDraggingGestureIfNeeded(float x, float y) {
if (!mDraggingDetected) {
return false;
}
if (!isLocationValid(mPointerLocation)) {
mPointerLocation.set(mPointerDown);
}
final float offsetX = x - mPointerLocation.x;
final float offsetY = y - mPointerLocation.y;
mPointerLocation.set(x, y);
return mOnGestureListener.onDrag(offsetX, offsetY);
}
private void reset() {
resetPointF(mPointerDown);
resetPointF(mPointerLocation);
mHandler.removeCallbacks(mCancelTapGestureRunnable);
mDetectSingleTap = true;
mDraggingDetected = false;
}
private static void resetPointF(PointF pointF) {
pointF.x = Float.NaN;
pointF.y = Float.NaN;
}
private static boolean isLocationValid(PointF location) {
return !Float.isNaN(location.x) && !Float.isNaN(location.y);
}
}

View File

@@ -22,16 +22,13 @@ import android.annotation.NonNull;
import android.content.Context;
import android.content.pm.ActivityInfo;
import android.graphics.PixelFormat;
import android.graphics.PointF;
import android.graphics.Rect;
import android.os.Bundle;
import android.os.UserHandle;
import android.provider.Settings;
import android.util.MathUtils;
import android.view.Gravity;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewConfiguration;
import android.view.WindowManager;
import android.view.WindowManager.LayoutParams;
import android.view.accessibility.AccessibilityManager;
@@ -46,11 +43,11 @@ import java.util.Collections;
/**
* Shows/hides a {@link android.widget.ImageView} on the screen and changes the values of
* {@link Settings.Secure.ACCESSIBILITY_MAGNIFICATION_MODE} when the UI is toggled.
* {@link Settings.Secure#ACCESSIBILITY_MAGNIFICATION_MODE} when the UI is toggled.
* The button icon is movable by dragging. And the button UI would automatically be dismissed after
* displaying for a period of time.
*/
class MagnificationModeSwitch {
class MagnificationModeSwitch implements MagnificationGestureDetector.OnGestureListener {
@VisibleForTesting
static final long FADING_ANIMATION_DURATION_MS = 300;
@@ -66,13 +63,11 @@ class MagnificationModeSwitch {
private final AccessibilityManager mAccessibilityManager;
private final WindowManager mWindowManager;
private final ImageView mImageView;
private final PointF mLastDown = new PointF();
private final PointF mLastDrag = new PointF();
private final int mTapTimeout = ViewConfiguration.getTapTimeout();
private final int mTouchSlop;
private int mMagnificationMode = Settings.Secure.ACCESSIBILITY_MAGNIFICATION_MODE_FULLSCREEN;
private final LayoutParams mParams;
private boolean mIsVisible = false;
private final MagnificationGestureDetector mGestureDetector;
private boolean mSingleTapDetected = false;
MagnificationModeSwitch(Context context) {
this(context, createView(context));
@@ -86,7 +81,6 @@ class MagnificationModeSwitch {
Context.WINDOW_SERVICE);
mParams = createLayoutParams(context);
mImageView = imageView;
mTouchSlop = ViewConfiguration.get(mContext).getScaledTouchSlop();
applyResourcesValues();
mImageView.setOnTouchListener(this::onTouch);
mImageView.setAccessibilityDelegate(new View.AccessibilityDelegate() {
@@ -127,6 +121,8 @@ class MagnificationModeSwitch {
.start();
mIsFadeOutAnimating = true;
};
mGestureDetector = new MagnificationGestureDetector(context,
context.getMainThreadHandler(), this);
}
private CharSequence formatStateDescription() {
@@ -144,39 +140,38 @@ class MagnificationModeSwitch {
}
private boolean onTouch(View v, MotionEvent event) {
if (!mIsVisible || mImageView == null) {
if (!mIsVisible) {
return false;
}
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
stopFadeOutAnimation();
mLastDown.set(event.getRawX(), event.getRawY());
mLastDrag.set(event.getRawX(), event.getRawY());
return true;
case MotionEvent.ACTION_MOVE:
// Move the button position.
moveButton(event.getRawX() - mLastDrag.x,
event.getRawY() - mLastDrag.y);
mLastDrag.set(event.getRawX(), event.getRawY());
return true;
case MotionEvent.ACTION_UP:
// Single tap to toggle magnification mode and the button position will be reset
// after the action is performed.
final float distance = MathUtils.dist(mLastDown.x, mLastDown.y,
event.getRawX(), event.getRawY());
if ((event.getEventTime() - event.getDownTime()) <= mTapTimeout
&& distance <= mTouchSlop) {
handleSingleTap();
} else {
showButton(mMagnificationMode);
}
return true;
case MotionEvent.ACTION_CANCEL:
showButton(mMagnificationMode);
return true;
default:
return false;
return mGestureDetector.onTouch(event);
}
@Override
public boolean onSingleTap() {
mSingleTapDetected = true;
handleSingleTap();
return true;
}
@Override
public boolean onDrag(float offsetX, float offsetY) {
moveButton(offsetX, offsetY);
return true;
}
@Override
public boolean onStart(float x, float y) {
stopFadeOutAnimation();
return true;
}
@Override
public boolean onFinish(float xOffset, float yOffset) {
if (!mSingleTapDetected) {
showButton(mMagnificationMode);
}
mSingleTapDetected = false;
return true;
}
private void moveButton(float offsetX, float offsetY) {

View File

@@ -0,0 +1,173 @@
/*
* Copyright (C) 2020 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 com.android.systemui.accessibility;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyFloat;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import android.os.Handler;
import android.os.SystemClock;
import android.testing.AndroidTestingRunner;
import android.view.MotionEvent;
import android.view.ViewConfiguration;
import androidx.test.filters.SmallTest;
import com.android.systemui.SysuiTestCase;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.InOrder;
import org.mockito.Mock;
import org.mockito.Mockito;
import org.mockito.MockitoAnnotations;
@SmallTest
@RunWith(AndroidTestingRunner.class)
public class MagnificationGestureDetectorTest extends SysuiTestCase {
private static final float ACTION_DOWN_X = 100;
private static final float ACTION_DOWN_Y = 200;
private int mTouchSlop = ViewConfiguration.get(getContext()).getScaledTouchSlop();
private MagnificationGestureDetector mGestureDetector;
private MotionEventHelper mMotionEventHelper = new MotionEventHelper();
@Mock
private MagnificationGestureDetector.OnGestureListener mListener;
@Mock
private Handler mHandler;
private Runnable mCancelSingleTapRunnable;
@Before
public void setUp() throws Exception {
MockitoAnnotations.initMocks(this);
doAnswer((invocation) -> {
mCancelSingleTapRunnable = invocation.getArgument(0);
return null;
}).when(mHandler).postAtTime(any(Runnable.class), anyLong());
mGestureDetector = new MagnificationGestureDetector(mContext, mHandler, mListener);
}
@After
public void tearDown() {
mMotionEventHelper.recycleEvents();
}
@Test
public void onActionDown_invokeDownCallback() {
final long downTime = SystemClock.uptimeMillis();
final MotionEvent downEvent = mMotionEventHelper.obtainMotionEvent(downTime, downTime,
MotionEvent.ACTION_DOWN, ACTION_DOWN_X, ACTION_DOWN_Y);
mGestureDetector.onTouch(downEvent);
mListener.onStart(ACTION_DOWN_X, ACTION_DOWN_Y);
}
@Test
public void performSingleTap_invokeCallbacksInOrder() {
final long downTime = SystemClock.uptimeMillis();
final MotionEvent downEvent = mMotionEventHelper.obtainMotionEvent(downTime, downTime,
MotionEvent.ACTION_DOWN, ACTION_DOWN_X, ACTION_DOWN_Y);
final MotionEvent upEvent = mMotionEventHelper.obtainMotionEvent(downTime, downTime,
MotionEvent.ACTION_UP, ACTION_DOWN_X, ACTION_DOWN_Y);
mGestureDetector.onTouch(downEvent);
mGestureDetector.onTouch(upEvent);
InOrder inOrder = Mockito.inOrder(mListener);
inOrder.verify(mListener).onStart(ACTION_DOWN_X, ACTION_DOWN_Y);
inOrder.verify(mListener).onSingleTap();
inOrder.verify(mListener).onFinish(ACTION_DOWN_X, ACTION_DOWN_Y);
verify(mListener, never()).onDrag(anyFloat(), anyFloat());
}
@Test
public void performSingleTapWithActionCancel_notInvokeOnSingleTapCallback() {
final long downTime = SystemClock.uptimeMillis();
final MotionEvent downEvent = mMotionEventHelper.obtainMotionEvent(downTime, downTime,
MotionEvent.ACTION_DOWN, ACTION_DOWN_X, ACTION_DOWN_Y);
final MotionEvent cancelEvent = mMotionEventHelper.obtainMotionEvent(downTime, downTime,
MotionEvent.ACTION_CANCEL, ACTION_DOWN_X, ACTION_DOWN_Y);
mGestureDetector.onTouch(downEvent);
mGestureDetector.onTouch(cancelEvent);
verify(mListener, never()).onSingleTap();
}
@Test
public void performSingleTapWithTwoPointers_notInvokeSingleTapCallback() {
final long downTime = SystemClock.uptimeMillis();
final MotionEvent downEvent = mMotionEventHelper.obtainMotionEvent(downTime, downTime,
MotionEvent.ACTION_DOWN, ACTION_DOWN_X, ACTION_DOWN_Y);
final MotionEvent upEvent = mMotionEventHelper.obtainMotionEvent(downTime, downTime,
MotionEvent.ACTION_POINTER_DOWN, ACTION_DOWN_X, ACTION_DOWN_Y);
mGestureDetector.onTouch(downEvent);
mGestureDetector.onTouch(upEvent);
verify(mListener, never()).onSingleTap();
}
@Test
public void performLongPress_invokeCallbacksInOrder() {
final long downTime = SystemClock.uptimeMillis();
final MotionEvent downEvent = mMotionEventHelper.obtainMotionEvent(downTime, downTime,
MotionEvent.ACTION_DOWN, ACTION_DOWN_X, ACTION_DOWN_Y);
final MotionEvent upEvent = mMotionEventHelper.obtainMotionEvent(downTime, downTime,
MotionEvent.ACTION_UP, ACTION_DOWN_X, ACTION_DOWN_Y);
mGestureDetector.onTouch(downEvent);
// Execute the pending message for stopping single-tap detection.
mCancelSingleTapRunnable.run();
mGestureDetector.onTouch(upEvent);
InOrder inOrder = Mockito.inOrder(mListener);
inOrder.verify(mListener).onStart(ACTION_DOWN_X, ACTION_DOWN_Y);
inOrder.verify(mListener).onFinish(ACTION_DOWN_X, ACTION_DOWN_Y);
verify(mListener, never()).onSingleTap();
}
@Test
public void performDrag_invokeCallbacksInOrder() {
final long downTime = SystemClock.uptimeMillis();
final float dragOffset = mTouchSlop + 10;
final MotionEvent downEvent = mMotionEventHelper.obtainMotionEvent(downTime, downTime,
MotionEvent.ACTION_DOWN, ACTION_DOWN_X, ACTION_DOWN_Y);
final MotionEvent moveEvent = mMotionEventHelper.obtainMotionEvent(downTime, downTime,
MotionEvent.ACTION_MOVE, ACTION_DOWN_X + dragOffset, ACTION_DOWN_Y);
final MotionEvent upEvent = mMotionEventHelper.obtainMotionEvent(downTime, downTime,
MotionEvent.ACTION_UP, ACTION_DOWN_X, ACTION_DOWN_Y);
mGestureDetector.onTouch(downEvent);
mGestureDetector.onTouch(moveEvent);
mGestureDetector.onTouch(upEvent);
InOrder inOrder = Mockito.inOrder(mListener);
inOrder.verify(mListener).onStart(ACTION_DOWN_X, ACTION_DOWN_Y);
inOrder.verify(mListener).onDrag(dragOffset, 0);
inOrder.verify(mListener).onFinish(ACTION_DOWN_X, ACTION_DOWN_Y);
verify(mListener, never()).onSingleTap();
}
}

View File

@@ -73,7 +73,6 @@ import org.mockito.Mock;
import org.mockito.Mockito;
import org.mockito.MockitoAnnotations;
import java.util.ArrayList;
import java.util.List;
@SmallTest
@@ -91,8 +90,8 @@ public class MagnificationModeSwitchTest extends SysuiTestCase {
private ViewPropertyAnimator mViewPropertyAnimator;
private MagnificationModeSwitch mMagnificationModeSwitch;
private View.OnTouchListener mTouchListener;
private List<MotionEvent> mMotionEvents = new ArrayList<>();
private Runnable mFadeOutAnimation;
private MotionEventHelper mMotionEventHelper = new MotionEventHelper();
@Before
public void setUp() throws Exception {
@@ -117,11 +116,8 @@ public class MagnificationModeSwitchTest extends SysuiTestCase {
@After
public void tearDown() {
for (MotionEvent event:mMotionEvents) {
event.recycle();
}
mMotionEvents.clear();
mFadeOutAnimation = null;
mMotionEventHelper.recycleEvents();
}
@Test
@@ -436,9 +432,7 @@ public class MagnificationModeSwitchTest extends SysuiTestCase {
private MotionEvent obtainMotionEvent(long downTime, long eventTime, int action, float x,
float y) {
MotionEvent event = MotionEvent.obtain(downTime, eventTime, action, x, y, 0);
mMotionEvents.add(event);
return event;
return mMotionEventHelper.obtainMotionEvent(downTime, eventTime, action, x, y);
}
private void executeFadeOutAnimation() {

View File

@@ -0,0 +1,47 @@
/*
* Copyright (C) 2020 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 com.android.systemui.accessibility;
import android.view.MotionEvent;
import com.android.internal.annotations.GuardedBy;
import java.util.ArrayList;
import java.util.List;
class MotionEventHelper {
@GuardedBy("this")
private final List<MotionEvent> mMotionEvents = new ArrayList<>();
void recycleEvents() {
for (MotionEvent event:mMotionEvents) {
event.recycle();
}
synchronized (this) {
mMotionEvents.clear();
}
}
MotionEvent obtainMotionEvent(long downTime, long eventTime, int action, float x,
float y) {
MotionEvent event = MotionEvent.obtain(downTime, eventTime, action, x, y, 0);
synchronized (this) {
mMotionEvents.add(event);
}
return event;
}
}