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