DayPickerView.java revision e763c9bd6ed0ca46daafc21fc4313ebcad4bcafa
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 OnDaySelectedListener mOnDaySelectedListener;
62
63    // which month should be displayed/highlighted [0-11]
64    private int mCurrentMonthDisplayed;
65    // used for tracking what state listview is in
66    private int mPreviousScrollState = OnScrollListener.SCROLL_STATE_IDLE;
67    // used for tracking what state listview is in
68    private int mCurrentScrollState = OnScrollListener.SCROLL_STATE_IDLE;
69
70    private boolean mPerformingScroll;
71
72    public DayPickerView(Context context) {
73        super(context);
74
75        setAdapter(mAdapter);
76        setLayoutParams(new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT));
77        setDrawSelectorOnTop(false);
78        setUpListView();
79
80        goTo(mSelectedDay, false, true, true);
81
82        mAdapter.setOnDaySelectedListener(mProxyOnDaySelectedListener);
83    }
84
85    public void setDay(Calendar day) {
86        goTo(day, false, true, true);
87    }
88
89    public void setFirstDayOfWeek(int firstDayOfWeek) {
90        mAdapter.setFirstDayOfWeek(firstDayOfWeek);
91    }
92
93    public void setRange(Calendar minDate, Calendar maxDate) {
94        mMinDate.setTimeInMillis(minDate.getTimeInMillis());
95        mMaxDate.setTimeInMillis(maxDate.getTimeInMillis());
96
97        mAdapter.setRange(mMinDate, mMaxDate);
98
99        // Changing the min/max date changes the selection position since we
100        // don't really have stable IDs.
101        goTo(mSelectedDay, false, true, true);
102    }
103
104    /**
105     * Sets the listener to call when the user selects a day.
106     *
107     * @param listener The listener to call.
108     */
109    public void setOnDaySelectedListener(OnDaySelectedListener listener) {
110        mOnDaySelectedListener = listener;
111    }
112
113    /*
114     * Sets all the required fields for the list view. Override this method to
115     * set a different list view behavior.
116     */
117    private void setUpListView() {
118        // Transparent background on scroll
119        setCacheColorHint(0);
120        // No dividers
121        setDivider(null);
122        // Items are clickable
123        setItemsCanFocus(true);
124        // The thumb gets in the way, so disable it
125        setFastScrollEnabled(false);
126        setVerticalScrollBarEnabled(false);
127        setOnScrollListener(this);
128        setFadingEdgeLength(0);
129        // Make the scrolling behavior nicer
130        setFriction(ViewConfiguration.getScrollFriction());
131    }
132
133    private int getDiffMonths(Calendar start, Calendar end) {
134        final int diffYears = end.get(Calendar.YEAR) - start.get(Calendar.YEAR);
135        final int diffMonths = end.get(Calendar.MONTH) - start.get(Calendar.MONTH) + 12 * diffYears;
136        return diffMonths;
137    }
138
139    private int getPositionFromDay(Calendar day) {
140        final int diffMonthMax = getDiffMonths(mMinDate, mMaxDate);
141        final int diffMonth = getDiffMonths(mMinDate, day);
142        return MathUtils.constrain(diffMonth, 0, diffMonthMax);
143    }
144
145    /**
146     * This moves to the specified time in the view. If the time is not already
147     * in range it will move the list so that the first of the month containing
148     * the time is at the top of the view. If the new time is already in view
149     * the list will not be scrolled unless forceScroll is true. This time may
150     * optionally be highlighted as selected as well.
151     *
152     * @param day The day to move to
153     * @param animate Whether to scroll to the given time or just redraw at the
154     *            new location
155     * @param setSelected Whether to set the given time as selected
156     * @param forceScroll Whether to recenter even if the time is already
157     *            visible
158     * @return Whether or not the view animated to the new location
159     */
160    private boolean goTo(Calendar day, boolean animate, boolean setSelected, boolean forceScroll) {
161
162        // Set the selected day
163        if (setSelected) {
164            mSelectedDay.setTimeInMillis(day.getTimeInMillis());
165        }
166
167        mTempDay.setTimeInMillis(day.getTimeInMillis());
168        final int position = getPositionFromDay(day);
169
170        View child;
171        int i = 0;
172        int top = 0;
173        // Find a child that's completely in the view
174        do {
175            child = getChildAt(i++);
176            if (child == null) {
177                break;
178            }
179            top = child.getTop();
180        } while (top < 0);
181
182        // Compute the first and last position visible
183        int selectedPosition;
184        if (child != null) {
185            selectedPosition = getPositionForView(child);
186        } else {
187            selectedPosition = 0;
188        }
189
190        if (setSelected) {
191            mAdapter.setSelectedDay(mSelectedDay);
192        }
193
194        // Check if the selected day is now outside of our visible range
195        // and if so scroll to the month that contains it
196        if (position != selectedPosition || forceScroll) {
197            setMonthDisplayed(mTempDay);
198            mPreviousScrollState = OnScrollListener.SCROLL_STATE_FLING;
199            if (animate) {
200                smoothScrollToPositionFromTop(
201                        position, LIST_TOP_OFFSET, GOTO_SCROLL_DURATION);
202                return true;
203            } else {
204                postSetSelection(position);
205            }
206        } else if (setSelected) {
207            setMonthDisplayed(mSelectedDay);
208        }
209        return false;
210    }
211
212    public void postSetSelection(final int position) {
213        clearFocus();
214        post(new Runnable() {
215
216            @Override
217            public void run() {
218                setSelection(position);
219            }
220        });
221        onScrollStateChanged(this, OnScrollListener.SCROLL_STATE_IDLE);
222    }
223
224    /**
225     * Updates the title and selected month if the view has moved to a new
226     * month.
227     */
228    @Override
229    public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount,
230                         int totalItemCount) {
231        SimpleMonthView child = (SimpleMonthView) view.getChildAt(0);
232        if (child == null) {
233            return;
234        }
235
236        mPreviousScrollState = mCurrentScrollState;
237    }
238
239    /**
240     * Sets the month displayed at the top of this view based on time. Override
241     * to add custom events when the title is changed.
242     */
243    protected void setMonthDisplayed(Calendar date) {
244        if (mCurrentMonthDisplayed != date.get(Calendar.MONTH)) {
245            mCurrentMonthDisplayed = date.get(Calendar.MONTH);
246            invalidateViews();
247        }
248    }
249
250    @Override
251    public void onScrollStateChanged(AbsListView view, int scrollState) {
252        // use a post to prevent re-entering onScrollStateChanged before it
253        // exits
254        mScrollStateChangedRunnable.doScrollStateChange(view, scrollState);
255    }
256
257    void setCalendarTextColor(ColorStateList colors) {
258        mAdapter.setCalendarTextColor(colors);
259    }
260
261    protected class ScrollStateRunnable implements Runnable {
262        private int mNewState;
263        private View mParent;
264
265        ScrollStateRunnable(View view) {
266            mParent = view;
267        }
268
269        /**
270         * Sets up the runnable with a short delay in case the scroll state
271         * immediately changes again.
272         *
273         * @param view The list view that changed state
274         * @param scrollState The new state it changed to
275         */
276        public void doScrollStateChange(AbsListView view, int scrollState) {
277            mParent.removeCallbacks(this);
278            mNewState = scrollState;
279            mParent.postDelayed(this, SCROLL_CHANGE_DELAY);
280        }
281
282        @Override
283        public void run() {
284            mCurrentScrollState = mNewState;
285            if (Log.isLoggable(TAG, Log.DEBUG)) {
286                Log.d(TAG,
287                        "new scroll state: " + mNewState + " old state: " + mPreviousScrollState);
288            }
289            // Fix the position after a scroll or a fling ends
290            if (mNewState == OnScrollListener.SCROLL_STATE_IDLE
291                    && mPreviousScrollState != OnScrollListener.SCROLL_STATE_IDLE
292                    && mPreviousScrollState != OnScrollListener.SCROLL_STATE_TOUCH_SCROLL) {
293                mPreviousScrollState = mNewState;
294                int i = 0;
295                View child = getChildAt(i);
296                while (child != null && child.getBottom() <= 0) {
297                    child = getChildAt(++i);
298                }
299                if (child == null) {
300                    // The view is no longer visible, just return
301                    return;
302                }
303                int firstPosition = getFirstVisiblePosition();
304                int lastPosition = getLastVisiblePosition();
305                boolean scroll = firstPosition != 0 && lastPosition != getCount() - 1;
306                final int top = child.getTop();
307                final int bottom = child.getBottom();
308                final int midpoint = getHeight() / 2;
309                if (scroll && top < LIST_TOP_OFFSET) {
310                    if (bottom > midpoint) {
311                        smoothScrollBy(top, GOTO_SCROLL_DURATION);
312                    } else {
313                        smoothScrollBy(bottom, GOTO_SCROLL_DURATION);
314                    }
315                }
316            } else {
317                mPreviousScrollState = mNewState;
318            }
319        }
320    }
321
322    /**
323     * Gets the position of the view that is most prominently displayed within the list view.
324     */
325    public int getMostVisiblePosition() {
326        final int firstPosition = getFirstVisiblePosition();
327        final int height = getHeight();
328
329        int maxDisplayedHeight = 0;
330        int mostVisibleIndex = 0;
331        int i=0;
332        int bottom = 0;
333        while (bottom < height) {
334            View child = getChildAt(i);
335            if (child == null) {
336                break;
337            }
338            bottom = child.getBottom();
339            int displayedHeight = Math.min(bottom, height) - Math.max(0, child.getTop());
340            if (displayedHeight > maxDisplayedHeight) {
341                mostVisibleIndex = i;
342                maxDisplayedHeight = displayedHeight;
343            }
344            i++;
345        }
346        return firstPosition + mostVisibleIndex;
347    }
348
349    /**
350     * Attempts to return the date that has accessibility focus.
351     *
352     * @return The date that has accessibility focus, or {@code null} if no date
353     *         has focus.
354     */
355    private Calendar findAccessibilityFocus() {
356        final int childCount = getChildCount();
357        for (int i = 0; i < childCount; i++) {
358            final View child = getChildAt(i);
359            if (child instanceof SimpleMonthView) {
360                final Calendar focus = ((SimpleMonthView) child).getAccessibilityFocus();
361                if (focus != null) {
362                    return focus;
363                }
364            }
365        }
366
367        return null;
368    }
369
370    /**
371     * Attempts to restore accessibility focus to a given date. No-op if
372     * {@code day} is {@code null}.
373     *
374     * @param day The date that should receive accessibility focus
375     * @return {@code true} if focus was restored
376     */
377    private boolean restoreAccessibilityFocus(Calendar day) {
378        if (day == null) {
379            return false;
380        }
381
382        final int childCount = getChildCount();
383        for (int i = 0; i < childCount; i++) {
384            final View child = getChildAt(i);
385            if (child instanceof SimpleMonthView) {
386                if (((SimpleMonthView) child).restoreAccessibilityFocus(day)) {
387                    return true;
388                }
389            }
390        }
391
392        return false;
393    }
394
395    @Override
396    protected void layoutChildren() {
397        final Calendar focusedDay = findAccessibilityFocus();
398        super.layoutChildren();
399        if (mPerformingScroll) {
400            mPerformingScroll = false;
401        } else {
402            restoreAccessibilityFocus(focusedDay);
403        }
404    }
405
406    @Override
407    protected void onConfigurationChanged(Configuration newConfig) {
408        mYearFormat = new SimpleDateFormat("yyyy", Locale.getDefault());
409    }
410
411    @Override
412    public void onInitializeAccessibilityEvent(AccessibilityEvent event) {
413        super.onInitializeAccessibilityEvent(event);
414        event.setItemCount(-1);
415    }
416
417    private String getMonthAndYearString(Calendar day) {
418        StringBuffer sbuf = new StringBuffer();
419        sbuf.append(day.getDisplayName(Calendar.MONTH, Calendar.LONG, Locale.getDefault()));
420        sbuf.append(" ");
421        sbuf.append(mYearFormat.format(day.getTime()));
422        return sbuf.toString();
423    }
424
425    /**
426     * Necessary for accessibility, to ensure we support "scrolling" forward and backward
427     * in the month list.
428     */
429    @Override
430    public void onInitializeAccessibilityNodeInfo(AccessibilityNodeInfo info) {
431        super.onInitializeAccessibilityNodeInfo(info);
432        info.addAction(AccessibilityNodeInfo.ACTION_SCROLL_FORWARD);
433        info.addAction(AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD);
434    }
435
436    /**
437     * When scroll forward/backward events are received, announce the newly scrolled-to month.
438     */
439    @Override
440    public boolean performAccessibilityAction(int action, Bundle arguments) {
441        if (action != AccessibilityNodeInfo.ACTION_SCROLL_FORWARD &&
442                action != AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD) {
443            return super.performAccessibilityAction(action, arguments);
444        }
445
446        // Figure out what month is showing.
447        final int firstVisiblePosition = getFirstVisiblePosition();
448        final int month = firstVisiblePosition % 12;
449        final int year = firstVisiblePosition / 12 + mMinDate.get(Calendar.YEAR);
450        final Calendar day = Calendar.getInstance();
451        day.set(year, month, 1);
452
453        // Scroll either forward or backward one month.
454        if (action == AccessibilityNodeInfo.ACTION_SCROLL_FORWARD) {
455            day.add(Calendar.MONTH, 1);
456            if (day.get(Calendar.MONTH) == 12) {
457                day.set(Calendar.MONTH, 0);
458                day.add(Calendar.YEAR, 1);
459            }
460        } else if (action == AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD) {
461            View firstVisibleView = getChildAt(0);
462            // If the view is fully visible, jump one month back. Otherwise, we'll just jump
463            // to the first day of first visible month.
464            if (firstVisibleView != null && firstVisibleView.getTop() >= -1) {
465                // There's an off-by-one somewhere, so the top of the first visible item will
466                // actually be -1 when it's at the exact top.
467                day.add(Calendar.MONTH, -1);
468                if (day.get(Calendar.MONTH) == -1) {
469                    day.set(Calendar.MONTH, 11);
470                    day.add(Calendar.YEAR, -1);
471                }
472            }
473        }
474
475        // Go to that month.
476        announceForAccessibility(getMonthAndYearString(day));
477        goTo(day, true, false, true);
478        mPerformingScroll = true;
479        return true;
480    }
481
482    public interface OnDaySelectedListener {
483        public void onDaySelected(DayPickerView view, Calendar day);
484    }
485
486    private final SimpleMonthAdapter.OnDaySelectedListener
487            mProxyOnDaySelectedListener = new SimpleMonthAdapter.OnDaySelectedListener() {
488        @Override
489        public void onDaySelected(SimpleMonthAdapter adapter, Calendar day) {
490            if (mOnDaySelectedListener != null) {
491                mOnDaySelectedListener.onDaySelected(DayPickerView.this, day);
492            }
493        }
494    };
495}
496