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