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