ExploreByTouchHelper.java

/*
 * Copyright 2018 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package androidx.customview.widget;

import android.content.Context;
import android.graphics.Rect;
import android.os.Bundle;
import android.view.KeyEvent;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewParent;
import android.view.accessibility.AccessibilityEvent;
import android.view.accessibility.AccessibilityManager;
import android.view.accessibility.AccessibilityRecord;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.collection.SparseArrayCompat;
import androidx.core.view.AccessibilityDelegateCompat;
import androidx.core.view.ViewCompat;
import androidx.core.view.ViewCompat.FocusDirection;
import androidx.core.view.ViewCompat.FocusRealDirection;
import androidx.core.view.accessibility.AccessibilityEventCompat;
import androidx.core.view.accessibility.AccessibilityNodeInfoCompat;
import androidx.core.view.accessibility.AccessibilityNodeProviderCompat;
import androidx.core.view.accessibility.AccessibilityRecordCompat;

import java.util.ArrayList;
import java.util.List;

/**
 * ExploreByTouchHelper is a utility class for implementing accessibility
 * support in custom {@link View}s that represent a collection of View-like
 * logical items. It extends {@link AccessibilityNodeProviderCompat} and
 * simplifies many aspects of providing information to accessibility services
 * and managing accessibility focus.
 * <p>
 * Clients should override abstract methods on this class and attach it to the
 * host view using {@link ViewCompat#setAccessibilityDelegate}.
 * <p>
 * The host view should also override the events in the following code snippet
 * so that the view's logical items are detected properly by the framework:
 * <p>
 * <pre>
 * class MyCustomView extends View {
 *     private MyExploreByTouchHelper mExploreByTouchHelper;
 *
 *     public MyCustomView(Context context, ...) {
 *         ...
 *         mExploreByTouchHelper = new MyExploreByTouchHelper(this);
 *         ViewCompat.setAccessibilityDelegate(this, mExploreByTouchHelper);
 *     }
 *
 *     &#64;Override
 *     public boolean dispatchHoverEvent(MotionEvent event) {
 *       return mHelper.dispatchHoverEvent(this, event)
 *           || super.dispatchHoverEvent(event);
 *     }
 *
 *     &#64;Override
 *     public boolean dispatchKeyEvent(KeyEvent event) {
 *       return mHelper.dispatchKeyEvent(event)
 *           || super.dispatchKeyEvent(event);
 *     }
 *
 *     &#64;Override
 *     public void onFocusChanged(boolean gainFocus, int direction,
 *         Rect previouslyFocusedRect) {
 *       super.onFocusChanged(gainFocus, direction, previouslyFocusedRect);
 *       mHelper.onFocusChanged(gainFocus, direction, previouslyFocusedRect);
 *     }
 * }
 * </pre>
 */
public abstract class ExploreByTouchHelper extends AccessibilityDelegateCompat {
    /** Virtual node identifier value for invalid nodes. */
    public static final int INVALID_ID = Integer.MIN_VALUE;

    /** Virtual node identifier value for the host view's node. */
    public static final int HOST_ID = View.NO_ID;

    /** Default class name used for virtual views. */
    private static final String DEFAULT_CLASS_NAME = "android.view.View";

    /** Default bounds used to determine if the client didn't set any. */
    private static final Rect INVALID_PARENT_BOUNDS = new Rect(
            Integer.MAX_VALUE, Integer.MAX_VALUE, Integer.MIN_VALUE, Integer.MIN_VALUE);

    // Temporary, reusable data structures.
    private final Rect mTempScreenRect = new Rect();
    private final Rect mTempParentRect = new Rect();
    private final Rect mTempVisibleRect = new Rect();
    private final int[] mTempGlobalRect = new int[2];

    /** System accessibility manager, used to check state and send events. */
    private final AccessibilityManager mManager;

    /** View whose internal structure is exposed through this helper. */
    private final View mHost;

    /** Virtual node provider used to expose logical structure to services. */
    private MyNodeProvider mNodeProvider;

    /** Identifier for the virtual view that holds accessibility focus. */
    @SuppressWarnings("WeakerAccess") /* synthetic access */
    int mAccessibilityFocusedVirtualViewId = INVALID_ID;

    /** Identifier for the virtual view that holds keyboard focus. */
    @SuppressWarnings("WeakerAccess") /* synthetic access */
    int mKeyboardFocusedVirtualViewId = INVALID_ID;

    /** Identifier for the virtual view that is currently hovered. */
    private int mHoveredVirtualViewId = INVALID_ID;

    /**
     * Constructs a new helper that can expose a virtual view hierarchy for the
     * specified host view.
     *
     * @param host view whose virtual view hierarchy is exposed by this helper
     */
    public ExploreByTouchHelper(@NonNull View host) {
        if (host == null) {
            throw new IllegalArgumentException("View may not be null");
        }

        mHost = host;

        final Context context = host.getContext();
        mManager = (AccessibilityManager) context.getSystemService(Context.ACCESSIBILITY_SERVICE);

        // Host view must be focusable so that we can delegate to virtual
        // views.
        host.setFocusable(true);
        if (ViewCompat.getImportantForAccessibility(host)
                == ViewCompat.IMPORTANT_FOR_ACCESSIBILITY_AUTO) {
            ViewCompat.setImportantForAccessibility(
                    host, ViewCompat.IMPORTANT_FOR_ACCESSIBILITY_YES);
        }
    }

    @Override
    public AccessibilityNodeProviderCompat getAccessibilityNodeProvider(View host) {
        if (mNodeProvider == null) {
            mNodeProvider = new MyNodeProvider();
        }
        return mNodeProvider;
    }

