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