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