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