DayPickerView.java revision 2d4c476aca79cc0f5b6f1e8561afd4bcc808dd54
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 setCalendarDayBackgroundColor(ColorStateList dayBackgroundColor) {
309        mAdapter.setCalendarDayBackgroundColor(dayBackgroundColor);
310    }
311
312    void setCalendarTextAppearance(int resId) {
313        mAdapter.setCalendarTextAppearance(resId);
314    }
315
316    protected class ScrollStateRunnable implements Runnable {
317        private int mNewState;
318        private View mParent;
319
320        ScrollStateRunnable(View view) {
321            mParent = view;
322        }
323
324        /**
325         * Sets up the runnable with a short delay in case the scroll state
326         * immediately changes again.
327         *
328         * @param view The list view that changed state
329         * @param scrollState The new state it changed to
330         */
331        public void doScrollStateChange(AbsListView view, int scrollState) {
332            mParent.removeCallbacks(this);
333            mNewState = scrollState;
334            mParent.postDelayed(this, SCROLL_CHANGE_DELAY);
335        }
336
337        @Override
338        public void run() {
339            mCurrentScrollState = mNewState;
340            if (Log.isLoggable(TAG, Log.DEBUG)) {
341                Log.d(TAG,
342                        "new scroll state: " + mNewState + " old state: " + mPreviousScrollState);
343            }
344            // Fix the position after a scroll or a fling ends
345            if (mNewState == OnScrollListener.SCROLL_STATE_IDLE
346                    && mPreviousScrollState != OnScrollListener.SCROLL_STATE_IDLE
347                    && mPreviousScrollState != OnScrollListener.SCROLL_STATE_TOUCH_SCROLL) {
348                mPreviousScrollState = mNewState;
349                int i = 0;
350                View child = getChildAt(i);
351                while (child != null && child.getBottom() <= 0) {
352                    child = getChildAt(++i);
353                }
354                if (child == null) {
355                    // The view is no longer visible, just return
356                    return;
357                }
358                int firstPosition = getFirstVisiblePosition();
359                int lastPosition = getLastVisiblePosition();
360                boolean scroll = firstPosition != 0 && lastPosition != getCount() - 1;
361                final int top = child.getTop();
362                final int bottom = child.getBottom();
363                final int midpoint = getHeight() / 2;
364                if (scroll && top < LIST_TOP_OFFSET) {
365                    if (bottom > midpoint) {
366                        smoothScrollBy(top, GOTO_SCROLL_DURATION);
367                    } else {
368                        smoothScrollBy(bottom, GOTO_SCROLL_DURATION);
369                    }
370                }
371            } else {
372                mPreviousScrollState = mNewState;
373            }
374        }
375    }
376
377    /**
378     * Gets the position of the view that is most prominently displayed within the list view.
379     */
380    public int getMostVisiblePosition() {
381        final int firstPosition = getFirstVisiblePosition();
382        final int height = getHeight();
383
384        int maxDisplayedHeight = 0;
385        int mostVisibleIndex = 0;
386        int i=0;
387        int bottom = 0;
388        while (bottom < height) {
389            View child = getChildAt(i);
390            if (child == null) {
391                break;
392            }
393            bottom = child.getBottom();
394            int displayedHeight = Math.min(bottom, height) - Math.max(0, child.getTop());
395            if (displayedHeight > maxDisplayedHeight) {
396                mostVisibleIndex = i;
397                maxDisplayedHeight = displayedHeight;
398            }
399            i++;
400        }
401        return firstPosition + mostVisibleIndex;
402    }
403
404    /**
405     * Attempts to return the date that has accessibility focus.
406     *
407     * @return The date that has accessibility focus, or {@code null} if no date
408     *         has focus.
409     */
410    private Calendar findAccessibilityFocus() {
411        final int childCount = getChildCount();
412        for (int i = 0; i < childCount; i++) {
413            final View child = getChildAt(i);
414            if (child instanceof SimpleMonthView) {
415                final Calendar focus = ((SimpleMonthView) child).getAccessibilityFocus();
416                if (focus != null) {
417                    return focus;
418                }
419            }
420        }
421
422        return null;
423    }
424
425    /**
426     * Attempts to restore accessibility focus to a given date. No-op if
427     * {@code day} is {@code null}.
428     *
429     * @param day The date that should receive accessibility focus
430     * @return {@code true} if focus was restored
431     */
432    private boolean restoreAccessibilityFocus(Calendar day) {
433        if (day == null) {
434            return false;
435        }
436
437        final int childCount = getChildCount();
438        for (int i = 0; i < childCount; i++) {
439            final View child = getChildAt(i);
440            if (child instanceof SimpleMonthView) {
441                if (((SimpleMonthView) child).restoreAccessibilityFocus(day)) {
442                    return true;
443                }
444            }
445        }
446
447        return false;
448    }
449
450    @Override
451    protected void layoutChildren() {
452        final Calendar focusedDay = findAccessibilityFocus();
453        super.layoutChildren();
454        if (mPerformingScroll) {
455            mPerformingScroll = false;
456        } else {
457            restoreAccessibilityFocus(focusedDay);
458        }
459    }
460
461    @Override
462    protected void onConfigurationChanged(Configuration newConfig) {
463        mYearFormat = new SimpleDateFormat("yyyy", Locale.getDefault());
464    }
465
466    /** @hide */
467    @Override
468    public void onInitializeAccessibilityEventInternal(AccessibilityEvent event) {
469        super.onInitializeAccessibilityEventInternal(event);
470        event.setItemCount(-1);
471    }
472
473    private String getMonthAndYearString(Calendar day) {
474        final StringBuilder sbuf = new StringBuilder();
475        sbuf.append(day.getDisplayName(Calendar.MONTH, Calendar.LONG, Locale.getDefault()));
476        sbuf.append(" ");
477        sbuf.append(mYearFormat.format(day.getTime()));
478        return sbuf.toString();
479    }
480
481    /**
482     * Necessary for accessibility, to ensure we support "scrolling" forward and backward
483     * in the month list.
484     *
485     * @hide
486     */
487    @Override
488    public void onInitializeAccessibilityNodeInfoInternal(AccessibilityNodeInfo info) {
489        super.onInitializeAccessibilityNodeInfoInternal(info);
490        info.addAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_SCROLL_FORWARD);
491        info.addAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_SCROLL_BACKWARD);
492    }
493
494    /**
495     * When scroll forward/backward events are received, announce the newly scrolled-to month.
496     *
497     * @hide
498     */
499    @Override
500    public boolean performAccessibilityActionInternal(int action, Bundle arguments) {
501        if (action != AccessibilityNodeInfo.ACTION_SCROLL_FORWARD &&
502                action != AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD) {
503            return super.performAccessibilityActionInternal(action, arguments);
504        }
505
506        // Figure out what month is showing.
507        final int firstVisiblePosition = getFirstVisiblePosition();
508        final int month = firstVisiblePosition % 12;
509        final int year = firstVisiblePosition / 12 + mMinDate.get(Calendar.YEAR);
510        final Calendar day = Calendar.getInstance();
511        day.set(year, month, 1);
512
513        // Scroll either forward or backward one month.
514        if (action == AccessibilityNodeInfo.ACTION_SCROLL_FORWARD) {
515            day.add(Calendar.MONTH, 1);
516            if (day.get(Calendar.MONTH) == 12) {
517                day.set(Calendar.MONTH, 0);
518                day.add(Calendar.YEAR, 1);
519            }
520        } else if (action == AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD) {
521            View firstVisibleView = getChildAt(0);
522            // If the view is fully visible, jump one month back. Otherwise, we'll just jump
523            // to the first day of first visible month.
524            if (firstVisibleView != null && firstVisibleView.getTop() >= -1) {
525                // There's an off-by-one somewhere, so the top of the first visible item will
526                // actually be -1 when it's at the exact top.
527                day.add(Calendar.MONTH, -1);
528                if (day.get(Calendar.MONTH) == -1) {
529                    day.set(Calendar.MONTH, 11);
530                    day.add(Calendar.YEAR, -1);
531                }
532            }
533        }
534
535        // Go to that month.
536        announceForAccessibility(getMonthAndYearString(day));
537        goTo(day.getTimeInMillis(), true, false, true);
538        mPerformingScroll = true;
539        return true;
540    }
541
542    public interface OnDaySelectedListener {
543        public void onDaySelected(DayPickerView view, Calendar day);
544    }
545
546    private final SimpleMonthAdapter.OnDaySelectedListener
547            mProxyOnDaySelectedListener = new SimpleMonthAdapter.OnDaySelectedListener() {
548        @Override
549        public void onDaySelected(SimpleMonthAdapter adapter, Calendar day) {
550            if (mOnDaySelectedListener != null) {
551                mOnDaySelectedListener.onDaySelected(DayPickerView.this, day);
552            }
553        }
554    };
555}
556