OSDN Git Service

Improve DatePicker focus for keyboards.
authorGeorge Mount <mount@google.com>
Tue, 27 Oct 2015 15:46:44 +0000 (08:46 -0700)
committerGeorge Mount <mount@google.com>
Fri, 6 Nov 2015 23:48:52 +0000 (15:48 -0800)
Bug 24873983

Focus moves properly within the SimpleMonthView and between
components of the DatePicker.

Change-Id: I61778f9b4f19536ff6c7a512f9b4faf7bf4447a7

core/java/android/widget/DatePickerCalendarDelegate.java
core/java/android/widget/DayPickerPagerAdapter.java
core/java/android/widget/DayPickerViewPager.java
core/java/android/widget/SimpleMonthView.java
core/java/android/widget/YearPickerView.java
core/java/com/android/internal/widget/ViewPager.java
core/res/res/layout/date_picker_header_material.xml
core/res/res/layout/date_picker_month_item_material.xml
core/res/res/layout/day_picker_content_material.xml

index 7e542c9..cbb6109 100755 (executable)
@@ -171,7 +171,7 @@ class DatePickerCalendarDelegate extends DatePicker.AbstractDatePickerDelegate {
         // Set up year picker view.
         mYearPickerView = (YearPickerView) mAnimator.findViewById(R.id.date_picker_year_picker);
         mYearPickerView.setRange(mMinDate, mMaxDate);
-        mYearPickerView.setDate(mCurrentDate.getTimeInMillis());
+        mYearPickerView.setYear(mCurrentDate.get(Calendar.YEAR));
         mYearPickerView.setOnYearSelectedListener(mOnYearSelectedListener);
 
         // Set up content descriptions.
@@ -267,6 +267,9 @@ class DatePickerCalendarDelegate extends DatePicker.AbstractDatePickerDelegate {
 
             // Automatically switch to day picker.
             setCurrentView(VIEW_MONTH_DAY);
+
+            // Switch focus back to the year text.
+            mHeaderYear.requestFocus();
         }
     };
 
