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