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