DayPickerView.java revision 5ecbfeb38b6bdcfe8f3561f8cdcb4af9ba30c886
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.annotation.SuppressLint;
20import android.content.Context;
21import android.content.res.ColorStateList;
22import android.content.res.Configuration;
23import android.os.Build;
24import android.os.Bundle;
25import android.os.Handler;
26import android.util.AttributeSet;
27import android.util.Log;
28import android.util.MathUtils;
29import android.view.View;
30import android.view.ViewConfiguration;
31import android.view.accessibility.AccessibilityEvent;
32import android.view.accessibility.AccessibilityNodeInfo;
33
34import java.text.SimpleDateFormat;
35import java.util.Calendar;
36import java.util.Locale;
37
38/**
39 * This displays a list of months in a calendar format with selectable days.
40 */
41class DayPickerView extends ListView implements AbsListView.OnScrollListener,
42        OnDateChangedListener {
43
44    private static final String TAG = "DayPickerView";
45
46    // How long the GoTo fling animation should last
47    private static final int GOTO_SCROLL_DURATION = 250;
48
49    // How long to wait after receiving an onScrollStateChanged notification before acting on it
50    private static final int SCROLL_CHANGE_DELAY = 40;
51
52    private static int LIST_TOP_OFFSET = -1; // so that the top line will be under the separator
53
54    private SimpleDateFormat mYearFormat = new SimpleDateFormat("yyyy", Locale.getDefault());
55
56    // These affect the scroll speed and feel
57    private float mFriction = 1.0f;
58
59    // highlighted time
60    private Calendar mSelectedDay = Calendar.getInstance();
61    private Calendar mTempDay = Calendar.getInstance();
62    private Calendar mMinDate = Calendar.getInstance();
63    private Calendar mMaxDate = Calendar.getInstance();
64
65    private SimpleMonthAdapter mAdapter;
66
67    // which month should be displayed/highlighted [0-11]
68    private int mCurrentMonthDisplayed;
69    // used for tracking what state listview is in
70    private int mPreviousScrollState = OnScrollListener.SCROLL_STATE_IDLE;
71    // used for tracking what state listview is in
72    private int mCurrentScrollState = OnScrollListener.SCROLL_STATE_IDLE;
73
74    private DatePickerController mController;
75    private boolean mPerformingScroll;
76
77    private ScrollStateRunnable mScrollStateChangedRunnable = new ScrollStateRunnable(this);
78
79    public DayPickerView(Context context, DatePickerController controller) {
80        super(context);
81
82        init();
83        setController(controller);
84    }
85
86    public void setController(DatePickerController controller) {
87        if (mController != null) {
88            mController.unregisterOnDateChangedListener(this);
89        }
90        mController = controller;
91        mController.registerOnDateChangedListener(this);
92        setUpAdapter();
93        setAdapter(mAdapter);
94        onDateChanged();
95    }
96
97    public void init() {
98        setLayoutParams(new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT));
99        setDrawSelectorOnTop(false);
100
101        setUpListView();
102    }
103
104    public void setRange(Calendar minDate, Calendar maxDate) {
105        mMinDate.setTimeInMillis(minDate.getTimeInMillis());
106        mMaxDate.setTimeInMillis(maxDate.getTimeInMillis());
107
108        mAdapter.setRange(mMinDate, mMaxDate);
109
110        // Changing the min/max date changes the selection position since we
111        // don't really have stable IDs.
112        goTo(mSelectedDay, false, true, true);
113    }
114
115    /**
116     * Constrains the supplied calendar to stay within the min and max
117     * calendars, returning <code>true</code> if the supplied calendar
118     * was modified.
119     *
120     * @param value The calendar to constrain
121     * @param min The minimum calendar
122     * @param max The maximum calendar
123     * @return True if <code>value</code> was modified
124     */
125    private boolean constrainCalendar(Calendar value, Calendar min, Calendar max) {
126        if (value.compareTo(min) < 0) {
127            value.setTimeInMillis(min.getTimeInMillis());
128            return true;
129        }
130
131        if (value.compareTo(max) > 0) {
132            value.setTimeInMillis(max.getTimeInMillis());
133            return true;
134        }
135
136        return false;
137    }
138
139    public void onChange() {
140        setUpAdapter();
141        setAdapter(mAdapter);
142    }
143
144    /**
145     * Creates a new adapter if necessary and sets up its parameters. Override
146     * this method to provide a custom adapter.
147     */
148    protected void setUpAdapter() {
149        if (mAdapter == null) {
150            mAdapter = new SimpleMonthAdapter(getContext(), mController);
151        } else {
152            mAdapter.setSelectedDay(mSelectedDay);
153            mAdapter.notifyDataSetChanged();
154        }
155        // refresh the view with the new parameters
156        mAdapter.notifyDataSetChanged();
157    }
158
159    /*
160     * Sets all the required fields for the list view. Override this method to
161     * set a different list view behavior.
162     */
163    protected void setUpListView() {
164        // Transparent background on scroll
165        setCacheColorHint(0);
166        // No dividers
167        setDivider(null);
168        // Items are clickable
169        setItemsCanFocus(true);
170        // The thumb gets in the way, so disable it
171        setFastScrollEnabled(false);
172        setVerticalScrollBarEnabled(false);
173        setOnScrollListener(this);
174        setFadingEdgeLength(0);
175        // Make the scrolling behavior nicer
176        setFriction(ViewConfiguration.getScrollFriction() * mFriction);
177    }
178
179    private int getDiffMonths(Calendar start, Calendar end) {
180        final int diffYears = end.get(Calendar.YEAR) - start.get(Calendar.YEAR);
181        final int diffMonths = end.get(Calendar.MONTH) - start.get(Calendar.MONTH) + 12 * diffYears;
182        return diffMonths;
183    }
184
185    private int getPositionFromDay(Calendar day) {
186        final int diffMonthMax = getDiffMonths(mMinDate, mMaxDate);
187        final int diffMonth = getDiffMonths(mMinDate, day);
188        return MathUtils.constrain(diffMonth, 0, diffMonthMax);
189    }
190
191    /**
192     * This moves to the specified time in the view. If the time is not already
193     * in range it will move the list so that the first of the month containing
194     * the time is at the top of the view. If the new time is already in view
195     * the list will not be scrolled unless forceScroll is true. This time may
196     * optionally be highlighted as selected as well.
197     *
198     * @param day The day to move to
199     * @param animate Whether to scroll to the given time or just redraw at the
200     *            new location
201     * @param setSelected Whether to set the given time as selected
202     * @param forceScroll Whether to recenter even if the time is already
203     *            visible
204     * @return Whether or not the view animated to the new location
205     */
206    public boolean goTo(Calendar day, boolean animate, boolean setSelected, boolean forceScroll) {
207
208        // Set the selected day
209        if (setSelected) {
210            mSelectedDay.setTimeInMillis(day.getTimeInMillis());
211        }
212
213        mTempDay.setTimeInMillis(day.getTimeInMillis());
214        final int position = getPositionFromDay(day);
215
216        View child;
217        int i = 0;
218        int top = 0;
219        // Find a child that's completely in the view
220        do {
221            child = getChildAt(i++);
222            if (child == null) {
223                break;
224            }
225            top = child.getTop();
226        } while (top < 0);
227
228        // Compute the first and last position visible
229        int selectedPosition;
230        if (child != null) {
231            selectedPosition = getPositionForView(child);
232        } else {
233            selectedPosition = 0;
234        }
235
236        if (setSelected) {
237            mAdapter.setSelectedDay(mSelectedDay);
238        }
239
240        // Check if the selected day is now outside of our visible range
241        // and if so scroll to the month that contains it
242        if (position != selectedPosition || forceScroll) {
243            setMonthDisplayed(mTempDay);
244            mPreviousScrollState = OnScrollListener.SCROLL_STATE_FLING;
245            if (animate) {
246                smoothScrollToPositionFromTop(
247                        position, LIST_TOP_OFFSET, GOTO_SCROLL_DURATION);
248                return true;
249            } else {
250                postSetSelection(position);
251            }
252        } else if (setSelected) {
253            setMonthDisplayed(mSelectedDay);
254        }
255        return false;
256    }
257
258    public void postSetSelection(final int position) {
259        clearFocus();
260        post(new Runnable() {
261
262            @Override
263            public void run() {
264                setSelection(position);
265            }
266        });
267        onScrollStateChanged(this, OnScrollListener.SCROLL_STATE_IDLE);
268    }
269
270    /**
271     * Updates the title and selected month if the view has moved to a new
272     * month.
273     */
274    @Override
275    public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount,
276                         int totalItemCount) {
277        SimpleMonthView child = (SimpleMonthView) view.getChildAt(0);
278        if (child == null) {
279            return;
280        }
281
282        mPreviousScrollState = mCurrentScrollState;
283    }
284
285    /**
286     * Sets the month displayed at the top of this view based on time. Override
287     * to add custom events when the title is changed.
288     */
289    protected void setMonthDisplayed(Calendar date) {
290        if (mCurrentMonthDisplayed != date.get(Calendar.MONTH)) {
291            mCurrentMonthDisplayed = date.get(Calendar.MONTH);
292            invalidateViews();
293        }
294    }
295
296    @Override
297    public void onScrollStateChanged(AbsListView view, int scrollState) {
298        // use a post to prevent re-entering onScrollStateChanged before it
299        // exits
300        mScrollStateChangedRunnable.doScrollStateChange(view, scrollState);
301    }
302
303    void setCalendarTextColor(ColorStateList colors) {
304        mAdapter.setCalendarTextColor(colors);
305    }
306
307    protected class ScrollStateRunnable implements Runnable {
308        private int mNewState;
309        private View mParent;
310
311        ScrollStateRunnable(View view) {
312            mParent = view;
313        }
314
315        /**
316         * Sets up the runnable with a short delay in case the scroll state
317         * immediately changes again.
318         *
319         * @param view The list view that changed state
320         * @param scrollState The new state it changed to
321         */
322        public void doScrollStateChange(AbsListView view, int scrollState) {
323            mParent.removeCallbacks(this);
324            mNewState = scrollState;
325            mParent.postDelayed(this, SCROLL_CHANGE_DELAY);
326        }
327
328        @Override
329        public void run() {
330            mCurrentScrollState = mNewState;
331            if (Log.isLoggable(TAG, Log.DEBUG)) {
332                Log.d(TAG,
333                        "new scroll state: " + mNewState + " old state: " + mPreviousScrollState);
334            }
335            // Fix the position after a scroll or a fling ends
336            if (mNewState == OnScrollListener.SCROLL_STATE_IDLE
337                    && mPreviousScrollState != OnScrollListener.SCROLL_STATE_IDLE
338                    && mPreviousScrollState != OnScrollListener.SCROLL_STATE_TOUCH_SCROLL) {
339                mPreviousScrollState = mNewState;
340                int i = 0;
341                View child = getChildAt(i);
342                while (child != null && child.getBottom() <= 0) {
343                    child = getChildAt(++i);
344                }
345                if (child == null) {
346                    // The view is no longer visible, just return
347                    return;
348                }
349                int firstPosition = getFirstVisiblePosition();
350                int lastPosition = getLastVisiblePosition();
351                boolean scroll = firstPosition != 0 && lastPosition != getCount() - 1;
352                final int top = child.getTop();
353                final int bottom = child.getBottom();
354                final int midpoint = getHeight() / 2;
355                if (scroll && top < LIST_TOP_OFFSET) {
356                    if (bottom > midpoint) {
357                        smoothScrollBy(top, GOTO_SCROLL_DURATION);
358                    } else {
359                        smoothScrollBy(bottom, GOTO_SCROLL_DURATION);
360                    }
361                }
362            } else {
363                mPreviousScrollState = mNewState;
364            }
365        }
366    }
367
368    /**
369     * Gets the position of the view that is most prominently displayed within the list view.
370     */
371    public int getMostVisiblePosition() {
372        final int firstPosition = getFirstVisiblePosition();
373        final int height = getHeight();
374
375        int maxDisplayedHeight = 0;
376        int mostVisibleIndex = 0;
377        int i=0;
378        int bottom = 0;
379        while (bottom < height) {
380            View child = getChildAt(i);
381            if (child == null) {
382                break;
383            }
384            bottom = child.getBottom();
385            int displayedHeight = Math.min(bottom, height) - Math.max(0, child.getTop());
386            if (displayedHeight > maxDisplayedHeight) {
387                mostVisibleIndex = i;
388                maxDisplayedHeight = displayedHeight;
389            }
390            i++;
391        }
392        return firstPosition + mostVisibleIndex;
393    }
394
395    @Override
396    public void onDateChanged() {
397        goTo(mController.getSelectedDay(), false, true, true);
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        StringBuffer sbuf = new StringBuffer();
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.ACTION_SCROLL_FORWARD);
484        info.addAction(AccessibilityNodeInfo.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, true, false, true);
529        mPerformingScroll = true;
530        return true;
531    }
532}
533