RadialPickerLayout.java revision 3fc32c45f5efc4ce4b91cbcdd925d9b30f67046e
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 17package com.android.datetimepicker.time; 18 19import android.animation.AnimatorSet; 20import android.animation.ObjectAnimator; 21import android.annotation.SuppressLint; 22import android.content.Context; 23import android.content.res.Resources; 24import android.os.Bundle; 25import android.os.Handler; 26import android.os.SystemClock; 27import android.text.format.DateUtils; 28import android.text.format.Time; 29import android.util.AttributeSet; 30import android.util.Log; 31import android.view.MotionEvent; 32import android.view.View; 33import android.view.View.OnTouchListener; 34import android.view.ViewConfiguration; 35import android.view.ViewGroup; 36import android.view.accessibility.AccessibilityEvent; 37import android.view.accessibility.AccessibilityManager; 38import android.view.accessibility.AccessibilityNodeInfo; 39import android.widget.FrameLayout; 40 41import com.android.datetimepicker.R; 42 43public class RadialPickerLayout extends FrameLayout implements OnTouchListener { 44 private static final String TAG = "RadialPickerLayout"; 45 46 private final int TOUCH_SLOP; 47 private final int TAP_TIMEOUT; 48 49 private static final int VISIBLE_DEGREES_STEP_SIZE = 30; 50 private static final int HOUR_VALUE_TO_DEGREES_STEP_SIZE = VISIBLE_DEGREES_STEP_SIZE; 51 private static final int MINUTE_VALUE_TO_DEGREES_STEP_SIZE = 6; 52 private static final int HOUR_INDEX = TimePickerDialog.HOUR_INDEX; 53 private static final int MINUTE_INDEX = TimePickerDialog.MINUTE_INDEX; 54 private static final int AMPM_INDEX = TimePickerDialog.AMPM_INDEX; 55 private static final int ENABLE_PICKER_INDEX = TimePickerDialog.ENABLE_PICKER_INDEX; 56 private static final int AM = TimePickerDialog.AM; 57 private static final int PM = TimePickerDialog.PM; 58 59 private int mLastValueSelected; 60 61 private TimePickerDialog mController; 62 private OnValueSelectedListener mListener; 63 private boolean mTimeInitialized; 64 private int mCurrentHoursOfDay; 65 private int mCurrentMinutes; 66 private boolean mIs24HourMode; 67 private boolean mHideAmPm; 68 private int mCurrentItemShowing; 69 70 private CircleView mCircleView; 71 private AmPmCirclesView mAmPmCirclesView; 72 private RadialTextsView mHourRadialTextsView; 73 private RadialTextsView mMinuteRadialTextsView; 74 private RadialSelectorView mHourRadialSelectorView; 75 private RadialSelectorView mMinuteRadialSelectorView; 76 private View mGrayBox; 77 78 private int[] mSnapPrefer30sMap; 79 private boolean mInputEnabled; 80 private int mIsTouchingAmOrPm = -1; 81 private boolean mDoingMove; 82 private boolean mDoingTouch; 83 private int mDownDegrees; 84 private float mDownX; 85 private float mDownY; 86 private AccessibilityManager mAccessibilityManager; 87 88 private AnimatorSet mTransition; 89 private Handler mHandler = new Handler(); 90 91 public interface OnValueSelectedListener { 92 void onValueSelected(int pickerIndex, int newValue, boolean autoAdvance); 93 } 94 95 public RadialPickerLayout(Context context, AttributeSet attrs) { 96 super(context, attrs); 97 98 setOnTouchListener(this); 99 ViewConfiguration vc = ViewConfiguration.get(context); 100 TOUCH_SLOP = vc.getScaledTouchSlop(); 101 TAP_TIMEOUT = ViewConfiguration.getTapTimeout(); 102 mDoingMove = false; 103 104 mCircleView = new CircleView(context); 105 addView(mCircleView); 106 107 mAmPmCirclesView = new AmPmCirclesView(context); 108 addView(mAmPmCirclesView); 109 110 mHourRadialTextsView = new RadialTextsView(context); 111 addView(mHourRadialTextsView); 112 mMinuteRadialTextsView = new RadialTextsView(context); 113 addView(mMinuteRadialTextsView); 114 115 mHourRadialSelectorView = new RadialSelectorView(context); 116 addView(mHourRadialSelectorView); 117 mMinuteRadialSelectorView = new RadialSelectorView(context); 118 addView(mMinuteRadialSelectorView); 119 120 // Prepare mapping to snap touchable degrees to selectable degrees. 121 preparePrefer30sMap(); 122 123 mLastValueSelected = -1; 124 125 mInputEnabled = true; 126 mGrayBox = new View(context); 127 mGrayBox.setLayoutParams(new ViewGroup.LayoutParams( 128 ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)); 129 mGrayBox.setBackgroundColor(getResources().getColor(R.color.transparent_black)); 130 mGrayBox.setVisibility(View.INVISIBLE); 131 addView(mGrayBox); 132 133 mAccessibilityManager = (AccessibilityManager) context.getSystemService(Context.ACCESSIBILITY_SERVICE); 134 135 mTimeInitialized = false; 136 } 137 138 /** 139 * Measure the view to end up as a square, based on the minimum of the height and width. 140 */ 141 @Override 142 public void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { 143 int measuredWidth = MeasureSpec.getSize(widthMeasureSpec); 144 int widthMode = MeasureSpec.getMode(widthMeasureSpec); 145 int measuredHeight = MeasureSpec.getSize(heightMeasureSpec); 146 int heightMode = MeasureSpec.getMode(heightMeasureSpec); 147 int minDimension = Math.min(measuredWidth, measuredHeight); 148 149 super.onMeasure(MeasureSpec.makeMeasureSpec(minDimension, widthMode), 150 MeasureSpec.makeMeasureSpec(minDimension, heightMode)); 151 } 152 153 public void setOnValueSelectedListener(OnValueSelectedListener listener) { 154 mListener = listener; 155 } 156 157 /** 158 * Initialize the Layout with starting values. 159 * @param context 160 * @param initialHoursOfDay 161 * @param initialMinutes 162 * @param is24HourMode 163 */ 164 public void initialize(Context context, TimePickerDialog controller, 165 int initialHoursOfDay, int initialMinutes, boolean is24HourMode) { 166 if (mTimeInitialized) { 167 Log.e(TAG, "Time has already been initialized."); 168 return; 169 } 170 171 mController = controller; 172 mIs24HourMode = is24HourMode; 173 mHideAmPm = mAccessibilityManager.isTouchExplorationEnabled()? true : mIs24HourMode; 174 175 // Initialize the circle and AM/PM circles if applicable. 176 mCircleView.initialize(context, mHideAmPm); 177 mCircleView.invalidate(); 178 if (!mHideAmPm) { 179 mAmPmCirclesView.initialize(context, initialHoursOfDay < 12? AM : PM); 180 mAmPmCirclesView.invalidate(); 181 } 182 183 // Initialize the hours and minutes numbers. 184 Resources res = context.getResources(); 185 int[] hours = {12, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11}; 186 int[] hours_24 = {0, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23}; 187 int[] minutes = {0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55}; 188 String[] hoursTexts = new String[12]; 189 String[] innerHoursTexts = new String[12]; 190 String[] minutesTexts = new String[12]; 191 for (int i = 0; i < 12; i++) { 192 hoursTexts[i] = is24HourMode? 193 String.format("%02d", hours_24[i]) : String.format("%d", hours[i]); 194 innerHoursTexts[i] = String.format("%d", hours[i]); 195 minutesTexts[i] = String.format("%02d", minutes[i]); 196 } 197 mHourRadialTextsView.initialize(res, 198 hoursTexts, (is24HourMode? innerHoursTexts : null), mHideAmPm, true); 199 mHourRadialTextsView.invalidate(); 200 mMinuteRadialTextsView.initialize(res, minutesTexts, null, mHideAmPm, false); 201 mMinuteRadialTextsView.invalidate(); 202 203 // Initialize the currently-selected hour and minute. 204 setValueForItem(HOUR_INDEX, initialHoursOfDay); 205 setValueForItem(MINUTE_INDEX, initialMinutes); 206 int hourDegrees = (initialHoursOfDay % 12) * HOUR_VALUE_TO_DEGREES_STEP_SIZE; 207 mHourRadialSelectorView.initialize(context, mHideAmPm, is24HourMode, true, 208 hourDegrees, isHourInnerCircle(initialHoursOfDay)); 209 int minuteDegrees = initialMinutes * MINUTE_VALUE_TO_DEGREES_STEP_SIZE; 210 mMinuteRadialSelectorView.initialize(context, mHideAmPm, false, false, 211 minuteDegrees, false); 212 213 mTimeInitialized = true; 214 } 215 216 public void setTime(int hours, int minutes) { 217 setItem(HOUR_INDEX, hours); 218 setItem(MINUTE_INDEX, minutes); 219 } 220 221 /** 222 * Set either the hour or the minute. Will set the internal value, and set the selection. 223 */ 224 private void setItem(int index, int value) { 225 if (index == HOUR_INDEX) { 226 setValueForItem(HOUR_INDEX, value); 227 int hourDegrees = (value % 12) * HOUR_VALUE_TO_DEGREES_STEP_SIZE; 228 mHourRadialSelectorView.setSelection(hourDegrees, isHourInnerCircle(value), false); 229 mHourRadialSelectorView.invalidate(); 230 } else if (index == MINUTE_INDEX) { 231 setValueForItem(MINUTE_INDEX, value); 232 int minuteDegrees = value * MINUTE_VALUE_TO_DEGREES_STEP_SIZE; 233 mMinuteRadialSelectorView.setSelection(minuteDegrees, false, false); 234 mMinuteRadialSelectorView.invalidate(); 235 } 236 } 237 238 /** 239 * Check if a given hour appears in the outer circle or the inner circle 240 * @return true if the hour is in the inner circle, false if it's in the outer circle. 241 */ 242 private boolean isHourInnerCircle(int hourOfDay) { 243 // We'll have the 00 hours on the outside circle. 244 return mIs24HourMode && (hourOfDay <= 12 && hourOfDay != 0); 245 } 246 247 public int getHours() { 248 return mCurrentHoursOfDay; 249 } 250 251 public int getMinutes() { 252 return mCurrentMinutes; 253 } 254 255 /** 256 * If the hours are showing, return the current hour. If the minutes are showing, return the 257 * current minute. 258 */ 259 private int getCurrentlyShowingValue() { 260 int currentIndex = getCurrentItemShowing(); 261 if (currentIndex == HOUR_INDEX) { 262 return mCurrentHoursOfDay; 263 } else if (currentIndex == MINUTE_INDEX) { 264 return mCurrentMinutes; 265 } else { 266 return -1; 267 } 268 } 269 270 public int getIsCurrentlyAmOrPm() { 271 if (mCurrentHoursOfDay < 12) { 272 return AM; 273 } else if (mCurrentHoursOfDay < 24) { 274 return PM; 275 } 276 return -1; 277 } 278 279 /** 280 * Set the internal value for the hour, minute, or AM/PM. 281 */ 282 private void setValueForItem(int index, int value) { 283 if (index == HOUR_INDEX) { 284 mCurrentHoursOfDay = value; 285 } else if (index == MINUTE_INDEX){ 286 mCurrentMinutes = value; 287 } else if (index == AMPM_INDEX) { 288 if (value == AM) { 289 mCurrentHoursOfDay = mCurrentHoursOfDay % 12; 290 } else if (value == PM) { 291 mCurrentHoursOfDay = (mCurrentHoursOfDay % 12) + 12; 292 } 293 } 294 } 295 296 /** 297 * Set the internal value as either AM or PM, and update the AM/PM circle displays. 298 * @param amOrPm 299 */ 300 public void setAmOrPm(int amOrPm) { 301 mAmPmCirclesView.setAmOrPm(amOrPm); 302 mAmPmCirclesView.invalidate(); 303 setValueForItem(AMPM_INDEX, amOrPm); 304 } 305 306 /** 307 * Split up the 360 degrees of the circle among the 60 selectable values. Assigns a larger 308 * selectable area to each of the 12 visible values, such that the ratio of space apportioned 309 * to a visible value : space apportioned to a non-visible value will be 14 : 4. 310 * E.g. the output of 30 degrees should have a higher range of input associated with it than 311 * the output of 24 degrees, because 30 degrees corresponds to a visible number on the clock 312 * circle (5 on the minutes, 1 or 13 on the hours). 313 */ 314 private void preparePrefer30sMap() { 315 // We'll split up the visible output and the non-visible output such that each visible 316 // output will correspond to a range of 14 associated input degrees, and each non-visible 317 // output will correspond to a range of 4 associate input degrees, so visible numbers 318 // are more than 3 times easier to get than non-visible numbers: 319 // {354-359,0-7}:0, {8-11}:6, {12-15}:12, {16-19}:18, {20-23}:24, {24-37}:30, etc. 320 // 321 // If an output of 30 degrees should correspond to a range of 14 associated degrees, then 322 // we'll need any input between 24 - 37 to snap to 30. Working out from there, 20-23 should 323 // snap to 24, while 38-41 should snap to 36. This is somewhat counter-intuitive, that you 324 // can be touching 36 degrees but have the selection snapped to 30 degrees; however, this 325 // inconsistency isn't noticeable at such fine-grained degrees, and it affords us the 326 // ability to aggressively prefer the visible values by a factor of more than 3:1, which 327 // greatly contributes to the selectability of these values. 328 329 // Our input will be 0 through 360. 330 mSnapPrefer30sMap = new int[361]; 331 332 // The first output is 0, and each following output will increment by 6 {0, 6, 12, ...}. 333 int snappedOutputDegrees = 0; 334 // Count of how many inputs we've designated to the specified output. 335 int count = 1; 336 // How many input we expect for a specified output. This will be 14 for output divisible 337 // by 30, and 4 for the remaining output. We'll special case the outputs of 0 and 360, so 338 // the caller can decide which they need. 339 int expectedCount = 8; 340 // Iterate through the input. 341 for (int degrees = 0; degrees < 361; degrees++) { 342 // Save the input-output mapping. 343 mSnapPrefer30sMap[degrees] = snappedOutputDegrees; 344 // If this is the last input for the specified output, calculate the next output and 345 // the next expected count. 346 if (count == expectedCount) { 347 snappedOutputDegrees += 6; 348 if (snappedOutputDegrees == 360) { 349 expectedCount = 7; 350 } else if (snappedOutputDegrees % 30 == 0) { 351 expectedCount = 14; 352 } else { 353 expectedCount = 4; 354 } 355 count = 1; 356 } else { 357 count++; 358 } 359 } 360 } 361 362 /** 363 * Returns mapping of any input degrees (0 to 360) to one of 60 selectable output degrees, 364 * where the degrees corresponding to visible numbers (i.e. those divisible by 30) will be 365 * weighted heavier than the degrees corresponding to non-visible numbers. 366 * See {@link #preparePrefer30sMap()} documentation for the rationale and generation of the 367 * mapping. 368 */ 369 private int snapPrefer30s(int degrees) { 370 if (mSnapPrefer30sMap == null) { 371 return -1; 372 } 373 return mSnapPrefer30sMap[degrees]; 374 } 375 376 /** 377 * Returns mapping of any input degrees (0 to 360) to one of 12 visible output degrees (all 378 * multiples of 30), where the input will be "snapped" to the closest visible degrees. 379 * @param degrees The input degrees 380 * @param forceAboveOrBelow The output may be forced to either the higher or lower step, or may 381 * be allowed to snap to whichever is closer. Use 1 to force strictly higher, -1 to force 382 * strictly lower, and 0 to snap to the closer one. 383 * @return output degrees, will be a multiple of 30 384 */ 385 private int snapOnly30s(int degrees, int forceHigherOrLower) { 386 int stepSize = HOUR_VALUE_TO_DEGREES_STEP_SIZE; 387 int floor = (degrees / stepSize) * stepSize; 388 int ceiling = floor + stepSize; 389 if (forceHigherOrLower == 1) { 390 degrees = ceiling; 391 } else if (forceHigherOrLower == -1) { 392 if (degrees == floor) { 393 floor -= stepSize; 394 } 395 degrees = floor; 396 } else { 397 if ((degrees - floor) < (ceiling - degrees)) { 398 degrees = floor; 399 } else { 400 degrees = ceiling; 401 } 402 } 403 return degrees; 404 } 405 406 /** 407 * For the currently showing view (either hours or minutes), re-calculate the position for the 408 * selector, and redraw it at that position. The input degrees will be snapped to a selectable 409 * value. 410 * @param degrees Degrees which should be selected. 411 * @param isInnerCircle Whether the selection should be in the inner circle; will be ignored 412 * if there is no inner circle. 413 * @param forceToVisibleValue Even if the currently-showing circle allows for fine-grained 414 * selection (i.e. minutes), force the selection to one of the visibly-showing values. 415 * @param forceDrawDot The dot in the circle will generally only be shown when the selection 416 * is on non-visible values, but use this to force the dot to be shown. 417 * @return The value that was selected, i.e. 0-23 for hours, 0-59 for minutes. 418 */ 419 private int reselectSelector(int degrees, boolean isInnerCircle, 420 boolean forceToVisibleValue, boolean forceDrawDot) { 421 if (degrees == -1) { 422 return -1; 423 } 424 int currentShowing = getCurrentItemShowing(); 425 426 int stepSize; 427 boolean allowFineGrained = !forceToVisibleValue && (currentShowing == MINUTE_INDEX); 428 if (allowFineGrained) { 429 degrees = snapPrefer30s(degrees); 430 } else { 431 degrees = snapOnly30s(degrees, 0); 432 } 433 434 RadialSelectorView radialSelectorView; 435 if (currentShowing == HOUR_INDEX) { 436 radialSelectorView = mHourRadialSelectorView; 437 stepSize = HOUR_VALUE_TO_DEGREES_STEP_SIZE; 438 } else { 439 radialSelectorView = mMinuteRadialSelectorView; 440 stepSize = MINUTE_VALUE_TO_DEGREES_STEP_SIZE; 441 } 442 radialSelectorView.setSelection(degrees, isInnerCircle, forceDrawDot); 443 radialSelectorView.invalidate(); 444 445 446 if (currentShowing == HOUR_INDEX) { 447 if (mIs24HourMode) { 448 if (degrees == 0 && isInnerCircle) { 449 degrees = 360; 450 } else if (degrees == 360 && !isInnerCircle) { 451 degrees = 0; 452 } 453 } else if (degrees == 0) { 454 degrees = 360; 455 } 456 } else if (degrees == 360 && currentShowing == MINUTE_INDEX) { 457 degrees = 0; 458 } 459 460 int value = degrees / stepSize; 461 if (currentShowing == HOUR_INDEX && mIs24HourMode && !isInnerCircle && degrees != 0) { 462 value += 12; 463 } 464 return value; 465 } 466 467 /** 468 * Calculate the degrees within the circle that corresponds to the specified coordinates, if 469 * the coordinates are within the range that will trigger a selection. 470 * @param pointX The x coordinate. 471 * @param pointY The y coordinate. 472 * @param forceLegal Force the selection to be legal, regardless of how far the coordinates are 473 * from the actual numbers. 474 * @param isInnerCircle If the selection may be in the inner circle, pass in a size-1 boolean 475 * array here, inside which the value will be true if the selection is in the inner circle, 476 * and false if in the outer circle. 477 * @return Degrees from 0 to 360, if the selection was within the legal range. -1 if not. 478 */ 479 private int getDegreesFromCoords(float pointX, float pointY, boolean forceLegal, 480 final Boolean[] isInnerCircle) { 481 int currentItem = getCurrentItemShowing(); 482 if (currentItem == HOUR_INDEX) { 483 return mHourRadialSelectorView.getDegreesFromCoords( 484 pointX, pointY, forceLegal, isInnerCircle); 485 } else if (currentItem == MINUTE_INDEX) { 486 return mMinuteRadialSelectorView.getDegreesFromCoords( 487 pointX, pointY, forceLegal, isInnerCircle); 488 } else { 489 return -1; 490 } 491 } 492 493 /** 494 * Get the item (hours or minutes) that is currently showing. 495 */ 496 public int getCurrentItemShowing() { 497 if (mCurrentItemShowing != HOUR_INDEX && mCurrentItemShowing != MINUTE_INDEX) { 498 Log.e(TAG, "Current item showing was unfortunately set to "+mCurrentItemShowing); 499 return -1; 500 } 501 return mCurrentItemShowing; 502 } 503 504 /** 505 * Set either minutes or hours as showing. 506 * @param animate True to animate the transition, false to show with no animation. 507 */ 508 public void setCurrentItemShowing(int index, boolean animate) { 509 if (index != HOUR_INDEX && index != MINUTE_INDEX) { 510 Log.e(TAG, "TimePicker does not support view at index "+index); 511 return; 512 } 513 514 int lastIndex = getCurrentItemShowing(); 515 mCurrentItemShowing = index; 516 517 if (animate && (index != lastIndex)) { 518 ObjectAnimator[] anims = new ObjectAnimator[4]; 519 if (index == MINUTE_INDEX) { 520 anims[0] = mHourRadialTextsView.getDisappearAnimator(); 521 anims[1] = mHourRadialSelectorView.getDisappearAnimator(); 522 anims[2] = mMinuteRadialTextsView.getReappearAnimator(); 523 anims[3] = mMinuteRadialSelectorView.getReappearAnimator(); 524 } else if (index == HOUR_INDEX){ 525 anims[0] = mHourRadialTextsView.getReappearAnimator(); 526 anims[1] = mHourRadialSelectorView.getReappearAnimator(); 527 anims[2] = mMinuteRadialTextsView.getDisappearAnimator(); 528 anims[3] = mMinuteRadialSelectorView.getDisappearAnimator(); 529 } 530 531 if (mTransition != null && mTransition.isRunning()) { 532 mTransition.end(); 533 } 534 mTransition = new AnimatorSet(); 535 mTransition.playTogether(anims); 536 mTransition.start(); 537 } else { 538 int hourAlpha = (index == HOUR_INDEX) ? 255 : 0; 539 int minuteAlpha = (index == MINUTE_INDEX) ? 255 : 0; 540 mHourRadialTextsView.setAlpha(hourAlpha); 541 mHourRadialSelectorView.setAlpha(hourAlpha); 542 mMinuteRadialTextsView.setAlpha(minuteAlpha); 543 mMinuteRadialSelectorView.setAlpha(minuteAlpha); 544 } 545 546 } 547 548 @Override 549 public boolean onTouch(View v, MotionEvent event) { 550 final float eventX = event.getX(); 551 final float eventY = event.getY(); 552 int degrees; 553 int value; 554 final Boolean[] isInnerCircle = new Boolean[1]; 555 isInnerCircle[0] = false; 556 557 long millis = SystemClock.uptimeMillis(); 558 559 switch(event.getAction()) { 560 case MotionEvent.ACTION_DOWN: 561 if (!mInputEnabled) { 562 return true; 563 } 564 565 mDownX = eventX; 566 mDownY = eventY; 567 568 mLastValueSelected = -1; 569 mDoingMove = false; 570 mDoingTouch = true; 571 // If we're showing the AM/PM, check to see if the user is touching it. 572 if (!mHideAmPm) { 573 mIsTouchingAmOrPm = mAmPmCirclesView.getIsTouchingAmOrPm(eventX, eventY); 574 } else { 575 mIsTouchingAmOrPm = -1; 576 } 577 if (mIsTouchingAmOrPm == AM || mIsTouchingAmOrPm == PM) { 578 // If the touch is on AM or PM, set it as "touched" after the TAP_TIMEOUT 579 // in case the user moves their finger quickly. 580 mController.tryVibrate(); 581 mDownDegrees = -1; 582 mHandler.postDelayed(new Runnable() { 583 @Override 584 public void run() { 585 mAmPmCirclesView.setAmOrPmPressed(mIsTouchingAmOrPm); 586 mAmPmCirclesView.invalidate(); 587 } 588 }, TAP_TIMEOUT); 589 } else { 590 // If we're in accessibility mode, force the touch to be legal. Otherwise, 591 // it will only register within the given touch target zone. 592 boolean forceLegal = mAccessibilityManager.isTouchExplorationEnabled(); 593 // Calculate the degrees that is currently being touched. 594 mDownDegrees = getDegreesFromCoords(eventX, eventY, forceLegal, isInnerCircle); 595 if (mDownDegrees != -1) { 596 // If it's a legal touch, set that number as "selected" after the 597 // TAP_TIMEOUT in case the user moves their finger quickly. 598 mController.tryVibrate(); 599 mHandler.postDelayed(new Runnable() { 600 @Override 601 public void run() { 602 mDoingMove = true; 603 int value = reselectSelector(mDownDegrees, isInnerCircle[0], 604 false, true); 605 mLastValueSelected = value; 606 mListener.onValueSelected(getCurrentItemShowing(), value, false); 607 } 608 }, TAP_TIMEOUT); 609 } 610 } 611 return true; 612 case MotionEvent.ACTION_MOVE: 613 if (!mInputEnabled) { 614 // We shouldn't be in this state, because input is disabled. 615 Log.e(TAG, "Input was disabled, but received ACTION_MOVE."); 616 return true; 617 } 618 619 float dY = Math.abs(eventY - mDownY); 620 float dX = Math.abs(eventX - mDownX); 621 622 if (!mDoingMove && dX <= TOUCH_SLOP && dY <= TOUCH_SLOP) { 623 // Hasn't registered down yet, just slight, accidental movement of finger. 624 break; 625 } 626 627 // If we're in the middle of touching down on AM or PM, check if we still are. 628 // If so, no-op. If not, remove its pressed state. Either way, no need to check 629 // for touches on the other circle. 630 if (mIsTouchingAmOrPm == AM || mIsTouchingAmOrPm == PM) { 631 mHandler.removeCallbacksAndMessages(null); 632 int isTouchingAmOrPm = mAmPmCirclesView.getIsTouchingAmOrPm(eventX, eventY); 633 if (isTouchingAmOrPm != mIsTouchingAmOrPm) { 634 mAmPmCirclesView.setAmOrPmPressed(-1); 635 mAmPmCirclesView.invalidate(); 636 mIsTouchingAmOrPm = -1; 637 } 638 break; 639 } 640 641 if (mDownDegrees == -1) { 642 // Original down was illegal, so no movement will register. 643 break; 644 } 645 646 // We're doing a move along the circle, so move the selection as appropriate. 647 mDoingMove = true; 648 mHandler.removeCallbacksAndMessages(null); 649 degrees = getDegreesFromCoords(eventX, eventY, true, isInnerCircle); 650 if (degrees != -1) { 651 value = reselectSelector(degrees, isInnerCircle[0], false, true); 652 if (value != mLastValueSelected) { 653 mController.tryVibrate(); 654 mLastValueSelected = value; 655 mListener.onValueSelected(getCurrentItemShowing(), value, false); 656 } 657 } 658 return true; 659 case MotionEvent.ACTION_UP: 660 if (!mInputEnabled) { 661 // If our touch input was disabled, tell the listener to re-enable us. 662 Log.d(TAG, "Input was disabled, but received ACTION_UP."); 663 mListener.onValueSelected(ENABLE_PICKER_INDEX, 1, false); 664 return true; 665 } 666 667 mHandler.removeCallbacksAndMessages(null); 668 mDoingTouch = false; 669 670 // If we're touching AM or PM, set it as selected, and tell the listener. 671 if (mIsTouchingAmOrPm == AM || mIsTouchingAmOrPm == PM) { 672 int isTouchingAmOrPm = mAmPmCirclesView.getIsTouchingAmOrPm(eventX, eventY); 673 mAmPmCirclesView.setAmOrPmPressed(-1); 674 mAmPmCirclesView.invalidate(); 675 676 if (isTouchingAmOrPm == mIsTouchingAmOrPm) { 677 mAmPmCirclesView.setAmOrPm(isTouchingAmOrPm); 678 if (getIsCurrentlyAmOrPm() != isTouchingAmOrPm) { 679 mListener.onValueSelected(AMPM_INDEX, mIsTouchingAmOrPm, false); 680 setValueForItem(AMPM_INDEX, isTouchingAmOrPm); 681 } 682 } 683 mIsTouchingAmOrPm = -1; 684 break; 685 } 686 687 // If we have a legal degrees selected, set the value and tell the listener. 688 if (mDownDegrees != -1) { 689 degrees = getDegreesFromCoords(eventX, eventY, mDoingMove, isInnerCircle); 690 if (degrees != -1) { 691 value = reselectSelector(degrees, isInnerCircle[0], !mDoingMove, false); 692 if (getCurrentItemShowing() == HOUR_INDEX && !mIs24HourMode) { 693 int amOrPm = getIsCurrentlyAmOrPm(); 694 if (amOrPm == AM && value == 12) { 695 value = 0; 696 } else if (amOrPm == PM && value != 12) { 697 value += 12; 698 } 699 } 700 setValueForItem(getCurrentItemShowing(), value); 701 mListener.onValueSelected(getCurrentItemShowing(), value, true); 702 } 703 } 704 mDoingMove = false; 705 return true; 706 default: 707 break; 708 } 709 return false; 710 } 711 712 /** 713 * Set touch input as enabled or disabled, for use with keyboard mode. 714 */ 715 public boolean trySettingInputEnabled(boolean inputEnabled) { 716 if (mDoingTouch && !inputEnabled) { 717 // If we're trying to disable input, but we're in the middle of a touch event, 718 // we'll allow the touch event to continue before disabling input. 719 return false; 720 } 721 mInputEnabled = inputEnabled; 722 mGrayBox.setVisibility(inputEnabled? View.INVISIBLE : View.VISIBLE); 723 return true; 724 } 725 726 /** 727 * Necessary for accessibility, to ensure we support "scrolling" forward and backward 728 * in the circle. 729 */ 730 @Override 731 public void onInitializeAccessibilityNodeInfo(AccessibilityNodeInfo info) { 732 super.onInitializeAccessibilityNodeInfo(info); 733 info.addAction(AccessibilityNodeInfo.ACTION_SCROLL_FORWARD); 734 info.addAction(AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD); 735 } 736 737 /** 738 * Announce the currently-selected time when launched. 739 */ 740 @Override 741 public boolean dispatchPopulateAccessibilityEvent(AccessibilityEvent event) { 742 if (event.getEventType() == AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED) { 743 // Clear the event's current text so that only the current time will be spoken. 744 event.getText().clear(); 745 Time time = new Time(); 746 time.hour = getHours(); 747 time.minute = getMinutes(); 748 long millis = time.normalize(true); 749 int flags = DateUtils.FORMAT_SHOW_TIME; 750 if (mIs24HourMode) { 751 flags |= DateUtils.FORMAT_24HOUR; 752 } 753 String timeString = DateUtils.formatDateTime(getContext(), millis, flags); 754 event.getText().add(timeString); 755 return true; 756 } 757 return super.dispatchPopulateAccessibilityEvent(event); 758 } 759 760 /** 761 * When scroll forward/backward events are received, jump the time to the higher/lower 762 * discrete, visible value on the circle. 763 */ 764 @SuppressLint("NewApi") 765 @Override 766 public boolean performAccessibilityAction(int action, Bundle arguments) { 767 if (super.performAccessibilityAction(action, arguments)) { 768 return true; 769 } 770 771 int changeMultiplier = 0; 772 if (action == AccessibilityNodeInfo.ACTION_SCROLL_FORWARD) { 773 changeMultiplier = 1; 774 } else if (action == AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD) { 775 changeMultiplier = -1; 776 } 777 if (changeMultiplier != 0) { 778 int value = getCurrentlyShowingValue(); 779 int stepSize = 0; 780 int currentItemShowing = getCurrentItemShowing(); 781 if (currentItemShowing == HOUR_INDEX) { 782 stepSize = HOUR_VALUE_TO_DEGREES_STEP_SIZE; 783 value %= 12; 784 } else if (currentItemShowing == MINUTE_INDEX) { 785 stepSize = MINUTE_VALUE_TO_DEGREES_STEP_SIZE; 786 } 787 788 int degrees = value * stepSize; 789 degrees = snapOnly30s(degrees, changeMultiplier); 790 value = degrees / stepSize; 791 int maxValue = 0; 792 int minValue = 0; 793 if (currentItemShowing == HOUR_INDEX) { 794 if (mIs24HourMode) { 795 maxValue = 23; 796 } else { 797 maxValue = 12; 798 minValue = 1; 799 } 800 } else { 801 maxValue = 55; 802 } 803 if (value > maxValue) { 804 // If we scrolled forward past the highest number, wrap around to the lowest. 805 value = minValue; 806 } else if (value < minValue) { 807 // If we scrolled backward past the lowest number, wrap around to the highest. 808 value = maxValue; 809 } 810 setItem(currentItemShowing, value); 811 mListener.onValueSelected(currentItemShowing, value, false); 812 return true; 813 } 814 815 return false; 816 } 817} 818