    /**
     * Delegates hover events from the host view.
     * <p>
     * Dispatches hover {@link MotionEvent}s to the virtual view hierarchy when
     * the Explore by Touch feature is enabled.
     * <p>
     * This method should be called by overriding the host view's
     * {@link View#dispatchHoverEvent(MotionEvent)} method:
     * <pre>&#64;Override
     * public boolean dispatchHoverEvent(MotionEvent event) {
     *   return mHelper.dispatchHoverEvent(this, event)
     *       || super.dispatchHoverEvent(event);
     * }
     * </pre>
     *
     * @param event The hover event to dispatch to the virtual view hierarchy.
     * @return Whether the hover event was handled.
     */
    public final boolean dispatchHoverEvent(@NonNull MotionEvent event) {
        if (!mManager.isEnabled() || !mManager.isTouchExplorationEnabled()) {
            return false;
        }

        switch (event.getAction()) {
            case MotionEvent.ACTION_HOVER_MOVE:
            case MotionEvent.ACTION_HOVER_ENTER:
                final int virtualViewId = getVirtualViewAt(event.getX(), event.getY());
                updateHoveredVirtualView(virtualViewId);
                return (virtualViewId != INVALID_ID);
            case MotionEvent.ACTION_HOVER_EXIT:
                if (mHoveredVirtualViewId != INVALID_ID) {
                    updateHoveredVirtualView(INVALID_ID);
                    return true;
                }
                return false;
            default:
                return false;
        }
    }

    /**
     * Delegates key events from the host view.
     * <p>
     * This method should be called by overriding the host view's
     * {@link View#dispatchKeyEvent(KeyEvent)} method:
     * <pre>&#64;Override
     * public boolean dispatchKeyEvent(KeyEvent event) {
     *   return mHelper.dispatchKeyEvent(event)
     *       || super.dispatchKeyEvent(event);
     * }
     * </pre>
     */
    public final boolean dispatchKeyEvent(@NonNull KeyEvent event) {
        boolean handled = false;

        final int action = event.getAction();
        if (action != KeyEvent.ACTION_UP) {
            final int keyCode = event.getKeyCode();
            switch (keyCode) {
                case KeyEvent.KEYCODE_DPAD_LEFT:
                case KeyEvent.KEYCODE_DPAD_UP:
                case KeyEvent.KEYCODE_DPAD_RIGHT:
                case KeyEvent.KEYCODE_DPAD_DOWN:
                    if (event.hasNoModifiers()) {
                        final int direction = keyToDirection(keyCode);
                        final int count = 1 + event.getRepeatCount();
                        for (int i = 0; i < count; i++) {
                            if (moveFocus(direction, null)) {
                                handled = true;
                            } else {
                                break;
                            }
                        }
                    }
                    break;
                case KeyEvent.KEYCODE_DPAD_CENTER:
                case KeyEvent.KEYCODE_ENTER:
                    if (event.hasNoModifiers()) {
                        if (event.getRepeatCount() == 0) {
                            clickKeyboardFocusedVirtualView();
                            handled = true;
                        }
                    }
                    break;
                case KeyEvent.KEYCODE_TAB:
                    if (event.hasNoModifiers()) {
                        handled = moveFocus(View.FOCUS_FORWARD, null);
                    } else if (event.hasModifiers(KeyEvent.META_SHIFT_ON)) {
                        handled = moveFocus(View.FOCUS_BACKWARD, null);
                    }
                    break;
            }
        }

        return handled;
    }

    /**
     * Delegates focus changes from the host view.
     * <p>
     * This method should be called by overriding the host view's
     * {@link View#onFocusChanged(boolean, int, Rect)} method:
     * <pre>&#64;Override
     * public boolean onFocusChanged(boolean gainFocus, int direction,
     *     Rect previouslyFocusedRect) {
     *   super.onFocusChanged(gainFocus, direction, previouslyFocusedRect);
     *   mHelper.onFocusChanged(gainFocus, direction, previouslyFocusedRect);
     * }
     * </pre>
     */
    public final void onFocusChanged(boolean gainFocus, int direction,
            @Nullable Rect previouslyFocusedRect) {
        if (mKeyboardFocusedVirtualViewId != INVALID_ID) {
            clearKeyboardFocusForVirtualView(mKeyboardFocusedVirtualViewId);
        }

        if (gainFocus) {
            moveFocus(direction, previouslyFocusedRect);
        }
    }

    /**
     * @return the identifier of the virtual view that has accessibility focus
     *         or {@link #INVALID_ID} if no virtual view has accessibility
     *         focus
     */
    public final int getAccessibilityFocusedVirtualViewId() {
        return mAccessibilityFocusedVirtualViewId;
    }

    /**
     * @return the identifier of the virtual view that has keyboard focus
     *         or {@link #INVALID_ID} if no virtual view has keyboard focus
     */
    public final int getKeyboardFocusedVirtualViewId() {
        return mKeyboardFocusedVirtualViewId;
    }

    /**
     * Maps key event codes to focus directions.
     *
     * @param keyCode the key event code
     * @return the corresponding focus direction
     */
    @FocusRealDirection
    private static int keyToDirection(int keyCode) {
        switch (keyCode) {
            case KeyEvent.KEYCODE_DPAD_LEFT:
                return View.FOCUS_LEFT;
            case KeyEvent.KEYCODE_DPAD_UP:
                return View.FOCUS_UP;
            case KeyEvent.KEYCODE_DPAD_RIGHT:
                return View.FOCUS_RIGHT;
            default:
                return View.FOCUS_DOWN;
        }
    }

    /**
     * Obtains the bounds for the specified virtual view.
     *
     * @param virtualViewId the identifier of the virtual view
     * @param outBounds the rect to populate with virtual view bounds
     */
    private void getBoundsInParent(int virtualViewId, Rect outBounds) {
        final AccessibilityNodeInfoCompat node = obtainAccessibilityNodeInfo(virtualViewId);
        node.getBoundsInParent(outBounds);
    }

    /**
     * Adapts AccessibilityNodeInfoCompat for obtaining bounds.
     */
    private static final FocusStrategy.BoundsAdapter<AccessibilityNodeInfoCompat> NODE_ADAPTER =
            new FocusStrategy.BoundsAdapter<AccessibilityNodeInfoCompat>() {
                @Override
                public void obtainBounds(AccessibilityNodeInfoCompat node, Rect outBounds) {
                    node.getBoundsInParent(outBounds);
                }
            };

