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