TimePickerSpinnerDelegate.java revision 177f37367e271964d5faf70cf19384f23ea8df0b
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 = (NumberPicker) 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 = (EditText) mHourSpinner.findViewById(R.id.numberpicker_input); 104 mHourSpinnerInput.setImeOptions(EditorInfo.IME_ACTION_NEXT); 105 106 // divider (only for the new widget style) 107 mDivider = (TextView) mDelegator.findViewById(R.id.divider); 108 if (mDivider != null) { 109 setDividerText(); 110 } 111 112 // minute 113 mMinuteSpinner = (NumberPicker) 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 = (EditText) 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 = (EditText) 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 = (ViewGroup) 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 private void getHourFormatData() { 223 final String bestDateTimePattern = DateFormat.getBestDateTimePattern(mLocale, 224 (mIs24HourView) ? "Hm" : "hm"); 225 final int lengthPattern = bestDateTimePattern.length(); 226 mHourWithTwoDigit = false; 227 char hourFormat = '\0'; 228 // Check if the returned pattern is single or double 'H', 'h', 'K', 'k'. We also save 229 // the hour format that we found. 230 for (int i = 0; i < lengthPattern; i++) { 231 final char c = bestDateTimePattern.charAt(i); 232 if (c == 'H' || c == 'h' || c == 'K' || c == 'k') { 233 mHourFormat = c; 234 if (i + 1 < lengthPattern && c == bestDateTimePattern.charAt(i + 1)) { 235 mHourWithTwoDigit = true; 236 } 237 break; 238 } 239 } 240 } 241 242 private boolean isAmPmAtStart() { 243 final String bestDateTimePattern = DateFormat.getBestDateTimePattern(mLocale, 244 "hm" /* skeleton */); 245 246 return bestDateTimePattern.startsWith("a"); 247 } 248 249 /** 250 * The time separator is defined in the Unicode CLDR and cannot be supposed to be ":". 251 * 252 * See http://unicode.org/cldr/trac/browser/trunk/common/main 253 * 254 * We pass the correct "skeleton" depending on 12 or 24 hours view and then extract the 255 * separator as the character which is just after the hour marker in the returned pattern. 256 */ 257 private void setDividerText() { 258 final String skeleton = (mIs24HourView) ? "Hm" : "hm"; 259 final String bestDateTimePattern = DateFormat.getBestDateTimePattern(mLocale, 260 skeleton); 261 final String separatorText; 262 int hourIndex = bestDateTimePattern.lastIndexOf('H'); 263 if (hourIndex == -1) { 264 hourIndex = bestDateTimePattern.lastIndexOf('h'); 265 } 266 if (hourIndex == -1) { 267 // Default case 268 separatorText = ":"; 269 } else { 270 int minuteIndex = bestDateTimePattern.indexOf('m', hourIndex + 1); 271 if (minuteIndex == -1) { 272 separatorText = Character.toString(bestDateTimePattern.charAt(hourIndex + 1)); 273 } else { 274 separatorText = bestDateTimePattern.substring(hourIndex + 1, minuteIndex); 275 } 276 } 277 mDivider.setText(separatorText); 278 } 279 280 @Override 281 public void setHour(int hour) { 282 setCurrentHour(hour, true); 283 } 284 285 private void setCurrentHour(int currentHour, boolean notifyTimeChanged) { 286 // why was Integer used in the first place? 287 if (currentHour == getHour()) { 288 return; 289 } 290 if (!is24Hour()) { 291 // convert [0,23] ordinal to wall clock display 292 if (currentHour >= HOURS_IN_HALF_DAY) { 293 mIsAm = false; 294 if (currentHour > HOURS_IN_HALF_DAY) { 295 currentHour = currentHour - HOURS_IN_HALF_DAY; 296 } 297 } else { 298 mIsAm = true; 299 if (currentHour == 0) { 300 currentHour = HOURS_IN_HALF_DAY; 301 } 302 } 303 updateAmPmControl(); 304 } 305 mHourSpinner.setValue(currentHour); 306 if (notifyTimeChanged) { 307 onTimeChanged(); 308 } 309 } 310 311 @Override 312 public int getHour() { 313 int currentHour = mHourSpinner.getValue(); 314 if (is24Hour()) { 315 return currentHour; 316 } else if (mIsAm) { 317 return currentHour % HOURS_IN_HALF_DAY; 318 } else { 319 return (currentHour % HOURS_IN_HALF_DAY) + HOURS_IN_HALF_DAY; 320 } 321 } 322 323 @Override 324 public void setMinute(int minute) { 325 if (minute == getMinute()) { 326 return; 327 } 328 mMinuteSpinner.setValue(minute); 329 onTimeChanged(); 330 } 331 332 @Override 333 public int getMinute() { 334 return mMinuteSpinner.getValue(); 335 } 336 337 public void setIs24Hour(boolean is24Hour) { 338 if (mIs24HourView == is24Hour) { 339 return; 340 } 341 // cache the current hour since spinner range changes and BEFORE changing mIs24HourView!! 342 int currentHour = getHour(); 343 // Order is important here. 344 mIs24HourView = is24Hour; 345 getHourFormatData(); 346 updateHourControl(); 347 // set value after spinner range is updated 348 setCurrentHour(currentHour, false); 349 updateMinuteControl(); 350 updateAmPmControl(); 351 } 352 353 @Override 354 public boolean is24Hour() { 355 return mIs24HourView; 356 } 357 358 @Override 359 public void setEnabled(boolean enabled) { 360 mMinuteSpinner.setEnabled(enabled); 361 if (mDivider != null) { 362 mDivider.setEnabled(enabled); 363 } 364 mHourSpinner.setEnabled(enabled); 365 if (mAmPmSpinner != null) { 366 mAmPmSpinner.setEnabled(enabled); 367 } else { 368 mAmPmButton.setEnabled(enabled); 369 } 370 mIsEnabled = enabled; 371 } 372 373 @Override 374 public boolean isEnabled() { 375 return mIsEnabled; 376 } 377 378 @Override 379 public int getBaseline() { 380 return mHourSpinner.getBaseline(); 381 } 382 383 @Override 384 public Parcelable onSaveInstanceState(Parcelable superState) { 385 return new SavedState(superState, getHour(), getMinute(), is24Hour()); 386 } 387 388 @Override 389 public void onRestoreInstanceState(Parcelable state) { 390 if (state instanceof SavedState) { 391 final SavedState ss = (SavedState) state; 392 setHour(ss.getHour()); 393 setMinute(ss.getMinute()); 394 } 395 } 396 397 @Override 398 public boolean dispatchPopulateAccessibilityEvent(AccessibilityEvent event) { 399 onPopulateAccessibilityEvent(event); 400 return true; 401 } 402 403 @Override 404 public void onPopulateAccessibilityEvent(AccessibilityEvent event) { 405 int flags = DateUtils.FORMAT_SHOW_TIME; 406 if (mIs24HourView) { 407 flags |= DateUtils.FORMAT_24HOUR; 408 } else { 409 flags |= DateUtils.FORMAT_12HOUR; 410 } 411 mTempCalendar.set(Calendar.HOUR_OF_DAY, getHour()); 412 mTempCalendar.set(Calendar.MINUTE, getMinute()); 413 String selectedDateUtterance = DateUtils.formatDateTime(mContext, 414 mTempCalendar.getTimeInMillis(), flags); 415 event.getText().add(selectedDateUtterance); 416 } 417 418 /** @hide */ 419 @Override 420 @TestApi 421 public View getHourView() { 422 return mHourSpinnerInput; 423 } 424 425 /** @hide */ 426 @Override 427 @TestApi 428 public View getMinuteView() { 429 return mMinuteSpinnerInput; 430 } 431 432 /** @hide */ 433 @Override 434 @TestApi 435 public View getAmView() { 436 return mAmPmSpinnerInput; 437 } 438 439 /** @hide */ 440 @Override 441 @TestApi 442 public View getPmView() { 443 return mAmPmSpinnerInput; 444 } 445 446 private void updateInputState() { 447 // Make sure that if the user changes the value and the IME is active 448 // for one of the inputs if this widget, the IME is closed. If the user 449 // changed the value via the IME and there is a next input the IME will 450 // be shown, otherwise the user chose another means of changing the 451 // value and having the IME up makes no sense. 452 InputMethodManager inputMethodManager = InputMethodManager.peekInstance(); 453 if (inputMethodManager != null) { 454 if (inputMethodManager.isActive(mHourSpinnerInput)) { 455 mHourSpinnerInput.clearFocus(); 456 inputMethodManager.hideSoftInputFromWindow(mDelegator.getWindowToken(), 0); 457 } else if (inputMethodManager.isActive(mMinuteSpinnerInput)) { 458 mMinuteSpinnerInput.clearFocus(); 459 inputMethodManager.hideSoftInputFromWindow(mDelegator.getWindowToken(), 0); 460 } else if (inputMethodManager.isActive(mAmPmSpinnerInput)) { 461 mAmPmSpinnerInput.clearFocus(); 462 inputMethodManager.hideSoftInputFromWindow(mDelegator.getWindowToken(), 0); 463 } 464 } 465 } 466 467 private void updateAmPmControl() { 468 if (is24Hour()) { 469 if (mAmPmSpinner != null) { 470 mAmPmSpinner.setVisibility(View.GONE); 471 } else { 472 mAmPmButton.setVisibility(View.GONE); 473 } 474 } else { 475 int index = mIsAm ? Calendar.AM : Calendar.PM; 476 if (mAmPmSpinner != null) { 477 mAmPmSpinner.setValue(index); 478 mAmPmSpinner.setVisibility(View.VISIBLE); 479 } else { 480 mAmPmButton.setText(mAmPmStrings[index]); 481 mAmPmButton.setVisibility(View.VISIBLE); 482 } 483 } 484 mDelegator.sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_SELECTED); 485 } 486 487 private void onTimeChanged() { 488 mDelegator.sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_SELECTED); 489 if (mOnTimeChangedListener != null) { 490 mOnTimeChangedListener.onTimeChanged(mDelegator, getHour(), 491 getMinute()); 492 } 493 } 494 495 private void updateHourControl() { 496 if (is24Hour()) { 497 // 'k' means 1-24 hour 498 if (mHourFormat == 'k') { 499 mHourSpinner.setMinValue(1); 500 mHourSpinner.setMaxValue(24); 501 } else { 502 mHourSpinner.setMinValue(0); 503 mHourSpinner.setMaxValue(23); 504 } 505 } else { 506 // 'K' means 0-11 hour 507 if (mHourFormat == 'K') { 508 mHourSpinner.setMinValue(0); 509 mHourSpinner.setMaxValue(11); 510 } else { 511 mHourSpinner.setMinValue(1); 512 mHourSpinner.setMaxValue(12); 513 } 514 } 515 mHourSpinner.setFormatter(mHourWithTwoDigit ? NumberPicker.getTwoDigitFormatter() : null); 516 } 517 518 private void updateMinuteControl() { 519 if (is24Hour()) { 520 mMinuteSpinnerInput.setImeOptions(EditorInfo.IME_ACTION_DONE); 521 } else { 522 mMinuteSpinnerInput.setImeOptions(EditorInfo.IME_ACTION_NEXT); 523 } 524 } 525 526 private void setContentDescriptions() { 527 // Minute 528 trySetContentDescription(mMinuteSpinner, R.id.increment, 529 R.string.time_picker_increment_minute_button); 530 trySetContentDescription(mMinuteSpinner, R.id.decrement, 531 R.string.time_picker_decrement_minute_button); 532 // Hour 533 trySetContentDescription(mHourSpinner, R.id.increment, 534 R.string.time_picker_increment_hour_button); 535 trySetContentDescription(mHourSpinner, R.id.decrement, 536 R.string.time_picker_decrement_hour_button); 537 // AM/PM 538 if (mAmPmSpinner != null) { 539 trySetContentDescription(mAmPmSpinner, R.id.increment, 540 R.string.time_picker_increment_set_pm_button); 541 trySetContentDescription(mAmPmSpinner, R.id.decrement, 542 R.string.time_picker_decrement_set_am_button); 543 } 544 } 545 546 private void trySetContentDescription(View root, int viewId, int contDescResId) { 547 View target = root.findViewById(viewId); 548 if (target != null) { 549 target.setContentDescription(mContext.getString(contDescResId)); 550 } 551 } 552 553 public static String[] getAmPmStrings(Context context) { 554 String[] result = new String[2]; 555 LocaleData d = LocaleData.get(context.getResources().getConfiguration().locale); 556 result[0] = d.amPm[0].length() > 4 ? d.narrowAm : d.amPm[0]; 557 result[1] = d.amPm[1].length() > 4 ? d.narrowPm : d.amPm[1]; 558 return result; 559 } 560} 561