1 /* 2 * Copyright (C) 2013 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package com.android.datetimepicker.date; 18 19 import android.annotation.SuppressLint; 20 import android.content.Context; 21 import android.os.Build; 22 import android.os.Bundle; 23 import android.os.Handler; 24 import android.util.AttributeSet; 25 import android.util.Log; 26 import android.view.View; 27 import android.view.ViewConfiguration; 28 import android.view.accessibility.AccessibilityEvent; 29 import android.view.accessibility.AccessibilityNodeInfo; 30 import android.widget.AbsListView; 31 import android.widget.AbsListView.OnScrollListener; 32 import android.widget.ListView; 33 34 import com.android.datetimepicker.Utils; 35 import com.android.datetimepicker.date.DatePickerDialog.OnDateChangedListener; 36 import com.android.datetimepicker.date.MonthAdapter.CalendarDay; 37 38 import java.text.SimpleDateFormat; 39 import java.util.Calendar; 40 import java.util.Locale; 41 42 /** 43 * This displays a list of months in a calendar format with selectable days. 44 */ 45 public abstract class DayPickerView extends ListView implements OnScrollListener, 46 OnDateChangedListener { 47 48 private static final String TAG = "MonthFragment"; 49 50 // Affects when the month selection will change while scrolling up 51 protected static final int SCROLL_HYST_WEEKS = 2; 52 // How long the GoTo fling animation should last 53 protected static final int GOTO_SCROLL_DURATION = 250; 54 // How long to wait after receiving an onScrollStateChanged notification 55 // before acting on it 56 protected static final int SCROLL_CHANGE_DELAY = 40; 57 // The number of days to display in each week 58 public static final int DAYS_PER_WEEK = 7; 59 public static int LIST_TOP_OFFSET = -1; // so that the top line will be 60 // under the separator 61 // You can override these numbers to get a different appearance 62 protected int mNumWeeks = 6; 63 protected boolean mShowWeekNumber = false; 64 protected int mDaysPerWeek = 7; 65 private static SimpleDateFormat YEAR_FORMAT = new SimpleDateFormat("yyyy", Locale.getDefault()); 66 67 // These affect the scroll speed and feel 68 protected float mFriction = 1.0f; 69 70 protected Context mContext; 71 protected Handler mHandler; 72 73 // highlighted time 74 protected CalendarDay mSelectedDay = new CalendarDay(); 75 protected MonthAdapter mAdapter; 76 77 protected CalendarDay mTempDay = new CalendarDay(); 78 79 // When the week starts; numbered like Time.<WEEKDAY> (e.g. SUNDAY=0). 80 protected int mFirstDayOfWeek; 81 // The last name announced by accessibility 82 protected CharSequence mPrevMonthName; 83 // which month should be displayed/highlighted [0-11] 84 protected int mCurrentMonthDisplayed; 85 // used for tracking during a scroll 86 protected long mPreviousScrollPosition; 87 // used for tracking what state listview is in 88 protected int mPreviousScrollState = OnScrollListener.SCROLL_STATE_IDLE; 89 // used for tracking what state listview is in 90 protected int mCurrentScrollState = OnScrollListener.SCROLL_STATE_IDLE; 91 92 private DatePickerController mController; 93 private boolean mPerformingScroll; 94 DayPickerView(Context context, AttributeSet attrs)95 public DayPickerView(Context context, AttributeSet attrs) { 96 super(context, attrs); 97 init(context); 98 } 99 DayPickerView(Context context, DatePickerController controller)100 public DayPickerView(Context context, DatePickerController controller) { 101 super(context); 102 init(context); 103 setController(controller); 104 } 105 setController(DatePickerController controller)106 public void setController(DatePickerController controller) { 107 mController = controller; 108 mController.registerOnDateChangedListener(this); 109 refreshAdapter(); 110 onDateChanged(); 111 } 112 init(Context context)113 public void init(Context context) { 114 mHandler = new Handler(); 115 setLayoutParams(new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)); 116 setDrawSelectorOnTop(false); 117 118 mContext = context; 119 setUpListView(); 120 } 121 onChange()122 public void onChange() { 123 refreshAdapter(); 124 } 125 126 /** 127 * Creates a new adapter if necessary and sets up its parameters. Override 128 * this method to provide a custom adapter. 129 */ refreshAdapter()130 protected void refreshAdapter() { 131 if (mAdapter == null) { 132 mAdapter = createMonthAdapter(getContext(), mController); 133 } else { 134 mAdapter.setSelectedDay(mSelectedDay); 135 } 136 // refresh the view with the new parameters 137 setAdapter(mAdapter); 138 } 139 createMonthAdapter(Context context, DatePickerController controller)140 public abstract MonthAdapter createMonthAdapter(Context context, 141 DatePickerController controller); 142 143 /* 144 * Sets all the required fields for the list view. Override this method to 145 * set a different list view behavior. 146 */ setUpListView()147 protected void setUpListView() { 148 // Transparent background on scroll 149 setCacheColorHint(0); 150 // No dividers 151 setDivider(null); 152 // Items are clickable 153 setItemsCanFocus(true); 154 // The thumb gets in the way, so disable it 155 setFastScrollEnabled(false); 156 setVerticalScrollBarEnabled(false); 157 setOnScrollListener(this); 158 setFadingEdgeLength(0); 159 // Make the scrolling behavior nicer 160 setFriction(ViewConfiguration.getScrollFriction() * mFriction); 161 } 162 163 /** 164 * This moves to the specified time in the view. If the time is not already 165 * in range it will move the list so that the first of the month containing 166 * the time is at the top of the view. If the new time is already in view 167 * the list will not be scrolled unless forceScroll is true. This time may 168 * optionally be highlighted as selected as well. 169 * 170 * @param time The time to move to 171 * @param animate Whether to scroll to the given time or just redraw at the 172 * new location 173 * @param setSelected Whether to set the given time as selected 174 * @param forceScroll Whether to recenter even if the time is already 175 * visible 176 * @return Whether or not the view animated to the new location 177 */ goTo(CalendarDay day, boolean animate, boolean setSelected, boolean forceScroll)178 public boolean goTo(CalendarDay day, boolean animate, boolean setSelected, boolean forceScroll) { 179 180 // Set the selected day 181 if (setSelected) { 182 mSelectedDay.set(day); 183 } 184 185 mTempDay.set(day); 186 final int position = (day.year - mController.getMinYear()) 187 * MonthAdapter.MONTHS_IN_YEAR + day.month; 188 189 View child; 190 int i = 0; 191 int top = 0; 192 // Find a child that's completely in the view 193 do { 194 child = getChildAt(i++); 195 if (child == null) { 196 break; 197 } 198 top = child.getTop(); 199 if (Log.isLoggable(TAG, Log.DEBUG)) { 200 Log.d(TAG, "child at " + (i - 1) + " has top " + top); 201 } 202 } while (top < 0); 203 204 // Compute the first and last position visible 205 int selectedPosition; 206 if (child != null) { 207 selectedPosition = getPositionForView(child); 208 } else { 209 selectedPosition = 0; 210 } 211 212 if (setSelected) { 213 mAdapter.setSelectedDay(mSelectedDay); 214 } 215 216 if (Log.isLoggable(TAG, Log.DEBUG)) { 217 Log.d(TAG, "GoTo position " + position); 218 } 219 // Check if the selected day is now outside of our visible range 220 // and if so scroll to the month that contains it 221 if (position != selectedPosition || forceScroll) { 222 setMonthDisplayed(mTempDay); 223 mPreviousScrollState = OnScrollListener.SCROLL_STATE_FLING; 224 if (animate) { 225 smoothScrollToPositionFromTop( 226 position, LIST_TOP_OFFSET, GOTO_SCROLL_DURATION); 227 return true; 228 } else { 229 postSetSelection(position); 230 } 231 } else if (setSelected) { 232 setMonthDisplayed(mSelectedDay); 233 } 234 return false; 235 } 236 postSetSelection(final int position)237 public void postSetSelection(final int position) { 238 clearFocus(); 239 post(new Runnable() { 240 241 @Override 242 public void run() { 243 setSelection(position); 244 } 245 }); 246 onScrollStateChanged(this, OnScrollListener.SCROLL_STATE_IDLE); 247 } 248 249 /** 250 * Updates the title and selected month if the view has moved to a new 251 * month. 252 */ 253 @Override onScroll( AbsListView view, int firstVisibleItem, int visibleItemCount, int totalItemCount)254 public void onScroll( 255 AbsListView view, int firstVisibleItem, int visibleItemCount, int totalItemCount) { 256 MonthView child = (MonthView) view.getChildAt(0); 257 if (child == null) { 258 return; 259 } 260 261 // Figure out where we are 262 long currScroll = view.getFirstVisiblePosition() * child.getHeight() - child.getBottom(); 263 mPreviousScrollPosition = currScroll; 264 mPreviousScrollState = mCurrentScrollState; 265 } 266 267 /** 268 * Sets the month displayed at the top of this view based on time. Override 269 * to add custom events when the title is changed. 270 */ setMonthDisplayed(CalendarDay date)271 protected void setMonthDisplayed(CalendarDay date) { 272 mCurrentMonthDisplayed = date.month; 273 invalidateViews(); 274 } 275 276 @Override onScrollStateChanged(AbsListView view, int scrollState)277 public void onScrollStateChanged(AbsListView view, int scrollState) { 278 // use a post to prevent re-entering onScrollStateChanged before it 279 // exits 280 mScrollStateChangedRunnable.doScrollStateChange(view, scrollState); 281 } 282 283 protected ScrollStateRunnable mScrollStateChangedRunnable = new ScrollStateRunnable(); 284 285 protected class ScrollStateRunnable implements Runnable { 286 private int mNewState; 287 288 /** 289 * Sets up the runnable with a short delay in case the scroll state 290 * immediately changes again. 291 * 292 * @param view The list view that changed state 293 * @param scrollState The new state it changed to 294 */ doScrollStateChange(AbsListView view, int scrollState)295 public void doScrollStateChange(AbsListView view, int scrollState) { 296 mHandler.removeCallbacks(this); 297 mNewState = scrollState; 298 mHandler.postDelayed(this, SCROLL_CHANGE_DELAY); 299 } 300 301 @Override run()302 public void run() { 303 mCurrentScrollState = mNewState; 304 if (Log.isLoggable(TAG, Log.DEBUG)) { 305 Log.d(TAG, 306 "new scroll state: " + mNewState + " old state: " + mPreviousScrollState); 307 } 308 // Fix the position after a scroll or a fling ends 309 if (mNewState == OnScrollListener.SCROLL_STATE_IDLE 310 && mPreviousScrollState != OnScrollListener.SCROLL_STATE_IDLE 311 && mPreviousScrollState != OnScrollListener.SCROLL_STATE_TOUCH_SCROLL) { 312 mPreviousScrollState = mNewState; 313 int i = 0; 314 View child = getChildAt(i); 315 while (child != null && child.getBottom() <= 0) { 316 child = getChildAt(++i); 317 } 318 if (child == null) { 319 // The view is no longer visible, just return 320 return; 321 } 322 int firstPosition = getFirstVisiblePosition(); 323 int lastPosition = getLastVisiblePosition(); 324 boolean scroll = firstPosition != 0 && lastPosition != getCount() - 1; 325 final int top = child.getTop(); 326 final int bottom = child.getBottom(); 327 final int midpoint = getHeight() / 2; 328 if (scroll && top < LIST_TOP_OFFSET) { 329 if (bottom > midpoint) { 330 smoothScrollBy(top, GOTO_SCROLL_DURATION); 331 } else { 332 smoothScrollBy(bottom, GOTO_SCROLL_DURATION); 333 } 334 } 335 } else { 336 mPreviousScrollState = mNewState; 337 } 338 } 339 } 340 341 /** 342 * Gets the position of the view that is most prominently displayed within the list view. 343 */ getMostVisiblePosition()344 public int getMostVisiblePosition() { 345 final int firstPosition = getFirstVisiblePosition(); 346 final int height = getHeight(); 347 348 int maxDisplayedHeight = 0; 349 int mostVisibleIndex = 0; 350 int i=0; 351 int bottom = 0; 352 while (bottom < height) { 353 View child = getChildAt(i); 354 if (child == null) { 355 break; 356 } 357 bottom = child.getBottom(); 358 int displayedHeight = Math.min(bottom, height) - Math.max(0, child.getTop()); 359 if (displayedHeight > maxDisplayedHeight) { 360 mostVisibleIndex = i; 361 maxDisplayedHeight = displayedHeight; 362 } 363 i++; 364 } 365 return firstPosition + mostVisibleIndex; 366 } 367 368 @Override onDateChanged()369 public void onDateChanged() { 370 goTo(mController.getSelectedDay(), false, true, true); 371 } 372 373 /** 374 * Attempts to return the date that has accessibility focus. 375 * 376 * @return The date that has accessibility focus, or {@code null} if no date 377 * has focus. 378 */ findAccessibilityFocus()379 private CalendarDay findAccessibilityFocus() { 380 final int childCount = getChildCount(); 381 for (int i = 0; i < childCount; i++) { 382 final View child = getChildAt(i); 383 if (child instanceof MonthView) { 384 final CalendarDay focus = ((MonthView) child).getAccessibilityFocus(); 385 if (focus != null) { 386 if (Build.VERSION.SDK_INT == Build.VERSION_CODES.JELLY_BEAN_MR1) { 387 // Clear focus to avoid ListView bug in Jelly Bean MR1. 388 ((MonthView) child).clearAccessibilityFocus(); 389 } 390 return focus; 391 } 392 } 393 } 394 395 return null; 396 } 397 398 /** 399 * Attempts to restore accessibility focus to a given date. No-op if 400 * {@code day} is {@code null}. 401 * 402 * @param day The date that should receive accessibility focus 403 * @return {@code true} if focus was restored 404 */ restoreAccessibilityFocus(CalendarDay day)405 private boolean restoreAccessibilityFocus(CalendarDay day) { 406 if (day == null) { 407 return false; 408 } 409 410 final int childCount = getChildCount(); 411 for (int i = 0; i < childCount; i++) { 412 final View child = getChildAt(i); 413 if (child instanceof MonthView) { 414 if (((MonthView) child).restoreAccessibilityFocus(day)) { 415 return true; 416 } 417 } 418 } 419 420 return false; 421 } 422 423 @Override layoutChildren()424 protected void layoutChildren() { 425 final CalendarDay focusedDay = findAccessibilityFocus(); 426 super.layoutChildren(); 427 if (mPerformingScroll) { 428 mPerformingScroll = false; 429 } else { 430 restoreAccessibilityFocus(focusedDay); 431 } 432 } 433 434 @Override onInitializeAccessibilityEvent(AccessibilityEvent event)435 public void onInitializeAccessibilityEvent(AccessibilityEvent event) { 436 super.onInitializeAccessibilityEvent(event); 437 event.setItemCount(-1); 438 } 439 getMonthAndYearString(CalendarDay day)440 private static String getMonthAndYearString(CalendarDay day) { 441 Calendar cal = Calendar.getInstance(); 442 cal.set(day.year, day.month, day.day); 443 444 StringBuffer sbuf = new StringBuffer(); 445 sbuf.append(cal.getDisplayName(Calendar.MONTH, Calendar.LONG, Locale.getDefault())); 446 sbuf.append(" "); 447 sbuf.append(YEAR_FORMAT.format(cal.getTime())); 448 return sbuf.toString(); 449 } 450 451 /** 452 * Necessary for accessibility, to ensure we support "scrolling" forward and backward 453 * in the month list. 454 */ 455 @Override onInitializeAccessibilityNodeInfo(AccessibilityNodeInfo info)456 public void onInitializeAccessibilityNodeInfo(AccessibilityNodeInfo info) { 457 super.onInitializeAccessibilityNodeInfo(info); 458 info.addAction(AccessibilityNodeInfo.ACTION_SCROLL_FORWARD); 459 info.addAction(AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD); 460 } 461 462 /** 463 * When scroll forward/backward events are received, announce the newly scrolled-to month. 464 */ 465 @SuppressLint("NewApi") 466 @Override performAccessibilityAction(int action, Bundle arguments)467 public boolean performAccessibilityAction(int action, Bundle arguments) { 468 if (action != AccessibilityNodeInfo.ACTION_SCROLL_FORWARD && 469 action != AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD) { 470 return super.performAccessibilityAction(action, arguments); 471 } 472 473 // Figure out what month is showing. 474 int firstVisiblePosition = getFirstVisiblePosition(); 475 int month = firstVisiblePosition % 12; 476 int year = firstVisiblePosition / 12 + mController.getMinYear(); 477 CalendarDay day = new CalendarDay(year, month, 1); 478 479 // Scroll either forward or backward one month. 480 if (action == AccessibilityNodeInfo.ACTION_SCROLL_FORWARD) { 481 day.month++; 482 if (day.month == 12) { 483 day.month = 0; 484 day.year++; 485 } 486 } else if (action == AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD) { 487 View firstVisibleView = getChildAt(0); 488 // If the view is fully visible, jump one month back. Otherwise, we'll just jump 489 // to the first day of first visible month. 490 if (firstVisibleView != null && firstVisibleView.getTop() >= -1) { 491 // There's an off-by-one somewhere, so the top of the first visible item will 492 // actually be -1 when it's at the exact top. 493 day.month--; 494 if (day.month == -1) { 495 day.month = 11; 496 day.year--; 497 } 498 } 499 } 500 501 // Go to that month. 502 Utils.tryAccessibilityAnnounce(this, getMonthAndYearString(day)); 503 goTo(day, true, false, true); 504 mPerformingScroll = true; 505 return true; 506 } 507 } 508