/*
* 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.doomonafireball.betterpickers.calendardatepicker;
import com.doomonafireball.betterpickers.R;
import com.doomonafireball.betterpickers.TouchExplorationHelper;
import com.doomonafireball.betterpickers.Utils;
import com.doomonafireball.betterpickers.calendardatepicker.SimpleMonthAdapter.CalendarDay;
import android.content.Context;
import android.content.res.Resources;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.Paint.Align;
import android.graphics.Paint.Style;
import android.graphics.Rect;
import android.graphics.Typeface;
import android.os.Bundle;
import android.support.v4.view.ViewCompat;
import android.support.v4.view.accessibility.AccessibilityNodeInfoCompat;
import android.text.format.DateFormat;
import android.text.format.DateUtils;
import android.text.format.Time;
import android.util.SparseArray;
import android.view.MotionEvent;
import android.view.View;
import android.view.accessibility.AccessibilityEvent;
import java.security.InvalidParameterException;
import java.util.Calendar;
import java.util.Formatter;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
/**
* A calendar-like view displaying a specified month and the appropriate selectable day numbers within the specified
* month.
*/
public class SimpleMonthView extends View {
private static final String TAG = "SimpleMonthView";
/**
* These params can be passed into the view to control how it appears.
* {@link #VIEW_PARAMS_WEEK} is the only required field, though the default
* values are unlikely to fit most layouts correctly.
*/
/**
* This sets the height of this week in pixels
*/
public static final String VIEW_PARAMS_HEIGHT = "height";
/**
* This specifies the position (or weeks since the epoch) of this week, calculated using {@link
* Utils#getWeeksSinceEpochFromJulianDay}
*/
public static final String VIEW_PARAMS_MONTH = "month";
/**
* This specifies the position (or weeks since the epoch) of this week, calculated using {@link
* Utils#getWeeksSinceEpochFromJulianDay}
*/
public static final String VIEW_PARAMS_YEAR = "year";
/**
* This sets one of the days in this view as selected {@link android.text.format.Time#SUNDAY} through {@link
* android.text.format.Time#SATURDAY}.
*/
public static final String VIEW_PARAMS_SELECTED_DAY = "selected_day";
/**
* Which day the week should start on. {@link android.text.format.Time#SUNDAY} through {@link
* android.text.format.Time#SATURDAY}.
*/
public static final String VIEW_PARAMS_WEEK_START = "week_start";
/**
* How many days to display at a time. Days will be displayed starting with {@link #mWeekStart}.
*/
public static final String VIEW_PARAMS_NUM_DAYS = "num_days";
/**
* Which month is currently in focus, as defined by {@link android.text.format.Time#month} [0-11].
*/
public static final String VIEW_PARAMS_FOCUS_MONTH = "focus_month";
/**
* If this month should display week numbers. false if 0, true otherwise.
*/
public static final String VIEW_PARAMS_SHOW_WK_NUM = "show_wk_num";
protected static final int DEFAULT_HEIGHT = 32;
protected static final int MIN_HEIGHT = 10;
protected static final int DEFAULT_SELECTED_DAY = -1;
protected static final int DEFAULT_WEEK_START = Calendar.SUNDAY;
protected static final int DEFAULT_NUM_DAYS = 7;
protected static final int DEFAULT_SHOW_WK_NUM = 0;
protected static final int DEFAULT_FOCUS_MONTH = -1;
protected static final int DEFAULT_NUM_ROWS = 6;
protected static final int MAX_NUM_ROWS = 6;
private static final int SELECTED_CIRCLE_ALPHA = 60;
protected static final int DAY_SEPARATOR_WIDTH = 1;
protected static int sMiniDayNumberTextSize;
protected static int sMonthLabelTextSize;
protected static int sMonthDayLabelTextSize;
protected static int sMonthHeaderSize;
protected static int sDaySelectedCircleSize;
// used for scaling to the device density
protected static float mScale = 0;
// affects the padding on the sides of this view
protected int mPadding = 0;
private String mDayOfWeekTypeface;
private String mMonthTitleTypeface;
protected Paint mMonthNumPaint;
protected Paint mMonthTitlePaint;
protected Paint mMonthTitleBGPaint;
protected Paint mSelectedCirclePaint;
protected Paint mMonthDayLabelPaint;
private final Formatter mFormatter;
private final StringBuilder mStringBuilder;
// The Julian day of the first day displayed by this item
protected int mFirstJulianDay = -1;
// The month of the first day in this week
protected int mFirstMonth = -1;
// The month of the last day in this week
protected int mLastMonth = -1;
protected int mMonth;
protected int mYear;
// Quick reference to the width of this view, matches parent
protected int mWidth;
// The height this view should draw at in pixels, set by height param
protected int mRowHeight = DEFAULT_HEIGHT;
// If this view contains the today
protected boolean mHasToday = false;
// Which day is selected [0-6] or -1 if no day is selected
protected int mSelectedDay = -1;
// Which day is today [0-6] or -1 if no day is today
protected int mToday = DEFAULT_SELECTED_DAY;
// Which day of the week to start on [0-6]
protected int mWeekStart = DEFAULT_WEEK_START;
// How many days to display
protected int mNumDays = DEFAULT_NUM_DAYS;
// The number of days + a spot for week number if it is displayed
protected int mNumCells = mNumDays;
// The left edge of the selected day
protected int mSelectedLeft = -1;
// The right edge of the selected day
protected int mSelectedRight = -1;
private final Calendar mCalendar;
private final Calendar mDayLabelCalendar;
private final MonthViewNodeProvider mNodeProvider;
private int mNumRows = DEFAULT_NUM_ROWS;
// Optional listener for handling day click actions
private OnDayClickListener mOnDayClickListener;
// Whether to prevent setting the accessibility delegate
private boolean mLockAccessibilityDelegate;
protected int mDayTextColor;
protected int mTodayNumberColor;
protected int mMonthTitleColor;
protected int mMonthTitleBGColor;
public SimpleMonthView(Context context) {
super(context);
Resources res = context.getResources();
mDayLabelCalendar = Calendar.getInstance();
mCalendar = Calendar.getInstance();
mDayOfWeekTypeface = res.getString(R.string.day_of_week_label_typeface);
mMonthTitleTypeface = res.getString(R.string.sans_serif);
mDayTextColor = res.getColor(R.color.date_picker_text_normal);
mTodayNumberColor = res.getColor(R.color.blue);
mMonthTitleColor = res.getColor(R.color.white);
mMonthTitleBGColor = res.getColor(R.color.circle_background);
mStringBuilder = new StringBuilder(50);
mFormatter = new Formatter(mStringBuilder, Locale.getDefault());
sMiniDayNumberTextSize = res.getDimensionPixelSize(R.dimen.day_number_size);
sMonthLabelTextSize = res.getDimensionPixelSize(R.dimen.month_label_size);
sMonthDayLabelTextSize = res.getDimensionPixelSize(R.dimen.month_day_label_text_size);
sMonthHeaderSize = res.getDimensionPixelOffset(R.dimen.month_list_item_header_height);
sDaySelectedCircleSize = res
.getDimensionPixelSize(R.dimen.day_number_select_circle_radius);
mRowHeight = (res.getDimensionPixelOffset(R.dimen.date_picker_view_animator_height)
- sMonthHeaderSize) / MAX_NUM_ROWS;
// Set up accessibility components.
mNodeProvider = new MonthViewNodeProvider(context, this);
ViewCompat.setAccessibilityDelegate(this, mNodeProvider.getAccessibilityDelegate());
ViewCompat.setImportantForAccessibility(this, ViewCompat.IMPORTANT_FOR_ACCESSIBILITY_YES);
mLockAccessibilityDelegate = true;
// Sets up any standard paints that will be used
initView();
}
@Override
public void setAccessibilityDelegate(AccessibilityDelegate delegate) {
// Workaround for a JB MR1 issue where accessibility delegates on
// top-level ListView items are overwritten.
if (!mLockAccessibilityDelegate) {
super.setAccessibilityDelegate(delegate);
}
}
public void setOnDayClickListener(OnDayClickListener listener) {
mOnDayClickListener = listener;
}
/* Removed for backwards compatibility with Gingerbread
@Override
public boolean onHoverEvent(MotionEvent event) {
// First right-of-refusal goes the touch exploration helper.
if (mNodeProvider.onHover(this, event)) {
return true;
}
return super.onHoverEvent(event);
}*/
@Override
public boolean onTouchEvent(MotionEvent event) {
switch (event.getAction()) {
case MotionEvent.ACTION_UP:
final CalendarDay day = getDayFromLocation(event.getX(), event.getY());
if (day != null) {
onDayClick(day);
}
break;
}
return true;
}
/**
* Sets up the text and style properties for painting. Override this if you want to use a different paint.
*/
protected void initView() {
mMonthTitlePaint = new Paint();
mMonthTitlePaint.setFakeBoldText(true);
mMonthTitlePaint.setAntiAlias(true);
mMonthTitlePaint.setTextSize(sMonthLabelTextSize);
mMonthTitlePaint.setTypeface(Typeface.create(mMonthTitleTypeface, Typeface.BOLD));
mMonthTitlePaint.setColor(mDayTextColor);
mMonthTitlePaint.setTextAlign(Align.CENTER);
mMonthTitlePaint.setStyle(Style.FILL);
mMonthTitleBGPaint = new Paint();
mMonthTitleBGPaint.setFakeBoldText(true);
mMonthTitleBGPaint.setAntiAlias(true);
mMonthTitleBGPaint.setColor(mMonthTitleBGColor);
mMonthTitleBGPaint.setTextAlign(Align.CENTER);
mMonthTitleBGPaint.setStyle(Style.FILL);
mSelectedCirclePaint = new Paint();
mSelectedCirclePaint.setFakeBoldText(true);
mSelectedCirclePaint.setAntiAlias(true);
mSelectedCirclePaint.setColor(mTodayNumberColor);
mSelectedCirclePaint.setTextAlign(Align.CENTER);
mSelectedCirclePaint.setStyle(Style.FILL);
mSelectedCirclePaint.setAlpha(SELECTED_CIRCLE_ALPHA);
mMonthDayLabelPaint = new Paint();
mMonthDayLabelPaint.setAntiAlias(true);
mMonthDayLabelPaint.setTextSize(sMonthDayLabelTextSize);
mMonthDayLabelPaint.setColor(mDayTextColor);
mMonthDayLabelPaint.setTypeface(Typeface.create(mDayOfWeekTypeface, Typeface.NORMAL));
mMonthDayLabelPaint.setStyle(Style.FILL);
mMonthDayLabelPaint.setTextAlign(Align.CENTER);
mMonthDayLabelPaint.setFakeBoldText(true);
mMonthNumPaint = new Paint();
mMonthNumPaint.setAntiAlias(true);
mMonthNumPaint.setTextSize(sMiniDayNumberTextSize);
mMonthNumPaint.setStyle(Style.FILL);
mMonthNumPaint.setTextAlign(Align.CENTER);
mMonthNumPaint.setFakeBoldText(false);
}
@Override
protected void onDraw(Canvas canvas) {
drawMonthTitle(canvas);
drawMonthDayLabels(canvas);
drawMonthNums(canvas);
}
private int mDayOfWeekStart = 0;
/**
* Sets all the parameters for displaying this week. The only required parameter is the week number. Other
* parameters have a default value and will only update if a new value is included, except for focus month, which
* will always default to no focus month if no value is passed in. See {@link #VIEW_PARAMS_HEIGHT} for more info on
* parameters.
*
* @param params A map of the new parameters, see {@link #VIEW_PARAMS_HEIGHT}
* @param tz The time zone this view should reference times in
*/
public void setMonthParams(HashMap<String, Integer> params) {
if (!params.containsKey(VIEW_PARAMS_MONTH) && !params.containsKey(VIEW_PARAMS_YEAR)) {
throw new InvalidParameterException("You must specify the month and year for this view");
}
setTag(params);
// We keep the current value for any params not present
if (params.containsKey(VIEW_PARAMS_HEIGHT)) {
mRowHeight = params.get(VIEW_PARAMS_HEIGHT);
if (mRowHeight < MIN_HEIGHT) {
mRowHeight = MIN_HEIGHT;
}
}
if (params.containsKey(VIEW_PARAMS_SELECTED_DAY)) {
mSelectedDay = params.get(VIEW_PARAMS_SELECTED_DAY);
}
// Allocate space for caching the day numbers and focus values
mMonth = params.get(VIEW_PARAMS_MONTH);
mYear = params.get(VIEW_PARAMS_YEAR);
// Figure out what day today is
final Time today = new Time(Time.getCurrentTimezone());
today.setToNow();
mHasToday = false;
mToday = -1;
mCalendar.set(Calendar.MONTH, mMonth);
mCalendar.set(Calendar.YEAR, mYear);
mCalendar.set(Calendar.DAY_OF_MONTH, 1);
mDayOfWeekStart = mCalendar.get(Calendar.DAY_OF_WEEK);
if (params.containsKey(VIEW_PARAMS_WEEK_START)) {
mWeekStart = params.get(VIEW_PARAMS_WEEK_START);
} else {
mWeekStart = mCalendar.getFirstDayOfWeek();
}
mNumCells = Utils.getDaysInMonth(mMonth, mYear);
for (int i = 0; i < mNumCells; i++) {
final int day = i + 1;
if (sameDay(day, today)) {
mHasToday = true;
mToday = day;
}
}
mNumRows = calculateNumRows();
// Invalidate cached accessibility information.
mNodeProvider.invalidateParent();
}
public void reuse() {
mNumRows = DEFAULT_NUM_ROWS;
requestLayout();
}
private int calculateNumRows() {
int offset = findDayOffset();
int dividend = (offset + mNumCells) / mNumDays;
int remainder = (offset + mNumCells) % mNumDays;
return (dividend + (remainder > 0 ? 1 : 0));
}
private boolean sameDay(int day, Time today) {
return mYear == today.year &&
mMonth == today.month &&
day == today.monthDay;
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
setMeasuredDimension(MeasureSpec.getSize(widthMeasureSpec), mRowHeight * mNumRows
+ sMonthHeaderSize);
}
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
mWidth = w;
// Invalidate cached accessibility information.
mNodeProvider.invalidateParent();
}
private String getMonthAndYearString() {
int flags = DateUtils.FORMAT_SHOW_DATE | DateUtils.FORMAT_SHOW_YEAR
| DateUtils.FORMAT_NO_MONTH_DAY;
mStringBuilder.setLength(0);
long millis = mCalendar.getTimeInMillis();
return DateUtils.formatDateRange(getContext(), mFormatter, millis, millis, flags,
Time.getCurrentTimezone()).toString();
}
private void drawMonthTitle(Canvas canvas) {
int x = (mWidth + 2 * mPadding) / 2;
int y = (sMonthHeaderSize - sMonthDayLabelTextSize) / 2 + (sMonthLabelTextSize / 3);
canvas.drawText(getMonthAndYearString(), x, y, mMonthTitlePaint);
}
private void drawMonthDayLabels(Canvas canvas) {
int y = sMonthHeaderSize - (sMonthDayLabelTextSize / 2);
int dayWidthHalf = (mWidth - mPadding * 2) / (mNumDays * 2);
for (int i = 0; i < mNumDays; i++) {
int calendarDay = (i + mWeekStart) % mNumDays;
int x = (2 * i + 1) * dayWidthHalf + mPadding;
mDayLabelCalendar.set(Calendar.DAY_OF_WEEK, calendarDay);
canvas.drawText(mDayLabelCalendar.getDisplayName(Calendar.DAY_OF_WEEK, Calendar.SHORT,
Locale.getDefault()).toUpperCase(Locale.getDefault()), x, y,
mMonthDayLabelPaint);
}
}
/**
* Draws the week and month day numbers for this week. Override this method if you need different placement.
*
* @param canvas The canvas to draw on
*/
protected void drawMonthNums(Canvas canvas) {
int y = (((mRowHeight + sMiniDayNumberTextSize) / 2) - DAY_SEPARATOR_WIDTH)
+ sMonthHeaderSize;
int dayWidthHalf = (mWidth - mPadding * 2) / (mNumDays * 2);
int j = findDayOffset();
for (int dayNumber = 1; dayNumber <= mNumCells; dayNumber++) {
int x = (2 * j + 1) * dayWidthHalf + mPadding;
if (mSelectedDay == dayNumber) {
canvas.drawCircle(x, y - (sMiniDayNumberTextSize / 3), sDaySelectedCircleSize,
mSelectedCirclePaint);
}
if (mHasToday && mToday == dayNumber) {
mMonthNumPaint.setColor(mTodayNumberColor);
} else {
mMonthNumPaint.setColor(mDayTextColor);
}
canvas.drawText(String.format("%d", dayNumber), x, y, mMonthNumPaint);
j++;
if (j == mNumDays) {
j = 0;
y += mRowHeight;
}
}
}
private int findDayOffset() {
return (mDayOfWeekStart < mWeekStart ? (mDayOfWeekStart + mNumDays) : mDayOfWeekStart)
- mWeekStart;
}
/**
* Calculates the day that the given x position is in, accounting for week number. Returns a Time referencing that
* day or null if
*
* @param x The x position of the touch event
* @return A time object for the tapped day or null if the position wasn't in a day
*/
public CalendarDay getDayFromLocation(float x, float y) {
int dayStart = mPadding;
if (x < dayStart || x > mWidth - mPadding) {
return null;
}
// Selection is (x - start) / (pixels/day) == (x -s) * day / pixels
int row = (int) (y - sMonthHeaderSize) / mRowHeight;
int column = (int) ((x - dayStart) * mNumDays / (mWidth - dayStart - mPadding));
int day = column - findDayOffset() + 1;
day += row * mNumDays;
if (day < 1 || day > mNumCells) {
return null;
}
return new CalendarDay(mYear, mMonth, day);
}
/**
* Called when the user clicks on a day. Handles callbacks to the {@link OnDayClickListener} if one is set.
*
* @param day A time object representing the day that was clicked
*/
private void onDayClick(CalendarDay day) {
if (mOnDayClickListener != null) {
mOnDayClickListener.onDayClick(this, day);
}
// This is a no-op if accessibility is turned off.
mNodeProvider.sendEventForItem(day, AccessibilityEvent.TYPE_VIEW_CLICKED);
}
/**
* @return The date that has accessibility focus, or {@code null} if no date has focus
*/
public CalendarDay getAccessibilityFocus() {
return mNodeProvider.getFocusedItem();
}
/**
* Clears accessibility focus within the view. No-op if the view does not contain accessibility focus.
*/
public void clearAccessibilityFocus() {
mNodeProvider.clearFocusedItem();
}
/**
* Attempts to restore accessibility focus to the specified date.
*
* @param day The date which should receive focus
* @return {@code false} if the date is not valid for this month view, or {@code true} if the date received focus
*/
public boolean restoreAccessibilityFocus(CalendarDay day) {
if ((day.year != mYear) || (day.month != mMonth) || (day.day > mNumCells)) {
return false;
}
mNodeProvider.setFocusedItem(day);
return true;
}
/**
* Provides a virtual view hierarchy for interfacing with an accessibility service.
*/
private class MonthViewNodeProvider extends TouchExplorationHelper<CalendarDay> {
private final SparseArray<CalendarDay> mCachedItems = new SparseArray<CalendarDay>();
private final Rect mTempRect = new Rect();
Calendar recycle;
public MonthViewNodeProvider(Context context, View parent) {
super(context, parent);
}
@Override
public void invalidateItem(CalendarDay item) {
super.invalidateItem(item);
mCachedItems.delete(getIdForItem(item));
}
@Override
public void invalidateParent() {
super.invalidateParent();
mCachedItems.clear();
}
@Override
protected boolean performActionForItem(CalendarDay item, int action, Bundle arguments) {
switch (action) {
case AccessibilityNodeInfoCompat.ACTION_CLICK:
onDayClick(item);
return true;
}
return false;
}
@Override
protected void populateEventForItem(CalendarDay item, AccessibilityEvent event) {
event.setContentDescription(getItemDescription(item));
}
@Override
protected void populateNodeForItem(CalendarDay item, AccessibilityNodeInfoCompat node) {
getItemBounds(item, mTempRect);
node.setContentDescription(getItemDescription(item));
node.setBoundsInParent(mTempRect);
node.addAction(AccessibilityNodeInfoCompat.ACTION_CLICK);
if (item.day == mSelectedDay) {
node.setSelected(true);
}
}
@Override
protected void getVisibleItems(List<CalendarDay> items) {
// TODO: Optimize, only return items visible within parent bounds.
for (int day = 1; day <= mNumCells; day++) {
items.add(getItemForId(day));
}
}
@Override
protected CalendarDay getItemAt(float x, float y) {
return getDayFromLocation(x, y);
}
@Override
protected int getIdForItem(CalendarDay item) {
return item.day;
}
@Override
protected CalendarDay getItemForId(int id) {
if ((id < 1) || (id > mNumCells)) {
return null;
}
final CalendarDay item;
if (mCachedItems.indexOfKey(id) >= 0) {
item = mCachedItems.get(id);
} else {
item = new CalendarDay(mYear, mMonth, id);
mCachedItems.put(id, item);
}
return item;
}
/**
* Calculates the bounding rectangle of a given time object.
*
* @param item The time object to calculate bounds for
* @param rect The rectangle in which to store the bounds
*/
private void getItemBounds(CalendarDay item, Rect rect) {
final int offsetX = mPadding;
final int offsetY = sMonthHeaderSize;
final int cellHeight = mRowHeight;
final int cellWidth = ((mWidth - (2 * mPadding)) / mNumDays);
final int index = ((item.day - 1) + findDayOffset());
final int row = (index / mNumDays);
final int column = (index % mNumDays);
final int x = (offsetX + (column * cellWidth));
final int y = (offsetY + (row * cellHeight));
rect.set(x, y, (x + cellWidth), (y + cellHeight));
}
/**
* Generates a description for a given time object. Since this description will be spoken, the components are
* ordered by descending specificity as DAY MONTH YEAR.
*
* @param item The time object to generate a description for
* @return A description of the time object
*/
private CharSequence getItemDescription(CalendarDay item) {
if (recycle == null) {
recycle = Calendar.getInstance();
}
recycle.set(item.year, item.month, item.day);
CharSequence date = DateFormat.format("dd MMMM yyyy", recycle.getTimeInMillis());
if (item.day == mSelectedDay) {
return getContext().getString(R.string.item_is_selected, date);
}
return date;
}
}
/**
* Handles callbacks when the user clicks on a time object.
*/
public interface OnDayClickListener {
public void onDayClick(SimpleMonthView view, CalendarDay day);
}
}