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