/* * 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.wdullaer.materialdatetimepicker.date; import android.annotation.SuppressLint; import android.content.Context; import android.os.Build; import android.os.Bundle; import android.os.Handler; import android.support.annotation.NonNull; import android.support.v7.widget.LinearLayoutManager; import android.support.v7.widget.RecyclerView; import android.util.AttributeSet; import android.util.Log; import android.view.Gravity; import android.view.View; import android.view.accessibility.AccessibilityEvent; import android.view.accessibility.AccessibilityNodeInfo; import com.wdullaer.materialdatetimepicker.GravitySnapHelper; import com.wdullaer.materialdatetimepicker.Utils; import com.wdullaer.materialdatetimepicker.date.DatePickerDialog.OnDateChangedListener; 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. */ public abstract class DayPickerView extends RecyclerView implements 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; // The number of days to display in each week public static final int DAYS_PER_WEEK = 7; protected int mNumWeeks = 6; protected boolean mShowWeekNumber = false; protected int mDaysPerWeek = 7; private static SimpleDateFormat YEAR_FORMAT = new SimpleDateFormat("yyyy", Locale.getDefault()); protected Context mContext; protected Handler mHandler; // highlighted time protected MonthAdapter.CalendarDay mSelectedDay; protected MonthAdapter mAdapter; protected MonthAdapter.CalendarDay mTempDay; // 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 = RecyclerView.SCROLL_STATE_IDLE; private DatePickerController mController; private LinearLayoutManager linearLayoutManager; 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); mSelectedDay = new MonthAdapter.CalendarDay(mController.getTimeZone()); mTempDay = new MonthAdapter.CalendarDay(mController.getTimeZone()); refreshAdapter(); onDateChanged(); } public void init(Context context) { linearLayoutManager = new LinearLayoutManager(context, LinearLayoutManager.VERTICAL, false); setLayoutManager(linearLayoutManager); mHandler = new Handler(); setLayoutParams(new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)); setVerticalScrollBarEnabled(false); setHorizontalScrollBarEnabled(false); mContext = context; setUpRecyclerView(); } public void setScrollOrientation(int orientation) { linearLayoutManager.setOrientation(orientation); } /** * Sets all the required fields for the list view. Override this method to * set a different list view behavior. */ protected void setUpRecyclerView() { setVerticalScrollBarEnabled(false); setFadingEdgeLength(0); GravitySnapHelper helper = new GravitySnapHelper(Gravity.TOP); helper.attachToRecyclerView(this); } public void onChange() { refreshAdapter(); } @Override protected void onLayout(boolean changed, int l, int t, int r, int b) { super.onLayout(changed, l, t, r, b); final MonthAdapter.CalendarDay focusedDay = findAccessibilityFocus(); restoreAccessibilityFocus(focusedDay); } /** * 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(mController); } else { mAdapter.setSelectedDay(mSelectedDay); } // refresh the view with the new parameters setAdapter(mAdapter); } public abstract MonthAdapter createMonthAdapter(DatePickerController controller); /** * 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 */ public boolean goTo(MonthAdapter.CalendarDay day, boolean animate, boolean setSelected, boolean forceScroll) { // Set the selected day if (setSelected) { mSelectedDay.set(day); } mTempDay.set(day); int minMonth = mController.getStartDate().get(Calendar.MONTH); final int position = (day.year - mController.getMinYear()) * MonthAdapter.MONTHS_IN_YEAR + day.month - minMonth; 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 = child != null ? getChildAdapterPosition(child) : 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 = RecyclerView.SCROLL_STATE_DRAGGING; if (animate) { smoothScrollToPosition(position); 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() { ((LinearLayoutManager) getLayoutManager()).scrollToPositionWithOffset(position, 0); } }); } /** * 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(MonthAdapter.CalendarDay date) { mCurrentMonthDisplayed = date.month; } /** * Gets the position of the view that is most prominently displayed within the list. */ public int getMostVisiblePosition() { return getChildAdapterPosition(getMostVisibleMonth()); } public MonthView getMostVisibleMonth() { boolean verticalScroll = ((LinearLayoutManager) getLayoutManager()).getOrientation() == LinearLayoutManager.VERTICAL; final int maxSize = verticalScroll ? getHeight() : getWidth(); int maxDisplayedSize = 0; int i = 0; int size = 0; MonthView mostVisibleMonth = null; while (size < maxSize) { View child = getChildAt(i); if (child == null) { break; } size = verticalScroll ? child.getBottom() : getRight(); int displayedSize = Math.min(size, maxSize) - Math.max(0, child.getTop()); if (displayedSize > maxDisplayedSize) { mostVisibleMonth = (MonthView) child; maxDisplayedSize = displayedSize; } i++; } return mostVisibleMonth; } @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 MonthAdapter.CalendarDay findAccessibilityFocus() { final int childCount = getChildCount(); for (int i = 0; i < childCount; i++) { final View child = getChildAt(i); if (child instanceof MonthView) { final MonthAdapter.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(MonthAdapter.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 public void onInitializeAccessibilityEvent(@NonNull AccessibilityEvent event) { super.onInitializeAccessibilityEvent(event); event.setItemCount(-1); } private static String getMonthAndYearString(MonthAdapter.CalendarDay day) { Calendar cal = Calendar.getInstance(); cal.set(day.year, day.month, day.day); String sbuf = ""; sbuf += cal.getDisplayName(Calendar.MONTH, Calendar.LONG, Locale.getDefault()); sbuf += " "; sbuf += YEAR_FORMAT.format(cal.getTime()); return sbuf; } /** * Necessary for accessibility, to ensure we support "scrolling" forward and backward * in the month list. */ @Override @SuppressWarnings("deprecation") public void onInitializeAccessibilityNodeInfo(@NonNull AccessibilityNodeInfo info) { super.onInitializeAccessibilityNodeInfo(info); if (Build.VERSION.SDK_INT >= 21) { info.addAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_SCROLL_BACKWARD); info.addAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_SCROLL_FORWARD); } else { 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 minMonth = mController.getStartDate().get(Calendar.MONTH); int month = (firstVisiblePosition + minMonth) % MonthAdapter.MONTHS_IN_YEAR; int year = (firstVisiblePosition + minMonth) / MonthAdapter.MONTHS_IN_YEAR + mController.getMinYear(); MonthAdapter.CalendarDay day = new MonthAdapter.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); return true; } private int getFirstVisiblePosition() { return getChildAdapterPosition(getChildAt(0)); } }