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 public 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 
DayPickerView(Context context, AttributeSet attrs)95     public DayPickerView(Context context, AttributeSet attrs) {
96         super(context, attrs);
97         init(context);
98     }
99 
DayPickerView(Context context, DatePickerController controller)100     public DayPickerView(Context context, DatePickerController controller) {
101         super(context);
102         init(context);
103         setController(controller);
104     }
105 
setController(DatePickerController controller)106     public void setController(DatePickerController controller) {
107         mController = controller;
108         mController.registerOnDateChangedListener(this);
109         refreshAdapter();
110         onDateChanged();
111     }
112 
init(Context context)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 
onChange()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      */
refreshAdapter()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 
createMonthAdapter(Context context, DatePickerController controller)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      */
setUpListView()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      */
goTo(CalendarDay day, boolean animate, boolean setSelected, boolean forceScroll)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 
postSetSelection(final int position)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
onScroll( AbsListView view, int firstVisibleItem, int visibleItemCount, int totalItemCount)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      */
setMonthDisplayed(CalendarDay date)271     protected void setMonthDisplayed(CalendarDay date) {
272         mCurrentMonthDisplayed = date.month;
273         invalidateViews();
274     }
275 
276     @Override
onScrollStateChanged(AbsListView view, int scrollState)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          */
doScrollStateChange(AbsListView view, int scrollState)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
run()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      */
getMostVisiblePosition()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
onDateChanged()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      */
findAccessibilityFocus()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      */
restoreAccessibilityFocus(CalendarDay day)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
layoutChildren()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
onInitializeAccessibilityEvent(AccessibilityEvent event)435     public void onInitializeAccessibilityEvent(AccessibilityEvent event) {
436         super.onInitializeAccessibilityEvent(event);
437         event.setItemCount(-1);
438    }
439 
getMonthAndYearString(CalendarDay day)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
onInitializeAccessibilityNodeInfo(AccessibilityNodeInfo info)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
performAccessibilityAction(int action, Bundle arguments)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