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 TimePickerSpinnerDelegate(TimePicker delegator, Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes)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 validateInput()224 public boolean validateInput() { 225 return true; 226 } 227 getHourFormatData()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 isAmPmAtStart()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 */ setDividerText()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 setDate(int hour, int minute)287 public void setDate(int hour, int minute) { 288 setCurrentHour(hour, false); 289 setCurrentMinute(minute, false); 290 291 onTimeChanged(); 292 } 293 294 @Override setHour(int hour)295 public void setHour(int hour) { 296 setCurrentHour(hour, true); 297 } 298 setCurrentHour(int currentHour, boolean notifyTimeChanged)299 private void setCurrentHour(int currentHour, boolean notifyTimeChanged) { 300 // why was Integer used in the first place? 301 if (currentHour == getHour()) { 302 return; 303 } 304 resetAutofilledValue(); 305 if (!is24Hour()) { 306 // convert [0,23] ordinal to wall clock display 307 if (currentHour >= HOURS_IN_HALF_DAY) { 308 mIsAm = false; 309 if (currentHour > HOURS_IN_HALF_DAY) { 310 currentHour = currentHour - HOURS_IN_HALF_DAY; 311 } 312 } else { 313 mIsAm = true; 314 if (currentHour == 0) { 315 currentHour = HOURS_IN_HALF_DAY; 316 } 317 } 318 updateAmPmControl(); 319 } 320 mHourSpinner.setValue(currentHour); 321 if (notifyTimeChanged) { 322 onTimeChanged(); 323 } 324 } 325 326 @Override getHour()327 public int getHour() { 328 int currentHour = mHourSpinner.getValue(); 329 if (is24Hour()) { 330 return currentHour; 331 } else if (mIsAm) { 332 return currentHour % HOURS_IN_HALF_DAY; 333 } else { 334 return (currentHour % HOURS_IN_HALF_DAY) + HOURS_IN_HALF_DAY; 335 } 336 } 337 338 @Override setMinute(int minute)339 public void setMinute(int minute) { 340 setCurrentMinute(minute, true); 341 } 342 setCurrentMinute(int minute, boolean notifyTimeChanged)343 private void setCurrentMinute(int minute, boolean notifyTimeChanged) { 344 if (minute == getMinute()) { 345 return; 346 } 347 resetAutofilledValue(); 348 mMinuteSpinner.setValue(minute); 349 if (notifyTimeChanged) { 350 onTimeChanged(); 351 } 352 } 353 354 @Override getMinute()355 public int getMinute() { 356 return mMinuteSpinner.getValue(); 357 } 358 setIs24Hour(boolean is24Hour)359 public void setIs24Hour(boolean is24Hour) { 360 if (mIs24HourView == is24Hour) { 361 return; 362 } 363 // cache the current hour since spinner range changes and BEFORE changing mIs24HourView!! 364 int currentHour = getHour(); 365 // Order is important here. 366 mIs24HourView = is24Hour; 367 getHourFormatData(); 368 updateHourControl(); 369 // set value after spinner range is updated 370 setCurrentHour(currentHour, false); 371 updateMinuteControl(); 372 updateAmPmControl(); 373 } 374 375 @Override is24Hour()376 public boolean is24Hour() { 377 return mIs24HourView; 378 } 379 380 @Override setEnabled(boolean enabled)381 public void setEnabled(boolean enabled) { 382 mMinuteSpinner.setEnabled(enabled); 383 if (mDivider != null) { 384 mDivider.setEnabled(enabled); 385 } 386 mHourSpinner.setEnabled(enabled); 387 if (mAmPmSpinner != null) { 388 mAmPmSpinner.setEnabled(enabled); 389 } else { 390 mAmPmButton.setEnabled(enabled); 391 } 392 mIsEnabled = enabled; 393 } 394 395 @Override isEnabled()396 public boolean isEnabled() { 397 return mIsEnabled; 398 } 399 400 @Override getBaseline()401 public int getBaseline() { 402 return mHourSpinner.getBaseline(); 403 } 404 405 @Override onSaveInstanceState(Parcelable superState)406 public Parcelable onSaveInstanceState(Parcelable superState) { 407 return new SavedState(superState, getHour(), getMinute(), is24Hour()); 408 } 409 410 @Override onRestoreInstanceState(Parcelable state)411 public void onRestoreInstanceState(Parcelable state) { 412 if (state instanceof SavedState) { 413 final SavedState ss = (SavedState) state; 414 setHour(ss.getHour()); 415 setMinute(ss.getMinute()); 416 } 417 } 418 419 @Override dispatchPopulateAccessibilityEvent(AccessibilityEvent event)420 public boolean dispatchPopulateAccessibilityEvent(AccessibilityEvent event) { 421 onPopulateAccessibilityEvent(event); 422 return true; 423 } 424 425 @Override onPopulateAccessibilityEvent(AccessibilityEvent event)426 public void onPopulateAccessibilityEvent(AccessibilityEvent event) { 427 int flags = DateUtils.FORMAT_SHOW_TIME; 428 if (mIs24HourView) { 429 flags |= DateUtils.FORMAT_24HOUR; 430 } else { 431 flags |= DateUtils.FORMAT_12HOUR; 432 } 433 mTempCalendar.set(Calendar.HOUR_OF_DAY, getHour()); 434 mTempCalendar.set(Calendar.MINUTE, getMinute()); 435 String selectedDateUtterance = DateUtils.formatDateTime(mContext, 436 mTempCalendar.getTimeInMillis(), flags); 437 event.getText().add(selectedDateUtterance); 438 } 439 440 /** @hide */ 441 @Override 442 @TestApi getHourView()443 public View getHourView() { 444 return mHourSpinnerInput; 445 } 446 447 /** @hide */ 448 @Override 449 @TestApi getMinuteView()450 public View getMinuteView() { 451 return mMinuteSpinnerInput; 452 } 453 454 /** @hide */ 455 @Override 456 @TestApi getAmView()457 public View getAmView() { 458 return mAmPmSpinnerInput; 459 } 460 461 /** @hide */ 462 @Override 463 @TestApi getPmView()464 public View getPmView() { 465 return mAmPmSpinnerInput; 466 } 467 updateInputState()468 private void updateInputState() { 469 // Make sure that if the user changes the value and the IME is active 470 // for one of the inputs if this widget, the IME is closed. If the user 471 // changed the value via the IME and there is a next input the IME will 472 // be shown, otherwise the user chose another means of changing the 473 // value and having the IME up makes no sense. 474 InputMethodManager inputMethodManager = mContext.getSystemService(InputMethodManager.class); 475 if (inputMethodManager != null) { 476 if (inputMethodManager.isActive(mHourSpinnerInput)) { 477 mHourSpinnerInput.clearFocus(); 478 inputMethodManager.hideSoftInputFromWindow(mDelegator.getWindowToken(), 0); 479 } else if (inputMethodManager.isActive(mMinuteSpinnerInput)) { 480 mMinuteSpinnerInput.clearFocus(); 481 inputMethodManager.hideSoftInputFromWindow(mDelegator.getWindowToken(), 0); 482 } else if (inputMethodManager.isActive(mAmPmSpinnerInput)) { 483 mAmPmSpinnerInput.clearFocus(); 484 inputMethodManager.hideSoftInputFromWindow(mDelegator.getWindowToken(), 0); 485 } 486 } 487 } 488 updateAmPmControl()489 private void updateAmPmControl() { 490 if (is24Hour()) { 491 if (mAmPmSpinner != null) { 492 mAmPmSpinner.setVisibility(View.GONE); 493 } else { 494 mAmPmButton.setVisibility(View.GONE); 495 } 496 } else { 497 int index = mIsAm ? Calendar.AM : Calendar.PM; 498 if (mAmPmSpinner != null) { 499 mAmPmSpinner.setValue(index); 500 mAmPmSpinner.setVisibility(View.VISIBLE); 501 } else { 502 mAmPmButton.setText(mAmPmStrings[index]); 503 mAmPmButton.setVisibility(View.VISIBLE); 504 } 505 } 506 mDelegator.sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_SELECTED); 507 } 508 onTimeChanged()509 private void onTimeChanged() { 510 mDelegator.sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_SELECTED); 511 if (mOnTimeChangedListener != null) { 512 mOnTimeChangedListener.onTimeChanged(mDelegator, getHour(), 513 getMinute()); 514 } 515 if (mAutoFillChangeListener != null) { 516 mAutoFillChangeListener.onTimeChanged(mDelegator, getHour(), getMinute()); 517 } 518 } 519 updateHourControl()520 private void updateHourControl() { 521 if (is24Hour()) { 522 // 'k' means 1-24 hour 523 if (mHourFormat == 'k') { 524 mHourSpinner.setMinValue(1); 525 mHourSpinner.setMaxValue(24); 526 } else { 527 mHourSpinner.setMinValue(0); 528 mHourSpinner.setMaxValue(23); 529 } 530 } else { 531 // 'K' means 0-11 hour 532 if (mHourFormat == 'K') { 533 mHourSpinner.setMinValue(0); 534 mHourSpinner.setMaxValue(11); 535 } else { 536 mHourSpinner.setMinValue(1); 537 mHourSpinner.setMaxValue(12); 538 } 539 } 540 mHourSpinner.setFormatter(mHourWithTwoDigit ? NumberPicker.getTwoDigitFormatter() : null); 541 } 542 updateMinuteControl()543 private void updateMinuteControl() { 544 if (is24Hour()) { 545 mMinuteSpinnerInput.setImeOptions(EditorInfo.IME_ACTION_DONE); 546 } else { 547 mMinuteSpinnerInput.setImeOptions(EditorInfo.IME_ACTION_NEXT); 548 } 549 } 550 setContentDescriptions()551 private void setContentDescriptions() { 552 // Minute 553 trySetContentDescription(mMinuteSpinner, R.id.increment, 554 R.string.time_picker_increment_minute_button); 555 trySetContentDescription(mMinuteSpinner, R.id.decrement, 556 R.string.time_picker_decrement_minute_button); 557 // Hour 558 trySetContentDescription(mHourSpinner, R.id.increment, 559 R.string.time_picker_increment_hour_button); 560 trySetContentDescription(mHourSpinner, R.id.decrement, 561 R.string.time_picker_decrement_hour_button); 562 // AM/PM 563 if (mAmPmSpinner != null) { 564 trySetContentDescription(mAmPmSpinner, R.id.increment, 565 R.string.time_picker_increment_set_pm_button); 566 trySetContentDescription(mAmPmSpinner, R.id.decrement, 567 R.string.time_picker_decrement_set_am_button); 568 } 569 } 570 trySetContentDescription(View root, int viewId, int contDescResId)571 private void trySetContentDescription(View root, int viewId, int contDescResId) { 572 View target = root.findViewById(viewId); 573 if (target != null) { 574 target.setContentDescription(mContext.getString(contDescResId)); 575 } 576 } 577 getAmPmStrings(Context context)578 public static String[] getAmPmStrings(Context context) { 579 String[] result = new String[2]; 580 LocaleData d = LocaleData.get(context.getResources().getConfiguration().locale); 581 result[0] = d.amPm[0].length() > 4 ? d.narrowAm : d.amPm[0]; 582 result[1] = d.amPm[1].length() > 4 ? d.narrowPm : d.amPm[1]; 583 return result; 584 } 585 } 586