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