1 /* 2 * Copyright (C) 2013 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.example.android.supportv4.widget; 18 19 import android.app.Activity; 20 import android.content.Context; 21 import android.graphics.Canvas; 22 import android.graphics.Color; 23 import android.graphics.Paint; 24 import android.graphics.Paint.Align; 25 import android.graphics.Paint.Style; 26 import android.graphics.Rect; 27 import android.graphics.RectF; 28 import android.os.Bundle; 29 import android.util.AttributeSet; 30 import android.view.MotionEvent; 31 import android.view.View; 32 import android.view.accessibility.AccessibilityEvent; 33 34 import androidx.core.view.ViewCompat; 35 import androidx.core.view.accessibility.AccessibilityNodeInfoCompat; 36 import androidx.core.view.accessibility.AccessibilityNodeProviderCompat; 37 import androidx.customview.widget.ExploreByTouchHelper; 38 39 import com.example.android.supportv4.R; 40 41 import org.jspecify.annotations.NonNull; 42 43 import java.util.ArrayList; 44 import java.util.List; 45 46 /** 47 * This example shows how to use the {@link ExploreByTouchHelper} class in the 48 * Android support library to add accessibility support to a custom view that 49 * represents multiple logical items. 50 * <p> 51 * The {@link ExploreByTouchHelper} class wraps 52 * {@link AccessibilityNodeProviderCompat} and simplifies exposing information 53 * about a custom view's logical structure to accessibility services. 54 * <p> 55 * The custom view in this example is responsible for: 56 * <ul> 57 * <li>Creating a helper class that extends {@link ExploreByTouchHelper} 58 * <li>Setting the helper as the accessibility delegate using 59 * {@link ViewCompat#setAccessibilityDelegate} 60 * <li>Dispatching hover events to the helper in {@link View#dispatchHoverEvent} 61 * </ul> 62 * <p> 63 * The helper class implementation in this example is responsible for: 64 * <ul> 65 * <li>Mapping hover event coordinates to logical items 66 * <li>Exposing information about logical items to accessibility services 67 * <li>Handling accessibility actions 68 * <ul> 69 */ 70 public class ExploreByTouchHelperActivity extends Activity { 71 @Override onCreate(Bundle savedInstanceState)72 protected void onCreate(Bundle savedInstanceState) { 73 super.onCreate(savedInstanceState); 74 75 setContentView(R.layout.explore_by_touch_helper); 76 77 final CustomView customView = findViewById(R.id.custom_view); 78 79 // Adds an item at the top-left quarter of the custom view. 80 customView.addItem(getString(R.string.sample_item_a), 0, 0, 0.5f, 0.5f); 81 82 // Adds an item at the bottom-right quarter of the custom view. 83 CustomView.CustomItem itemB = 84 customView.addItem(getString(R.string.sample_item_b), 0.5f, 0.5f, 1, 1); 85 86 // Add an item at the bottom quarter of Item B. 87 CustomView.CustomItem itemC = 88 customView.addItem(getString(R.string.sample_item_c), 0, 0.75f, 1, 1); 89 customView.setParentItem(itemC, itemB); 90 91 // Add an item at the left quarter of Item C. 92 CustomView.CustomItem itemD = 93 customView.addItem(getString(R.string.sample_item_d), 0, 0f, 0.25f, 1); 94 customView.setParentItem(itemD, itemC); 95 96 customView.layoutItems(); 97 } 98 99 /** 100 * Simple custom view that draws rectangular items to the screen. Each item 101 * has a checked state that may be toggled by tapping on the item. 102 */ 103 public static class CustomView extends View { 104 private static final int NO_ITEM = -1; 105 106 private final Paint mPaint = new Paint(); 107 private final Rect mTempBounds = new Rect(); 108 private final List<CustomItem> mItems = new ArrayList<CustomItem>(); 109 private CustomViewTouchHelper mTouchHelper; 110 CustomView(Context context, AttributeSet attrs)111 public CustomView(Context context, AttributeSet attrs) { 112 super(context, attrs); 113 114 // Set up accessibility helper class. 115 mTouchHelper = new CustomViewTouchHelper(this); 116 ViewCompat.setAccessibilityDelegate(this, mTouchHelper); 117 } 118 119 @Override dispatchHoverEvent(MotionEvent event)120 public boolean dispatchHoverEvent(MotionEvent event) { 121 // Always attempt to dispatch hover events to accessibility first. 122 if (mTouchHelper.dispatchHoverEvent(event)) { 123 return true; 124 } 125 126 return super.dispatchHoverEvent(event); 127 } 128 129 @Override onTouchEvent(MotionEvent event)130 public boolean onTouchEvent(MotionEvent event) { 131 switch (event.getAction()) { 132 case MotionEvent.ACTION_DOWN: 133 return true; 134 case MotionEvent.ACTION_UP: 135 final int itemIndex = getItemIndexUnder(event.getX(), event.getY()); 136 if (itemIndex >= 0) { 137 onItemClicked(itemIndex); 138 } 139 return true; 140 } 141 142 return super.onTouchEvent(event); 143 } 144 145 /** 146 * Adds an item to the custom view. The item is positioned relative to 147 * the custom view bounds and its descriptions is drawn at its center. 148 * 149 * @param description The item's description. 150 * @param top Top coordinate as a fraction of the parent height, range 151 * is [0,1]. 152 * @param left Left coordinate as a fraction of the parent width, range 153 * is [0,1]. 154 * @param bottom Bottom coordinate as a fraction of the parent height, 155 * range is [0,1]. 156 * @param right Right coordinate as a fraction of the parent width, 157 * range is [0,1]. 158 */ addItem(String description, float left, float top, float right, float bottom)159 public CustomItem addItem(String description, float left, float top, float right, 160 float bottom) { 161 final CustomItem item = new CustomItem(); 162 item.mId = mItems.size(); 163 item.mBounds = new RectF(left, top, right, bottom); 164 item.mDescription = description; 165 item.mChecked = false; 166 mItems.add(item); 167 return item; 168 } 169 170 /** 171 * Sets the parent of an CustomItem. This adjusts the bounds so that they are relative to 172 * the specified view, and initializes the parent and child info to point to each either. 173 * @param item CustomItem that will become a child node. 174 * @param parent CustomItem that will become the parent node. 175 */ setParentItem(CustomItem item, CustomItem parent)176 public void setParentItem(CustomItem item, CustomItem parent) { 177 item.mParent = parent; 178 parent.mChildren.add(item.mId); 179 } 180 181 /** 182 * Walk the view hierarchy of each item and calculate mBoundsInRoot. 183 */ layoutItems()184 public void layoutItems() { 185 for (CustomItem item : mItems) { 186 layoutItem(item); 187 } 188 } 189 layoutItem(CustomItem item)190 void layoutItem(CustomItem item) { 191 item.mBoundsInRoot = new RectF(item.mBounds); 192 CustomItem parent = item.mParent; 193 while (parent != null) { 194 RectF bounds = item.mBoundsInRoot; 195 item.mBoundsInRoot.set(parent.mBounds.left + bounds.left * parent.mBounds.width(), 196 parent.mBounds.top + bounds.top * parent.mBounds.height(), 197 parent.mBounds.left + bounds.right * parent.mBounds.width(), 198 parent.mBounds.top + bounds.bottom * parent.mBounds.height()); 199 parent = parent.mParent; 200 } 201 } 202 203 @Override onDraw(@onNull Canvas canvas)204 protected void onDraw(@NonNull Canvas canvas) { 205 super.onDraw(canvas); 206 207 final Paint paint = mPaint; 208 final Rect bounds = mTempBounds; 209 final int height = getHeight(); 210 final int width = getWidth(); 211 212 for (CustomItem item : mItems) { 213 if (item.mParent == null) { 214 paint.setColor(item.mChecked ? Color.RED : Color.BLUE); 215 } else { 216 paint.setColor(item.mChecked ? Color.MAGENTA : Color.GREEN); 217 } 218 paint.setStyle(Style.FILL); 219 scaleRectF(item.mBoundsInRoot, bounds, width, height); 220 canvas.drawRect(bounds, paint); 221 paint.setColor(Color.WHITE); 222 paint.setTextAlign(Align.CENTER); 223 canvas.drawText(item.mDescription, bounds.centerX(), bounds.centerY(), paint); 224 } 225 } 226 onItemClicked(int index)227 protected boolean onItemClicked(int index) { 228 final CustomItem item = getItem(index); 229 if (item == null) { 230 return false; 231 } 232 233 item.mChecked = !item.mChecked; 234 invalidate(); 235 236 // Since the item's checked state is exposed to accessibility 237 // services through its AccessibilityNodeInfo, we need to invalidate 238 // the item's virtual view. At some point in the future, the 239 // framework will obtain an updated version of the virtual view. 240 mTouchHelper.invalidateVirtualView(index); 241 242 // We also need to let the framework know what type of event 243 // happened. Accessibility services may use this event to provide 244 // appropriate feedback to the user. 245 mTouchHelper.sendEventForVirtualView(index, AccessibilityEvent.TYPE_VIEW_CLICKED); 246 247 return true; 248 } 249 getItemIndexUnder(float x, float y)250 protected int getItemIndexUnder(float x, float y) { 251 final float scaledX = (x / getWidth()); 252 final float scaledY = (y / getHeight()); 253 final int n = mItems.size(); 254 255 // Search in reverse order, so that topmost items are selected first. 256 for (int i = n - 1; i >= 0; i--) { 257 final CustomItem item = mItems.get(i); 258 if (item.mBoundsInRoot.contains(scaledX, scaledY)) { 259 return i; 260 } 261 } 262 263 return NO_ITEM; 264 } 265 getItem(int index)266 protected CustomItem getItem(int index) { 267 if ((index < 0) || (index >= mItems.size())) { 268 return null; 269 } 270 271 return mItems.get(index); 272 } 273 scaleRectF( @onNull RectF in, @NonNull Rect out, int width, int height)274 protected static void scaleRectF( 275 @NonNull RectF in, @NonNull Rect out, int width, int height) { 276 out.top = (int) (in.top * height); 277 out.bottom = (int) (in.bottom * height); 278 out.left = (int) (in.left * width); 279 out.right = (int) (in.right * width); 280 } 281 282 private class CustomViewTouchHelper extends ExploreByTouchHelper { 283 private final Rect mTempRect = new Rect(); 284 CustomViewTouchHelper(View forView)285 public CustomViewTouchHelper(View forView) { 286 super(forView); 287 } 288 289 @Override getVirtualViewAt(float x, float y)290 protected int getVirtualViewAt(float x, float y) { 291 // We also perform hit detection in onTouchEvent(), and we can 292 // reuse that logic here. This will ensure consistency whether 293 // accessibility is on or off. 294 final int index = getItemIndexUnder(x, y); 295 if (index == NO_ITEM) { 296 return ExploreByTouchHelper.INVALID_ID; 297 } 298 299 return index; 300 } 301 302 @Override getVisibleVirtualViews(List<Integer> virtualViewIds)303 protected void getVisibleVirtualViews(List<Integer> virtualViewIds) { 304 // Since every item should be visible, and since we're mapping 305 // directly from item index to virtual view id, we can add 306 // the index of every view that doesn't have a parent. 307 final int n = mItems.size(); 308 for (int i = 0; i < n; i++) { 309 if (mItems.get(i).mParent == null) { 310 virtualViewIds.add(i); 311 } 312 } 313 } 314 315 @Override onPopulateEventForVirtualView( int virtualViewId, AccessibilityEvent event)316 protected void onPopulateEventForVirtualView( 317 int virtualViewId, AccessibilityEvent event) { 318 final CustomItem item = getItem(virtualViewId); 319 if (item == null) { 320 throw new IllegalArgumentException("Invalid virtual view id"); 321 } 322 323 // The event must be populated with text, either using 324 // getText().add() or setContentDescription(). Since the item's 325 // description is displayed visually, we'll add it to the event 326 // text. If it was only used for accessibility, we would use 327 // setContentDescription(). 328 event.getText().add(item.mDescription); 329 } 330 331 @Override onPopulateNodeForVirtualView( int virtualViewId, AccessibilityNodeInfoCompat node)332 protected void onPopulateNodeForVirtualView( 333 int virtualViewId, AccessibilityNodeInfoCompat node) { 334 final CustomItem item = getItem(virtualViewId); 335 if (item == null) { 336 throw new IllegalArgumentException("Invalid virtual view id"); 337 } 338 339 // Node and event text and content descriptions are usually 340 // identical, so we'll use the exact same string as before. 341 node.setText(item.mDescription); 342 343 // Reported bounds should be consistent with those used to draw 344 // the item in onDraw(). They should also be consistent with the 345 // hit detection performed in getVirtualViewAt() and 346 // onTouchEvent(). 347 final Rect bounds = mTempRect; 348 int height = getHeight(); 349 int width = getWidth(); 350 if (item.mParent != null) { 351 width = (int) (width * item.mParent.mBoundsInRoot.width()); 352 height = (int) (height * item.mParent.mBoundsInRoot.height()); 353 } 354 scaleRectF(item.mBounds, bounds, width, height); 355 node.setBoundsInParent(bounds); 356 357 // Since the user can tap an item, add the CLICK action. We'll 358 // need to handle this later in onPerformActionForVirtualView. 359 node.addAction(AccessibilityNodeInfoCompat.ACTION_CLICK); 360 361 // This item has a checked state. 362 node.setCheckable(true); 363 node.setChecked(item.mChecked); 364 365 // Setup the hierarchy. 366 if (item.mParent != null) { 367 node.setParent(CustomView.this, item.mParent.mId); 368 } 369 for (Integer id : item.mChildren) { 370 node.addChild(CustomView.this, id); 371 } 372 } 373 374 @Override onPerformActionForVirtualView( int virtualViewId, int action, Bundle arguments)375 protected boolean onPerformActionForVirtualView( 376 int virtualViewId, int action, Bundle arguments) { 377 switch (action) { 378 case AccessibilityNodeInfoCompat.ACTION_CLICK: 379 // Click handling should be consistent with 380 // onTouchEvent(). This ensures that the view works the 381 // same whether accessibility is turned on or off. 382 return onItemClicked(virtualViewId); 383 } 384 385 return false; 386 } 387 388 } 389 390 public static class CustomItem { 391 private int mId; 392 private CustomItem mParent; 393 private List<Integer> mChildren = new ArrayList<>(); 394 private String mDescription; 395 private RectF mBounds; 396 private RectF mBoundsInRoot; 397 private boolean mChecked; 398 } 399 } 400 } 401