DayPickerView.java revision 5ecbfeb38b6bdcfe8f3561f8cdcb4af9ba30c886
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.annotation.SuppressLint; 20import android.content.Context; 21import android.content.res.ColorStateList; 22import android.content.res.Configuration; 23import android.os.Build; 24import android.os.Bundle; 25import android.os.Handler; 26import android.util.AttributeSet; 27import android.util.Log; 28import android.util.MathUtils; 29import android.view.View; 30import android.view.ViewConfiguration; 31import android.view.accessibility.AccessibilityEvent; 32import android.view.accessibility.AccessibilityNodeInfo; 33 34import java.text.SimpleDateFormat; 35import java.util.Calendar; 36import java.util.Locale; 37 38/** 39 * This displays a list of months in a calendar format with selectable days. 40 */ 41class DayPickerView extends ListView implements AbsListView.OnScrollListener, 42 OnDateChangedListener { 43 44 private static final String TAG = "DayPickerView"; 45 46 // How long the GoTo fling animation should last 47 private static final int GOTO_SCROLL_DURATION = 250; 48 49 // How long to wait after receiving an onScrollStateChanged notification before acting on it 50 private static final int SCROLL_CHANGE_DELAY = 40; 51 52 private static int LIST_TOP_OFFSET = -1; // so that the top line will be under the separator 53 54 private SimpleDateFormat mYearFormat = new SimpleDateFormat("yyyy", Locale.getDefault()); 55 56 // These affect the scroll speed and feel 57 private float mFriction = 1.0f; 58 59 // highlighted time 60 private Calendar mSelectedDay = Calendar.getInstance(); 61 private Calendar mTempDay = Calendar.getInstance(); 62 private Calendar mMinDate = Calendar.getInstance(); 63 private Calendar mMaxDate = Calendar.getInstance(); 64 65 private SimpleMonthAdapter mAdapter; 66 67 // which month should be displayed/highlighted [0-11] 68 private int mCurrentMonthDisplayed; 69 // used for tracking what state listview is in 70 private int mPreviousScrollState = OnScrollListener.SCROLL_STATE_IDLE; 71 // used for tracking what state listview is in 72 private int mCurrentScrollState = OnScrollListener.SCROLL_STATE_IDLE; 73 74 private DatePickerController mController; 75 private boolean mPerformingScroll; 76 77 private ScrollStateRunnable mScrollStateChangedRunnable = new ScrollStateRunnable(this); 78 79 public DayPickerView(Context context, DatePickerController controller) { 80 super(context); 81 82 init(); 83 setController(controller); 84 } 85 86 public void setController(DatePickerController controller) { 87 if (mController != null) { 88 mController.unregisterOnDateChangedListener(this); 89 } 90 mController = controller; 91 mController.registerOnDateChangedListener(this); 92 setUpAdapter(); 93 setAdapter(mAdapter); 94 onDateChanged(); 95 } 96 97 public void init() { 98 setLayoutParams(new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)); 99 setDrawSelectorOnTop(false); 100 101 setUpListView(); 102 } 103 104 public void setRange(Calendar minDate, Calendar maxDate) { 105 mMinDate.setTimeInMillis(minDate.getTimeInMillis()); 106 mMaxDate.setTimeInMillis(maxDate.getTimeInMillis()); 107 108 mAdapter.setRange(mMinDate, mMaxDate); 109 110 // Changing the min/max date changes the selection position since we 111 // don't really have stable IDs. 112 goTo(mSelectedDay, false, true, true); 113 } 114 115 /** 116 * Constrains the supplied calendar to stay within the min and max 117 * calendars, returning <code>true</code> if the supplied calendar 118 * was modified. 119 * 120 * @param value The calendar to constrain 121 * @param min The minimum calendar 122 * @param max The maximum calendar 123 * @return True if <code>value</code> was modified 124 */ 125 private boolean constrainCalendar(Calendar value, Calendar min, Calendar max) { 126 if (value.compareTo(min) < 0) { 127 value.setTimeInMillis(min.getTimeInMillis()); 128 return true; 129 } 130 131 if (value.compareTo(max) > 0) { 132 value.setTimeInMillis(max.getTimeInMillis()); 133 return true; 134 } 135 136 return false; 137 } 138 139 public void onChange() { 140 setUpAdapter(); 141 setAdapter(mAdapter); 142 } 143 144 /** 145 * Creates a new adapter if necessary and sets up its parameters. Override 146 * this method to provide a custom adapter. 147 */ 148 protected void setUpAdapter() { 149 if (mAdapter == null) { 150 mAdapter = new SimpleMonthAdapter(getContext(), mController); 151 } else { 152 mAdapter.setSelectedDay(mSelectedDay); 153 mAdapter.notifyDataSetChanged(); 154 } 155 // refresh the view with the new parameters 156 mAdapter.notifyDataSetChanged(); 157 } 158 159 /* 160 * Sets all the required fields for the list view. Override this method to 161 * set a different list view behavior. 162 */ 163 protected void setUpListView() { 164 // Transparent background on scroll 165 setCacheColorHint(0); 166 // No dividers 167 setDivider(null); 168 // Items are clickable 169 setItemsCanFocus(true); 170 // The thumb gets in the way, so disable it 171 setFastScrollEnabled(false); 172 setVerticalScrollBarEnabled(false); 173 setOnScrollListener(this); 174 setFadingEdgeLength(0); 175 // Make the scrolling behavior nicer 176 setFriction(ViewConfiguration.getScrollFriction() * mFriction); 177 } 178 179 private int getDiffMonths(Calendar start, Calendar end) { 180 final int diffYears = end.get(Calendar.YEAR) - start.get(Calendar.YEAR); 181 final int diffMonths = end.get(Calendar.MONTH) - start.get(Calendar.MONTH) + 12 * diffYears; 182 return diffMonths; 183 } 184 185 private int getPositionFromDay(Calendar day) { 186 final int diffMonthMax = getDiffMonths(mMinDate, mMaxDate); 187 final int diffMonth = getDiffMonths(mMinDate, day); 188 return MathUtils.constrain(diffMonth, 0, diffMonthMax); 189 } 190 191 /** 192 * This moves to the specified time in the view. If the time is not already 193 * in range it will move the list so that the first of the month containing 194 * the time is at the top of the view. If the new time is already in view 195 * the list will not be scrolled unless forceScroll is true. This time may 196 * optionally be highlighted as selected as well. 197 * 198 * @param day The day to move to 199 * @param animate Whether to scroll to the given time or just redraw at the 200 * new location 201 * @param setSelected Whether to set the given time as selected 202 * @param forceScroll Whether to recenter even if the time is already 203 * visible 204 * @return Whether or not the view animated to the new location 205 */ 206 public boolean goTo(Calendar day, boolean animate, boolean setSelected, boolean forceScroll) { 207 208 // Set the selected day 209 if (setSelected) { 210 mSelectedDay.setTimeInMillis(day.getTimeInMillis()); 211 } 212 213 mTempDay.setTimeInMillis(day.getTimeInMillis()); 214 final int position = getPositionFromDay(day); 215 216 View child; 217 int i = 0; 218 int top = 0; 219 // Find a child that's completely in the view 220 do { 221 child = getChildAt(i++); 222 if (child == null) { 223 break; 224 } 225 top = child.getTop(); 226 } while (top < 0); 227 228 // Compute the first and last position visible 229 int selectedPosition; 230 if (child != null) { 231 selectedPosition = getPositionForView(child); 232 } else { 233 selectedPosition = 0; 234 } 235 236 if (setSelected) { 237 mAdapter.setSelectedDay(mSelectedDay); 238 } 239 240 // Check if the selected day is now outside of our visible range 241 // and if so scroll to the month that contains it 242 if (position != selectedPosition || forceScroll) { 243 setMonthDisplayed(mTempDay); 244 mPreviousScrollState = OnScrollListener.SCROLL_STATE_FLING; 245 if (animate) { 246 smoothScrollToPositionFromTop( 247 position, LIST_TOP_OFFSET, GOTO_SCROLL_DURATION); 248 return true; 249 } else { 250 postSetSelection(position); 251 } 252 } else if (setSelected) { 253 setMonthDisplayed(mSelectedDay); 254 } 255 return false; 256 } 257 258 public void postSetSelection(final int position) { 259 clearFocus(); 260 post(new Runnable() { 261 262 @Override 263 public void run() { 264 setSelection(position); 265 } 266 }); 267 onScrollStateChanged(this, OnScrollListener.SCROLL_STATE_IDLE); 268 } 269 270 /** 271 * Updates the title and selected month if the view has moved to a new 272 * month. 273 */ 274 @Override 275 public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount, 276 int totalItemCount) { 277 SimpleMonthView child = (SimpleMonthView) view.getChildAt(0); 278 if (child == null) { 279 return; 280 } 281 282 mPreviousScrollState = mCurrentScrollState; 283 } 284 285 /** 286 * Sets the month displayed at the top of this view based on time. Override 287 * to add custom events when the title is changed. 288 */ 289 protected void setMonthDisplayed(Calendar date) { 290 if (mCurrentMonthDisplayed != date.get(Calendar.MONTH)) { 291 mCurrentMonthDisplayed = date.get(Calendar.MONTH); 292 invalidateViews(); 293 } 294 } 295 296 @Override 297 public void onScrollStateChanged(AbsListView view, int scrollState) { 298 // use a post to prevent re-entering onScrollStateChanged before it 299 // exits 300 mScrollStateChangedRunnable.doScrollStateChange(view, scrollState); 301 } 302 303 void setCalendarTextColor(ColorStateList colors) { 304 mAdapter.setCalendarTextColor(colors); 305 } 306 307 protected class ScrollStateRunnable implements Runnable { 308 private int mNewState; 309 private View mParent; 310 311 ScrollStateRunnable(View view) { 312 mParent = view; 313 } 314 315 /** 316 * Sets up the runnable with a short delay in case the scroll state 317 * immediately changes again. 318 * 319 * @param view The list view that changed state 320 * @param scrollState The new state it changed to 321 */ 322 public void doScrollStateChange(AbsListView view, int scrollState) { 323 mParent.removeCallbacks(this); 324 mNewState = scrollState; 325 mParent.postDelayed(this, SCROLL_CHANGE_DELAY); 326 } 327 328 @Override 329 public void run() { 330 mCurrentScrollState = mNewState; 331 if (Log.isLoggable(TAG, Log.DEBUG)) { 332 Log.d(TAG, 333 "new scroll state: " + mNewState + " old state: " + mPreviousScrollState); 334 } 335 // Fix the position after a scroll or a fling ends 336 if (mNewState == OnScrollListener.SCROLL_STATE_IDLE 337 && mPreviousScrollState != OnScrollListener.SCROLL_STATE_IDLE 338 && mPreviousScrollState != OnScrollListener.SCROLL_STATE_TOUCH_SCROLL) { 339 mPreviousScrollState = mNewState; 340 int i = 0; 341 View child = getChildAt(i); 342 while (child != null && child.getBottom() <= 0) { 343 child = getChildAt(++i); 344 } 345 if (child == null) { 346 // The view is no longer visible, just return 347 return; 348 } 349 int firstPosition = getFirstVisiblePosition(); 350 int lastPosition = getLastVisiblePosition(); 351 boolean scroll = firstPosition != 0 && lastPosition != getCount() - 1; 352 final int top = child.getTop(); 353 final int bottom = child.getBottom(); 354 final int midpoint = getHeight() / 2; 355 if (scroll && top < LIST_TOP_OFFSET) { 356 if (bottom > midpoint) { 357 smoothScrollBy(top, GOTO_SCROLL_DURATION); 358 } else { 359 smoothScrollBy(bottom, GOTO_SCROLL_DURATION); 360 } 361 } 362 } else { 363 mPreviousScrollState = mNewState; 364 } 365 } 366 } 367 368 /** 369 * Gets the position of the view that is most prominently displayed within the list view. 370 */ 371 public int getMostVisiblePosition() { 372 final int firstPosition = getFirstVisiblePosition(); 373 final int height = getHeight(); 374 375 int maxDisplayedHeight = 0; 376 int mostVisibleIndex = 0; 377 int i=0; 378 int bottom = 0; 379 while (bottom < height) { 380 View child = getChildAt(i); 381 if (child == null) { 382 break; 383 } 384 bottom = child.getBottom(); 385 int displayedHeight = Math.min(bottom, height) - Math.max(0, child.getTop()); 386 if (displayedHeight > maxDisplayedHeight) { 387 mostVisibleIndex = i; 388 maxDisplayedHeight = displayedHeight; 389 } 390 i++; 391 } 392 return firstPosition + mostVisibleIndex; 393 } 394 395 @Override 396 public void onDateChanged() { 397 goTo(mController.getSelectedDay(), false, true, true); 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 StringBuffer sbuf = new StringBuffer(); 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.ACTION_SCROLL_FORWARD); 484 info.addAction(AccessibilityNodeInfo.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, true, false, true); 529 mPerformingScroll = true; 530 return true; 531 } 532} 533