TimePickerSpinnerDelegate.java revision 8e1a72964517bfd01d8e650453ef41e22f770f21
1/* 2 * Copyright (C) 2013 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17package android.widget; 18 19import static android.view.View.IMPORTANT_FOR_ACCESSIBILITY_AUTO; 20import static android.view.View.IMPORTANT_FOR_ACCESSIBILITY_YES; 21 22import android.annotation.TestApi; 23import android.content.Context; 24import android.content.res.TypedArray; 25import android.os.Parcelable; 26import android.text.format.DateFormat; 27import android.text.format.DateUtils; 28import android.util.AttributeSet; 29import android.view.LayoutInflater; 30import android.view.View; 31import android.view.ViewGroup; 32import android.view.accessibility.AccessibilityEvent; 33import android.view.inputmethod.EditorInfo; 34import android.view.inputmethod.InputMethodManager; 35 36import com.android.internal.R; 37 38import libcore.icu.LocaleData; 39 40import java.util.Calendar; 41 42/** 43 * A delegate implementing the basic spinner-based TimePicker. 44 */ 45class TimePickerSpinnerDelegate extends TimePicker.AbstractTimePickerDelegate { 46 private static final boolean DEFAULT_ENABLED_STATE = true; 47 private static final int HOURS_IN_HALF_DAY = 12; 48 49 private final NumberPicker mHourSpinner; 50 private final NumberPicker mMinuteSpinner; 51 private final NumberPicker mAmPmSpinner; 52 private final EditText mHourSpinnerInput; 53 private final EditText mMinuteSpinnerInput; 54 private final EditText mAmPmSpinnerInput; 55 private final TextView mDivider; 56 57 // Note that the legacy implementation of the TimePicker is 58 // using a button for toggling between AM/PM while the new 59 // version uses a NumberPicker spinner. Therefore the code 60 // accommodates these two cases to be backwards compatible. 61 private final Button mAmPmButton; 62 63 private final String[] mAmPmStrings; 64 65 private final Calendar mTempCalendar; 66 67 private boolean mIsEnabled = DEFAULT_ENABLED_STATE; 68 private boolean mHourWithTwoDigit; 69 private char mHourFormat; 70 71 private boolean mIs24HourView; 72 private boolean mIsAm; 73 74 public TimePickerSpinnerDelegate(TimePicker delegator, Context context, AttributeSet attrs, 75 int defStyleAttr, int defStyleRes) { 76 super(delegator, context); 77 78 // process style attributes 79 final TypedArray a = mContext.obtainStyledAttributes( 80 attrs, R.styleable.TimePicker, defStyleAttr, defStyleRes); 81 final int layoutResourceId = a.getResourceId( 82 R.styleable.TimePicker_legacyLayout, R.layout.time_picker_legacy); 83 a.recycle(); 84 85 final LayoutInflater inflater = LayoutInflater.from(mContext); 86 inflater.inflate(layoutResourceId, mDelegator, true); 87 88 // hour 89 mHourSpinner = delegator.findViewById(R.id.hour); 90 mHourSpinner.setOnValueChangedListener(new NumberPicker.OnValueChangeListener() { 91 public void onValueChange(NumberPicker spinner, int oldVal, int newVal) { 92 updateInputState(); 93 if (!is24Hour()) { 94 if ((oldVal == HOURS_IN_HALF_DAY - 1 && newVal == HOURS_IN_HALF_DAY) || 95 (oldVal == HOURS_IN_HALF_DAY && newVal == HOURS_IN_HALF_DAY - 1)) { 96 mIsAm = !mIsAm; 97 updateAmPmControl(); 98 } 99 } 100 onTimeChanged(); 101 } 102 }); 103 mHourSpinnerInput = mHourSpinner.findViewById(R.id.numberpicker_input); 104 mHourSpinnerInput.setImeOptions(EditorInfo.IME_ACTION_NEXT); 105 106 // divider (only for the new widget style) 107 mDivider = mDelegator.findViewById(R.id.divider); 108 if (mDivider != null) { 109 setDividerText(); 110 } 111 112 // minute 113 mMinuteSpinner = mDelegator.findViewById(R.id.minute); 114 mMinuteSpinner.setMinValue(0); 115 mMinuteSpinner.setMaxValue(59); 116 mMinuteSpinner.setOnLongPressUpdateInterval(100); 117 mMinuteSpinner.setFormatter(NumberPicker.getTwoDigitFormatter()); 118 mMinuteSpinner.setOnValueChangedListener(new NumberPicker.OnValueChangeListener() { 119 public void onValueChange(NumberPicker spinner, int oldVal, int newVal) { 120 updateInputState(); 121 int minValue = mMinuteSpinner.getMinValue(); 122 int maxValue = mMinuteSpinner.getMaxValue(); 123 if (oldVal == maxValue && newVal == minValue) { 124 int newHour = mHourSpinner.getValue() + 1; 125 if (!is24Hour() && newHour == HOURS_IN_HALF_DAY) { 126 mIsAm = !mIsAm; 127 updateAmPmControl(); 128 } 129 mHourSpinner.setValue(newHour); 130 } else if (oldVal == minValue && newVal == maxValue) { 131 int newHour = mHourSpinner.getValue() - 1; 132 if (!is24Hour() && newHour == HOURS_IN_HALF_DAY - 1) { 133 mIsAm = !mIsAm; 134 updateAmPmControl(); 135 } 136 mHourSpinner.setValue(newHour); 137 } 138 onTimeChanged(); 139 } 140 }); 141 mMinuteSpinnerInput = mMinuteSpinner.findViewById(R.id.numberpicker_input); 142 mMinuteSpinnerInput.setImeOptions(EditorInfo.IME_ACTION_NEXT); 143 144 // Get the localized am/pm strings and use them in the spinner. 145 mAmPmStrings = getAmPmStrings(context); 146 147 // am/pm 148 final View amPmView = mDelegator.findViewById(R.id.amPm); 149 if (amPmView instanceof Button) { 150 mAmPmSpinner = null; 151 mAmPmSpinnerInput = null; 152 mAmPmButton = (Button) amPmView; 153 mAmPmButton.setOnClickListener(new View.OnClickListener() { 154 public void onClick(View button) { 155 button.requestFocus(); 156 mIsAm = !mIsAm; 157 updateAmPmControl(); 158 onTimeChanged(); 159 } 160 }); 161 } else { 162 mAmPmButton = null; 163 mAmPmSpinner = (NumberPicker) amPmView; 164 mAmPmSpinner.setMinValue(0); 165 mAmPmSpinner.setMaxValue(1); 166 mAmPmSpinner.setDisplayedValues(mAmPmStrings); 167 mAmPmSpinner.setOnValueChangedListener(new NumberPicker.OnValueChangeListener() { 168 public void onValueChange(NumberPicker picker, int oldVal, int newVal) { 169 updateInputState(); 170 picker.requestFocus(); 171 mIsAm = !mIsAm; 172 updateAmPmControl(); 173 onTimeChanged(); 174 } 175 }); 176 mAmPmSpinnerInput = mAmPmSpinner.findViewById(R.id.numberpicker_input); 177 mAmPmSpinnerInput.setImeOptions(EditorInfo.IME_ACTION_DONE); 178 } 179 180 if (isAmPmAtStart()) { 181 // Move the am/pm view to the beginning 182 ViewGroup amPmParent = delegator.findViewById(R.id.timePickerLayout); 183 amPmParent.removeView(amPmView); 184 amPmParent.addView(amPmView, 0); 185 // Swap layout margins if needed. They may be not symmetrical (Old Standard Theme 186 // for example and not for Holo Theme) 187 ViewGroup.MarginLayoutParams lp = 188 (ViewGroup.MarginLayoutParams) amPmView.getLayoutParams(); 189 final int startMargin = lp.getMarginStart(); 190 final int endMargin = lp.getMarginEnd(); 191 if (startMargin != endMargin) { 192 lp.setMarginStart(endMargin); 193 lp.setMarginEnd(startMargin); 194 } 195 } 196 197 getHourFormatData(); 198 199 // update controls to initial state 200 updateHourControl(); 201 updateMinuteControl(); 202 updateAmPmControl(); 203 204 // set to current time 205 mTempCalendar = Calendar.getInstance(mLocale); 206 setHour(mTempCalendar.get(Calendar.HOUR_OF_DAY)); 207 setMinute(mTempCalendar.get(Calendar.MINUTE)); 208 209 if (!isEnabled()) { 210 setEnabled(false); 211 } 212 213 // set the content descriptions 214 setContentDescriptions(); 215 216 // If not explicitly specified this view is important for accessibility. 217 if (mDelegator.getImportantForAccessibility() == IMPORTANT_FOR_ACCESSIBILITY_AUTO) { 218 mDelegator.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_YES); 219 } 220 } 221 222 @Override 223 public boolean validateInput() { 224 return true; 225 } 226 227 private void getHourFormatData() { 228 final String bestDateTimePattern = DateFormat.getBestDateTimePattern(mLocale, 229 (mIs24HourView) ? "Hm" : "hm"); 230 final int lengthPattern = bestDateTimePattern.length(); 231 mHourWithTwoDigit = false; 232 char hourFormat = '\0'; 233 // Check if the returned pattern is single or double 'H', 'h', 'K', 'k'. We also save 234 // the hour format that we found. 235 for (int i = 0; i < lengthPattern; i++) { 236 final char c = bestDateTimePattern.charAt(i); 237 if (c == 'H' || c == 'h' || c == 'K' || c == 'k') { 238 mHourFormat = c; 239 if (i + 1 < lengthPattern && c == bestDateTimePattern.charAt(i + 1)) { 240 mHourWithTwoDigit = true; 241 } 242 break; 243 } 244 } 245 } 246 247 private boolean isAmPmAtStart() { 248 final String bestDateTimePattern = DateFormat.getBestDateTimePattern(mLocale, 249 "hm" /* skeleton */); 250 251 return bestDateTimePattern.startsWith("a"); 252 } 253 254 /** 255 * The time separator is defined in the Unicode CLDR and cannot be supposed to be ":". 256 * 257 * See http://unicode.org/cldr/trac/browser/trunk/common/main 258 * 259 * We pass the correct "skeleton" depending on 12 or 24 hours view and then extract the 260 * separator as the character which is just after the hour marker in the returned pattern. 261 */ 262 private void setDividerText() { 263 final String skeleton = (mIs24HourView) ? "Hm" : "hm"; 264 final String bestDateTimePattern = DateFormat.getBestDateTimePattern(mLocale, 265 skeleton); 266 final String separatorText; 267 int hourIndex = bestDateTimePattern.lastIndexOf('H'); 268 if (hourIndex == -1) { 269 hourIndex = bestDateTimePattern.lastIndexOf('h'); 270 } 271 if (hourIndex == -1) { 272 // Default case 273 separatorText = ":"; 274 } else { 275 int minuteIndex = bestDateTimePattern.indexOf('m', hourIndex + 1); 276 if (minuteIndex == -1) { 277 separatorText = Character.toString(bestDateTimePattern.charAt(hourIndex + 1)); 278 } else { 279 separatorText = bestDateTimePattern.substring(hourIndex + 1, minuteIndex); 280 } 281 } 282 mDivider.setText(separatorText); 283 } 284 285 @Override 286 public void setHour(int hour) { 287 setCurrentHour(hour, true); 288 } 289 290 private void setCurrentHour(int currentHour, boolean notifyTimeChanged) { 291 // why was Integer used in the first place? 292 if (currentHour == getHour()) { 293 return; 294 } 295 if (!is24Hour()) { 296 // convert [0,23] ordinal to wall clock display 297 if (currentHour >= HOURS_IN_HALF_DAY) { 298 mIsAm = false; 299 if (currentHour > HOURS_IN_HALF_DAY) { 300 currentHour = currentHour - HOURS_IN_HALF_DAY; 301 } 302 } else { 303 mIsAm = true; 304 if (currentHour == 0) { 305 currentHour = HOURS_IN_HALF_DAY; 306 } 307 } 308 updateAmPmControl(); 309 } 310 mHourSpinner.setValue(currentHour); 311 if (notifyTimeChanged) { 312 onTimeChanged(); 313 } 314 } 315 316 @Override 317 public int getHour() { 318 int currentHour = mHourSpinner.getValue(); 319 if (is24Hour()) { 320 return currentHour; 321 } else if (mIsAm) { 322 return currentHour % HOURS_IN_HALF_DAY; 323 } else { 324 return (currentHour % HOURS_IN_HALF_DAY) + HOURS_IN_HALF_DAY; 325 } 326 } 327 328 @Override 329 public void setMinute(int minute) { 330 if (minute == getMinute()) { 331 return; 332 } 333 mMinuteSpinner.setValue(minute); 334 onTimeChanged(); 335 } 336 337 @Override 338 public int getMinute() { 339 return mMinuteSpinner.getValue(); 340 } 341 342 public void setIs24Hour(boolean is24Hour) { 343 if (mIs24HourView == is24Hour) { 344 return; 345 } 346 // cache the current hour since spinner range changes and BEFORE changing mIs24HourView!! 347 int currentHour = getHour(); 348 // Order is important here. 349 mIs24HourView = is24Hour; 350 getHourFormatData(); 351 updateHourControl(); 352 // set value after spinner range is updated 353 setCurrentHour(currentHour, false); 354 updateMinuteControl(); 355 updateAmPmControl(); 356 } 357 358 @Override 359 public boolean is24Hour() { 360 return mIs24HourView; 361 } 362 363 @Override 364 public void setEnabled(boolean enabled) { 365 mMinuteSpinner.setEnabled(enabled); 366 if (mDivider != null) { 367 mDivider.setEnabled(enabled); 368 } 369 mHourSpinner.setEnabled(enabled); 370 if (mAmPmSpinner != null) { 371 mAmPmSpinner.setEnabled(enabled); 372 } else { 373 mAmPmButton.setEnabled(enabled); 374 } 375 mIsEnabled = enabled; 376 } 377 378 @Override 379 public boolean isEnabled() { 380 return mIsEnabled; 381 } 382 383 @Override 384 public int getBaseline() { 385 return mHourSpinner.getBaseline(); 386 } 387 388 @Override 389 public Parcelable onSaveInstanceState(Parcelable superState) { 390 return new SavedState(superState, getHour(), getMinute(), is24Hour()); 391 } 392 393 @Override 394 public void onRestoreInstanceState(Parcelable state) { 395 if (state instanceof SavedState) { 396 final SavedState ss = (SavedState) state; 397 setHour(ss.getHour()); 398 setMinute(ss.getMinute()); 399 } 400 } 401 402 @Override 403 public boolean dispatchPopulateAccessibilityEvent(AccessibilityEvent event) { 404 onPopulateAccessibilityEvent(event); 405 return true; 406 } 407 408 @Override 409 public void onPopulateAccessibilityEvent(AccessibilityEvent event) { 410 int flags = DateUtils.FORMAT_SHOW_TIME; 411 if (mIs24HourView) { 412 flags |= DateUtils.FORMAT_24HOUR; 413 } else { 414 flags |= DateUtils.FORMAT_12HOUR; 415 } 416 mTempCalendar.set(Calendar.HOUR_OF_DAY, getHour()); 417 mTempCalendar.set(Calendar.MINUTE, getMinute()); 418 String selectedDateUtterance = DateUtils.formatDateTime(mContext, 419 mTempCalendar.getTimeInMillis(), flags); 420 event.getText().add(selectedDateUtterance); 421 } 422 423 /** @hide */ 424 @Override 425 @TestApi 426 public View getHourView() { 427 return mHourSpinnerInput; 428 } 429 430 /** @hide */ 431 @Override 432 @TestApi 433 public View getMinuteView() { 434 return mMinuteSpinnerInput; 435 } 436 437 /** @hide */ 438 @Override 439 @TestApi 440 public View getAmView() { 441 return mAmPmSpinnerInput; 442 } 443 444 /** @hide */ 445 @Override 446 @TestApi 447 public View getPmView() { 448 return mAmPmSpinnerInput; 449 } 450 451 private void updateInputState() { 452 // Make sure that if the user changes the value and the IME is active 453 // for one of the inputs if this widget, the IME is closed. If the user 454 // changed the value via the IME and there is a next input the IME will 455 // be shown, otherwise the user chose another means of changing the 456 // value and having the IME up makes no sense. 457 InputMethodManager inputMethodManager = InputMethodManager.peekInstance(); 458 if (inputMethodManager != null) { 459 if (inputMethodManager.isActive(mHourSpinnerInput)) { 460 mHourSpinnerInput.clearFocus(); 461 inputMethodManager.hideSoftInputFromWindow(mDelegator.getWindowToken(), 0); 462 } else if (inputMethodManager.isActive(mMinuteSpinnerInput)) { 463 mMinuteSpinnerInput.clearFocus(); 464 inputMethodManager.hideSoftInputFromWindow(mDelegator.getWindowToken(), 0); 465 } else if (inputMethodManager.isActive(mAmPmSpinnerInput)) { 466 mAmPmSpinnerInput.clearFocus(); 467 inputMethodManager.hideSoftInputFromWindow(mDelegator.getWindowToken(), 0); 468 } 469 } 470 } 471 472 private void updateAmPmControl() { 473 if (is24Hour()) { 474 if (mAmPmSpinner != null) { 475 mAmPmSpinner.setVisibility(View.GONE); 476 } else { 477 mAmPmButton.setVisibility(View.GONE); 478 } 479 } else { 480 int index = mIsAm ? Calendar.AM : Calendar.PM; 481 if (mAmPmSpinner != null) { 482 mAmPmSpinner.setValue(index); 483 mAmPmSpinner.setVisibility(View.VISIBLE); 484 } else { 485 mAmPmButton.setText(mAmPmStrings[index]); 486 mAmPmButton.setVisibility(View.VISIBLE); 487 } 488 } 489 mDelegator.sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_SELECTED); 490 } 491 492 private void onTimeChanged() { 493 mDelegator.sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_SELECTED); 494 if (mOnTimeChangedListener != null) { 495 mOnTimeChangedListener.onTimeChanged(mDelegator, getHour(), 496 getMinute()); 497 } 498 } 499 500 private void updateHourControl() { 501 if (is24Hour()) { 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 (is24Hour()) { 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 public static String[] getAmPmStrings(Context context) { 559 String[] result = new String[2]; 560 LocaleData d = LocaleData.get(context.getResources().getConfiguration().locale); 561 result[0] = d.amPm[0].length() > 4 ? d.narrowAm : d.amPm[0]; 562 result[1] = d.amPm[1].length() > 4 ? d.narrowPm : d.amPm[1]; 563 return result; 564 } 565} 566