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 17 package android.widget; 18 19 import android.content.Context; 20 import android.content.res.ColorStateList; 21 import android.content.res.Configuration; 22 import android.os.Bundle; 23 import android.util.Log; 24 import android.util.MathUtils; 25 import android.view.View; 26 import android.view.ViewConfiguration; 27 import android.view.accessibility.AccessibilityEvent; 28 import android.view.accessibility.AccessibilityNodeInfo; 29 30 import java.text.SimpleDateFormat; 31 import java.util.Calendar; 32 import java.util.Locale; 33 34 /** 35 * This displays a list of months in a calendar format with selectable days. 36 */ 37 class 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 setCalendarTextAppearance(int resId) { 309 mAdapter.setCalendarTextAppearance(resId); 310 } 311 312 protected class ScrollStateRunnable implements Runnable { 313 private int mNewState; 314 private View mParent; 315 316 ScrollStateRunnable(View view) { 317 mParent = view; 318 } 319 320 /** 321 * Sets up the runnable with a short delay in case the scroll state 322 * immediately changes again. 323 * 324 * @param view The list view that changed state 325 * @param scrollState The new state it changed to 326 */ 327 public void doScrollStateChange(AbsListView view, int scrollState) { 328 mParent.removeCallbacks(this); 329 mNewState = scrollState; 330 mParent.postDelayed(this, SCROLL_CHANGE_DELAY); 331 } 332 333 @Override 334 public void run() { 335 mCurrentScrollState = mNewState; 336 if (Log.isLoggable(TAG, Log.DEBUG)) { 337 Log.d(TAG, 338 "new scroll state: " + mNewState + " old state: " + mPreviousScrollState); 339 } 340 // Fix the position after a scroll or a fling ends 341 if (mNewState == OnScrollListener.SCROLL_STATE_IDLE 342 && mPreviousScrollState != OnScrollListener.SCROLL_STATE_IDLE 343 && mPreviousScrollState != OnScrollListener.SCROLL_STATE_TOUCH_SCROLL) { 344 mPreviousScrollState = mNewState; 345 int i = 0; 346 View child = getChildAt(i); 347 while (child != null && child.getBottom() <= 0) { 348 child = getChildAt(++i); 349 } 350 if (child == null) { 351 // The view is no longer visible, just return 352 return; 353 } 354 int firstPosition = getFirstVisiblePosition(); 355 int lastPosition = getLastVisiblePosition(); 356 boolean scroll = firstPosition != 0 && lastPosition != getCount() - 1; 357 final int top = child.getTop(); 358 final int bottom = child.getBottom(); 359 final int midpoint = getHeight() / 2; 360 if (scroll && top < LIST_TOP_OFFSET) { 361 if (bottom > midpoint) { 362 smoothScrollBy(top, GOTO_SCROLL_DURATION); 363 } else { 364 smoothScrollBy(bottom, GOTO_SCROLL_DURATION); 365 } 366 } 367 } else { 368 mPreviousScrollState = mNewState; 369 } 370 } 371 } 372 373 /** 374 * Gets the position of the view that is most prominently displayed within the list view. 375 */ 376 public int getMostVisiblePosition() { 377 final int firstPosition = getFirstVisiblePosition(); 378 final int height = getHeight(); 379 380 int maxDisplayedHeight = 0; 381 int mostVisibleIndex = 0; 382 int i=0; 383 int bottom = 0; 384 while (bottom < height) { 385 View child = getChildAt(i); 386 if (child == null) { 387 break; 388 } 389 bottom = child.getBottom(); 390 int displayedHeight = Math.min(bottom, height) - Math.max(0, child.getTop()); 391 if (displayedHeight > maxDisplayedHeight) { 392 mostVisibleIndex = i; 393 maxDisplayedHeight = displayedHeight; 394 } 395 i++; 396 } 397 return firstPosition + mostVisibleIndex; 398 } 399 400 /** 401 * Attempts to return the date that has accessibility focus. 402 * 403 * @return The date that has accessibility focus, or {@code null} if no date 404 * has focus. 405 */ 406 private Calendar findAccessibilityFocus() { 407 final int childCount = getChildCount(); 408 for (int i = 0; i < childCount; i++) { 409 final View child = getChildAt(i); 410 if (child instanceof SimpleMonthView) { 411 final Calendar focus = ((SimpleMonthView) child).getAccessibilityFocus(); 412 if (focus != null) { 413 return focus; 414 } 415 } 416 } 417 418 return null; 419 } 420 421 /** 422 * Attempts to restore accessibility focus to a given date. No-op if 423 * {@code day} is {@code null}. 424 * 425 * @param day The date that should receive accessibility focus 426 * @return {@code true} if focus was restored 427 */ 428 private boolean restoreAccessibilityFocus(Calendar day) { 429 if (day == null) { 430 return false; 431 } 432 433 final int childCount = getChildCount(); 434 for (int i = 0; i < childCount; i++) { 435 final View child = getChildAt(i); 436 if (child instanceof SimpleMonthView) { 437 if (((SimpleMonthView) child).restoreAccessibilityFocus(day)) { 438 return true; 439 } 440 } 441 } 442 443 return false; 444 } 445 446 @Override 447 protected void layoutChildren() { 448 final Calendar focusedDay = findAccessibilityFocus(); 449 super.layoutChildren(); 450 if (mPerformingScroll) { 451 mPerformingScroll = false; 452 } else { 453 restoreAccessibilityFocus(focusedDay); 454 } 455 } 456 457 @Override 458 protected void onConfigurationChanged(Configuration newConfig) { 459 mYearFormat = new SimpleDateFormat("yyyy", Locale.getDefault()); 460 } 461 462 @Override 463 public void onInitializeAccessibilityEvent(AccessibilityEvent event) { 464 super.onInitializeAccessibilityEvent(event); 465 event.setItemCount(-1); 466 } 467 468 private String getMonthAndYearString(Calendar day) { 469 final StringBuilder sbuf = new StringBuilder(); 470 sbuf.append(day.getDisplayName(Calendar.MONTH, Calendar.LONG, Locale.getDefault())); 471 sbuf.append(" "); 472 sbuf.append(mYearFormat.format(day.getTime())); 473 return sbuf.toString(); 474 } 475 476 /** 477 * Necessary for accessibility, to ensure we support "scrolling" forward and backward 478 * in the month list. 479 */ 480 @Override 481 public void onInitializeAccessibilityNodeInfo(AccessibilityNodeInfo info) { 482 super.onInitializeAccessibilityNodeInfo(info); 483 info.addAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_SCROLL_FORWARD); 484 info.addAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_SCROLL_BACKWARD); 485 } 486 487 /** 488 * When scroll forward/backward events are received, announce the newly scrolled-to month. 489 */ 490 @Override 491 public boolean performAccessibilityAction(int action, Bundle arguments) { 492 if (action != AccessibilityNodeInfo.ACTION_SCROLL_FORWARD && 493 action != AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD) { 494 return super.performAccessibilityAction(action, arguments); 495 } 496 497 // Figure out what month is showing. 498 final int firstVisiblePosition = getFirstVisiblePosition(); 499 final int month = firstVisiblePosition % 12; 500 final int year = firstVisiblePosition / 12 + mMinDate.get(Calendar.YEAR); 501 final Calendar day = Calendar.getInstance(); 502 day.set(year, month, 1); 503 504 // Scroll either forward or backward one month. 505 if (action == AccessibilityNodeInfo.ACTION_SCROLL_FORWARD) { 506 day.add(Calendar.MONTH, 1); 507 if (day.get(Calendar.MONTH) == 12) { 508 day.set(Calendar.MONTH, 0); 509 day.add(Calendar.YEAR, 1); 510 } 511 } else if (action == AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD) { 512 View firstVisibleView = getChildAt(0); 513 // If the view is fully visible, jump one month back. Otherwise, we'll just jump 514 // to the first day of first visible month. 515 if (firstVisibleView != null && firstVisibleView.getTop() >= -1) { 516 // There's an off-by-one somewhere, so the top of the first visible item will 517 // actually be -1 when it's at the exact top. 518 day.add(Calendar.MONTH, -1); 519 if (day.get(Calendar.MONTH) == -1) { 520 day.set(Calendar.MONTH, 11); 521 day.add(Calendar.YEAR, -1); 522 } 523 } 524 } 525 526 // Go to that month. 527 announceForAccessibility(getMonthAndYearString(day)); 528 goTo(day.getTimeInMillis(), true, false, true); 529 mPerformingScroll = true; 530 return true; 531 } 532 533 public interface OnDaySelectedListener { 534 public void onDaySelected(DayPickerView view, Calendar day); 535 } 536 537 private final SimpleMonthAdapter.OnDaySelectedListener 538 mProxyOnDaySelectedListener = new SimpleMonthAdapter.OnDaySelectedListener() { 539 @Override 540 public void onDaySelected(SimpleMonthAdapter adapter, Calendar day) { 541 if (mOnDaySelectedListener != null) { 542 mOnDaySelectedListener.onDaySelected(DayPickerView.this, day); 543 } 544 } 545 }; 546 } 547