DayPickerView.java revision 2d4c476aca79cc0f5b6f1e8561afd4bcc808dd54
1/* 2 * Copyright (C) 2014 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 17package android.widget; 18 19import android.content.Context; 20import android.content.res.ColorStateList; 21import android.content.res.Configuration; 22import android.os.Bundle; 23import android.util.Log; 24import android.util.MathUtils; 25import android.view.View; 26import android.view.ViewConfiguration; 27import android.view.accessibility.AccessibilityEvent; 28import android.view.accessibility.AccessibilityNodeInfo; 29 30import java.text.SimpleDateFormat; 31import java.util.Calendar; 32import java.util.Locale; 33 34/** 35 * This displays a list of months in a calendar format with selectable days. 36 */ 37class DayPickerView extends ListView implements AbsListView.OnScrollListener { 38 private static final String TAG = "DayPickerView"; 39 40 // How long the GoTo fling animation should last 41 private static final int GOTO_SCROLL_DURATION = 250; 42 43 // How long to wait after receiving an onScrollStateChanged notification before acting on it 44 private static final int SCROLL_CHANGE_DELAY = 40; 45 46 // so that the top line will be under the separator 47 private static final int LIST_TOP_OFFSET = -1; 48 49 private final SimpleMonthAdapter mAdapter = new SimpleMonthAdapter(getContext()); 50 51 private final ScrollStateRunnable mScrollStateChangedRunnable = new ScrollStateRunnable(this); 52 53 private SimpleDateFormat mYearFormat = new SimpleDateFormat("yyyy", Locale.getDefault()); 54 55 // highlighted time 56 private Calendar mSelectedDay = Calendar.getInstance(); 57 private Calendar mTempDay = Calendar.getInstance(); 58 private Calendar mMinDate = Calendar.getInstance(); 59 private Calendar mMaxDate = Calendar.getInstance(); 60 61 private Calendar mTempCalendar; 62 63 private OnDaySelectedListener mOnDaySelectedListener; 64 65 // which month should be displayed/highlighted [0-11] 66 private int mCurrentMonthDisplayed; 67 // used for tracking what state listview is in 68 private int mPreviousScrollState = OnScrollListener.SCROLL_STATE_IDLE; 69 // used for tracking what state listview is in 70 private int mCurrentScrollState = OnScrollListener.SCROLL_STATE_IDLE; 71 72 private boolean mPerformingScroll; 73 74 public DayPickerView(Context context) { 75 super(context); 76 77 setAdapter(mAdapter); 78 setLayoutParams(new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)); 79 setDrawSelectorOnTop(false); 80 setUpListView(); 81 82 goTo(mSelectedDay.getTimeInMillis(), false, false, true); 83 84 mAdapter.setOnDaySelectedListener(mProxyOnDaySelectedListener); 85 } 86 87 /** 88 * Sets the currently selected date to the specified timestamp. Jumps 89 * immediately to the new date. To animate to the new date, use 90 * {@link #setDate(long, boolean, boolean)}. 91 * 92 * @param timeInMillis 93 */ 94 public void setDate(long timeInMillis) { 95 setDate(timeInMillis, false, true); 96 } 97 98 public void setDate(long timeInMillis, boolean animate, boolean forceScroll) { 99 goTo(timeInMillis, animate, true, forceScroll); 100 } 101 102 public long getDate() { 103 return mSelectedDay.getTimeInMillis(); 104 } 105 106 public void setFirstDayOfWeek(int firstDayOfWeek) { 107 mAdapter.setFirstDayOfWeek(firstDayOfWeek); 108 } 109 110 public int getFirstDayOfWeek() { 111 return mAdapter.getFirstDayOfWeek(); 112 } 113 114 public void setMinDate(long timeInMillis) { 115 mMinDate.setTimeInMillis(timeInMillis); 116 onRangeChanged(); 117 } 118 119 public long getMinDate() { 120 return mMinDate.getTimeInMillis(); 121 } 122 123 public void setMaxDate(long timeInMillis) { 124 mMaxDate.setTimeInMillis(timeInMillis); 125 onRangeChanged(); 126 } 127 128 public long getMaxDate() { 129 return mMaxDate.getTimeInMillis(); 130 } 131 132 /** 133 * Handles changes to date range. 134 */ 135 public void onRangeChanged() { 136 mAdapter.setRange(mMinDate, mMaxDate); 137 138 // Changing the min/max date changes the selection position since we 139 // don't really have stable IDs. Jumps immediately to the new position. 140 goTo(mSelectedDay.getTimeInMillis(), false, false, true); 141 } 142 143 /** 144 * Sets the listener to call when the user selects a day. 145 * 146 * @param listener The listener to call. 147 */ 148 public void setOnDaySelectedListener(OnDaySelectedListener listener) { 149 mOnDaySelectedListener = listener; 150 } 151 152 /* 153 * Sets all the required fields for the list view. Override this method to 154 * set a different list view behavior. 155 */ 156 private void setUpListView() { 157 // Transparent background on scroll 158 setCacheColorHint(0); 159 // No dividers 160 setDivider(null); 161 // Items are clickable 162 setItemsCanFocus(true); 163 // The thumb gets in the way, so disable it 164 setFastScrollEnabled(false); 165 setVerticalScrollBarEnabled(false); 166 setOnScrollListener(this); 167 setFadingEdgeLength(0); 168 // Make the scrolling behavior nicer 169 setFriction(ViewConfiguration.getScrollFriction()); 170 } 171 172 private int getDiffMonths(Calendar start, Calendar end) { 173 final int diffYears = end.get(Calendar.YEAR) - start.get(Calendar.YEAR); 174 final int diffMonths = end.get(Calendar.MONTH) - start.get(Calendar.MONTH) + 12 * diffYears; 175 return diffMonths; 176 } 177 178 private int getPositionFromDay(long timeInMillis) { 179 final int diffMonthMax = getDiffMonths(mMinDate, mMaxDate); 180 final int diffMonth = getDiffMonths(mMinDate, getTempCalendarForTime(timeInMillis)); 181 return MathUtils.constrain(diffMonth, 0, diffMonthMax); 182 } 183 184 private Calendar getTempCalendarForTime(long timeInMillis) { 185 if (mTempCalendar == null) { 186 mTempCalendar = Calendar.getInstance(); 187 } 188 mTempCalendar.setTimeInMillis(timeInMillis); 189 return mTempCalendar; 190 } 191 192 /** 193 * This moves to the specified time in the view. If the time is not already 194 * in range it will move the list so that the first of the month containing 195 * the time is at the top of the view. If the new time is already in view 196 * the list will not be scrolled unless forceScroll is true. This time may 197 * optionally be highlighted as selected as well. 198 * 199 * @param day The day to move to 200 * @param animate Whether to scroll to the given time or just redraw at the 201 * new location 202 * @param setSelected Whether to set the given time as selected 203 * @param forceScroll Whether to recenter even if the time is already 204 * visible 205 * @return Whether or not the view animated to the new location 206 */ 207 private boolean goTo(long day, boolean animate, boolean setSelected, boolean forceScroll) { 208 209 // Set the selected day 210 if (setSelected) { 211 mSelectedDay.setTimeInMillis(day); 212 } 213 214 mTempDay.setTimeInMillis(day); 215 final int position = getPositionFromDay(day); 216 217 View child; 218 int i = 0; 219 int top = 0; 220 // Find a child that's completely in the view 221 do { 222 child = getChildAt(i++); 223 if (child == null) { 224 break; 225 } 226 top = child.getTop(); 227 } while (top < 0); 228 229 // Compute the first and last position visible 230 int selectedPosition; 231 if (child != null) { 232 selectedPosition = getPositionForView(child); 233 } else { 234 selectedPosition = 0; 235 } 236 237 if (setSelected) { 238 mAdapter.setSelectedDay(mSelectedDay); 239 } 240 241 // Check if the selected day is now outside of our visible range 242 // and if so scroll to the month that contains it 243 if (position != selectedPosition || forceScroll) { 244 setMonthDisplayed(mTempDay); 245 mPreviousScrollState = OnScrollListener.SCROLL_STATE_FLING; 246 if (animate) { 247 smoothScrollToPositionFromTop( 248 position, LIST_TOP_OFFSET, GOTO_SCROLL_DURATION); 249 return true; 250 } else { 251 postSetSelection(position); 252 } 253 } else if (setSelected) { 254 setMonthDisplayed(mSelectedDay); 255 } 256 return false; 257 } 258 259 public void postSetSelection(final int position) { 260 clearFocus(); 261 post(new Runnable() { 262 263 @Override 264 public void run() { 265 setSelection(position); 266 } 267 }); 268 onScrollStateChanged(this, OnScrollListener.SCROLL_STATE_IDLE); 269 } 270 271 /** 272 * Updates the title and selected month if the view has moved to a new 273 * month. 274 */ 275 @Override 276 public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount, 277 int totalItemCount) { 278 SimpleMonthView child = (SimpleMonthView) view.getChildAt(0); 279 if (child == null) { 280 return; 281 } 282 283 mPreviousScrollState = mCurrentScrollState; 284 } 285 286 /** 287 * Sets the month displayed at the top of this view based on time. Override 288 * to add custom events when the title is changed. 289 */ 290 protected void setMonthDisplayed(Calendar date) { 291 if (mCurrentMonthDisplayed != date.get(Calendar.MONTH)) { 292 mCurrentMonthDisplayed = date.get(Calendar.MONTH); 293 invalidateViews(); 294 } 295 } 296 297 @Override 298 public void onScrollStateChanged(AbsListView view, int scrollState) { 299 // use a post to prevent re-entering onScrollStateChanged before it 300 // exits 301 mScrollStateChangedRunnable.doScrollStateChange(view, scrollState); 302 } 303 304 void setCalendarTextColor(ColorStateList colors) { 305 mAdapter.setCalendarTextColor(colors); 306 } 307 308 void setCalendarDayBackgroundColor(ColorStateList dayBackgroundColor) { 309 mAdapter.setCalendarDayBackgroundColor(dayBackgroundColor); 310 } 311 312 void setCalendarTextAppearance(int resId) { 313 mAdapter.setCalendarTextAppearance(resId); 314 } 315 316 protected class ScrollStateRunnable implements Runnable { 317 private int mNewState; 318 private View mParent; 319 320 ScrollStateRunnable(View view) { 321 mParent = view; 322 } 323 324 /** 325 * Sets up the runnable with a short delay in case the scroll state 326 * immediately changes again. 327 * 328 * @param view The list view that changed state 329 * @param scrollState The new state it changed to 330 */ 331 public void doScrollStateChange(AbsListView view, int scrollState) { 332 mParent.removeCallbacks(this); 333 mNewState = scrollState; 334 mParent.postDelayed(this, SCROLL_CHANGE_DELAY); 335 } 336 337 @Override 338 public void run() { 339 mCurrentScrollState = mNewState; 340 if (Log.isLoggable(TAG, Log.DEBUG)) { 341 Log.d(TAG, 342 "new scroll state: " + mNewState + " old state: " + mPreviousScrollState); 343 } 344 // Fix the position after a scroll or a fling ends 345 if (mNewState == OnScrollListener.SCROLL_STATE_IDLE 346 && mPreviousScrollState != OnScrollListener.SCROLL_STATE_IDLE 347 && mPreviousScrollState != OnScrollListener.SCROLL_STATE_TOUCH_SCROLL) { 348 mPreviousScrollState = mNewState; 349 int i = 0; 350 View child = getChildAt(i); 351 while (child != null && child.getBottom() <= 0) { 352 child = getChildAt(++i); 353 } 354 if (child == null) { 355 // The view is no longer visible, just return 356 return; 357 } 358 int firstPosition = getFirstVisiblePosition(); 359 int lastPosition = getLastVisiblePosition(); 360 boolean scroll = firstPosition != 0 && lastPosition != getCount() - 1; 361 final int top = child.getTop(); 362 final int bottom = child.getBottom(); 363 final int midpoint = getHeight() / 2; 364 if (scroll && top < LIST_TOP_OFFSET) { 365 if (bottom > midpoint) { 366 smoothScrollBy(top, GOTO_SCROLL_DURATION); 367 } else { 368 smoothScrollBy(bottom, GOTO_SCROLL_DURATION); 369 } 370 } 371 } else { 372 mPreviousScrollState = mNewState; 373 } 374 } 375 } 376 377 /** 378 * Gets the position of the view that is most prominently displayed within the list view. 379 */ 380 public int getMostVisiblePosition() { 381 final int firstPosition = getFirstVisiblePosition(); 382 final int height = getHeight(); 383 384 int maxDisplayedHeight = 0; 385 int mostVisibleIndex = 0; 386 int i=0; 387 int bottom = 0; 388 while (bottom < height) { 389 View child = getChildAt(i); 390 if (child == null) { 391 break; 392 } 393 bottom = child.getBottom(); 394 int displayedHeight = Math.min(bottom, height) - Math.max(0, child.getTop()); 395 if (displayedHeight > maxDisplayedHeight) { 396 mostVisibleIndex = i; 397 maxDisplayedHeight = displayedHeight; 398 } 399 i++; 400 } 401 return firstPosition + mostVisibleIndex; 402 } 403 404 /** 405 * Attempts to return the date that has accessibility focus. 406 * 407 * @return The date that has accessibility focus, or {@code null} if no date 408 * has focus. 409 */ 410 private Calendar findAccessibilityFocus() { 411 final int childCount = getChildCount(); 412 for (int i = 0; i < childCount; i++) { 413 final View child = getChildAt(i); 414 if (child instanceof SimpleMonthView) { 415 final Calendar focus = ((SimpleMonthView) child).getAccessibilityFocus(); 416 if (focus != null) { 417 return focus; 418 } 419 } 420 } 421 422 return null; 423 } 424 425 /** 426 * Attempts to restore accessibility focus to a given date. No-op if 427 * {@code day} is {@code null}. 428 * 429 * @param day The date that should receive accessibility focus 430 * @return {@code true} if focus was restored 431 */ 432 private boolean restoreAccessibilityFocus(Calendar day) { 433 if (day == null) { 434 return false; 435 } 436 437 final int childCount = getChildCount(); 438 for (int i = 0; i < childCount; i++) { 439 final View child = getChildAt(i); 440 if (child instanceof SimpleMonthView) { 441 if (((SimpleMonthView) child).restoreAccessibilityFocus(day)) { 442 return true; 443 } 444 } 445 } 446 447 return false; 448 } 449 450 @Override 451 protected void layoutChildren() { 452 final Calendar focusedDay = findAccessibilityFocus(); 453 super.layoutChildren(); 454 if (mPerformingScroll) { 455 mPerformingScroll = false; 456 } else { 457 restoreAccessibilityFocus(focusedDay); 458 } 459 } 460 461 @Override 462 protected void onConfigurationChanged(Configuration newConfig) { 463 mYearFormat = new SimpleDateFormat("yyyy", Locale.getDefault()); 464 } 465 466 /** @hide */ 467 @Override 468 public void onInitializeAccessibilityEventInternal(AccessibilityEvent event) { 469 super.onInitializeAccessibilityEventInternal(event); 470 event.setItemCount(-1); 471 } 472 473 private String getMonthAndYearString(Calendar day) { 474 final StringBuilder sbuf = new StringBuilder(); 475 sbuf.append(day.getDisplayName(Calendar.MONTH, Calendar.LONG, Locale.getDefault())); 476 sbuf.append(" "); 477 sbuf.append(mYearFormat.format(day.getTime())); 478 return sbuf.toString(); 479 } 480 481 /** 482 * Necessary for accessibility, to ensure we support "scrolling" forward and backward 483 * in the month list. 484 * 485 * @hide 486 */ 487 @Override 488 public void onInitializeAccessibilityNodeInfoInternal(AccessibilityNodeInfo info) { 489 super.onInitializeAccessibilityNodeInfoInternal(info); 490 info.addAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_SCROLL_FORWARD); 491 info.addAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_SCROLL_BACKWARD); 492 } 493 494 /** 495 * When scroll forward/backward events are received, announce the newly scrolled-to month. 496 * 497 * @hide 498 */ 499 @Override 500 public boolean performAccessibilityActionInternal(int action, Bundle arguments) { 501 if (action != AccessibilityNodeInfo.ACTION_SCROLL_FORWARD && 502 action != AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD) { 503 return super.performAccessibilityActionInternal(action, arguments); 504 } 505 506 // Figure out what month is showing. 507 final int firstVisiblePosition = getFirstVisiblePosition(); 508 final int month = firstVisiblePosition % 12; 509 final int year = firstVisiblePosition / 12 + mMinDate.get(Calendar.YEAR); 510 final Calendar day = Calendar.getInstance(); 511 day.set(year, month, 1); 512 513 // Scroll either forward or backward one month. 514 if (action == AccessibilityNodeInfo.ACTION_SCROLL_FORWARD) { 515 day.add(Calendar.MONTH, 1); 516 if (day.get(Calendar.MONTH) == 12) { 517 day.set(Calendar.MONTH, 0); 518 day.add(Calendar.YEAR, 1); 519 } 520 } else if (action == AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD) { 521 View firstVisibleView = getChildAt(0); 522 // If the view is fully visible, jump one month back. Otherwise, we'll just jump 523 // to the first day of first visible month. 524 if (firstVisibleView != null && firstVisibleView.getTop() >= -1) { 525 // There's an off-by-one somewhere, so the top of the first visible item will 526 // actually be -1 when it's at the exact top. 527 day.add(Calendar.MONTH, -1); 528 if (day.get(Calendar.MONTH) == -1) { 529 day.set(Calendar.MONTH, 11); 530 day.add(Calendar.YEAR, -1); 531 } 532 } 533 } 534 535 // Go to that month. 536 announceForAccessibility(getMonthAndYearString(day)); 537 goTo(day.getTimeInMillis(), true, false, true); 538 mPerformingScroll = true; 539 return true; 540 } 541 542 public interface OnDaySelectedListener { 543 public void onDaySelected(DayPickerView view, Calendar day); 544 } 545 546 private final SimpleMonthAdapter.OnDaySelectedListener 547 mProxyOnDaySelectedListener = new SimpleMonthAdapter.OnDaySelectedListener() { 548 @Override 549 public void onDaySelected(SimpleMonthAdapter adapter, Calendar day) { 550 if (mOnDaySelectedListener != null) { 551 mOnDaySelectedListener.onDaySelected(DayPickerView.this, day); 552 } 553 } 554 }; 555} 556