1 /* 2 * Copyright (C) 2013 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.animation.ObjectAnimator; 20 import android.content.Context; 21 import android.content.res.ColorStateList; 22 import android.content.res.Resources; 23 import android.content.res.TypedArray; 24 import android.graphics.Canvas; 25 import android.graphics.Color; 26 import android.graphics.Paint; 27 import android.graphics.Path; 28 import android.graphics.Rect; 29 import android.graphics.Region; 30 import android.graphics.Typeface; 31 import android.os.Bundle; 32 import android.util.AttributeSet; 33 import android.util.FloatProperty; 34 import android.util.IntArray; 35 import android.util.Log; 36 import android.util.MathUtils; 37 import android.util.StateSet; 38 import android.util.TypedValue; 39 import android.view.HapticFeedbackConstants; 40 import android.view.MotionEvent; 41 import android.view.View; 42 import android.view.accessibility.AccessibilityEvent; 43 import android.view.accessibility.AccessibilityNodeInfo; 44 import android.view.accessibility.AccessibilityNodeInfo.AccessibilityAction; 45 46 import com.android.internal.R; 47 import com.android.internal.widget.ExploreByTouchHelper; 48 49 import java.util.Calendar; 50 import java.util.Locale; 51 52 /** 53 * View to show a clock circle picker (with one or two picking circles) 54 * 55 * @hide 56 */ 57 public class RadialTimePickerView extends View { 58 59 private static final String TAG = "RadialTimePickerView"; 60 61 public static final int HOURS = 0; 62 public static final int MINUTES = 1; 63 private static final int HOURS_INNER = 2; 64 65 private static final int SELECTOR_CIRCLE = 0; 66 private static final int SELECTOR_DOT = 1; 67 private static final int SELECTOR_LINE = 2; 68 69 private static final int AM = 0; 70 private static final int PM = 1; 71 72 private static final int HOURS_IN_CIRCLE = 12; 73 private static final int MINUTES_IN_CIRCLE = 60; 74 private static final int DEGREES_FOR_ONE_HOUR = 360 / HOURS_IN_CIRCLE; 75 private static final int DEGREES_FOR_ONE_MINUTE = 360 / MINUTES_IN_CIRCLE; 76 77 private static final int[] HOURS_NUMBERS = {12, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11}; 78 private static final int[] HOURS_NUMBERS_24 = {0, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23}; 79 private static final int[] MINUTES_NUMBERS = {0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55}; 80 81 private static final int ANIM_DURATION_NORMAL = 500; 82 private static final int ANIM_DURATION_TOUCH = 60; 83 84 private static final int[] SNAP_PREFER_30S_MAP = new int[361]; 85 86 private static final int NUM_POSITIONS = 12; 87 private static final float[] COS_30 = new float[NUM_POSITIONS]; 88 private static final float[] SIN_30 = new float[NUM_POSITIONS]; 89 90 /** "Something is wrong" color used when a color attribute is missing. */ 91 private static final int MISSING_COLOR = Color.MAGENTA; 92 93 static { 94 // Prepare mapping to snap touchable degrees to selectable degrees. preparePrefer30sMap()95 preparePrefer30sMap(); 96 97 final double increment = 2.0 * Math.PI / NUM_POSITIONS; 98 double angle = Math.PI / 2.0; 99 for (int i = 0; i < NUM_POSITIONS; i++) { 100 COS_30[i] = (float) Math.cos(angle); 101 SIN_30[i] = (float) Math.sin(angle); 102 angle += increment; 103 } 104 } 105 106 private final FloatProperty<RadialTimePickerView> HOURS_TO_MINUTES = 107 new FloatProperty<RadialTimePickerView>("hoursToMinutes") { 108 @Override 109 public Float get(RadialTimePickerView radialTimePickerView) { 110 return radialTimePickerView.mHoursToMinutes; 111 } 112 113 @Override 114 public void setValue(RadialTimePickerView object, float value) { 115 object.mHoursToMinutes = value; 116 object.invalidate(); 117 } 118 }; 119 120 private final String[] mHours12Texts = new String[12]; 121 private final String[] mOuterHours24Texts = new String[12]; 122 private final String[] mInnerHours24Texts = new String[12]; 123 private final String[] mMinutesTexts = new String[12]; 124 125 private final Paint[] mPaint = new Paint[2]; 126 private final Paint mPaintCenter = new Paint(); 127 private final Paint[] mPaintSelector = new Paint[3]; 128 private final Paint mPaintBackground = new Paint(); 129 130 private final Typeface mTypeface; 131 132 private final ColorStateList[] mTextColor = new ColorStateList[3]; 133 private final int[] mTextSize = new int[3]; 134 private final int[] mTextInset = new int[3]; 135 136 private final float[][] mOuterTextX = new float[2][12]; 137 private final float[][] mOuterTextY = new float[2][12]; 138 139 private final float[] mInnerTextX = new float[12]; 140 private final float[] mInnerTextY = new float[12]; 141 142 private final int[] mSelectionDegrees = new int[2]; 143 144 private final RadialPickerTouchHelper mTouchHelper; 145 146 private final Path mSelectorPath = new Path(); 147 148 private boolean mIs24HourMode; 149 private boolean mShowHours; 150 151 private ObjectAnimator mHoursToMinutesAnimator; 152 private float mHoursToMinutes; 153 154 /** 155 * When in 24-hour mode, indicates that the current hour is between 156 * 1 and 12 (inclusive). 157 */ 158 private boolean mIsOnInnerCircle; 159 160 private int mSelectorRadius; 161 private int mSelectorStroke; 162 private int mSelectorDotRadius; 163 private int mCenterDotRadius; 164 165 private int mSelectorColor; 166 private int mSelectorDotColor; 167 168 private int mXCenter; 169 private int mYCenter; 170 private int mCircleRadius; 171 172 private int mMinDistForInnerNumber; 173 private int mMaxDistForOuterNumber; 174 private int mHalfwayDist; 175 176 private String[] mOuterTextHours; 177 private String[] mInnerTextHours; 178 private String[] mMinutesText; 179 180 private int mAmOrPm; 181 182 private float mDisabledAlpha; 183 184 private OnValueSelectedListener mListener; 185 186 private boolean mInputEnabled = true; 187 188 public interface OnValueSelectedListener { onValueSelected(int pickerIndex, int newValue, boolean autoAdvance)189 void onValueSelected(int pickerIndex, int newValue, boolean autoAdvance); 190 } 191 192 /** 193 * Split up the 360 degrees of the circle among the 60 selectable values. Assigns a larger 194 * selectable area to each of the 12 visible values, such that the ratio of space apportioned 195 * to a visible value : space apportioned to a non-visible value will be 14 : 4. 196 * E.g. the output of 30 degrees should have a higher range of input associated with it than 197 * the output of 24 degrees, because 30 degrees corresponds to a visible number on the clock 198 * circle (5 on the minutes, 1 or 13 on the hours). 199 */ preparePrefer30sMap()200 private static void preparePrefer30sMap() { 201 // We'll split up the visible output and the non-visible output such that each visible 202 // output will correspond to a range of 14 associated input degrees, and each non-visible 203 // output will correspond to a range of 4 associate input degrees, so visible numbers 204 // are more than 3 times easier to get than non-visible numbers: 205 // {354-359,0-7}:0, {8-11}:6, {12-15}:12, {16-19}:18, {20-23}:24, {24-37}:30, etc. 206 // 207 // If an output of 30 degrees should correspond to a range of 14 associated degrees, then 208 // we'll need any input between 24 - 37 to snap to 30. Working out from there, 20-23 should 209 // snap to 24, while 38-41 should snap to 36. This is somewhat counter-intuitive, that you 210 // can be touching 36 degrees but have the selection snapped to 30 degrees; however, this 211 // inconsistency isn't noticeable at such fine-grained degrees, and it affords us the 212 // ability to aggressively prefer the visible values by a factor of more than 3:1, which 213 // greatly contributes to the selectability of these values. 214 215 // The first output is 0, and each following output will increment by 6 {0, 6, 12, ...}. 216 int snappedOutputDegrees = 0; 217 // Count of how many inputs we've designated to the specified output. 218 int count = 1; 219 // How many input we expect for a specified output. This will be 14 for output divisible 220 // by 30, and 4 for the remaining output. We'll special case the outputs of 0 and 360, so 221 // the caller can decide which they need. 222 int expectedCount = 8; 223 // Iterate through the input. 224 for (int degrees = 0; degrees < 361; degrees++) { 225 // Save the input-output mapping. 226 SNAP_PREFER_30S_MAP[degrees] = snappedOutputDegrees; 227 // If this is the last input for the specified output, calculate the next output and 228 // the next expected count. 229 if (count == expectedCount) { 230 snappedOutputDegrees += 6; 231 if (snappedOutputDegrees == 360) { 232 expectedCount = 7; 233 } else if (snappedOutputDegrees % 30 == 0) { 234 expectedCount = 14; 235 } else { 236 expectedCount = 4; 237 } 238 count = 1; 239 } else { 240 count++; 241 } 242 } 243 } 244 245 /** 246 * Returns mapping of any input degrees (0 to 360) to one of 60 selectable output degrees, 247 * where the degrees corresponding to visible numbers (i.e. those divisible by 30) will be 248 * weighted heavier than the degrees corresponding to non-visible numbers. 249 * See {@link #preparePrefer30sMap()} documentation for the rationale and generation of the 250 * mapping. 251 */ snapPrefer30s(int degrees)252 private static int snapPrefer30s(int degrees) { 253 if (SNAP_PREFER_30S_MAP == null) { 254 return -1; 255 } 256 return SNAP_PREFER_30S_MAP[degrees]; 257 } 258 259 /** 260 * Returns mapping of any input degrees (0 to 360) to one of 12 visible output degrees (all 261 * multiples of 30), where the input will be "snapped" to the closest visible degrees. 262 * @param degrees The input degrees 263 * @param forceHigherOrLower The output may be forced to either the higher or lower step, or may 264 * be allowed to snap to whichever is closer. Use 1 to force strictly higher, -1 to force 265 * strictly lower, and 0 to snap to the closer one. 266 * @return output degrees, will be a multiple of 30 267 */ snapOnly30s(int degrees, int forceHigherOrLower)268 private static int snapOnly30s(int degrees, int forceHigherOrLower) { 269 final int stepSize = DEGREES_FOR_ONE_HOUR; 270 int floor = (degrees / stepSize) * stepSize; 271 final int ceiling = floor + stepSize; 272 if (forceHigherOrLower == 1) { 273 degrees = ceiling; 274 } else if (forceHigherOrLower == -1) { 275 if (degrees == floor) { 276 floor -= stepSize; 277 } 278 degrees = floor; 279 } else { 280 if ((degrees - floor) < (ceiling - degrees)) { 281 degrees = floor; 282 } else { 283 degrees = ceiling; 284 } 285 } 286 return degrees; 287 } 288 289 @SuppressWarnings("unused") RadialTimePickerView(Context context)290 public RadialTimePickerView(Context context) { 291 this(context, null); 292 } 293 RadialTimePickerView(Context context, AttributeSet attrs)294 public RadialTimePickerView(Context context, AttributeSet attrs) { 295 this(context, attrs, R.attr.timePickerStyle); 296 } 297 RadialTimePickerView(Context context, AttributeSet attrs, int defStyleAttr)298 public RadialTimePickerView(Context context, AttributeSet attrs, int defStyleAttr) { 299 this(context, attrs, defStyleAttr, 0); 300 } 301 RadialTimePickerView( Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes)302 public RadialTimePickerView( 303 Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { 304 super(context, attrs); 305 306 applyAttributes(attrs, defStyleAttr, defStyleRes); 307 308 // Pull disabled alpha from theme. 309 final TypedValue outValue = new TypedValue(); 310 context.getTheme().resolveAttribute(android.R.attr.disabledAlpha, outValue, true); 311 mDisabledAlpha = outValue.getFloat(); 312 313 mTypeface = Typeface.create("sans-serif", Typeface.NORMAL); 314 315 mPaint[HOURS] = new Paint(); 316 mPaint[HOURS].setAntiAlias(true); 317 mPaint[HOURS].setTextAlign(Paint.Align.CENTER); 318 319 mPaint[MINUTES] = new Paint(); 320 mPaint[MINUTES].setAntiAlias(true); 321 mPaint[MINUTES].setTextAlign(Paint.Align.CENTER); 322 323 mPaintCenter.setAntiAlias(true); 324 325 mPaintSelector[SELECTOR_CIRCLE] = new Paint(); 326 mPaintSelector[SELECTOR_CIRCLE].setAntiAlias(true); 327 328 mPaintSelector[SELECTOR_DOT] = new Paint(); 329 mPaintSelector[SELECTOR_DOT].setAntiAlias(true); 330 331 mPaintSelector[SELECTOR_LINE] = new Paint(); 332 mPaintSelector[SELECTOR_LINE].setAntiAlias(true); 333 mPaintSelector[SELECTOR_LINE].setStrokeWidth(2); 334 335 mPaintBackground.setAntiAlias(true); 336 337 final Resources res = getResources(); 338 mSelectorRadius = res.getDimensionPixelSize(R.dimen.timepicker_selector_radius); 339 mSelectorStroke = res.getDimensionPixelSize(R.dimen.timepicker_selector_stroke); 340 mSelectorDotRadius = res.getDimensionPixelSize(R.dimen.timepicker_selector_dot_radius); 341 mCenterDotRadius = res.getDimensionPixelSize(R.dimen.timepicker_center_dot_radius); 342 343 mTextSize[HOURS] = res.getDimensionPixelSize(R.dimen.timepicker_text_size_normal); 344 mTextSize[MINUTES] = res.getDimensionPixelSize(R.dimen.timepicker_text_size_normal); 345 mTextSize[HOURS_INNER] = res.getDimensionPixelSize(R.dimen.timepicker_text_size_inner); 346 347 mTextInset[HOURS] = res.getDimensionPixelSize(R.dimen.timepicker_text_inset_normal); 348 mTextInset[MINUTES] = res.getDimensionPixelSize(R.dimen.timepicker_text_inset_normal); 349 mTextInset[HOURS_INNER] = res.getDimensionPixelSize(R.dimen.timepicker_text_inset_inner); 350 351 mShowHours = true; 352 mHoursToMinutes = HOURS; 353 mIs24HourMode = false; 354 mAmOrPm = AM; 355 356 // Set up accessibility components. 357 mTouchHelper = new RadialPickerTouchHelper(); 358 setAccessibilityDelegate(mTouchHelper); 359 360 if (getImportantForAccessibility() == IMPORTANT_FOR_ACCESSIBILITY_AUTO) { 361 setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_YES); 362 } 363 364 initHoursAndMinutesText(); 365 initData(); 366 367 // Initial values 368 final Calendar calendar = Calendar.getInstance(Locale.getDefault()); 369 final int currentHour = calendar.get(Calendar.HOUR_OF_DAY); 370 final int currentMinute = calendar.get(Calendar.MINUTE); 371 372 setCurrentHourInternal(currentHour, false, false); 373 setCurrentMinuteInternal(currentMinute, false); 374 375 setHapticFeedbackEnabled(true); 376 } 377 applyAttributes(AttributeSet attrs, int defStyleAttr, int defStyleRes)378 void applyAttributes(AttributeSet attrs, int defStyleAttr, int defStyleRes) { 379 final Context context = getContext(); 380 final TypedArray a = getContext().obtainStyledAttributes(attrs, 381 R.styleable.TimePicker, defStyleAttr, defStyleRes); 382 383 final ColorStateList numbersTextColor = a.getColorStateList( 384 R.styleable.TimePicker_numbersTextColor); 385 final ColorStateList numbersInnerTextColor = a.getColorStateList( 386 R.styleable.TimePicker_numbersInnerTextColor); 387 mTextColor[HOURS] = numbersTextColor == null ? 388 ColorStateList.valueOf(MISSING_COLOR) : numbersTextColor; 389 mTextColor[HOURS_INNER] = numbersInnerTextColor == null ? 390 ColorStateList.valueOf(MISSING_COLOR) : numbersInnerTextColor; 391 mTextColor[MINUTES] = mTextColor[HOURS]; 392 393 // Set up various colors derived from the selector "activated" state. 394 final ColorStateList selectorColors = a.getColorStateList( 395 R.styleable.TimePicker_numbersSelectorColor); 396 final int selectorActivatedColor; 397 if (selectorColors != null) { 398 final int[] stateSetEnabledActivated = StateSet.get( 399 StateSet.VIEW_STATE_ENABLED | StateSet.VIEW_STATE_ACTIVATED); 400 selectorActivatedColor = selectorColors.getColorForState( 401 stateSetEnabledActivated, 0); 402 } else { 403 selectorActivatedColor = MISSING_COLOR; 404 } 405 406 mPaintCenter.setColor(selectorActivatedColor); 407 408 final int[] stateSetActivated = StateSet.get( 409 StateSet.VIEW_STATE_ENABLED | StateSet.VIEW_STATE_ACTIVATED); 410 411 mSelectorColor = selectorActivatedColor; 412 mSelectorDotColor = mTextColor[HOURS].getColorForState(stateSetActivated, 0); 413 414 mPaintBackground.setColor(a.getColor(R.styleable.TimePicker_numbersBackgroundColor, 415 context.getColor(R.color.timepicker_default_numbers_background_color_material))); 416 417 a.recycle(); 418 } 419 initialize(int hour, int minute, boolean is24HourMode)420 public void initialize(int hour, int minute, boolean is24HourMode) { 421 if (mIs24HourMode != is24HourMode) { 422 mIs24HourMode = is24HourMode; 423 initData(); 424 } 425 426 setCurrentHourInternal(hour, false, false); 427 setCurrentMinuteInternal(minute, false); 428 } 429 setCurrentItemShowing(int item, boolean animate)430 public void setCurrentItemShowing(int item, boolean animate) { 431 switch (item){ 432 case HOURS: 433 showHours(animate); 434 break; 435 case MINUTES: 436 showMinutes(animate); 437 break; 438 default: 439 Log.e(TAG, "ClockView does not support showing item " + item); 440 } 441 } 442 getCurrentItemShowing()443 public int getCurrentItemShowing() { 444 return mShowHours ? HOURS : MINUTES; 445 } 446 setOnValueSelectedListener(OnValueSelectedListener listener)447 public void setOnValueSelectedListener(OnValueSelectedListener listener) { 448 mListener = listener; 449 } 450 451 /** 452 * Sets the current hour in 24-hour time. 453 * 454 * @param hour the current hour between 0 and 23 (inclusive) 455 */ setCurrentHour(int hour)456 public void setCurrentHour(int hour) { 457 setCurrentHourInternal(hour, true, false); 458 } 459 460 /** 461 * Sets the current hour. 462 * 463 * @param hour The current hour 464 * @param callback Whether the value listener should be invoked 465 * @param autoAdvance Whether the listener should auto-advance to the next 466 * selection mode, e.g. hour to minutes 467 */ setCurrentHourInternal(int hour, boolean callback, boolean autoAdvance)468 private void setCurrentHourInternal(int hour, boolean callback, boolean autoAdvance) { 469 final int degrees = (hour % 12) * DEGREES_FOR_ONE_HOUR; 470 mSelectionDegrees[HOURS] = degrees; 471 472 // 0 is 12 AM (midnight) and 12 is 12 PM (noon). 473 final int amOrPm = (hour == 0 || (hour % 24) < 12) ? AM : PM; 474 final boolean isOnInnerCircle = getInnerCircleForHour(hour); 475 if (mAmOrPm != amOrPm || mIsOnInnerCircle != isOnInnerCircle) { 476 mAmOrPm = amOrPm; 477 mIsOnInnerCircle = isOnInnerCircle; 478 479 initData(); 480 mTouchHelper.invalidateRoot(); 481 } 482 483 invalidate(); 484 485 if (callback && mListener != null) { 486 mListener.onValueSelected(HOURS, hour, autoAdvance); 487 } 488 } 489 490 /** 491 * Returns the current hour in 24-hour time. 492 * 493 * @return the current hour between 0 and 23 (inclusive) 494 */ getCurrentHour()495 public int getCurrentHour() { 496 return getHourForDegrees(mSelectionDegrees[HOURS], mIsOnInnerCircle); 497 } 498 getHourForDegrees(int degrees, boolean innerCircle)499 private int getHourForDegrees(int degrees, boolean innerCircle) { 500 int hour = (degrees / DEGREES_FOR_ONE_HOUR) % 12; 501 if (mIs24HourMode) { 502 // Convert the 12-hour value into 24-hour time based on where the 503 // selector is positioned. 504 if (!innerCircle && hour == 0) { 505 // Outer circle is 1 through 12. 506 hour = 12; 507 } else if (innerCircle && hour != 0) { 508 // Inner circle is 13 through 23 and 0. 509 hour += 12; 510 } 511 } else if (mAmOrPm == PM) { 512 hour += 12; 513 } 514 return hour; 515 } 516 517 /** 518 * @param hour the hour in 24-hour time or 12-hour time 519 */ getDegreesForHour(int hour)520 private int getDegreesForHour(int hour) { 521 // Convert to be 0-11. 522 if (mIs24HourMode) { 523 if (hour >= 12) { 524 hour -= 12; 525 } 526 } else if (hour == 12) { 527 hour = 0; 528 } 529 return hour * DEGREES_FOR_ONE_HOUR; 530 } 531 532 /** 533 * @param hour the hour in 24-hour time or 12-hour time 534 */ getInnerCircleForHour(int hour)535 private boolean getInnerCircleForHour(int hour) { 536 return mIs24HourMode && (hour == 0 || hour > 12); 537 } 538 setCurrentMinute(int minute)539 public void setCurrentMinute(int minute) { 540 setCurrentMinuteInternal(minute, true); 541 } 542 setCurrentMinuteInternal(int minute, boolean callback)543 private void setCurrentMinuteInternal(int minute, boolean callback) { 544 mSelectionDegrees[MINUTES] = (minute % MINUTES_IN_CIRCLE) * DEGREES_FOR_ONE_MINUTE; 545 546 invalidate(); 547 548 if (callback && mListener != null) { 549 mListener.onValueSelected(MINUTES, minute, false); 550 } 551 } 552 553 // Returns minutes in 0-59 range getCurrentMinute()554 public int getCurrentMinute() { 555 return getMinuteForDegrees(mSelectionDegrees[MINUTES]); 556 } 557 getMinuteForDegrees(int degrees)558 private int getMinuteForDegrees(int degrees) { 559 return degrees / DEGREES_FOR_ONE_MINUTE; 560 } 561 getDegreesForMinute(int minute)562 private int getDegreesForMinute(int minute) { 563 return minute * DEGREES_FOR_ONE_MINUTE; 564 } 565 566 /** 567 * Sets whether the picker is showing AM or PM hours. Has no effect when 568 * in 24-hour mode. 569 * 570 * @param amOrPm {@link #AM} or {@link #PM} 571 * @return {@code true} if the value changed from what was previously set, 572 * or {@code false} otherwise 573 */ setAmOrPm(int amOrPm)574 public boolean setAmOrPm(int amOrPm) { 575 if (mAmOrPm == amOrPm || mIs24HourMode) { 576 return false; 577 } 578 579 mAmOrPm = amOrPm; 580 invalidate(); 581 mTouchHelper.invalidateRoot(); 582 return true; 583 } 584 getAmOrPm()585 public int getAmOrPm() { 586 return mAmOrPm; 587 } 588 showHours(boolean animate)589 public void showHours(boolean animate) { 590 showPicker(true, animate); 591 } 592 showMinutes(boolean animate)593 public void showMinutes(boolean animate) { 594 showPicker(false, animate); 595 } 596 initHoursAndMinutesText()597 private void initHoursAndMinutesText() { 598 // Initialize the hours and minutes numbers. 599 for (int i = 0; i < 12; i++) { 600 mHours12Texts[i] = String.format("%d", HOURS_NUMBERS[i]); 601 mInnerHours24Texts[i] = String.format("%02d", HOURS_NUMBERS_24[i]); 602 mOuterHours24Texts[i] = String.format("%d", HOURS_NUMBERS[i]); 603 mMinutesTexts[i] = String.format("%02d", MINUTES_NUMBERS[i]); 604 } 605 } 606 initData()607 private void initData() { 608 if (mIs24HourMode) { 609 mOuterTextHours = mOuterHours24Texts; 610 mInnerTextHours = mInnerHours24Texts; 611 } else { 612 mOuterTextHours = mHours12Texts; 613 mInnerTextHours = mHours12Texts; 614 } 615 616 mMinutesText = mMinutesTexts; 617 } 618 619 @Override onLayout(boolean changed, int left, int top, int right, int bottom)620 protected void onLayout(boolean changed, int left, int top, int right, int bottom) { 621 if (!changed) { 622 return; 623 } 624 625 mXCenter = getWidth() / 2; 626 mYCenter = getHeight() / 2; 627 mCircleRadius = Math.min(mXCenter, mYCenter); 628 629 mMinDistForInnerNumber = mCircleRadius - mTextInset[HOURS_INNER] - mSelectorRadius; 630 mMaxDistForOuterNumber = mCircleRadius - mTextInset[HOURS] + mSelectorRadius; 631 mHalfwayDist = mCircleRadius - (mTextInset[HOURS] + mTextInset[HOURS_INNER]) / 2; 632 633 calculatePositionsHours(); 634 calculatePositionsMinutes(); 635 636 mTouchHelper.invalidateRoot(); 637 } 638 639 @Override onDraw(Canvas canvas)640 public void onDraw(Canvas canvas) { 641 final float alphaMod = mInputEnabled ? 1 : mDisabledAlpha; 642 643 drawCircleBackground(canvas); 644 645 final Path selectorPath = mSelectorPath; 646 drawSelector(canvas, selectorPath); 647 drawHours(canvas, selectorPath, alphaMod); 648 drawMinutes(canvas, selectorPath, alphaMod); 649 drawCenter(canvas, alphaMod); 650 } 651 showPicker(boolean hours, boolean animate)652 private void showPicker(boolean hours, boolean animate) { 653 if (mShowHours == hours) { 654 return; 655 } 656 657 mShowHours = hours; 658 659 if (animate) { 660 animatePicker(hours, ANIM_DURATION_NORMAL); 661 } else { 662 // If we have a pending or running animator, cancel it. 663 if (mHoursToMinutesAnimator != null && mHoursToMinutesAnimator.isStarted()) { 664 mHoursToMinutesAnimator.cancel(); 665 mHoursToMinutesAnimator = null; 666 } 667 mHoursToMinutes = hours ? 0.0f : 1.0f; 668 } 669 670 initData(); 671 invalidate(); 672 mTouchHelper.invalidateRoot(); 673 } 674 animatePicker(boolean hoursToMinutes, long duration)675 private void animatePicker(boolean hoursToMinutes, long duration) { 676 final float target = hoursToMinutes ? HOURS : MINUTES; 677 if (mHoursToMinutes == target) { 678 // If we have a pending or running animator, cancel it. 679 if (mHoursToMinutesAnimator != null && mHoursToMinutesAnimator.isStarted()) { 680 mHoursToMinutesAnimator.cancel(); 681 mHoursToMinutesAnimator = null; 682 } 683 684 // We're already showing the correct picker. 685 return; 686 } 687 688 mHoursToMinutesAnimator = ObjectAnimator.ofFloat(this, HOURS_TO_MINUTES, target); 689 mHoursToMinutesAnimator.setAutoCancel(true); 690 mHoursToMinutesAnimator.setDuration(duration); 691 mHoursToMinutesAnimator.start(); 692 } 693 drawCircleBackground(Canvas canvas)694 private void drawCircleBackground(Canvas canvas) { 695 canvas.drawCircle(mXCenter, mYCenter, mCircleRadius, mPaintBackground); 696 } 697 drawHours(Canvas canvas, Path selectorPath, float alphaMod)698 private void drawHours(Canvas canvas, Path selectorPath, float alphaMod) { 699 final int hoursAlpha = (int) (255f * (1f - mHoursToMinutes) * alphaMod + 0.5f); 700 if (hoursAlpha > 0) { 701 // Exclude the selector region, then draw inner/outer hours with no 702 // activated states. 703 canvas.save(Canvas.CLIP_SAVE_FLAG); 704 canvas.clipPath(selectorPath, Region.Op.DIFFERENCE); 705 drawHoursClipped(canvas, hoursAlpha, false); 706 canvas.restore(); 707 708 // Intersect the selector region, then draw minutes with only 709 // activated states. 710 canvas.save(Canvas.CLIP_SAVE_FLAG); 711 canvas.clipPath(selectorPath, Region.Op.INTERSECT); 712 drawHoursClipped(canvas, hoursAlpha, true); 713 canvas.restore(); 714 } 715 } 716 drawHoursClipped(Canvas canvas, int hoursAlpha, boolean showActivated)717 private void drawHoursClipped(Canvas canvas, int hoursAlpha, boolean showActivated) { 718 // Draw outer hours. 719 drawTextElements(canvas, mTextSize[HOURS], mTypeface, mTextColor[HOURS], mOuterTextHours, 720 mOuterTextX[HOURS], mOuterTextY[HOURS], mPaint[HOURS], hoursAlpha, 721 showActivated && !mIsOnInnerCircle, mSelectionDegrees[HOURS], showActivated); 722 723 // Draw inner hours (13-00) for 24-hour time. 724 if (mIs24HourMode && mInnerTextHours != null) { 725 drawTextElements(canvas, mTextSize[HOURS_INNER], mTypeface, mTextColor[HOURS_INNER], 726 mInnerTextHours, mInnerTextX, mInnerTextY, mPaint[HOURS], hoursAlpha, 727 showActivated && mIsOnInnerCircle, mSelectionDegrees[HOURS], showActivated); 728 } 729 } 730 drawMinutes(Canvas canvas, Path selectorPath, float alphaMod)731 private void drawMinutes(Canvas canvas, Path selectorPath, float alphaMod) { 732 final int minutesAlpha = (int) (255f * mHoursToMinutes * alphaMod + 0.5f); 733 if (minutesAlpha > 0) { 734 // Exclude the selector region, then draw minutes with no 735 // activated states. 736 canvas.save(Canvas.CLIP_SAVE_FLAG); 737 canvas.clipPath(selectorPath, Region.Op.DIFFERENCE); 738 drawMinutesClipped(canvas, minutesAlpha, false); 739 canvas.restore(); 740 741 // Intersect the selector region, then draw minutes with only 742 // activated states. 743 canvas.save(Canvas.CLIP_SAVE_FLAG); 744 canvas.clipPath(selectorPath, Region.Op.INTERSECT); 745 drawMinutesClipped(canvas, minutesAlpha, true); 746 canvas.restore(); 747 } 748 } 749 drawMinutesClipped(Canvas canvas, int minutesAlpha, boolean showActivated)750 private void drawMinutesClipped(Canvas canvas, int minutesAlpha, boolean showActivated) { 751 drawTextElements(canvas, mTextSize[MINUTES], mTypeface, mTextColor[MINUTES], mMinutesText, 752 mOuterTextX[MINUTES], mOuterTextY[MINUTES], mPaint[MINUTES], minutesAlpha, 753 showActivated, mSelectionDegrees[MINUTES], showActivated); 754 } 755 drawCenter(Canvas canvas, float alphaMod)756 private void drawCenter(Canvas canvas, float alphaMod) { 757 mPaintCenter.setAlpha((int) (255 * alphaMod + 0.5f)); 758 canvas.drawCircle(mXCenter, mYCenter, mCenterDotRadius, mPaintCenter); 759 } 760 getMultipliedAlpha(int argb, int alpha)761 private int getMultipliedAlpha(int argb, int alpha) { 762 return (int) (Color.alpha(argb) * (alpha / 255.0) + 0.5); 763 } 764 drawSelector(Canvas canvas, Path selectorPath)765 private void drawSelector(Canvas canvas, Path selectorPath) { 766 // Determine the current length, angle, and dot scaling factor. 767 final int hoursIndex = mIsOnInnerCircle ? HOURS_INNER : HOURS; 768 final int hoursInset = mTextInset[hoursIndex]; 769 final int hoursAngleDeg = mSelectionDegrees[hoursIndex % 2]; 770 final float hoursDotScale = mSelectionDegrees[hoursIndex % 2] % 30 != 0 ? 1 : 0; 771 772 final int minutesIndex = MINUTES; 773 final int minutesInset = mTextInset[minutesIndex]; 774 final int minutesAngleDeg = mSelectionDegrees[minutesIndex]; 775 final float minutesDotScale = mSelectionDegrees[minutesIndex] % 30 != 0 ? 1 : 0; 776 777 // Calculate the current radius at which to place the selection circle. 778 final int selRadius = mSelectorRadius; 779 final float selLength = 780 mCircleRadius - MathUtils.lerp(hoursInset, minutesInset, mHoursToMinutes); 781 final double selAngleRad = 782 Math.toRadians(MathUtils.lerpDeg(hoursAngleDeg, minutesAngleDeg, mHoursToMinutes)); 783 final float selCenterX = mXCenter + selLength * (float) Math.sin(selAngleRad); 784 final float selCenterY = mYCenter - selLength * (float) Math.cos(selAngleRad); 785 786 // Draw the selection circle. 787 final Paint paint = mPaintSelector[SELECTOR_CIRCLE]; 788 paint.setColor(mSelectorColor); 789 canvas.drawCircle(selCenterX, selCenterY, selRadius, paint); 790 791 // If needed, set up the clip path for later. 792 if (selectorPath != null) { 793 selectorPath.reset(); 794 selectorPath.addCircle(selCenterX, selCenterY, selRadius, Path.Direction.CCW); 795 } 796 797 // Draw the dot if we're between two items. 798 final float dotScale = MathUtils.lerp(hoursDotScale, minutesDotScale, mHoursToMinutes); 799 if (dotScale > 0) { 800 final Paint dotPaint = mPaintSelector[SELECTOR_DOT]; 801 dotPaint.setColor(mSelectorDotColor); 802 canvas.drawCircle(selCenterX, selCenterY, mSelectorDotRadius * dotScale, dotPaint); 803 } 804 805 // Shorten the line to only go from the edge of the center dot to the 806 // edge of the selection circle. 807 final double sin = Math.sin(selAngleRad); 808 final double cos = Math.cos(selAngleRad); 809 final float lineLength = selLength - selRadius; 810 final int centerX = mXCenter + (int) (mCenterDotRadius * sin); 811 final int centerY = mYCenter - (int) (mCenterDotRadius * cos); 812 final float linePointX = centerX + (int) (lineLength * sin); 813 final float linePointY = centerY - (int) (lineLength * cos); 814 815 // Draw the line. 816 final Paint linePaint = mPaintSelector[SELECTOR_LINE]; 817 linePaint.setColor(mSelectorColor); 818 linePaint.setStrokeWidth(mSelectorStroke); 819 canvas.drawLine(mXCenter, mYCenter, linePointX, linePointY, linePaint); 820 } 821 calculatePositionsHours()822 private void calculatePositionsHours() { 823 // Calculate the text positions 824 final float numbersRadius = mCircleRadius - mTextInset[HOURS]; 825 826 // Calculate the positions for the 12 numbers in the main circle. 827 calculatePositions(mPaint[HOURS], numbersRadius, mXCenter, mYCenter, 828 mTextSize[HOURS], mOuterTextX[HOURS], mOuterTextY[HOURS]); 829 830 // If we have an inner circle, calculate those positions too. 831 if (mIs24HourMode) { 832 final int innerNumbersRadius = mCircleRadius - mTextInset[HOURS_INNER]; 833 calculatePositions(mPaint[HOURS], innerNumbersRadius, mXCenter, mYCenter, 834 mTextSize[HOURS_INNER], mInnerTextX, mInnerTextY); 835 } 836 } 837 calculatePositionsMinutes()838 private void calculatePositionsMinutes() { 839 // Calculate the text positions 840 final float numbersRadius = mCircleRadius - mTextInset[MINUTES]; 841 842 // Calculate the positions for the 12 numbers in the main circle. 843 calculatePositions(mPaint[MINUTES], numbersRadius, mXCenter, mYCenter, 844 mTextSize[MINUTES], mOuterTextX[MINUTES], mOuterTextY[MINUTES]); 845 } 846 847 /** 848 * Using the trigonometric Unit Circle, calculate the positions that the text will need to be 849 * drawn at based on the specified circle radius. Place the values in the textGridHeights and 850 * textGridWidths parameters. 851 */ calculatePositions(Paint paint, float radius, float xCenter, float yCenter, float textSize, float[] x, float[] y)852 private static void calculatePositions(Paint paint, float radius, float xCenter, float yCenter, 853 float textSize, float[] x, float[] y) { 854 // Adjust yCenter to account for the text's baseline. 855 paint.setTextSize(textSize); 856 yCenter -= (paint.descent() + paint.ascent()) / 2; 857 858 for (int i = 0; i < NUM_POSITIONS; i++) { 859 x[i] = xCenter - radius * COS_30[i]; 860 y[i] = yCenter - radius * SIN_30[i]; 861 } 862 } 863 864 /** 865 * Draw the 12 text values at the positions specified by the textGrid parameters. 866 */ drawTextElements(Canvas canvas, float textSize, Typeface typeface, ColorStateList textColor, String[] texts, float[] textX, float[] textY, Paint paint, int alpha, boolean showActivated, int activatedDegrees, boolean activatedOnly)867 private void drawTextElements(Canvas canvas, float textSize, Typeface typeface, 868 ColorStateList textColor, String[] texts, float[] textX, float[] textY, Paint paint, 869 int alpha, boolean showActivated, int activatedDegrees, boolean activatedOnly) { 870 paint.setTextSize(textSize); 871 paint.setTypeface(typeface); 872 873 // The activated index can touch a range of elements. 874 final float activatedIndex = activatedDegrees / (360.0f / NUM_POSITIONS); 875 final int activatedFloor = (int) activatedIndex; 876 final int activatedCeil = ((int) Math.ceil(activatedIndex)) % NUM_POSITIONS; 877 878 for (int i = 0; i < 12; i++) { 879 final boolean activated = (activatedFloor == i || activatedCeil == i); 880 if (activatedOnly && !activated) { 881 continue; 882 } 883 884 final int stateMask = StateSet.VIEW_STATE_ENABLED 885 | (showActivated && activated ? StateSet.VIEW_STATE_ACTIVATED : 0); 886 final int color = textColor.getColorForState(StateSet.get(stateMask), 0); 887 paint.setColor(color); 888 paint.setAlpha(getMultipliedAlpha(color, alpha)); 889 890 canvas.drawText(texts[i], textX[i], textY[i], paint); 891 } 892 } 893 getDegreesFromXY(float x, float y, boolean constrainOutside)894 private int getDegreesFromXY(float x, float y, boolean constrainOutside) { 895 // Ensure the point is inside the touchable area. 896 final int innerBound; 897 final int outerBound; 898 if (mIs24HourMode && mShowHours) { 899 innerBound = mMinDistForInnerNumber; 900 outerBound = mMaxDistForOuterNumber; 901 } else { 902 final int index = mShowHours ? HOURS : MINUTES; 903 final int center = mCircleRadius - mTextInset[index]; 904 innerBound = center - mSelectorRadius; 905 outerBound = center + mSelectorRadius; 906 } 907 908 final double dX = x - mXCenter; 909 final double dY = y - mYCenter; 910 final double distFromCenter = Math.sqrt(dX * dX + dY * dY); 911 if (distFromCenter < innerBound || constrainOutside && distFromCenter > outerBound) { 912 return -1; 913 } 914 915 // Convert to degrees. 916 final int degrees = (int) (Math.toDegrees(Math.atan2(dY, dX) + Math.PI / 2) + 0.5); 917 if (degrees < 0) { 918 return degrees + 360; 919 } else { 920 return degrees; 921 } 922 } 923 getInnerCircleFromXY(float x, float y)924 private boolean getInnerCircleFromXY(float x, float y) { 925 if (mIs24HourMode && mShowHours) { 926 final double dX = x - mXCenter; 927 final double dY = y - mYCenter; 928 final double distFromCenter = Math.sqrt(dX * dX + dY * dY); 929 return distFromCenter <= mHalfwayDist; 930 } 931 return false; 932 } 933 934 boolean mChangedDuringTouch = false; 935 936 @Override onTouchEvent(MotionEvent event)937 public boolean onTouchEvent(MotionEvent event) { 938 if (!mInputEnabled) { 939 return true; 940 } 941 942 final int action = event.getActionMasked(); 943 if (action == MotionEvent.ACTION_MOVE 944 || action == MotionEvent.ACTION_UP 945 || action == MotionEvent.ACTION_DOWN) { 946 boolean forceSelection = false; 947 boolean autoAdvance = false; 948 949 if (action == MotionEvent.ACTION_DOWN) { 950 // This is a new event stream, reset whether the value changed. 951 mChangedDuringTouch = false; 952 } else if (action == MotionEvent.ACTION_UP) { 953 autoAdvance = true; 954 955 // If we saw a down/up pair without the value changing, assume 956 // this is a single-tap selection and force a change. 957 if (!mChangedDuringTouch) { 958 forceSelection = true; 959 } 960 } 961 962 mChangedDuringTouch |= handleTouchInput( 963 event.getX(), event.getY(), forceSelection, autoAdvance); 964 } 965 966 return true; 967 } 968 handleTouchInput( float x, float y, boolean forceSelection, boolean autoAdvance)969 private boolean handleTouchInput( 970 float x, float y, boolean forceSelection, boolean autoAdvance) { 971 final boolean isOnInnerCircle = getInnerCircleFromXY(x, y); 972 final int degrees = getDegreesFromXY(x, y, false); 973 if (degrees == -1) { 974 return false; 975 } 976 977 // Ensure we're showing the correct picker. 978 animatePicker(mShowHours, ANIM_DURATION_TOUCH); 979 980 final int type; 981 final int newValue; 982 final boolean valueChanged; 983 984 if (mShowHours) { 985 final int snapDegrees = snapOnly30s(degrees, 0) % 360; 986 valueChanged = mIsOnInnerCircle != isOnInnerCircle 987 || mSelectionDegrees[HOURS] != snapDegrees; 988 mIsOnInnerCircle = isOnInnerCircle; 989 mSelectionDegrees[HOURS] = snapDegrees; 990 type = HOURS; 991 newValue = getCurrentHour(); 992 } else { 993 final int snapDegrees = snapPrefer30s(degrees) % 360; 994 valueChanged = mSelectionDegrees[MINUTES] != snapDegrees; 995 mSelectionDegrees[MINUTES] = snapDegrees; 996 type = MINUTES; 997 newValue = getCurrentMinute(); 998 } 999 1000 if (valueChanged || forceSelection || autoAdvance) { 1001 // Fire the listener even if we just need to auto-advance. 1002 if (mListener != null) { 1003 mListener.onValueSelected(type, newValue, autoAdvance); 1004 } 1005 1006 // Only provide feedback if the value actually changed. 1007 if (valueChanged || forceSelection) { 1008 performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK); 1009 invalidate(); 1010 } 1011 return true; 1012 } 1013 1014 return false; 1015 } 1016 1017 @Override dispatchHoverEvent(MotionEvent event)1018 public boolean dispatchHoverEvent(MotionEvent event) { 1019 // First right-of-refusal goes the touch exploration helper. 1020 if (mTouchHelper.dispatchHoverEvent(event)) { 1021 return true; 1022 } 1023 return super.dispatchHoverEvent(event); 1024 } 1025 setInputEnabled(boolean inputEnabled)1026 public void setInputEnabled(boolean inputEnabled) { 1027 mInputEnabled = inputEnabled; 1028 invalidate(); 1029 } 1030 1031 private class RadialPickerTouchHelper extends ExploreByTouchHelper { 1032 private final Rect mTempRect = new Rect(); 1033 1034 private final int TYPE_HOUR = 1; 1035 private final int TYPE_MINUTE = 2; 1036 1037 private final int SHIFT_TYPE = 0; 1038 private final int MASK_TYPE = 0xF; 1039 1040 private final int SHIFT_VALUE = 8; 1041 private final int MASK_VALUE = 0xFF; 1042 1043 /** Increment in which virtual views are exposed for minutes. */ 1044 private final int MINUTE_INCREMENT = 5; 1045 RadialPickerTouchHelper()1046 public RadialPickerTouchHelper() { 1047 super(RadialTimePickerView.this); 1048 } 1049 1050 @Override onInitializeAccessibilityNodeInfo(View host, AccessibilityNodeInfo info)1051 public void onInitializeAccessibilityNodeInfo(View host, AccessibilityNodeInfo info) { 1052 super.onInitializeAccessibilityNodeInfo(host, info); 1053 1054 info.addAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_SCROLL_FORWARD); 1055 info.addAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_SCROLL_BACKWARD); 1056 } 1057 1058 @Override performAccessibilityAction(View host, int action, Bundle arguments)1059 public boolean performAccessibilityAction(View host, int action, Bundle arguments) { 1060 if (super.performAccessibilityAction(host, action, arguments)) { 1061 return true; 1062 } 1063 1064 switch (action) { 1065 case AccessibilityNodeInfo.ACTION_SCROLL_FORWARD: 1066 adjustPicker(1); 1067 return true; 1068 case AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD: 1069 adjustPicker(-1); 1070 return true; 1071 } 1072 1073 return false; 1074 } 1075 adjustPicker(int step)1076 private void adjustPicker(int step) { 1077 final int stepSize; 1078 final int initialStep; 1079 final int maxValue; 1080 final int minValue; 1081 if (mShowHours) { 1082 stepSize = 1; 1083 1084 final int currentHour24 = getCurrentHour(); 1085 if (mIs24HourMode) { 1086 initialStep = currentHour24; 1087 minValue = 0; 1088 maxValue = 23; 1089 } else { 1090 initialStep = hour24To12(currentHour24); 1091 minValue = 1; 1092 maxValue = 12; 1093 } 1094 } else { 1095 stepSize = 5; 1096 initialStep = getCurrentMinute() / stepSize; 1097 minValue = 0; 1098 maxValue = 55; 1099 } 1100 1101 final int nextValue = (initialStep + step) * stepSize; 1102 final int clampedValue = MathUtils.constrain(nextValue, minValue, maxValue); 1103 if (mShowHours) { 1104 setCurrentHour(clampedValue); 1105 } else { 1106 setCurrentMinute(clampedValue); 1107 } 1108 } 1109 1110 @Override getVirtualViewAt(float x, float y)1111 protected int getVirtualViewAt(float x, float y) { 1112 final int id; 1113 final int degrees = getDegreesFromXY(x, y, true); 1114 if (degrees != -1) { 1115 final int snapDegrees = snapOnly30s(degrees, 0) % 360; 1116 if (mShowHours) { 1117 final boolean isOnInnerCircle = getInnerCircleFromXY(x, y); 1118 final int hour24 = getHourForDegrees(snapDegrees, isOnInnerCircle); 1119 final int hour = mIs24HourMode ? hour24 : hour24To12(hour24); 1120 id = makeId(TYPE_HOUR, hour); 1121 } else { 1122 final int current = getCurrentMinute(); 1123 final int touched = getMinuteForDegrees(degrees); 1124 final int snapped = getMinuteForDegrees(snapDegrees); 1125 1126 // If the touched minute is closer to the current minute 1127 // than it is to the snapped minute, return current. 1128 final int currentOffset = getCircularDiff(current, touched, MINUTES_IN_CIRCLE); 1129 final int snappedOffset = getCircularDiff(snapped, touched, MINUTES_IN_CIRCLE); 1130 final int minute; 1131 if (currentOffset < snappedOffset) { 1132 minute = current; 1133 } else { 1134 minute = snapped; 1135 } 1136 id = makeId(TYPE_MINUTE, minute); 1137 } 1138 } else { 1139 id = INVALID_ID; 1140 } 1141 1142 return id; 1143 } 1144 1145 /** 1146 * Returns the difference in degrees between two values along a circle. 1147 * 1148 * @param first value in the range [0,max] 1149 * @param second value in the range [0,max] 1150 * @param max the maximum value along the circle 1151 * @return the difference in between the two values 1152 */ getCircularDiff(int first, int second, int max)1153 private int getCircularDiff(int first, int second, int max) { 1154 final int diff = Math.abs(first - second); 1155 final int midpoint = max / 2; 1156 return (diff > midpoint) ? (max - diff) : diff; 1157 } 1158 1159 @Override getVisibleVirtualViews(IntArray virtualViewIds)1160 protected void getVisibleVirtualViews(IntArray virtualViewIds) { 1161 if (mShowHours) { 1162 final int min = mIs24HourMode ? 0 : 1; 1163 final int max = mIs24HourMode ? 23 : 12; 1164 for (int i = min; i <= max ; i++) { 1165 virtualViewIds.add(makeId(TYPE_HOUR, i)); 1166 } 1167 } else { 1168 final int current = getCurrentMinute(); 1169 for (int i = 0; i < MINUTES_IN_CIRCLE; i += MINUTE_INCREMENT) { 1170 virtualViewIds.add(makeId(TYPE_MINUTE, i)); 1171 1172 // If the current minute falls between two increments, 1173 // insert an extra node for it. 1174 if (current > i && current < i + MINUTE_INCREMENT) { 1175 virtualViewIds.add(makeId(TYPE_MINUTE, current)); 1176 } 1177 } 1178 } 1179 } 1180 1181 @Override onPopulateEventForVirtualView(int virtualViewId, AccessibilityEvent event)1182 protected void onPopulateEventForVirtualView(int virtualViewId, AccessibilityEvent event) { 1183 event.setClassName(getClass().getName()); 1184 1185 final int type = getTypeFromId(virtualViewId); 1186 final int value = getValueFromId(virtualViewId); 1187 final CharSequence description = getVirtualViewDescription(type, value); 1188 event.setContentDescription(description); 1189 } 1190 1191 @Override onPopulateNodeForVirtualView(int virtualViewId, AccessibilityNodeInfo node)1192 protected void onPopulateNodeForVirtualView(int virtualViewId, AccessibilityNodeInfo node) { 1193 node.setClassName(getClass().getName()); 1194 node.addAction(AccessibilityAction.ACTION_CLICK); 1195 1196 final int type = getTypeFromId(virtualViewId); 1197 final int value = getValueFromId(virtualViewId); 1198 final CharSequence description = getVirtualViewDescription(type, value); 1199 node.setContentDescription(description); 1200 1201 getBoundsForVirtualView(virtualViewId, mTempRect); 1202 node.setBoundsInParent(mTempRect); 1203 1204 final boolean selected = isVirtualViewSelected(type, value); 1205 node.setSelected(selected); 1206 1207 final int nextId = getVirtualViewIdAfter(type, value); 1208 if (nextId != INVALID_ID) { 1209 node.setTraversalBefore(RadialTimePickerView.this, nextId); 1210 } 1211 } 1212 getVirtualViewIdAfter(int type, int value)1213 private int getVirtualViewIdAfter(int type, int value) { 1214 if (type == TYPE_HOUR) { 1215 final int nextValue = value + 1; 1216 final int max = mIs24HourMode ? 23 : 12; 1217 if (nextValue <= max) { 1218 return makeId(type, nextValue); 1219 } 1220 } else if (type == TYPE_MINUTE) { 1221 final int current = getCurrentMinute(); 1222 final int snapValue = value - (value % MINUTE_INCREMENT); 1223 final int nextValue = snapValue + MINUTE_INCREMENT; 1224 if (value < current && nextValue > current) { 1225 // The current value is between two snap values. 1226 return makeId(type, current); 1227 } else if (nextValue < MINUTES_IN_CIRCLE) { 1228 return makeId(type, nextValue); 1229 } 1230 } 1231 return INVALID_ID; 1232 } 1233 1234 @Override onPerformActionForVirtualView(int virtualViewId, int action, Bundle arguments)1235 protected boolean onPerformActionForVirtualView(int virtualViewId, int action, 1236 Bundle arguments) { 1237 if (action == AccessibilityNodeInfo.ACTION_CLICK) { 1238 final int type = getTypeFromId(virtualViewId); 1239 final int value = getValueFromId(virtualViewId); 1240 if (type == TYPE_HOUR) { 1241 final int hour = mIs24HourMode ? value : hour12To24(value, mAmOrPm); 1242 setCurrentHour(hour); 1243 return true; 1244 } else if (type == TYPE_MINUTE) { 1245 setCurrentMinute(value); 1246 return true; 1247 } 1248 } 1249 return false; 1250 } 1251 hour12To24(int hour12, int amOrPm)1252 private int hour12To24(int hour12, int amOrPm) { 1253 int hour24 = hour12; 1254 if (hour12 == 12) { 1255 if (amOrPm == AM) { 1256 hour24 = 0; 1257 } 1258 } else if (amOrPm == PM) { 1259 hour24 += 12; 1260 } 1261 return hour24; 1262 } 1263 hour24To12(int hour24)1264 private int hour24To12(int hour24) { 1265 if (hour24 == 0) { 1266 return 12; 1267 } else if (hour24 > 12) { 1268 return hour24 - 12; 1269 } else { 1270 return hour24; 1271 } 1272 } 1273 getBoundsForVirtualView(int virtualViewId, Rect bounds)1274 private void getBoundsForVirtualView(int virtualViewId, Rect bounds) { 1275 final float radius; 1276 final int type = getTypeFromId(virtualViewId); 1277 final int value = getValueFromId(virtualViewId); 1278 final float centerRadius; 1279 final float degrees; 1280 if (type == TYPE_HOUR) { 1281 final boolean innerCircle = getInnerCircleForHour(value); 1282 if (innerCircle) { 1283 centerRadius = mCircleRadius - mTextInset[HOURS_INNER]; 1284 radius = mSelectorRadius; 1285 } else { 1286 centerRadius = mCircleRadius - mTextInset[HOURS]; 1287 radius = mSelectorRadius; 1288 } 1289 1290 degrees = getDegreesForHour(value); 1291 } else if (type == TYPE_MINUTE) { 1292 centerRadius = mCircleRadius - mTextInset[MINUTES]; 1293 degrees = getDegreesForMinute(value); 1294 radius = mSelectorRadius; 1295 } else { 1296 // This should never happen. 1297 centerRadius = 0; 1298 degrees = 0; 1299 radius = 0; 1300 } 1301 1302 final double radians = Math.toRadians(degrees); 1303 final float xCenter = mXCenter + centerRadius * (float) Math.sin(radians); 1304 final float yCenter = mYCenter - centerRadius * (float) Math.cos(radians); 1305 1306 bounds.set((int) (xCenter - radius), (int) (yCenter - radius), 1307 (int) (xCenter + radius), (int) (yCenter + radius)); 1308 } 1309 getVirtualViewDescription(int type, int value)1310 private CharSequence getVirtualViewDescription(int type, int value) { 1311 final CharSequence description; 1312 if (type == TYPE_HOUR || type == TYPE_MINUTE) { 1313 description = Integer.toString(value); 1314 } else { 1315 description = null; 1316 } 1317 return description; 1318 } 1319 isVirtualViewSelected(int type, int value)1320 private boolean isVirtualViewSelected(int type, int value) { 1321 final boolean selected; 1322 if (type == TYPE_HOUR) { 1323 selected = getCurrentHour() == value; 1324 } else if (type == TYPE_MINUTE) { 1325 selected = getCurrentMinute() == value; 1326 } else { 1327 selected = false; 1328 } 1329 return selected; 1330 } 1331 makeId(int type, int value)1332 private int makeId(int type, int value) { 1333 return type << SHIFT_TYPE | value << SHIFT_VALUE; 1334 } 1335 getTypeFromId(int id)1336 private int getTypeFromId(int id) { 1337 return id >>> SHIFT_TYPE & MASK_TYPE; 1338 } 1339 getValueFromId(int id)1340 private int getValueFromId(int id) { 1341 return id >>> SHIFT_VALUE & MASK_VALUE; 1342 } 1343 } 1344 } 1345