From 4da7ce1a4136e5dcea08df080e75344de05e5981 Mon Sep 17 00:00:00 2001 From: leticiars Date: Fri, 5 Aug 2022 13:47:19 -0400 Subject: [PATCH] [Checkbox] Added indeterminate state support to the checkbox. A MaterialCheckBox can now be checked, unchecked, or indeterminate. Resolves https://github.com/material-components/material-components-android/issues/857 PiperOrigin-RevId: 465598224 (cherry picked from commit 8f149b10dcebf807157c313f483f167b75f0c73f) --- .../checkbox/CheckBoxMainDemoFragment.java | 66 ++++ .../checkbox/res/layout/cat_checkbox.xml | 73 ++++- .../catalog/checkbox/res/values/strings.xml | 8 + docs/components/Checkbox.md | 28 +- .../material/checkbox/MaterialCheckBox.java | 297 +++++++++++++++++- .../checkbox/res-public/values/public.xml | 2 + .../color/m3_checkbox_button_icon_tint.xml | 3 + .../res/color/m3_checkbox_button_tint.xml | 3 + .../res/drawable/mtrl_checkbox_button.xml | 14 + .../drawable/mtrl_checkbox_button_icon.xml | 23 ++ ...kbox_button_icon_checked_indeterminate.xml | 33 ++ ...kbox_button_icon_indeterminate_checked.xml | 33 ++ ...ox_button_icon_indeterminate_unchecked.xml | 55 ++++ ...ox_button_icon_unchecked_indeterminate.xml | 55 ++++ .../res/drawable/mtrl_ic_indeterminate.xml | 33 ++ .../material/checkbox/res/values/attrs.xml | 16 +- .../material/checkbox/res/values/strings.xml | 13 + .../checkbox/MaterialCheckBoxTest.java | 141 ++++++++- 18 files changed, 874 insertions(+), 22 deletions(-) create mode 100644 lib/java/com/google/android/material/checkbox/res/drawable/mtrl_checkbox_button_icon_checked_indeterminate.xml create mode 100644 lib/java/com/google/android/material/checkbox/res/drawable/mtrl_checkbox_button_icon_indeterminate_checked.xml create mode 100644 lib/java/com/google/android/material/checkbox/res/drawable/mtrl_checkbox_button_icon_indeterminate_unchecked.xml create mode 100644 lib/java/com/google/android/material/checkbox/res/drawable/mtrl_checkbox_button_icon_unchecked_indeterminate.xml create mode 100644 lib/java/com/google/android/material/checkbox/res/drawable/mtrl_ic_indeterminate.xml diff --git a/catalog/java/io/material/catalog/checkbox/CheckBoxMainDemoFragment.java b/catalog/java/io/material/catalog/checkbox/CheckBoxMainDemoFragment.java index 78b9c324db8..b74c54cb58c 100644 --- a/catalog/java/io/material/catalog/checkbox/CheckBoxMainDemoFragment.java +++ b/catalog/java/io/material/catalog/checkbox/CheckBoxMainDemoFragment.java @@ -24,8 +24,10 @@ import android.view.ViewGroup; import android.widget.CheckBox; import android.widget.CompoundButton; +import androidx.annotation.NonNull; import androidx.annotation.Nullable; import com.google.android.material.checkbox.MaterialCheckBox; +import com.google.android.material.checkbox.MaterialCheckBox.OnCheckedStateChangedListener; import io.material.catalog.feature.DemoFragment; import io.material.catalog.feature.DemoUtils; import java.util.List; @@ -33,6 +35,8 @@ /** A fragment that displays the main Checkbox demos for the Catalog app. */ public class CheckBoxMainDemoFragment extends DemoFragment { + private boolean isUpdatingChildren = false; + @Override public View onCreateDemoView( LayoutInflater layoutInflater, @Nullable ViewGroup viewGroup, @Nullable Bundle bundle) { @@ -57,6 +61,68 @@ public View onCreateDemoView( cb.setErrorShown(isChecked); } }); + + CheckBox firstChild = view.findViewById(R.id.checkbox_child_1); + firstChild.setChecked(true); + ViewGroup indeterminateContainer = view.findViewById(R.id.checkbox_indeterminate_container); + List childrenCheckBoxes = + DemoUtils.findViewsWithType(indeterminateContainer, CheckBox.class); + MaterialCheckBox checkBoxParent = view.findViewById(R.id.checkbox_parent); + OnCheckedStateChangedListener parentOnCheckedStateChangedListener = + (checkBox, state) -> { + boolean isChecked = checkBox.isChecked(); + if (state != MaterialCheckBox.STATE_INDETERMINATE) { + isUpdatingChildren = true; + for (CheckBox child : childrenCheckBoxes) { + child.setChecked(isChecked); + } + isUpdatingChildren = false; + } + }; + checkBoxParent.addOnCheckedStateChangedListener(parentOnCheckedStateChangedListener); + + OnCheckedStateChangedListener childOnCheckedStateChangedListener = + (checkBox, state) -> { + if (isUpdatingChildren) { + return; + } + setParentState(checkBoxParent, childrenCheckBoxes, parentOnCheckedStateChangedListener); + }; + + for (CheckBox child : childrenCheckBoxes) { + ((MaterialCheckBox) child) + .addOnCheckedStateChangedListener(childOnCheckedStateChangedListener); + } + + setParentState(checkBoxParent, childrenCheckBoxes, parentOnCheckedStateChangedListener); + return view; } + + private void setParentState( + @NonNull MaterialCheckBox checkBoxParent, + @NonNull List childrenCheckBoxes, + @NonNull OnCheckedStateChangedListener parentOnCheckedStateChangedListener) { + boolean allChecked = true; + boolean noneChecked = true; + for (CheckBox child : childrenCheckBoxes) { + if (!child.isChecked()) { + allChecked = false; + } else { + noneChecked = false; + } + if (!allChecked && !noneChecked) { + break; + } + } + checkBoxParent.removeOnCheckedStateChangedListener(parentOnCheckedStateChangedListener); + if (allChecked) { + checkBoxParent.setChecked(true); + } else if (noneChecked) { + checkBoxParent.setChecked(false); + } else { + checkBoxParent.setCheckedState(MaterialCheckBox.STATE_INDETERMINATE); + } + checkBoxParent.addOnCheckedStateChangedListener(parentOnCheckedStateChangedListener); + } } diff --git a/catalog/java/io/material/catalog/checkbox/res/layout/cat_checkbox.xml b/catalog/java/io/material/catalog/checkbox/res/layout/cat_checkbox.xml index ad0f86ca0e0..47a33c28af4 100644 --- a/catalog/java/io/material/catalog/checkbox/res/layout/cat_checkbox.xml +++ b/catalog/java/io/material/catalog/checkbox/res/layout/cat_checkbox.xml @@ -16,10 +16,10 @@ limitations under the License. --> - + android:layout_height="match_parent" + xmlns:app="http://schemas.android.com/apk/res-auto"> + + + + + + + + + + + + + + + + + + + Controlled by a checkbox + + The checkboxes below have a parent/child relationship. The parent\'s state + depends on the state of the children. + + + Parent checkbox + + Child checkbox Below is a checkbox with no text. diff --git a/docs/components/Checkbox.md b/docs/components/Checkbox.md index bfe695473d9..65851b2cc6f 100644 --- a/docs/components/Checkbox.md +++ b/docs/components/Checkbox.md @@ -73,7 +73,6 @@ checkbox.errorShown = true // Optional listener: checkbox.addOnErrorChangedListener { checkBox, errorShown -> // Responds to when the checkbox enters/leaves error state - } } // To set a custom accessibility label: @@ -81,6 +80,30 @@ checkbox.errorAccessibilityLabel = "Error: custom error announcement." ``` +### Making a checkbox indeterminate + +In the layout: + +```xml + +``` + +In code: + +```kt +// You can set the state of the checkbox (STATE_CHECKED, STATE_UNCHECKED, +// or STATE_INDETERMINATE) via setCheckedState. +checkBox.setCheckedState(MaterialCheckbox.STATE_INDETERMINATE); + +// Checkbox state listener. +checkbox.addOnCheckedStateChangedListener { checkBox, state -> + // Responds to when the checkbox changes state. +} +``` + + ## Checkbox A checkbox is a square button with a check to denote its current state. @@ -193,9 +216,6 @@ enabled, disabled, hover, focused, and pressed states. pressed. Rows are selected, unselected, or indeterminite](assets/checkbox/checkbox_states.png) -**Note:** `MaterialCheckBox` does not support the indeterminate state. Only -selected and unselected states are supported. - ### Styles Element | Style diff --git a/lib/java/com/google/android/material/checkbox/MaterialCheckBox.java b/lib/java/com/google/android/material/checkbox/MaterialCheckBox.java index aec35bdbdfe..1f0e17d23ae 100644 --- a/lib/java/com/google/android/material/checkbox/MaterialCheckBox.java +++ b/lib/java/com/google/android/material/checkbox/MaterialCheckBox.java @@ -18,6 +18,7 @@ import com.google.android.material.R; +import static androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP; import static com.google.android.material.theme.overlay.MaterialThemeOverlay.wrap; import android.annotation.SuppressLint; @@ -32,15 +33,21 @@ import android.graphics.drawable.Drawable; import android.os.Build.VERSION; import android.os.Build.VERSION_CODES; +import android.os.Parcel; +import android.os.Parcelable; import androidx.appcompat.content.res.AppCompatResources; import androidx.appcompat.widget.AppCompatCheckBox; import androidx.appcompat.widget.TintTypedArray; import android.text.TextUtils; import android.util.AttributeSet; import android.view.accessibility.AccessibilityNodeInfo; +import android.view.autofill.AutofillManager; import androidx.annotation.DrawableRes; +import androidx.annotation.IntDef; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; +import androidx.annotation.RestrictTo; import androidx.annotation.StringRes; import androidx.core.graphics.drawable.DrawableCompat; import androidx.core.widget.CompoundButtonCompat; @@ -52,13 +59,16 @@ import com.google.android.material.internal.ThemeEnforcement; import com.google.android.material.internal.ViewUtils; import com.google.android.material.resources.MaterialResources; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; import java.util.LinkedHashSet; /** * A class that creates a Material Themed CheckBox. * *

