Home | History | Annotate | Download | only in widget
      1 /*
      2  * Copyright (C) 2014 The Android Open Source Project
      3  *
      4  * Licensed under the Apache License, Version 2.0 (the "License");
      5  * you may not use this file except in compliance with the License.
      6  * You may obtain a copy of the License at
      7  *
      8  *      http://www.apache.org/licenses/LICENSE-2.0
      9  *
     10  * Unless required by applicable law or agreed to in writing, software
     11  * distributed under the License is distributed on an "AS IS" BASIS,
     12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
     13  * See the License for the specific language governing permissions and
     14  * limitations under the License.
     15  */
     16 
     17 package android.widget;
     18 
     19 import android.content.Context;
     20 import android.content.res.ColorStateList;
     21 import android.content.res.Configuration;
     22 import android.os.Bundle;
     23 import android.util.Log;
     24 import android.util.MathUtils;
     25 import android.view.View;
     26 import android.view.ViewConfiguration;
     27 import android.view.accessibility.AccessibilityEvent;
     28 import android.view.accessibility.AccessibilityNodeInfo;
     29 
     30 import java.text.SimpleDateFormat;
     31 import java.util.Calendar;
     32 import java.util.Locale;
     33 
     34 /**
     35  * This displays a list of months in a calendar format with selectable days.
     36  */
     37 class DayPickerView extends ListView implements AbsListView.OnScrollListener {
     38     private static final String TAG = "DayPickerView";
     39 
     40     // How long the GoTo fling animation should last
     41     private static final int GOTO_SCROLL_DURATION = 250;
     42 
     43     // How long to wait after receiving an onScrollStateChanged notification before acting on it
     44     private static final int SCROLL_CHANGE_DELAY = 40;
     45 
     46     // so that the top line will be under the separator
     47     private static final int LIST_TOP_OFFSET = -1;
     48 
     49     private final SimpleMonthAdapter mAdapter = new SimpleMonthAdapter(getContext());
     50 
     51     private final ScrollStateRunnable mScrollStateChangedRunnable = new ScrollStateRunnable(this);
     52 
     53     private SimpleDateFormat mYearFormat = new SimpleDateFormat("yyyy", Locale.getDefault());
     54 
     55     // highlighted time
     56     private Calendar mSelectedDay = Calendar.getInstance();
     57     private Calendar mTempDay = Calendar.getInstance();
     58     private Calendar mMinDate = Calendar.getInstance();
     59     private Calendar mMaxDate = Calendar.getInstance();
     60 
     61     private Calendar mTempCalendar;
     62 
     63     private OnDaySelectedListener mOnDaySelectedListener;
     64 
     65     // which month should be displayed/highlighted [0-11]
     66     private int mCurrentMonthDisplayed;
     67     // used for tracking what state listview is in
     68     private int mPreviousScrollState = OnScrollListener.SCROLL_STATE_IDLE;
     69     // used for tracking what state listview is in
     70     private int mCurrentScrollState = OnScrollListener.SCROLL_STATE_IDLE;
     71 
     72     private boolean mPerformingScroll;
     73 
     74     public DayPickerView(Context context) {
     75         super(context);
     76 
     77         setAdapter(mAdapter);
     78         setLayoutParams(new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT));
     79         setDrawSelectorOnTop(false);
     80         setUpListView();
     81 
     82         goTo(mSelectedDay.getTimeInMillis(), false, false, true);
     83 
     84         mAdapter.setOnDaySelectedListener(mProxyOnDaySelectedListener);
     85     }
     86 
     87     /**
     88      * Sets the currently selected date to the specified timestamp. Jumps
     89      * immediately to the new date. To animate to the new date, use
     90      * {@link #setDate(long, boolean, boolean)}.
     91      *
     92      * @param timeInMillis
     93      */
     94     public void setDate(long timeInMillis) {
     95         setDate(timeInMillis, false, true);
     96     }
     97 
     98     public void setDate(long timeInMillis, boolean animate, boolean forceScroll) {
     99         goTo(timeInMillis, animate, true, forceScroll);
    100     }
    101 
    102     public long getDate() {
    103         return mSelectedDay.getTimeInMillis();
    104     }
    105 
    106     public void setFirstDayOfWeek(int firstDayOfWeek) {
    107         mAdapter.setFirstDayOfWeek(firstDayOfWeek);
    108     }
    109 
    110     public int getFirstDayOfWeek() {
    111         return mAdapter.getFirstDayOfWeek();
    112     }
    113 
    114     public void setMinDate(long timeInMillis) {
    115         mMinDate.setTimeInMillis(timeInMillis);
    116         onRangeChanged();
    117     }
    118 
    119     public long getMinDate() {
    120         return mMinDate.getTimeInMillis();
    121     }
    122 
    123     public void setMaxDate(long timeInMillis) {
    124         mMaxDate.setTimeInMillis(timeInMillis);
    125         onRangeChanged();
    126     }
    127 
    128     public long getMaxDate() {
    129         return mMaxDate.getTimeInMillis();
    130     }
    131 
    132     /**
    133      * Handles changes to date range.
    134      */
    135     public void onRangeChanged() {
    136         mAdapter.setRange(mMinDate, mMaxDate);
    137 
    138         // Changing the min/max date changes the selection position since we
    139         // don't really have stable IDs. Jumps immediately to the new position.
    140         goTo(mSelectedDay.getTimeInMillis(), false, false, true);
    141     }
    142 
    143     /**
    144      * Sets the listener to call when the user selects a day.
    145      *
    146      * @param listener The listener to call.
    147      */
    148     public void setOnDaySelectedListener(OnDaySelectedListener listener) {
    149         mOnDaySelectedListener = listener;
    150     }
    151 
    152     /*
    153      * Sets all the required fields for the list view. Override this method to
    154      * set a different list view behavior.
    155      */
    156     private void setUpListView() {
    157         // Transparent background on scroll
    158         setCacheColorHint(0);
    159         // No dividers
    160         setDivider(null);
    161         // Items are clickable
    162         setItemsCanFocus(true);
    163         // The thumb gets in the way, so disable it
    164         setFastScrollEnabled(false);
    165         setVerticalScrollBarEnabled(false);
    166         setOnScrollListener(this);
    167         setFadingEdgeLength(0);
    168         // Make the scrolling behavior nicer
    169         setFriction(ViewConfiguration.getScrollFriction());
    170     }
    171 
    172     private int getDiffMonths(Calendar start, Calendar end) {
    173         final int diffYears = end.get(Calendar.YEAR) - start.get(Calendar.YEAR);
    174         final int diffMonths = end.get(Calendar.MONTH) - start.get(Calendar.MONTH) + 12 * diffYears;
    175         return diffMonths;
    176     }
    177 
    178     private int getPositionFromDay(long timeInMillis) {
    179         final int diffMonthMax = getDiffMonths(mMinDate, mMaxDate);
    180         final int diffMonth = getDiffMonths(mMinDate, getTempCalendarForTime(timeInMillis));
    181         return MathUtils.constrain(diffMonth, 0, diffMonthMax);
    182     }
    183 
    184     private Calendar getTempCalendarForTime(long timeInMillis) {
    185         if (mTempCalendar == null) {
    186             mTempCalendar = Calendar.getInstance();
    187         }
    188         mTempCalendar.setTimeInMillis(timeInMillis);
    189         return mTempCalendar;
    190     }
    191 
    192     /**
    193      * This moves to the specified time in the view. If the time is not already
    194      * in range it will move the list so that the first of the month containing
    195      * the time is at the top of the view. If the new time is already in view
    196      * the list will not be scrolled unless forceScroll is true. This time may
    197      * optionally be highlighted as selected as well.
    198      *
    199      * @param day The day to move to
    200      * @param animate Whether to scroll to the given time or just redraw at the
    201      *            new location
    202      * @param setSelected Whether to set the given time as selected
    203      * @param forceScroll Whether to recenter even if the time is already
    204      *            visible
    205      * @return Whether or not the view animated to the new location
    206      */
    207     private boolean goTo(long day, boolean animate, boolean setSelected, boolean forceScroll) {
    208 
    209         // Set the selected day
    210         if (setSelected) {
    211             mSelectedDay.setTimeInMillis(day);
    212         }
    213 
    214         mTempDay.setTimeInMillis(day);
    215         final int position = getPositionFromDay(day);
    216 
    217         View child;
    218         int i = 0;
    219         int top = 0;
    220         // Find a child that's completely in the view
    221         do {
    222             child = getChildAt(i++);
    223             if (child == null) {
    224                 break;
    225             }
    226             top = child.getTop();
    227         } while (top < 0);
    228 
    229         // Compute the first and last position visible
    230         int selectedPosition;
    231         if (child != null) {
    232             selectedPosition = getPositionForView(child);
    233         } else {
    234             selectedPosition = 0;
    235         }
    236 
    237         if (setSelected) {
    238             mAdapter.setSelectedDay(mSelectedDay);
    239         }
    240 
    241         // Check if the selected day is now outside of our visible range
    242         // and if so scroll to the month that contains it
    243         if (position != selectedPosition || forceScroll) {
    244             setMonthDisplayed(mTempDay);
    245             mPreviousScrollState = OnScrollListener.SCROLL_STATE_FLING;
    246             if (animate) {
    247                 smoothScrollToPositionFromTop(
    248                         position, LIST_TOP_OFFSET, GOTO_SCROLL_DURATION);
    249                 return true;
    250             } else {
    251                 postSetSelection(position);
    252             }
    253         } else if (setSelected) {
    254             setMonthDisplayed(mSelectedDay);
    255         }
    256         return false;
    257     }
    258 
    259     public void postSetSelection(final int position) {
    260         clearFocus();
    261         post(new Runnable() {
    262 
    263             @Override
    264             public void run() {
    265                 setSelection(position);
    266             }
    267         });
    268         onScrollStateChanged(this, OnScrollListener.SCROLL_STATE_IDLE);
    269     }
    270 
    271     /**
    272      * Updates the title and selected month if the view has moved to a new
    273      * month.
    274      */
    275     @Override
    276     public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount,
    277                          int totalItemCount) {
    278         SimpleMonthView child = (SimpleMonthView) view.getChildAt(0);
    279         if (child == null) {
    280             return;
    281         }
    282 
    283         mPreviousScrollState = mCurrentScrollState;
    284     }
    285 
    286     /**
    287      * Sets the month displayed at the top of this view based on time. Override
    288      * to add custom events when the title is changed.
    289      */
    290     protected void setMonthDisplayed(Calendar date) {
    291         if (mCurrentMonthDisplayed != date.get(Calendar.MONTH)) {
    292             mCurrentMonthDisplayed = date.get(Calendar.MONTH);
    293             invalidateViews();
    294         }
    295     }
    296 
    297     @Override
    298     public void onScrollStateChanged(AbsListView view, int scrollState) {
    299         // use a post to prevent re-entering onScrollStateChanged before it
    300         // exits
    301         mScrollStateChangedRunnable.doScrollStateChange(view, scrollState);
    302     }
    303 
    304     void setCalendarTextColor(ColorStateList colors) {
    305         mAdapter.setCalendarTextColor(colors);
    306     }
    307 
    308     void setCalendarTextAppearance(int resId) {
    309         mAdapter.setCalendarTextAppearance(resId);
    310     }
    311 
    312     protected class ScrollStateRunnable implements Runnable {
    313         private int mNewState;
    314         private View mParent;
    315 
    316         ScrollStateRunnable(View view) {
    317             mParent = view;
    318         }
    319 
    320         /**
    321          * Sets up the runnable with a short delay in case the scroll state
    322          * immediately changes again.
    323          *
    324          * @param view The list view that changed state
    325          * @param scrollState The new state it changed to
    326          */
    327         public void doScrollStateChange(AbsListView view, int scrollState) {
    328             mParent.removeCallbacks(this);
    329             mNewState = scrollState;
    330             mParent.postDelayed(this, SCROLL_CHANGE_DELAY);
    331         }
    332 
    333         @Override
    334         public void run() {
    335             mCurrentScrollState = mNewState;
    336             if (Log.isLoggable(TAG, Log.DEBUG)) {
    337                 Log.d(TAG,
    338                         "new scroll state: " + mNewState + " old state: " + mPreviousScrollState);
    339             }
    340             // Fix the position after a scroll or a fling ends
    341             if (mNewState == OnScrollListener.SCROLL_STATE_IDLE
    342                     && mPreviousScrollState != OnScrollListener.SCROLL_STATE_IDLE
    343                     && mPreviousScrollState != OnScrollListener.SCROLL_STATE_TOUCH_SCROLL) {
    344                 mPreviousScrollState = mNewState;
    345                 int i = 0;
    346                 View child = getChildAt(i);
    347                 while (child != null && child.getBottom() <= 0) {
    348                     child = getChildAt(++i);
    349                 }
    350                 if (child == null) {
    351                     // The view is no longer visible, just return
    352                     return;
    353                 }
    354                 int firstPosition = getFirstVisiblePosition();
    355                 int lastPosition = getLastVisiblePosition();
    356                 boolean scroll = firstPosition != 0 && lastPosition != getCount() - 1;
    357                 final int top = child.getTop();
    358                 final int bottom = child.getBottom();
    359                 final int midpoint = getHeight() / 2;
    360                 if (scroll && top < LIST_TOP_OFFSET) {
    361                     if (bottom > midpoint) {
    362                         smoothScrollBy(top, GOTO_SCROLL_DURATION);
    363                     } else {
    364                         smoothScrollBy(bottom, GOTO_SCROLL_DURATION);
    365                     }
    366                 }
    367             } else {
    368                 mPreviousScrollState = mNewState;
    369             }
    370         }
    371     }
    372 
    373     /**
    374      * Gets the position of the view that is most prominently displayed within the list view.
    375      */
    376     public int getMostVisiblePosition() {
    377         final int firstPosition = getFirstVisiblePosition();
    378         final int height = getHeight();
    379 
    380         int maxDisplayedHeight = 0;
    381         int mostVisibleIndex = 0;
    382         int i=0;
    383         int bottom = 0;
    384         while (bottom < height) {
    385             View child = getChildAt(i);
    386             if (child == null) {
    387                 break;
    388             }
    389             bottom = child.getBottom();
    390             int displayedHeight = Math.min(bottom, height) - Math.max(0, child.getTop());
    391             if (displayedHeight > maxDisplayedHeight) {
    392                 mostVisibleIndex = i;
    393                 maxDisplayedHeight = displayedHeight;
    394             }
    395             i++;
    396         }
    397         return firstPosition + mostVisibleIndex;
    398     }
    399 
    400     /**
    401      * Attempts to return the date that has accessibility focus.
    402      *
    403      * @return The date that has accessibility focus, or {@code null} if no date
    404      *         has focus.
    405      */
    406     private Calendar findAccessibilityFocus() {
    407         final int childCount = getChildCount();
    408         for (int i = 0; i < childCount; i++) {
    409             final View child = getChildAt(i);
    410             if (child instanceof SimpleMonthView) {
    411                 final Calendar focus = ((SimpleMonthView) child).getAccessibilityFocus();
    412                 if (focus != null) {
    413                     return focus;
    414                 }
    415             }
    416         }
    417 
    418         return null;
    419     }
    420 
    421     /**
    422      * Attempts to restore accessibility focus to a given date. No-op if
    423      * {@code day} is {@code null}.
    424      *
    425      * @param day The date that should receive accessibility focus
    426      * @return {@code true} if focus was restored
    427      */
    428     private boolean restoreAccessibilityFocus(Calendar day) {
    429         if (day == null) {
    430             return false;
    431         }
    432 
    433         final int childCount = getChildCount();
    434         for (int i = 0; i < childCount; i++) {
    435             final View child = getChildAt(i);
    436             if (child instanceof SimpleMonthView) {
    437                 if (((SimpleMonthView) child).restoreAccessibilityFocus(day)) {
    438                     return true;
    439                 }
    440             }
    441         }
    442 
    443         return false;
    444     }
    445 
    446     @Override
    447     protected void layoutChildren() {
    448         final Calendar focusedDay = findAccessibilityFocus();
    449         super.layoutChildren();
    450         if (mPerformingScroll) {
    451             mPerformingScroll = false;
    452         } else {
    453             restoreAccessibilityFocus(focusedDay);
    454         }
    455     }
    456 
    457     @Override
    458     protected void onConfigurationChanged(Configuration newConfig) {
    459         mYearFormat = new SimpleDateFormat("yyyy", Locale.getDefault());
    460     }
    461 
    462     @Override
    463     public void onInitializeAccessibilityEvent(AccessibilityEvent event) {
    464         super.onInitializeAccessibilityEvent(event);
    465         event.setItemCount(-1);
    466     }
    467 
    468     private String getMonthAndYearString(Calendar day) {
    469         final StringBuilder sbuf = new StringBuilder();
    470         sbuf.append(day.getDisplayName(Calendar.MONTH, Calendar.LONG, Locale.getDefault()));
    471         sbuf.append(" ");
    472         sbuf.append(mYearFormat.format(day.getTime()));
    473         return sbuf.toString();
    474     }
    475 
    476     /**
    477      * Necessary for accessibility, to ensure we support "scrolling" forward and backward
    478      * in the month list.
    479      */
    480     @Override
    481     public void onInitializeAccessibilityNodeInfo(AccessibilityNodeInfo info) {
    482         super.onInitializeAccessibilityNodeInfo(info);
    483         info.addAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_SCROLL_FORWARD);
    484         info.addAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_SCROLL_BACKWARD);
    485     }
    486 
    487     /**
    488      * When scroll forward/backward events are received, announce the newly scrolled-to month.
    489      */
    490     @Override
    491     public boolean performAccessibilityAction(int action, Bundle arguments) {
    492         if (action != AccessibilityNodeInfo.ACTION_SCROLL_FORWARD &&
    493                 action != AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD) {
    494             return super.performAccessibilityAction(action, arguments);
    495         }
    496 
    497         // Figure out what month is showing.
    498         final int firstVisiblePosition = getFirstVisiblePosition();
    499         final int month = firstVisiblePosition % 12;
    500         final int year = firstVisiblePosition / 12 + mMinDate.get(Calendar.YEAR);
    501         final Calendar day = Calendar.getInstance();
    502         day.set(year, month, 1);
    503 
    504         // Scroll either forward or backward one month.
    505         if (action == AccessibilityNodeInfo.ACTION_SCROLL_FORWARD) {
    506             day.add(Calendar.MONTH, 1);
    507             if (day.get(Calendar.MONTH) == 12) {
    508                 day.set(Calendar.MONTH, 0);
    509                 day.add(Calendar.YEAR, 1);
    510             }
    511         } else if (action == AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD) {
    512             View firstVisibleView = getChildAt(0);
    513             // If the view is fully visible, jump one month back. Otherwise, we'll just jump
    514             // to the first day of first visible month.
    515             if (firstVisibleView != null && firstVisibleView.getTop() >= -1) {
    516                 // There's an off-by-one somewhere, so the top of the first visible item will
    517                 // actually be -1 when it's at the exact top.
    518                 day.add(Calendar.MONTH, -1);
    519                 if (day.get(Calendar.MONTH) == -1) {
    520                     day.set(Calendar.MONTH, 11);
    521                     day.add(Calendar.YEAR, -1);
    522                 }
    523             }
    524         }
    525 
    526         // Go to that month.
    527         announceForAccessibility(getMonthAndYearString(day));
    528         goTo(day.getTimeInMillis(), true, false, true);
    529         mPerformingScroll = true;
    530         return true;
    531     }
    532 
    533     public interface OnDaySelectedListener {
    534         public void onDaySelected(DayPickerView view, Calendar day);
    535     }
    536 
    537     private final SimpleMonthAdapter.OnDaySelectedListener
    538             mProxyOnDaySelectedListener = new SimpleMonthAdapter.OnDaySelectedListener() {
    539         @Override
    540         public void onDaySelected(SimpleMonthAdapter adapter, Calendar day) {
    541             if (mOnDaySelectedListener != null) {
    542                 mOnDaySelectedListener.onDaySelected(DayPickerView.this, day);
    543             }
    544         }
    545     };
    546 }
    547