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