    /**
     * Adapts SparseArrayCompat for iterating through values.
     */
    private static final FocusStrategy.CollectionAdapter<SparseArrayCompat<
            AccessibilityNodeInfoCompat>, AccessibilityNodeInfoCompat> SPARSE_VALUES_ADAPTER =
            new FocusStrategy.CollectionAdapter<SparseArrayCompat<
                    AccessibilityNodeInfoCompat>, AccessibilityNodeInfoCompat>() {
                @Override
                public AccessibilityNodeInfoCompat get(
                        SparseArrayCompat<AccessibilityNodeInfoCompat> collection, int index) {
                    return collection.valueAt(index);
                }

                @Override
                public int size(SparseArrayCompat<AccessibilityNodeInfoCompat> collection) {
                    return collection.size();
                }
            };

    /**
     * Attempts to move keyboard focus in the specified direction.
     *
     * @param direction the direction in which to move keyboard focus
     * @param previouslyFocusedRect the bounds of the previously focused item,
     *                              or {@code null} if not available
     * @return {@code true} if keyboard focus moved to a virtual view managed
     *         by this helper, or {@code false} otherwise
     */
    private boolean moveFocus(@FocusDirection int direction, @Nullable Rect previouslyFocusedRect) {
        final SparseArrayCompat<AccessibilityNodeInfoCompat> allNodes = getAllNodes();

        final int focusedNodeId = mKeyboardFocusedVirtualViewId;
        final AccessibilityNodeInfoCompat focusedNode =
                focusedNodeId == INVALID_ID ? null : allNodes.get(focusedNodeId);

        final AccessibilityNodeInfoCompat nextFocusedNode;
        switch (direction) {
            case View.FOCUS_FORWARD:
            case View.FOCUS_BACKWARD:
                final boolean isLayoutRtl =
                        ViewCompat.getLayoutDirection(mHost) == ViewCompat.LAYOUT_DIRECTION_RTL;
                nextFocusedNode = FocusStrategy.findNextFocusInRelativeDirection(allNodes,
                        SPARSE_VALUES_ADAPTER, NODE_ADAPTER, focusedNode, direction, isLayoutRtl,
                        false);
                break;
            case View.FOCUS_LEFT:
            case View.FOCUS_UP:
            case View.FOCUS_RIGHT:
            case View.FOCUS_DOWN:
                final Rect selectedRect = new Rect();
                if (mKeyboardFocusedVirtualViewId != INVALID_ID) {
                    // Focus is moving from a virtual view within the host.
                    getBoundsInParent(mKeyboardFocusedVirtualViewId, selectedRect);
                } else if (previouslyFocusedRect != null) {
                    // Focus is moving from a real view outside the host.
                    selectedRect.set(previouslyFocusedRect);
                } else {
                    // Focus is moving from... somewhere? Make a guess.
                    // Usually this happens when another view was too lazy
                    // to pass the previously focused rect (ex. ScrollView
                    // when moving UP or DOWN).
                    guessPreviouslyFocusedRect(mHost, direction, selectedRect);
                }
                nextFocusedNode = FocusStrategy.findNextFocusInAbsoluteDirection(allNodes,
                        SPARSE_VALUES_ADAPTER, NODE_ADAPTER, focusedNode, selectedRect, direction);
                break;
            default:
                throw new IllegalArgumentException("direction must be one of "
                        + "{FOCUS_FORWARD, FOCUS_BACKWARD, FOCUS_UP, FOCUS_DOWN, "
                        + "FOCUS_LEFT, FOCUS_RIGHT}.");
        }

        final int nextFocusedNodeId;
        if (nextFocusedNode == null) {
            nextFocusedNodeId = INVALID_ID;
        } else {
            final int index = allNodes.indexOfValue(nextFocusedNode);
            nextFocusedNodeId = allNodes.keyAt(index);
        }

        return requestKeyboardFocusForVirtualView(nextFocusedNodeId);
    }

    private SparseArrayCompat<AccessibilityNodeInfoCompat> getAllNodes() {
        final List<Integer> virtualViewIds = new ArrayList<>();
        getVisibleVirtualViews(virtualViewIds);

        final SparseArrayCompat<AccessibilityNodeInfoCompat> allNodes = new SparseArrayCompat<>();
        for (int virtualViewIdIndex = 0; virtualViewIdIndex < virtualViewIds.size();
                virtualViewIdIndex++) {
            final AccessibilityNodeInfoCompat virtualView =
                    createNodeForChild(virtualViewIds.get(virtualViewIdIndex));
            allNodes.put(virtualViewIds.get(virtualViewIdIndex), virtualView);
        }

        return allNodes;
    }

    /**
     * Obtains a best guess for the previously focused rect for keyboard focus
     * moving in the specified direction.
     *
     * @param host the view into which focus is moving
     * @param direction the absolute direction in which focus is moving
     * @param outBounds the rect to populate with the best-guess bounds for the
     *                  previous focus rect
     */
    private static Rect guessPreviouslyFocusedRect(@NonNull View host,
            @FocusRealDirection int direction, @NonNull Rect outBounds) {
        final int w = host.getWidth();
        final int h = host.getHeight();

        switch (direction) {
            case View.FOCUS_LEFT:
                outBounds.set(w, 0, w, h);
                break;
            case View.FOCUS_UP:
                outBounds.set(0, h, w, h);
                break;
            case View.FOCUS_RIGHT:
                outBounds.set(-1, 0, -1, h);
                break;
            case View.FOCUS_DOWN:
                outBounds.set(0, -1, w, -1);
                break;
            default:
                throw new IllegalArgumentException("direction must be one of "
                        + "{FOCUS_UP, FOCUS_DOWN, FOCUS_LEFT, FOCUS_RIGHT}.");
        }

        return outBounds;
    }

    /**
     * Performs a click action on the keyboard focused virtual view, if any.
     *
     * @return {@code true} if the click action was performed successfully or
     *         {@code false} otherwise
     */
    private boolean clickKeyboardFocusedVirtualView() {
        return mKeyboardFocusedVirtualViewId != INVALID_ID && onPerformActionForVirtualView(
                mKeyboardFocusedVirtualViewId, AccessibilityNodeInfoCompat.ACTION_CLICK, null);
    }

