Skip to content

Commit 8dd9c7e

Browse files
leticiarossipekingme
authored andcommittedJun 8, 2022
[Checkbox] Added support for error state.
PiperOrigin-RevId: 453496914
1 parent 5c0003c commit 8dd9c7e

File tree

10 files changed

+320
-17
lines changed

10 files changed

+320
-17
lines changed
 

‎docs/components/Checkbox.md

+39-5
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,13 @@ Material Components for Android library. For more information, go to the
3535
[Getting started](https://github.com/material-components/material-components-android/tree/master/docs/getting-started.md)
3636
page.
3737

38+
```xml
39+
<CheckBox
40+
android:layout_width="match_parent"
41+
android:layout_height="wrap_content"
42+
android:text="@string/label"/>
43+
```
44+
3845
**Note:** `<CheckBox>` is auto-inflated as
3946
`<com.google.android.material.button.MaterialCheckBox>` via
4047
`MaterialComponentsViewInflater` when using a `Theme.Material3.*`
@@ -47,6 +54,33 @@ screen readers, such as TalkBack. Text rendered in check boxes is automatically
4754
provided to accessibility services. Additional content labels are usually
4855
unnecessary.
4956

57+
### Setting the error state on checkbox
58+
59+
In the layout:
60+
61+
```xml
62+
<CheckBox
63+
...
64+
app:errorShown="true"/>
65+
```
66+
67+
In code:
68+
69+
```kt
70+
// Set error.
71+
checkbox.errorShown = true
72+
73+
// Optional listener:
74+
checkbox.addOnErrorChangedListener { checkBox, errorShown ->
75+
// Responds to when the checkbox enters/leaves error state
76+
}
77+
}
78+
79+
// To set a custom accessibility label:
80+
checkbox.errorAccessibilityLabel = "Error: custom error announcement."
81+
82+
```
83+
5084
## Checkbox
5185

5286
A checkbox is a square button with a check to denote its current state.
@@ -75,24 +109,24 @@ In the layout:
75109
```xml
76110
<CheckBox
77111
android:layout_width="match_parent"
78-
android:layout_height="match_parent"
112+
android:layout_height="wrap_content"
79113
android:checked="true"
80114
android:text="@string/label_1"/>
81115
<CheckBox
82116
android:layout_width="match_parent"
83-
android:layout_height="match_parent"
117+
android:layout_height="wrap_content"
84118
android:text="@string/label_2"/>
85119
<CheckBox
86120
android:layout_width="match_parent"
87-
android:layout_height="match_parent"
121+
android:layout_height="wrap_content"
88122
android:text="@string/label_3"/>
89123
<CheckBox
90124
android:layout_width="match_parent"
91-
android:layout_height="match_parent"
125+
android:layout_height="wrap_content"
92126
android:text="@string/label_4"/>
93127
<CheckBox
94128
android:layout_width="match_parent"
95-
android:layout_height="match_parent"
129+
android:layout_height="wrap_content"
96130
android:enabled="false"
97131
android:text="@string/label_5"/>
98132
```

‎lib/java/com/google/android/material/checkbox/MaterialCheckBox.java

+155-10
Original file line numberDiff line numberDiff line change
@@ -29,13 +29,17 @@
2929
import androidx.appcompat.widget.AppCompatCheckBox;
3030
import android.text.TextUtils;
3131
import android.util.AttributeSet;
32+
import android.view.accessibility.AccessibilityNodeInfo;
33+
import androidx.annotation.NonNull;
3234
import androidx.annotation.Nullable;
35+
import androidx.annotation.StringRes;
3336
import androidx.core.graphics.drawable.DrawableCompat;
3437
import androidx.core.widget.CompoundButtonCompat;
3538
import com.google.android.material.color.MaterialColors;
3639
import com.google.android.material.internal.ThemeEnforcement;
3740
import com.google.android.material.internal.ViewUtils;
3841
import com.google.android.material.resources.MaterialResources;
42+
import java.util.LinkedHashSet;
3943

4044
/**
4145
* A class that creates a Material Themed CheckBox.
@@ -49,16 +53,36 @@ public class MaterialCheckBox extends AppCompatCheckBox {
4953

5054
private static final int DEF_STYLE_RES =
5155
R.style.Widget_MaterialComponents_CompoundButton_CheckBox;
52-
private static final int[][] ENABLED_CHECKED_STATES =
56+
private static final int[] ERROR_STATE_SET = {R.attr.state_error};
57+
private static final int[][] CHECKBOX_STATES =
5358
new int[][] {
54-
new int[] {android.R.attr.state_enabled, android.R.attr.state_checked}, // [0]
55-
new int[] {android.R.attr.state_enabled, -android.R.attr.state_checked}, // [1]
56-
new int[] {-android.R.attr.state_enabled, android.R.attr.state_checked}, // [2]
57-
new int[] {-android.R.attr.state_enabled, -android.R.attr.state_checked} // [3]
59+
new int[] {android.R.attr.state_enabled, R.attr.state_error}, // [0]
60+
new int[] {android.R.attr.state_enabled, android.R.attr.state_checked}, // [1]
61+
new int[] {android.R.attr.state_enabled, -android.R.attr.state_checked}, // [2]
62+
new int[] {-android.R.attr.state_enabled, android.R.attr.state_checked}, // [3]
63+
new int[] {-android.R.attr.state_enabled, -android.R.attr.state_checked} // [4]
5864
};
65+
@NonNull private final LinkedHashSet<OnErrorChangedListener> onErrorChangedListeners =
66+
new LinkedHashSet<>();
5967
@Nullable private ColorStateList materialThemeColorsTintList;
6068
private boolean useMaterialThemeColors;
6169
private boolean centerIfNoTextEnabled;
70+
private boolean errorShown;
71+
private CharSequence errorAccessibilityLabel;
72+
73+
/**
74+
* Callback interface invoked when the checkbox error state changes.
75+
*/
76+
public interface OnErrorChangedListener {
77+
78+
/**
79+
* Called when the error state of a checkbox changes.
80+
*
81+
* @param checkBox the {@link MaterialCheckBox}
82+
* @param errorShown whether the checkbox is on error
83+
*/
84+
void onErrorChanged(@NonNull MaterialCheckBox checkBox, boolean errorShown);
85+
}
6286

6387
public MaterialCheckBox(Context context) {
6488
this(context, null);
@@ -90,6 +114,9 @@ public MaterialCheckBox(Context context, @Nullable AttributeSet attrs, int defSt
90114
attributes.getBoolean(R.styleable.MaterialCheckBox_useMaterialThemeColors, false);
91115
centerIfNoTextEnabled =
92116
attributes.getBoolean(R.styleable.MaterialCheckBox_centerIfNoTextEnabled, true);
117+
errorShown = attributes.getBoolean(R.styleable.MaterialCheckBox_errorShown, false);
118+
errorAccessibilityLabel =
119+
attributes.getText(R.styleable.MaterialCheckBox_errorAccessibilityLabel);
93120

94121
attributes.recycle();
95122
}
@@ -130,6 +157,121 @@ protected void onAttachedToWindow() {
130157
}
131158
}
132159

160+
@Override
161+
protected int[] onCreateDrawableState(int extraSpace) {
162+
final int[] drawableStates = super.onCreateDrawableState(extraSpace + 1);
163+
164+
if (isErrorShown()) {
165+
mergeDrawableStates(drawableStates, ERROR_STATE_SET);
166+
}
167+
168+
return drawableStates;
169+
}
170+
171+
@Override
172+
public void onInitializeAccessibilityNodeInfo(@Nullable AccessibilityNodeInfo info) {
173+
super.onInitializeAccessibilityNodeInfo(info);
174+
if (info == null) {
175+
return;
176+
}
177+
178+
if (isErrorShown()) {
179+
info.setText(info.getText() + ", " + errorAccessibilityLabel);
180+
}
181+
}
182+
183+
/**
184+
* Sets whether the checkbox should be on error state. If true, the error color will be applied to
185+
* the checkbox.
186+
*
187+
* @param errorShown whether the checkbox should be on error state.
188+
* @see #isErrorShown()
189+
* @attr ref com.google.android.material.R.styleable#MaterialCheckBox_errorShown
190+
*/
191+
public void setErrorShown(boolean errorShown) {
192+
if (this.errorShown == errorShown) {
193+
return;
194+
}
195+
this.errorShown = errorShown;
196+
refreshDrawableState();
197+
for (OnErrorChangedListener listener : onErrorChangedListeners) {
198+
listener.onErrorChanged(this, this.errorShown);
199+
}
200+
}
201+
202+
/**
203+
* Returns whether the checkbox is on error state.
204+
*
205+
* @see #setErrorShown(boolean)
206+
* @attr ref com.google.android.material.R.styleable#MaterialCheckBox_errorShown
207+
*/
208+
public boolean isErrorShown() {
209+
return errorShown;
210+
}
211+
212+
/**
213+
* Sets the accessibility label to be used for the error state announcement by screen readers.
214+
*
215+
* @param resId resource ID of the error announcement text
216+
* @see #setErrorShown(boolean)
217+
* @see #getErrorAccessibilityLabel()
218+
* @attr ref com.google.android.material.R.styleable#MaterialCheckBox_errorAccessibilityLabel
219+
*/
220+
public void setErrorAccessibilityLabelResource(@StringRes int resId) {
221+
setErrorAccessibilityLabel(resId != 0 ? getResources().getText(resId) : null);
222+
}
223+
224+
/**
225+
* Sets the accessibility label to be used for the error state announcement by screen readers.
226+
*
227+
* @param errorAccessibilityLabel the error announcement
228+
* @see #setErrorShown(boolean)
229+
* @see #getErrorAccessibilityLabel()
230+
* @attr ref com.google.android.material.R.styleable#MaterialCheckBox_errorAccessibilityLabel
231+
*/
232+
public void setErrorAccessibilityLabel(@Nullable CharSequence errorAccessibilityLabel) {
233+
this.errorAccessibilityLabel = errorAccessibilityLabel;
234+
}
235+
236+
/**
237+
* Returns the accessibility label used for the error state announcement.
238+
*
239+
* @see #setErrorAccessibilityLabel(CharSequence)
240+
* @attr ref com.google.android.material.R.styleable#MaterialCheckBox_errorAccessibilityLabel
241+
*/
242+
@Nullable
243+
public CharSequence getErrorAccessibilityLabel() {
244+
return errorAccessibilityLabel;
245+
}
246+
247+
/**
248+
* Adds a {@link OnErrorChangedListener} that will be invoked when the checkbox error state
249+
* changes.
250+
*
251+
* <p>Components that add a listener should take care to remove it when finished via {@link
252+
* #removeOnErrorChangedListener(OnErrorChangedListener)}.
253+
*
254+
* @param listener listener to add
255+
*/
256+
public void addOnErrorChangedListener(@NonNull OnErrorChangedListener listener) {
257+
onErrorChangedListeners.add(listener);
258+
}
259+
260+
/**
261+
* Remove a listener that was previously added via {@link
262+
* #addOnErrorChangedListener(OnErrorChangedListener)}
263+
*
264+
* @param listener listener to remove
265+
*/
266+
public void removeOnErrorChangedListener(@NonNull OnErrorChangedListener listener) {
267+
onErrorChangedListeners.remove(listener);
268+
}
269+
270+
/** Remove all previously added {@link OnErrorChangedListener}s. */
271+
public void clearOnErrorChangedListeners() {
272+
onErrorChangedListeners.clear();
273+
}
274+
133275
/**
134276
* Forces the {@link MaterialCheckBox} to use colors from a Material Theme. Overrides any
135277
* specified ButtonTintList. If set to false, sets the tints to null. Use {@link
@@ -167,21 +309,24 @@ public boolean isCenterIfNoTextEnabled() {
167309

168310
private ColorStateList getMaterialThemeColorsTintList() {
169311
if (materialThemeColorsTintList == null) {
170-
int[] checkBoxColorsList = new int[ENABLED_CHECKED_STATES.length];
312+
int[] checkBoxColorsList = new int[CHECKBOX_STATES.length];
171313
int colorControlActivated = MaterialColors.getColor(this, R.attr.colorControlActivated);
314+
int colorError = MaterialColors.getColor(this, R.attr.colorError);
172315
int colorSurface = MaterialColors.getColor(this, R.attr.colorSurface);
173316
int colorOnSurface = MaterialColors.getColor(this, R.attr.colorOnSurface);
174317

175318
checkBoxColorsList[0] =
176-
MaterialColors.layer(colorSurface, colorControlActivated, MaterialColors.ALPHA_FULL);
319+
MaterialColors.layer(colorSurface, colorError, MaterialColors.ALPHA_FULL);
177320
checkBoxColorsList[1] =
178-
MaterialColors.layer(colorSurface, colorOnSurface, MaterialColors.ALPHA_MEDIUM);
321+
MaterialColors.layer(colorSurface, colorControlActivated, MaterialColors.ALPHA_FULL);
179322
checkBoxColorsList[2] =
180-
MaterialColors.layer(colorSurface, colorOnSurface, MaterialColors.ALPHA_DISABLED);
323+
MaterialColors.layer(colorSurface, colorOnSurface, MaterialColors.ALPHA_MEDIUM);
181324
checkBoxColorsList[3] =
182325
MaterialColors.layer(colorSurface, colorOnSurface, MaterialColors.ALPHA_DISABLED);
326+
checkBoxColorsList[4] =
327+
MaterialColors.layer(colorSurface, colorOnSurface, MaterialColors.ALPHA_DISABLED);
183328

184-
materialThemeColorsTintList = new ColorStateList(ENABLED_CHECKED_STATES, checkBoxColorsList);
329+
materialThemeColorsTintList = new ColorStateList(CHECKBOX_STATES, checkBoxColorsList);
185330
}
186331
return materialThemeColorsTintList;
187332
}

‎lib/java/com/google/android/material/checkbox/res-public/values/public.xml

+3
Original file line numberDiff line numberDiff line change
@@ -20,4 +20,7 @@
2020
<public name="Widget.Material3.CompoundButton.CheckBox" type="style"/>
2121

2222
<public name="centerIfNoTextEnabled" type="attr"/>
23+
<public name="errorShown" type="attr"/>
24+
<public name="errorAccessibilityLabel" type="attr"/>
25+
<public name="state_error" type="attr"/>
2326
</resources>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<!--
3+
~ Copyright (C) 2022 The Android Open Source Project
4+
~
5+
~ Licensed under the Apache License, Version 2.0 (the "License");
6+
~ you may not use this file except in compliance with the License.
7+
~ You may obtain a copy of the License at
8+
~
9+
~ http://www.apache.org/licenses/LICENSE-2.0
10+
~
11+
~ Unless required by applicable law or agreed to in writing, software
12+
~ distributed under the License is distributed on an "AS IS" BASIS,
13+
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
~ See the License for the specific language governing permissions and
15+
~ limitations under the License.
16+
-->
17+
<selector xmlns:android="http://schemas.android.com/apk/res/android"
18+
xmlns:app="http://schemas.android.com/apk/res-auto">
19+
<!-- Disabled -->
20+
<item android:alpha="@dimen/material_emphasis_disabled" android:color="?attr/colorOnSurface" android:state_enabled="false"/>
21+
22+
<!-- Error -->
23+
<item android:color="?attr/colorError" app:state_error="true"/>
24+
25+
<!-- Checked -->
26+
<item android:color="?attr/colorPrimary" android:state_checked="true"/>
27+
28+
<!-- Unchecked -->
29+
<item android:color="?attr/colorOnSurface" android:state_checked="false"/>
30+
</selector>

‎lib/java/com/google/android/material/checkbox/res/values/attrs.xml

+13
Original file line numberDiff line numberDiff line change
@@ -26,5 +26,18 @@
2626
<!-- Whether the MaterialCheckBox button drawable (checkbox icon) will be
2727
centered when there is no text set for the checkbox. Default is true. -->
2828
<attr name="centerIfNoTextEnabled" format="boolean"/>
29+
<!-- Whether the checkbox should be in the error state. If true, it'll be
30+
tinted with the error color. -->
31+
<attr name="errorShown" format="boolean"/>
32+
<!-- The error accessibility label that is announced by screen readers when
33+
the checkbox is in the error state. -->
34+
<attr name="errorAccessibilityLabel" format="string"/>
35+
</declare-styleable>
36+
37+
<declare-styleable name="MaterialCheckBoxStates">
38+
<!-- Error state of the checkbox. Behaves as part of existing states; a
39+
checkbox is always one of checked/unchecked and can also be on error or
40+
not. -->
41+
<attr name="state_error" format="boolean"/>
2942
</declare-styleable>
3043
</resources>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<!--
3+
~ Copyright (C) 2022 The Android Open Source Project
4+
~
5+
~ Licensed under the Apache License, Version 2.0 (the "License");
6+
~ you may not use this file except in compliance with the License.
7+
~ You may obtain a copy of the License at
8+
~
9+
~ http://www.apache.org/licenses/LICENSE-2.0
10+
~
11+
~ Unless required by applicable law or agreed to in writing, software
12+
~ distributed under the License is distributed on an "AS IS" BASIS,
13+
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
~ See the License for the specific language governing permissions and
15+
~ limitations under the License.
16+
-->
17+
<resources>
18+
<!-- Following the "Error: <error message>" pattern of the edit text. -->
19+
<string name="error_a11y_label" description="Screen reader announcement for when a checkbox is on error. [CHAR LIMIT=NONE]">
20+
Error: invalid
21+
</string>
22+
</resources>

‎lib/java/com/google/android/material/checkbox/res/values/styles.xml

+2-1
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,8 @@
2626
<style name="Base.Widget.Material3.CompoundButton.CheckBox" parent="Widget.MaterialComponents.CompoundButton.CheckBox">
2727
<!-- Inherit default text color since the component doesn't draw a surface. -->
2828
<item name="android:textAppearance">?attr/textAppearanceBodyMedium</item>
29-
<item name="buttonTint">@color/m3_selection_control_button_tint</item>
29+
<item name="buttonTint">@color/m3_checkbox_button_tint</item>
30+
<item name="errorAccessibilityLabel">@string/error_a11y_label</item>
3031
</style>
3132

3233
<style name="Widget.Material3.CompoundButton.CheckBox" parent="Base.Widget.Material3.CompoundButton.CheckBox" />

‎lib/javatests/com/google/android/material/checkbox/MaterialCheckBoxTest.java

+48
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919
import com.google.android.material.test.R;
2020

2121
import static com.google.common.truth.Truth.assertThat;
22+
import static org.mockito.Mockito.never;
23+
import static org.mockito.Mockito.verify;
2224

2325
import android.content.res.ColorStateList;
2426
import android.graphics.Color;
@@ -28,10 +30,12 @@
2830
import android.view.View;
2931
import android.widget.CheckBox;
3032
import androidx.core.widget.CompoundButtonCompat;
33+
import com.google.android.material.checkbox.MaterialCheckBox.OnErrorChangedListener;
3134
import com.google.android.material.color.MaterialColors;
3235
import org.junit.Before;
3336
import org.junit.Test;
3437
import org.junit.runner.RunWith;
38+
import org.mockito.Mockito;
3539
import org.robolectric.Robolectric;
3640
import org.robolectric.RobolectricTestRunner;
3741
import org.robolectric.annotation.Config;
@@ -41,15 +45,56 @@ public class MaterialCheckBoxTest {
4145

4246
private static final int[] STATE_CHECKED =
4347
new int[] {android.R.attr.state_enabled, android.R.attr.state_checked};
48+
private static final int[] STATE_ERROR =
49+
new int[] {android.R.attr.state_enabled, R.attr.state_error};
4450
private static final int[] STATE_UNCHECKED = new int[] {android.R.attr.state_enabled};
4551

4652
private AppCompatActivity activity;
4753
private View checkboxes;
54+
private MaterialCheckBox materialCheckBox;
4855

4956
@Before
5057
public void createAndThemeApplicationContext() {
5158
activity = Robolectric.buildActivity(TestActivity.class).setup().get();
5259
checkboxes = activity.getLayoutInflater().inflate(R.layout.test_design_checkbox, null);
60+
materialCheckBox = checkboxes.findViewById(R.id.test_checkbox);
61+
}
62+
63+
@Test
64+
public void testSetError_succeeds() {
65+
materialCheckBox.setErrorShown(true);
66+
67+
assertThat(materialCheckBox.isErrorShown()).isTrue();
68+
}
69+
70+
@Test
71+
public void testSetError_callsListener() {
72+
OnErrorChangedListener mockListener = Mockito.mock(OnErrorChangedListener.class);
73+
materialCheckBox.setErrorShown(false);
74+
materialCheckBox.addOnErrorChangedListener(mockListener);
75+
76+
materialCheckBox.setErrorShown(true);
77+
78+
verify(mockListener).onErrorChanged(materialCheckBox, /* errorShown= */ true);
79+
}
80+
81+
@Test
82+
public void testSetError_withSameValue_doesNotCallListener() {
83+
OnErrorChangedListener mockListener = Mockito.mock(OnErrorChangedListener.class);
84+
materialCheckBox.setErrorShown(false);
85+
materialCheckBox.addOnErrorChangedListener(mockListener);
86+
87+
materialCheckBox.setErrorShown(false);
88+
89+
verify(mockListener, never()).onErrorChanged(materialCheckBox, /* errorShown= */ false);
90+
}
91+
92+
@Test
93+
public void testSetErrorA11yLabel_succeeds() {
94+
materialCheckBox.setErrorAccessibilityLabel("error");
95+
96+
assertThat(materialCheckBox.getErrorAccessibilityLabel()).isNotNull();
97+
assertThat(materialCheckBox.getErrorAccessibilityLabel().toString()).isEqualTo("error");
5398
}
5499

