blob: 9f8153762b705405b7cc824fb1bfecd6bdda9c91 [file] [log] [blame]
/*
* Copyright (C) 2013 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 com.android.datetimepicker.date;
import android.annotation.SuppressLint;
import android.content.Context;
import android.os.Build;
import android.os.Bundle;
import android.os.Handler;
import android.util.AttributeSet;
import android.util.Log;
import android.view.View;
import android.view.ViewConfiguration;
import android.view.accessibility.AccessibilityEvent;
import android.view.accessibility.AccessibilityNodeInfo;
import android.widget.AbsListView;
import android.widget.AbsListView.OnScrollListener;
import android.widget.ListView;
import com.android.datetimepicker.Utils;
import com.android.datetimepicker.date.DatePickerDialog.OnDateChangedListener;
import com.android.datetimepicker.date.MonthAdapter.CalendarDay;
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.
*/
abstract class DayPickerView extends ListView implements OnScrollListener,
OnDateChangedListener {
private static final String TAG = "MonthFragment";
// Affects when the month selection will change while scrolling up
protected static final int SCROLL_HYST_WEEKS = 2;
// How long the GoTo fling animation should last
protected static final int GOTO_SCROLL_DURATION = 250;
// How long to wait after receiving an onScrollStateChanged notification
// before acting on it
protected static final int SCROLL_CHANGE_DELAY = 40;
// The number of days to display in each week
public static final int DAYS_PER_WEEK = 7;
public static int LIST_TOP_OFFSET = -1; // so that the top line will be
// under the separator
// You can override these numbers to get a different appearance
protected int mNumWeeks = 6;
protected boolean mShowWeekNumber = false;
protected int mDaysPerWeek = 7;
private static SimpleDateFormat YEAR_FORMAT = new SimpleDateFormat("yyyy", Locale.getDefault());
// These affect the scroll speed and feel
protected float mFriction = 1.0f;
protected Context mContext;
protected Handler mHandler;
// highlighted time
protected CalendarDay mSelectedDay = new CalendarDay();
protected MonthAdapter mAdapter;
protected CalendarDay mTempDay = new CalendarDay();
// When the week starts; numbered like Time.<WEEKDAY> (e.g. SUNDAY=0).
protected int mFirstDayOfWeek;
// The last name announced by accessibility
protected CharSequence mPrevMonthName;
// which month should be displayed/highlighted [0-11]
protected int mCurrentMonthDisplayed;
// used for tracking during a scroll
protected long mPreviousScrollPosition;
// used for tracking what state listview is in
protected int mPreviousScrollState = OnScrollListener.SCROLL_STATE_IDLE;
// used for tracking what state listview is in
protected int mCurrentScrollState = OnScrollListener.SCROLL_STATE_IDLE;
private DatePickerController mController;
private boolean mPerformingScroll;
public DayPickerView(Context context, AttributeSet attrs) {
super(context, attrs);
init(context);
}
public DayPickerView(Context context, DatePickerController controller) {
super(context);
init(context);
setController(controller);
}
public void setController(DatePickerController controller) {
mController = controller;
mController.registerOnDateChangedListener(this);
refreshAdapter();
onDateChanged();
}
public void init(Context context) {
mHandler = new Handler();
setLayoutParams(new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT));
setDrawSelectorOnTop(false);
mContext = context;
setUpListView();
}
public void onChange() {
refreshAdapter();
}
/**
* Creates a new adapter if necessary and sets up its parameters. Override
* this method to provide a custom adapter.
*/
protected void refreshAdapter() {
if (mAdapter == null) {
mAdapter = createMonthAdapter(getContext(), mController);
} else {
mAdapter.setSelectedDay(mSelectedDay);
}
// refresh the view with the new parameters
setAdapter(mAdapter);
}
public abstract MonthAdapter createMonthAdapter(Context context,
DatePickerController controller);
/*
* Sets all the required fields for the list view. Override this method to
* set a different list view behavior.
*/
protected 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() * mFriction);
}
/**
* 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 time The time 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
*/
public boolean goTo(CalendarDay day, boolean animate, boolean setSelected, boolean forceScroll) {
// Set the selected day
if (setSelected) {
mSelectedDay.set(day);
}
mTempDay.set(day);
final int position = (day.year - mController.getMinYear())
* MonthAdapter.MONTHS_IN_YEAR + day.month;
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();
if (Log.isLoggable(TAG, Log.DEBUG)) {
Log.d(TAG, "child at " + (i - 1) + " has top " + top);
}
} 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);
}
if (Log.isLoggable(TAG, Log.DEBUG)) {
Log.d(TAG, "GoTo position " + position);
}
// 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) {
MonthView child = (MonthView) view.getChildAt(0);
if (child == null) {
return;
}
// Figure out where we are
long currScroll = view.getFirstVisiblePosition() * child.getHeight() - child.getBottom();
mPreviousScrollPosition = currScroll;
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(CalendarDay date) {
mCurrentMonthDisplayed = date.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);
}
protected ScrollStateRunnable mScrollStateChangedRunnable = new ScrollStateRunnable();
protected class ScrollStateRunnable implements Runnable {
private int mNewState;
/**
* 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) {
mHandler.removeCallbacks(this);
mNewState = scrollState;
mHandler.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;
}
@Override
public void onDateChanged() {
goTo(mController.getSelectedDay(), false, true, true);
}
/**
* 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 CalendarDay findAccessibilityFocus() {
final int childCount = getChildCount();
for (int i = 0; i < childCount; i++) {
final View child = getChildAt(i);
if (child instanceof MonthView) {
final CalendarDay focus = ((MonthView) child).getAccessibilityFocus();
if (focus != null) {
if (Build.VERSION.SDK_INT == Build.VERSION_CODES.JELLY_BEAN_MR1) {
// Clear focus to avoid ListView bug in Jelly Bean MR1.
((MonthView) child).clearAccessibilityFocus();
}
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(CalendarDay 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 MonthView) {
if (((MonthView) child).restoreAccessibilityFocus(day)) {
return true;
}
}
}
return false;
}
@Override
protected void layoutChildren() {
final CalendarDay focusedDay = findAccessibilityFocus();
super.layoutChildren();
if (mPerformingScroll) {
mPerformingScroll = false;
} else {
restoreAccessibilityFocus(focusedDay);
}
}
@Override
public void onInitializeAccessibilityEvent(AccessibilityEvent event) {
super.onInitializeAccessibilityEvent(event);
event.setItemCount(-1);
}
private static String getMonthAndYearString(CalendarDay day) {
Calendar cal = Calendar.getInstance();
cal.set(day.year, day.month, day.day);
StringBuffer sbuf = new StringBuffer();
sbuf.append(cal.getDisplayName(Calendar.MONTH, Calendar.LONG, Locale.getDefault()));
sbuf.append(" ");
sbuf.append(YEAR_FORMAT.format(cal.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.ACTION_SCROLL_FORWARD);
info.addAction(AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD);
}
/**
* When scroll forward/backward events are received, announce the newly scrolled-to month.
*/
@SuppressLint("NewApi")
@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.
int firstVisiblePosition = getFirstVisiblePosition();
int month = firstVisiblePosition % 12;
int year = firstVisiblePosition / 12 + mController.getMinYear();
CalendarDay day = new CalendarDay(year, month, 1);
// Scroll either forward or backward one month.
if (action == AccessibilityNodeInfo.ACTION_SCROLL_FORWARD) {
day.month++;
if (day.month == 12) {
day.month = 0;
day.year++;
}
} 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.month--;
if (day.month == -1) {
day.month = 11;
day.year--;
}
}
}
// Go to that month.
Utils.tryAccessibilityAnnounce(this, getMonthAndYearString(day));
goTo(day, true, false, true);
mPerformingScroll = true;
return true;
}
}