    /**
     * Populates an event of the specified type with information about an item
     * and attempts to send it up through the view hierarchy.
     * <p>
     * You should call this method after performing a user action that normally
     * fires an accessibility event, such as clicking on an item.
     * <p>
     * <pre>public void performItemClick(T item) {
     *   ...
     *   sendEventForVirtualViewId(item.id, AccessibilityEvent.TYPE_VIEW_CLICKED);
     * }
     * </pre>
     *
     * @param virtualViewId the identifier of the virtual view for which to
     *                      send an event
     * @param eventType the type of event to send
     * @return {@code true} if the event was sent successfully, {@code false}
     *         otherwise
     */
    public final boolean sendEventForVirtualView(int virtualViewId, int eventType) {
        if ((virtualViewId == INVALID_ID) || !mManager.isEnabled()) {
            return false;
        }

        final ViewParent parent = mHost.getParent();
        if (parent == null) {
            return false;
        }

        final AccessibilityEvent event = createEvent(virtualViewId, eventType);
        return parent.requestSendAccessibilityEvent(mHost, event);
    }

    /**
     * Notifies the accessibility framework that the properties of the parent
     * view have changed.
     * <p>
     * You <strong>must</strong> call this method after adding or removing
     * items from the parent view.
     */
    public final void invalidateRoot() {
        invalidateVirtualView(HOST_ID, AccessibilityEventCompat.CONTENT_CHANGE_TYPE_SUBTREE);
    }

    /**
     * Notifies the accessibility framework that the properties of a particular
     * item have changed.
     * <p>
     * You <strong>must</strong> call this method after changing any of the
     * properties set in
     * {@link #onPopulateNodeForVirtualView(int, AccessibilityNodeInfoCompat)}.
     *
     * @param virtualViewId the virtual view id to invalidate, or
     *                      {@link #HOST_ID} to invalidate the root view
     * @see #invalidateVirtualView(int, int)
     */
    public final void invalidateVirtualView(int virtualViewId) {
        invalidateVirtualView(virtualViewId,
                AccessibilityEventCompat.CONTENT_CHANGE_TYPE_UNDEFINED);
    }

    /**
     * Notifies the accessibility framework that the properties of a particular
     * item have changed.
     * <p>
     * You <strong>must</strong> call this method after changing any of the
     * properties set in
     * {@link #onPopulateNodeForVirtualView(int, AccessibilityNodeInfoCompat)}.
     *
     * @param virtualViewId the virtual view id to invalidate, or
     *                      {@link #HOST_ID} to invalidate the root view
     * @param changeTypes the bit mask of change types. May be {@code 0} for the
     *                    default (undefined) change type or one or more of:
     *         <ul>
     *         <li>{@link AccessibilityEventCompat#CONTENT_CHANGE_TYPE_CONTENT_DESCRIPTION}
     *         <li>{@link AccessibilityEventCompat#CONTENT_CHANGE_TYPE_SUBTREE}
     *         <li>{@link AccessibilityEventCompat#CONTENT_CHANGE_TYPE_TEXT}
     *         <li>{@link AccessibilityEventCompat#CONTENT_CHANGE_TYPE_UNDEFINED}
     *         </ul>
     */
    public final void invalidateVirtualView(int virtualViewId, int changeTypes) {
        if (virtualViewId != INVALID_ID && mManager.isEnabled()) {
            final ViewParent parent = mHost.getParent();
            if (parent != null) {
                // Send events up the hierarchy so they can be coalesced.
                final AccessibilityEvent event = createEvent(virtualViewId,
                        AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED);
                AccessibilityEventCompat.setContentChangeTypes(event, changeTypes);
                parent.requestSendAccessibilityEvent(mHost, event);
            }
        }
    }

    /**
     * Returns the virtual view ID for the currently accessibility focused
     * item.
     *
     * @return the identifier of the virtual view that has accessibility focus
     *         or {@link #INVALID_ID} if no virtual view has accessibility
     *         focus
     * @deprecated Use {@link #getAccessibilityFocusedVirtualViewId()}.
     */
    @Deprecated
    public int getFocusedVirtualView() {
        return getAccessibilityFocusedVirtualViewId();
    }

    /**
     * Called when the focus state of a virtual view changes.
     *
     * @param virtualViewId the virtual view identifier
     * @param hasFocus      {@code true} if the view has focus, {@code false}
     *                      otherwise
     */
    protected void onVirtualViewKeyboardFocusChanged(int virtualViewId, boolean hasFocus) {
        // Stub method.
    }

    /**
     * Sets the currently hovered item, sending hover accessibility events as
     * necessary to maintain the correct state.
     *
     * @param virtualViewId the virtual view id for the item currently being
     *                      hovered, or {@link #INVALID_ID} if no item is
     *                      hovered within the parent view
     */
    private void updateHoveredVirtualView(int virtualViewId) {
        if (mHoveredVirtualViewId == virtualViewId) {
            return;
        }

        final int previousVirtualViewId = mHoveredVirtualViewId;
        mHoveredVirtualViewId = virtualViewId;

        // Stay consistent with framework behavior by sending ENTER/EXIT pairs
        // in reverse order. This is accurate as of API 18.
        sendEventForVirtualView(virtualViewId, AccessibilityEvent.TYPE_VIEW_HOVER_ENTER);
        sendEventForVirtualView(
                previousVirtualViewId, AccessibilityEvent.TYPE_VIEW_HOVER_EXIT);
    }

    /**
     * Constructs and returns an {@link AccessibilityEvent} for the specified
     * virtual view id, which includes the host view ({@link #HOST_ID}).
     *
     * @param virtualViewId the virtual view id for the item for which to
     *                      construct an event
     * @param eventType the type of event to construct
     * @return an {@link AccessibilityEvent} populated with information about
     *         the specified item
     */
    private AccessibilityEvent createEvent(int virtualViewId, int eventType) {
        switch (virtualViewId) {
            case HOST_ID:
                return createEventForHost(eventType);
            default:
                return createEventForChild(virtualViewId, eventType);
        }
    }

    /**
     * Constructs and returns an {@link AccessibilityEvent} for the host node.
     *
     * @param eventType the type of event to construct
     * @return an {@link AccessibilityEvent} populated with information about
     *         the specified item
     */
    private AccessibilityEvent createEventForHost(int eventType) {
        final AccessibilityEvent event = AccessibilityEvent.obtain(eventType);
        mHost.onInitializeAccessibilityEvent(event);
        return event;
    }

