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