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