    @Override
    public void onInitializeAccessibilityEvent(View host, AccessibilityEvent event) {
        super.onInitializeAccessibilityEvent(host, event);

        // Allow the client to populate the event.
        onPopulateEventForHost(event);
    }

    /**
     * Constructs and returns an {@link AccessibilityEvent} populated with
     * information about the specified item.
     *
     * @param virtualViewId the virtual view id for the item for which to
     *                      construct an event
     * @param eventType the type of event to construct
     * @return an {@link AccessibilityEvent} populated with information about
     *         the specified item
     */
    private AccessibilityEvent createEventForChild(int virtualViewId, int eventType) {
        final AccessibilityEvent event = AccessibilityEvent.obtain(eventType);
        final AccessibilityNodeInfoCompat node = obtainAccessibilityNodeInfo(virtualViewId);

        // Allow the client to override these properties,
        event.getText().add(node.getText());
        event.setContentDescription(node.getContentDescription());
        event.setScrollable(node.isScrollable());
        event.setPassword(node.isPassword());
        event.setEnabled(node.isEnabled());
        event.setChecked(node.isChecked());

        // Allow the client to populate the event.
        onPopulateEventForVirtualView(virtualViewId, event);

        // Make sure the developer is following the rules.
        if (event.getText().isEmpty() && (event.getContentDescription() == null)) {
            throw new RuntimeException("Callbacks must add text or a content description in "
                    + "populateEventForVirtualViewId()");
        }

        // Don't allow the client to override these properties.
        event.setClassName(node.getClassName());
        AccessibilityRecordCompat.setSource(event, mHost, virtualViewId);
        event.setPackageName(mHost.getContext().getPackageName());

        return event;
    }

    /**
     * Obtains a populated {@link AccessibilityNodeInfoCompat} for the
     * virtual view with the specified identifier.
     * <p>
     * This method may be called with identifier {@link #HOST_ID} to obtain a
     * node for the host view.
     *
     * @param virtualViewId the identifier of the virtual view for which to
     *                      construct a node
     * @return an {@link AccessibilityNodeInfoCompat} populated with information
     *         about the specified item
     */
    @NonNull
    AccessibilityNodeInfoCompat obtainAccessibilityNodeInfo(int virtualViewId) {
        if (virtualViewId == HOST_ID) {
            return createNodeForHost();
        }

        return createNodeForChild(virtualViewId);
    }

    /**
     * Constructs and returns an {@link AccessibilityNodeInfoCompat} for the
     * host view populated with its virtual descendants.
     *
     * @return an {@link AccessibilityNodeInfoCompat} for the parent node
     */
    @NonNull
    private AccessibilityNodeInfoCompat createNodeForHost() {
        final AccessibilityNodeInfoCompat info = AccessibilityNodeInfoCompat.obtain(mHost);
        ViewCompat.onInitializeAccessibilityNodeInfo(mHost, info);

        // Add the virtual descendants.
        final ArrayList<Integer> virtualViewIds = new ArrayList<>();
        getVisibleVirtualViews(virtualViewIds);

        final int realNodeCount = info.getChildCount();
        if (realNodeCount > 0 && virtualViewIds.size() > 0) {
            throw new RuntimeException("Views cannot have both real and virtual children");
        }

        for (int i = 0, count = virtualViewIds.size(); i < count; i++) {
            info.addChild(mHost, virtualViewIds.get(i));
        }

        return info;
    }

    @Override
    public void onInitializeAccessibilityNodeInfo(View host, AccessibilityNodeInfoCompat info) {
        super.onInitializeAccessibilityNodeInfo(host, info);

        // Allow the client to populate the host node.
        onPopulateNodeForHost(info);
    }

