/* * 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 android.content.Context; import android.content.res.TypedArray; import android.graphics.Canvas; import androidx.annotation.BoolRes; import androidx.annotation.IdRes; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; import androidx.core.view.MarginLayoutParamsCompat; import androidx.core.view.ViewCompat; import android.text.TextUtils.TruncateAt; import android.util.AttributeSet; import android.util.Log; import android.view.View; import android.view.ViewGroup; import android.widget.LinearLayout; 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.ShapeAppearanceModel; import java.util.ArrayList; import java.util.Comparator; import java.util.LinkedHashSet; import java.util.List; 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 * {@link R.attr#materialButtonOutlinedStyle} attribute for all child buttons. {@link * R.attr#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 {@link R.attr#singleSelection * app:singleSelection} attribute to {@code true} on the MaterialButtonToggleGroup or call {@link * #setSingleSelection(boolean) setSingleSelection(true)}. * *

MaterialButtonToggleGroup is a {@link LinearLayout}, orientation {@code VERTICAL} is not * supported yet. * *

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. */ 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 = MaterialButtonToggleGroup.class.getSimpleName(); private final List originalCornerData = new ArrayList<>(); private final CheckedStateTracker checkedStateTracker = new CheckedStateTracker(); private final PressedStateTracker pressedStateTracker = new PressedStateTracker(); private final LinkedHashSet onButtonCheckedListeners = new LinkedHashSet<>(); private final Comparator childOrderComparator = new Comparator() { @Override public int compare(MaterialButton v1, MaterialButton v2) { int checked = Boolean.valueOf(v1.isChecked()).compareTo(v2.isChecked()); if (checked != 0) { return checked; } int stateful = Boolean.valueOf(v1.isPressed()).compareTo(v2.isPressed()); if (stateful != 0) { return stateful; } // don't return 0s return Integer.valueOf(indexOfChild(v1)).compareTo(indexOfChild(v2)); } }; private Integer[] childOrder; private boolean skipCheckedStateTracker = false; private boolean singleSelection; @IdRes private int checkedId; public MaterialButtonToggleGroup(@NonNull Context context) { this(context, null); } public MaterialButtonToggleGroup(@NonNull Context context, @Nullable AttributeSet attrs) { this(context, attrs, R.attr.materialButtonToggleGroupStyle); } public MaterialButtonToggleGroup( @NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); setOrientation(HORIZONTAL); TypedArray attributes = ThemeEnforcement.obtainStyledAttributes( context, attrs, R.styleable.MaterialButtonToggleGroup, defStyleAttr, R.style.Widget_MaterialComponents_MaterialButtonToggleGroup); setSingleSelection( attributes.getBoolean(R.styleable.MaterialButtonToggleGroup_singleSelection, false)); checkedId = attributes.getResourceId(R.styleable.MaterialButtonToggleGroup_checkedButton, View.NO_ID); setChildrenDrawingOrderEnabled(true); attributes.recycle(); } @Override protected void onFinishInflate() { super.onFinishInflate(); // Checks the appropriate button as requested via XML if (checkedId != View.NO_ID) { checkForced(checkedId); } } @Override protected void dispatchDraw(@NonNull Canvas canvas) { updateChildOrder(); super.dispatchDraw(canvas); } /** * This override prohibits Views other than {@link MaterialButton} to be added. It also makes * updates to the add button shape and margins. */ @Override public void addView(View child, int index, ViewGroup.LayoutParams params) { if (!(child instanceof MaterialButton)) { Log.e(LOG_TAG, "Child views must be of type MaterialButton."); return; } super.addView(child, index, params); MaterialButton buttonChild = (MaterialButton) child; setGeneratedIdIfNeeded(buttonChild); // Sets sensible default values and an internal checked change listener for this child setupButtonChild(buttonChild); // Reorders children if a checked child was added to this layout if (buttonChild.isChecked()) { updateCheckedStates(buttonChild.getId(), true); setCheckedId(buttonChild.getId()); } // Saves original corner data ShapeAppearanceModel shapeAppearanceModel = buttonChild.getShapeAppearanceModel(); originalCornerData.add( new CornerData( shapeAppearanceModel.getTopLeftCornerSize(), shapeAppearanceModel.getTopRightCornerSize(), shapeAppearanceModel.getBottomRightCornerSize(), shapeAppearanceModel.getBottomLeftCornerSize())); } @Override public void onViewRemoved(View child) { super.onViewRemoved(child); if (child instanceof MaterialButton) { ((MaterialButton) child).removeOnCheckedChangeListener(checkedStateTracker); ((MaterialButton) child).setOnPressedChangeListenerInternal(null); } int indexOfChild = indexOfChild(child); if (indexOfChild >= 0) { originalCornerData.remove(indexOfChild); } updateChildShapes(); adjustChildMarginsAndUpdateLayout(); } @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { updateChildShapes(); adjustChildMarginsAndUpdateLayout(); super.onMeasure(widthMeasureSpec, heightMeasureSpec); } @NonNull @Override public CharSequence getAccessibilityClassName() { return MaterialButtonToggleGroup.class.getName(); } /** * Sets the {@link MaterialButton} whose id is passed in to the checked state. If this * MaterialButtonToggleGroup is in {@link #isSingleSelection() single selection mode}, then all * other MaterialButtons in this group will be unchecked. Otherwise, other MaterialButtons will * retain their checked state. * * @param id View ID of {@link MaterialButton} to set checked * @see #uncheck(int) * @see #clearChecked() * @see #getCheckedButtonIds() * @see #getCheckedButtonId() */ public void check(@IdRes int id) { if (id == checkedId) { return; } checkForced(id); } /** * Sets the {@link MaterialButton} whose id is passed in to the unchecked state. * * @param id View ID of {@link MaterialButton} to set unchecked * @see #check(int) * @see #clearChecked() * @see #getCheckedButtonIds() * @see #getCheckedButtonId() */ public void uncheck(@IdRes int id) { setCheckedStateForView(id, false); updateCheckedStates(id, false); checkedId = View.NO_ID; dispatchOnButtonChecked(id, false); } /** * Clears the selections. When the selections are cleared, no {@link MaterialButton} in this group * is checked and {@link #getCheckedButtonIds()} returns an empty list. * * @see #check(int) * @see #uncheck(int) * @see #getCheckedButtonIds() * @see #getCheckedButtonId() */ public void clearChecked() { skipCheckedStateTracker = true; for (int i = 0; i < getChildCount(); i++) { MaterialButton child = getChildButton(i); child.setChecked(false); dispatchOnButtonChecked(child.getId(), false); } skipCheckedStateTracker = false; setCheckedId(View.NO_ID); } /** * When in {@link #isSingleSelection() single selection mode}, returns the identifier of the * selected button in this group. Upon empty selection, the returned value is {@link View#NO_ID}. * If not in single selection mode, the return value is {@link View#NO_ID}. * * @return The unique id of the selected {@link MaterialButton} in this group in {@link * #isSingleSelection() single selection mode}. When not in {@link #isSingleSelection() single * selection mode}, returns {@link View#NO_ID}. * @see #check(int) * @see #uncheck(int) * @see #clearChecked() * @see #getCheckedButtonIds() * @attr ref R.styleable#MaterialButtonToggleGroup_checkedButton */ @IdRes public int getCheckedButtonId() { return singleSelection ? checkedId : View.NO_ID; } /** * Returns the identifiers of the selected {@link MaterialButton}s in this group. Upon empty * selection, the returned value is an empty list. * * @return The unique IDs of the selected {@link MaterialButton}s in this group. When in {@link * #isSingleSelection() single selection mode}, returns a list with a single ID. When no * {@link MaterialButton}s are selected, returns an empty list. * @see #check(int) * @see #uncheck(int) * @see #clearChecked() * @see #getCheckedButtonId() */ @NonNull public List getCheckedButtonIds() { ArrayList checkedIds = new ArrayList<>(); for (int i = 0; i < getChildCount(); i++) { MaterialButton child = getChildButton(i); if (child.isChecked()) { checkedIds.add(child.getId()); } } return checkedIds; } /** * Add a listener that will be invoked when the check state of a {@link MaterialButton} in this * group changes. See {@link OnButtonCheckedListener}. * *

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(); } } /** * 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)); } private void setCheckedStateForView(@IdRes int viewId, boolean checked) { View checkedView = findViewById(viewId); if (checkedView instanceof MaterialButton) { skipCheckedStateTracker = true; ((MaterialButton) checkedView).setChecked(checked); skipCheckedStateTracker = false; } } private void setCheckedId(int checkedId) { this.checkedId = checkedId; dispatchOnButtonChecked(checkedId, true); } /** * 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 * another. * *

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() { for (int i = 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 = Math.min(currentButton.getStrokeWidth(), previousButton.getStrokeWidth()); LayoutParams params = buildLayoutParams(currentButton); MarginLayoutParamsCompat.setMarginEnd(params, 0); MarginLayoutParamsCompat.setMarginStart(params, -smallestStrokeWidth); currentButton.setLayoutParams(params); } resetFirstChildMargin(); } private MaterialButton getChildButton(int index) { return (MaterialButton) getChildAt(index); } private void resetFirstChildMargin() { if (getChildCount() == 0) { return; } MaterialButton currentButton = getChildButton(0); LayoutParams params = (LayoutParams) currentButton.getLayoutParams(); MarginLayoutParamsCompat.setMarginEnd(params, 0); MarginLayoutParamsCompat.setMarginStart(params, 0); } /** Sets all corner radii to 0 except for leftmost and rightmost corners. */ @VisibleForTesting void updateChildShapes() { int childcount = getChildCount(); for (int i = 0; i < childcount; i++) { MaterialButton button = getChildButton(i); ShapeAppearanceModel.Builder builder = button.getShapeAppearanceModel().toBuilder(); CornerData newcornerdata = getNewCornerData(i); updateBuilderWithCornerData(builder, newcornerdata); button.setShapeAppearanceModel(builder.build()); } } @Nullable private CornerData getNewCornerData(int index) { int childCount = getChildCount(); CornerData cornerData = originalCornerData.get(index); if (childCount == 1) { return cornerData; } AbsoluteCornerSize noCorner = new AbsoluteCornerSize(0); if (index == (ViewUtils.isLayoutRtl(this) ? (childCount - 1) : 0)) { // Keeps the left corners of the first child in LTR, or the last child in RTL return new CornerData(cornerData.topLeft, noCorner, noCorner, cornerData.bottomLeft); } if (index == (ViewUtils.isLayoutRtl(this) ? 0 : (childCount - 1))) { // Keeps the right corners of the last child in LTR, or the first child in RTL return new CornerData(noCorner, cornerData.topRight, cornerData.bottomRight, noCorner); } return null; } private static void updateBuilderWithCornerData( ShapeAppearanceModel.Builder shapeAppearanceModelBuilder, @Nullable CornerData cornerData) { if (cornerData == null) { shapeAppearanceModelBuilder.setAllCornerSizes(0); return; } shapeAppearanceModelBuilder .setTopLeftCornerSize(cornerData.topLeft) .setTopRightCornerSize(cornerData.topRight) .setBottomRightCornerSize(cornerData.bottomRight) .setBottomLeftCornerSize(cornerData.bottomLeft); } /** * When a checked child is added, or a child is clicked, updates checked state and draw order of * children to draw all checked children on top of all unchecked children. * *

If {@code singleSelection} is true, this will unselect any other children as well. * * @param childId ID of child whose checked state may have changed * @param childIsChecked Whether the child is checked */ private void updateCheckedStates(int childId, boolean childIsChecked) { for (int i = 0; i < getChildCount(); i++) { MaterialButton button = getChildButton(i); if (button.isChecked()) { if (singleSelection && childIsChecked && button.getId() != childId) { setCheckedStateForView(button.getId(), false); dispatchOnButtonChecked(button.getId(), false); } } } } private void dispatchOnButtonChecked(@IdRes int buttonId, boolean checked) { for (OnButtonCheckedListener listener : onButtonCheckedListeners) { listener.onButtonChecked(this, buttonId, checked); } } private void checkForced(int checkedId) { setCheckedStateForView(checkedId, true); updateCheckedStates(checkedId, true); setCheckedId(checkedId); } private void setGeneratedIdIfNeeded(@NonNull MaterialButton materialButton) { // Generates an ID if none is set, for relative positioning purposes if (materialButton.getId() == View.NO_ID) { materialButton.setId(ViewCompat.generateViewId()); } } /** * Sets sensible default values for {@link MaterialButton} child of this group, set child to * {@code checkable}, and set internal checked change listener for this child. * * @param buttonChild {@link MaterialButton} child to set up to be added to this {@link * MaterialButtonToggleGroup} */ private void setupButtonChild(@NonNull MaterialButton buttonChild) { buttonChild.setMaxLines(1); buttonChild.setEllipsize(TruncateAt.END); buttonChild.setCheckable(true); buttonChild.addOnCheckedChangeListener(checkedStateTracker); buttonChild.setOnPressedChangeListenerInternal(pressedStateTracker); // Enables surface layer drawing for semi-opaque strokes buttonChild.setShouldDrawSurfaceColorStroke(true); } @NonNull private LinearLayout.LayoutParams buildLayoutParams(@NonNull View child) { ViewGroup.LayoutParams layoutParams = child.getLayoutParams(); if (layoutParams instanceof LinearLayout.LayoutParams) { return (LayoutParams) layoutParams; } LinearLayout.LayoutParams newParams = new LinearLayout.LayoutParams(layoutParams.width, layoutParams.height); return newParams; } /** * We keep track of which views are pressed and checked to draw them last. This prevents visual * issues with overlapping strokes. */ @Override protected int getChildDrawingOrder(int childCount, int i) { if (childOrder == null || i >= childOrder.length) { Log.w(LOG_TAG, "Child order wasn't updated"); return i; } return childOrder[i]; } private void updateChildOrder() { final SortedMap viewToIndexMap = new TreeMap<>(childOrderComparator); int childCount = getChildCount(); for (int i = 0; i < childCount; i++) { viewToIndexMap.put(getChildButton(i), i); } childOrder = viewToIndexMap.values().toArray(new Integer[0]); } private class CheckedStateTracker implements MaterialButton.OnCheckedChangeListener { @Override public void onCheckedChanged(@NonNull MaterialButton button, boolean isChecked) { // Prevents infinite recursion if (skipCheckedStateTracker) { return; } if (singleSelection) { checkedId = isChecked ? button.getId() : View.NO_ID; } dispatchOnButtonChecked(button.getId(), isChecked); updateCheckedStates(button.getId(), isChecked); invalidate(); } } private class PressedStateTracker implements OnPressedChangeListener { @Override public void onPressedChanged(@NonNull MaterialButton button, boolean isPressed) { updateCheckedStates(button.getId(), button.isChecked()); invalidate(); } } private static class CornerData { CornerSize topLeft; CornerSize topRight; CornerSize bottomRight; CornerSize bottomLeft; CornerData( CornerSize topLeft, CornerSize topRight, CornerSize bottomRight, CornerSize bottomLeft) { this.topLeft = topLeft; this.topRight = topRight; this.bottomRight = bottomRight; this.bottomLeft = bottomLeft; } } }