blob: 7db3fb9e113a32cdff1b1026502aa1b98328d287 [file] [log] [blame]
/*
* 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 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());
}
@Override
public void onInitializeAccessibilityEvent(AccessibilityEvent event) {
super.onInitializeAccessibilityEvent(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.
*/
@Override
public void onInitializeAccessibilityNodeInfo(AccessibilityNodeInfo info) {
super.onInitializeAccessibilityNodeInfo(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.
*/
@Override
public boolean performAccessibilityAction(int action, Bundle arguments) {
if (action != AccessibilityNodeInfo.ACTION_SCROLL_FORWARD &&
action != AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD) {
return super.performAccessibilityAction(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);
}
}
};
}