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