@@ -344,7 +347,18 @@ class DatePickerCalendarDelegate extends DatePicker.AbstractDatePickerDelegate {
                 mAnimator.announceForAccessibility(mSelectDay);
                 break;
             case VIEW_YEAR:
-                mYearPickerView.setDate(mCurrentDate.getTimeInMillis());
+                final int year = mCurrentDate.get(Calendar.YEAR);
+                mYearPickerView.setYear(year);
+                mYearPickerView.post(new Runnable() {
+                    @Override
+                    public void run() {
+                        mYearPickerView.requestFocus();
+                        final View selected = mYearPickerView.getSelectedView();
+                        if (selected != null) {
+                            selected.requestFocus();
+                        }
+                    }
+                });
 
                 if (mCurrentView != viewIndex) {
                     mHeaderMonthDay.setActivated(false);
index 8fe8252..f5840dc 100644 (file)
@@ -287,6 +287,14 @@ class DayPickerPagerAdapter extends PagerAdapter {
         return null;
     }
 
+    SimpleMonthView getView(Object object) {
+        if (object == null) {
+            return null;
+        }
+        final ViewHolder holder = (ViewHolder) object;
+        return holder.calendar;
+    }
+
     private final OnDayClickListener mOnDayClickListener = new OnDayClickListener() {
         @Override
         public void onDayClick(SimpleMonthView view, Calendar day) {
index bb6e3a4..5f0ae29 100644 (file)
 
 package android.widget;
 
-import com.android.internal.widget.ViewPager;
-
+import android.annotation.Nullable;
 import android.content.Context;
+import android.graphics.Rect;
 import android.graphics.drawable.Drawable;
 import android.util.AttributeSet;
 import android.view.View;
+import android.view.ViewGroup;
+import android.view.ViewGroup.LayoutParams;
+
+import com.android.internal.util.Predicate;
+import com.android.internal.widget.PagerAdapter;
+import com.android.internal.widget.ViewPager;
 
 import java.util.ArrayList;
 
@@ -134,4 +140,37 @@ class DayPickerViewPager extends ViewPager {
 
         mMatchParentChildren.clear();
     }
+
+    @Override
+    protected View findViewByPredicateTraversal(Predicate<View> predicate, View childToSkip) {
+        if (predicate.apply(this)) {
+            return this;
+        }
+
+        // Always try the selected view first.
+        final DayPickerPagerAdapter adapter = (DayPickerPagerAdapter) getAdapter();
+        final SimpleMonthView current = adapter.getView(getCurrent());
+        if (current != childToSkip && current != null) {
+            final View v = current.findViewByPredicate(predicate);
+            if (v != null) {
+                return v;
+            }
+        }
+
+        final int len = getChildCount();
+        for (int i = 0; i < len; i++) {
+            final View child = getChildAt(i);
+
+            if (child != childToSkip && child != current) {
+                final View v = child.findViewByPredicate(predicate);
+
+                if (v != null) {
+                    return v;
+                }
+            }
+        }
+
+        return null;
+    }
+
 }
index e9325ef..6edce91 100644 (file)
@@ -16,6 +16,7 @@
 
 package android.widget;
 
+import android.annotation.Nullable;
 import android.content.Context;
 import android.content.res.ColorStateList;
 import android.content.res.Resources;
@@ -34,8 +35,10 @@ import android.util.AttributeSet;
 import android.util.IntArray;
 import android.util.MathUtils;
 import android.util.StateSet;
+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.AccessibilityNodeInfo;
 import android.view.accessibility.AccessibilityNodeInfo.AccessibilityAction;
@@ -61,11 +64,14 @@ class SimpleMonthView extends View {
     private static final String DEFAULT_TITLE_FORMAT = "MMMMy";
     private static final String DAY_OF_WEEK_FORMAT = "EEEEE";
 
+    private static final int SELECTED_HIGHLIGHT_ALPHA = 0xB0;
+
     private final TextPaint mMonthPaint = new TextPaint();
     private final TextPaint mDayOfWeekPaint = new TextPaint();
     private final TextPaint mDayPaint = new TextPaint();
     private final Paint mDaySelectorPaint = new Paint();
     private final Paint mDayHighlightPaint = new Paint();
+    private final Paint mDayHighlightSelectorPaint = new Paint();
 
     private final Calendar mCalendar = Calendar.getInstance();
     private final Calendar mDayOfWeekLabelCalendar = Calendar.getInstance();
@@ -130,7 +136,9 @@ class SimpleMonthView extends View {
 
     private ColorStateList mDayTextColor;
 
-    private int mTouchedItem = -1;
+    private int mHighlightedDay = -1;
+    private int mPreviouslyHighlightedDay = -1;
+    private boolean mIsTouchHighlighted = false;
 
     public SimpleMonthView(Context context) {
         this(context, null);
@@ -268,6 +276,9 @@ class SimpleMonthView extends View {
         mDayHighlightPaint.setAntiAlias(true);
         mDayHighlightPaint.setStyle(Style.FILL);
 
+        mDayHighlightSelectorPaint.setAntiAlias(true);
+        mDayHighlightSelectorPaint.setStyle(Style.FILL);
+
         mDayPaint.setAntiAlias(true);
         mDayPaint.setTextSize(dayTextSize);
         mDayPaint.setTypeface(Typeface.create(dayTypeface, 0));
@@ -296,6 +307,8 @@ class SimpleMonthView extends View {
         final int activatedColor = dayBackgroundColor.getColorForState(
                 StateSet.get(StateSet.VIEW_STATE_ENABLED | StateSet.VIEW_STATE_ACTIVATED), 0);
         mDaySelectorPaint.setColor(activatedColor);
+        mDayHighlightSelectorPaint.setColor(activatedColor);
+        mDayHighlightSelectorPaint.setAlpha(SELECTED_HIGHLIGHT_ALPHA);
         invalidate();
     }
 
@@ -326,8 +339,10 @@ class SimpleMonthView extends View {
             case MotionEvent.ACTION_DOWN:
             case MotionEvent.ACTION_MOVE:
                 final int touchedItem = getDayAtLocation(x, y);
-                if (mTouchedItem != touchedItem) {
-                    mTouchedItem = touchedItem;
+                mIsTouchHighlighted = true;
+                if (mHighlightedDay != touchedItem) {
+                    mHighlightedDay = touchedItem;
+                    mPreviouslyHighlightedDay = touchedItem;
                     invalidate();
                 }
                 if (action == MotionEvent.ACTION_DOWN && touchedItem < 0) {
@@ -342,7 +357,8 @@ class SimpleMonthView extends View {
                 // Fall through.
             case MotionEvent.ACTION_CANCEL:
                 // Reset touched day on stream end.
-                mTouchedItem = -1;
+                mHighlightedDay = -1;
+                mIsTouchHighlighted = false;
                 invalidate();
                 break;
         }
@@ -350,6 +366,228 @@ class SimpleMonthView extends View {
     }
 
     @Override
+    public boolean onKeyDown(int keyCode, KeyEvent event) {
+        // We need to handle focus change within the SimpleMonthView because we are simulating
+        // multiple Views. The arrow keys will move between days until there is no space (no
+        // day to the left, top, right, or bottom). Focus forward and back jumps out of the
+        // SimpleMonthView, skipping over other SimpleMonthViews in the parent ViewPager
+        // to the next focusable View in the hierarchy.
+        boolean focusChanged = false;
+        switch (event.getKeyCode()) {
+            case KeyEvent.KEYCODE_DPAD_LEFT:
+                if (event.hasNoModifiers()) {
+                    focusChanged = moveOneDay(isLayoutRtl());
+                }
+                break;
+            case KeyEvent.KEYCODE_DPAD_RIGHT:
+                if (event.hasNoModifiers()) {
+                    focusChanged = moveOneDay(!isLayoutRtl());
+                }
+                break;
+            case KeyEvent.KEYCODE_DPAD_UP:
+                if (event.hasNoModifiers()) {
+                    ensureFocusedDay();
+                    if (mHighlightedDay > 7) {
+                        mHighlightedDay -= 7;
+                        focusChanged = true;
+                    }
+                }
+                break;
+            case KeyEvent.KEYCODE_DPAD_DOWN:
+                if (event.hasNoModifiers()) {
+                    ensureFocusedDay();
+                    if (mHighlightedDay <= mDaysInMonth - 7) {
+                        mHighlightedDay += 7;
+                        focusChanged = true;
+                    }
+                }
+                break;
+            case KeyEvent.KEYCODE_DPAD_CENTER:
+            case KeyEvent.KEYCODE_ENTER:
+                if (mHighlightedDay != -1) {
+                    onDayClicked(mHighlightedDay);
+                    return true;
+                }
+                break;
+            case KeyEvent.KEYCODE_TAB: {
+                int focusChangeDirection = 0;
+                if (event.hasNoModifiers()) {
+                    focusChangeDirection = View.FOCUS_FORWARD;
+                } else if (event.hasModifiers(KeyEvent.META_SHIFT_ON)) {
+                    focusChangeDirection = View.FOCUS_BACKWARD;
+                }
+                if (focusChangeDirection != 0) {
+                    final ViewParent parent = getParent();
+                    // move out of the ViewPager next/previous
+                    View nextFocus = this;
+                    do {
+                        nextFocus = nextFocus.focusSearch(focusChangeDirection);
+                    } while (nextFocus != null && nextFocus != this &&
+                            nextFocus.getParent() == parent);
+                    if (nextFocus != null) {
+                        nextFocus.requestFocus();
+                        return true;
+                    }
+                }
+                break;
+            }
+        }
+        if (focusChanged) {
+            invalidate();
+            return true;
+        } else {
+            return super.onKeyDown(keyCode, event);
+        }
+    }
+
+    private boolean moveOneDay(boolean positive) {
+        ensureFocusedDay();
+        boolean focusChanged = false;
+        if (positive) {
+            if (!isLastDayOfWeek(mHighlightedDay) && mHighlightedDay < mDaysInMonth) {
+                mHighlightedDay++;
+                focusChanged = true;
+            }
+        } else {
+            if (!isFirstDayOfWeek(mHighlightedDay) && mHighlightedDay > 1) {
+                mHighlightedDay--;
+                focusChanged = true;
+            }
+        }
+        return focusChanged;
+    }
+
+    @Override
+    protected void onFocusChanged(boolean gainFocus, @FocusDirection int direction,
+            @Nullable Rect previouslyFocusedRect) {
+        if (gainFocus) {
+            // If we've gained focus through arrow keys, we should find the day closest
+            // to the focus rect. If we've gained focus through forward/back, we should
+            // focus on the selected day if there is one.
+            final int offset = findDayOffset();
+            switch(direction) {
+                case View.FOCUS_RIGHT: {
+                    int row = findClosestRow(previouslyFocusedRect);
+                    mHighlightedDay = row == 0 ? 1 : (row * DAYS_IN_WEEK) - offset + 1;
+                    break;
+                }
+                case View.FOCUS_LEFT: {
+                    int row = findClosestRow(previouslyFocusedRect) + 1;
+                    mHighlightedDay = Math.min(mDaysInMonth, (row * DAYS_IN_WEEK) - offset);
+                    break;
+                }
+                case View.FOCUS_DOWN: {
+                    final int col = findClosestColumn(previouslyFocusedRect);
+                    final int day = col - offset + 1;
+                    mHighlightedDay = day < 1 ? day + DAYS_IN_WEEK : day;
+                    break;
+                }
+                case View.FOCUS_UP: {
+                    final int col = findClosestColumn(previouslyFocusedRect);
+                    final int maxWeeks = (offset + mDaysInMonth) / DAYS_IN_WEEK;
+                    final int day = col - offset + (DAYS_IN_WEEK * maxWeeks) + 1;
+                    mHighlightedDay = day > mDaysInMonth ? day - DAYS_IN_WEEK : day;
+                    break;
+                }
+            }
+            ensureFocusedDay();
+            invalidate();
+        }
+        super.onFocusChanged(gainFocus, direction, previouslyFocusedRect);
+    }
+
+    /**
+     * Returns the row (0 indexed) closest to previouslyFocusedRect or center if null.
+     */
+    private int findClosestRow(@Nullable Rect previouslyFocusedRect) {
+        if (previouslyFocusedRect == null) {
+            return 3;
+        } else {
+            int centerY = previouslyFocusedRect.centerY();
+
+            final TextPaint p = mDayPaint;
+            final int headerHeight = mMonthHeight + mDayOfWeekHeight;
+            final int rowHeight = mDayHeight;
+
+            // Text is vertically centered within the row height.
+            final float halfLineHeight = (p.ascent() + p.descent()) / 2f;
+            final int rowCenter = headerHeight + rowHeight / 2;
+
+            centerY -= rowCenter - halfLineHeight;
+            int row = Math.round(centerY / (float) rowHeight);
+            final int maxDay = findDayOffset() + mDaysInMonth;
+            final int maxRows = (maxDay / DAYS_IN_WEEK) - ((maxDay % DAYS_IN_WEEK == 0) ? 1 : 0);
+
+            row = MathUtils.constrain(row, 0, maxRows);
+            return row;
+        }
+    }
+
+    /**
+     * Returns the column (0 indexed) closest to the previouslyFocusedRect or center if null.
+     * The 0 index is related to the first day of the week.
+     */
+    private int findClosestColumn(@Nullable Rect previouslyFocusedRect) {
+        if (previouslyFocusedRect == null) {
+            return DAYS_IN_WEEK / 2;
+        } else {
+            int centerX = previouslyFocusedRect.centerX() - mPaddingLeft;
+            final int columnFromLeft =
+                    MathUtils.constrain(centerX / mCellWidth, 0, DAYS_IN_WEEK - 1);
+            return isLayoutRtl() ? DAYS_IN_WEEK - columnFromLeft - 1: columnFromLeft;
+        }
+    }
+
+    @Override
+    public void getFocusedRect(Rect r) {
+        if (mHighlightedDay > 0) {
+            getBoundsForDay(mHighlightedDay, r);
+        } else {
+            super.getFocusedRect(r);
+        }
+    }
+
+    @Override
+    protected void onFocusLost() {
+        if (!mIsTouchHighlighted) {
+            // Unhighlight a day.
+            mPreviouslyHighlightedDay = mHighlightedDay;
+            mHighlightedDay = -1;
+            invalidate();
+        }
+        super.onFocusLost();
+    }
+
+    /**
+     * Ensure some day is highlighted. If a day isn't highlighted, it chooses the selected day,
+     * if possible, or the first day of the month if not.
+     */
+    private void ensureFocusedDay() {
+        if (mHighlightedDay != -1) {
+            return;
+        }
+        if (mPreviouslyHighlightedDay != -1) {
+            mHighlightedDay = mPreviouslyHighlightedDay;
+            return;
+        }
+        if (mActivatedDay != -1) {
+            mHighlightedDay = mActivatedDay;
+            return;
+        }
+        mHighlightedDay = 1;
+    }
+
+    private boolean isFirstDayOfWeek(int day) {
+        final int offset = findDayOffset();
+        return (offset + day - 1) % DAYS_IN_WEEK == 0;
+    }
+
+    private boolean isLastDayOfWeek(int day) {
+        final int offset = findDayOffset();
+        return (offset + day) % DAYS_IN_WEEK == 0;
+    }
+
+    @Override
     protected void onDraw(Canvas canvas) {
         final int paddingLeft = getPaddingLeft();
         final int paddingTop = getPaddingTop();
@@ -432,12 +670,15 @@ class SimpleMonthView extends View {
             }
 
             final boolean isDayActivated = mActivatedDay == day;
+            final boolean isDayHighlighted = mHighlightedDay == day;
             if (isDayActivated) {
                 stateMask |= StateSet.VIEW_STATE_ACTIVATED;
 
                 // Adjust the circle to be centered on the row.
-                canvas.drawCircle(colCenterRtl, rowCenter, mDaySelectorRadius, mDaySelectorPaint);
-            } else if (mTouchedItem == day) {
+                final Paint paint = isDayHighlighted ? mDayHighlightSelectorPaint :
+                        mDaySelectorPaint;
+                canvas.drawCircle(colCenterRtl, rowCenter, mDaySelectorRadius, paint);
+            } else if (isDayHighlighted) {
                 stateMask |= StateSet.VIEW_STATE_PRESSED;
 
                 if (isDayEnabled) {
index 89e59f9..96624d2 100644 (file)
@@ -38,8 +38,6 @@ class YearPickerView extends ListView {
 
     private OnYearSelectedListener mOnYearSelectedListener;
 
-    private long mCurrentTimeMillis;
-
     public YearPickerView(Context context, AttributeSet attrs) {
         this(context, attrs, R.attr.listViewStyle);
     }
@@ -79,10 +77,6 @@ class YearPickerView extends ListView {
         mOnYearSelectedListener = listener;
     }
 
-    public void setDate(long currentTimeMillis) {
-        mCurrentTimeMillis = currentTimeMillis;
-    }
-
     /**
      * Sets the currently selected year. Jumps immediately to the new year.
      *
index 6217d6e..948a6bb 100644 (file)
@@ -1095,7 +1095,16 @@ public class ViewPager extends ViewGroup {
                     View child = getChildAt(i);
                     ii = infoForChild(child);
                     if (ii != null && ii.position == mCurItem) {
-                        if (child.requestFocus(focusDirection)) {
+                        final Rect focusRect;
+                        if (currentFocused == null) {
+                            focusRect = null;
+                        } else {
+                            focusRect = mTempRect;
+                            currentFocused.getFocusedRect(mTempRect);
+                            offsetDescendantRectToMyCoords(currentFocused, mTempRect);
+                            offsetRectIntoDescendantCoords(child, mTempRect);
+                        }
+                        if (child.requestFocus(focusDirection, focusRect)) {
                             break;
                         }
                     }
@@ -1321,6 +1330,11 @@ public class ViewPager extends ViewGroup {
         }
     }
 
+    public Object getCurrent() {
+        final ItemInfo itemInfo = infoForPosition(getCurrentItem());
+        return itemInfo == null ? null : itemInfo.object;
+    }
+
     @Override
     public void removeView(View view) {
         if (mInLayout) {
index a4388f6..821b588 100644 (file)
               android:id="@+id/date_picker_header"
               android:layout_width="match_parent"
               android:layout_height="wrap_content"
-              android:paddingBottom="18dp"
               android:paddingStart="?attr/dialogPreferredPadding"
               android:paddingEnd="?attr/dialogPreferredPadding"
-              android:orientation="vertical">
+              android:paddingTop="16dp"
+              android:paddingBottom="18dp"
+              android:orientation="vertical"
+              android:clipToPadding="false"
+              android:clipChildren="false">
 
-    <!-- Top padding should stay on this view so that
-         the touch target is a bit larger. -->
     <TextView
         android:id="@+id/date_picker_header_year"
-        android:layout_width="match_parent"
+        android:layout_width="wrap_content"
         android:layout_height="wrap_content"
-        android:paddingTop="16dp"
-        android:textAppearance="@style/TextAppearance.Material.DatePicker.YearLabel" />
+        android:focusable="true"
+        android:layout_marginStart="-8dp"
+        android:layout_marginEnd="-8dp"
+        android:layout_marginTop="-8dp"
+        android:layout_marginBottom="-8dp"
+        android:padding="8dp"
+        android:background="?attr/selectableItemBackground"
+        android:textAppearance="@style/TextAppearance.Material.DatePicker.YearLabel"
+        android:nextFocusForward="@+id/prev" />
 
     <TextView
         android:id="@+id/date_picker_header_date"
index cb79cee..52f7b8e 100644 (file)
@@ -21,4 +21,5 @@
     android:layout_height="wrap_content"
     android:paddingStart="@dimen/day_picker_padding_horizontal"
     android:paddingEnd="@dimen/day_picker_padding_horizontal"
-    android:paddingTop="@dimen/day_picker_padding_top" />
+    android:paddingTop="@dimen/day_picker_padding_top"
+    android:focusable="true"/>
index b582d74..d77e8dc 100644 (file)
@@ -33,7 +33,9 @@
         android:src="@drawable/ic_chevron_start"
         android:background="?attr/selectableItemBackgroundBorderless"
         android:contentDescription="@string/date_picker_prev_month_button"
-        android:visibility="invisible" />
+        android:visibility="invisible"
+        android:nextFocusForward="@+id/next"
+        android:nextFocusDown="@+id/month_view" />
 
     <ImageButton
         android:id="@+id/next"
@@ -44,6 +46,8 @@
         android:src="@drawable/ic_chevron_end"
         android:background="?attr/selectableItemBackgroundBorderless"
         android:contentDescription="@string/date_picker_next_month_button"
-        android:visibility="invisible" />
+        android:visibility="invisible"
+        android:nextFocusForward="@+id/month_view"
+        android:nextFocusDown="@+id/month_view"/>
 
 </FrameLayout>