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