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