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