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