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
17package com.example.android.supportv4.widget;
18
19import android.app.Activity;
20import android.content.Context;
21import android.graphics.Canvas;
22import android.graphics.Color;
23import android.graphics.Paint;
24import android.graphics.Paint.Align;
25import android.graphics.Paint.Style;
26import android.graphics.Rect;
27import android.graphics.RectF;
28import android.os.Bundle;
29import android.util.AttributeSet;
30import android.view.MotionEvent;
31import android.view.View;
32import android.view.accessibility.AccessibilityEvent;
33
34import androidx.core.view.ViewCompat;
35import androidx.core.view.accessibility.AccessibilityNodeInfoCompat;
36import androidx.core.view.accessibility.AccessibilityNodeProviderCompat;
37import androidx.customview.widget.ExploreByTouchHelper;
38
39import com.example.android.supportv4.R;
40
41import java.util.ArrayList;
42import 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 */
68public class ExploreByTouchHelperActivity extends Activity {
69    @Override
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
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
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
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         */
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         */
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         */
182        public void layoutItems() {
183            for (CustomItem item : mItems) {
184                layoutItem(item);
185            }
186        }
187
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
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
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
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
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
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
282            public CustomViewTouchHelper(View forView) {
283                super(forView);
284            }
285
286            @Override
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
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
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
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
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