Merge "Fix null pointer exception in year picker Retain year picker position offset on orientation change" into jb-mr2-dev
diff --git a/src/com/android/datetimepicker/date/DayPickerView.java b/src/com/android/datetimepicker/date/DayPickerView.java
index 1b0e871..fcd1d8c 100644
--- a/src/com/android/datetimepicker/date/DayPickerView.java
+++ b/src/com/android/datetimepicker/date/DayPickerView.java
@@ -347,4 +347,59 @@
public void onDateChanged() {
goTo(mController.getSelectedDay(), false, true, true);
}
+
+ /**
+ * Attempts to return the date that has accessibility focus.
+ *
+ * @return The date that has accessibility focus, or {@code null} if no date
+ * has focus.
+ */
+ private CalendarDay findAccessibilityFocus() {
+ final int childCount = getChildCount();
+ for (int i = 0; i < childCount; i++) {
+ final View child = getChildAt(i);
+ if (child instanceof SimpleMonthView) {
+ final CalendarDay focus = ((SimpleMonthView) child).getAccessibilityFocus();
+ if (focus != null) {
+ // Clear focus to avoid ListView bug in Jelly Bean MR1.
+ ((SimpleMonthView) child).clearAccessibilityFocus();
+ return focus;
+ }
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Attempts to restore accessibility focus to a given date. No-op if
+ * {@code day} is {@code null}.
+ *
+ * @param day The date that should receive accessibility focus
+ * @return {@code true} if focus was restored
+ */
+ private boolean restoreAccessibilityFocus(CalendarDay day) {
+ if (day == null) {
+ return false;
+ }
+
+ final int childCount = getChildCount();
+ for (int i = 0; i < childCount; i++) {
+ final View child = getChildAt(i);
+ if (child instanceof SimpleMonthView) {
+ if (((SimpleMonthView) child).restoreAccessibilityFocus(day)) {
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+
+ @Override
+ protected void layoutChildren() {
+ final CalendarDay focusedDay = findAccessibilityFocus();
+ super.layoutChildren();
+ restoreAccessibilityFocus(focusedDay);
+ }
}
diff --git a/src/com/android/datetimepicker/date/SimpleMonthAdapter.java b/src/com/android/datetimepicker/date/SimpleMonthAdapter.java
index 6d3ef7a..84be210 100644
--- a/src/com/android/datetimepicker/date/SimpleMonthAdapter.java
+++ b/src/com/android/datetimepicker/date/SimpleMonthAdapter.java
@@ -18,28 +18,26 @@
import android.content.Context;
import android.util.Log;
-import android.view.GestureDetector;
-import android.view.MotionEvent;
import android.view.View;
-import android.view.View.OnTouchListener;
import android.view.ViewGroup;
import android.widget.AbsListView.LayoutParams;
import android.widget.BaseAdapter;
+import com.android.datetimepicker.date.SimpleMonthView.OnDayClickListener;
+
import java.util.Calendar;
import java.util.HashMap;
/**
* An adapter for a list of {@link SimpleMonthView} items.
*/
-public class SimpleMonthAdapter extends BaseAdapter implements OnTouchListener {
+public class SimpleMonthAdapter extends BaseAdapter implements OnDayClickListener {
private static final String TAG = "SimpleMonthAdapter";
private final Context mContext;
private final DatePickerController mController;
- private GestureDetector mGestureDetector;
private CalendarDay mSelectedDay;
protected static int WEEK_7_OVERHANG_HEIGHT = 7;
@@ -121,7 +119,6 @@
* Set up the gesture detector and selected time
*/
protected void init() {
- mGestureDetector = new GestureDetector(mContext, new CalendarGestureListener());
mSelectedDay = new CalendarDay(System.currentTimeMillis());
}
@@ -156,7 +153,7 @@
LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT);
v.setLayoutParams(params);
v.setClickable(true);
- v.setOnTouchListener(this);
+ v.setOnDayClickListener(this);
}
if (drawingParams == null) {
drawingParams = new HashMap<String, Integer>();
@@ -190,15 +187,10 @@
}
@Override
- public boolean onTouch(View v, MotionEvent event) {
- if (mGestureDetector.onTouchEvent(event)) {
- CalendarDay day = ((SimpleMonthView) v).getDayFromLocation(event.getX(), event.getY());
- if (day != null) {
- onDayTapped(day);
- }
- return true;
+ public void onDayClick(SimpleMonthView view, CalendarDay day) {
+ if (day != null) {
+ onDayTapped(day);
}
- return false;
}
/**
@@ -211,15 +203,4 @@
mController.onDayOfMonthSelected(day.year, day.month, day.day);
setSelectedDay(day);
}
-
- /**
- * This is here so we can identify single tap events and set the selected
- * day correctly
- */
- protected class CalendarGestureListener extends GestureDetector.SimpleOnGestureListener {
- @Override
- public boolean onSingleTapUp(MotionEvent e) {
- return true;
- }
- }
}
diff --git a/src/com/android/datetimepicker/date/SimpleMonthView.java b/src/com/android/datetimepicker/date/SimpleMonthView.java
index c3a4346..bcb592f 100644
--- a/src/com/android/datetimepicker/date/SimpleMonthView.java
+++ b/src/com/android/datetimepicker/date/SimpleMonthView.java
@@ -22,17 +22,29 @@
import android.graphics.Paint;
import android.graphics.Paint.Align;
import android.graphics.Paint.Style;
+import android.graphics.Rect;
import android.graphics.Typeface;
+import android.os.Bundle;
+import android.support.v4.view.ViewCompat;
+import android.support.v4.view.accessibility.AccessibilityNodeInfoCompat;
+import android.text.format.DateUtils;
import android.text.format.Time;
+import android.util.SparseArray;
+import android.view.MotionEvent;
import android.view.View;
+import android.view.accessibility.AccessibilityEvent;
+import android.view.accessibility.AccessibilityNodeInfo;
import com.android.datetimepicker.R;
import com.android.datetimepicker.Utils;
import com.android.datetimepicker.date.SimpleMonthAdapter.CalendarDay;
+import com.googlecode.eyesfree.utils.TouchExplorationHelper;
import java.security.InvalidParameterException;
import java.util.Calendar;
+import java.util.Formatter;
import java.util.HashMap;
+import java.util.List;
import java.util.Locale;
/**
@@ -119,6 +131,9 @@
protected Paint mSelectedCirclePaint;
protected Paint mMonthDayLabelPaint;
+ private final Formatter mFormatter;
+ private final StringBuilder mStringBuilder;
+
// The Julian day of the first day displayed by this item
protected int mFirstJulianDay = -1;
// The month of the first day in this week
@@ -152,9 +167,15 @@
private final Calendar mCalendar;
private final Calendar mDayLabelCalendar;
+ private final MonthViewNodeProvider mNodeProvider;
private int mNumRows = DEFAULT_NUM_ROWS;
+ // Optional listener for handling day click actions
+ private OnDayClickListener mOnDayClickListener;
+ // Whether to prevent setting the accessibility delegate
+ private boolean mLockAccessibilityDelegate;
+
protected int mDayTextColor;
protected int mTodayNumberColor;
protected int mMonthTitleColor;
@@ -176,6 +197,9 @@
mMonthTitleColor = res.getColor(R.color.white);
mMonthTitleBGColor = res.getColor(R.color.circle_background);
+ mStringBuilder = new StringBuilder(50);
+ mFormatter = new Formatter(mStringBuilder, Locale.getDefault());
+
MINI_DAY_NUMBER_TEXT_SIZE = res.getDimensionPixelSize(R.dimen.day_number_size);
MONTH_LABEL_TEXT_SIZE = res.getDimensionPixelSize(R.dimen.month_label_size);
MONTH_DAY_LABEL_TEXT_SIZE = res.getDimensionPixelSize(R.dimen.month_day_label_text_size);
@@ -185,10 +209,52 @@
mRowHeight = (res.getDimensionPixelOffset(R.dimen.date_picker_view_animator_height)
- MONTH_HEADER_SIZE) / MAX_NUM_ROWS;
+
+ // Set up accessibility components.
+ mNodeProvider = new MonthViewNodeProvider(context, this);
+ ViewCompat.setAccessibilityDelegate(this, mNodeProvider.getAccessibilityDelegate());
+ ViewCompat.setImportantForAccessibility(this, ViewCompat.IMPORTANT_FOR_ACCESSIBILITY_YES);
+ mLockAccessibilityDelegate = true;
+
// Sets up any standard paints that will be used
initView();
}
+ @Override
+ public void setAccessibilityDelegate(AccessibilityDelegate delegate) {
+ // Workaround for a JB MR1 issue where accessibility delegates on
+ // top-level ListView items are overwritten.
+ if (!mLockAccessibilityDelegate) {
+ super.setAccessibilityDelegate(delegate);
+ }
+ }
+
+ public void setOnDayClickListener(OnDayClickListener listener) {
+ mOnDayClickListener = listener;
+ }
+
+ @Override
+ public boolean onHoverEvent(MotionEvent event) {
+ // First right-of-refusal goes the touch exploration helper.
+ if (mNodeProvider.onHover(this, event)) {
+ return true;
+ }
+ return super.onHoverEvent(event);
+ }
+
+ @Override
+ public boolean onTouchEvent(MotionEvent event) {
+ switch (event.getAction()) {
+ case MotionEvent.ACTION_UP:
+ final CalendarDay day = getDayFromLocation(event.getX(), event.getY());
+ if (day != null) {
+ onDayClick(day);
+ }
+ break;
+ }
+ return true;
+ }
+
/**
* Sets up the text and style properties for painting. Override this if you
* want to use a different paint.
@@ -301,6 +367,9 @@
}
}
mNumRows = calculateNumRows();
+
+ // Invalidate cached accessibility information.
+ mNodeProvider.invalidateParent();
}
public void reuse() {
@@ -330,17 +399,22 @@
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
mWidth = w;
+
+ // Invalidate cached accessibility information.
+ mNodeProvider.invalidateParent();
}
private void drawMonthTitle(Canvas canvas) {
int x = (mWidth + 2 * mPadding) / 2;
int y = (MONTH_HEADER_SIZE - MONTH_DAY_LABEL_TEXT_SIZE) / 2 + (MONTH_LABEL_TEXT_SIZE / 3);
- StringBuffer sbuf = new StringBuffer();
- sbuf.append(mCalendar.getDisplayName(Calendar.MONTH, Calendar.LONG,
- Locale.getDefault()));
- sbuf.append(" ");
- sbuf.append(String.format("%d", mYear));
- canvas.drawText(sbuf.toString(), x, y, mMonthTitlePaint);
+ int flags = DateUtils.FORMAT_SHOW_DATE | DateUtils.FORMAT_SHOW_YEAR
+ | DateUtils.FORMAT_NO_MONTH_DAY;
+
+ mStringBuilder.setLength(0);
+ long millis = mCalendar.getTimeInMillis();
+ String title = DateUtils.formatDateRange(getContext(), mFormatter, millis, millis, flags,
+ Time.getCurrentTimezone()).toString();
+ canvas.drawText(title, x, y, mMonthTitlePaint);
}
private void drawMonthDayLabels(Canvas canvas) {
@@ -421,4 +495,190 @@
return new CalendarDay(mYear, mMonth, day);
}
+ /**
+ * Called when the user clicks on a day. Handles callbacks to the
+ * {@link OnDayClickListener} if one is set.
+ *
+ * @param day A time object representing the day that was clicked
+ */
+ private void onDayClick(CalendarDay day) {
+ if (mOnDayClickListener != null) {
+ mOnDayClickListener.onDayClick(this, day);
+ }
+
+ // This is a no-op if accessibility is turned off.
+ mNodeProvider.sendEventForItem(day, AccessibilityEvent.TYPE_VIEW_CLICKED);
+ }
+
+ /**
+ * @return The date that has accessibility focus, or {@code null} if no date
+ * has focus
+ */
+ public CalendarDay getAccessibilityFocus() {
+ return mNodeProvider.getFocusedItem();
+ }
+
+ /**
+ * Clears accessibility focus within the view. No-op if the view does not
+ * contain accessibility focus.
+ */
+ public void clearAccessibilityFocus() {
+ mNodeProvider.clearFocusedItem();
+ }
+
+ /**
+ * Attempts to restore accessibility focus to the specified date.
+ *
+ * @param day The date which should receive focus
+ * @return {@code false} if the date is not valid for this month view, or
+ * {@code true} if the date received focus
+ */
+ public boolean restoreAccessibilityFocus(CalendarDay day) {
+ if ((day.year != mYear) || (day.month != mMonth) || (day.day > mNumCells)) {
+ return false;
+ }
+
+ mNodeProvider.setFocusedItem(day);
+ return true;
+ }
+
+ /**
+ * Provides a virtual view hierarchy for interfacing with an accessibility
+ * service.
+ */
+ private class MonthViewNodeProvider extends TouchExplorationHelper<CalendarDay> {
+ private final SparseArray<CalendarDay> mCachedItems = new SparseArray<CalendarDay>();
+ private final Rect mTempRect = new Rect();
+
+ public MonthViewNodeProvider(Context context, View parent) {
+ super(context, parent);
+ }
+
+ @Override
+ public void invalidateItem(CalendarDay item) {
+ super.invalidateItem(item);
+ mCachedItems.delete(getIdForItem(item));
+ }
+
+ @Override
+ public void invalidateParent() {
+ super.invalidateParent();
+ mCachedItems.clear();
+ }
+
+ @Override
+ protected boolean performActionForItem(CalendarDay item, int action, Bundle arguments) {
+ switch (action) {
+ case AccessibilityNodeInfo.ACTION_CLICK:
+ onDayClick(item);
+ return true;
+ }
+
+ return false;
+ }
+
+ @Override
+ protected void populateEventForItem(CalendarDay item, AccessibilityEvent event) {
+ event.setContentDescription(getItemDescription(item));
+ }
+
+ @Override
+ protected void populateNodeForItem(CalendarDay item, AccessibilityNodeInfoCompat node) {
+ getItemBounds(item, mTempRect);
+
+ node.setContentDescription(getItemDescription(item));
+ node.setBoundsInParent(mTempRect);
+ node.addAction(AccessibilityNodeInfo.ACTION_CLICK);
+
+ if (item.day == mSelectedDay) {
+ node.setSelected(true);
+ }
+ }
+
+ @Override
+ protected void getVisibleItems(List<CalendarDay> items) {
+ // TODO: Optimize, only return items visible within parent bounds.
+ for (int day = 1; day <= mNumCells; day++) {
+ items.add(getItemForId(day));
+ }
+ }
+
+ @Override
+ protected CalendarDay getItemAt(float x, float y) {
+ return getDayFromLocation(x, y);
+ }
+
+ @Override
+ protected int getIdForItem(CalendarDay item) {
+ return item.day;
+ }
+
+ @Override
+ protected CalendarDay getItemForId(int id) {
+ if ((id < 1) || (id > mNumCells)) {
+ return null;
+ }
+
+ final CalendarDay item;
+ if (mCachedItems.indexOfKey(id) >= 0) {
+ item = mCachedItems.get(id);
+ } else {
+ item = new CalendarDay(mYear, mMonth, id);
+ mCachedItems.put(id, item);
+ }
+
+ return item;
+ }
+
+ /**
+ * Calculates the bounding rectangle of a given time object.
+ *
+ * @param item The time object to calculate bounds for
+ * @param rect The rectangle in which to store the bounds
+ */
+ private void getItemBounds(CalendarDay item, Rect rect) {
+ final int offsetX = mPadding;
+ final int offsetY = MONTH_HEADER_SIZE;
+ final int cellHeight = mRowHeight;
+ final int cellWidth = ((mWidth - (2 * mPadding)) / mNumDays);
+ final int index = ((item.day - 1) + findDayOffset());
+ final int row = (index / mNumDays);
+ final int column = (index % mNumDays);
+ final int x = (offsetX + (column * cellWidth));
+ final int y = (offsetY + (row * cellHeight));
+
+ rect.set(x, y, (x + cellWidth), (y + cellHeight));
+ }
+
+ /**
+ * Generates a description for a given time object. Since this
+ * description will be spoken, the components are ordered by descending
+ * specificity as DAY MONTH YEAR.
+ *
+ * @param item The time object to generate a description for
+ * @return A description of the time object
+ */
+ private CharSequence getItemDescription(CalendarDay item) {
+ final StringBuffer sbuf = new StringBuffer();
+ sbuf.append(String.format("%d", item.day));
+ sbuf.append(" ");
+ sbuf.append(mCalendar.getDisplayName(Calendar.MONTH, Calendar.LONG,
+ Locale.getDefault()));
+ sbuf.append(" ");
+ sbuf.append(String.format("%d", mYear));
+
+ if (item.day == mSelectedDay) {
+ return getContext().getString(R.string.item_is_selected, sbuf);
+ }
+
+ return sbuf;
+ }
+ }
+
+ /**
+ * Handles callbacks when the user clicks on a time object.
+ */
+ public interface OnDayClickListener {
+ public void onDayClick(SimpleMonthView view, CalendarDay day);
+ }
}
diff --git a/src/com/googlecode/eyesfree/utils/TouchExplorationHelper.java b/src/com/googlecode/eyesfree/utils/TouchExplorationHelper.java
new file mode 100644
index 0000000..b9df653
--- /dev/null
+++ b/src/com/googlecode/eyesfree/utils/TouchExplorationHelper.java
@@ -0,0 +1,475 @@
+/*
+ * Copyright (C) 2012 Google Inc.
+ *
+ * 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 com.googlecode.eyesfree.utils;
+
+import android.content.Context;
+import android.graphics.Rect;
+import android.os.Bundle;
+import android.support.v4.view.AccessibilityDelegateCompat;
+import android.support.v4.view.ViewCompat;
+import android.support.v4.view.accessibility.AccessibilityNodeInfoCompat;
+import android.support.v4.view.accessibility.AccessibilityNodeProviderCompat;
+import android.support.v4.view.accessibility.AccessibilityRecordCompat;
+import android.text.TextUtils;
+import android.view.MotionEvent;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.accessibility.AccessibilityEvent;
+import android.view.accessibility.AccessibilityManager;
+
+import java.util.LinkedList;
+import java.util.List;
+
+public abstract class TouchExplorationHelper<T> extends AccessibilityNodeProviderCompat
+ implements View.OnHoverListener {
+ /** Virtual node identifier value for invalid nodes. */
+ public static final int INVALID_ID = Integer.MIN_VALUE;
+
+ 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];
+
+ private final AccessibilityManager mManager;
+
+ private View mParentView;
+ private int mFocusedItemId = INVALID_ID;
+ private T mCurrentItem = null;
+
+ /**
+ * Constructs a new touch exploration helper.
+ *
+ * @param context The parent context.
+ */
+ public TouchExplorationHelper(Context context, View parentView) {
+ mManager = (AccessibilityManager) context.getSystemService(Context.ACCESSIBILITY_SERVICE);
+ mParentView = parentView;
+ }
+
+ /**
+ * @return The current accessibility focused item, or {@code null} if no
+ * item is focused.
+ */
+ public T getFocusedItem() {
+ return getItemForId(mFocusedItemId);
+ }
+
+ /**
+ * Clears the current accessibility focused item.
+ */
+ public void clearFocusedItem() {
+ final int itemId = mFocusedItemId;
+ if (itemId == INVALID_ID) {
+ return;
+ }
+
+ performAction(itemId, AccessibilityNodeInfoCompat.ACTION_CLEAR_ACCESSIBILITY_FOCUS, null);
+ }
+
+ /**
+ * Requests accessibility focus be placed on the specified item.
+ *
+ * @param item The item to place focus on.
+ */
+ public void setFocusedItem(T item) {
+ final int itemId = getIdForItem(item);
+ if (itemId == INVALID_ID) {
+ return;
+ }
+
+ performAction(itemId, AccessibilityNodeInfoCompat.ACTION_ACCESSIBILITY_FOCUS, null);
+ }
+
+ /**
+ * Invalidates cached information about the parent view.
+ * <p>
+ * You <b>must</b> call this method after adding or removing items from the
+ * parent view.
+ * </p>
+ */
+ public void invalidateParent() {
+ mParentView.sendAccessibilityEvent(AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED);
+ }
+
+ /**
+ * Invalidates cached information for a particular item.
+ * <p>
+ * You <b>must</b> call this method when any of the properties set in
+ * {@link #populateNodeForItem(Object, AccessibilityNodeInfoCompat)} have
+ * changed.
+ * </p>
+ *
+ * @param item
+ */
+ public void invalidateItem(T item) {
+ sendEventForItem(item, AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED);
+ }
+
+ /**
+ * Populates an event of the specified type with information about an item
+ * and attempts to send it up through the view hierarchy.
+ *
+ * @param item The item for which to send an event.
+ * @param eventType The type of event to send.
+ * @return {@code true} if the event was sent successfully.
+ */
+ public boolean sendEventForItem(T item, int eventType) {
+ if (!mManager.isEnabled()) {
+ return false;
+ }
+
+ final AccessibilityEvent event = getEventForItem(item, eventType);
+ final ViewGroup group = (ViewGroup) mParentView.getParent();
+
+ return group.requestSendAccessibilityEvent(mParentView, event);
+ }
+
+ @Override
+ public AccessibilityNodeInfoCompat createAccessibilityNodeInfo(int virtualViewId) {
+ if (virtualViewId == View.NO_ID) {
+ return getNodeForParent();
+ }
+
+ final T item = getItemForId(virtualViewId);
+ if (item == null) {
+ return null;
+ }
+
+ final AccessibilityNodeInfoCompat node = AccessibilityNodeInfoCompat.obtain();
+ populateNodeForItemInternal(item, node);
+ return node;
+ }
+
+ @Override
+ public boolean performAction(int virtualViewId, int action, Bundle arguments) {
+ if (virtualViewId == View.NO_ID) {
+ return ViewCompat.performAccessibilityAction(mParentView, action, arguments);
+ }
+
+ final T item = getItemForId(virtualViewId);
+ if (item == null) {
+ return false;
+ }
+
+ boolean handled = false;
+
+ switch (action) {
+ case AccessibilityNodeInfoCompat.ACTION_ACCESSIBILITY_FOCUS:
+ if (mFocusedItemId != virtualViewId) {
+ mFocusedItemId = virtualViewId;
+ sendEventForItem(item, AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED);
+ handled = true;
+ }
+ break;
+ case AccessibilityNodeInfoCompat.ACTION_CLEAR_ACCESSIBILITY_FOCUS:
+ if (mFocusedItemId == virtualViewId) {
+ mFocusedItemId = INVALID_ID;
+ sendEventForItem(item, AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUS_CLEARED);
+ handled = true;
+ }
+ break;
+ }
+
+ handled |= performActionForItem(item, action, arguments);
+
+ return handled;
+ }
+
+ @Override
+ public boolean onHover(View view, MotionEvent event) {
+ if (!mManager.isTouchExplorationEnabled()) {
+ return false;
+ }
+
+ switch (event.getAction()) {
+ case MotionEvent.ACTION_HOVER_ENTER:
+ case MotionEvent.ACTION_HOVER_MOVE:
+ final T item = getItemAt(event.getX(), event.getY());
+ setCurrentItem(item);
+ return true;
+ case MotionEvent.ACTION_HOVER_EXIT:
+ setCurrentItem(null);
+ return true;
+ }
+
+ return false;
+ }
+
+ private void setCurrentItem(T item) {
+ if (mCurrentItem == item) {
+ return;
+ }
+
+ if (mCurrentItem != null) {
+ sendEventForItem(mCurrentItem, AccessibilityEvent.TYPE_VIEW_HOVER_EXIT);
+ }
+
+ mCurrentItem = item;
+
+ if (mCurrentItem != null) {
+ sendEventForItem(mCurrentItem, AccessibilityEvent.TYPE_VIEW_HOVER_ENTER);
+ }
+ }
+
+ private AccessibilityEvent getEventForItem(T item, int eventType) {
+ final AccessibilityEvent event = AccessibilityEvent.obtain(eventType);
+ final AccessibilityRecordCompat record = new AccessibilityRecordCompat(event);
+ final int virtualDescendantId = getIdForItem(item);
+
+ // Ensure the client has good defaults.
+ event.setEnabled(true);
+
+ // Allow the client to populate the event.
+ populateEventForItem(item, event);
+
+ if (event.getText().isEmpty() && TextUtils.isEmpty(event.getContentDescription())) {
+ throw new RuntimeException(
+ "You must add text or a content description in populateEventForItem()");
+ }
+
+ // Don't allow the client to override these properties.
+ event.setClassName(item.getClass().getName());
+ event.setPackageName(mParentView.getContext().getPackageName());
+ record.setSource(mParentView, virtualDescendantId);
+
+ return event;
+ }
+
+ private AccessibilityNodeInfoCompat getNodeForParent() {
+ final AccessibilityNodeInfoCompat info = AccessibilityNodeInfoCompat.obtain(mParentView);
+ ViewCompat.onInitializeAccessibilityNodeInfo(mParentView, info);
+
+ final LinkedList<T> items = new LinkedList<T>();
+ getVisibleItems(items);
+
+ for (T item : items) {
+ final int virtualDescendantId = getIdForItem(item);
+ info.addChild(mParentView, virtualDescendantId);
+ }
+
+ return info;
+ }
+
+ private AccessibilityNodeInfoCompat populateNodeForItemInternal(
+ T item, AccessibilityNodeInfoCompat node) {
+ final int virtualDescendantId = getIdForItem(item);
+
+ // Ensure the client has good defaults.
+ node.setEnabled(true);
+
+ // Allow the client to populate the node.
+ populateNodeForItem(item, node);
+
+ if (TextUtils.isEmpty(node.getText()) && TextUtils.isEmpty(node.getContentDescription())) {
+ throw new RuntimeException(
+ "You must add text or a content description in populateNodeForItem()");
+ }
+
+ // Don't allow the client to override these properties.
+ node.setPackageName(mParentView.getContext().getPackageName());
+ node.setClassName(item.getClass().getName());
+ node.setParent(mParentView);
+ node.setSource(mParentView, virtualDescendantId);
+
+ if (mFocusedItemId == virtualDescendantId) {
+ node.addAction(AccessibilityNodeInfoCompat.ACTION_CLEAR_ACCESSIBILITY_FOCUS);
+ } else {
+ node.addAction(AccessibilityNodeInfoCompat.ACTION_ACCESSIBILITY_FOCUS);
+ }
+
+ node.getBoundsInParent(mTempParentRect);
+ if (mTempParentRect.isEmpty()) {
+ throw new RuntimeException("You must set parent bounds in populateNodeForItem()");
+ }
+
+ // Set the visibility based on the parent bound.
+ if (intersectVisibleToUser(mTempParentRect)) {
+ node.setVisibleToUser(true);
+ node.setBoundsInParent(mTempParentRect);
+ }
+
+ // Calculate screen-relative bound.
+ mParentView.getLocationOnScreen(mTempGlobalRect);
+ final int offsetX = mTempGlobalRect[0];
+ final int offsetY = mTempGlobalRect[1];
+ mTempScreenRect.set(mTempParentRect);
+ mTempScreenRect.offset(offsetX, offsetY);
+ node.setBoundsInScreen(mTempScreenRect);
+
+ return node;
+ }
+
+ /**
+ * 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 intersectVisibleToUser(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 (mParentView.getWindowVisibility() != View.VISIBLE) {
+ return false;
+ }
+
+ // An invisible predecessor or one with alpha zero means
+ // that this view is not visible to the user.
+ Object current = this;
+ while (current instanceof View) {
+ final View view = (View) current;
+ // We have attach info so this view is attached and there is no
+ // need to check whether we reach to ViewRootImpl on the way up.
+ if ((view.getAlpha() <= 0) || (view.getVisibility() != View.VISIBLE)) {
+ return false;
+ }
+ current = view.getParent();
+ }
+
+ // If no portion of the parent is visible, this view is not visible.
+ if (!mParentView.getLocalVisibleRect(mTempVisibleRect)) {
+ return false;
+ }
+
+ // Check if the view intersects the visible portion of the parent.
+ return localRect.intersect(mTempVisibleRect);
+ }
+
+ public AccessibilityDelegateCompat getAccessibilityDelegate() {
+ return mDelegate;
+ }
+
+ private final AccessibilityDelegateCompat mDelegate = new AccessibilityDelegateCompat() {
+ @Override
+ public void onInitializeAccessibilityEvent(View view, AccessibilityEvent event) {
+ super.onInitializeAccessibilityEvent(view, event);
+ event.setClassName(view.getClass().getName());
+ }
+
+ @Override
+ public void onInitializeAccessibilityNodeInfo(View view, AccessibilityNodeInfoCompat info) {
+ super.onInitializeAccessibilityNodeInfo(view, info);
+ info.setClassName(view.getClass().getName());
+ }
+
+ @Override
+ public AccessibilityNodeProviderCompat getAccessibilityNodeProvider(View host) {
+ return TouchExplorationHelper.this;
+ }
+ };
+
+ /**
+ * Performs an accessibility action on the specified item. See
+ * {@link AccessibilityNodeInfoCompat#performAction(int, Bundle)}.
+ * <p>
+ * The helper class automatically handles focus management resulting from
+ * {@link AccessibilityNodeInfoCompat#ACTION_ACCESSIBILITY_FOCUS} and
+ * {@link AccessibilityNodeInfoCompat#ACTION_CLEAR_ACCESSIBILITY_FOCUS}, so
+ * typically a developer only needs to handle actions added manually in the
+ * {{@link #populateNodeForItem(Object, AccessibilityNodeInfoCompat)}
+ * method.
+ * </p>
+ *
+ * @param item The item on which to perform the action.
+ * @param action The accessibility action to perform.
+ * @param arguments Arguments for the action, or optionally {@code null}.
+ * @return {@code true} if the action was performed successfully.
+ */
+ protected abstract boolean performActionForItem(T item, int action, Bundle arguments);
+
+ /**
+ * Populates an event with information about the specified item.
+ * <p>
+ * At a minimum, a developer must populate the event text by doing one of
+ * the following:
+ * <ul>
+ * <li>appending text to {@link AccessibilityEvent#getText()}</li>
+ * <li>populating a description with
+ * {@link AccessibilityEvent#setContentDescription(CharSequence)}</li>
+ * </ul>
+ * </p>
+ *
+ * @param item The item for which to populate the event.
+ * @param event The event to populate.
+ */
+ protected abstract void populateEventForItem(T item, AccessibilityEvent event);
+
+ /**
+ * Populates a node with information about the specified item.
+ * <p>
+ * At a minimum, a developer must:
+ * <ul>
+ * <li>populate the event text using
+ * {@link AccessibilityNodeInfoCompat#setText(CharSequence)} or
+ * {@link AccessibilityNodeInfoCompat#setContentDescription(CharSequence)}
+ * </li>
+ * <li>set the item's parent-relative bounds using
+ * {@link AccessibilityNodeInfoCompat#setBoundsInParent(Rect)}
+ * </ul>
+ *
+ * @param item The item for which to populate the node.
+ * @param node The node to populate.
+ */
+ protected abstract void populateNodeForItem(T item, AccessibilityNodeInfoCompat node);
+
+ /**
+ * Populates a list with the parent view's visible items.
+ * <p>
+ * The result of this method is cached until the developer calls
+ * {@link #invalidateParent()}.
+ * </p>
+ *
+ * @param items The list to populate with visible items.
+ */
+ protected abstract void getVisibleItems(List<T> items);
+
+ /**
+ * Returns the item under the specified parent-relative coordinates.
+ *
+ * @param x The parent-relative x coordinate.
+ * @param y The parent-relative y coordinate.
+ * @return The item under coordinates (x,y).
+ */
+ protected abstract T getItemAt(float x, float y);
+
+ /**
+ * Returns the unique identifier for an item. If the specified item does not
+ * exist, returns {@link #INVALID_ID}.
+ * <p>
+ * This result of this method must be consistent with
+ * {@link #getItemForId(int)}.
+ * </p>
+ *
+ * @param item The item whose identifier to return.
+ * @return A unique identifier, or {@link #INVALID_ID}.
+ */
+ protected abstract int getIdForItem(T item);
+
+ /**
+ * Returns the item for a unique identifier. If the specified item does not
+ * exist, returns {@code null}.
+ *
+ * @param id The identifier for the item to return.
+ * @return An item, or {@code null}.
+ */
+ protected abstract T getItemForId(int id);
+}