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 TimePickerClockDelegate(TimePicker delegator, Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes)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 toggleRadialPickerMode()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 = mContext.getSystemService(InputMethodManager.class); 273 if (imm != null) { 274 imm.hideSoftInputFromWindow(mDelegator.getWindowToken(), 0); 275 } 276 mRadialPickerModeEnabled = true; 277 } 278 } 279 280 @Override validateInput()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 */ ensureMinimumTextWidth(TextView v)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 */ updateHourFormat()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 obtainVerbatim(String text)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 applyLegacyColorFixes(@ullable ColorStateList color)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 ta.recycle(); 377 defaultColor = multiplyAlphaComponent(activatedColor, disabledAlpha); 378 } 379 380 if (activatedColor == 0 || defaultColor == 0) { 381 // We somehow failed to obtain the colors. 382 return null; 383 } 384 385 final int[][] stateSet = new int[][] {{ R.attr.state_activated }, {}}; 386 final int[] colors = new int[] { activatedColor, defaultColor }; 387 return new ColorStateList(stateSet, colors); 388 } 389 multiplyAlphaComponent(int color, float alphaMod)390 private int multiplyAlphaComponent(int color, float alphaMod) { 391 final int srcRgb = color & 0xFFFFFF; 392 final int srcAlpha = (color >> 24) & 0xFF; 393 final int dstAlpha = (int) (srcAlpha * alphaMod + 0.5f); 394 return srcRgb | (dstAlpha << 24); 395 } 396 397 private static class ClickActionDelegate extends AccessibilityDelegate { 398 private final AccessibilityAction mClickAction; 399 ClickActionDelegate(Context context, int resId)400 public ClickActionDelegate(Context context, int resId) { 401 mClickAction = new AccessibilityAction( 402 AccessibilityNodeInfo.ACTION_CLICK, context.getString(resId)); 403 } 404 405 @Override onInitializeAccessibilityNodeInfo(View host, AccessibilityNodeInfo info)406 public void onInitializeAccessibilityNodeInfo(View host, AccessibilityNodeInfo info) { 407 super.onInitializeAccessibilityNodeInfo(host, info); 408 409 info.addAction(mClickAction); 410 } 411 } 412 initialize(int hourOfDay, int minute, boolean is24HourView, int index)413 private void initialize(int hourOfDay, int minute, boolean is24HourView, int index) { 414 mCurrentHour = hourOfDay; 415 mCurrentMinute = minute; 416 mIs24Hour = is24HourView; 417 updateUI(index); 418 } 419 updateUI(int index)420 private void updateUI(int index) { 421 updateHeaderAmPm(); 422 updateHeaderHour(mCurrentHour, false); 423 updateHeaderSeparator(); 424 updateHeaderMinute(mCurrentMinute, false); 425 updateRadialPicker(index); 426 updateTextInputPicker(); 427 428 mDelegator.invalidate(); 429 } 430 updateTextInputPicker()431 private void updateTextInputPicker() { 432 mTextInputPickerView.updateTextInputValues(getLocalizedHour(mCurrentHour), mCurrentMinute, 433 mCurrentHour < 12 ? AM : PM, mIs24Hour, mHourFormatStartsAtZero); 434 } 435 436 private void updateRadialPicker(int index) { 437 mRadialTimePickerView.initialize(mCurrentHour, mCurrentMinute, mIs24Hour); 438 setCurrentItemShowing(index, false, true); 439 } 440 441 private void updateHeaderAmPm() { 442 if (mIs24Hour) { 443 mAmPmLayout.setVisibility(View.GONE); 444 } else { 445 // Find the location of AM/PM based on locale information. 446 final String dateTimePattern = DateFormat.getBestDateTimePattern(mLocale, "hm"); 447 final boolean isAmPmAtStart = dateTimePattern.startsWith("a"); 448 setAmPmStart(isAmPmAtStart); 449 updateAmPmLabelStates(mCurrentHour < 12 ? AM : PM); 450 } 451 } 452 453 private void setAmPmStart(boolean isAmPmAtStart) { 454 final RelativeLayout.LayoutParams params = 455 (RelativeLayout.LayoutParams) mAmPmLayout.getLayoutParams(); 456 if (params.getRule(RelativeLayout.RIGHT_OF) != 0 457 || params.getRule(RelativeLayout.LEFT_OF) != 0) { 458 final int margin = (int) (mContext.getResources().getDisplayMetrics().density * 8); 459 // Horizontal mode, with AM/PM appearing to left/right of hours and minutes. 460 final boolean isAmPmAtLeft; 461 if (TextUtils.getLayoutDirectionFromLocale(mLocale) == View.LAYOUT_DIRECTION_LTR) { 462 isAmPmAtLeft = isAmPmAtStart; 463 } else { 464 isAmPmAtLeft = !isAmPmAtStart; 465 } 466 467 if (isAmPmAtLeft) { 468 params.removeRule(RelativeLayout.RIGHT_OF); 469 params.addRule(RelativeLayout.LEFT_OF, mHourView.getId()); 470 } else { 471 params.removeRule(RelativeLayout.LEFT_OF); 472 params.addRule(RelativeLayout.RIGHT_OF, mMinuteView.getId()); 473 } 474 475 if (isAmPmAtStart) { 476 params.setMarginStart(0); 477 params.setMarginEnd(margin); 478 } else { 479 params.setMarginStart(margin); 480 params.setMarginEnd(0); 481 } 482 mIsAmPmAtLeft = isAmPmAtLeft; 483 } else if (params.getRule(RelativeLayout.BELOW) != 0 484 || params.getRule(RelativeLayout.ABOVE) != 0) { 485 // Vertical mode, with AM/PM appearing to top/bottom of hours and minutes. 486 if (mIsAmPmAtTop == isAmPmAtStart) { 487 // AM/PM is already at the correct location. No change needed. 488 return; 489 } 490 491 final int otherViewId; 492 if (isAmPmAtStart) { 493 otherViewId = params.getRule(RelativeLayout.BELOW); 494 params.removeRule(RelativeLayout.BELOW); 495 params.addRule(RelativeLayout.ABOVE, otherViewId); 496 } else { 497 otherViewId = params.getRule(RelativeLayout.ABOVE); 498 params.removeRule(RelativeLayout.ABOVE); 499 params.addRule(RelativeLayout.BELOW, otherViewId); 500 } 501 502 // Switch the top and bottom paddings on the other view. 503 final View otherView = mRadialTimePickerHeader.findViewById(otherViewId); 504 final int top = otherView.getPaddingTop(); 505 final int bottom = otherView.getPaddingBottom(); 506 final int left = otherView.getPaddingLeft(); 507 final int right = otherView.getPaddingRight(); 508 otherView.setPadding(left, bottom, right, top); 509 510 mIsAmPmAtTop = isAmPmAtStart; 511 } 512 513 mAmPmLayout.setLayoutParams(params); 514 } 515 516 @Override 517 public void setDate(int hour, int minute) { 518 setHourInternal(hour, FROM_EXTERNAL_API, true, false); 519 setMinuteInternal(minute, FROM_EXTERNAL_API, false); 520 521 onTimeChanged(); 522 } 523 524 /** 525 * Set the current hour. 526 */ 527 @Override 528 public void setHour(int hour) { 529 setHourInternal(hour, FROM_EXTERNAL_API, true, true); 530 } 531 532 private void setHourInternal(int hour, @ChangeSource int source, boolean announce, 533 boolean notify) { 534 if (mCurrentHour == hour) { 535 return; 536 } 537 538 resetAutofilledValue(); 539 mCurrentHour = hour; 540 updateHeaderHour(hour, announce); 541 updateHeaderAmPm(); 542 543 if (source != FROM_RADIAL_PICKER) { 544 mRadialTimePickerView.setCurrentHour(hour); 545 mRadialTimePickerView.setAmOrPm(hour < 12 ? AM : PM); 546 } 547 if (source != FROM_INPUT_PICKER) { 548 updateTextInputPicker(); 549 } 550 551 mDelegator.invalidate(); 552 if (notify) { 553 onTimeChanged(); 554 } 555 } 556 557 /** 558 * @return the current hour in the range (0-23) 559 */ 560 @Override 561 public int getHour() { 562 final int currentHour = mRadialTimePickerView.getCurrentHour(); 563 if (mIs24Hour) { 564 return currentHour; 565 } 566 567 if (mRadialTimePickerView.getAmOrPm() == PM) { 568 return (currentHour % HOURS_IN_HALF_DAY) + HOURS_IN_HALF_DAY; 569 } else { 570 return currentHour % HOURS_IN_HALF_DAY; 571 } 572 } 573 574 /** 575 * Set the current minute (0-59). 576 */ 577 @Override 578 public void setMinute(int minute) { 579 setMinuteInternal(minute, FROM_EXTERNAL_API, true); 580 } 581 582 private void setMinuteInternal(int minute, @ChangeSource int source, boolean notify) { 583 if (mCurrentMinute == minute) { 584 return; 585 } 586 587 resetAutofilledValue(); 588 mCurrentMinute = minute; 589 updateHeaderMinute(minute, true); 590 591 if (source != FROM_RADIAL_PICKER) { 592 mRadialTimePickerView.setCurrentMinute(minute); 593 } 594 if (source != FROM_INPUT_PICKER) { 595 updateTextInputPicker(); 596 } 597 598 mDelegator.invalidate(); 599 if (notify) { 600 onTimeChanged(); 601 } 602 } 603 604 /** 605 * @return The current minute. 606 */ 607 @Override 608 public int getMinute() { 609 return mRadialTimePickerView.getCurrentMinute(); 610 } 611 612 /** 613 * Sets whether time is displayed in 24-hour mode or 12-hour mode with 614 * AM/PM indicators. 615 * 616 * @param is24Hour {@code true} to display time in 24-hour mode or 617 * {@code false} for 12-hour mode with AM/PM 618 */ 619 public void setIs24Hour(boolean is24Hour) { 620 if (mIs24Hour != is24Hour) { 621 mIs24Hour = is24Hour; 622 mCurrentHour = getHour(); 623 624 updateHourFormat(); 625 updateUI(mRadialTimePickerView.getCurrentItemShowing()); 626 } 627 } 628 629 /** 630 * @return {@code true} if time is displayed in 24-hour mode, or 631 * {@code false} if time is displayed in 12-hour mode with AM/PM 632 * indicators 633 */ 634 @Override 635 public boolean is24Hour() { 636 return mIs24Hour; 637 } 638 639 @Override 640 public void setEnabled(boolean enabled) { 641 mHourView.setEnabled(enabled); 642 mMinuteView.setEnabled(enabled); 643 mAmLabel.setEnabled(enabled); 644 mPmLabel.setEnabled(enabled); 645 mRadialTimePickerView.setEnabled(enabled); 646 mIsEnabled = enabled; 647 } 648 649 @Override 650 public boolean isEnabled() { 651 return mIsEnabled; 652 } 653 654 @Override 655 public int getBaseline() { 656 // does not support baseline alignment 657 return -1; 658 } 659 660 @Override 661 public Parcelable onSaveInstanceState(Parcelable superState) { 662 return new SavedState(superState, getHour(), getMinute(), 663 is24Hour(), getCurrentItemShowing()); 664 } 665 666 @Override 667 public void onRestoreInstanceState(Parcelable state) { 668 if (state instanceof SavedState) { 669 final SavedState ss = (SavedState) state; 670 initialize(ss.getHour(), ss.getMinute(), ss.is24HourMode(), ss.getCurrentItemShowing()); 671 mRadialTimePickerView.invalidate(); 672 } 673 } 674 675 @Override 676 public boolean dispatchPopulateAccessibilityEvent(AccessibilityEvent event) { 677 onPopulateAccessibilityEvent(event); 678 return true; 679 } 680 681 @Override 682 public void onPopulateAccessibilityEvent(AccessibilityEvent event) { 683 int flags = DateUtils.FORMAT_SHOW_TIME; 684 if (mIs24Hour) { 685 flags |= DateUtils.FORMAT_24HOUR; 686 } else { 687 flags |= DateUtils.FORMAT_12HOUR; 688 } 689 690 mTempCalendar.set(Calendar.HOUR_OF_DAY, getHour()); 691 mTempCalendar.set(Calendar.MINUTE, getMinute()); 692 693 final String selectedTime = DateUtils.formatDateTime(mContext, 694 mTempCalendar.getTimeInMillis(), flags); 695 final String selectionMode = mRadialTimePickerView.getCurrentItemShowing() == HOUR_INDEX ? 696 mSelectHours : mSelectMinutes; 697 event.getText().add(selectedTime + " " + selectionMode); 698 } 699 700 /** @hide */ 701 @Override 702 @TestApi 703 public View getHourView() { 704 return mHourView; 705 } 706 707 /** @hide */ 708 @Override 709 @TestApi 710 public View getMinuteView() { 711 return mMinuteView; 712 } 713 714 /** @hide */ 715 @Override 716 @TestApi 717 public View getAmView() { 718 return mAmLabel; 719 } 720 721 /** @hide */ 722 @Override 723 @TestApi 724 public View getPmView() { 725 return mPmLabel; 726 } 727 728 /** 729 * @return the index of the current item showing 730 */ 731 private int getCurrentItemShowing() { 732 return mRadialTimePickerView.getCurrentItemShowing(); 733 } 734 735 /** 736 * Propagate the time change 737 */ 738 private void onTimeChanged() { 739 mDelegator.sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_SELECTED); 740 if (mOnTimeChangedListener != null) { 741 mOnTimeChangedListener.onTimeChanged(mDelegator, getHour(), getMinute()); 742 } 743 if (mAutoFillChangeListener != null) { 744 mAutoFillChangeListener.onTimeChanged(mDelegator, getHour(), getMinute()); 745 } 746 } 747 748 private void tryVibrate() { 749 mDelegator.performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK); 750 } 751 752 private void updateAmPmLabelStates(int amOrPm) { 753 final boolean isAm = amOrPm == AM; 754 mAmLabel.setActivated(isAm); 755 mAmLabel.setChecked(isAm); 756 757 final boolean isPm = amOrPm == PM; 758 mPmLabel.setActivated(isPm); 759 mPmLabel.setChecked(isPm); 760 } 761 762 /** 763 * Converts hour-of-day (0-23) time into a localized hour number. 764 * <p> 765 * The localized value may be in the range (0-23), (1-24), (0-11), or 766 * (1-12) depending on the locale. This method does not handle leading 767 * zeroes. 768 * 769 * @param hourOfDay the hour-of-day (0-23) 770 * @return a localized hour number 771 */ 772 private int getLocalizedHour(int hourOfDay) { 773 if (!mIs24Hour) { 774 // Convert to hour-of-am-pm. 775 hourOfDay %= 12; 776 } 777 778 if (!mHourFormatStartsAtZero && hourOfDay == 0) { 779 // Convert to clock-hour (either of-day or of-am-pm). 780 hourOfDay = mIs24Hour ? 24 : 12; 781 } 782 783 return hourOfDay; 784 } 785 786 private void updateHeaderHour(int hourOfDay, boolean announce) { 787 final int localizedHour = getLocalizedHour(hourOfDay); 788 mHourView.setValue(localizedHour); 789 790 if (announce) { 791 tryAnnounceForAccessibility(mHourView.getText(), true); 792 } 793 } 794 795 private void updateHeaderMinute(int minuteOfHour, boolean announce) { 796 mMinuteView.setValue(minuteOfHour); 797 798 if (announce) { 799 tryAnnounceForAccessibility(mMinuteView.getText(), false); 800 } 801 } 802 803 /** 804 * The time separator is defined in the Unicode CLDR and cannot be supposed to be ":". 805 * 806 * See http://unicode.org/cldr/trac/browser/trunk/common/main 807 * 808 * We pass the correct "skeleton" depending on 12 or 24 hours view and then extract the 809 * separator as the character which is just after the hour marker in the returned pattern. 810 */ 811 private void updateHeaderSeparator() { 812 final String bestDateTimePattern = DateFormat.getBestDateTimePattern(mLocale, 813 (mIs24Hour) ? "Hm" : "hm"); 814 final String separatorText = getHourMinSeparatorFromPattern(bestDateTimePattern); 815 mSeparatorView.setText(separatorText); 816 mTextInputPickerView.updateSeparator(separatorText); 817 } 818 819 /** 820 * This helper method extracts the time separator from the {@code datetimePattern}. 821 * 822 * The time separator is defined in the Unicode CLDR and cannot be supposed to be ":". 823 * 824 * See http://unicode.org/cldr/trac/browser/trunk/common/main 825 * 826 * @return Separator string. This is the character or set of quoted characters just after the 827 * hour marker in {@code dateTimePattern}. Returns a colon (:) if it can't locate the 828 * separator. 829 * 830 * @hide 831 */ 832 private static String getHourMinSeparatorFromPattern(String dateTimePattern) { 833 final String defaultSeparator = ":"; 834 boolean foundHourPattern = false; 835 for (int i = 0; i < dateTimePattern.length(); i++) { 836 switch (dateTimePattern.charAt(i)) { 837 // See http://www.unicode.org/reports/tr35/tr35-dates.html for hour formats. 838 case 'H': 839 case 'h': 840 case 'K': 841 case 'k': 842 foundHourPattern = true; 843 continue; 844 case ' ': // skip spaces 845 continue; 846 case '\'': 847 if (!foundHourPattern) { 848 continue; 849 } 850 SpannableStringBuilder quotedSubstring = new SpannableStringBuilder( 851 dateTimePattern.substring(i)); 852 int quotedTextLength = DateFormat.appendQuotedText(quotedSubstring, 0); 853 return quotedSubstring.subSequence(0, quotedTextLength).toString(); 854 default: 855 if (!foundHourPattern) { 856 continue; 857 } 858 return Character.toString(dateTimePattern.charAt(i)); 859 } 860 } 861 return defaultSeparator; 862 } 863 864 static private int lastIndexOfAny(String str, char[] any) { 865 final int lengthAny = any.length; 866 if (lengthAny > 0) { 867 for (int i = str.length() - 1; i >= 0; i--) { 868 char c = str.charAt(i); 869 for (int j = 0; j < lengthAny; j++) { 870 if (c == any[j]) { 871 return i; 872 } 873 } 874 } 875 } 876 return -1; 877 } 878 879 private void tryAnnounceForAccessibility(CharSequence text, boolean isHour) { 880 if (mLastAnnouncedIsHour != isHour || !text.equals(mLastAnnouncedText)) { 881 // TODO: Find a better solution, potentially live regions? 882 mDelegator.announceForAccessibility(text); 883 mLastAnnouncedText = text; 884 mLastAnnouncedIsHour = isHour; 885 } 886 } 887 888 /** 889 * Show either Hours or Minutes. 890 */ 891 private void setCurrentItemShowing(int index, boolean animateCircle, boolean announce) { 892 mRadialTimePickerView.setCurrentItemShowing(index, animateCircle); 893 894 if (index == HOUR_INDEX) { 895 if (announce) { 896 mDelegator.announceForAccessibility(mSelectHours); 897 } 898 } else { 899 if (announce) { 900 mDelegator.announceForAccessibility(mSelectMinutes); 901 } 902 } 903 904 mHourView.setActivated(index == HOUR_INDEX); 905 mMinuteView.setActivated(index == MINUTE_INDEX); 906 } 907 908 private void setAmOrPm(int amOrPm) { 909 updateAmPmLabelStates(amOrPm); 910 911 if (mRadialTimePickerView.setAmOrPm(amOrPm)) { 912 mCurrentHour = getHour(); 913 updateTextInputPicker(); 914 if (mOnTimeChangedListener != null) { 915 mOnTimeChangedListener.onTimeChanged(mDelegator, getHour(), getMinute()); 916 } 917 } 918 } 919 920 /** Listener for RadialTimePickerView interaction. */ 921 private final OnValueSelectedListener mOnValueSelectedListener = new OnValueSelectedListener() { 922 @Override 923 public void onValueSelected(int pickerType, int newValue, boolean autoAdvance) { 924 boolean valueChanged = false; 925 switch (pickerType) { 926 case RadialTimePickerView.HOURS: 927 if (getHour() != newValue) { 928 valueChanged = true; 929 } 930 final boolean isTransition = mAllowAutoAdvance && autoAdvance; 931 setHourInternal(newValue, FROM_RADIAL_PICKER, !isTransition, true); 932 if (isTransition) { 933 setCurrentItemShowing(MINUTE_INDEX, true, false); 934 935 final int localizedHour = getLocalizedHour(newValue); 936 mDelegator.announceForAccessibility(localizedHour + ". " + mSelectMinutes); 937 } 938 break; 939 case RadialTimePickerView.MINUTES: 940 if (getMinute() != newValue) { 941 valueChanged = true; 942 } 943 setMinuteInternal(newValue, FROM_RADIAL_PICKER, true); 944 break; 945 } 946 947 if (mOnTimeChangedListener != null && valueChanged) { 948 mOnTimeChangedListener.onTimeChanged(mDelegator, getHour(), getMinute()); 949 } 950 } 951 }; 952 953 private final OnValueTypedListener mOnValueTypedListener = new OnValueTypedListener() { 954 @Override 955 public void onValueChanged(int pickerType, int newValue) { 956 switch (pickerType) { 957 case TextInputTimePickerView.HOURS: 958 setHourInternal(newValue, FROM_INPUT_PICKER, false, true); 959 break; 960 case TextInputTimePickerView.MINUTES: 961 setMinuteInternal(newValue, FROM_INPUT_PICKER, true); 962 break; 963 case TextInputTimePickerView.AMPM: 964 setAmOrPm(newValue); 965 break; 966 } 967 } 968 }; 969 970 /** Listener for keyboard interaction. */ 971 private final OnValueChangedListener mDigitEnteredListener = new OnValueChangedListener() { 972 @Override 973 public void onValueChanged(NumericTextView view, int value, 974 boolean isValid, boolean isFinished) { 975 final Runnable commitCallback; 976 final View nextFocusTarget; 977 if (view == mHourView) { 978 commitCallback = mCommitHour; 979 nextFocusTarget = view.isFocused() ? mMinuteView : null; 980 } else if (view == mMinuteView) { 981 commitCallback = mCommitMinute; 982 nextFocusTarget = null; 983 } else { 984 return; 985 } 986 987 view.removeCallbacks(commitCallback); 988 989 if (isValid) { 990 if (isFinished) { 991 // Done with hours entry, make visual updates 992 // immediately and move to next focus if needed. 993 commitCallback.run(); 994 995 if (nextFocusTarget != null) { 996 nextFocusTarget.requestFocus(); 997 } 998 } else { 999 // May still be making changes. Postpone visual 1000 // updates to prevent distracting the user. 1001 view.postDelayed(commitCallback, DELAY_COMMIT_MILLIS); 1002 } 1003 } 1004 } 1005 }; 1006 1007 private final Runnable mCommitHour = new Runnable() { 1008 @Override 1009 public void run() { 1010 setHour(mHourView.getValue()); 1011 } 1012 }; 1013 1014 private final Runnable mCommitMinute = new Runnable() { 1015 @Override 1016 public void run() { 1017 setMinute(mMinuteView.getValue()); 1018 } 1019 }; 1020 1021 private final View.OnFocusChangeListener mFocusListener = new View.OnFocusChangeListener() { 1022 @Override 1023 public void onFocusChange(View v, boolean focused) { 1024 if (focused) { 1025 switch (v.getId()) { 1026 case R.id.am_label: 1027 setAmOrPm(AM); 1028 break; 1029 case R.id.pm_label: 1030 setAmOrPm(PM); 1031 break; 1032 case R.id.hours: 1033 setCurrentItemShowing(HOUR_INDEX, true, true); 1034 break; 1035 case R.id.minutes: 1036 setCurrentItemShowing(MINUTE_INDEX, true, true); 1037 break; 1038 default: 1039 // Failed to handle this click, don't vibrate. 1040 return; 1041 } 1042 1043 tryVibrate(); 1044 } 1045 } 1046 }; 1047 1048 private final View.OnClickListener mClickListener = new View.OnClickListener() { 1049 @Override 1050 public void onClick(View v) { 1051 1052 final int amOrPm; 1053 switch (v.getId()) { 1054 case R.id.am_label: 1055 setAmOrPm(AM); 1056 break; 1057 case R.id.pm_label: 1058 setAmOrPm(PM); 1059 break; 1060 case R.id.hours: 1061 setCurrentItemShowing(HOUR_INDEX, true, true); 1062 break; 1063 case R.id.minutes: 1064 setCurrentItemShowing(MINUTE_INDEX, true, true); 1065 break; 1066 default: 1067 // Failed to handle this click, don't vibrate. 1068 return; 1069 } 1070 1071 tryVibrate(); 1072 } 1073 }; 1074 1075 /** 1076 * Delegates unhandled touches in a view group to the nearest child view. 1077 */ 1078 private static class NearestTouchDelegate implements View.OnTouchListener { 1079 private View mInitialTouchTarget; 1080 1081 @Override 1082 public boolean onTouch(View view, MotionEvent motionEvent) { 1083 final int actionMasked = motionEvent.getActionMasked(); 1084 if (actionMasked == MotionEvent.ACTION_DOWN) { 1085 if (view instanceof ViewGroup) { 1086 mInitialTouchTarget = findNearestChild((ViewGroup) view, 1087 (int) motionEvent.getX(), (int) motionEvent.getY()); 1088 } else { 1089 mInitialTouchTarget = null; 1090 } 1091 } 1092 1093 final View child = mInitialTouchTarget; 1094 if (child == null) { 1095 return false; 1096 } 1097 1098 final float offsetX = view.getScrollX() - child.getLeft(); 1099 final float offsetY = view.getScrollY() - child.getTop(); 1100 motionEvent.offsetLocation(offsetX, offsetY); 1101 final boolean handled = child.dispatchTouchEvent(motionEvent); 1102 motionEvent.offsetLocation(-offsetX, -offsetY); 1103 1104 if (actionMasked == MotionEvent.ACTION_UP 1105 || actionMasked == MotionEvent.ACTION_CANCEL) { 1106 mInitialTouchTarget = null; 1107 } 1108 1109 return handled; 1110 } 1111 1112 private View findNearestChild(ViewGroup v, int x, int y) { 1113 View bestChild = null; 1114 int bestDist = Integer.MAX_VALUE; 1115 1116 for (int i = 0, count = v.getChildCount(); i < count; i++) { 1117 final View child = v.getChildAt(i); 1118 final int dX = x - (child.getLeft() + child.getWidth() / 2); 1119 final int dY = y - (child.getTop() + child.getHeight() / 2); 1120 final int dist = dX * dX + dY * dY; 1121 if (bestDist > dist) { 1122 bestChild = child; 1123 bestDist = dist; 1124 } 1125 } 1126 1127 return bestChild; 1128 } 1129 } 1130 } 1131