TimePickerClockDelegate.java revision b0f5461eeca28d6c4be6ba1f826a43a8961f587e
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.Parcelable; 25import android.text.SpannableStringBuilder; 26import android.text.format.DateFormat; 27import android.text.format.DateUtils; 28import android.text.style.TtsSpan; 29import android.util.AttributeSet; 30import android.util.StateSet; 31import android.view.HapticFeedbackConstants; 32import android.view.LayoutInflater; 33import android.view.MotionEvent; 34import android.view.View; 35import android.view.View.AccessibilityDelegate; 36import android.view.View.MeasureSpec; 37import android.view.ViewGroup; 38import android.view.accessibility.AccessibilityEvent; 39import android.view.accessibility.AccessibilityNodeInfo; 40import android.view.accessibility.AccessibilityNodeInfo.AccessibilityAction; 41 42import com.android.internal.R; 43import com.android.internal.widget.NumericTextView; 44import com.android.internal.widget.NumericTextView.OnValueChangedListener; 45 46import java.util.Calendar; 47 48/** 49 * A delegate implementing the radial clock-based TimePicker. 50 */ 51class TimePickerClockDelegate extends TimePicker.AbstractTimePickerDelegate implements 52 RadialTimePickerView.OnValueSelectedListener { 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 mInitialHourOfDay; 92 private int mInitialMinute; 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 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 193 setupListeners(); 194 195 mAllowAutoAdvance = true; 196 197 updateHourFormat(); 198 199 // Initialize with current time. 200 mTempCalendar = Calendar.getInstance(mLocale); 201 final int currentHour = mTempCalendar.get(Calendar.HOUR_OF_DAY); 202 final int currentMinute = mTempCalendar.get(Calendar.MINUTE); 203 initialize(currentHour, currentMinute, mIs24Hour, HOUR_INDEX); 204 } 205 206 /** 207 * Ensures that a TextView is wide enough to contain its text without 208 * wrapping or clipping. Measures the specified view and sets the minimum 209 * width to the view's desired width. 210 * 211 * @param v the text view to measure 212 */ 213 private static void ensureMinimumTextWidth(TextView v) { 214 v.measure(MeasureSpec.UNSPECIFIED, MeasureSpec.UNSPECIFIED); 215 216 // Set both the TextView and the View version of minimum 217 // width because they are subtly different. 218 final int minWidth = v.getMeasuredWidth(); 219 v.setMinWidth(minWidth); 220 v.setMinimumWidth(minWidth); 221 } 222 223 /** 224 * Updates hour formatting based on the current locale and 24-hour mode. 225 * <p> 226 * Determines how the hour should be formatted, sets member variables for 227 * leading zero and starting hour, and sets the hour view's presentation. 228 */ 229 private void updateHourFormat() { 230 final String bestDateTimePattern = DateFormat.getBestDateTimePattern( 231 mLocale, mIs24Hour ? "Hm" : "hm"); 232 final int lengthPattern = bestDateTimePattern.length(); 233 boolean showLeadingZero = false; 234 char hourFormat = '\0'; 235 236 for (int i = 0; i < lengthPattern; i++) { 237 final char c = bestDateTimePattern.charAt(i); 238 if (c == 'H' || c == 'h' || c == 'K' || c == 'k') { 239 hourFormat = c; 240 if (i + 1 < lengthPattern && c == bestDateTimePattern.charAt(i + 1)) { 241 showLeadingZero = true; 242 } 243 break; 244 } 245 } 246 247 mHourFormatShowLeadingZero = showLeadingZero; 248 mHourFormatStartsAtZero = hourFormat == 'K' || hourFormat == 'H'; 249 250 // Update hour text field. 251 final int minHour = mHourFormatStartsAtZero ? 0 : 1; 252 final int maxHour = (mIs24Hour ? 23 : 11) + minHour; 253 mHourView.setRange(minHour, maxHour); 254 mHourView.setShowLeadingZeroes(mHourFormatShowLeadingZero); 255 } 256 257 private static final CharSequence obtainVerbatim(String text) { 258 return new SpannableStringBuilder().append(text, 259 new TtsSpan.VerbatimBuilder(text).build(), 0); 260 } 261 262 /** 263 * The legacy text color might have been poorly defined. Ensures that it 264 * has an appropriate activated state, using the selected state if one 265 * exists or modifying the default text color otherwise. 266 * 267 * @param color a legacy text color, or {@code null} 268 * @return a color state list with an appropriate activated state, or 269 * {@code null} if a valid activated state could not be generated 270 */ 271 @Nullable 272 private ColorStateList applyLegacyColorFixes(@Nullable ColorStateList color) { 273 if (color == null || color.hasState(R.attr.state_activated)) { 274 return color; 275 } 276 277 final int activatedColor; 278 final int defaultColor; 279 if (color.hasState(R.attr.state_selected)) { 280 activatedColor = color.getColorForState(StateSet.get( 281 StateSet.VIEW_STATE_ENABLED | StateSet.VIEW_STATE_SELECTED), 0); 282 defaultColor = color.getColorForState(StateSet.get( 283 StateSet.VIEW_STATE_ENABLED), 0); 284 } else { 285 activatedColor = color.getDefaultColor(); 286 287 // Generate a non-activated color using the disabled alpha. 288 final TypedArray ta = mContext.obtainStyledAttributes(ATTRS_DISABLED_ALPHA); 289 final float disabledAlpha = ta.getFloat(0, 0.30f); 290 defaultColor = multiplyAlphaComponent(activatedColor, disabledAlpha); 291 } 292 293 if (activatedColor == 0 || defaultColor == 0) { 294 // We somehow failed to obtain the colors. 295 return null; 296 } 297 298 final int[][] stateSet = new int[][] {{ R.attr.state_activated }, {}}; 299 final int[] colors = new int[] { activatedColor, defaultColor }; 300 return new ColorStateList(stateSet, colors); 301 } 302 303 private int multiplyAlphaComponent(int color, float alphaMod) { 304 final int srcRgb = color & 0xFFFFFF; 305 final int srcAlpha = (color >> 24) & 0xFF; 306 final int dstAlpha = (int) (srcAlpha * alphaMod + 0.5f); 307 return srcRgb | (dstAlpha << 24); 308 } 309 310 private static class ClickActionDelegate extends AccessibilityDelegate { 311 private final AccessibilityAction mClickAction; 312 313 public ClickActionDelegate(Context context, int resId) { 314 mClickAction = new AccessibilityAction( 315 AccessibilityNodeInfo.ACTION_CLICK, context.getString(resId)); 316 } 317 318 @Override 319 public void onInitializeAccessibilityNodeInfo(View host, AccessibilityNodeInfo info) { 320 super.onInitializeAccessibilityNodeInfo(host, info); 321 322 info.addAction(mClickAction); 323 } 324 } 325 326 private void initialize(int hourOfDay, int minute, boolean is24HourView, int index) { 327 mInitialHourOfDay = hourOfDay; 328 mInitialMinute = minute; 329 mIs24Hour = is24HourView; 330 updateUI(index); 331 } 332 333 private void setupListeners() { 334 mRadialTimePickerView.setOnValueSelectedListener(this); 335 } 336 337 private void updateUI(int index) { 338 updateHeaderAmPm(); 339 updateHeaderHour(mInitialHourOfDay, false); 340 updateHeaderSeparator(); 341 updateHeaderMinute(mInitialMinute, false); 342 updateRadialPicker(index); 343 344 mDelegator.invalidate(); 345 } 346 347 private void updateRadialPicker(int index) { 348 mRadialTimePickerView.initialize(mInitialHourOfDay, mInitialMinute, mIs24Hour); 349 setCurrentItemShowing(index, false, true); 350 } 351 352 private void updateHeaderAmPm() { 353 if (mIs24Hour) { 354 mAmPmLayout.setVisibility(View.GONE); 355 } else { 356 // Ensure that AM/PM layout is in the correct position. 357 final String dateTimePattern = DateFormat.getBestDateTimePattern(mLocale, "hm"); 358 final boolean isAmPmAtStart = dateTimePattern.startsWith("a"); 359 setAmPmAtStart(isAmPmAtStart); 360 361 updateAmPmLabelStates(mInitialHourOfDay < 12 ? AM : PM); 362 } 363 } 364 365 private void setAmPmAtStart(boolean isAmPmAtStart) { 366 if (mIsAmPmAtStart != isAmPmAtStart) { 367 mIsAmPmAtStart = isAmPmAtStart; 368 369 final RelativeLayout.LayoutParams params = 370 (RelativeLayout.LayoutParams) mAmPmLayout.getLayoutParams(); 371 if (params.getRule(RelativeLayout.RIGHT_OF) != 0 || 372 params.getRule(RelativeLayout.LEFT_OF) != 0) { 373 if (isAmPmAtStart) { 374 params.removeRule(RelativeLayout.RIGHT_OF); 375 params.addRule(RelativeLayout.LEFT_OF, mHourView.getId()); 376 } else { 377 params.removeRule(RelativeLayout.LEFT_OF); 378 params.addRule(RelativeLayout.RIGHT_OF, mMinuteView.getId()); 379 } 380 } 381 382 mAmPmLayout.setLayoutParams(params); 383 } 384 } 385 386 /** 387 * Set the current hour. 388 */ 389 @Override 390 public void setHour(int hour) { 391 if (mInitialHourOfDay != hour) { 392 mInitialHourOfDay = hour; 393 updateHeaderHour(hour, true); 394 updateHeaderAmPm(); 395 mRadialTimePickerView.setCurrentHour(hour); 396 mRadialTimePickerView.setAmOrPm(mInitialHourOfDay < 12 ? AM : PM); 397 mDelegator.invalidate(); 398 onTimeChanged(); 399 } 400 } 401 402 /** 403 * @return the current hour in the range (0-23) 404 */ 405 @Override 406 public int getHour() { 407 final int currentHour = mRadialTimePickerView.getCurrentHour(); 408 if (mIs24Hour) { 409 return currentHour; 410 } 411 412 if (mRadialTimePickerView.getAmOrPm() == PM) { 413 return (currentHour % HOURS_IN_HALF_DAY) + HOURS_IN_HALF_DAY; 414 } else { 415 return currentHour % HOURS_IN_HALF_DAY; 416 } 417 } 418 419 /** 420 * Set the current minute (0-59). 421 */ 422 @Override 423 public void setMinute(int minute) { 424 if (mInitialMinute != minute) { 425 mInitialMinute = minute; 426 updateHeaderMinute(minute, true); 427 mRadialTimePickerView.setCurrentMinute(minute); 428 mDelegator.invalidate(); 429 onTimeChanged(); 430 } 431 } 432 433 /** 434 * @return The current minute. 435 */ 436 @Override 437 public int getMinute() { 438 return mRadialTimePickerView.getCurrentMinute(); 439 } 440 441 /** 442 * Sets whether time is displayed in 24-hour mode or 12-hour mode with 443 * AM/PM indicators. 444 * 445 * @param is24Hour {@code true} to display time in 24-hour mode or 446 * {@code false} for 12-hour mode with AM/PM 447 */ 448 public void setIs24Hour(boolean is24Hour) { 449 if (mIs24Hour != is24Hour) { 450 mIs24Hour = is24Hour; 451 mInitialHourOfDay = getHour(); 452 453 updateHourFormat(); 454 updateUI(mRadialTimePickerView.getCurrentItemShowing()); 455 } 456 } 457 458 /** 459 * @return {@code true} if time is displayed in 24-hour mode, or 460 * {@code false} if time is displayed in 12-hour mode with AM/PM 461 * indicators 462 */ 463 @Override 464 public boolean is24Hour() { 465 return mIs24Hour; 466 } 467 468 @Override 469 public void setOnTimeChangedListener(TimePicker.OnTimeChangedListener callback) { 470 mOnTimeChangedListener = callback; 471 } 472 473 @Override 474 public void setEnabled(boolean enabled) { 475 mHourView.setEnabled(enabled); 476 mMinuteView.setEnabled(enabled); 477 mAmLabel.setEnabled(enabled); 478 mPmLabel.setEnabled(enabled); 479 mRadialTimePickerView.setEnabled(enabled); 480 mIsEnabled = enabled; 481 } 482 483 @Override 484 public boolean isEnabled() { 485 return mIsEnabled; 486 } 487 488 @Override 489 public int getBaseline() { 490 // does not support baseline alignment 491 return -1; 492 } 493 494 @Override 495 public Parcelable onSaveInstanceState(Parcelable superState) { 496 return new SavedState(superState, getHour(), getMinute(), 497 is24Hour(), getCurrentItemShowing()); 498 } 499 500 @Override 501 public void onRestoreInstanceState(Parcelable state) { 502 if (state instanceof SavedState) { 503 final SavedState ss = (SavedState) state; 504 initialize(ss.getHour(), ss.getMinute(), ss.is24HourMode(), ss.getCurrentItemShowing()); 505 mRadialTimePickerView.invalidate(); 506 } 507 } 508 509 @Override 510 public boolean dispatchPopulateAccessibilityEvent(AccessibilityEvent event) { 511 onPopulateAccessibilityEvent(event); 512 return true; 513 } 514 515 @Override 516 public void onPopulateAccessibilityEvent(AccessibilityEvent event) { 517 int flags = DateUtils.FORMAT_SHOW_TIME; 518 if (mIs24Hour) { 519 flags |= DateUtils.FORMAT_24HOUR; 520 } else { 521 flags |= DateUtils.FORMAT_12HOUR; 522 } 523 524 mTempCalendar.set(Calendar.HOUR_OF_DAY, getHour()); 525 mTempCalendar.set(Calendar.MINUTE, getMinute()); 526 527 final String selectedTime = DateUtils.formatDateTime(mContext, 528 mTempCalendar.getTimeInMillis(), flags); 529 final String selectionMode = mRadialTimePickerView.getCurrentItemShowing() == HOUR_INDEX ? 530 mSelectHours : mSelectMinutes; 531 event.getText().add(selectedTime + " " + selectionMode); 532 } 533 534 /** 535 * @return the index of the current item showing 536 */ 537 private int getCurrentItemShowing() { 538 return mRadialTimePickerView.getCurrentItemShowing(); 539 } 540 541 /** 542 * Propagate the time change 543 */ 544 private void onTimeChanged() { 545 mDelegator.sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_SELECTED); 546 if (mOnTimeChangedListener != null) { 547 mOnTimeChangedListener.onTimeChanged(mDelegator, getHour(), getMinute()); 548 } 549 } 550 551 private void tryVibrate() { 552 mDelegator.performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK); 553 } 554 555 private void updateAmPmLabelStates(int amOrPm) { 556 final boolean isAm = amOrPm == AM; 557 mAmLabel.setActivated(isAm); 558 mAmLabel.setChecked(isAm); 559 560 final boolean isPm = amOrPm == PM; 561 mPmLabel.setActivated(isPm); 562 mPmLabel.setChecked(isPm); 563 } 564 565 /** 566 * Called by the picker for updating the header display. 567 */ 568 @Override 569 public void onValueSelected(int pickerIndex, int newValue, boolean autoAdvance) { 570 switch (pickerIndex) { 571 case HOUR_INDEX: 572 if (mAllowAutoAdvance && autoAdvance) { 573 updateHeaderHour(newValue, false); 574 setCurrentItemShowing(MINUTE_INDEX, true, false); 575 mDelegator.announceForAccessibility(newValue + ". " + mSelectMinutes); 576 } else { 577 updateHeaderHour(newValue, true); 578 } 579 break; 580 case MINUTE_INDEX: 581 updateHeaderMinute(newValue, true); 582 break; 583 case AMPM_INDEX: 584 updateAmPmLabelStates(newValue); 585 break; 586 } 587 588 if (mOnTimeChangedListener != null) { 589 mOnTimeChangedListener.onTimeChanged(mDelegator, getHour(), getMinute()); 590 } 591 } 592 593 /** 594 * Converts hour-of-day (0-23) time into a localized hour number. 595 * <p> 596 * The localized value may be in the range (0-23), (1-24), (0-11), or 597 * (1-12) depending on the locale. This method does not handle leading 598 * zeroes. 599 * 600 * @param hourOfDay the hour-of-day (0-23) 601 * @return a localized hour number 602 */ 603 private int getLocalizedHour(int hourOfDay) { 604 if (!mIs24Hour) { 605 // Convert to hour-of-am-pm. 606 hourOfDay %= 12; 607 } 608 609 if (!mHourFormatStartsAtZero && hourOfDay == 0) { 610 // Convert to clock-hour (either of-day or of-am-pm). 611 hourOfDay = mIs24Hour ? 24 : 12; 612 } 613 614 return hourOfDay; 615 } 616 617 private void updateHeaderHour(int hourOfDay, boolean announce) { 618 final int localizedHour = getLocalizedHour(hourOfDay); 619 mHourView.setValue(localizedHour); 620 621 if (announce) { 622 tryAnnounceForAccessibility(mHourView.getText(), true); 623 } 624 } 625 626 private void updateHeaderMinute(int minuteOfHour, boolean announce) { 627 mMinuteView.setValue(minuteOfHour); 628 629 if (announce) { 630 tryAnnounceForAccessibility(mMinuteView.getText(), false); 631 } 632 } 633 634 /** 635 * The time separator is defined in the Unicode CLDR and cannot be supposed to be ":". 636 * 637 * See http://unicode.org/cldr/trac/browser/trunk/common/main 638 * 639 * We pass the correct "skeleton" depending on 12 or 24 hours view and then extract the 640 * separator as the character which is just after the hour marker in the returned pattern. 641 */ 642 private void updateHeaderSeparator() { 643 final String bestDateTimePattern = DateFormat.getBestDateTimePattern(mLocale, 644 (mIs24Hour) ? "Hm" : "hm"); 645 final String separatorText; 646 // See http://www.unicode.org/reports/tr35/tr35-dates.html for hour formats 647 final char[] hourFormats = {'H', 'h', 'K', 'k'}; 648 int hIndex = lastIndexOfAny(bestDateTimePattern, hourFormats); 649 if (hIndex == -1) { 650 // Default case 651 separatorText = ":"; 652 } else { 653 separatorText = Character.toString(bestDateTimePattern.charAt(hIndex + 1)); 654 } 655 mSeparatorView.setText(separatorText); 656 } 657 658 static private int lastIndexOfAny(String str, char[] any) { 659 final int lengthAny = any.length; 660 if (lengthAny > 0) { 661 for (int i = str.length() - 1; i >= 0; i--) { 662 char c = str.charAt(i); 663 for (int j = 0; j < lengthAny; j++) { 664 if (c == any[j]) { 665 return i; 666 } 667 } 668 } 669 } 670 return -1; 671 } 672 673 private void tryAnnounceForAccessibility(CharSequence text, boolean isHour) { 674 if (mLastAnnouncedIsHour != isHour || !text.equals(mLastAnnouncedText)) { 675 // TODO: Find a better solution, potentially live regions? 676 mDelegator.announceForAccessibility(text); 677 mLastAnnouncedText = text; 678 mLastAnnouncedIsHour = isHour; 679 } 680 } 681 682 /** 683 * Show either Hours or Minutes. 684 */ 685 private void setCurrentItemShowing(int index, boolean animateCircle, boolean announce) { 686 mRadialTimePickerView.setCurrentItemShowing(index, animateCircle); 687 688 if (index == HOUR_INDEX) { 689 if (announce) { 690 mDelegator.announceForAccessibility(mSelectHours); 691 } 692 } else { 693 if (announce) { 694 mDelegator.announceForAccessibility(mSelectMinutes); 695 } 696 } 697 698 mHourView.setActivated(index == HOUR_INDEX); 699 mMinuteView.setActivated(index == MINUTE_INDEX); 700 } 701 702 private void setAmOrPm(int amOrPm) { 703 updateAmPmLabelStates(amOrPm); 704 mRadialTimePickerView.setAmOrPm(amOrPm); 705 } 706 707 private final OnValueChangedListener mDigitEnteredListener = new OnValueChangedListener() { 708 @Override 709 public void onValueChanged(NumericTextView view, int value, 710 boolean isValid, boolean isFinished) { 711 final Runnable commitCallback; 712 final View nextFocusTarget; 713 if (view == mHourView) { 714 commitCallback = mCommitHour; 715 nextFocusTarget = view.isFocused() ? mMinuteView : null; 716 } else if (view == mMinuteView) { 717 commitCallback = mCommitMinute; 718 nextFocusTarget = null; 719 } else { 720 return; 721 } 722 723 view.removeCallbacks(commitCallback); 724 725 if (isValid) { 726 if (isFinished) { 727 // Done with hours entry, make visual updates 728 // immediately and move to next focus if needed. 729 commitCallback.run(); 730 731 if (nextFocusTarget != null) { 732 nextFocusTarget.requestFocus(); 733 } 734 } else { 735 // May still be making changes. Postpone visual 736 // updates to prevent distracting the user. 737 view.postDelayed(commitCallback, DELAY_COMMIT_MILLIS); 738 } 739 } 740 } 741 }; 742 743 private final Runnable mCommitHour = new Runnable() { 744 @Override 745 public void run() { 746 setHour(mHourView.getValue()); 747 } 748 }; 749 750 private final Runnable mCommitMinute = new Runnable() { 751 @Override 752 public void run() { 753 setMinute(mMinuteView.getValue()); 754 } 755 }; 756 757 private final View.OnFocusChangeListener mFocusListener = new View.OnFocusChangeListener() { 758 @Override 759 public void onFocusChange(View v, boolean focused) { 760 if (focused) { 761 switch (v.getId()) { 762 case R.id.am_label: 763 setAmOrPm(AM); 764 break; 765 case R.id.pm_label: 766 setAmOrPm(PM); 767 break; 768 case R.id.hours: 769 setCurrentItemShowing(HOUR_INDEX, true, true); 770 break; 771 case R.id.minutes: 772 setCurrentItemShowing(MINUTE_INDEX, true, true); 773 break; 774 default: 775 // Failed to handle this click, don't vibrate. 776 return; 777 } 778 779 tryVibrate(); 780 } 781 } 782 }; 783 784 private final View.OnClickListener mClickListener = new View.OnClickListener() { 785 @Override 786 public void onClick(View v) { 787 788 final int amOrPm; 789 switch (v.getId()) { 790 case R.id.am_label: 791 setAmOrPm(AM); 792 break; 793 case R.id.pm_label: 794 setAmOrPm(PM); 795 break; 796 case R.id.hours: 797 setCurrentItemShowing(HOUR_INDEX, true, true); 798 break; 799 case R.id.minutes: 800 setCurrentItemShowing(MINUTE_INDEX, true, true); 801 break; 802 default: 803 // Failed to handle this click, don't vibrate. 804 return; 805 } 806 807 tryVibrate(); 808 } 809 }; 810 811 /** 812 * Delegates unhandled touches in a view group to the nearest child view. 813 */ 814 private static class NearestTouchDelegate implements View.OnTouchListener { 815 private View mInitialTouchTarget; 816 817 @Override 818 public boolean onTouch(View view, MotionEvent motionEvent) { 819 final int actionMasked = motionEvent.getActionMasked(); 820 if (actionMasked == MotionEvent.ACTION_DOWN) { 821 if (view instanceof ViewGroup) { 822 mInitialTouchTarget = findNearestChild((ViewGroup) view, 823 (int) motionEvent.getX(), (int) motionEvent.getY()); 824 } else { 825 mInitialTouchTarget = null; 826 } 827 } 828 829 final View child = mInitialTouchTarget; 830 if (child == null) { 831 return false; 832 } 833 834 final float offsetX = view.getScrollX() - child.getLeft(); 835 final float offsetY = view.getScrollY() - child.getTop(); 836 motionEvent.offsetLocation(offsetX, offsetY); 837 final boolean handled = child.dispatchTouchEvent(motionEvent); 838 motionEvent.offsetLocation(-offsetX, -offsetY); 839 840 if (actionMasked == MotionEvent.ACTION_UP 841 || actionMasked == MotionEvent.ACTION_CANCEL) { 842 mInitialTouchTarget = null; 843 } 844 845 return handled; 846 } 847 848 private View findNearestChild(ViewGroup v, int x, int y) { 849 View bestChild = null; 850 int bestDist = Integer.MAX_VALUE; 851 852 for (int i = 0, count = v.getChildCount(); i < count; i++) { 853 final View child = v.getChildAt(i); 854 final int dX = x - (child.getLeft() + child.getWidth() / 2); 855 final int dY = y - (child.getTop() + child.getHeight() / 2); 856 final int dist = dX * dX + dY * dY; 857 if (bestDist > dist) { 858 bestChild = child; 859 bestDist = dist; 860 } 861 } 862 863 return bestChild; 864 } 865 } 866} 867