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