/* * Copyright (C) 2019 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.google.android.material.button; import com.google.android.material.R; import static com.google.android.material.theme.overlay.MaterialThemeOverlay.wrap; import static java.lang.Math.min; import android.content.Context; import android.content.res.TypedArray; import android.graphics.Canvas; import android.text.TextUtils.TruncateAt; import android.util.AttributeSet; import android.util.Log; import android.view.View; import android.view.ViewGroup; import android.view.accessibility.AccessibilityNodeInfo; import android.widget.LinearLayout; import android.widget.RadioButton; import android.widget.ToggleButton; import androidx.annotation.BoolRes; import androidx.annotation.IdRes; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.Px; import androidx.annotation.RestrictTo; import androidx.annotation.RestrictTo.Scope; import androidx.annotation.VisibleForTesting; import androidx.core.view.AccessibilityDelegateCompat; import androidx.core.view.ViewCompat; import androidx.core.view.accessibility.AccessibilityNodeInfoCompat; import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.CollectionInfoCompat; import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.CollectionItemInfoCompat; import com.google.android.material.button.MaterialButton.OnPressedChangeListener; import com.google.android.material.internal.ThemeEnforcement; import com.google.android.material.internal.ViewUtils; import com.google.android.material.shape.AbsoluteCornerSize; import com.google.android.material.shape.CornerSize; import com.google.android.material.shape.RelativeCornerSize; import com.google.android.material.shape.ShapeAppearanceModel; import com.google.android.material.shape.StateListCornerSize; import com.google.android.material.shape.StateListShapeAppearanceModel; import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; import java.util.HashSet; import java.util.LinkedHashSet; import java.util.List; import java.util.Set; import java.util.SortedMap; import java.util.TreeMap; /** * A common container for a set of related, toggleable {@link MaterialButton}s. The {@link * MaterialButton}s in this group will be shown on a single line. * *
This layout currently only supports child views of type {@link MaterialButton}. Buttons can be * added to this view group via XML, as follows: * *
* <com.google.android.material.button.MaterialButtonToggleGroup * xmlns:android="http://schemas.android.com/apk/res/android" * android:id="@+id/toggle_button_group" * android:layout_width="wrap_content" * android:layout_height="wrap_content"> * * <com.google.android.material.button.MaterialButton * style="?attr/materialButtonOutlinedStyle" * android:layout_width="wrap_content" * android:layout_height="wrap_content" * android:text="@string/button_label_private"/> * <com.google.android.material.button.MaterialButton * style="?attr/materialButtonOutlinedStyle" * android:layout_width="wrap_content" * android:layout_height="wrap_content" * android:text="@string/button_label_team"/> * <com.google.android.material.button.MaterialButton * style="?attr/materialButtonOutlinedStyle" * android:layout_width="wrap_content" * android:layout_height="wrap_content" * android:text="@string/button_label_everyone"/> * <com.google.android.material.button.MaterialButton * style="?attr/materialButtonOutlinedStyle" * android:layout_width="wrap_content" * android:layout_height="wrap_content" * android:text="@string/button_label_custom"/> * * </com.google.android.material.button.MaterialButtonToggleGroup> ** *
Buttons can also be added to this view group programmatically via the {@link #addView(View)} * methods. * *
Note: Styling must applied to each child button individually. It is recommended to use the * {@code materialButtonOutlinedStyle} attribute for all child buttons. {@code * materialButtonOutlinedStyle} will most closely match the Material Design guidelines for this * component, and supports the checked state for child buttons. * *
Any {@link MaterialButton}s added to this view group are automatically marked as {@code * checkable}, and by default multiple buttons within the same group can be checked. To enforce that * only one button can be checked at a time, set the {@code app:singleSelection} attribute to {@code * true} on the MaterialButtonToggleGroup or call {@link #setSingleSelection(boolean) * setSingleSelection(true)}. * *
MaterialButtonToggleGroup is a {@link LinearLayout}. Using {@code * android:layout_width="MATCH_PARENT"} and removing {@code android:insetBottom} {@code * android:insetTop} on the children is recommended if using {@code VERTICAL}. * *
In order to cohesively group multiple buttons together, MaterialButtonToggleGroup overrides * the start and end margins of any children added to this layout such that child buttons are placed * directly adjacent to one another. * *
MaterialButtonToggleGroup also overrides any {@code shapeAppearance}, {@code * shapeAppearanceOverlay}, or {@code cornerRadius} attribute set on MaterialButton children such * that only the left-most corners of the first child and the right-most corners of the last child * retain their shape appearance or corner size. * *
For more information, see the component
* developer guidance and design
* guidelines.
*/
public class MaterialButtonToggleGroup extends LinearLayout {
/**
* Interface definition for a callback to be invoked when a {@link MaterialButton} is checked or
* unchecked in this group.
*/
public interface OnButtonCheckedListener {
/**
* Called when a {@link MaterialButton} in this group is checked or unchecked.
*
* @param group The group in which the MaterialButton's checked state was changed
* @param checkedId The unique identifier of the MaterialButton whose check state changed
* @param isChecked Whether the MaterialButton is currently checked
*/
void onButtonChecked(MaterialButtonToggleGroup group, @IdRes int checkedId, boolean isChecked);
}
private static final String LOG_TAG = "MButtonToggleGroup";
private static final int DEF_STYLE_RES =
R.style.Widget_MaterialComponents_MaterialButtonToggleGroup;
private final List Components that add a listener should take care to remove it when finished via {@link
* #removeOnButtonCheckedListener(OnButtonCheckedListener)}.
*
* @param listener listener to add
*/
public void addOnButtonCheckedListener(@NonNull OnButtonCheckedListener listener) {
onButtonCheckedListeners.add(listener);
}
/**
* Remove a listener that was previously added via {@link
* #addOnButtonCheckedListener(OnButtonCheckedListener)}.
*
* @param listener listener to remove
*/
public void removeOnButtonCheckedListener(@NonNull OnButtonCheckedListener listener) {
onButtonCheckedListeners.remove(listener);
}
/** Remove all previously added {@link OnButtonCheckedListener}s. */
public void clearOnButtonCheckedListeners() {
onButtonCheckedListeners.clear();
}
/**
* Returns whether this group only allows a single button to be checked.
*
* @return whether this group only allows a single button to be checked
* @attr ref R.styleable#MaterialButtonToggleGroup_singleSelection
*/
public boolean isSingleSelection() {
return singleSelection;
}
/**
* Sets whether this group only allows a single button to be checked.
*
* Calling this method results in all the buttons in this group to become unchecked.
*
* @param singleSelection whether this group only allows a single button to be checked
* @attr ref R.styleable#MaterialButtonToggleGroup_singleSelection
*/
public void setSingleSelection(boolean singleSelection) {
if (this.singleSelection != singleSelection) {
this.singleSelection = singleSelection;
clearChecked();
}
updateChildrenA11yClassName();
}
private void updateChildrenA11yClassName() {
for (int i = 0; i < getChildCount(); i++) {
String className =
singleSelection ? RadioButton.class.getName() : ToggleButton.class.getName();
getChildButton(i).setA11yClassName(className);
}
}
/**
* Sets whether we prevent all child buttons from being deselected.
*
* @attr ref R.styleable#MaterialButtonToggleGroup_selectionRequired
*/
public void setSelectionRequired(boolean selectionRequired) {
this.selectionRequired = selectionRequired;
}
/**
* Returns whether we prevent all child buttons from being deselected.
*
* @attr ref R.styleable#MaterialButtonToggleGroup_selectionRequired
*/
public boolean isSelectionRequired() {
return selectionRequired;
}
/**
* Sets whether this group only allows a single button to be checked.
*
* Calling this method results in all the buttons in this group to become unchecked.
*
* @param id boolean resource ID of whether this group only allows a single button to be checked
* @attr ref R.styleable#MaterialButtonToggleGroup_singleSelection
*/
public void setSingleSelection(@BoolRes int id) {
setSingleSelection(getResources().getBoolean(id));
}
/** Returns the spacing (in pixels) between each button in the group. */
@Px
public int getSpacing() {
return spacing;
}
/**
* Sets the spacing between each button in the group.
*
* @param spacing the spacing (in pixels) between each button in the group
*/
public void setSpacing(@Px int spacing) {
this.spacing = spacing;
invalidate();
requestLayout();
}
/** Returns the inner corner size of the group. */
@NonNull
public CornerSize getInnerCornerSize() {
return innerCornerSize.getDefaultCornerSize();
}
/**
* Sets the inner corner size of the group.
*
* Can set as an {@link AbsoluteCornerSize} or {@link RelativeCornerSize}. Don't set relative
* corner size larger than 50% or absolute corner size larger than half height to avoid corner
* overlapping.
*
* @param cornerSize the inner corner size of the group
*/
public void setInnerCornerSize(@NonNull CornerSize cornerSize) {
innerCornerSize = StateListCornerSize.create(cornerSize);
updateChildShapes();
invalidate();
}
/**
* Returns the inner corner size state list of the group.
*
* @hide
*/
@NonNull
@RestrictTo(Scope.LIBRARY_GROUP)
public StateListCornerSize getInnerCornerSizeStateList() {
return innerCornerSize;
}
/**
* Sets the inner corner size state list of the group.
*
* Can set as an {@link StateListCornerSize}. Don't set relative corner size larger than 50% or
* absolute corner size larger than half height to the corner size in any state to avoid corner
* overlapping.
*
* @param cornerSizeStateList the inner corner size state list of the group
* @hide
*/
@RestrictTo(Scope.LIBRARY_GROUP)
public void setInnerCornerSizeStateList(@NonNull StateListCornerSize cornerSizeStateList) {
innerCornerSize = cornerSizeStateList;
updateChildShapes();
invalidate();
}
/** Returns the {@link ShapeAppearanceModel} of the group. */
@Nullable
public ShapeAppearanceModel getShapeAppearance() {
return groupStateListShapeAppearance == null
? null
: groupStateListShapeAppearance.getDefaultShape(/* withCornerSizeOverrides= */ true);
}
/**
* Sets the {@link ShapeAppearanceModel} of the group.
*
* @param shapeAppearance The new {@link ShapeAppearanceModel} of the group.
*/
public void setShapeAppearance(@Nullable ShapeAppearanceModel shapeAppearance) {
groupStateListShapeAppearance =
new StateListShapeAppearanceModel.Builder(shapeAppearance).build();
updateChildShapes();
invalidate();
}
/**
* Returns the {@link StateListShapeAppearanceModel} of the group.
*
* @hide
*/
@Nullable
@RestrictTo(Scope.LIBRARY_GROUP)
public StateListShapeAppearanceModel getStateListShapeAppearance() {
return groupStateListShapeAppearance;
}
/**
* Sets the {@link StateListShapeAppearanceModel} of the group.
*
* @param stateListShapeAppearance The new {@link StateListShapeAppearanceModel} of the group.
* @hide
*/
@RestrictTo(Scope.LIBRARY_GROUP)
public void setStateListShapeAppearance(
@Nullable StateListShapeAppearanceModel stateListShapeAppearance) {
groupStateListShapeAppearance = stateListShapeAppearance;
updateChildShapes();
invalidate();
}
private void setCheckedStateForView(@IdRes int viewId, boolean checked) {
View checkedView = findViewById(viewId);
if (checkedView instanceof MaterialButton) {
skipCheckedStateTracker = true;
((MaterialButton) checkedView).setChecked(checked);
skipCheckedStateTracker = false;
}
}
/**
* Sets a negative marginStart on all but the first child, if two adjacent children both have a
* stroke width greater than 0. This prevents a double-width stroke from being drawn for two
* adjacent stroked children, and instead draws the adjacent strokes directly on top of each
* other.
*
* The negative margin adjustment amount will be equal to the smaller of the two adjacent
* stroke widths.
*
* Also rearranges children such that they are shown in the correct visual order.
*/
private void adjustChildMarginsAndUpdateLayout() {
int firstVisibleChildIndex = getFirstVisibleChildIndex();
if (firstVisibleChildIndex == -1) {
return;
}
for (int i = firstVisibleChildIndex + 1; i < getChildCount(); i++) {
// Only adjusts margins if both adjacent children are MaterialButtons
MaterialButton currentButton = getChildButton(i);
MaterialButton previousButton = getChildButton(i - 1);
// Calculates the margin adjustment to be the smaller of the two adjacent stroke widths
int smallestStrokeWidth = 0;
if (spacing <= 0) {
smallestStrokeWidth = min(currentButton.getStrokeWidth(), previousButton.getStrokeWidth());
}
LayoutParams params = buildLayoutParams(currentButton);
if (getOrientation() == HORIZONTAL) {
params.setMarginEnd(0);
params.setMarginStart(spacing - smallestStrokeWidth);
params.topMargin = 0;
} else {
params.bottomMargin = 0;
params.topMargin = spacing - smallestStrokeWidth;
params.setMarginStart(0);
}
currentButton.setLayoutParams(params);
}
resetChildMargins(firstVisibleChildIndex);
}
@NonNull
private MaterialButton getChildButton(int index) {
return (MaterialButton) getChildAt(index);
}
private void resetChildMargins(int childIndex) {
if (getChildCount() == 0 || childIndex == -1) {
return;
}
MaterialButton currentButton = getChildButton(childIndex);
LayoutParams params = (LayoutParams) currentButton.getLayoutParams();
if (getOrientation() == VERTICAL) {
params.topMargin = 0;
params.bottomMargin = 0;
return;
}
params.setMarginEnd(0);
params.setMarginStart(0);
params.leftMargin = 0;
params.rightMargin = 0;
}
/**
* Sets all corner radii override to inner corner size except for leftmost and rightmost corners.
*/
@VisibleForTesting
void updateChildShapes() {
int childCount = getChildCount();
int firstVisibleChildIndex = getFirstVisibleChildIndex();
int lastVisibleChildIndex = getLastVisibleChildIndex();
for (int i = 0; i < childCount; i++) {
MaterialButton button = getChildButton(i);
if (button.getVisibility() == GONE) {
continue;
}
boolean isFirstVisible = i == firstVisibleChildIndex;
boolean isLastVisible = i == lastVisibleChildIndex;
StateListShapeAppearanceModel.Builder originalStateListShapeBuilder =
getOriginalStateListShapeBuilder(isFirstVisible, isLastVisible, i);
// Determines which corners of the original shape should be kept.
boolean isHorizontal = getOrientation() == HORIZONTAL;
boolean isRtl = ViewUtils.isLayoutRtl(this);
int cornerPositionBitsToKeep = 0;
if (isHorizontal) {
// When horizontal (ltr), keeps the left two original corners for the first button.
if (isFirstVisible) {
cornerPositionBitsToKeep |=
StateListShapeAppearanceModel.CORNER_TOP_LEFT
| StateListShapeAppearanceModel.CORNER_BOTTOM_LEFT;
}
// When horizontal (ltr), keeps the right two original corners for the last button.
if (isLastVisible) {
cornerPositionBitsToKeep |=
StateListShapeAppearanceModel.CORNER_TOP_RIGHT
| StateListShapeAppearanceModel.CORNER_BOTTOM_RIGHT;
}
// If rtl, swap the position bits of left corners and right corners.
if (isRtl) {
cornerPositionBitsToKeep =
StateListShapeAppearanceModel.swapCornerPositionRtl(cornerPositionBitsToKeep);
}
} else {
// When vertical, keeps the top two original corners for the first button.
if (isFirstVisible) {
cornerPositionBitsToKeep |=
StateListShapeAppearanceModel.CORNER_TOP_LEFT
| StateListShapeAppearanceModel.CORNER_TOP_RIGHT;
}
// When vertical, keeps the bottom two original corners for the last button.
if (isLastVisible) {
cornerPositionBitsToKeep |=
StateListShapeAppearanceModel.CORNER_BOTTOM_LEFT
| StateListShapeAppearanceModel.CORNER_BOTTOM_RIGHT;
}
}
// Overrides the corners that don't need to keep with unary operator.
int cornerPositionBitsToOverride = ~cornerPositionBitsToKeep;
StateListShapeAppearanceModel newStateListShape =
originalStateListShapeBuilder
.setCornerSizeOverride(innerCornerSize, cornerPositionBitsToOverride)
.build();
if (newStateListShape.isStateful()) {
button.setStateListShapeAppearanceModel(newStateListShape);
} else {
button.setShapeAppearanceModel(
newStateListShape.getDefaultShape(/* withCornerSizeOverrides= */ true));
}
}
}
/**
* Returns a {@link StateListShapeAppearanceModel.Builder} as the original shape of a child
* button.
*
* It takes the group shape, if specified, as the original state list shape for the first and
* last buttons. Otherwise, it takes the state list shape (or build one from the shape appearance
* model, if state list shape is not specified) in the child button.
*
* @param isFirstVisible Whether this is the first visible child button regardless its index.
* @param isLastVisible Whether this is the last visible child button regardless its index.
* @param index The index of the child button.
*/
@NonNull
private StateListShapeAppearanceModel.Builder getOriginalStateListShapeBuilder(
boolean isFirstVisible, boolean isLastVisible, int index) {
StateListShapeAppearanceModel originalStateList =
groupStateListShapeAppearance != null && (isFirstVisible || isLastVisible)
? groupStateListShapeAppearance
: originalChildStateListShapeAppearanceModels.get(index);
// If the state list shape is not specified, creates one from the shape appearance model.
return originalStateList == null
? new StateListShapeAppearanceModel.Builder(originalChildShapeAppearanceModels.get(index))
: originalStateList.toBuilder();
}
private int getFirstVisibleChildIndex() {
int childCount = getChildCount();
for (int i = 0; i < childCount; i++) {
if (isChildVisible(i)) {
return i;
}
}
return -1;
}
private int getLastVisibleChildIndex() {
int childCount = getChildCount();
for (int i = childCount - 1; i >= 0; i--) {
if (isChildVisible(i)) {
return i;
}
}
return -1;
}
private boolean isChildVisible(int i) {
View child = getChildAt(i);
return child.getVisibility() != View.GONE;
}
private int getVisibleButtonCount() {
int count = 0;
for (int i = 0; i < getChildCount(); i++) {
if (this.getChildAt(i) instanceof MaterialButton && isChildVisible(i)) {
count++;
}
}
return count;
}
private int getIndexWithinVisibleButtons(@Nullable View child) {
if (!(child instanceof MaterialButton)) {
return -1;
}
int index = 0;
for (int i = 0; i < getChildCount(); i++) {
if (this.getChildAt(i) == child) {
return index;
}
if (this.getChildAt(i) instanceof MaterialButton && isChildVisible(i)) {
index++;
}
}
return -1;
}
private void checkInternal(@IdRes int buttonId, boolean checked) {
if (buttonId == View.NO_ID) {
Log.e(LOG_TAG, "Button ID is not valid: " + buttonId);
return;
}
Set