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