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