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.content.Context; 20 import android.content.res.Configuration; 21 import android.content.res.TypedArray; 22 import android.os.Parcel; 23 import android.os.Parcelable; 24 import android.text.format.DateFormat; 25 import android.text.format.DateUtils; 26 import android.util.AttributeSet; 27 import android.view.LayoutInflater; 28 import android.view.View; 29 import android.view.ViewGroup; 30 import android.view.accessibility.AccessibilityEvent; 31 import android.view.accessibility.AccessibilityNodeInfo; 32 import android.view.inputmethod.EditorInfo; 33 import android.view.inputmethod.InputMethodManager; 34 import com.android.internal.R; 35 36 import java.text.DateFormatSymbols; 37 import java.util.Calendar; 38 import java.util.Locale; 39 40 import libcore.icu.LocaleData; 41 42 import static android.view.View.IMPORTANT_FOR_ACCESSIBILITY_AUTO; 43 import static android.view.View.IMPORTANT_FOR_ACCESSIBILITY_YES; 44 45 /** 46 * A delegate implementing the basic spinner-based TimePicker. 47 */ 48 class TimePickerSpinnerDelegate extends TimePicker.AbstractTimePickerDelegate { 49 private static final boolean DEFAULT_ENABLED_STATE = true; 50 private static final int HOURS_IN_HALF_DAY = 12; 51 52 // state 53 private boolean mIs24HourView; 54 private boolean mIsAm; 55 56 // ui components 57 private final NumberPicker mHourSpinner; 58 private final NumberPicker mMinuteSpinner; 59 private final NumberPicker mAmPmSpinner; 60 private final EditText mHourSpinnerInput; 61 private final EditText mMinuteSpinnerInput; 62 private final EditText mAmPmSpinnerInput; 63 private final TextView mDivider; 64 65 // Note that the legacy implementation of the TimePicker is 66 // using a button for toggling between AM/PM while the new 67 // version uses a NumberPicker spinner. Therefore the code 68 // accommodates these two cases to be backwards compatible. 69 private final Button mAmPmButton; 70 71 private final String[] mAmPmStrings; 72 73 private boolean mIsEnabled = DEFAULT_ENABLED_STATE; 74 private Calendar mTempCalendar; 75 private boolean mHourWithTwoDigit; 76 private char mHourFormat; 77 78 public TimePickerSpinnerDelegate(TimePicker delegator, Context context, AttributeSet attrs, 79 int defStyleAttr, int defStyleRes) { 80 super(delegator, context); 81 82 // process style attributes 83 final TypedArray a = mContext.obtainStyledAttributes( 84 attrs, R.styleable.TimePicker, defStyleAttr, defStyleRes); 85 final int layoutResourceId = a.getResourceId( 86 R.styleable.TimePicker_legacyLayout, R.layout.time_picker_legacy); 87 a.recycle(); 88 89 final LayoutInflater inflater = LayoutInflater.from(mContext); 90 inflater.inflate(layoutResourceId, mDelegator, true); 91 92 // hour 93 mHourSpinner = (NumberPicker) delegator.findViewById(R.id.hour); 94 mHourSpinner.setOnValueChangedListener(new NumberPicker.OnValueChangeListener() { 95 public void onValueChange(NumberPicker spinner, int oldVal, int newVal) { 96 updateInputState(); 97 if (!is24HourView()) { 98 if ((oldVal == HOURS_IN_HALF_DAY - 1 && newVal == HOURS_IN_HALF_DAY) || 99 (oldVal == HOURS_IN_HALF_DAY && newVal == HOURS_IN_HALF_DAY - 1)) { 100 mIsAm = !mIsAm; 101 updateAmPmControl(); 102 } 103 } 104 onTimeChanged(); 105 } 106 }); 107 mHourSpinnerInput = (EditText) mHourSpinner.findViewById(R.id.numberpicker_input); 108 mHourSpinnerInput.setImeOptions(EditorInfo.IME_ACTION_NEXT); 109 110 // divider (only for the new widget style) 111 mDivider = (TextView) mDelegator.findViewById(R.id.divider); 112 if (mDivider != null) { 113 setDividerText(); 114 } 115 116 // minute 117 mMinuteSpinner = (NumberPicker) mDelegator.findViewById(R.id.minute); 118 mMinuteSpinner.setMinValue(0); 119 mMinuteSpinner.setMaxValue(59); 120 mMinuteSpinner.setOnLongPressUpdateInterval(100); 121 mMinuteSpinner.setFormatter(NumberPicker.getTwoDigitFormatter()); 122 mMinuteSpinner.setOnValueChangedListener(new NumberPicker.OnValueChangeListener() { 123 public void onValueChange(NumberPicker spinner, int oldVal, int newVal) { 124 updateInputState(); 125 int minValue = mMinuteSpinner.getMinValue(); 126 int maxValue = mMinuteSpinner.getMaxValue(); 127 if (oldVal == maxValue && newVal == minValue) { 128 int newHour = mHourSpinner.getValue() + 1; 129 if (!is24HourView() && newHour == HOURS_IN_HALF_DAY) { 130 mIsAm = !mIsAm; 131 updateAmPmControl(); 132 } 133 mHourSpinner.setValue(newHour); 134 } else if (oldVal == minValue && newVal == maxValue) { 135 int newHour = mHourSpinner.getValue() - 1; 136 if (!is24HourView() && newHour == HOURS_IN_HALF_DAY - 1) { 137 mIsAm = !mIsAm; 138 updateAmPmControl(); 139 } 140 mHourSpinner.setValue(newHour); 141 } 142 onTimeChanged(); 143 } 144 }); 145 mMinuteSpinnerInput = (EditText) mMinuteSpinner.findViewById(R.id.numberpicker_input); 146 mMinuteSpinnerInput.setImeOptions(EditorInfo.IME_ACTION_NEXT); 147 148 // Get the localized am/pm strings and use them in the spinner. 149 mAmPmStrings = getAmPmStrings(context); 150 151 // am/pm 152 final View amPmView = mDelegator.findViewById(R.id.amPm); 153 if (amPmView instanceof Button) { 154 mAmPmSpinner = null; 155 mAmPmSpinnerInput = null; 156 mAmPmButton = (Button) amPmView; 157 mAmPmButton.setOnClickListener(new View.OnClickListener() { 158 public void onClick(View button) { 159 button.requestFocus(); 160 mIsAm = !mIsAm; 161 updateAmPmControl(); 162 onTimeChanged(); 163 } 164 }); 165 } else { 166 mAmPmButton = null; 167 mAmPmSpinner = (NumberPicker) amPmView; 168 mAmPmSpinner.setMinValue(0); 169 mAmPmSpinner.setMaxValue(1); 170 mAmPmSpinner.setDisplayedValues(mAmPmStrings); 171 mAmPmSpinner.setOnValueChangedListener(new NumberPicker.OnValueChangeListener() { 172 public void onValueChange(NumberPicker picker, int oldVal, int newVal) { 173 updateInputState(); 174 picker.requestFocus(); 175 mIsAm = !mIsAm; 176 updateAmPmControl(); 177 onTimeChanged(); 178 } 179 }); 180 mAmPmSpinnerInput = (EditText) mAmPmSpinner.findViewById(R.id.numberpicker_input); 181 mAmPmSpinnerInput.setImeOptions(EditorInfo.IME_ACTION_DONE); 182 } 183 184 if (isAmPmAtStart()) { 185 // Move the am/pm view to the beginning 186 ViewGroup amPmParent = (ViewGroup) delegator.findViewById(R.id.timePickerLayout); 187 amPmParent.removeView(amPmView); 188 amPmParent.addView(amPmView, 0); 189 // Swap layout margins if needed. They may be not symmetrical (Old Standard Theme 190 // for example and not for Holo Theme) 191 ViewGroup.MarginLayoutParams lp = 192 (ViewGroup.MarginLayoutParams) amPmView.getLayoutParams(); 193 final int startMargin = lp.getMarginStart(); 194 final int endMargin = lp.getMarginEnd(); 195 if (startMargin != endMargin) { 196 lp.setMarginStart(endMargin); 197 lp.setMarginEnd(startMargin); 198 } 199 } 200 201 getHourFormatData(); 202 203 // update controls to initial state 204 updateHourControl(); 205 updateMinuteControl(); 206 updateAmPmControl(); 207 208 // set to current time 209 setCurrentHour(mTempCalendar.get(Calendar.HOUR_OF_DAY)); 210 setCurrentMinute(mTempCalendar.get(Calendar.MINUTE)); 211 212 if (!isEnabled()) { 213 setEnabled(false); 214 } 215 216 // set the content descriptions 217 setContentDescriptions(); 218 219 // If not explicitly specified this view is important for accessibility. 220 if (mDelegator.getImportantForAccessibility() == IMPORTANT_FOR_ACCESSIBILITY_AUTO) { 221 mDelegator.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_YES); 222 } 223 } 224 225 private void getHourFormatData() { 226 final String bestDateTimePattern = DateFormat.getBestDateTimePattern(mCurrentLocale, 227 (mIs24HourView) ? "Hm" : "hm"); 228 final int lengthPattern = bestDateTimePattern.length(); 229 mHourWithTwoDigit = false; 230 char hourFormat = '\0'; 231 // Check if the returned pattern is single or double 'H', 'h', 'K', 'k'. We also save 232 // the hour format that we found. 233 for (int i = 0; i < lengthPattern; i++) { 234 final char c = bestDateTimePattern.charAt(i); 235 if (c == 'H' || c == 'h' || c == 'K' || c == 'k') { 236 mHourFormat = c; 237 if (i + 1 < lengthPattern && c == bestDateTimePattern.charAt(i + 1)) { 238 mHourWithTwoDigit = true; 239 } 240 break; 241 } 242 } 243 } 244 245 private boolean isAmPmAtStart() { 246 final String bestDateTimePattern = DateFormat.getBestDateTimePattern(mCurrentLocale, 247 "hm" /* skeleton */); 248 249 return bestDateTimePattern.startsWith("a"); 250 } 251 252 /** 253 * The time separator is defined in the Unicode CLDR and cannot be supposed to be ":". 254 * 255 * See http://unicode.org/cldr/trac/browser/trunk/common/main 256 * 257 * We pass the correct "skeleton" depending on 12 or 24 hours view and then extract the 258 * separator as the character which is just after the hour marker in the returned pattern. 259 */ 260 private void setDividerText() { 261 final String skeleton = (mIs24HourView) ? "Hm" : "hm"; 262 final String bestDateTimePattern = DateFormat.getBestDateTimePattern(mCurrentLocale, 263 skeleton); 264 final String separatorText; 265 int hourIndex = bestDateTimePattern.lastIndexOf('H'); 266 if (hourIndex == -1) { 267 hourIndex = bestDateTimePattern.lastIndexOf('h'); 268 } 269 if (hourIndex == -1) { 270 // Default case 271 separatorText = ":"; 272 } else { 273 int minuteIndex = bestDateTimePattern.indexOf('m', hourIndex + 1); 274 if (minuteIndex == -1) { 275 separatorText = Character.toString(bestDateTimePattern.charAt(hourIndex + 1)); 276 } else { 277 separatorText = bestDateTimePattern.substring(hourIndex + 1, minuteIndex); 278 } 279 } 280 mDivider.setText(separatorText); 281 } 282 283 @Override 284 public void setCurrentHour(Integer currentHour) { 285 setCurrentHour(currentHour, true); 286 } 287 288 private void setCurrentHour(Integer currentHour, boolean notifyTimeChanged) { 289 // why was Integer used in the first place? 290 if (currentHour == null || currentHour == getCurrentHour()) { 291 return; 292 } 293 if (!is24HourView()) { 294 // convert [0,23] ordinal to wall clock display 295 if (currentHour >= HOURS_IN_HALF_DAY) { 296 mIsAm = false; 297 if (currentHour > HOURS_IN_HALF_DAY) { 298 currentHour = currentHour - HOURS_IN_HALF_DAY; 299 } 300 } else { 301 mIsAm = true; 302 if (currentHour == 0) { 303 currentHour = HOURS_IN_HALF_DAY; 304 } 305 } 306 updateAmPmControl(); 307 } 308 mHourSpinner.setValue(currentHour); 309 if (notifyTimeChanged) { 310 onTimeChanged(); 311 } 312 } 313 314 @Override 315 public Integer getCurrentHour() { 316 int currentHour = mHourSpinner.getValue(); 317 if (is24HourView()) { 318 return currentHour; 319 } else if (mIsAm) { 320 return currentHour % HOURS_IN_HALF_DAY; 321 } else { 322 return (currentHour % HOURS_IN_HALF_DAY) + HOURS_IN_HALF_DAY; 323 } 324 } 325 326 @Override 327 public void setCurrentMinute(Integer currentMinute) { 328 if (currentMinute == getCurrentMinute()) { 329 return; 330 } 331 mMinuteSpinner.setValue(currentMinute); 332 onTimeChanged(); 333 } 334 335 @Override 336 public Integer getCurrentMinute() { 337 return mMinuteSpinner.getValue(); 338 } 339 340 @Override 341 public void setIs24HourView(Boolean is24HourView) { 342 if (mIs24HourView == is24HourView) { 343 return; 344 } 345 // cache the current hour since spinner range changes and BEFORE changing mIs24HourView!! 346 int currentHour = getCurrentHour(); 347 // Order is important here. 348 mIs24HourView = is24HourView; 349 getHourFormatData(); 350 updateHourControl(); 351 // set value after spinner range is updated 352 setCurrentHour(currentHour, false); 353 updateMinuteControl(); 354 updateAmPmControl(); 355 } 356 357 @Override 358 public boolean is24HourView() { 359 return mIs24HourView; 360 } 361 362 @Override 363 public void setOnTimeChangedListener(TimePicker.OnTimeChangedListener onTimeChangedListener) { 364 mOnTimeChangedListener = onTimeChangedListener; 365 } 366 367 @Override 368 public void setEnabled(boolean enabled) { 369 mMinuteSpinner.setEnabled(enabled); 370 if (mDivider != null) { 371 mDivider.setEnabled(enabled); 372 } 373 mHourSpinner.setEnabled(enabled); 374 if (mAmPmSpinner != null) { 375 mAmPmSpinner.setEnabled(enabled); 376 } else { 377 mAmPmButton.setEnabled(enabled); 378 } 379 mIsEnabled = enabled; 380 } 381 382 @Override 383 public boolean isEnabled() { 384 return mIsEnabled; 385 } 386 387 @Override 388 public int getBaseline() { 389 return mHourSpinner.getBaseline(); 390 } 391 392 @Override 393 public void onConfigurationChanged(Configuration newConfig) { 394 setCurrentLocale(newConfig.locale); 395 } 396 397 @Override 398 public Parcelable onSaveInstanceState(Parcelable superState) { 399 return new SavedState(superState, getCurrentHour(), getCurrentMinute()); 400 } 401 402 @Override 403 public void onRestoreInstanceState(Parcelable state) { 404 SavedState ss = (SavedState) state; 405 setCurrentHour(ss.getHour()); 406 setCurrentMinute(ss.getMinute()); 407 } 408 409 @Override 410 public boolean dispatchPopulateAccessibilityEvent(AccessibilityEvent event) { 411 onPopulateAccessibilityEvent(event); 412 return true; 413 } 414 415 @Override 416 public void onPopulateAccessibilityEvent(AccessibilityEvent event) { 417 int flags = DateUtils.FORMAT_SHOW_TIME; 418 if (mIs24HourView) { 419 flags |= DateUtils.FORMAT_24HOUR; 420 } else { 421 flags |= DateUtils.FORMAT_12HOUR; 422 } 423 mTempCalendar.set(Calendar.HOUR_OF_DAY, getCurrentHour()); 424 mTempCalendar.set(Calendar.MINUTE, getCurrentMinute()); 425 String selectedDateUtterance = DateUtils.formatDateTime(mContext, 426 mTempCalendar.getTimeInMillis(), flags); 427 event.getText().add(selectedDateUtterance); 428 } 429 430 @Override 431 public void onInitializeAccessibilityEvent(AccessibilityEvent event) { 432 event.setClassName(TimePicker.class.getName()); 433 } 434 435 @Override 436 public void onInitializeAccessibilityNodeInfo(AccessibilityNodeInfo info) { 437 info.setClassName(TimePicker.class.getName()); 438 } 439 440 private void updateInputState() { 441 // Make sure that if the user changes the value and the IME is active 442 // for one of the inputs if this widget, the IME is closed. If the user 443 // changed the value via the IME and there is a next input the IME will 444 // be shown, otherwise the user chose another means of changing the 445 // value and having the IME up makes no sense. 446 InputMethodManager inputMethodManager = InputMethodManager.peekInstance(); 447 if (inputMethodManager != null) { 448 if (inputMethodManager.isActive(mHourSpinnerInput)) { 449 mHourSpinnerInput.clearFocus(); 450 inputMethodManager.hideSoftInputFromWindow(mDelegator.getWindowToken(), 0); 451 } else if (inputMethodManager.isActive(mMinuteSpinnerInput)) { 452 mMinuteSpinnerInput.clearFocus(); 453 inputMethodManager.hideSoftInputFromWindow(mDelegator.getWindowToken(), 0); 454 } else if (inputMethodManager.isActive(mAmPmSpinnerInput)) { 455 mAmPmSpinnerInput.clearFocus(); 456 inputMethodManager.hideSoftInputFromWindow(mDelegator.getWindowToken(), 0); 457 } 458 } 459 } 460 461 private void updateAmPmControl() { 462 if (is24HourView()) { 463 if (mAmPmSpinner != null) { 464 mAmPmSpinner.setVisibility(View.GONE); 465 } else { 466 mAmPmButton.setVisibility(View.GONE); 467 } 468 } else { 469 int index = mIsAm ? Calendar.AM : Calendar.PM; 470 if (mAmPmSpinner != null) { 471 mAmPmSpinner.setValue(index); 472 mAmPmSpinner.setVisibility(View.VISIBLE); 473 } else { 474 mAmPmButton.setText(mAmPmStrings[index]); 475 mAmPmButton.setVisibility(View.VISIBLE); 476 } 477 } 478 mDelegator.sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_SELECTED); 479 } 480 481 /** 482 * Sets the current locale. 483 * 484 * @param locale The current locale. 485 */ 486 @Override 487 public void setCurrentLocale(Locale locale) { 488 super.setCurrentLocale(locale); 489 mTempCalendar = Calendar.getInstance(locale); 490 } 491 492 private void onTimeChanged() { 493 mDelegator.sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_SELECTED); 494 if (mOnTimeChangedListener != null) { 495 mOnTimeChangedListener.onTimeChanged(mDelegator, getCurrentHour(), 496 getCurrentMinute()); 497 } 498 } 499 500 private void updateHourControl() { 501 if (is24HourView()) { 502 // 'k' means 1-24 hour 503 if (mHourFormat == 'k') { 504 mHourSpinner.setMinValue(1); 505 mHourSpinner.setMaxValue(24); 506 } else { 507 mHourSpinner.setMinValue(0); 508 mHourSpinner.setMaxValue(23); 509 } 510 } else { 511 // 'K' means 0-11 hour 512 if (mHourFormat == 'K') { 513 mHourSpinner.setMinValue(0); 514 mHourSpinner.setMaxValue(11); 515 } else { 516 mHourSpinner.setMinValue(1); 517 mHourSpinner.setMaxValue(12); 518 } 519 } 520 mHourSpinner.setFormatter(mHourWithTwoDigit ? NumberPicker.getTwoDigitFormatter() : null); 521 } 522 523 private void updateMinuteControl() { 524 if (is24HourView()) { 525 mMinuteSpinnerInput.setImeOptions(EditorInfo.IME_ACTION_DONE); 526 } else { 527 mMinuteSpinnerInput.setImeOptions(EditorInfo.IME_ACTION_NEXT); 528 } 529 } 530 531 private void setContentDescriptions() { 532 // Minute 533 trySetContentDescription(mMinuteSpinner, R.id.increment, 534 R.string.time_picker_increment_minute_button); 535 trySetContentDescription(mMinuteSpinner, R.id.decrement, 536 R.string.time_picker_decrement_minute_button); 537 // Hour 538 trySetContentDescription(mHourSpinner, R.id.increment, 539 R.string.time_picker_increment_hour_button); 540 trySetContentDescription(mHourSpinner, R.id.decrement, 541 R.string.time_picker_decrement_hour_button); 542 // AM/PM 543 if (mAmPmSpinner != null) { 544 trySetContentDescription(mAmPmSpinner, R.id.increment, 545 R.string.time_picker_increment_set_pm_button); 546 trySetContentDescription(mAmPmSpinner, R.id.decrement, 547 R.string.time_picker_decrement_set_am_button); 548 } 549 } 550 551 private void trySetContentDescription(View root, int viewId, int contDescResId) { 552 View target = root.findViewById(viewId); 553 if (target != null) { 554 target.setContentDescription(mContext.getString(contDescResId)); 555 } 556 } 557 558 /** 559 * Used to save / restore state of time picker 560 */ 561 private static class SavedState extends View.BaseSavedState { 562 private final int mHour; 563 private final int mMinute; 564 565 private SavedState(Parcelable superState, int hour, int minute) { 566 super(superState); 567 mHour = hour; 568 mMinute = minute; 569 } 570 571 private SavedState(Parcel in) { 572 super(in); 573 mHour = in.readInt(); 574 mMinute = in.readInt(); 575 } 576 577 public int getHour() { 578 return mHour; 579 } 580 581 public int getMinute() { 582 return mMinute; 583 } 584 585 @Override 586 public void writeToParcel(Parcel dest, int flags) { 587 super.writeToParcel(dest, flags); 588 dest.writeInt(mHour); 589 dest.writeInt(mMinute); 590 } 591 592 @SuppressWarnings({"unused", "hiding"}) 593 public static final Parcelable.Creator<SavedState> CREATOR = new Creator<SavedState>() { 594 public SavedState createFromParcel(Parcel in) { 595 return new SavedState(in); 596 } 597 598 public SavedState[] newArray(int size) { 599 return new SavedState[size]; 600 } 601 }; 602 } 603 604 public static String[] getAmPmStrings(Context context) { 605 String[] result = new String[2]; 606 LocaleData d = LocaleData.get(context.getResources().getConfiguration().locale); 607 result[0] = d.amPm[0].length() > 2 ? d.narrowAm : d.amPm[0]; 608 result[1] = d.amPm[1].length() > 2 ? d.narrowPm : d.amPm[1]; 609 return result; 610 } 611 } 612