• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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 
DayPickerView(Context context, DatePickerController controller)76     public DayPickerView(Context context, DatePickerController controller) {
77         super(context);
78         init();
79         setController(controller);
80     }
81 
setController(DatePickerController controller)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 
init()93     public void init() {
94         setLayoutParams(new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT));
95         setDrawSelectorOnTop(false);
96 
97         setUpListView();
98     }
99 
onChange()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      */
setUpAdapter()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      */
setUpListView()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 
getDiffMonths(Calendar start, Calendar end)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 
getPositionFromDay(Calendar day)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      */
goTo(Calendar day, boolean animate, boolean setSelected, boolean forceScroll)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 
postSetSelection(final int position)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
onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount, int totalItemCount)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      */
setMonthDisplayed(Calendar date)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
onScrollStateChanged(AbsListView view, int scrollState)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 
setCalendarTextColor(ColorStateList colors)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 
ScrollStateRunnable(View view)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          */
doScrollStateChange(AbsListView view, int scrollState)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
run()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      */
getMostVisiblePosition()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
onDateChanged()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      */
findAccessibilityFocus()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      */
restoreAccessibilityFocus(Calendar day)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
layoutChildren()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
onConfigurationChanged(Configuration newConfig)427     protected void onConfigurationChanged(Configuration newConfig) {
428         mYearFormat = new SimpleDateFormat("yyyy", Locale.getDefault());
429     }
430 
431     @Override
onInitializeAccessibilityEvent(AccessibilityEvent event)432     public void onInitializeAccessibilityEvent(AccessibilityEvent event) {
433         super.onInitializeAccessibilityEvent(event);
434         event.setItemCount(-1);
435     }
436 
getMonthAndYearString(Calendar day)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
onInitializeAccessibilityNodeInfo(AccessibilityNodeInfo info)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
performAccessibilityAction(int action, Bundle arguments)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