    /**
     * Constructs and returns an {@link AccessibilityNodeInfoCompat} for the
     * specified item. Automatically manages accessibility focus actions.
     * <p>
     * Allows the implementing class to specify most node properties, but
     * overrides the following:
     * <ul>
     * <li>{@link AccessibilityNodeInfoCompat#setPackageName}
     * <li>{@link AccessibilityNodeInfoCompat#setClassName}
     * <li>{@link AccessibilityNodeInfoCompat#setParent(View)}
     * <li>{@link AccessibilityNodeInfoCompat#setSource(View, int)}
     * <li>{@link AccessibilityNodeInfoCompat#setVisibleToUser}
     * <li>{@link AccessibilityNodeInfoCompat#setBoundsInScreen(Rect)}
     * </ul>
     * <p>
     * Uses the bounds of the parent view and the parent-relative bounding
     * rectangle specified by
     * {@link AccessibilityNodeInfoCompat#getBoundsInParent} to automatically
     * update the following properties:
     * <ul>
     * <li>{@link AccessibilityNodeInfoCompat#setVisibleToUser}
     * <li>{@link AccessibilityNodeInfoCompat#setBoundsInParent}
     * </ul>
     *
     * @param virtualViewId the virtual view id for item for which to construct
     *                      a node
     * @return an {@link AccessibilityNodeInfoCompat} for the specified item
     */
    @NonNull
    private AccessibilityNodeInfoCompat createNodeForChild(int virtualViewId) {
        final AccessibilityNodeInfoCompat node = AccessibilityNodeInfoCompat.obtain();

        // Ensure the client has good defaults.
        node.setEnabled(true);
        node.setFocusable(true);
        node.setClassName(DEFAULT_CLASS_NAME);

        node.setBoundsInParent(INVALID_PARENT_BOUNDS);
        node.setBoundsInScreen(INVALID_PARENT_BOUNDS);
        node.setParent(mHost);

        // Allow the client to populate the node.
        onPopulateNodeForVirtualView(virtualViewId, node);

        // Make sure the developer is following the rules.
        if ((node.getText() == null) && (node.getContentDescription() == null)) {
            throw new RuntimeException("Callbacks must add text or a content description in "
                    + "populateNodeForVirtualViewId()");
        }

        node.getBoundsInParent(mTempParentRect);
        if (mTempParentRect.equals(INVALID_PARENT_BOUNDS)) {
            throw new RuntimeException("Callbacks must set parent bounds in "
                    + "populateNodeForVirtualViewId()");
        }

        final int actions = node.getActions();
        if ((actions & AccessibilityNodeInfoCompat.ACTION_ACCESSIBILITY_FOCUS) != 0) {
            throw new RuntimeException("Callbacks must not add ACTION_ACCESSIBILITY_FOCUS in "
                    + "populateNodeForVirtualViewId()");
        }
        if ((actions & AccessibilityNodeInfoCompat.ACTION_CLEAR_ACCESSIBILITY_FOCUS) != 0) {
            throw new RuntimeException("Callbacks must not add ACTION_CLEAR_ACCESSIBILITY_FOCUS in "
                    + "populateNodeForVirtualViewId()");
        }

        // Don't allow the client to override these properties.
        node.setPackageName(mHost.getContext().getPackageName());
        node.setSource(mHost, virtualViewId);

        // Manage internal accessibility focus state.
        if (mAccessibilityFocusedVirtualViewId == virtualViewId) {
            node.setAccessibilityFocused(true);
            node.addAction(AccessibilityNodeInfoCompat.ACTION_CLEAR_ACCESSIBILITY_FOCUS);
        } else {
            node.setAccessibilityFocused(false);
            node.addAction(AccessibilityNodeInfoCompat.ACTION_ACCESSIBILITY_FOCUS);
        }

        // Manage internal keyboard focus state.
        final boolean isFocused = mKeyboardFocusedVirtualViewId == virtualViewId;
        if (isFocused) {
            node.addAction(AccessibilityNodeInfoCompat.ACTION_CLEAR_FOCUS);
        } else if (node.isFocusable()) {
            node.addAction(AccessibilityNodeInfoCompat.ACTION_FOCUS);
        }
        node.setFocused(isFocused);

        mHost.getLocationOnScreen(mTempGlobalRect);

        // If not explicitly specified, calculate screen-relative bounds and
        // offset for scroll position based on bounds in parent.
        node.getBoundsInScreen(mTempScreenRect);
        if (mTempScreenRect.equals(INVALID_PARENT_BOUNDS)) {
            node.getBoundsInParent(mTempScreenRect);

            // If there is a parent node, adjust bounds based on the parent node.
            if (node.mParentVirtualDescendantId != HOST_ID) {
                AccessibilityNodeInfoCompat parentNode = AccessibilityNodeInfoCompat.obtain();
                // Walk up the node tree to adjust the screen rect.
                for (int virtualDescendantId = node.mParentVirtualDescendantId;
                        virtualDescendantId != HOST_ID;
                        virtualDescendantId = parentNode.mParentVirtualDescendantId) {
                    // Reset the values in the parent node we'll be using.
                    parentNode.setParent(mHost, HOST_ID);
                    parentNode.setBoundsInParent(INVALID_PARENT_BOUNDS);
                    // Adjust the bounds for the parent node.
                    onPopulateNodeForVirtualView(virtualDescendantId, parentNode);
                    parentNode.getBoundsInParent(mTempParentRect);
                    mTempScreenRect.offset(mTempParentRect.left, mTempParentRect.top);
                }
                parentNode.recycle();
            }
            // Adjust the rect for the host view's location.
            mTempScreenRect.offset(mTempGlobalRect[0] - mHost.getScrollX(),
                    mTempGlobalRect[1] - mHost.getScrollY());
        }

        if (mHost.getLocalVisibleRect(mTempVisibleRect)) {
            mTempVisibleRect.offset(mTempGlobalRect[0] - mHost.getScrollX(),
                    mTempGlobalRect[1] - mHost.getScrollY());
            final boolean intersects = mTempScreenRect.intersect(mTempVisibleRect);
            if (intersects) {
                node.setBoundsInScreen(mTempScreenRect);

                if (isVisibleToUser(mTempScreenRect)) {
                    node.setVisibleToUser(true);
                }
            }
        }

        return node;
    }

    boolean performAction(int virtualViewId, int action, Bundle arguments) {
        switch (virtualViewId) {
            case HOST_ID:
                return performActionForHost(action, arguments);
            default:
                return performActionForChild(virtualViewId, action, arguments);
        }
    }

    private boolean performActionForHost(int action, Bundle arguments) {
        return ViewCompat.performAccessibilityAction(mHost, action, arguments);
    }

    private boolean performActionForChild(int virtualViewId, int action, Bundle arguments) {
        switch (action) {
            case AccessibilityNodeInfoCompat.ACTION_ACCESSIBILITY_FOCUS:
                return requestAccessibilityFocus(virtualViewId);
            case AccessibilityNodeInfoCompat.ACTION_CLEAR_ACCESSIBILITY_FOCUS:
                return clearAccessibilityFocus(virtualViewId);
            case AccessibilityNodeInfoCompat.ACTION_FOCUS:
                return requestKeyboardFocusForVirtualView(virtualViewId);
            case AccessibilityNodeInfoCompat.ACTION_CLEAR_FOCUS:
                return clearKeyboardFocusForVirtualView(virtualViewId);
            default:
                return onPerformActionForVirtualView(virtualViewId, action, arguments);
        }
    }

    /**
     * Computes whether the specified {@link Rect} intersects with the visible
     * portion of its parent {@link View}. Modifies {@code localRect} to contain
     * only the visible portion.
     *
     * @param localRect a rectangle in local (parent) coordinates
     * @return whether the specified {@link Rect} is visible on the screen
     */
    private boolean isVisibleToUser(Rect localRect) {
        // Missing or empty bounds mean this view is not visible.
        if ((localRect == null) || localRect.isEmpty()) {
            return false;
        }

        // Attached to invisible window means this view is not visible.
        if (mHost.getWindowVisibility() != View.VISIBLE) {
            return false;
        }

        // An invisible predecessor means that this view is not visible.
        ViewParent viewParent = mHost.getParent();
        while (viewParent instanceof View) {
            final View view = (View) viewParent;
            if ((view.getAlpha() <= 0) || (view.getVisibility() != View.VISIBLE)) {
                return false;
            }
            viewParent = view.getParent();
        }

        // A null parent implies the view is not visible.
        return viewParent != null;
    }

