Skip to content

Commit d2cce65

Browse files
paulfthomasdrchen
authored andcommitted
[TimePicker][a11y] Error handling update
- properly show the chip background color when selected - add error state with message when the hour/minute is incorrect, giving the user a visual feedback instead of just preventing inputting an incorrect value - add vibration and beep on error or max length reached PiperOrigin-RevId: 799921970
1 parent fca1418 commit d2cce65

File tree

12 files changed

+278
-223
lines changed

12 files changed

+278
-223
lines changed

catalog/java/io/material/catalog/AndroidManifest.xml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@
2222

2323
<uses-sdk />
2424

25+
<uses-permission android:name="android.permission.VIBRATE"/>
26+
2527
<application
2628
android:name="io.material.catalog.application.${application_name}"
2729
android:allowBackup="true"

lib/java/com/google/android/material/timepicker/ChipTextInputComboView.java

Lines changed: 94 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
import static com.google.android.material.timepicker.TimePickerView.GENERIC_VIEW_ACCESSIBILITY_CLASS_NAME;
2323

2424
import android.content.Context;
25+
import android.content.res.ColorStateList;
2526
import android.content.res.Configuration;
2627
import android.os.Build.VERSION;
2728
import android.os.Build.VERSION_CODES;
@@ -36,13 +37,15 @@
3637
import android.widget.EditText;
3738
import android.widget.FrameLayout;
3839
import android.widget.TextView;
40+
import androidx.annotation.ColorInt;
3941
import androidx.annotation.NonNull;
4042
import androidx.annotation.Nullable;
4143
import androidx.annotation.VisibleForTesting;
4244
import androidx.core.view.AccessibilityDelegateCompat;
4345
import androidx.core.view.ViewCompat;
4446
import androidx.core.view.accessibility.AccessibilityNodeInfoCompat;
4547
import com.google.android.material.chip.Chip;
48+
import com.google.android.material.color.MaterialColors;
4649
import com.google.android.material.internal.TextWatcherAdapter;
4750
import com.google.android.material.internal.ViewUtils;
4851
import com.google.android.material.textfield.TextInputLayout;
@@ -59,6 +62,15 @@ class ChipTextInputComboView extends FrameLayout implements Checkable {
5962
private final EditText editText;
6063
private TextWatcher watcher;
6164
private TextView label;
65+
private CharSequence chipText = "";
66+
67+
private boolean hasError = false;
68+
private ColorStateList originalChipBackgroundColor;
69+
private ColorStateList originalChipTextColor;
70+
private ColorStateList originalEditTextColor;
71+
private ColorStateList originalEditTextCursorColor;
72+
private ColorStateList originalLabelColor;
73+
@ColorInt private int originalChipStrokeColor;
6274

6375
public ChipTextInputComboView(@NonNull Context context) {
6476
this(context, null);
@@ -105,10 +117,14 @@ public boolean isChecked() {
105117
@Override
106118
public void setChecked(boolean checked) {
107119
chip.setChecked(checked);
120+
if (checked) {
121+
chip.setText("");
122+
chip.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_NO);
123+
} else {
124+
chip.setText(chipText);
125+
chip.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_YES);
126+
}
108127
editText.setVisibility(checked ? VISIBLE : INVISIBLE);
109-
// TODO(b/247609386) Should not hide chip, we need the background in M3 (but not M2...).
110-
// Instead, the text in chip should be hidden.
111-
chip.setVisibility(checked ? GONE : VISIBLE);
112128
if (isChecked()) {
113129
ViewUtils.requestFocusAndShowKeyboard(editText, /* useWindowInsetsController= */ false);
114130
}
@@ -121,30 +137,21 @@ public void toggle() {
121137

122138
public void setText(CharSequence text) {
123139
String formattedText = formatText(text);
140+
chipText = formattedText;
124141
chip.setText(formattedText);
125142
if (!isEmpty(formattedText)) {
126143
editText.removeTextChangedListener(watcher);
127144

128145
editText.setText(formattedText);
129-
ViewCompat.setAccessibilityDelegate(
130-
editText,
131-
new AccessibilityDelegateCompat() {
132-
@Override
133-
public void onInitializeAccessibilityNodeInfo(
134-
@NonNull View host, @NonNull AccessibilityNodeInfoCompat info) {
135-
super.onInitializeAccessibilityNodeInfo(host, info);
136-
info.setText(formattedText);
137-
info.setHintText(label.getText());
138-
}
139-
});
146+
setAccessibilityDelegate(editText, chipText.toString(), label.getText());
140147

141148
editText.addTextChangedListener(watcher);
142149
}
143150
}
144151

145152
@VisibleForTesting
146153
CharSequence getChipText() {
147-
return chip.getText();
154+
return chipText;
148155
}
149156

150157
private String formatText(CharSequence text) {
@@ -184,21 +191,91 @@ public void setChipDelegate(AccessibilityDelegateCompat clickActionDelegate) {
184191
ViewCompat.setAccessibilityDelegate(chip, clickActionDelegate);
185192
}
186193

194+
public void setError(boolean hasError) {
195+
if (this.hasError == hasError) {
196+
return;
197+
}
198+
this.hasError = hasError;
199+
200+
if (hasError) {
201+
applyErrorColors();
202+
} else {
203+
clearErrorColors();
204+
}
205+
}
206+
207+
private void applyErrorColors() {
208+
originalChipBackgroundColor = chip.getChipBackgroundColor();
209+
originalChipTextColor = chip.getTextColors();
210+
originalEditTextColor = editText.getTextColors();
211+
originalLabelColor = label.getTextColors();
212+
originalChipStrokeColor = textInputLayout.getBoxStrokeColor();
213+
214+
// TODO(b/394610420): tokens and ColorStateList with error state
215+
ColorStateList colorErrorContainer =
216+
MaterialColors.getColorStateListOrNull(getContext(), R.attr.colorErrorContainer);
217+
ColorStateList colorOnErrorContainer =
218+
MaterialColors.getColorStateListOrNull(getContext(), R.attr.colorOnErrorContainer);
219+
if (colorErrorContainer != null && colorOnErrorContainer != null) {
220+
chip.setChipBackgroundColor(colorErrorContainer);
221+
chip.setTextColor(colorOnErrorContainer);
222+
editText.setTextColor(colorOnErrorContainer);
223+
textInputLayout.setBoxStrokeColor(colorOnErrorContainer.getDefaultColor());
224+
label.setTextColor(colorOnErrorContainer);
225+
if (VERSION.SDK_INT >= VERSION_CODES.Q) {
226+
originalEditTextCursorColor = textInputLayout.getCursorColor();
227+
textInputLayout.setCursorColor(colorOnErrorContainer);
228+
}
229+
}
230+
}
231+
232+
private void clearErrorColors() {
233+
chip.setChipBackgroundColor(originalChipBackgroundColor);
234+
chip.setTextColor(originalChipTextColor);
235+
editText.setTextColor(originalEditTextColor);
236+
textInputLayout.setBoxStrokeColor(originalChipStrokeColor);
237+
label.setTextColor(originalLabelColor);
238+
if (VERSION.SDK_INT >= VERSION_CODES.Q) {
239+
textInputLayout.setCursorColor(originalEditTextCursorColor);
240+
}
241+
}
242+
243+
public boolean hasError() {
244+
return hasError;
245+
}
246+
187247
private class TextFormatter extends TextWatcherAdapter {
188248

189249
private static final String DEFAULT_TEXT = "00";
190250

191251
@Override
192252
public void afterTextChanged(Editable editable) {
193253
if (isEmpty(editable)) {
194-
chip.setText(formatText(DEFAULT_TEXT));
254+
chipText = formatText(DEFAULT_TEXT);
195255
return;
196256
}
197257
String formattedText = formatText(editable);
198-
chip.setText(isEmpty(formattedText) ? formatText(DEFAULT_TEXT) : formattedText);
258+
chipText = isEmpty(formattedText) ? formatText(DEFAULT_TEXT) : formattedText;
259+
setAccessibilityDelegate(editText, editable.toString(), label.getText());
199260
}
200261
}
201262

263+
private void setAccessibilityDelegate(
264+
@NonNull View view, CharSequence text, @Nullable CharSequence hint) {
265+
ViewCompat.setAccessibilityDelegate(
266+
view,
267+
new AccessibilityDelegateCompat() {
268+
@Override
269+
public void onInitializeAccessibilityNodeInfo(
270+
@NonNull View host, @NonNull AccessibilityNodeInfoCompat info) {
271+
super.onInitializeAccessibilityNodeInfo(host, info);
272+
info.setText(text);
273+
info.setHintText(hint);
274+
info.setMaxTextLength(2);
275+
}
276+
});
277+
}
278+
202279
@Override
203280
protected void onConfigurationChanged(Configuration newConfig) {
204281
super.onConfigurationChanged(newConfig);

lib/java/com/google/android/material/timepicker/MaterialTimePicker.java

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ public final class MaterialTimePicker extends DialogFragment implements OnDouble
113113
static final String OVERRIDE_THEME_RES_ID = "TIME_PICKER_OVERRIDE_THEME_RES_ID";
114114

115115
private MaterialButton modeButton;
116+
private Button okButton;
116117
private Button cancelButton;
117118

118119
@InputMode private int inputMode = INPUT_MODE_CLOCK;
@@ -275,6 +276,8 @@ public final View onCreateView(
275276
timePickerView.setOnDoubleTapListener(this);
276277
textInputStub = root.findViewById(R.id.material_textinput_timepicker);
277278
modeButton = root.findViewById(R.id.material_timepicker_mode_button);
279+
okButton = root.findViewById(R.id.material_timepicker_ok_button);
280+
cancelButton = root.findViewById(R.id.material_timepicker_cancel_button);
278281
TextView headerTitle = root.findViewById(R.id.header_title);
279282

280283
if (titleResId != 0) {
@@ -284,24 +287,26 @@ public final View onCreateView(
284287
}
285288

286289
updateInputMode(modeButton);
287-
Button okButton = root.findViewById(R.id.material_timepicker_ok_button);
288290
okButton.setOnClickListener(
289-
new OnClickListener() {
290-
@Override
291-
public void onClick(View v) {
292-
for (OnClickListener listener : positiveButtonListeners) {
293-
listener.onClick(v);
291+
v -> {
292+
if (activePresenter instanceof TimePickerTextInputPresenter) {
293+
TimePickerTextInputPresenter presenter = (TimePickerTextInputPresenter) activePresenter;
294+
if (presenter.hasError()) {
295+
presenter.vibrateAndMaybeBeep(root.getContext());
296+
return;
294297
}
295-
dismiss();
296298
}
299+
for (OnClickListener listener : positiveButtonListeners) {
300+
listener.onClick(v);
301+
}
302+
dismiss();
297303
});
298304
if (positiveButtonTextResId != 0) {
299305
okButton.setText(positiveButtonTextResId);
300306
} else if (!TextUtils.isEmpty(positiveButtonText)) {
301307
okButton.setText(positiveButtonText);
302308
}
303309

304-
cancelButton = root.findViewById(R.id.material_timepicker_cancel_button);
305310
cancelButton.setOnClickListener(
306311
v -> {
307312
for (OnClickListener listener : negativeButtonListeners) {
@@ -430,6 +435,7 @@ private TimePickerPresenter initializeOrRetrieveActivePresenterForMode(
430435
timePickerTextInputPresenter = new TimePickerTextInputPresenter(textInputView, time);
431436
}
432437

438+
timePickerTextInputPresenter.clearError();
433439
timePickerTextInputPresenter.clearCheck();
434440

435441
return timePickerTextInputPresenter;

lib/java/com/google/android/material/timepicker/MaxInputValidator.java

Lines changed: 0 additions & 54 deletions
This file was deleted.

lib/java/com/google/android/material/timepicker/TimeModel.java

Lines changed: 12 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,6 @@ class TimeModel implements Parcelable {
4040
public static final String ZERO_LEADING_NUMBER_FORMAT = "%02d";
4141
public static final String NUMBER_FORMAT = "%d";
4242

43-
private final MaxInputValidator minuteInputValidator;
44-
private final MaxInputValidator hourInputValidator;
45-
4643
@TimeFormat final int format;
4744

4845
int hour;
@@ -65,8 +62,6 @@ public TimeModel(int hour, int minute, @ActiveSelection int selection, @TimeForm
6562
this.selection = selection;
6663
this.format = format;
6764
period = getPeriod(hour);
68-
minuteInputValidator = new MaxInputValidator(59);
69-
hourInputValidator = new MaxInputValidator(format == CLOCK_24H ? 23 : 12);
7065
}
7166

7267
protected TimeModel(Parcel in) {
@@ -119,14 +114,6 @@ public int getHourContentDescriptionResId() {
119114
return format == CLOCK_24H ? R.string.material_hour_24h_suffix : R.string.material_hour_suffix;
120115
}
121116

122-
public MaxInputValidator getMinuteInputValidator() {
123-
return minuteInputValidator;
124-
}
125-
126-
public MaxInputValidator getHourInputValidator() {
127-
return hourInputValidator;
128-
}
129-
130117
@Override
131118
public int hashCode() {
132119
Object[] hashedFields = {format, hour, minute, selection};
@@ -164,17 +151,18 @@ public void writeToParcel(Parcel dest, int flags) {
164151
}
165152

166153
@SuppressWarnings("unused")
167-
public static final Parcelable.Creator<TimeModel> CREATOR = new Parcelable.Creator<TimeModel>() {
168-
@Override
169-
public TimeModel createFromParcel(Parcel in) {
170-
return new TimeModel(in);
171-
}
172-
173-
@Override
174-
public TimeModel[] newArray(int size) {
175-
return new TimeModel[size];
176-
}
177-
};
154+
public static final Parcelable.Creator<TimeModel> CREATOR =
155+
new Parcelable.Creator<TimeModel>() {
156+
@Override
157+
public TimeModel createFromParcel(Parcel in) {
158+
return new TimeModel(in);
159+
}
160+
161+
@Override
162+
public TimeModel[] newArray(int size) {
163+
return new TimeModel[size];
164+
}
165+
};
178166

179167
public void setPeriod(@ClockPeriod int period) {
180168
if (period != this.period) {

lib/java/com/google/android/material/timepicker/TimePickerTextInputKeyController.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,8 @@ private boolean onHourKeyPress(int keyCode, KeyEvent event, EditText editText) {
131131
&& keyCode <= KeyEvent.KEYCODE_9
132132
&& event.getAction() == KeyEvent.ACTION_UP
133133
&& editText.getSelectionStart() == 2
134-
&& text.length() == 2;
134+
&& text.length() == 2
135+
&& !hourLayoutComboView.hasError();
135136
if (switchFocus) {
136137
moveSelection(MINUTE);
137138
return true;

0 commit comments

Comments
 (0)