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