    /**
     * Attempts to give accessibility focus to a virtual view.
     * <p>
     * A virtual view will not actually take focus if
     * {@link AccessibilityManager#isEnabled()} returns false,
     * {@link AccessibilityManager#isTouchExplorationEnabled()} returns false,
     * or the view already has accessibility focus.
     *
     * @param virtualViewId the identifier of the virtual view on which to
     *                      place accessibility focus
     * @return whether this virtual view actually took accessibility focus
     */
    private boolean requestAccessibilityFocus(int virtualViewId) {
        if (!mManager.isEnabled() || !mManager.isTouchExplorationEnabled()) {
            return false;
        }
        // TODO: Check virtual view visibility.
        if (mAccessibilityFocusedVirtualViewId != virtualViewId) {
            // Clear focus from the previously focused view, if applicable.
            if (mAccessibilityFocusedVirtualViewId != INVALID_ID) {
                clearAccessibilityFocus(mAccessibilityFocusedVirtualViewId);
            }

            // Set focus on the new view.
            mAccessibilityFocusedVirtualViewId = virtualViewId;

            // TODO: Only invalidate virtual view bounds.
            mHost.invalidate();
            sendEventForVirtualView(virtualViewId,
                    AccessibilityEventCompat.TYPE_VIEW_ACCESSIBILITY_FOCUSED);
            return true;
        }
        return false;
    }

    /**
     * Attempts to clear accessibility focus from a virtual view.
     *
     * @param virtualViewId the identifier of the virtual view from which to
     *                      clear accessibility focus
     * @return whether this virtual view actually cleared accessibility focus
     */
    private boolean clearAccessibilityFocus(int virtualViewId) {
        if (mAccessibilityFocusedVirtualViewId == virtualViewId) {
            mAccessibilityFocusedVirtualViewId = INVALID_ID;
            mHost.invalidate();
            sendEventForVirtualView(virtualViewId,
                    AccessibilityEventCompat.TYPE_VIEW_ACCESSIBILITY_FOCUS_CLEARED);
            return true;
        }
        return false;
    }

    /**
     * Attempts to give keyboard focus to a virtual view.
     *
     * @param virtualViewId the identifier of the virtual view on which to
     *                      place keyboard focus
     * @return whether this virtual view actually took keyboard focus
     */
    public final boolean requestKeyboardFocusForVirtualView(int virtualViewId) {
        if (!mHost.isFocused() && !mHost.requestFocus()) {
            // Host must have real keyboard focus.
            return false;
        }

        if (mKeyboardFocusedVirtualViewId == virtualViewId) {
            // The virtual view already has focus.
            return false;
        }

        if (mKeyboardFocusedVirtualViewId != INVALID_ID) {
            clearKeyboardFocusForVirtualView(mKeyboardFocusedVirtualViewId);
        }

        if (virtualViewId == INVALID_ID) {
            return false;
        }

        mKeyboardFocusedVirtualViewId = virtualViewId;

        onVirtualViewKeyboardFocusChanged(virtualViewId, true);
        sendEventForVirtualView(virtualViewId, AccessibilityEvent.TYPE_VIEW_FOCUSED);

        return true;
    }

    /**
     * Attempts to clear keyboard focus from a virtual view.
     *
     * @param virtualViewId the identifier of the virtual view from which to
     *                      clear keyboard focus
     * @return whether this virtual view actually cleared keyboard focus
     */
    public final boolean clearKeyboardFocusForVirtualView(int virtualViewId) {
        if (mKeyboardFocusedVirtualViewId != virtualViewId) {
            // The virtual view is not focused.
            return false;
        }

        mKeyboardFocusedVirtualViewId = INVALID_ID;

        onVirtualViewKeyboardFocusChanged(virtualViewId, false);
        sendEventForVirtualView(virtualViewId, AccessibilityEvent.TYPE_VIEW_FOCUSED);

        return true;
    }

    /**
     * Provides a mapping between view-relative coordinates and logical
     * items.
     *
     * @param x The view-relative x coordinate
     * @param y The view-relative y coordinate
     * @return virtual view identifier for the logical item under
     *         coordinates (x,y) or {@link #HOST_ID} if there is no item at
     *         the given coordinates
     */
    protected abstract int getVirtualViewAt(float x, float y);

    /**
     * Populates a list with the view's visible items. The ordering of items
     * within {@code virtualViewIds} specifies order of accessibility focus
     * traversal.
     *
     * @param virtualViewIds The list to populate with visible items
     */
    protected abstract void getVisibleVirtualViews(List<Integer> virtualViewIds);

    /**
     * Populates an {@link AccessibilityEvent} with information about the
     * specified item.
     * <p>
     * The helper class automatically populates the following fields based on
     * the values set by
     * {@link #onPopulateNodeForVirtualView(int, AccessibilityNodeInfoCompat)},
     * but implementations may optionally override them:
     * <ul>
     * <li>event text, see {@link AccessibilityEvent#getText()}
     * <li>content description, see
     * {@link AccessibilityEvent#setContentDescription(CharSequence)}
     * <li>scrollability, see {@link AccessibilityEvent#setScrollable(boolean)}
     * <li>password state, see {@link AccessibilityEvent#setPassword(boolean)}
     * <li>enabled state, see {@link AccessibilityEvent#setEnabled(boolean)}
     * <li>checked state, see {@link AccessibilityEvent#setChecked(boolean)}
     * </ul>
     * <p>
     * The following required fields are automatically populated by the
     * helper class and may not be overridden:
     * <ul>
     * <li>item class name, set to the value used in
     * {@link #onPopulateNodeForVirtualView(int, AccessibilityNodeInfoCompat)}
     * <li>package name, set to the package of the host view's
     * {@link Context}, see {@link AccessibilityEvent#setPackageName}
     * <li>event source, set to the host view and virtual view identifier,
     * see {@link AccessibilityRecordCompat#setSource(AccessibilityRecord, View, int)}
     * </ul>
     *
     * @param virtualViewId The virtual view id for the item for which to
     *            populate the event
     * @param event The event to populate
     */
    protected void onPopulateEventForVirtualView(int virtualViewId,
            @NonNull AccessibilityEvent event) {
        // Default implementation is no-op.
    }

