1 /*
2  * Copyright (C) 2008 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 android.view;
18 
19 import android.annotation.NonNull;
20 import android.compat.annotation.UnsupportedAppUsage;
21 import android.graphics.Rect;
22 import android.graphics.Region;
23 import android.util.ArrayMap;
24 import android.view.accessibility.AccessibilityNodeInfo.TouchDelegateInfo;
25 
26 /**
27  * Helper class to handle situations where you want a view to have a larger touch area than its
28  * actual view bounds. The view whose touch area is changed is called the delegate view. This
29  * class should be used by an ancestor of the delegate. To use a TouchDelegate, first create an
30  * instance that specifies the bounds that should be mapped to the delegate and the delegate
31  * view itself.
32  * <p>
33  * The ancestor should then forward all of its touch events received in its
34  * {@link android.view.View#onTouchEvent(MotionEvent)} to {@link #onTouchEvent(MotionEvent)}.
35  * </p>
36  */
37 public class TouchDelegate {
38 
39     /**
40      * View that should receive forwarded touch events
41      */
42     private View mDelegateView;
43 
44     /**
45      * Bounds in local coordinates of the containing view that should be mapped to the delegate
46      * view. This rect is used for initial hit testing.
47      */
48     private Rect mBounds;
49 
50     /**
51      * mBounds inflated to include some slop. This rect is to track whether the motion events
52      * should be considered to be within the delegate view.
53      */
54     private Rect mSlopBounds;
55 
56     /**
57      * True if the delegate had been targeted on a down event (intersected mBounds).
58      */
59     @UnsupportedAppUsage
60     private boolean mDelegateTargeted;
61 
62     /**
63      * The touchable region of the View extends above its actual extent.
64      */
65     public static final int ABOVE = 1;
66 
67     /**
68      * The touchable region of the View extends below its actual extent.
69      */
70     public static final int BELOW = 2;
71 
72     /**
73      * The touchable region of the View extends to the left of its actual extent.
74      */
75     public static final int TO_LEFT = 4;
76 
77     /**
78      * The touchable region of the View extends to the right of its actual extent.
79      */
80     public static final int TO_RIGHT = 8;
81 
82     private int mSlop;
83 
84     /**
85      * Touch delegate information for accessibility
86      */
87     private TouchDelegateInfo mTouchDelegateInfo;
88 
89     /**
90      * Constructor
91      *
92      * @param bounds Bounds in local coordinates of the containing view that should be mapped to
93      *        the delegate view
94      * @param delegateView The view that should receive motion events
95      */
TouchDelegate(Rect bounds, View delegateView)96     public TouchDelegate(Rect bounds, View delegateView) {
97         mBounds = bounds;
98 
99         mSlop = ViewConfiguration.get(delegateView.getContext()).getScaledTouchSlop();
100         mSlopBounds = new Rect(bounds);
101         mSlopBounds.inset(-mSlop, -mSlop);
102         mDelegateView = delegateView;
103     }
104 
105     /**
106      * Forward touch events to the delegate view if the event is within the bounds
107      * specified in the constructor.
108      *
109      * @param event The touch event to forward
110      * @return True if the event was consumed by the delegate, false otherwise.
111      */
onTouchEvent(@onNull MotionEvent event)112     public boolean onTouchEvent(@NonNull MotionEvent event) {
113         int x = (int)event.getX();
114         int y = (int)event.getY();
115         boolean sendToDelegate = false;
116         boolean hit = true;
117         boolean handled = false;
118 
119         switch (event.getActionMasked()) {
120             case MotionEvent.ACTION_DOWN:
121                 mDelegateTargeted = mBounds.contains(x, y);
122                 sendToDelegate = mDelegateTargeted;
123                 break;
124             case MotionEvent.ACTION_POINTER_DOWN:
125             case MotionEvent.ACTION_POINTER_UP:
126             case MotionEvent.ACTION_UP:
127             case MotionEvent.ACTION_MOVE:
128                 sendToDelegate = mDelegateTargeted;
129                 if (sendToDelegate) {
130                     Rect slopBounds = mSlopBounds;
131                     if (!slopBounds.contains(x, y)) {
132                         hit = false;
133                     }
134                 }
135                 break;
136             case MotionEvent.ACTION_CANCEL:
137                 sendToDelegate = mDelegateTargeted;
138                 mDelegateTargeted = false;
139                 break;
140         }
141         if (sendToDelegate) {
142             if (hit) {
143                 // Offset event coordinates to be inside the target view
144                 event.setLocation(mDelegateView.getWidth() / 2, mDelegateView.getHeight() / 2);
145             } else {
146                 // Offset event coordinates to be outside the target view (in case it does
147                 // something like tracking pressed state)
148                 int slop = mSlop;
149                 event.setLocation(-(slop * 2), -(slop * 2));
150             }
151             handled = mDelegateView.dispatchTouchEvent(event);
152         }
153         return handled;
154     }
155 
156     /**
157      * Forward hover events to the delegate view if the event is within the bounds
158      * specified in the constructor and touch exploration is enabled.
159      *
160      * <p>This method is provided for accessibility purposes so touch exploration, which is
161      * commonly used by screen readers, can properly place accessibility focus on views that
162      * use touch delegates. Therefore, touch exploration must be enabled for hover events
163      * to be dispatched through the delegate.</p>
164      *
165      * @param event The hover event to forward
166      * @return True if the event was consumed by the delegate, false otherwise.
167      *
168      * @see android.view.accessibility.AccessibilityManager#isTouchExplorationEnabled
169      */
onTouchExplorationHoverEvent(@onNull MotionEvent event)170     public boolean onTouchExplorationHoverEvent(@NonNull MotionEvent event) {
171         if (mBounds == null) {
172             return false;
173         }
174 
175         final int x = (int) event.getX();
176         final int y = (int) event.getY();
177         boolean hit = true;
178         boolean handled = false;
179 
180         final boolean isInbound = mBounds.contains(x, y);
181         switch (event.getActionMasked()) {
182             case MotionEvent.ACTION_HOVER_ENTER:
183                 mDelegateTargeted = isInbound;
184                 break;
185             case MotionEvent.ACTION_HOVER_MOVE:
186                 if (isInbound) {
187                     mDelegateTargeted = true;
188                 } else {
189                     // delegated previously
190                     if (mDelegateTargeted && !mSlopBounds.contains(x, y)) {
191                         hit = false;
192                     }
193                 }
194                 break;
195             case MotionEvent.ACTION_HOVER_EXIT:
196                 mDelegateTargeted = true;
197                 break;
198         }
199         if (mDelegateTargeted) {
200             if (hit) {
201                 event.setLocation(mDelegateView.getWidth() / 2, mDelegateView.getHeight() / 2);
202             } else {
203                 mDelegateTargeted = false;
204             }
205             handled = mDelegateView.dispatchHoverEvent(event);
206         }
207         return handled;
208     }
209 
210     /**
211      * Return a {@link TouchDelegateInfo} mapping from regions (in view coordinates) to
212      * delegated views for accessibility usage.
213      *
214      * @return A TouchDelegateInfo.
215      */
216     @NonNull
getTouchDelegateInfo()217     public TouchDelegateInfo getTouchDelegateInfo() {
218         if (mTouchDelegateInfo == null) {
219             final ArrayMap<Region, View> targetMap = new ArrayMap<>(1);
220             Rect bounds = mBounds;
221             if (bounds == null) {
222                 bounds = new Rect();
223             }
224             targetMap.put(new Region(bounds), mDelegateView);
225             mTouchDelegateInfo = new TouchDelegateInfo(targetMap);
226         }
227         return mTouchDelegateInfo;
228     }
229 }
230