/* * Copyright (C) 2014 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package android.widget; import android.content.Context; import android.content.res.ColorStateList; import android.content.res.Configuration; import android.os.Bundle; import android.util.Log; import android.util.MathUtils; import android.view.View; import android.view.ViewConfiguration; import android.view.accessibility.AccessibilityEvent; import android.view.accessibility.AccessibilityNodeInfo; import java.text.SimpleDateFormat; import java.util.Calendar; import java.util.Locale; /** * This displays a list of months in a calendar format with selectable days. */ class DayPickerView extends ListView implements AbsListView.OnScrollListener { private static final String TAG = "DayPickerView"; // How long the GoTo fling animation should last private static final int GOTO_SCROLL_DURATION = 250; // How long to wait after receiving an onScrollStateChanged notification before acting on it private static final int SCROLL_CHANGE_DELAY = 40; // so that the top line will be under the separator private static final int LIST_TOP_OFFSET = -1; private final SimpleMonthAdapter mAdapter = new SimpleMonthAdapter(getContext()); private final ScrollStateRunnable mScrollStateChangedRunnable = new ScrollStateRunnable(this); private SimpleDateFormat mYearFormat = new SimpleDateFormat("yyyy", Locale.getDefault()); // highlighted time private Calendar mSelectedDay = Calendar.getInstance(); private Calendar mTempDay = Calendar.getInstance(); private Calendar mMinDate = Calendar.getInstance(); private Calendar mMaxDate = Calendar.getInstance(); private Calendar mTempCalendar; private OnDaySelectedListener mOnDaySelectedListener; // which month should be displayed/highlighted [0-11] private int mCurrentMonthDisplayed; // used for tracking what state listview is in private int mPreviousScrollState = OnScrollListener.SCROLL_STATE_IDLE; // used for tracking what state listview is in private int mCurrentScrollState = OnScrollListener.SCROLL_STATE_IDLE; private boolean mPerformingScroll; public DayPickerView(Context context) { super(context); setAdapter(mAdapter); setLayoutParams(new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)); setDrawSelectorOnTop(false); setUpListView(); goTo(mSelectedDay.getTimeInMillis(), false, false, true); mAdapter.setOnDaySelectedListener(mProxyOnDaySelectedListener); } /** * Sets the currently selected date to the specified timestamp. Jumps * immediately to the new date. To animate to the new date, use * {@link #setDate(long, boolean, boolean)}. * * @param timeInMillis */ public void setDate(long timeInMillis) { setDate(timeInMillis, false, true); } public void setDate(long timeInMillis, boolean animate, boolean forceScroll) { goTo(timeInMillis, animate, true, forceScroll); } public long getDate() { return mSelectedDay.getTimeInMillis(); } public void setFirstDayOfWeek(int firstDayOfWeek) { mAdapter.setFirstDayOfWeek(firstDayOfWeek); } public int getFirstDayOfWeek() { return mAdapter.getFirstDayOfWeek(); } public void setMinDate(long timeInMillis) { mMinDate.setTimeInMillis(timeInMillis); onRangeChanged(); } public long getMinDate() { return mMinDate.getTimeInMillis(); } public void setMaxDate(long timeInMillis) { mMaxDate.setTimeInMillis(timeInMillis); onRangeChanged(); } public long getMaxDate() { return mMaxDate.getTimeInMillis(); } /** * Handles changes to date range. */ public void onRangeChanged() { mAdapter.setRange(mMinDate, mMaxDate); // Changing the min/max date changes the selection position since we // don't really have stable IDs. Jumps immediately to the new position. goTo(mSelectedDay.getTimeInMillis(), false, false, true); } /** * Sets the listener to call when the user selects a day. * * @param listener The listener to call. */ public void setOnDaySelectedListener(OnDaySelectedListener listener) { mOnDaySelectedListener = listener; } /* * Sets all the required fields for the list view. Override this method to * set a different list view behavior. */ private void setUpListView() { // Transparent background on scroll setCacheColorHint(0); // No dividers setDivider(null); // Items are clickable setItemsCanFocus(true); // The thumb gets in the way, so disable it setFastScrollEnabled(false); setVerticalScrollBarEnabled(false); setOnScrollListener(this); setFadingEdgeLength(0); // Make the scrolling behavior nicer setFriction(ViewConfiguration.getScrollFriction()); } private int getDiffMonths(Calendar start, Calendar end) { final int diffYears = end.get(Calendar.YEAR) - start.get(Calendar.YEAR); final int diffMonths = end.get(Calendar.MONTH) - start.get(Calendar.MONTH) + 12 * diffYears; return diffMonths; } private int getPositionFromDay(long timeInMillis) { final int diffMonthMax = getDiffMonths(mMinDate, mMaxDate); final int diffMonth = getDiffMonths(mMinDate, getTempCalendarForTime(timeInMillis)); return MathUtils.constrain(diffMonth, 0, diffMonthMax); } private Calendar getTempCalendarForTime(long timeInMillis) { if (mTempCalendar == null) { mTempCalendar = Calendar.getInstance(); } mTempCalendar.setTimeInMillis(timeInMillis); return mTempCalendar; } /** * This moves to the specified time in the view. If the time is not already * in range it will move the list so that the first of the month containing * the time is at the top of the view. If the new time is already in view * the list will not be scrolled unless forceScroll is true. This time may * optionally be highlighted as selected as well. * * @param day The day to move to * @param animate Whether to scroll to the given time or just redraw at the * new location * @param setSelected Whether to set the given time as selected * @param forceScroll Whether to recenter even if the time is already * visible * @return Whether or not the view animated to the new location */ private boolean goTo(long day, boolean animate, boolean setSelected, boolean forceScroll) { // Set the selected day if (setSelected) { mSelectedDay.setTimeInMillis(day); } mTempDay.setTimeInMillis(day); final int position = getPositionFromDay(day); View child; int i = 0; int top = 0; // Find a child that's completely in the view do { child = getChildAt(i++); if (child == null) { break; } top = child.getTop(); } while (top < 0); // Compute the first and last position visible int selectedPosition; if (child != null) { selectedPosition = getPositionForView(child); } else { selectedPosition = 0; } if (setSelected) { mAdapter.setSelectedDay(mSelectedDay); } // Check if the selected day is now outside of our visible range // and if so scroll to the month that contains it if (position != selectedPosition || forceScroll) { setMonthDisplayed(mTempDay); mPreviousScrollState = OnScrollListener.SCROLL_STATE_FLING; if (animate) { smoothScrollToPositionFromTop( position, LIST_TOP_OFFSET, GOTO_SCROLL_DURATION); return true; } else { postSetSelection(position); } } else if (setSelected) { setMonthDisplayed(mSelectedDay); } return false; } public void postSetSelection(final int position) { clearFocus(); post(new Runnable() { @Override public void run() { setSelection(position); } }); onScrollStateChanged(this, OnScrollListener.SCROLL_STATE_IDLE); } /** * Updates the title and selected month if the view has moved to a new * month. */ @Override public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount, int totalItemCount) { SimpleMonthView child = (SimpleMonthView) view.getChildAt(0); if (child == null) { return; } mPreviousScrollState = mCurrentScrollState; } /** * Sets the month displayed at the top of this view based on time. Override * to add custom events when the title is changed. */ protected void setMonthDisplayed(Calendar date) { if (mCurrentMonthDisplayed != date.get(Calendar.MONTH)) { mCurrentMonthDisplayed = date.get(Calendar.MONTH); invalidateViews(); } } @Override public void onScrollStateChanged(AbsListView view, int scrollState) { // use a post to prevent re-entering onScrollStateChanged before it // exits mScrollStateChangedRunnable.doScrollStateChange(view, scrollState); } void setCalendarTextColor(ColorStateList colors) { mAdapter.setCalendarTextColor(colors); } void setCalendarDayBackgroundColor(ColorStateList dayBackgroundColor) { mAdapter.setCalendarDayBackgroundColor(dayBackgroundColor); } void setCalendarTextAppearance(int resId) { mAdapter.setCalendarTextAppearance(resId); } protected class ScrollStateRunnable implements Runnable { private int mNewState; private View mParent; ScrollStateRunnable(View view) { mParent = view; } /** * Sets up the runnable with a short delay in case the scroll state * immediately changes again. * * @param view The list view that changed state * @param scrollState The new state it changed to */ public void doScrollStateChange(AbsListView view, int scrollState) { mParent.removeCallbacks(this); mNewState = scrollState; mParent.postDelayed(this, SCROLL_CHANGE_DELAY); } @Override public void run() { mCurrentScrollState = mNewState; if (Log.isLoggable(TAG, Log.DEBUG)) { Log.d(TAG, "new scroll state: " + mNewState + " old state: " + mPreviousScrollState); } // Fix the position after a scroll or a fling ends if (mNewState == OnScrollListener.SCROLL_STATE_IDLE && mPreviousScrollState != OnScrollListener.SCROLL_STATE_IDLE && mPreviousScrollState != OnScrollListener.SCROLL_STATE_TOUCH_SCROLL) { mPreviousScrollState = mNewState; int i = 0; View child = getChildAt(i); while (child != null && child.getBottom() <= 0) { child = getChildAt(++i); } if (child == null) { // The view is no longer visible, just return return; } int firstPosition = getFirstVisiblePosition(); int lastPosition = getLastVisiblePosition(); boolean scroll = firstPosition != 0 && lastPosition != getCount() - 1; final int top = child.getTop(); final int bottom = child.getBottom(); final int midpoint = getHeight() / 2; if (scroll && top < LIST_TOP_OFFSET) { if (bottom > midpoint) { smoothScrollBy(top, GOTO_SCROLL_DURATION); } else { smoothScrollBy(bottom, GOTO_SCROLL_DURATION); } } } else { mPreviousScrollState = mNewState; } } } /** * Gets the position of the view that is most prominently displayed within the list view. */ public int getMostVisiblePosition() { final int firstPosition = getFirstVisiblePosition(); final int height = getHeight(); int maxDisplayedHeight = 0; int mostVisibleIndex = 0; int i=0; int bottom = 0; while (bottom < height) { View child = getChildAt(i); if (child == null) { break; } bottom = child.getBottom(); int displayedHeight = Math.min(bottom, height) - Math.max(0, child.getTop()); if (displayedHeight > maxDisplayedHeight) { mostVisibleIndex = i; maxDisplayedHeight = displayedHeight; } i++; } return firstPosition + mostVisibleIndex; } /** * Attempts to return the date that has accessibility focus. * * @return The date that has accessibility focus, or {@code null} if no date * has focus. */ private Calendar findAccessibilityFocus() { final int childCount = getChildCount(); for (int i = 0; i < childCount; i++) { final View child = getChildAt(i); if (child instanceof SimpleMonthView) { final Calendar focus = ((SimpleMonthView) child).getAccessibilityFocus(); if (focus != null) { return focus; } } } return null; } /** * Attempts to restore accessibility focus to a given date. No-op if * {@code day} is {@code null}. * * @param day The date that should receive accessibility focus * @return {@code true} if focus was restored */ private boolean restoreAccessibilityFocus(Calendar day) { if (day == null) { return false; } final int childCount = getChildCount(); for (int i = 0; i < childCount; i++) { final View child = getChildAt(i); if (child instanceof SimpleMonthView) { if (((SimpleMonthView) child).restoreAccessibilityFocus(day)) { return true; } } } return false; } @Override protected void layoutChildren() { final Calendar focusedDay = findAccessibilityFocus(); super.layoutChildren(); if (mPerformingScroll) { mPerformingScroll = false; } else { restoreAccessibilityFocus(focusedDay); } } @Override protected void onConfigurationChanged(Configuration newConfig) { mYearFormat = new SimpleDateFormat("yyyy", Locale.getDefault()); } /** @hide */ @Override public void onInitializeAccessibilityEventInternal(AccessibilityEvent event) { super.onInitializeAccessibilityEventInternal(event); event.setItemCount(-1); } private String getMonthAndYearString(Calendar day) { final StringBuilder sbuf = new StringBuilder(); sbuf.append(day.getDisplayName(Calendar.MONTH, Calendar.LONG, Locale.getDefault())); sbuf.append(" "); sbuf.append(mYearFormat.format(day.getTime())); return sbuf.toString(); } /** * Necessary for accessibility, to ensure we support "scrolling" forward and backward * in the month list. * * @hide */ @Override public void onInitializeAccessibilityNodeInfoInternal(AccessibilityNodeInfo info) { super.onInitializeAccessibilityNodeInfoInternal(info); info.addAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_SCROLL_FORWARD); info.addAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_SCROLL_BACKWARD); } /** * When scroll forward/backward events are received, announce the newly scrolled-to month. * * @hide */ @Override public boolean performAccessibilityActionInternal(int action, Bundle arguments) { if (action != AccessibilityNodeInfo.ACTION_SCROLL_FORWARD && action != AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD) { return super.performAccessibilityActionInternal(action, arguments); } // Figure out what month is showing. final int firstVisiblePosition = getFirstVisiblePosition(); final int month = firstVisiblePosition % 12; final int year = firstVisiblePosition / 12 + mMinDate.get(Calendar.YEAR); final Calendar day = Calendar.getInstance(); day.set(year, month, 1); // Scroll either forward or backward one month. if (action == AccessibilityNodeInfo.ACTION_SCROLL_FORWARD) { day.add(Calendar.MONTH, 1); if (day.get(Calendar.MONTH) == 12) { day.set(Calendar.MONTH, 0); day.add(Calendar.YEAR, 1); } } else if (action == AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD) { View firstVisibleView = getChildAt(0); // If the view is fully visible, jump one month back. Otherwise, we'll just jump // to the first day of first visible month. if (firstVisibleView != null && firstVisibleView.getTop() >= -1) { // There's an off-by-one somewhere, so the top of the first visible item will // actually be -1 when it's at the exact top. day.add(Calendar.MONTH, -1); if (day.get(Calendar.MONTH) == -1) { day.set(Calendar.MONTH, 11); day.add(Calendar.YEAR, -1); } } } // Go to that month. announceForAccessibility(getMonthAndYearString(day)); goTo(day.getTimeInMillis(), true, false, true); mPerformingScroll = true; return true; } public interface OnDaySelectedListener { public void onDaySelected(DayPickerView view, Calendar day); } private final SimpleMonthAdapter.OnDaySelectedListener mProxyOnDaySelectedListener = new SimpleMonthAdapter.OnDaySelectedListener() { @Override public void onDaySelected(SimpleMonthAdapter adapter, Calendar day) { if (mOnDaySelectedListener != null) { mOnDaySelectedListener.onDaySelected(DayPickerView.this, day); } } }; }