This class uses attributes from the Material Theme to style a CheckBox. It behaves similarly - * to {@link AppCompatCheckBox}, but with color changes and the support of an error state. + * to {@link AppCompatCheckBox}, but with color changes and the support of the indeterminate state, + * and an independent error state. * *

The checkbox is composed of an {@code app:buttonCompat} button drawable (the squared icon) and * an {@code app:buttonIcon} icon drawable (the checkmark icon) layered on top of it. Their colors @@ -67,13 +77,48 @@ *

If setting a custom {@code app:buttonCompat}, make sure to also set {@code app:buttonIcon} if * an icon is desired. The checkbox does not support having a custom {@code app:buttonCompat} and * preserving the default {@code app:buttonIcon} checkmark at the same time. - * */ public class MaterialCheckBox extends AppCompatCheckBox { private static final int DEF_STYLE_RES = R.style.Widget_MaterialComponents_CompoundButton_CheckBox; + /** + * Values for the state of the checkbox. The checkbox can be unchecked, checked, or indeterminate. + * + * @hide + */ + @RestrictTo(LIBRARY_GROUP) + @IntDef({STATE_UNCHECKED, STATE_CHECKED, STATE_INDETERMINATE}) + @Retention(RetentionPolicy.SOURCE) + public @interface CheckedState {} + + /** + * The unchecked state of the checkbox. A checkbox is unchecked by default. + * + * @see #setCheckedState(int) + * @see #getCheckedState() + */ + public static final int STATE_UNCHECKED = 0; + + /** + * The checked state of the checkbox. + * + * @see #setCheckedState(int) + * @see #getCheckedState() + */ + public static final int STATE_CHECKED = 1; + + /** + * The indeterminate state of the checkbox. + * + * @see #setCheckedState(int) + * @see #getCheckedState() + */ + public static final int STATE_INDETERMINATE = 2; + + // Attributes that represent each state. They're used to modify the checkbox drawable. + private static final int[] INDETERMINATE_STATE_SET = {R.attr.state_indeterminate}; private static final int[] ERROR_STATE_SET = {R.attr.state_error}; private static final int[][] CHECKBOX_STATES = new int[][] { @@ -90,11 +135,15 @@ public class MaterialCheckBox extends AppCompatCheckBox { @NonNull private final LinkedHashSet onErrorChangedListeners = new LinkedHashSet<>(); + @NonNull + private final LinkedHashSet onCheckedStateChangedListeners = + new LinkedHashSet<>(); + @Nullable private ColorStateList materialThemeColorsTintList; private boolean useMaterialThemeColors; private boolean centerIfNoTextEnabled; private boolean errorShown; - private CharSequence errorAccessibilityLabel; + @Nullable private CharSequence errorAccessibilityLabel; @Nullable private Drawable buttonDrawable; @Nullable private Drawable buttonIconDrawable; @@ -104,7 +153,12 @@ public class MaterialCheckBox extends AppCompatCheckBox { @Nullable ColorStateList buttonIconTintList; @NonNull private PorterDuff.Mode buttonIconTintMode; + @CheckedState + private int checkedState; private int[] currentStateChecked; + private boolean broadcasting; + @Nullable private CharSequence customStateDescription; + @Nullable private OnCheckedChangeListener onCheckedChangeListener; @Nullable private final AnimatedVectorDrawableCompat transitionToUnchecked = @@ -135,6 +189,22 @@ public void onAnimationEnd(Drawable drawable) { } }; + /** + * Callback interface invoked when one of three independent checkbox states change. + * + * @see #setCheckedState(int) + */ + public interface OnCheckedStateChangedListener { + + /** + * Called when the checked/indeterminate/unchecked state of a checkbox changes. + * + * @param checkBox the {@link MaterialCheckBox} + * @param state the new state of the checkbox + */ + void onCheckedStateChangedListener(@NonNull MaterialCheckBox checkBox, @CheckedState int state); + } + /** * Callback interface invoked when the checkbox error state changes. */ @@ -197,6 +267,10 @@ && isButtonDrawableLegacy(attributes)) { errorShown = attributes.getBoolean(R.styleable.MaterialCheckBox_errorShown, false); errorAccessibilityLabel = attributes.getText(R.styleable.MaterialCheckBox_errorAccessibilityLabel); + if (attributes.hasValue(R.styleable.MaterialCheckBox_checkedState)) { + setCheckedState( + attributes.getInt(R.styleable.MaterialCheckBox_checkedState, STATE_UNCHECKED)); + } attributes.recycle(); @@ -252,7 +326,11 @@ protected void onAttachedToWindow() { @Override protected int[] onCreateDrawableState(int extraSpace) { - final int[] drawableStates = super.onCreateDrawableState(extraSpace + 1); + final int[] drawableStates = super.onCreateDrawableState(extraSpace + 2); + + if (getCheckedState() == STATE_INDETERMINATE) { + mergeDrawableStates(drawableStates, INDETERMINATE_STATE_SET); + } if (isErrorShown()) { mergeDrawableStates(drawableStates, ERROR_STATE_SET); @@ -267,6 +345,26 @@ protected int[] onCreateDrawableState(int extraSpace) { return drawableStates; } + @Override + public void setChecked(boolean checked) { + setCheckedState(checked ? STATE_CHECKED : STATE_UNCHECKED); + } + + @Override + public boolean isChecked() { + return checkedState == STATE_CHECKED; + } + + @Override + public void toggle() { + setChecked(!isChecked()); + } + + @Override + public void setOnCheckedChangeListener(@Nullable OnCheckedChangeListener listener) { + onCheckedChangeListener = listener; + } + @Override public void onInitializeAccessibilityNodeInfo(@Nullable AccessibilityNodeInfo info) { super.onInitializeAccessibilityNodeInfo(info); @@ -279,6 +377,83 @@ public void onInitializeAccessibilityNodeInfo(@Nullable AccessibilityNodeInfo in } } + /** + * Sets the {@link CheckedState} of the checkbox. + * + * @param checkedState the checked, unchecked, or indeterminate state to be set + * @see #getCheckedState() + */ + public void setCheckedState(@CheckedState int checkedState) { + if (this.checkedState != checkedState) { + this.checkedState = checkedState; + super.setChecked(this.checkedState == STATE_CHECKED); + refreshDrawableState(); + setDefaultStateDescription(); + + // Avoid infinite recursions if setCheckedState is called from a listener. + if (broadcasting) { + return; + } + + broadcasting = true; + if (onCheckedStateChangedListeners != null) { + for (OnCheckedStateChangedListener listener : onCheckedStateChangedListeners) { + listener.onCheckedStateChangedListener(/* checkBox= */ this, this.checkedState); + } + } + if (this.checkedState != STATE_INDETERMINATE && onCheckedChangeListener != null) { + onCheckedChangeListener.onCheckedChanged(/* buttonView= */ this, isChecked()); + } + if (VERSION.SDK_INT >= VERSION_CODES.O) { + final AutofillManager autofillManager = getContext() + .getSystemService(AutofillManager.class); + if (autofillManager != null) { + autofillManager.notifyValueChanged(/* view= */ this); + } + } + + broadcasting = false; + } + } + + /** + * Returns the current checkbox state. + * + * @see #setCheckedState(int) + */ + @CheckedState + public int getCheckedState() { + return checkedState; + } + + /** + * Adds a {@link OnCheckedStateChangedListener} that will be invoked when the checkbox state + * changes. + * + *

Components that add a listener should take care to remove it when finished via {@link + * #removeOnCheckedStateChangedListener(OnCheckedStateChangedListener)}. + * + * @param listener listener to add + */ + public void addOnCheckedStateChangedListener(@NonNull OnCheckedStateChangedListener listener) { + onCheckedStateChangedListeners.add(listener); + } + + /** + * Removes a listener that was previously added via {@link + * #addOnCheckedStateChangedListener(OnCheckedStateChangedListener)} + * + * @param listener listener to remove + */ + public void removeOnCheckedStateChangedListener(@NonNull OnCheckedStateChangedListener listener) { + onCheckedStateChangedListeners.remove(listener); + } + + /** Removes all previously added {@link OnCheckedStateChangedListener}s. */ + public void clearOnCheckedStateChangedListeners() { + onCheckedStateChangedListeners.clear(); + } + /** * Sets whether the checkbox should be on error state. If true, the error color will be applied to * the checkbox. @@ -591,6 +766,9 @@ private void setUpDefaultButtonDrawableAnimationIfNeeded() { ((AnimatedStateListDrawable) buttonDrawable) .addTransition( R.id.checked, R.id.unchecked, transitionToUnchecked, /* reversible= */ false); + ((AnimatedStateListDrawable) buttonDrawable) + .addTransition( + R.id.indeterminate, R.id.unchecked, transitionToUnchecked, /* reversible= */ false); } } @@ -604,6 +782,34 @@ private void updateButtonTints() { } } + @RequiresApi(VERSION_CODES.R) + @Override + public void setStateDescription(@Nullable CharSequence stateDescription) { + customStateDescription = stateDescription; + if (stateDescription == null) { + setDefaultStateDescription(); + } else { + super.setStateDescription(stateDescription); + } + } + + private void setDefaultStateDescription() { + if (VERSION.SDK_INT >= VERSION_CODES.R && customStateDescription == null) { + super.setStateDescription(getButtonStateDescription()); + } + } + + @NonNull + private String getButtonStateDescription() { + if (checkedState == STATE_CHECKED) { + return getResources().getString(R.string.mtrl_checkbox_state_description_checked); + } else if (checkedState == STATE_UNCHECKED) { + return getResources().getString(R.string.mtrl_checkbox_state_description_unchecked); + } else { + return getResources().getString(R.string.mtrl_checkbox_state_description_indeterminate); + } + } + @Nullable private ColorStateList getSuperButtonTintList() { if (buttonTintList != null) { @@ -650,4 +856,87 @@ private ColorStateList getMaterialThemeColorsTintList() { } return materialThemeColorsTintList; } + + @Override + @Nullable + public Parcelable onSaveInstanceState() { + Parcelable superState = super.onSaveInstanceState(); + + SavedState ss = new SavedState(superState); + + ss.checkedState = getCheckedState(); + return ss; + } + + @Override + public void onRestoreInstanceState(@Nullable Parcelable state) { + if (!(state instanceof SavedState)) { + super.onRestoreInstanceState(state); + return; + } + SavedState ss = (SavedState) state; + super.onRestoreInstanceState(ss.getSuperState()); + setCheckedState(ss.checkedState); + } + + static class SavedState extends BaseSavedState { + @CheckedState + int checkedState; + + /** + * Constructor called from {@link MaterialCheckBox#onSaveInstanceState()} + */ + SavedState(Parcelable superState) { + super(superState); + } + + /** + * Constructor called from {@link #CREATOR} + */ + private SavedState(Parcel in) { + super(in); + checkedState = (Integer) in.readValue(getClass().getClassLoader()); + } + + @Override + public void writeToParcel(Parcel out, int flags) { + super.writeToParcel(out, flags); + out.writeValue(checkedState); + } + + @Override + @NonNull + public String toString() { + return "MaterialCheckBox.SavedState{" + + Integer.toHexString(System.identityHashCode(this)) + + " CheckedState=" + getCheckedStateString() + "}"; + } + + @NonNull + public static final Parcelable.Creator CREATOR = + new Parcelable.Creator() { + @Override + public SavedState createFromParcel(Parcel in) { + return new SavedState(in); + } + + @Override + public SavedState[] newArray(int size) { + return new SavedState[size]; + } + }; + + @NonNull + private String getCheckedStateString() { + switch (checkedState) { + case STATE_CHECKED: + return "checked"; + case STATE_INDETERMINATE: + return "indeterminate"; + case STATE_UNCHECKED: + default: + return "unchecked"; + } + } + } } diff --git a/lib/java/com/google/android/material/checkbox/res-public/values/public.xml b/lib/java/com/google/android/material/checkbox/res-public/values/public.xml index 40b67f84105..f1f72f629bc 100644 --- a/lib/java/com/google/android/material/checkbox/res-public/values/public.xml +++ b/lib/java/com/google/android/material/checkbox/res-public/values/public.xml @@ -23,7 +23,9 @@ + + diff --git a/lib/java/com/google/android/material/checkbox/res/color/m3_checkbox_button_icon_tint.xml b/lib/java/com/google/android/material/checkbox/res/color/m3_checkbox_button_icon_tint.xml index 4ce9322bbe3..e84b1e77350 100644 --- a/lib/java/com/google/android/material/checkbox/res/color/m3_checkbox_button_icon_tint.xml +++ b/lib/java/com/google/android/material/checkbox/res/color/m3_checkbox_button_icon_tint.xml @@ -22,6 +22,9 @@ + + + diff --git a/lib/java/com/google/android/material/checkbox/res/color/m3_checkbox_button_tint.xml b/lib/java/com/google/android/material/checkbox/res/color/m3_checkbox_button_tint.xml index cb0b521f90e..4d2d32d05c1 100644 --- a/lib/java/com/google/android/material/checkbox/res/color/m3_checkbox_button_tint.xml +++ b/lib/java/com/google/android/material/checkbox/res/color/m3_checkbox_button_tint.xml @@ -22,6 +22,9 @@ + + + diff --git a/lib/java/com/google/android/material/checkbox/res/drawable/mtrl_checkbox_button.xml b/lib/java/com/google/android/material/checkbox/res/drawable/mtrl_checkbox_button.xml index 92e6b42440d..4b9cdcd2429 100644 --- a/lib/java/com/google/android/material/checkbox/res/drawable/mtrl_checkbox_button.xml +++ b/lib/java/com/google/android/material/checkbox/res/drawable/mtrl_checkbox_button.xml @@ -16,11 +16,16 @@ --> + + + + diff --git a/lib/java/com/google/android/material/checkbox/res/drawable/mtrl_checkbox_button_icon.xml b/lib/java/com/google/android/material/checkbox/res/drawable/mtrl_checkbox_button_icon.xml index 6c0462437d5..639b73658aa 100644 --- a/lib/java/com/google/android/material/checkbox/res/drawable/mtrl_checkbox_button_icon.xml +++ b/lib/java/com/google/android/material/checkbox/res/drawable/mtrl_checkbox_button_icon.xml @@ -16,11 +16,16 @@ --> + + + + + + + diff --git a/lib/java/com/google/android/material/checkbox/res/drawable/mtrl_checkbox_button_icon_checked_indeterminate.xml b/lib/java/com/google/android/material/checkbox/res/drawable/mtrl_checkbox_button_icon_checked_indeterminate.xml new file mode 100644 index 00000000000..7387b321652 --- /dev/null +++ b/lib/java/com/google/android/material/checkbox/res/drawable/mtrl_checkbox_button_icon_checked_indeterminate.xml @@ -0,0 +1,33 @@ + + + + + + + + + diff --git a/lib/java/com/google/android/material/checkbox/res/drawable/mtrl_checkbox_button_icon_indeterminate_checked.xml b/lib/java/com/google/android/material/checkbox/res/drawable/mtrl_checkbox_button_icon_indeterminate_checked.xml new file mode 100644 index 00000000000..6a4a3c3d6b2 --- /dev/null +++ b/lib/java/com/google/android/material/checkbox/res/drawable/mtrl_checkbox_button_icon_indeterminate_checked.xml @@ -0,0 +1,33 @@ + + + + + + + + + diff --git a/lib/java/com/google/android/material/checkbox/res/drawable/mtrl_checkbox_button_icon_indeterminate_unchecked.xml b/lib/java/com/google/android/material/checkbox/res/drawable/mtrl_checkbox_button_icon_indeterminate_unchecked.xml new file mode 100644 index 00000000000..eb48dde3197 --- /dev/null +++ b/lib/java/com/google/android/material/checkbox/res/drawable/mtrl_checkbox_button_icon_indeterminate_unchecked.xml @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + + diff --git a/lib/java/com/google/android/material/checkbox/res/drawable/mtrl_checkbox_button_icon_unchecked_indeterminate.xml b/lib/java/com/google/android/material/checkbox/res/drawable/mtrl_checkbox_button_icon_unchecked_indeterminate.xml new file mode 100644 index 00000000000..c9ff2253a8a --- /dev/null +++ b/lib/java/com/google/android/material/checkbox/res/drawable/mtrl_checkbox_button_icon_unchecked_indeterminate.xml @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + + diff --git a/lib/java/com/google/android/material/checkbox/res/drawable/mtrl_ic_indeterminate.xml b/lib/java/com/google/android/material/checkbox/res/drawable/mtrl_ic_indeterminate.xml new file mode 100644 index 00000000000..e303ebc1efd --- /dev/null +++ b/lib/java/com/google/android/material/checkbox/res/drawable/mtrl_ic_indeterminate.xml @@ -0,0 +1,33 @@ + + + + + + + diff --git a/lib/java/com/google/android/material/checkbox/res/values/attrs.xml b/lib/java/com/google/android/material/checkbox/res/values/attrs.xml index 8849fe84727..f0fa4fc0a15 100644 --- a/lib/java/com/google/android/material/checkbox/res/values/attrs.xml +++ b/lib/java/com/google/android/material/checkbox/res/values/attrs.xml @@ -61,12 +61,24 @@ + + + + + + + + + + checkbox is always one of checked/unchecked/indeterminate and can also + be on error or not. --> + + diff --git a/lib/java/com/google/android/material/checkbox/res/values/strings.xml b/lib/java/com/google/android/material/checkbox/res/values/strings.xml index 7d4d5889420..f1bc1cbbfae 100644 --- a/lib/java/com/google/android/material/checkbox/res/values/strings.xml +++ b/lib/java/com/google/android/material/checkbox/res/values/strings.xml @@ -19,6 +19,17 @@ Error: invalid + + Checked + + + Not checked + + + Indeterminate + + + button button path icon @@ -29,4 +40,6 @@ M23,7H9C7.9,7,7,7.9,7,9v14c0,1.1,0.9,2,2,2h14c1.1,0,2-0.9,2-2V9C25,7.9,24.1,7,23,7z M23,23H9V9h14V23z M14,18.2 11.4,15.6 10,17 14,21 22,13 20.6,11.6z + + M13.4,15 11,15 11,17 13.4,17 21,17 21,15z diff --git a/lib/javatests/com/google/android/material/checkbox/MaterialCheckBoxTest.java b/lib/javatests/com/google/android/material/checkbox/MaterialCheckBoxTest.java index c717a990fd3..4ef369cbe6d 100644 --- a/lib/javatests/com/google/android/material/checkbox/MaterialCheckBoxTest.java +++ b/lib/javatests/com/google/android/material/checkbox/MaterialCheckBoxTest.java @@ -29,7 +29,9 @@ import androidx.appcompat.app.AppCompatActivity; import android.view.View; import android.widget.CheckBox; +import android.widget.CompoundButton.OnCheckedChangeListener; import androidx.core.widget.CompoundButtonCompat; +import com.google.android.material.checkbox.MaterialCheckBox.OnCheckedStateChangedListener; import com.google.android.material.checkbox.MaterialCheckBox.OnErrorChangedListener; import com.google.android.material.color.MaterialColors; import org.junit.Before; @@ -52,6 +54,12 @@ public class MaterialCheckBoxTest { private AppCompatActivity activity; private View checkboxes; private MaterialCheckBox materialCheckBox; + private final OnCheckedChangeListener mockCheckedListener = + Mockito.mock(OnCheckedChangeListener.class); + private final OnErrorChangedListener mockErrorListener = + Mockito.mock(OnErrorChangedListener.class); + private final OnCheckedStateChangedListener mockStateListener = + Mockito.mock(OnCheckedStateChangedListener.class); @Before public void createAndThemeApplicationContext() { @@ -60,6 +68,129 @@ public void createAndThemeApplicationContext() { materialCheckBox = checkboxes.findViewById(R.id.test_checkbox); } + @Test + public void testSetCheckedState_checked_succeeds() { + materialCheckBox.setCheckedState(MaterialCheckBox.STATE_CHECKED); + + assertThat(materialCheckBox.isChecked()).isTrue(); + assertThat(materialCheckBox.getCheckedState()).isEqualTo(MaterialCheckBox.STATE_CHECKED); + } + + @Test + public void testSetCheckedState_unchecked_succeeds() { + materialCheckBox.setCheckedState(MaterialCheckBox.STATE_CHECKED); + assertThat(materialCheckBox.isChecked()).isTrue(); + + materialCheckBox.setCheckedState(MaterialCheckBox.STATE_UNCHECKED); + + assertThat(materialCheckBox.isChecked()).isFalse(); + assertThat(materialCheckBox.getCheckedState()).isEqualTo(MaterialCheckBox.STATE_UNCHECKED); + } + + @Test + public void testSetCheckedState_indeterminate_succeeds() { + materialCheckBox.setCheckedState(MaterialCheckBox.STATE_INDETERMINATE); + + assertThat(materialCheckBox.isChecked()).isFalse(); + assertThat(materialCheckBox.getCheckedState()).isEqualTo(MaterialCheckBox.STATE_INDETERMINATE); + } + + @Test + public void testSetCheckedState_checkedToIndeterminate_succeeds() { + materialCheckBox.setCheckedState(MaterialCheckBox.STATE_CHECKED); + + materialCheckBox.setCheckedState(MaterialCheckBox.STATE_INDETERMINATE); + + assertThat(materialCheckBox.isChecked()).isFalse(); + assertThat(materialCheckBox.getCheckedState()).isEqualTo(MaterialCheckBox.STATE_INDETERMINATE); + } + + @Test + public void testSetCheckedState_checked_callsStateAndCheckedListeners() { + materialCheckBox.addOnCheckedStateChangedListener(mockStateListener); + materialCheckBox.setOnCheckedChangeListener(mockCheckedListener); + + materialCheckBox.setCheckedState(MaterialCheckBox.STATE_CHECKED); + + verify(mockStateListener) + .onCheckedStateChangedListener(materialCheckBox, MaterialCheckBox.STATE_CHECKED); + verify(mockCheckedListener).onCheckedChanged(materialCheckBox, /* isChecked= */ true); + } + + @Test + public void testSetCheckedState_unchecked_callsStateAndCheckedListeners() { + materialCheckBox.setCheckedState(MaterialCheckBox.STATE_CHECKED); + materialCheckBox.addOnCheckedStateChangedListener(mockStateListener); + materialCheckBox.setOnCheckedChangeListener(mockCheckedListener); + + materialCheckBox.setCheckedState(MaterialCheckBox.STATE_UNCHECKED); + + verify(mockStateListener) + .onCheckedStateChangedListener(materialCheckBox, MaterialCheckBox.STATE_UNCHECKED); + verify(mockCheckedListener).onCheckedChanged(materialCheckBox, /* isChecked= */ false); + } + + @Test + public void testSetCheckedState_indeterminate_callsStateListener() { + materialCheckBox.addOnCheckedStateChangedListener(mockStateListener); + materialCheckBox.setOnCheckedChangeListener(mockCheckedListener); + + materialCheckBox.setCheckedState(MaterialCheckBox.STATE_INDETERMINATE); + + verify(mockStateListener) + .onCheckedStateChangedListener(materialCheckBox, MaterialCheckBox.STATE_INDETERMINATE); + verify(mockCheckedListener, never()).onCheckedChanged(materialCheckBox, /* isChecked= */ false); + } + + @Test + public void testSetChecked_succeeds() { + materialCheckBox.setChecked(true); + + assertThat(materialCheckBox.isChecked()).isTrue(); + assertThat(materialCheckBox.getCheckedState()).isEqualTo(MaterialCheckBox.STATE_CHECKED); + } + + @Test + public void testSetUnchecked_succeeds() { + materialCheckBox.setChecked(true); + assertThat(materialCheckBox.isChecked()).isTrue(); + + materialCheckBox.setChecked(false); + + assertThat(materialCheckBox.isChecked()).isFalse(); + assertThat(materialCheckBox.getCheckedState()).isEqualTo(MaterialCheckBox.STATE_UNCHECKED); + } + + @Test + public void testIndeterminate_onClick_becomesChecked() { + materialCheckBox.setCheckedState(MaterialCheckBox.STATE_INDETERMINATE); + + materialCheckBox.performClick(); + + assertThat(materialCheckBox.isChecked()).isTrue(); + assertThat(materialCheckBox.getCheckedState()).isEqualTo(MaterialCheckBox.STATE_CHECKED); + } + + @Test + public void testIndeterminate_setChecked_becomesChecked() { + materialCheckBox.setCheckedState(MaterialCheckBox.STATE_INDETERMINATE); + + materialCheckBox.setChecked(true); + + assertThat(materialCheckBox.isChecked()).isTrue(); + assertThat(materialCheckBox.getCheckedState()).isEqualTo(MaterialCheckBox.STATE_CHECKED); + } + + @Test + public void testIndeterminate_setUnchecked_becomesUnchecked() { + materialCheckBox.setCheckedState(MaterialCheckBox.STATE_INDETERMINATE); + + materialCheckBox.setChecked(false); + + assertThat(materialCheckBox.isChecked()).isFalse(); + assertThat(materialCheckBox.getCheckedState()).isEqualTo(MaterialCheckBox.STATE_UNCHECKED); + } + @Test public void testSetError_succeeds() { materialCheckBox.setErrorShown(true); @@ -69,24 +200,22 @@ public void testSetError_succeeds() { @Test public void testSetError_callsListener() { - OnErrorChangedListener mockListener = Mockito.mock(OnErrorChangedListener.class); materialCheckBox.setErrorShown(false); - materialCheckBox.addOnErrorChangedListener(mockListener); + materialCheckBox.addOnErrorChangedListener(mockErrorListener); materialCheckBox.setErrorShown(true); - verify(mockListener).onErrorChanged(materialCheckBox, /* errorShown= */ true); + verify(mockErrorListener).onErrorChanged(materialCheckBox, /* errorShown= */ true); } @Test public void testSetError_withSameValue_doesNotCallListener() { - OnErrorChangedListener mockListener = Mockito.mock(OnErrorChangedListener.class); materialCheckBox.setErrorShown(false); - materialCheckBox.addOnErrorChangedListener(mockListener); + materialCheckBox.addOnErrorChangedListener(mockErrorListener); materialCheckBox.setErrorShown(false); - verify(mockListener, never()).onErrorChanged(materialCheckBox, /* errorShown= */ false); + verify(mockErrorListener, never()).onErrorChanged(materialCheckBox, /* errorShown= */ false); } @Test