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