1 /*
2  * Copyright (C) 2020 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package com.android.systemui.accessibility;
18 
19 import android.annotation.DisplayContext;
20 import android.annotation.NonNull;
21 import android.content.Context;
22 import android.graphics.PointF;
23 import android.os.Handler;
24 import android.view.Display;
25 import android.view.MotionEvent;
26 import android.view.View;
27 import android.view.ViewConfiguration;
28 
29 /**
30  * Detects single tap and drag gestures using the supplied {@link MotionEvent}s. The {@link
31  * OnGestureListener} callback will notify users when a particular motion event has occurred. This
32  * class should only be used with {@link MotionEvent}s reported via touch (don't use for trackball
33  * events).
34  */
35 class MagnificationGestureDetector {
36 
37     interface OnGestureListener {
38         /**
39          * Called when a tap is completed within {@link ViewConfiguration#getLongPressTimeout()} and
40          * the offset between {@link MotionEvent}s and the down event doesn't exceed {@link
41          * ViewConfiguration#getScaledTouchSlop()}.
42          *
43          * @return {@code true} if this gesture is handled.
44          */
onSingleTap(View view)45         boolean onSingleTap(View view);
46 
47         /**
48          * Called when the user is performing dragging gesture. It is started after the offset
49          * between the down location and the move event location exceed
50          * {@link ViewConfiguration#getScaledTouchSlop()}.
51          *
52          * @param offsetX The X offset in screen coordinate.
53          * @param offsetY The Y offset in screen coordinate.
54          * @return {@code true} if this gesture is handled.
55          */
onDrag(View view, float offsetX, float offsetY)56         boolean onDrag(View view, float offsetX, float offsetY);
57 
58         /**
59          * Notified when a tap occurs with the down {@link MotionEvent} that triggered it. This will
60          * be triggered immediately for every down event. All other events should be preceded by
61          * this.
62          *
63          * @param x The X coordinate of the down event.
64          * @param y The Y coordinate of the down event.
65          * @return {@code true} if the down event is handled, otherwise the events won't be sent to
66          * the view.
67          */
onStart(float x, float y)68         boolean onStart(float x, float y);
69 
70         /**
71          * Called when the detection is finished. In other words, it is called when up/cancel {@link
72          * MotionEvent} is received. It will be triggered after single-tap
73          *
74          * @param x The X coordinate on the screen of the up event or the cancel event.
75          * @param y The Y coordinate on the screen of the up event or the cancel event.
76          * @return {@code true} if the event is handled.
77          */
onFinish(float x, float y)78         boolean onFinish(float x, float y);
79     }
80 
81     private final PointF mPointerDown = new PointF();
82     private final PointF mPointerLocation = new PointF(Float.NaN, Float.NaN);
83     private final Handler mHandler;
84     private final Runnable mCancelTapGestureRunnable;
85     private final OnGestureListener mOnGestureListener;
86     private int mTouchSlopSquare;
87     // Assume the gesture default is a single-tap. Set it to false if the gesture couldn't be a
88     // single-tap anymore.
89     private boolean mDetectSingleTap = true;
90     private boolean mDraggingDetected = false;
91 
92     /**
93      * @param context  {@link Context} that is from {@link Context#createDisplayContext(Display)}.
94      * @param handler  The handler to post the runnable.
95      * @param listener The listener invoked for all the callbacks.
96      */
MagnificationGestureDetector(@isplayContext Context context, @NonNull Handler handler, @NonNull OnGestureListener listener)97     MagnificationGestureDetector(@DisplayContext Context context, @NonNull Handler handler,
98             @NonNull OnGestureListener listener) {
99         final int touchSlop = ViewConfiguration.get(context).getScaledTouchSlop();
100         mTouchSlopSquare = touchSlop * touchSlop;
101         mHandler = handler;
102         mOnGestureListener = listener;
103         mCancelTapGestureRunnable = () -> mDetectSingleTap = false;
104     }
105 
106     /**
107      * Analyzes the given motion event and if applicable to trigger the appropriate callbacks on the
108      * {@link OnGestureListener} supplied.
109      *
110      * @param event The current motion event.
111      * @return {@code True} if the {@link OnGestureListener} consumes the event, else false.
112      */
onTouch(View view, MotionEvent event)113     boolean onTouch(View view, MotionEvent event) {
114         final float rawX = event.getRawX();
115         final float rawY = event.getRawY();
116         boolean handled = false;
117         switch (event.getActionMasked()) {
118             case MotionEvent.ACTION_DOWN:
119                 mPointerDown.set(rawX, rawY);
120                 mHandler.postAtTime(mCancelTapGestureRunnable,
121                         event.getDownTime() + ViewConfiguration.getLongPressTimeout());
122                 handled |= mOnGestureListener.onStart(rawX, rawY);
123                 break;
124             case MotionEvent.ACTION_POINTER_DOWN:
125                 stopSingleTapDetection();
126                 break;
127             case MotionEvent.ACTION_MOVE:
128                 stopSingleTapDetectionIfNeeded(rawX, rawY);
129                 handled |= notifyDraggingGestureIfNeeded(view, rawX, rawY);
130                 break;
131             case MotionEvent.ACTION_UP:
132                 stopSingleTapDetectionIfNeeded(rawX, rawY);
133                 if (mDetectSingleTap) {
134                     handled |= mOnGestureListener.onSingleTap(view);
135                 }
136                 // Fall through
137             case MotionEvent.ACTION_CANCEL:
138                 handled |= mOnGestureListener.onFinish(rawX, rawY);
139                 reset();
140                 break;
141         }
142         return handled;
143     }
144 
stopSingleTapDetectionIfNeeded(float x, float y)145     private void stopSingleTapDetectionIfNeeded(float x, float y) {
146         if (mDraggingDetected) {
147             return;
148         }
149         if (!isLocationValid(mPointerDown)) {
150             return;
151         }
152 
153         final int deltaX = (int) (mPointerDown.x - x);
154         final int deltaY = (int) (mPointerDown.y - y);
155         final int distanceSquare = (deltaX * deltaX) + (deltaY * deltaY);
156         if (distanceSquare > mTouchSlopSquare) {
157             mDraggingDetected = true;
158             stopSingleTapDetection();
159         }
160     }
161 
stopSingleTapDetection()162     private void stopSingleTapDetection() {
163         mHandler.removeCallbacks(mCancelTapGestureRunnable);
164         mDetectSingleTap = false;
165     }
166 
notifyDraggingGestureIfNeeded(View view, float x, float y)167     private boolean notifyDraggingGestureIfNeeded(View view, float x, float y) {
168         if (!mDraggingDetected) {
169             return false;
170         }
171         if (!isLocationValid(mPointerLocation)) {
172             mPointerLocation.set(mPointerDown);
173         }
174         final float offsetX = x - mPointerLocation.x;
175         final float offsetY = y - mPointerLocation.y;
176         mPointerLocation.set(x, y);
177         return mOnGestureListener.onDrag(view, offsetX, offsetY);
178     }
179 
reset()180     private void reset() {
181         resetPointF(mPointerDown);
182         resetPointF(mPointerLocation);
183         mHandler.removeCallbacks(mCancelTapGestureRunnable);
184         mDetectSingleTap = true;
185         mDraggingDetected = false;
186     }
187 
resetPointF(PointF pointF)188     private static void resetPointF(PointF pointF) {
189         pointF.x = Float.NaN;
190         pointF.y = Float.NaN;
191     }
192 
isLocationValid(PointF location)193     private static boolean isLocationValid(PointF location) {
194         return !Float.isNaN(location.x) && !Float.isNaN(location.y);
195     }
196 }
197