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