55100
@Test
@@ -75,8 +120,11 @@ public void testThemeableAndroidButtonTint() {
75120
*/
76121
static void testThemeableButtonTint(CheckBox checkBox) {
77122
ColorStateList buttonTintList = CompoundButtonCompat.getButtonTintList(checkBox);
123+
78124
assertThat(buttonTintList.getColorForState(STATE_CHECKED, Color.BLACK))
79125
.isEqualTo(MaterialColors.getColor(checkBox, R.attr.colorControlActivated));
126+
assertThat(buttonTintList.getColorForState(STATE_ERROR, Color.BLACK))
127+
.isEqualTo(MaterialColors.getColor(checkBox, R.attr.colorError));
80128
assertThat(buttonTintList.getColorForState(STATE_UNCHECKED, Color.BLACK))
81129
.isEqualTo(MaterialColors.getColor(checkBox, R.attr.colorOnSurface));
82130
}

‎lib/javatests/com/google/android/material/checkbox/res/color/checkbox_themeable_attribute_color.xml

+3-1
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@
1414
limitations under the License.
1515
-->
1616

17-
<selector xmlns:android="http://schemas.android.com/apk/res/android">
17+
<selector xmlns:android="http://schemas.android.com/apk/res/android"
18+
xmlns:app="http://schemas.android.com/apk/res-auto">
19+
<item android:color="?attr/colorError" app:state_error="true"/>
1820
<item android:color="?attr/colorControlActivated" android:state_checked="true"/>
1921
<item android:color="?attr/colorOnSurface"/>
2022
</selector>

‎lib/javatests/com/google/android/material/checkbox/res/layout/test_design_checkbox.xml

+5
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,11 @@
1919
xmlns:tools="http://schemas.android.com/tools"
2020
android:layout_width="wrap_content"
2121
android:layout_height="wrap_content">
22+
<com.google.android.material.checkbox.MaterialCheckBox
23+
android:id="@+id/test_checkbox"
24+
android:layout_width="wrap_content"
25+
android:layout_height="wrap_content"/>
26+
2227
<CheckBox
2328
android:id="@+id/test_checkbox_android_button_tint"
2429
android:layout_width="wrap_content"

0 commit comments

Comments
 (0)
Please sign in to comment.