    /**
     * Populates an {@link AccessibilityEvent} with information about the host
     * view.
     * <p>
     * The default implementation is a no-op.
     *
     * @param event the event to populate with information about the host view
     */
    protected void onPopulateEventForHost(@NonNull AccessibilityEvent event) {
        // Default implementation is no-op.
    }

    /**
     * Populates an {@link AccessibilityNodeInfoCompat} with information
     * about the specified item.
     * <p>
     * Implementations <strong>must</strong> populate the following required
     * fields:
     * <ul>
     * <li>event text, see
     * {@link AccessibilityNodeInfoCompat#setText(CharSequence)} or
     * {@link AccessibilityNodeInfoCompat#setContentDescription(CharSequence)}
     * <li>bounds in parent coordinates, see
     * {@link AccessibilityNodeInfoCompat#setBoundsInParent(Rect)}
     * </ul>
     * <p>
     * The helper class automatically populates the following fields with
     * default values, but implementations may optionally override them:
     * <ul>
     * <li>enabled state, set to {@code true}, see
     * {@link AccessibilityNodeInfoCompat#setEnabled(boolean)}
     * <li>keyboard focusability, set to {@code true}, see
     * {@link AccessibilityNodeInfoCompat#setFocusable(boolean)}
     * <li>item class name, set to {@code android.view.View}, see
     * {@link AccessibilityNodeInfoCompat#setClassName(CharSequence)}
     * </ul>
     * <p>
     * The following required fields are automatically populated by the
     * helper class and may not be overridden:
     * <ul>
     * <li>package name, identical to the package name set by
     * {@link #onPopulateEventForVirtualView(int, AccessibilityEvent)}, see
     * {@link AccessibilityNodeInfoCompat#setPackageName}
     * <li>node source, identical to the event source set in
     * {@link #onPopulateEventForVirtualView(int, AccessibilityEvent)}, see
     * {@link AccessibilityNodeInfoCompat#setSource(View, int)}
     * <li>parent view, set to the host view, see
     * {@link AccessibilityNodeInfoCompat#setParent(View)}
     * <li>visibility, computed based on parent-relative bounds, see
     * {@link AccessibilityNodeInfoCompat#setVisibleToUser(boolean)}
     * <li>accessibility focus, computed based on internal helper state, see
     * {@link AccessibilityNodeInfoCompat#setAccessibilityFocused(boolean)}
     * <li>keyboard focus, computed based on internal helper state, see
     * {@link AccessibilityNodeInfoCompat#setFocused(boolean)}
     * <li>bounds in screen coordinates, computed based on host view bounds,
     * see {@link AccessibilityNodeInfoCompat#setBoundsInScreen(Rect)}
     * </ul>
     * <p>
     * Additionally, the helper class automatically handles keyboard focus and
     * accessibility focus management by adding the appropriate
     * {@link AccessibilityNodeInfoCompat#ACTION_FOCUS},
     * {@link AccessibilityNodeInfoCompat#ACTION_CLEAR_FOCUS},
     * {@link AccessibilityNodeInfoCompat#ACTION_ACCESSIBILITY_FOCUS}, or
     * {@link AccessibilityNodeInfoCompat#ACTION_CLEAR_ACCESSIBILITY_FOCUS}
     * actions. Implementations must <strong>never</strong> manually add these
     * actions.
     * <p>
     * The helper class also automatically modifies parent- and
     * screen-relative bounds to reflect the portion of the item visible
     * within its parent.
     *
     * @param virtualViewId The virtual view identifier of the item for
     *            which to populate the node
     * @param node The node to populate
     */
    protected abstract void onPopulateNodeForVirtualView(
            int virtualViewId, @NonNull AccessibilityNodeInfoCompat node);

    /**
     * Populates an {@link AccessibilityNodeInfoCompat} with information
     * about the host view.
     * <p>
     * The default implementation is a no-op.
     *
     * @param node the node to populate with information about the host view
     */
    protected void onPopulateNodeForHost(@NonNull AccessibilityNodeInfoCompat node) {
        // Default implementation is no-op.
    }

    /**
     * Performs the specified accessibility action on the item associated
     * with the virtual view identifier. See
     * {@link AccessibilityNodeInfoCompat#performAction(int, Bundle)} for
     * more information.
     * <p>
     * Implementations <strong>must</strong> handle any actions added manually
     * in
     * {@link #onPopulateNodeForVirtualView(int, AccessibilityNodeInfoCompat)}.
     * <p>
     * The helper class automatically handles focus management resulting
     * from {@link AccessibilityNodeInfoCompat#ACTION_ACCESSIBILITY_FOCUS}
     * and
     * {@link AccessibilityNodeInfoCompat#ACTION_CLEAR_ACCESSIBILITY_FOCUS}
     * actions.
     *
     * @param virtualViewId The virtual view identifier of the item on which
     *            to perform the action
     * @param action The accessibility action to perform
     * @param arguments (Optional) A bundle with additional arguments, or
     *            null
     * @return true if the action was performed
     */
    protected abstract boolean onPerformActionForVirtualView(
            int virtualViewId, int action, @Nullable Bundle arguments);

    /**
     * Exposes a virtual view hierarchy to the accessibility framework.
     */
    private class MyNodeProvider extends AccessibilityNodeProviderCompat {
        MyNodeProvider() {
        }

        @Override
        public AccessibilityNodeInfoCompat createAccessibilityNodeInfo(int virtualViewId) {
            // The caller takes ownership of the node and is expected to
            // recycle it when done, so always return a copy.
            final AccessibilityNodeInfoCompat node =
                    ExploreByTouchHelper.this.obtainAccessibilityNodeInfo(virtualViewId);
            return AccessibilityNodeInfoCompat.obtain(node);
        }

        @Override
        public boolean performAction(int virtualViewId, int action, Bundle arguments) {
            return ExploreByTouchHelper.this.performAction(virtualViewId, action, arguments);
        }

        @Override
        public AccessibilityNodeInfoCompat findFocus(int focusType) {
            int focusedId = (focusType == AccessibilityNodeInfoCompat.FOCUS_ACCESSIBILITY)
                    ? mAccessibilityFocusedVirtualViewId : mKeyboardFocusedVirtualViewId;
            if (focusedId == INVALID_ID) {
                return null;
            }
            return createAccessibilityNodeInfo(focusedId);
        }
    }
}