1 /* 2 * Copyright (C) 2013 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package android.widget; 18 19 import android.annotation.Nullable; 20 import android.content.Context; 21 import android.content.res.ColorStateList; 22 import android.content.res.Resources; 23 import android.content.res.TypedArray; 24 import android.os.Parcelable; 25 import android.text.SpannableStringBuilder; 26 import android.text.format.DateFormat; 27 import android.text.format.DateUtils; 28 import android.text.style.TtsSpan; 29 import android.util.AttributeSet; 30 import android.util.StateSet; 31 import android.view.HapticFeedbackConstants; 32 import android.view.LayoutInflater; 33 import android.view.MotionEvent; 34 import android.view.View; 35 import android.view.View.AccessibilityDelegate; 36 import android.view.View.MeasureSpec; 37 import android.view.ViewGroup; 38 import android.view.accessibility.AccessibilityEvent; 39 import android.view.accessibility.AccessibilityNodeInfo; 40 import android.view.accessibility.AccessibilityNodeInfo.AccessibilityAction; 41 import android.widget.RadialTimePickerView.OnValueSelectedListener; 42 43 import com.android.internal.R; 44 import com.android.internal.widget.NumericTextView; 45 import com.android.internal.widget.NumericTextView.OnValueChangedListener; 46 47 import java.util.Calendar; 48 49 /** 50 * A delegate implementing the radial clock-based TimePicker. 51 */ 52 class TimePickerClockDelegate extends TimePicker.AbstractTimePickerDelegate { 53 /** 54 * Delay in milliseconds before valid but potentially incomplete, for 55 * example "1" but not "12", keyboard edits are propagated from the 56 * hour / minute fields to the radial picker. 57 */ 58 private static final long DELAY_COMMIT_MILLIS = 2000; 59 60 // Index used by RadialPickerLayout 61 private static final int HOUR_INDEX = RadialTimePickerView.HOURS; 62 private static final int MINUTE_INDEX = RadialTimePickerView.MINUTES; 63 64 // NOT a real index for the purpose of what's showing. 65 private static final int AMPM_INDEX = 2; 66 67 private static final int[] ATTRS_TEXT_COLOR = new int[] {R.attr.textColor}; 68 private static final int[] ATTRS_DISABLED_ALPHA = new int[] {R.attr.disabledAlpha}; 69 70 private static final int AM = 0; 71 private static final int PM = 1; 72 73 private static final int HOURS_IN_HALF_DAY = 12; 74 75 private final NumericTextView mHourView; 76 private final NumericTextView mMinuteView; 77 private final View mAmPmLayout; 78 private final RadioButton mAmLabel; 79 private final RadioButton mPmLabel; 80 private final RadialTimePickerView mRadialTimePickerView; 81 private final TextView mSeparatorView; 82 83 private final Calendar mTempCalendar; 84 85 // Accessibility strings. 86 private final String mSelectHours; 87 private final String mSelectMinutes; 88 89 private boolean mIsEnabled = true; 90 private boolean mAllowAutoAdvance; 91 private int mCurrentHour; 92 private int mCurrentMinute; 93 private boolean mIs24Hour; 94 private boolean mIsAmPmAtStart; 95 96 // Localization data. 97 private boolean mHourFormatShowLeadingZero; 98 private boolean mHourFormatStartsAtZero; 99 100 // Most recent time announcement values for accessibility. 101 private CharSequence mLastAnnouncedText; 102 private boolean mLastAnnouncedIsHour; 103 TimePickerClockDelegate(TimePicker delegator, Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes)104 public TimePickerClockDelegate(TimePicker delegator, Context context, AttributeSet attrs, 105 int defStyleAttr, int defStyleRes) { 106 super(delegator, context); 107 108 // process style attributes 109 final TypedArray a = mContext.obtainStyledAttributes(attrs, 110 R.styleable.TimePicker, defStyleAttr, defStyleRes); 111 final LayoutInflater inflater = (LayoutInflater) mContext.getSystemService( 112 Context.LAYOUT_INFLATER_SERVICE); 113 final Resources res = mContext.getResources(); 114 115 mSelectHours = res.getString(R.string.select_hours); 116 mSelectMinutes = res.getString(R.string.select_minutes); 117 118 final int layoutResourceId = a.getResourceId(R.styleable.TimePicker_internalLayout, 119 R.layout.time_picker_material); 120 final View mainView = inflater.inflate(layoutResourceId, delegator); 121 final View headerView = mainView.findViewById(R.id.time_header); 122 headerView.setOnTouchListener(new NearestTouchDelegate()); 123 124 // Set up hour/minute labels. 125 mHourView = (NumericTextView) mainView.findViewById(R.id.hours); 126 mHourView.setOnClickListener(mClickListener); 127 mHourView.setOnFocusChangeListener(mFocusListener); 128 mHourView.setOnDigitEnteredListener(mDigitEnteredListener); 129 mHourView.setAccessibilityDelegate( 130 new ClickActionDelegate(context, R.string.select_hours)); 131 mSeparatorView = (TextView) mainView.findViewById(R.id.separator); 132 mMinuteView = (NumericTextView) mainView.findViewById(R.id.minutes); 133 mMinuteView.setOnClickListener(mClickListener); 134 mMinuteView.setOnFocusChangeListener(mFocusListener); 135 mMinuteView.setOnDigitEnteredListener(mDigitEnteredListener); 136 mMinuteView.setAccessibilityDelegate( 137 new ClickActionDelegate(context, R.string.select_minutes)); 138 mMinuteView.setRange(0, 59); 139 140 // Set up AM/PM labels. 141 mAmPmLayout = mainView.findViewById(R.id.ampm_layout); 142 mAmPmLayout.setOnTouchListener(new NearestTouchDelegate()); 143 144 final String[] amPmStrings = TimePicker.getAmPmStrings(context); 145 mAmLabel = (RadioButton) mAmPmLayout.findViewById(R.id.am_label); 146 mAmLabel.setText(obtainVerbatim(amPmStrings[0])); 147 mAmLabel.setOnClickListener(mClickListener); 148 ensureMinimumTextWidth(mAmLabel); 149 150 mPmLabel = (RadioButton) mAmPmLayout.findViewById(R.id.pm_label); 151 mPmLabel.setText(obtainVerbatim(amPmStrings[1])); 152 mPmLabel.setOnClickListener(mClickListener); 153 ensureMinimumTextWidth(mPmLabel); 154 155 // For the sake of backwards compatibility, attempt to extract the text 156 // color from the header time text appearance. If it's set, we'll let 157 // that override the "real" header text color. 158 ColorStateList headerTextColor = null; 159 160 @SuppressWarnings("deprecation") 161 final int timeHeaderTextAppearance = a.getResourceId( 162 R.styleable.TimePicker_headerTimeTextAppearance, 0); 163 if (timeHeaderTextAppearance != 0) { 164 final TypedArray textAppearance = mContext.obtainStyledAttributes(null, 165 ATTRS_TEXT_COLOR, 0, timeHeaderTextAppearance); 166 final ColorStateList legacyHeaderTextColor = textAppearance.getColorStateList(0); 167 headerTextColor = applyLegacyColorFixes(legacyHeaderTextColor); 168 textAppearance.recycle(); 169 } 170 171 if (headerTextColor == null) { 172 headerTextColor = a.getColorStateList(R.styleable.TimePicker_headerTextColor); 173 } 174 175 if (headerTextColor != null) { 176 mHourView.setTextColor(headerTextColor); 177 mSeparatorView.setTextColor(headerTextColor); 178 mMinuteView.setTextColor(headerTextColor); 179 mAmLabel.setTextColor(headerTextColor); 180 mPmLabel.setTextColor(headerTextColor); 181 } 182 183 // Set up header background, if available. 184 if (a.hasValueOrEmpty(R.styleable.TimePicker_headerBackground)) { 185 headerView.setBackground(a.getDrawable(R.styleable.TimePicker_headerBackground)); 186 } 187 188 a.recycle(); 189 190 mRadialTimePickerView = (RadialTimePickerView) mainView.findViewById(R.id.radial_picker); 191 mRadialTimePickerView.applyAttributes(attrs, defStyleAttr, defStyleRes); 192 mRadialTimePickerView.setOnValueSelectedListener(mOnValueSelectedListener); 193 194 mAllowAutoAdvance = true; 195 196 updateHourFormat(); 197 198 // Initialize with current time. 199 mTempCalendar = Calendar.getInstance(mLocale); 200 final int currentHour = mTempCalendar.get(Calendar.HOUR_OF_DAY); 201 final int currentMinute = mTempCalendar.get(Calendar.MINUTE); 202 initialize(currentHour, currentMinute, mIs24Hour, HOUR_INDEX); 203 } 204 205 /** 206 * Ensures that a TextView is wide enough to contain its text without 207 * wrapping or clipping. Measures the specified view and sets the minimum 208 * width to the view's desired width. 209 * 210 * @param v the text view to measure 211 */ ensureMinimumTextWidth(TextView v)212 private static void ensureMinimumTextWidth(TextView v) { 213 v.measure(MeasureSpec.UNSPECIFIED, MeasureSpec.UNSPECIFIED); 214 215 // Set both the TextView and the View version of minimum 216 // width because they are subtly different. 217 final int minWidth = v.getMeasuredWidth(); 218 v.setMinWidth(minWidth); 219 v.setMinimumWidth(minWidth); 220 } 221 222 /** 223 * Updates hour formatting based on the current locale and 24-hour mode. 224 * <p> 225 * Determines how the hour should be formatted, sets member variables for 226 * leading zero and starting hour, and sets the hour view's presentation. 227 */ updateHourFormat()228 private void updateHourFormat() { 229 final String bestDateTimePattern = DateFormat.getBestDateTimePattern( 230 mLocale, mIs24Hour ? "Hm" : "hm"); 231 final int lengthPattern = bestDateTimePattern.length(); 232 boolean showLeadingZero = false; 233 char hourFormat = '\0'; 234 235 for (int i = 0; i < lengthPattern; i++) { 236 final char c = bestDateTimePattern.charAt(i); 237 if (c == 'H' || c == 'h' || c == 'K' || c == 'k') { 238 hourFormat = c; 239 if (i + 1 < lengthPattern && c == bestDateTimePattern.charAt(i + 1)) { 240 showLeadingZero = true; 241 } 242 break; 243 } 244 } 245 246 mHourFormatShowLeadingZero = showLeadingZero; 247 mHourFormatStartsAtZero = hourFormat == 'K' || hourFormat == 'H'; 248 249 // Update hour text field. 250 final int minHour = mHourFormatStartsAtZero ? 0 : 1; 251 final int maxHour = (mIs24Hour ? 23 : 11) + minHour; 252 mHourView.setRange(minHour, maxHour); 253 mHourView.setShowLeadingZeroes(mHourFormatShowLeadingZero); 254 } 255 obtainVerbatim(String text)256 private static final CharSequence obtainVerbatim(String text) { 257 return new SpannableStringBuilder().append(text, 258 new TtsSpan.VerbatimBuilder(text).build(), 0); 259 } 260 261 /** 262 * The legacy text color might have been poorly defined. Ensures that it 263 * has an appropriate activated state, using the selected state if one 264 * exists or modifying the default text color otherwise. 265 * 266 * @param color a legacy text color, or {@code null} 267 * @return a color state list with an appropriate activated state, or 268 * {@code null} if a valid activated state could not be generated 269 */ 270 @Nullable applyLegacyColorFixes(@ullable ColorStateList color)271 private ColorStateList applyLegacyColorFixes(@Nullable ColorStateList color) { 272 if (color == null || color.hasState(R.attr.state_activated)) { 273 return color; 274 } 275 276 final int activatedColor; 277 final int defaultColor; 278 if (color.hasState(R.attr.state_selected)) { 279 activatedColor = color.getColorForState(StateSet.get( 280 StateSet.VIEW_STATE_ENABLED | StateSet.VIEW_STATE_SELECTED), 0); 281 defaultColor = color.getColorForState(StateSet.get( 282 StateSet.VIEW_STATE_ENABLED), 0); 283 } else { 284 activatedColor = color.getDefaultColor(); 285 286 // Generate a non-activated color using the disabled alpha. 287 final TypedArray ta = mContext.obtainStyledAttributes(ATTRS_DISABLED_ALPHA); 288 final float disabledAlpha = ta.getFloat(0, 0.30f); 289 defaultColor = multiplyAlphaComponent(activatedColor, disabledAlpha); 290 } 291 292 if (activatedColor == 0 || defaultColor == 0) { 293 // We somehow failed to obtain the colors. 294 return null; 295 } 296 297 final int[][] stateSet = new int[][] {{ R.attr.state_activated }, {}}; 298 final int[] colors = new int[] { activatedColor, defaultColor }; 299 return new ColorStateList(stateSet, colors); 300 } 301 multiplyAlphaComponent(int color, float alphaMod)302 private int multiplyAlphaComponent(int color, float alphaMod) { 303 final int srcRgb = color & 0xFFFFFF; 304 final int srcAlpha = (color >> 24) & 0xFF; 305 final int dstAlpha = (int) (srcAlpha * alphaMod + 0.5f); 306 return srcRgb | (dstAlpha << 24); 307 } 308 309 private static class ClickActionDelegate extends AccessibilityDelegate { 310 private final AccessibilityAction mClickAction; 311 ClickActionDelegate(Context context, int resId)312 public ClickActionDelegate(Context context, int resId) { 313 mClickAction = new AccessibilityAction( 314 AccessibilityNodeInfo.ACTION_CLICK, context.getString(resId)); 315 } 316 317 @Override onInitializeAccessibilityNodeInfo(View host, AccessibilityNodeInfo info)318 public void onInitializeAccessibilityNodeInfo(View host, AccessibilityNodeInfo info) { 319 super.onInitializeAccessibilityNodeInfo(host, info); 320 321 info.addAction(mClickAction); 322 } 323 } 324 initialize(int hourOfDay, int minute, boolean is24HourView, int index)325 private void initialize(int hourOfDay, int minute, boolean is24HourView, int index) { 326 mCurrentHour = hourOfDay; 327 mCurrentMinute = minute; 328 mIs24Hour = is24HourView; 329 updateUI(index); 330 } 331 updateUI(int index)332 private void updateUI(int index) { 333 updateHeaderAmPm(); 334 updateHeaderHour(mCurrentHour, false); 335 updateHeaderSeparator(); 336 updateHeaderMinute(mCurrentMinute, false); 337 updateRadialPicker(index); 338 339 mDelegator.invalidate(); 340 } 341 updateRadialPicker(int index)342 private void updateRadialPicker(int index) { 343 mRadialTimePickerView.initialize(mCurrentHour, mCurrentMinute, mIs24Hour); 344 setCurrentItemShowing(index, false, true); 345 } 346 updateHeaderAmPm()347 private void updateHeaderAmPm() { 348 if (mIs24Hour) { 349 mAmPmLayout.setVisibility(View.GONE); 350 } else { 351 // Ensure that AM/PM layout is in the correct position. 352 final String dateTimePattern = DateFormat.getBestDateTimePattern(mLocale, "hm"); 353 final boolean isAmPmAtStart = dateTimePattern.startsWith("a"); 354 setAmPmAtStart(isAmPmAtStart); 355 356 updateAmPmLabelStates(mCurrentHour < 12 ? AM : PM); 357 } 358 } 359 360 private void setAmPmAtStart(boolean isAmPmAtStart) { 361 if (mIsAmPmAtStart != isAmPmAtStart) { 362 mIsAmPmAtStart = isAmPmAtStart; 363 364 final RelativeLayout.LayoutParams params = 365 (RelativeLayout.LayoutParams) mAmPmLayout.getLayoutParams(); 366 if (params.getRule(RelativeLayout.RIGHT_OF) != 0 || 367 params.getRule(RelativeLayout.LEFT_OF) != 0) { 368 if (isAmPmAtStart) { 369 params.removeRule(RelativeLayout.RIGHT_OF); 370 params.addRule(RelativeLayout.LEFT_OF, mHourView.getId()); 371 } else { 372 params.removeRule(RelativeLayout.LEFT_OF); 373 params.addRule(RelativeLayout.RIGHT_OF, mMinuteView.getId()); 374 } 375 } 376 377 mAmPmLayout.setLayoutParams(params); 378 } 379 } 380 381 /** 382 * Set the current hour. 383 */ 384 @Override 385 public void setHour(int hour) { 386 setHourInternal(hour, false, true); 387 } 388 389 private void setHourInternal(int hour, boolean isFromPicker, boolean announce) { 390 if (mCurrentHour == hour) { 391 return; 392 } 393 394 mCurrentHour = hour; 395 updateHeaderHour(hour, announce); 396 updateHeaderAmPm(); 397 398 if (!isFromPicker) { 399 mRadialTimePickerView.setCurrentHour(hour); 400 mRadialTimePickerView.setAmOrPm(hour < 12 ? AM : PM); 401 } 402 403 mDelegator.invalidate(); 404 onTimeChanged(); 405 } 406 407 /** 408 * @return the current hour in the range (0-23) 409 */ 410 @Override 411 public int getHour() { 412 final int currentHour = mRadialTimePickerView.getCurrentHour(); 413 if (mIs24Hour) { 414 return currentHour; 415 } 416 417 if (mRadialTimePickerView.getAmOrPm() == PM) { 418 return (currentHour % HOURS_IN_HALF_DAY) + HOURS_IN_HALF_DAY; 419 } else { 420 return currentHour % HOURS_IN_HALF_DAY; 421 } 422 } 423 424 /** 425 * Set the current minute (0-59). 426 */ 427 @Override 428 public void setMinute(int minute) { 429 setMinuteInternal(minute, false); 430 } 431 432 private void setMinuteInternal(int minute, boolean isFromPicker) { 433 if (mCurrentMinute == minute) { 434 return; 435 } 436 437 mCurrentMinute = minute; 438 updateHeaderMinute(minute, true); 439 440 if (!isFromPicker) { 441 mRadialTimePickerView.setCurrentMinute(minute); 442 } 443 444 mDelegator.invalidate(); 445 onTimeChanged(); 446 } 447 448 /** 449 * @return The current minute. 450 */ 451 @Override 452 public int getMinute() { 453 return mRadialTimePickerView.getCurrentMinute(); 454 } 455 456 /** 457 * Sets whether time is displayed in 24-hour mode or 12-hour mode with 458 * AM/PM indicators. 459 * 460 * @param is24Hour {@code true} to display time in 24-hour mode or 461 * {@code false} for 12-hour mode with AM/PM 462 */ 463 public void setIs24Hour(boolean is24Hour) { 464 if (mIs24Hour != is24Hour) { 465 mIs24Hour = is24Hour; 466 mCurrentHour = getHour(); 467 468 updateHourFormat(); 469 updateUI(mRadialTimePickerView.getCurrentItemShowing()); 470 } 471 } 472 473 /** 474 * @return {@code true} if time is displayed in 24-hour mode, or 475 * {@code false} if time is displayed in 12-hour mode with AM/PM 476 * indicators 477 */ 478 @Override 479 public boolean is24Hour() { 480 return mIs24Hour; 481 } 482 483 @Override 484 public void setOnTimeChangedListener(TimePicker.OnTimeChangedListener callback) { 485 mOnTimeChangedListener = callback; 486 } 487 488 @Override 489 public void setEnabled(boolean enabled) { 490 mHourView.setEnabled(enabled); 491 mMinuteView.setEnabled(enabled); 492 mAmLabel.setEnabled(enabled); 493 mPmLabel.setEnabled(enabled); 494 mRadialTimePickerView.setEnabled(enabled); 495 mIsEnabled = enabled; 496 } 497 498 @Override 499 public boolean isEnabled() { 500 return mIsEnabled; 501 } 502 503 @Override 504 public int getBaseline() { 505 // does not support baseline alignment 506 return -1; 507 } 508 509 @Override 510 public Parcelable onSaveInstanceState(Parcelable superState) { 511 return new SavedState(superState, getHour(), getMinute(), 512 is24Hour(), getCurrentItemShowing()); 513 } 514 515 @Override 516 public void onRestoreInstanceState(Parcelable state) { 517 if (state instanceof SavedState) { 518 final SavedState ss = (SavedState) state; 519 initialize(ss.getHour(), ss.getMinute(), ss.is24HourMode(), ss.getCurrentItemShowing()); 520 mRadialTimePickerView.invalidate(); 521 } 522 } 523 524 @Override 525 public boolean dispatchPopulateAccessibilityEvent(AccessibilityEvent event) { 526 onPopulateAccessibilityEvent(event); 527 return true; 528 } 529 530 @Override 531 public void onPopulateAccessibilityEvent(AccessibilityEvent event) { 532 int flags = DateUtils.FORMAT_SHOW_TIME; 533 if (mIs24Hour) { 534 flags |= DateUtils.FORMAT_24HOUR; 535 } else { 536 flags |= DateUtils.FORMAT_12HOUR; 537 } 538 539 mTempCalendar.set(Calendar.HOUR_OF_DAY, getHour()); 540 mTempCalendar.set(Calendar.MINUTE, getMinute()); 541 542 final String selectedTime = DateUtils.formatDateTime(mContext, 543 mTempCalendar.getTimeInMillis(), flags); 544 final String selectionMode = mRadialTimePickerView.getCurrentItemShowing() == HOUR_INDEX ? 545 mSelectHours : mSelectMinutes; 546 event.getText().add(selectedTime + " " + selectionMode); 547 } 548 549 /** 550 * @return the index of the current item showing 551 */ 552 private int getCurrentItemShowing() { 553 return mRadialTimePickerView.getCurrentItemShowing(); 554 } 555 556 /** 557 * Propagate the time change 558 */ 559 private void onTimeChanged() { 560 mDelegator.sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_SELECTED); 561 if (mOnTimeChangedListener != null) { 562 mOnTimeChangedListener.onTimeChanged(mDelegator, getHour(), getMinute()); 563 } 564 } 565 566 private void tryVibrate() { 567 mDelegator.performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK); 568 } 569 570 private void updateAmPmLabelStates(int amOrPm) { 571 final boolean isAm = amOrPm == AM; 572 mAmLabel.setActivated(isAm); 573 mAmLabel.setChecked(isAm); 574 575 final boolean isPm = amOrPm == PM; 576 mPmLabel.setActivated(isPm); 577 mPmLabel.setChecked(isPm); 578 } 579 580 /** 581 * Converts hour-of-day (0-23) time into a localized hour number. 582 * <p> 583 * The localized value may be in the range (0-23), (1-24), (0-11), or 584 * (1-12) depending on the locale. This method does not handle leading 585 * zeroes. 586 * 587 * @param hourOfDay the hour-of-day (0-23) 588 * @return a localized hour number 589 */ 590 private int getLocalizedHour(int hourOfDay) { 591 if (!mIs24Hour) { 592 // Convert to hour-of-am-pm. 593 hourOfDay %= 12; 594 } 595 596 if (!mHourFormatStartsAtZero && hourOfDay == 0) { 597 // Convert to clock-hour (either of-day or of-am-pm). 598 hourOfDay = mIs24Hour ? 24 : 12; 599 } 600 601 return hourOfDay; 602 } 603 604 private void updateHeaderHour(int hourOfDay, boolean announce) { 605 final int localizedHour = getLocalizedHour(hourOfDay); 606 mHourView.setValue(localizedHour); 607 608 if (announce) { 609 tryAnnounceForAccessibility(mHourView.getText(), true); 610 } 611 } 612 613 private void updateHeaderMinute(int minuteOfHour, boolean announce) { 614 mMinuteView.setValue(minuteOfHour); 615 616 if (announce) { 617 tryAnnounceForAccessibility(mMinuteView.getText(), false); 618 } 619 } 620 621 /** 622 * The time separator is defined in the Unicode CLDR and cannot be supposed to be ":". 623 * 624 * See http://unicode.org/cldr/trac/browser/trunk/common/main 625 * 626 * We pass the correct "skeleton" depending on 12 or 24 hours view and then extract the 627 * separator as the character which is just after the hour marker in the returned pattern. 628 */ 629 private void updateHeaderSeparator() { 630 final String bestDateTimePattern = DateFormat.getBestDateTimePattern(mLocale, 631 (mIs24Hour) ? "Hm" : "hm"); 632 final String separatorText; 633 // See http://www.unicode.org/reports/tr35/tr35-dates.html for hour formats 634 final char[] hourFormats = {'H', 'h', 'K', 'k'}; 635 int hIndex = lastIndexOfAny(bestDateTimePattern, hourFormats); 636 if (hIndex == -1) { 637 // Default case 638 separatorText = ":"; 639 } else { 640 separatorText = Character.toString(bestDateTimePattern.charAt(hIndex + 1)); 641 } 642 mSeparatorView.setText(separatorText); 643 } 644 645 static private int lastIndexOfAny(String str, char[] any) { 646 final int lengthAny = any.length; 647 if (lengthAny > 0) { 648 for (int i = str.length() - 1; i >= 0; i--) { 649 char c = str.charAt(i); 650 for (int j = 0; j < lengthAny; j++) { 651 if (c == any[j]) { 652 return i; 653 } 654 } 655 } 656 } 657 return -1; 658 } 659 tryAnnounceForAccessibility(CharSequence text, boolean isHour)660 private void tryAnnounceForAccessibility(CharSequence text, boolean isHour) { 661 if (mLastAnnouncedIsHour != isHour || !text.equals(mLastAnnouncedText)) { 662 // TODO: Find a better solution, potentially live regions? 663 mDelegator.announceForAccessibility(text); 664 mLastAnnouncedText = text; 665 mLastAnnouncedIsHour = isHour; 666 } 667 } 668 669 /** 670 * Show either Hours or Minutes. 671 */ setCurrentItemShowing(int index, boolean animateCircle, boolean announce)672 private void setCurrentItemShowing(int index, boolean animateCircle, boolean announce) { 673 mRadialTimePickerView.setCurrentItemShowing(index, animateCircle); 674 675 if (index == HOUR_INDEX) { 676 if (announce) { 677 mDelegator.announceForAccessibility(mSelectHours); 678 } 679 } else { 680 if (announce) { 681 mDelegator.announceForAccessibility(mSelectMinutes); 682 } 683 } 684 685 mHourView.setActivated(index == HOUR_INDEX); 686 mMinuteView.setActivated(index == MINUTE_INDEX); 687 } 688 setAmOrPm(int amOrPm)689 private void setAmOrPm(int amOrPm) { 690 updateAmPmLabelStates(amOrPm); 691 692 if (mRadialTimePickerView.setAmOrPm(amOrPm)) { 693 mCurrentHour = getHour(); 694 695 if (mOnTimeChangedListener != null) { 696 mOnTimeChangedListener.onTimeChanged(mDelegator, getHour(), getMinute()); 697 } 698 } 699 } 700 701 /** Listener for RadialTimePickerView interaction. */ 702 private final OnValueSelectedListener mOnValueSelectedListener = new OnValueSelectedListener() { 703 @Override 704 public void onValueSelected(int pickerIndex, int newValue, boolean autoAdvance) { 705 switch (pickerIndex) { 706 case HOUR_INDEX: 707 final boolean isTransition = mAllowAutoAdvance && autoAdvance; 708 setHourInternal(newValue, true, !isTransition); 709 if (isTransition) { 710 setCurrentItemShowing(MINUTE_INDEX, true, false); 711 mDelegator.announceForAccessibility(newValue + ". " + mSelectMinutes); 712 } 713 break; 714 case MINUTE_INDEX: 715 setMinuteInternal(newValue, true); 716 break; 717 case AMPM_INDEX: 718 updateAmPmLabelStates(newValue); 719 break; 720 } 721 722 if (mOnTimeChangedListener != null) { 723 mOnTimeChangedListener.onTimeChanged(mDelegator, getHour(), getMinute()); 724 } 725 } 726 }; 727 728 /** Listener for keyboard interaction. */ 729 private final OnValueChangedListener mDigitEnteredListener = new OnValueChangedListener() { 730 @Override 731 public void onValueChanged(NumericTextView view, int value, 732 boolean isValid, boolean isFinished) { 733 final Runnable commitCallback; 734 final View nextFocusTarget; 735 if (view == mHourView) { 736 commitCallback = mCommitHour; 737 nextFocusTarget = view.isFocused() ? mMinuteView : null; 738 } else if (view == mMinuteView) { 739 commitCallback = mCommitMinute; 740 nextFocusTarget = null; 741 } else { 742 return; 743 } 744 745 view.removeCallbacks(commitCallback); 746 747 if (isValid) { 748 if (isFinished) { 749 // Done with hours entry, make visual updates 750 // immediately and move to next focus if needed. 751 commitCallback.run(); 752 753 if (nextFocusTarget != null) { 754 nextFocusTarget.requestFocus(); 755 } 756 } else { 757 // May still be making changes. Postpone visual 758 // updates to prevent distracting the user. 759 view.postDelayed(commitCallback, DELAY_COMMIT_MILLIS); 760 } 761 } 762 } 763 }; 764 765 private final Runnable mCommitHour = new Runnable() { 766 @Override 767 public void run() { 768 setHour(mHourView.getValue()); 769 } 770 }; 771 772 private final Runnable mCommitMinute = new Runnable() { 773 @Override 774 public void run() { 775 setMinute(mMinuteView.getValue()); 776 } 777 }; 778 779 private final View.OnFocusChangeListener mFocusListener = new View.OnFocusChangeListener() { 780 @Override 781 public void onFocusChange(View v, boolean focused) { 782 if (focused) { 783 switch (v.getId()) { 784 case R.id.am_label: 785 setAmOrPm(AM); 786 break; 787 case R.id.pm_label: 788 setAmOrPm(PM); 789 break; 790 case R.id.hours: 791 setCurrentItemShowing(HOUR_INDEX, true, true); 792 break; 793 case R.id.minutes: 794 setCurrentItemShowing(MINUTE_INDEX, true, true); 795 break; 796 default: 797 // Failed to handle this click, don't vibrate. 798 return; 799 } 800 801 tryVibrate(); 802 } 803 } 804 }; 805 806 private final View.OnClickListener mClickListener = new View.OnClickListener() { 807 @Override 808 public void onClick(View v) { 809 810 final int amOrPm; 811 switch (v.getId()) { 812 case R.id.am_label: 813 setAmOrPm(AM); 814 break; 815 case R.id.pm_label: 816 setAmOrPm(PM); 817 break; 818 case R.id.hours: 819 setCurrentItemShowing(HOUR_INDEX, true, true); 820 break; 821 case R.id.minutes: 822 setCurrentItemShowing(MINUTE_INDEX, true, true); 823 break; 824 default: 825 // Failed to handle this click, don't vibrate. 826 return; 827 } 828 829 tryVibrate(); 830 } 831 }; 832 833 /** 834 * Delegates unhandled touches in a view group to the nearest child view. 835 */ 836 private static class NearestTouchDelegate implements View.OnTouchListener { 837 private View mInitialTouchTarget; 838 839 @Override onTouch(View view, MotionEvent motionEvent)840 public boolean onTouch(View view, MotionEvent motionEvent) { 841 final int actionMasked = motionEvent.getActionMasked(); 842 if (actionMasked == MotionEvent.ACTION_DOWN) { 843 if (view instanceof ViewGroup) { 844 mInitialTouchTarget = findNearestChild((ViewGroup) view, 845 (int) motionEvent.getX(), (int) motionEvent.getY()); 846 } else { 847 mInitialTouchTarget = null; 848 } 849 } 850 851 final View child = mInitialTouchTarget; 852 if (child == null) { 853 return false; 854 } 855 856 final float offsetX = view.getScrollX() - child.getLeft(); 857 final float offsetY = view.getScrollY() - child.getTop(); 858 motionEvent.offsetLocation(offsetX, offsetY); 859 final boolean handled = child.dispatchTouchEvent(motionEvent); 860 motionEvent.offsetLocation(-offsetX, -offsetY); 861 862 if (actionMasked == MotionEvent.ACTION_UP 863 || actionMasked == MotionEvent.ACTION_CANCEL) { 864 mInitialTouchTarget = null; 865 } 866 867 return handled; 868 } 869 findNearestChild(ViewGroup v, int x, int y)870 private View findNearestChild(ViewGroup v, int x, int y) { 871 View bestChild = null; 872 int bestDist = Integer.MAX_VALUE; 873 874 for (int i = 0, count = v.getChildCount(); i < count; i++) { 875 final View child = v.getChildAt(i); 876 final int dX = x - (child.getLeft() + child.getWidth() / 2); 877 final int dY = y - (child.getTop() + child.getHeight() / 2); 878 final int dist = dX * dX + dY * dY; 879 if (bestDist > dist) { 880 bestChild = child; 881 bestDist = dist; 882 } 883 } 884 885 return bestChild; 886 } 887 } 888 } 889