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