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