SimpleDayPickerFragment.java revision e0983f72aa6b33e8733699b6f72d67126fc785bd
1/*
2 * Copyright (C) 2010 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.calendar.month;
18
19import com.android.calendar.R;
20import com.android.calendar.Utils;
21
22import android.app.Activity;
23import android.app.ListFragment;
24import android.content.Context;
25import android.content.res.Resources;
26import android.database.DataSetObserver;
27import android.os.Bundle;
28import android.os.Handler;
29import android.text.TextUtils;
30import android.text.format.DateUtils;
31import android.text.format.Time;
32import android.util.Log;
33import android.view.LayoutInflater;
34import android.view.View;
35import android.view.ViewConfiguration;
36import android.view.ViewGroup;
37import android.view.accessibility.AccessibilityEvent;
38import android.widget.AbsListView;
39import android.widget.AbsListView.OnScrollListener;
40import android.widget.ListView;
41import android.widget.TextView;
42
43import java.util.Calendar;
44import java.util.HashMap;
45import java.util.Locale;
46
47/**
48 * <p>
49 * This displays a titled list of weeks with selectable days. It can be
50 * configured to display the week number, start the week on a given day, show a
51 * reduced number of days, or display an arbitrary number of weeks at a time. By
52 * overriding methods and changing variables this fragment can be customized to
53 * easily display a month selection component in a given style.
54 * </p>
55 */
56public class SimpleDayPickerFragment extends ListFragment implements OnScrollListener {
57
58    private static final String TAG = "MonthFragment";
59    private static final String KEY_CURRENT_TIME = "current_time";
60
61    // Affects when the month selection will change while scrolling up
62    protected static final int SCROLL_HYST_WEEKS = 2;
63    // How long the GoTo fling animation should last
64    protected static final int GOTO_SCROLL_DURATION = 1000;
65    // How long to wait after receiving an onScrollStateChanged notification
66    // before acting on it
67    protected static final int SCROLL_CHANGE_DELAY = 40;
68    // The number of days to display in each week
69    protected static final int DAYS_PER_WEEK = 7;
70    // The size of the month name displayed above the week list
71    protected static final int MINI_MONTH_NAME_TEXT_SIZE = 18;
72    protected static int LIST_TOP_OFFSET = -1;  // so that the top line will be under the separator
73    protected int WEEK_MIN_VISIBLE_HEIGHT = 12;
74    protected int BOTTOM_BUFFER = 20;
75    protected int mSaturdayColor = 0;
76    protected int mSundayColor = 0;
77    protected int mDayNameColor = 0;
78
79    // You can override these numbers to get a different appearance
80    protected int mNumWeeks = 6;
81    protected boolean mShowWeekNumber = false;
82    protected int mDaysPerWeek = 7;
83
84    // These affect the scroll speed and feel
85    protected float mFriction = .05f;
86    protected float mVelocityScale = 0.333f;
87
88    protected Context mContext;
89    protected Handler mHandler;
90
91    protected float mMinimumFlingVelocity;
92
93    // highlighted time
94    protected Time mSelectedDay = new Time();
95    protected SimpleWeeksAdapter mAdapter;
96    protected ListView mListView;
97    protected ViewGroup mDayNamesHeader;
98    protected String[] mDayLabels;
99
100    // disposable variable used for time calculations
101    protected Time mTempTime = new Time();
102
103    private static float mScale = 0;
104    // When the week starts; numbered like Time.<WEEKDAY> (e.g. SUNDAY=0).
105    protected int mFirstDayOfWeek;
106    // The first day of the focus month
107    protected Time mFirstDayOfMonth = new Time();
108    // The first day that is visible in the view
109    protected Time mFirstVisibleDay = new Time();
110    // The name of the month to display
111    protected TextView mMonthName;
112    // The last name announced by accessibility
113    protected CharSequence mPrevMonthName;
114    // which month should be displayed/highlighted [0-11]
115    protected int mCurrentMonthDisplayed;
116    // used for tracking during a scroll
117    protected long mPreviousScrollPosition;
118    // used for tracking which direction the view is scrolling
119    protected boolean mIsScrollingUp = false;
120    // used for tracking what state listview is in
121    protected int mPreviousScrollState = OnScrollListener.SCROLL_STATE_IDLE;
122    // used for tracking what state listview is in
123    protected int mCurrentScrollState = OnScrollListener.SCROLL_STATE_IDLE;
124
125    // This causes an update of the view at midnight
126    protected Runnable mTodayUpdater = new Runnable() {
127        @Override
128        public void run() {
129            Time midnight = new Time(mFirstVisibleDay.timezone);
130            midnight.setToNow();
131            long currentMillis = midnight.toMillis(true);
132
133            midnight.hour = 0;
134            midnight.minute = 0;
135            midnight.second = 0;
136            midnight.monthDay++;
137            long millisToMidnight = midnight.normalize(true) - currentMillis;
138            mHandler.postDelayed(this, millisToMidnight);
139
140            if (mAdapter != null) {
141                mAdapter.notifyDataSetChanged();
142            }
143        }
144    };
145
146    // This allows us to update our position when a day is tapped
147    protected DataSetObserver mObserver = new DataSetObserver() {
148        @Override
149        public void onChanged() {
150            Time day = mAdapter.getSelectedDay();
151            if (day.year != mSelectedDay.year || day.yearDay != mSelectedDay.yearDay) {
152                goTo(day.toMillis(true), true, true, false);
153            }
154        }
155    };
156
157    public SimpleDayPickerFragment(long initialTime) {
158        goTo(initialTime, false, true, true);
159        mHandler = new Handler();
160    }
161
162    @Override
163    public void onAttach(Activity activity) {
164        super.onAttach(activity);
165        mContext = activity;
166        String tz = Time.getCurrentTimezone();
167        ViewConfiguration viewConfig = ViewConfiguration.get(activity);
168        mMinimumFlingVelocity = viewConfig.getScaledMinimumFlingVelocity();
169
170        // Ensure we're in the correct time zone
171        mSelectedDay.switchTimezone(tz);
172        mSelectedDay.normalize(true);
173        mFirstDayOfMonth.timezone = tz;
174        mFirstDayOfMonth.normalize(true);
175        mFirstVisibleDay.timezone = tz;
176        mFirstVisibleDay.normalize(true);
177        mTempTime.timezone = tz;
178
179        Resources res = activity.getResources();
180        mSaturdayColor = res.getColor(R.color.month_saturday);
181        mSundayColor = res.getColor(R.color.month_sunday);
182        mDayNameColor = res.getColor(R.color.month_day_names_color);
183
184        // Adjust sizes for screen density
185        if (mScale == 0) {
186            mScale = activity.getResources().getDisplayMetrics().density;
187            if (mScale != 1) {
188                WEEK_MIN_VISIBLE_HEIGHT *= mScale;
189                BOTTOM_BUFFER *= mScale;
190                LIST_TOP_OFFSET *= mScale;
191            }
192        }
193        setUpAdapter();
194        setListAdapter(mAdapter);
195    }
196
197    /**
198     * Creates a new adapter if necessary and sets up its parameters. Override
199     * this method to provide a custom adapter.
200     */
201    protected void setUpAdapter() {
202        HashMap<String, Integer> weekParams = new HashMap<String, Integer>();
203        weekParams.put(SimpleWeeksAdapter.WEEK_PARAMS_NUM_WEEKS, mNumWeeks);
204        weekParams.put(SimpleWeeksAdapter.WEEK_PARAMS_SHOW_WEEK, mShowWeekNumber ? 1 : 0);
205        weekParams.put(SimpleWeeksAdapter.WEEK_PARAMS_WEEK_START, mFirstDayOfWeek);
206        weekParams.put(SimpleWeeksAdapter.WEEK_PARAMS_JULIAN_DAY,
207                Time.getJulianDay(mSelectedDay.toMillis(false), mSelectedDay.gmtoff));
208        if (mAdapter == null) {
209            mAdapter = new SimpleWeeksAdapter(getActivity(), weekParams);
210            mAdapter.registerDataSetObserver(mObserver);
211        } else {
212            mAdapter.updateParams(weekParams);
213        }
214        // refresh the view with the new parameters
215        mAdapter.notifyDataSetChanged();
216    }
217
218    @Override
219    public void onCreate(Bundle savedInstanceState) {
220        super.onCreate(savedInstanceState);
221        if (savedInstanceState != null && savedInstanceState.containsKey(KEY_CURRENT_TIME)) {
222            goTo(savedInstanceState.getLong(KEY_CURRENT_TIME), false, true, true);
223        }
224    }
225
226    @Override
227    public void onActivityCreated(Bundle savedInstanceState) {
228        super.onActivityCreated(savedInstanceState);
229
230        setUpListView();
231        setUpHeader();
232
233        mMonthName = (TextView) getView().findViewById(R.id.month_name);
234        SimpleWeekView child = (SimpleWeekView) mListView.getChildAt(0);
235        if (child == null) {
236            return;
237        }
238        int julianDay = child.getFirstJulianDay();
239        mFirstVisibleDay.setJulianDay(julianDay);
240        // set the title to the month of the second week
241        mTempTime.setJulianDay(julianDay + DAYS_PER_WEEK);
242        setMonthDisplayed(mTempTime, true);
243    }
244
245    /**
246     * Sets up the strings to be used by the header. Override this method to use
247     * different strings or modify the view params.
248     */
249    protected void setUpHeader() {
250        mDayLabels = new String[7];
251        for (int i = Calendar.SUNDAY; i <= Calendar.SATURDAY; i++) {
252            mDayLabels[i - Calendar.SUNDAY] = DateUtils.getDayOfWeekString(i,
253                    DateUtils.LENGTH_SHORTEST).toUpperCase();
254        }
255    }
256
257    /**
258     * Sets all the required fields for the list view. Override this method to
259     * set a different list view behavior.
260     */
261    protected void setUpListView() {
262        // Configure the listview
263        mListView = getListView();
264        // Transparent background on scroll
265        mListView.setCacheColorHint(0);
266        // No dividers
267        mListView.setDivider(null);
268        // Items are clickable
269        mListView.setItemsCanFocus(true);
270        // The thumb gets in the way, so disable it
271        mListView.setFastScrollEnabled(false);
272        mListView.setVerticalScrollBarEnabled(false);
273        mListView.setOnScrollListener(this);
274        mListView.setFadingEdgeLength(0);
275        // Make the scrolling behavior nicer
276        mListView.setFriction(mFriction);
277        mListView.setVelocityScale(mVelocityScale);
278    }
279
280    @Override
281    public void onResume() {
282        super.onResume();
283        doResumeUpdates();
284        setUpAdapter();
285    }
286
287    @Override
288    public void onPause() {
289        super.onPause();
290        mHandler.removeCallbacks(mTodayUpdater);
291    }
292
293    @Override
294    public void onSaveInstanceState(Bundle outState) {
295        outState.putLong(KEY_CURRENT_TIME, mSelectedDay.toMillis(true));
296    }
297
298    /**
299     * Updates the user preference fields. Override this to use a different
300     * preference space.
301     */
302    protected void doResumeUpdates() {
303        // Get default week start based on locale, subtracting one for use with android Time.
304        Calendar cal = Calendar.getInstance(Locale.getDefault());
305        mFirstDayOfWeek = cal.getFirstDayOfWeek() - 1;
306
307        mShowWeekNumber = false;
308
309        updateHeader();
310        goTo(mSelectedDay.toMillis(true), false, false, false);
311        mAdapter.setSelectedDay(mSelectedDay);
312        mTodayUpdater.run();
313    }
314
315    /**
316     * Fixes the day names header to provide correct spacing and updates the
317     * label text. Override this to set up a custom header.
318     */
319    protected void updateHeader() {
320        TextView label = (TextView) mDayNamesHeader.findViewById(R.id.wk_label);
321        if (mShowWeekNumber) {
322            label.setVisibility(View.VISIBLE);
323        } else {
324            label.setVisibility(View.GONE);
325        }
326        int offset = mFirstDayOfWeek - 1;
327        for (int i = 1; i < 8; i++) {
328            label = (TextView) mDayNamesHeader.getChildAt(i);
329            if (i < mDaysPerWeek + 1) {
330                int position = (offset + i) % 7;
331                label.setText(mDayLabels[position]);
332                label.setVisibility(View.VISIBLE);
333                if (position == Time.SATURDAY) {
334                    label.setTextColor(mSaturdayColor);
335                } else if (position == Time.SUNDAY) {
336                    label.setTextColor(mSundayColor);
337                } else {
338                    label.setTextColor(mDayNameColor);
339                }
340            } else {
341                label.setVisibility(View.GONE);
342            }
343        }
344        mDayNamesHeader.invalidate();
345    }
346
347    @Override
348    public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
349        View v = inflater.inflate(R.layout.month_by_week,
350                container, false);
351        mDayNamesHeader = (ViewGroup) v.findViewById(R.id.day_names);
352        return v;
353    }
354
355    /**
356     * Returns the UTC millis since epoch representation of the currently
357     * selected time.
358     *
359     * @return
360     */
361    public long getSelectedTime() {
362        return mSelectedDay.toMillis(true);
363    }
364
365    /**
366     * This moves to the specified time in the view. If the time is not already
367     * in range it will move the list so that the first of the month containing
368     * the time is at the top of the view. If the new time is already in view
369     * the list will not be scrolled unless forceScroll is true. This time may
370     * optionally be highlighted as selected as well.
371     *
372     * @param time The time to move to
373     * @param animate Whether to scroll to the given time or just redraw at the
374     *            new location
375     * @param setSelected Whether to set the given time as selected
376     * @param forceScroll Whether to recenter even if the time is already
377     *            visible
378     * @return Whether or not the view animated to the new location
379     */
380    public boolean goTo(long time, boolean animate, boolean setSelected, boolean forceScroll) {
381        if (time == -1) {
382            Log.e(TAG, "time is invalid");
383            return false;
384        }
385
386        // Set the selected day
387        if (setSelected) {
388            mSelectedDay.set(time);
389            mSelectedDay.normalize(true);
390        }
391
392        // If this view isn't returned yet we won't be able to load the lists
393        // current position, so return after setting the selected day.
394        if (!isResumed()) {
395            if (Log.isLoggable(TAG, Log.DEBUG)) {
396                Log.d(TAG, "We're not visible yet");
397            }
398            return false;
399        }
400
401        mTempTime.set(time);
402        long millis = mTempTime.normalize(true);
403        // Get the week we're going to
404        // TODO push Util function into Calendar public api.
405        int position = Utils.getWeeksSinceEpochFromJulianDay(
406                Time.getJulianDay(millis, mTempTime.gmtoff), mFirstDayOfWeek);
407
408        View child;
409        int i = 0;
410        int top = 0;
411        // Find a child that's completely in the view
412        do {
413            child = mListView.getChildAt(i++);
414            if (child == null) {
415                break;
416            }
417            top = child.getTop();
418            if (Log.isLoggable(TAG, Log.DEBUG)) {
419                Log.d(TAG, "child at " + (i-1) + " has top " + top);
420            }
421        } while (top < 0);
422
423        // Compute the first and last position visible
424        int firstPosition;
425        if (child != null) {
426            firstPosition = mListView.getPositionForView(child);
427        } else {
428            firstPosition = 0;
429        }
430        int lastPosition = firstPosition + mNumWeeks - 1;
431        if (top > BOTTOM_BUFFER) {
432            lastPosition--;
433        }
434
435        if (setSelected) {
436            mAdapter.setSelectedDay(mSelectedDay);
437        }
438
439        if (Log.isLoggable(TAG, Log.DEBUG)) {
440            Log.d(TAG, "GoTo position " + position);
441        }
442        // Check if the selected day is now outside of our visible range
443        // and if so scroll to the month that contains it
444        if (position < firstPosition || position > lastPosition || forceScroll) {
445            mFirstDayOfMonth.set(mTempTime);
446            mFirstDayOfMonth.monthDay = 1;
447            millis = mFirstDayOfMonth.normalize(true);
448            setMonthDisplayed(mFirstDayOfMonth, true);
449            position = Utils.getWeeksSinceEpochFromJulianDay(
450                    Time.getJulianDay(millis, mFirstDayOfMonth.gmtoff), mFirstDayOfWeek);
451
452            mPreviousScrollState = OnScrollListener.SCROLL_STATE_FLING;
453            if (animate) {
454                mListView.smoothScrollToPositionFromTop(
455                        position, LIST_TOP_OFFSET, GOTO_SCROLL_DURATION);
456                return true;
457            } else {
458                mListView.setSelectionFromTop(position, LIST_TOP_OFFSET);
459                // Perform any after scroll operations that are needed
460                onScrollStateChanged(mListView, OnScrollListener.SCROLL_STATE_IDLE);
461            }
462        } else if (setSelected) {
463            // Otherwise just set the selection
464            setMonthDisplayed(mSelectedDay, true);
465        }
466        return false;
467    }
468
469     /**
470     * Updates the title and selected month if the view has moved to a new
471     * month.
472     */
473    @Override
474    public void onScroll(
475            AbsListView view, int firstVisibleItem, int visibleItemCount, int totalItemCount) {
476        SimpleWeekView child = (SimpleWeekView)view.getChildAt(0);
477        if (child == null) {
478            return;
479        }
480
481        // Figure out where we are
482        long currScroll = view.getFirstVisiblePosition() * child.getHeight() - child.getBottom();
483        mFirstVisibleDay.setJulianDay(child.getFirstJulianDay());
484
485        // If we have moved since our last call update the direction
486        if (currScroll < mPreviousScrollPosition) {
487            mIsScrollingUp = true;
488        } else if (currScroll > mPreviousScrollPosition) {
489            mIsScrollingUp = false;
490        } else {
491            return;
492        }
493
494        mPreviousScrollPosition = currScroll;
495        mPreviousScrollState = mCurrentScrollState;
496
497        updateMonthHighlight(mListView);
498    }
499
500    /**
501     * Figures out if the month being shown has changed and updates the
502     * highlight if needed
503     *
504     * @param view The ListView containing the weeks
505     */
506    private void updateMonthHighlight(AbsListView view) {
507        SimpleWeekView child = (SimpleWeekView) view.getChildAt(0);
508        if (child == null) {
509            return;
510        }
511
512        // Figure out where we are
513        int offset = child.getBottom() < WEEK_MIN_VISIBLE_HEIGHT ? 1 : 0;
514        // Use some hysteresis for checking which month to highlight. This
515        // causes the month to transition when two full weeks of a month are
516        // visible.
517        child = (SimpleWeekView) view.getChildAt(SCROLL_HYST_WEEKS + offset);
518
519        if (child == null) {
520            return;
521        }
522
523        // Find out which month we're moving into
524        int month;
525        if (mIsScrollingUp) {
526            month = child.getFirstMonth();
527        } else {
528            month = child.getLastMonth();
529        }
530
531        // And how it relates to our current highlighted month
532        int monthDiff;
533        if (mCurrentMonthDisplayed == 11 && month == 0) {
534            monthDiff = 1;
535        } else if (mCurrentMonthDisplayed == 0 && month == 11) {
536            monthDiff = -1;
537        } else {
538            monthDiff = month - mCurrentMonthDisplayed;
539        }
540
541        // Only switch months if we're scrolling away from the currently
542        // selected month
543        if (monthDiff != 0) {
544            int julianDay = child.getFirstJulianDay();
545            if (mIsScrollingUp) {
546                // Takes the start of the week
547            } else {
548                // Takes the start of the following week
549                julianDay += DAYS_PER_WEEK;
550            }
551            mTempTime.setJulianDay(julianDay);
552            setMonthDisplayed(mTempTime, false);
553        }
554    }
555
556    /**
557     * Sets the month displayed at the top of this view based on time. Override
558     * to add custom events when the title is changed.
559     *
560     * @param time A day in the new focus month.
561     * @param updateHighlight TODO(epastern):
562     */
563    protected void setMonthDisplayed(Time time, boolean updateHighlight) {
564        CharSequence oldMonth = mMonthName.getText();
565        mMonthName.setText(Utils.formatMonthYear(mContext, time));
566        mMonthName.invalidate();
567        if (!TextUtils.equals(oldMonth, mMonthName.getText())) {
568            mMonthName.sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_FOCUSED);
569        }
570        mCurrentMonthDisplayed = time.month;
571        if (updateHighlight) {
572            mAdapter.updateFocusMonth(mCurrentMonthDisplayed);
573        }
574    }
575
576    @Override
577    public void onScrollStateChanged(AbsListView view, int scrollState) {
578        // use a post to prevent re-entering onScrollStateChanged before it
579        // exits
580        mScrollStateChangedRunnable.doScrollStateChange(view, scrollState);
581    }
582
583    protected ScrollStateRunnable mScrollStateChangedRunnable = new ScrollStateRunnable();
584
585    protected class ScrollStateRunnable implements Runnable {
586        private int mNewState;
587
588        /**
589         * Sets up the runnable with a short delay in case the scroll state
590         * immediately changes again.
591         *
592         * @param view The list view that changed state
593         * @param scrollState The new state it changed to
594         */
595        public void doScrollStateChange(AbsListView view, int scrollState) {
596            mHandler.removeCallbacks(this);
597            mNewState = scrollState;
598            mHandler.postDelayed(this, SCROLL_CHANGE_DELAY);
599        }
600
601        public void run() {
602            mCurrentScrollState = mNewState;
603            if (Log.isLoggable(TAG, Log.DEBUG)) {
604                Log.d(TAG,
605                        "new scroll state: " + mNewState + " old state: " + mPreviousScrollState);
606            }
607            // Fix the position after a scroll or a fling ends
608            if (mNewState == OnScrollListener.SCROLL_STATE_IDLE
609                    && mPreviousScrollState != OnScrollListener.SCROLL_STATE_IDLE) {
610                mPreviousScrollState = mNewState;
611                // Uncomment the below to add snap to week back
612//                int i = 0;
613//                View child = mView.getChildAt(i);
614//                while (child != null && child.getBottom() <= 0) {
615//                    child = mView.getChildAt(++i);
616//                }
617//                if (child == null) {
618//                    // The view is no longer visible, just return
619//                    return;
620//                }
621//                int dist = child.getTop();
622//                if (dist < LIST_TOP_OFFSET) {
623//                    if (Log.isLoggable(TAG, Log.DEBUG)) {
624//                        Log.d(TAG, "scrolling by " + dist + " up? " + mIsScrollingUp);
625//                    }
626//                    int firstPosition = mView.getFirstVisiblePosition();
627//                    int lastPosition = mView.getLastVisiblePosition();
628//                    boolean scroll = firstPosition != 0 && lastPosition != mView.getCount() - 1;
629//                    if (mIsScrollingUp && scroll) {
630//                        mView.smoothScrollBy(dist, 500);
631//                    } else if (!mIsScrollingUp && scroll) {
632//                        mView.smoothScrollBy(child.getHeight() + dist, 500);
633//                    }
634//                }
635                mAdapter.updateFocusMonth(mCurrentMonthDisplayed);
636            } else {
637                mPreviousScrollState = mNewState;
638            }
639        }
640    }
641}
642