DayPickerView.java revision 4612740ddc76b3518dc6d189d5f8b5b7f60e9d64
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
17package android.widget;
18
19import android.content.Context;
20import android.content.res.ColorStateList;
21import android.content.res.Configuration;
22import android.os.Bundle;
23import android.util.Log;
24import android.util.MathUtils;
25import android.view.View;
26import android.view.ViewConfiguration;
27import android.view.accessibility.AccessibilityEvent;
28import android.view.accessibility.AccessibilityNodeInfo;
29
30import java.text.SimpleDateFormat;
31import java.util.Calendar;
32import java.util.Locale;
33
34/**
35 * This displays a list of months in a calendar format with selectable days.
36 */
37class 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