package androidx.appcompat.widget; import android.content.Context; import android.content.res.ColorStateList; import android.content.res.TypedArray; import android.graphics.Canvas; import android.graphics.Color; import android.graphics.Rect; import android.graphics.drawable.Drawable; import android.os.Build; import android.util.AttributeSet; import android.view.View; import android.view.ViewGroup; import android.widget.CompoundButton; import com.anthonymandra.widget.R; import java.util.ArrayList; import java.util.List; import androidx.annotation.IdRes; import androidx.annotation.Nullable; import androidx.cardview.widget.ToggleGroupApi17; import androidx.cardview.widget.ToggleGroupApi21; import androidx.cardview.widget.ToggleGroupApi9; @SuppressWarnings({"unused", "WeakerAccess"}) public class ToggleGroup extends LinearLayoutCompat { private static final int[] COLOR_BACKGROUND_ATTR = {android.R.attr.colorBackground}; private static final ToggleGroupImpl IMPL; static { if (Build.VERSION.SDK_INT >= 21) { IMPL = new ToggleGroupApi21(); } else if (Build.VERSION.SDK_INT >= 17) { IMPL = new ToggleGroupApi17(); } else { IMPL = new ToggleGroupApi9(); } IMPL.initStatic(); } private boolean mCompatPadding; private boolean mPreventCornerOverlap; /** * ToggleGroup requires to have a particular minimum size to draw shadows before API 21. If * developer also sets min width/height, they might be overridden. * * ToggleGroup works around this issue by recording user given parameters and using an internal * method to set them. */ int mUserSetMinWidth, mUserSetMinHeight; final Rect mContentPadding = new Rect(); final Rect mShadowBounds = new Rect(); // holds all checked values in the case of nonexclusive mode; the selection is empty by default private final List<Integer> mCheckedIds = new ArrayList<>(); private boolean mExclusive; private boolean mAllowUnselected; // tracks children radio buttons checked state private CompoundButton.OnCheckedChangeListener mChildOnCheckedChangeListener; // when true, mOnCheckedChangeListener discards events private boolean mProtectFromCheckedChange = false; private ToggleGroup.OnCheckedChangeListener mOnCheckedChangeListener; private PassThroughHierarchyChangeListener mPassThroughListener; private int mDividerWidth; private int mDividerHeight; public ToggleGroup(Context context) { super(context); setOrientation(HORIZONTAL); init(); } public ToggleGroup(Context context, AttributeSet attrs) { this(context, attrs, R.attr.toggleGroupStyle); } public ToggleGroup(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); TypedArray attributes = context.obtainStyledAttributes( attrs, R.styleable.ToggleGroup, defStyleAttr, R.style.Widget_Material_ToggleGroup); final int index = attributes.getInt(R.styleable.ToggleGroup_orientation, HORIZONTAL); //noinspection WrongConstant setOrientation(index); mExclusive = attributes.getBoolean(R.styleable.ToggleGroup_exclusive, false); mAllowUnselected = attributes.getBoolean(R.styleable.ToggleGroup_allowUnselected, false); final float cornerRadius = attributes.getDimension(R.styleable.ToggleGroup_cornerRadius, 0); final float elevation = attributes.getDimension(R.styleable.ToggleGroup_toggleElevation, 0); final float maxElevation = attributes.getDimension(R.styleable.ToggleGroup_toggleMaxElevation, 0); mCompatPadding = attributes.getBoolean(R.styleable.ToggleGroup_toggleUseCompatPadding, false); mPreventCornerOverlap = attributes.getBoolean(R.styleable.ToggleGroup_togglePreventCornerOverlap, true); // This creates a background which is important for both elevation shadow and rounded corner clipping ColorStateList backgroundColor; if (attributes.hasValue(R.styleable.ToggleGroup_backgroundColor)) { backgroundColor = attributes.getColorStateList(R.styleable.ToggleGroup_backgroundColor); } else { // There isn't one set, so we'll compute one based on the theme final TypedArray aa = getContext().obtainStyledAttributes(COLOR_BACKGROUND_ATTR); final int themeColorBackground = aa.getColor(0, 0); aa.recycle(); // If the theme colorBackground is light, use our own light color, otherwise dark final float[] hsv = new float[3]; Color.colorToHSV(themeColorBackground, hsv); //noinspection deprecation backgroundColor = ColorStateList.valueOf(hsv[2] > 0.5f ? getResources().getColor(R.color.toggleGroup_light_background) : getResources().getColor(R.color.toggleGroup_dark_background)); } int value = attributes.getResourceId(R.styleable.ToggleGroup_checkedToggleButton, View.NO_ID); if (value != NO_ID) { addCheckedId(value); // We set this even if not exclusive to help with designer if (mExclusive) mCheckedIds.add(value); } attributes.recycle(); init(); IMPL.initialize(mToggleGroupDelegate, context, backgroundColor, cornerRadius, elevation, maxElevation); } private void init() { mChildOnCheckedChangeListener = new CheckedStateTracker(); mPassThroughListener = new PassThroughHierarchyChangeListener(); super.setOnHierarchyChangeListener(mPassThroughListener); } /** * When true the group will allow up to a single selection, deselecting existing ones when a new one is selected. * @param exclusive true if only one selection is valid. */ public void setExclusive(boolean exclusive) { mExclusive = exclusive; } /** * see {@link #setExclusive(boolean)} * @return true if this group allows a single selection */ public boolean isExclusive() { return mExclusive; } /** * When true the group will allow no selection, otherwise "deselecting" a single selection will do nothing. * @param allowUnselected true if no selection is allowed. */ public void setAllowUnselected(boolean allowUnselected) { mAllowUnselected = allowUnselected; } /** * see {@link #setAllowUnselected(boolean)} * @return true if this group allows no selection */ public boolean isUnselectedAllowed() { return mAllowUnselected; } private int getExclusiveCheckedId() { return mCheckedIds.size() > 0 ? mCheckedIds.get(0) : NO_ID; } /** * {@inheritDoc} */ @Override public void setOnHierarchyChangeListener(OnHierarchyChangeListener listener) { // the user listener is delegated to our pass-through listener mPassThroughListener.mOnHierarchyChangeListener = listener; } /** * {@inheritDoc} */ @Override protected void onFinishInflate() { super.onFinishInflate(); // checks the appropriate radio button as requested in the XML file int initialCheck = getExclusiveCheckedId(); if (initialCheck != View.NO_ID) { mProtectFromCheckedChange = true; setCheckedStateForView(initialCheck, true); mProtectFromCheckedChange = false; addCheckedId(initialCheck); } } @Override public void addView(View child, int index, ViewGroup.LayoutParams params) { if (child instanceof CompoundButton) { final CompoundButton button = (CompoundButton) child; if (button.isChecked()) { mProtectFromCheckedChange = true; int currentCheck = getExclusiveCheckedId(); if (mExclusive && currentCheck != View.NO_ID) { setCheckedStateForView(currentCheck, false); } mProtectFromCheckedChange = false; addCheckedId(button.getId()); } } super.addView(child, index, params); } /** * <p>Sets the selection to the radio button whose identifier is passed in * parameter. Using -1 as the selection identifier clears the selection; * such an operation is equivalent to invoking {@link #clearChecked()}.</p> * * @param id the unique id of the radio button to select in this group * * @see #getCheckedId() * @see #clearChecked() */ public void check(@IdRes int id) { if (mExclusive) checkExclusive(id); else checkMulti(id); } private void checkMulti(@IdRes int id) { if (id == NO_ID) removeAllChecked(); else if (mCheckedIds.contains(id)) { setCheckedStateForView(id, false); removeCheckedId(id); } else { setCheckedStateForView(id, true); addCheckedId(id); } // If this group has dividers, request a redraw if (hasDivider()) requestLayout(); } private void checkExclusive(@IdRes int id) { int currentCheck = getExclusiveCheckedId(); // If no id, then clear if (id == NO_ID) { removeAllChecked(); } else if (id == currentCheck) { // If we've been sent an already checked item if(mAllowUnselected) { // If we don't allow unselected we simply do nothing in this method. setCheckedStateForView(id, false); removeCheckedId(id); } } else { // Unchecked item selected if (currentCheck != NO_ID) { // Uncheck existing selection setCheckedStateForView(currentCheck, false); } setCheckedStateForView(id, true); addCheckedId(id); } } private void removeAllChecked() { mProtectFromCheckedChange = true; for (int id : mCheckedIds) setCheckedStateForView(id, false); mCheckedIds.clear(); mProtectFromCheckedChange = false; fireCheckedChanged(); } private void removeCheckedId(@IdRes int id) { mCheckedIds.remove((Integer)id); fireCheckedChanged(); } private void fireCheckedChanged() { if (mOnCheckedChangeListener != null) { int[] checked = new int[mCheckedIds.size()]; for (int i = 0; i < mCheckedIds.size(); i++) { checked[i] = mCheckedIds.get(i); } mOnCheckedChangeListener.onCheckedChanged(this, checked); } } private void addCheckedId(@IdRes int id) { if (id == NO_ID) mCheckedIds.clear(); else { if (mExclusive) mCheckedIds.clear(); // keep the checked list clean since listeners send the whole list mCheckedIds.add(id); } fireCheckedChanged(); } private void setCheckedStateForView(int viewId, boolean checked) { View checkedView = findViewById(viewId); if (checkedView instanceof CompoundButton) { ((CompoundButton) checkedView).setChecked(checked); } } /** * <p>Returns the identifier of the selected radio button in this group. * Upon empty selection, the returned value is -1. * * Note: If this group is NOT exclusive this will return the first checked id, * there may be multiple checked ids! * * @return the unique id of the selected radio button in this group * * @see #check(int) */ @IdRes public int getCheckedId() { return getExclusiveCheckedId(); } /** * <p>Returns the identifiers of the selected toggles in this group. * Upon empty selection, the returned value is null.</p> * * @return the unique id of the selected radio button in this group * * @see #check(int) * @see #clearChecked() * */ @Nullable @IdRes public int[] getCheckedIds() { if (mCheckedIds. size() == 0) return null; int[] checked = new int[mCheckedIds.size()]; for (int i = 0; i < mCheckedIds.size(); i++) { checked[i] = mCheckedIds.get(i); } return checked; } /** * <p>Clears the selection. When the selection is cleared, no radio button * in this group is selected and {@link #getCheckedId()} returns * null.</p> * * @see #check(int) * @see #getCheckedId() */ public void clearChecked() { check(View.NO_ID); } /** * <p>Register a callback to be invoked when the checked radio button * changes in this group.</p> * * @param listener the callback to call on checked state change */ public void setOnCheckedChangeListener(ToggleGroup.OnCheckedChangeListener listener) { mOnCheckedChangeListener = listener; } /** * {@inheritDoc} */ @Override public ToggleGroup.LayoutParams generateLayoutParams(AttributeSet attrs) { return new ToggleGroup.LayoutParams(getContext(), attrs); } /** * {@inheritDoc} */ @Override protected boolean checkLayoutParams(ViewGroup.LayoutParams p) { return p instanceof ToggleGroup.LayoutParams; } @Override protected LinearLayoutCompat.LayoutParams generateDefaultLayoutParams() { return new ToggleGroup.LayoutParams(ToggleGroup.LayoutParams.WRAP_CONTENT, ToggleGroup.LayoutParams.WRAP_CONTENT); } @Override public CharSequence getAccessibilityClassName() { return ToggleGroup.class.getName(); } /** * <p>This set of layout parameters defaults the width and the height of * the children to {@link ViewGroup.LayoutParams#WRAP_CONTENT} when they are not specified in the * XML file. Otherwise, this class uses the value read from the XML file.</p> * */ public static class LayoutParams extends LinearLayoutCompat.LayoutParams { /** * {@inheritDoc} */ public LayoutParams(Context c, AttributeSet attrs) { super(c, attrs); } /** * {@inheritDoc} */ public LayoutParams(int w, int h) { super(w, h); } /** * {@inheritDoc} */ public LayoutParams(int w, int h, float initWeight) { super(w, h, initWeight); } /** * {@inheritDoc} */ public LayoutParams(ViewGroup.LayoutParams p) { super(p); } /** * {@inheritDoc} */ public LayoutParams(MarginLayoutParams source) { super(source); } /** * <p>Fixes the child's width to * {@link android.view.ViewGroup.LayoutParams#WRAP_CONTENT} and the child's * height to {@link android.view.ViewGroup.LayoutParams#WRAP_CONTENT} * when not specified in the XML file.</p> * * @param a the styled attributes set * @param widthAttr the width attribute to fetch * @param heightAttr the height attribute to fetch */ @Override protected void setBaseAttributes(TypedArray a, int widthAttr, int heightAttr) { if (a.hasValue(widthAttr)) { width = a.getLayoutDimension(widthAttr, "layout_width"); } else { width = WRAP_CONTENT; } if (a.hasValue(heightAttr)) { height = a.getLayoutDimension(heightAttr, "layout_height"); } else { height = WRAP_CONTENT; } } } /** * <p>Interface definition for a callback to be invoked when the checked * toggle changed in this group.</p> */ public interface OnCheckedChangeListener { /** * <p>Called when the checked toggle has changed. When the * selection is cleared, checkedId is null.</p> * * @param group the group in which the checked radio button has changed * @param checkedId the unique identifier of the newly checked radio button */ void onCheckedChanged(ToggleGroup group, @IdRes int[] checkedId); } private class CheckedStateTracker implements CompoundButton.OnCheckedChangeListener { public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) { // prevents from infinite recursion if (mProtectFromCheckedChange) { return; } mProtectFromCheckedChange = true; int id = buttonView.getId(); int currentCheck = getExclusiveCheckedId(); // If this item was already checked by itself (implies !isChecked) if (mCheckedIds.size() == 1 && mCheckedIds.contains(id) && !mAllowUnselected) { // If we don't allow unselected setCheckedStateForView(id, true); // then we need to undo the auto-toggle } else { // If checked in exclusive mode we need to uncheck the prior selection (implies isChecked) if (mExclusive && isChecked) { if (currentCheck != NO_ID) { setCheckedStateForView(currentCheck, false); // Uncheck existing selection mCheckedIds.remove((Integer) id); // wait for add to fire listener once } } if (isChecked) addCheckedId(id); else removeCheckedId(id); } mProtectFromCheckedChange = false; if (hasDivider()) requestLayout(); } } /** * <p>A pass-through listener acts upon the events and dispatches them * to another listener. This allows the table layout to set its own internal * hierarchy change listener without preventing the user to setup his.</p> */ private class PassThroughHierarchyChangeListener implements ViewGroup.OnHierarchyChangeListener { private ViewGroup.OnHierarchyChangeListener mOnHierarchyChangeListener; /** * {@inheritDoc} */ public void onChildViewAdded(View parent, View child) { if (parent == ToggleGroup.this && child instanceof CompoundButton) { int id = child.getId(); // generates an id if it's missing if (id == View.NO_ID) { if (Build.VERSION.SDK_INT < 17) id = child.hashCode(); else id = View.generateViewId(); child.setId(id); } ((CompoundButton) child).setOnCheckedChangeListener(mChildOnCheckedChangeListener); } if (mOnHierarchyChangeListener != null) { mOnHierarchyChangeListener.onChildViewAdded(parent, child); } } /** * {@inheritDoc} */ public void onChildViewRemoved(View parent, View child) { if (parent == ToggleGroup.this && child instanceof CompoundButton) { ((CompoundButton) child).setOnCheckedChangeListener(null); } if (mOnHierarchyChangeListener != null) { mOnHierarchyChangeListener.onChildViewRemoved(parent, child); } } } @Override protected void onDraw(Canvas canvas) { if (getDividerDrawable() == null) { return; } if (getOrientation() == VERTICAL) { drawDividersVertical(canvas); } else { drawDividersHorizontal(canvas); } } /** * Set a drawable to be used as a divider between items. * * @param divider Drawable that will divide each item. */ @Override public void setDividerDrawable(Drawable divider) { // We need our own copies of the dimensions if (divider != null) { mDividerWidth = divider.getIntrinsicWidth(); mDividerHeight = divider.getIntrinsicHeight(); } else { mDividerWidth = 0; mDividerHeight = 0; } super.setDividerDrawable(divider); } protected boolean hasDivider() { return getDividerDrawable() != null; } CompoundButton getVisibleViewBeforeChildAt(int index) { index--; while (index >= 0) { final CompoundButton previous = (CompoundButton) getChildAt(index); if (previous.getVisibility() != GONE) return previous; index--; } return null; } /** * Determines where to position dividers between children. Note: this is an 'illegal' override * of a hidden method. * * @param childIndex Index of child to check for preceding divider * @return true if there should be a divider before the child at childIndex */ protected boolean hasDividerBeforeChildAt(int childIndex) { final CompoundButton child = (CompoundButton) getChildAt(childIndex); if (child == null) return false; if (child.getVisibility() == GONE) return false; final CompoundButton previous = getVisibleViewBeforeChildAt(childIndex); if (previous == null) return false; // If both are checked, add a divider return child.isChecked() && previous.isChecked(); } void drawDividersVertical(Canvas canvas) { final int count = getChildCount(); for (int i = 0; i < count; i++) { final CompoundButton child = (CompoundButton) getChildAt(i); if (hasDividerBeforeChildAt(i)) { final LinearLayoutCompat.LayoutParams lp = (LinearLayoutCompat.LayoutParams) child.getLayoutParams(); final int top = child.getTop() - lp.topMargin - mDividerHeight; drawHorizontalDivider(canvas, top); } } } void drawDividersHorizontal(Canvas canvas) { final int count = getChildCount(); for (int i = 0; i < count; i++) { final CompoundButton child = (CompoundButton) getChildAt(i); if (hasDividerBeforeChildAt(i)) { final LinearLayoutCompat.LayoutParams lp = (LinearLayoutCompat.LayoutParams) child.getLayoutParams(); final int left = child.getLeft() - lp.leftMargin - mDividerWidth; drawVerticalDivider(canvas, left); } } } void drawHorizontalDivider(Canvas canvas, int top) { final Drawable divider = getDividerDrawable(); final int dividerPadding = getDividerPadding(); divider.setBounds(getPaddingLeft() + dividerPadding, top, getWidth() - getPaddingRight() - dividerPadding, top + mDividerHeight); divider.draw(canvas); } void drawVerticalDivider(Canvas canvas, int left) { final Drawable divider = getDividerDrawable(); final int dividerPadding = getDividerPadding(); divider.setBounds(left, getPaddingTop() + dividerPadding, left + mDividerWidth, getHeight() - getPaddingBottom() - dividerPadding); divider.draw(canvas); } /** * Returns whether ToggleGroup will add inner padding on platforms Lollipop and after. * * @return <code>true</code> if ToggleGroup adds inner padding on platforms Lollipop and after to * have same dimensions with platforms before Lollipop. */ public boolean getUseCompatPadding() { return mCompatPadding; } /** * ToggleGroup adds additional padding to draw shadows on platforms before Lollipop. * <p> * This may cause Groups to have different sizes between Lollipop and before Lollipop. If you * need to align ToggleGroup with other Views, you may need api version specific dimension * resources to account for the changes. * As an alternative, you can set this flag to <code>true</code> and ToggleGroup will add the same * padding values on platforms Lollipop and after. * <p> * Since setting this flag to true adds unnecessary gaps in the UI, default value is * <code>false</code>. * * @param useCompatPadding <code>true</code> if ToggleGroup should add padding for the shadows on * platforms Lollipop and above. */ public void setUseCompatPadding(boolean useCompatPadding) { if (mCompatPadding != useCompatPadding) { mCompatPadding = useCompatPadding; IMPL.onCompatPaddingChanged(mToggleGroupDelegate); } } /** * Returns whether ToggleGroup should add extra padding to content to avoid overlaps with rounded * corners on pre-Lollipop platforms. * * @return True if ToggleGroup prevents overlaps with rounded corners on platforms before Lollipop. * Default value is <code>true</code>. */ public boolean getPreventCornerOverlap() { return mPreventCornerOverlap; } /** * On pre-Lollipop platforms, ToggleGroup does not clip its bounds for the rounded * corners. Instead, it adds padding to content so that it won't overlap with the rounded * corners. You can disable this behavior by setting this field to <code>false</code>. * <p> * Setting this value on Lollipop and above does not have any effect unless you have enabled * compatibility padding. * * @param preventCornerOverlap Whether ToggleGroup should add extra padding to content to avoid * overlaps with the ToggleGroup corners. * @see #setUseCompatPadding(boolean) */ public void setPreventCornerOverlap(boolean preventCornerOverlap) { if (preventCornerOverlap != mPreventCornerOverlap) { mPreventCornerOverlap = preventCornerOverlap; IMPL.onPreventCornerOverlapChanged(mToggleGroupDelegate); } } private final ToggleGroupDelegate mToggleGroupDelegate = new ToggleGroupDelegate() { private Drawable mGroupBackground; @Override public void setGroupBackground(Drawable drawable) { mGroupBackground = drawable; //noinspection deprecation setBackgroundDrawable(drawable); } @Override public boolean getUseCompatPadding() { return ToggleGroup.this.getUseCompatPadding(); } @Override public boolean getPreventCornerOverlap() { return ToggleGroup.this.getPreventCornerOverlap(); } @Override public void setShadowPadding(int left, int top, int right, int bottom) { mShadowBounds.set(left, top, right, bottom); ToggleGroup.super.setPadding(left + mContentPadding.left, top + mContentPadding.top, right + mContentPadding.right, bottom + mContentPadding.bottom); } @Override public void setMinWidthHeightInternal(int width, int height) { if (width > mUserSetMinWidth) { ToggleGroup.super.setMinimumWidth(width); } if (height > mUserSetMinHeight) { ToggleGroup.super.setMinimumHeight(height); } } @Override public Drawable getGroupBackground() { return mGroupBackground; } @Override public View getToggleGroup() { return ToggleGroup.this; } }; }