mirror of
https://github.com/material-components/material-components-android.git
synced 2026-01-15 17:22:16 +08:00
This change introduces pending states for icon and icon size. When a width animation is active, calls to setIcon and setIconSize are deferred and applied only after the animation completes, preventing layout conflicts. PiperOrigin-RevId: 840343132
1790 lines
59 KiB
Java
1790 lines
59 KiB
Java
/*
|
|
* Copyright (C) 2017 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 android.view.Gravity.CENTER_HORIZONTAL;
|
|
import static android.view.Gravity.END;
|
|
import static android.view.Gravity.LEFT;
|
|
import static android.view.Gravity.RIGHT;
|
|
import static android.view.Gravity.START;
|
|
import static androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP;
|
|
import static com.google.android.material.theme.overlay.MaterialThemeOverlay.wrap;
|
|
import static java.lang.Math.ceil;
|
|
import static java.lang.Math.max;
|
|
import static java.lang.Math.min;
|
|
|
|
import android.annotation.SuppressLint;
|
|
import android.content.Context;
|
|
import android.content.res.ColorStateList;
|
|
import android.content.res.TypedArray;
|
|
import android.graphics.Paint;
|
|
import android.graphics.PorterDuff;
|
|
import android.graphics.PorterDuff.Mode;
|
|
import android.graphics.Rect;
|
|
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.AppCompatButton;
|
|
import android.text.Layout.Alignment;
|
|
import android.text.TextUtils;
|
|
import android.util.AttributeSet;
|
|
import android.util.Log;
|
|
import android.view.Gravity;
|
|
import android.view.View;
|
|
import android.view.accessibility.AccessibilityEvent;
|
|
import android.view.accessibility.AccessibilityNodeInfo;
|
|
import android.widget.Button;
|
|
import android.widget.Checkable;
|
|
import android.widget.CompoundButton;
|
|
import android.widget.LinearLayout;
|
|
import android.widget.LinearLayout.LayoutParams;
|
|
import androidx.annotation.AttrRes;
|
|
import androidx.annotation.ColorInt;
|
|
import androidx.annotation.ColorRes;
|
|
import androidx.annotation.DimenRes;
|
|
import androidx.annotation.Dimension;
|
|
import androidx.annotation.DrawableRes;
|
|
import androidx.annotation.IntDef;
|
|
import androidx.annotation.NonNull;
|
|
import androidx.annotation.Nullable;
|
|
import androidx.annotation.Px;
|
|
import androidx.annotation.RestrictTo;
|
|
import androidx.core.graphics.drawable.DrawableCompat;
|
|
import androidx.customview.view.AbsSavedState;
|
|
import androidx.dynamicanimation.animation.FloatPropertyCompat;
|
|
import androidx.dynamicanimation.animation.SpringAnimation;
|
|
import androidx.dynamicanimation.animation.SpringForce;
|
|
import com.google.android.material.internal.ThemeEnforcement;
|
|
import com.google.android.material.internal.ViewUtils;
|
|
import com.google.android.material.motion.MotionUtils;
|
|
import androidx.resourceinspection.annotation.Attribute;
|
|
import com.google.android.material.resources.MaterialResources;
|
|
import com.google.android.material.shape.MaterialShapeDrawable;
|
|
import com.google.android.material.shape.MaterialShapeUtils;
|
|
import com.google.android.material.shape.ShapeAppearance;
|
|
import com.google.android.material.shape.ShapeAppearanceModel;
|
|
import com.google.android.material.shape.Shapeable;
|
|
import com.google.android.material.shape.StateListShapeAppearanceModel;
|
|
import com.google.android.material.shape.StateListSizeChange;
|
|
import java.lang.annotation.Retention;
|
|
import java.lang.annotation.RetentionPolicy;
|
|
import java.util.LinkedHashSet;
|
|
|
|
/**
|
|
* A convenience class for creating a new Material button.
|
|
*
|
|
* <p>This class supplies updated Material styles for the button in the constructor. The widget will
|
|
* display the correct default Material styles without the use of the style flag.
|
|
*
|
|
* <p>All attributes from {@code com.google.android.material.R.styleable#MaterialButton} are
|
|
* supported. Do not use the {@code android:background} attribute. MaterialButton manages its own
|
|
* background drawable, and setting a new background means {@link MaterialButton} can no longer
|
|
* guarantee that the new attributes it introduces will function properly. If the default background
|
|
* is changed, {@link MaterialButton} cannot guarantee well-defined behavior.
|
|
*
|
|
* <p>For filled buttons, this class uses your theme's {@code ?attr/colorPrimary} for the background
|
|
* tint color and {@code ?attr/colorOnPrimary} for the text color. For unfilled buttons, this class
|
|
* uses {@code ?attr/colorPrimary} for the text color and transparent for the background tint.
|
|
*
|
|
* <p>Add icons to the start, center, or end of this button using the {@code app:icon}, {@code
|
|
* app:iconPadding}, {@code app:iconTint}, {@code app:iconTintMode} and {@code app:iconGravity}
|
|
* attributes.
|
|
*
|
|
* <p>If a start-aligned icon is added to this button, please use a style like one of the ".Icon"
|
|
* styles specified in the default MaterialButton styles. The ".Icon" styles adjust padding slightly
|
|
* to achieve a better visual balance. This style should only be used with a start-aligned icon
|
|
* button. If your icon is end-aligned, you cannot use a ".Icon" style and must instead manually
|
|
* adjust your padding such that the visual adjustment is mirrored.
|
|
*
|
|
* <p>Specify background tint using the {@code app:backgroundTint} and {@code
|
|
* app:backgroundTintMode} attributes, which accepts either a color or a color state list.
|
|
*
|
|
* <p>Ripple color / press state color can be specified using the {@code app:rippleColor} attribute.
|
|
* Ripple opacity will be determined by the Android framework when available. Otherwise, this color
|
|
* will be overlaid on the button at a 50% opacity when button is pressed.
|
|
*
|
|
* <p>Set the stroke color using the {@code app:strokeColor} attribute, which accepts either a color
|
|
* or a color state list. Stroke width can be set using the {@code app:strokeWidth} attribute.
|
|
*
|
|
* <p>Specify the radius of all four corners of the button using the {@code app:cornerRadius}
|
|
* attribute.
|
|
*
|
|
* <p>For more information, see the <a
|
|
* href="https://github.com/material-components/material-components-android/blob/master/docs/components/Button.md">component
|
|
* developer guidance</a> and <a href="https://material.io/components/buttons/overview">design
|
|
* guidelines</a>.
|
|
*/
|
|
public class MaterialButton extends AppCompatButton implements Checkable, Shapeable {
|
|
|
|
/** Interface definition for a callback to be invoked when the button checked state changes. */
|
|
public interface OnCheckedChangeListener {
|
|
/**
|
|
* Called when the checked state of a MaterialButton has changed.
|
|
*
|
|
* @param button The MaterialButton whose state has changed.
|
|
* @param isChecked The new checked state of MaterialButton.
|
|
*/
|
|
void onCheckedChanged(MaterialButton button, boolean isChecked);
|
|
}
|
|
|
|
/** Interface to listen for press state changes on this button. Internal use only. */
|
|
interface OnPressedChangeListener {
|
|
void onPressedChanged(MaterialButton button, boolean isPressed);
|
|
}
|
|
|
|
private static final int[] CHECKABLE_STATE_SET = {android.R.attr.state_checkable};
|
|
private static final int[] CHECKED_STATE_SET = {android.R.attr.state_checked};
|
|
|
|
/**
|
|
* Gravity used to position the icon at the start of the view.
|
|
*
|
|
* @see #setIconGravity(int)
|
|
* @see #getIconGravity()
|
|
*/
|
|
public static final int ICON_GRAVITY_START = 0x1;
|
|
|
|
/**
|
|
* Gravity used to position the icon in the center of the view at the start of the text
|
|
*
|
|
* @see #setIconGravity(int)
|
|
* @see #getIconGravity()
|
|
*/
|
|
public static final int ICON_GRAVITY_TEXT_START = 0x2;
|
|
|
|
/**
|
|
* Gravity used to position the icon at the end of the view.
|
|
*
|
|
* @see #setIconGravity(int)
|
|
* @see #getIconGravity()
|
|
*/
|
|
public static final int ICON_GRAVITY_END = 0x3;
|
|
|
|
/**
|
|
* Gravity used to position the icon in the center of the view at the end of the text
|
|
*
|
|
* @see #setIconGravity(int)
|
|
* @see #getIconGravity()
|
|
*/
|
|
public static final int ICON_GRAVITY_TEXT_END = 0x4;
|
|
|
|
/**
|
|
* Gravity used to position the icon at the top of the view.
|
|
*
|
|
* @see #setIconGravity(int)
|
|
* @see #getIconGravity()
|
|
*/
|
|
public static final int ICON_GRAVITY_TOP = 0x10;
|
|
|
|
/**
|
|
* Gravity used to position the icon in the center of the view at the top of the text
|
|
*
|
|
* @see #setIconGravity(int)
|
|
* @see #getIconGravity()
|
|
*/
|
|
public static final int ICON_GRAVITY_TEXT_TOP = 0x20;
|
|
|
|
/** Positions the icon can be set to. */
|
|
@IntDef({
|
|
ICON_GRAVITY_START,
|
|
ICON_GRAVITY_TEXT_START,
|
|
ICON_GRAVITY_END,
|
|
ICON_GRAVITY_TEXT_END,
|
|
ICON_GRAVITY_TOP,
|
|
ICON_GRAVITY_TEXT_TOP
|
|
})
|
|
@Retention(RetentionPolicy.SOURCE)
|
|
public @interface IconGravity {}
|
|
|
|
enum WidthChangeDirection {
|
|
NONE,
|
|
START,
|
|
END,
|
|
BOTH
|
|
}
|
|
|
|
private static final String LOG_TAG = "MaterialButton";
|
|
|
|
private static final int DEF_STYLE_RES = R.style.Widget_MaterialComponents_Button;
|
|
|
|
@AttrRes private static final int MATERIAL_SIZE_OVERLAY_ATTR = R.attr.materialSizeOverlay;
|
|
private static final float OPTICAL_CENTER_RATIO = 0.11f;
|
|
|
|
private static final int UNSET = Integer.MIN_VALUE;
|
|
|
|
@NonNull private final MaterialButtonHelper materialButtonHelper;
|
|
|
|
@NonNull
|
|
private final LinkedHashSet<OnCheckedChangeListener> onCheckedChangeListeners =
|
|
new LinkedHashSet<>();
|
|
|
|
@Nullable private OnPressedChangeListener onPressedChangeListenerInternal;
|
|
@Nullable private Mode iconTintMode;
|
|
@Nullable private ColorStateList iconTint;
|
|
@Nullable private Drawable icon;
|
|
@Nullable private String accessibilityClassName;
|
|
|
|
@Px private int iconSize;
|
|
@Px private int iconLeft;
|
|
@Px private int iconTop;
|
|
@Px private int iconPadding;
|
|
|
|
private boolean checked = false;
|
|
private boolean broadcasting = false;
|
|
@IconGravity private int iconGravity;
|
|
|
|
private int orientation = UNSET;
|
|
private float originalWidth = UNSET;
|
|
@Px private int originalPaddingStart = UNSET;
|
|
@Px private int originalPaddingEnd = UNSET;
|
|
|
|
@Nullable private LayoutParams originalLayoutParams;
|
|
|
|
// Fields for optical center.
|
|
private boolean opticalCenterEnabled;
|
|
private int opticalCenterShift;
|
|
private boolean isInHorizontalButtonGroup;
|
|
|
|
// Fields for size morphing.
|
|
@Px int allowedWidthDecrease = UNSET;
|
|
@Nullable StateListSizeChange sizeChange;
|
|
@Px int widthChangeMax;
|
|
private WidthChangeDirection widthChangeDirection = WidthChangeDirection.BOTH;
|
|
private float displayedWidthIncrease;
|
|
private float displayedWidthDecrease;
|
|
@Nullable private SpringAnimation widthIncreaseSpringAnimation;
|
|
|
|
public MaterialButton(@NonNull Context context) {
|
|
this(context, null /* attrs */);
|
|
}
|
|
|
|
public MaterialButton(@NonNull Context context, @Nullable AttributeSet attrs) {
|
|
this(context, attrs, R.attr.materialButtonStyle);
|
|
}
|
|
|
|
public MaterialButton(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
|
|
super(
|
|
wrap(context, attrs, defStyleAttr, DEF_STYLE_RES, new int[] {MATERIAL_SIZE_OVERLAY_ATTR}),
|
|
attrs,
|
|
defStyleAttr);
|
|
// Ensure we are using the correctly themed context rather than the context that was passed in.
|
|
context = getContext();
|
|
|
|
TypedArray attributes =
|
|
ThemeEnforcement.obtainStyledAttributes(
|
|
context, attrs, R.styleable.MaterialButton, defStyleAttr, DEF_STYLE_RES);
|
|
|
|
iconPadding = attributes.getDimensionPixelSize(R.styleable.MaterialButton_iconPadding, 0);
|
|
iconTintMode =
|
|
ViewUtils.parseTintMode(
|
|
attributes.getInt(R.styleable.MaterialButton_iconTintMode, -1), Mode.SRC_IN);
|
|
|
|
iconTint =
|
|
MaterialResources.getColorStateList(
|
|
getContext(), attributes, R.styleable.MaterialButton_iconTint);
|
|
icon = MaterialResources.getDrawable(getContext(), attributes, R.styleable.MaterialButton_icon);
|
|
iconGravity = attributes.getInteger(R.styleable.MaterialButton_iconGravity, ICON_GRAVITY_START);
|
|
|
|
iconSize = attributes.getDimensionPixelSize(R.styleable.MaterialButton_iconSize, 0);
|
|
StateListShapeAppearanceModel stateListShapeAppearanceModel =
|
|
StateListShapeAppearanceModel.create(
|
|
context, attributes, R.styleable.MaterialButton_shapeAppearance);
|
|
ShapeAppearance shapeAppearance =
|
|
stateListShapeAppearanceModel != null
|
|
? stateListShapeAppearanceModel
|
|
: ShapeAppearanceModel.builder(context, attrs, defStyleAttr, DEF_STYLE_RES).build();
|
|
boolean opticalCenterEnabled =
|
|
attributes.getBoolean(R.styleable.MaterialButton_opticalCenterEnabled, false);
|
|
|
|
// Loads and sets background drawable attributes
|
|
materialButtonHelper = new MaterialButtonHelper(this, shapeAppearance);
|
|
materialButtonHelper.loadFromAttributes(attributes);
|
|
|
|
// Sets the checked state after the MaterialButtonHelper is initialized.
|
|
setCheckedInternal(attributes.getBoolean(R.styleable.MaterialButton_android_checked, false));
|
|
|
|
if (shapeAppearance instanceof StateListShapeAppearanceModel) {
|
|
materialButtonHelper.setCornerSpringForce(createSpringForce());
|
|
}
|
|
setOpticalCenterEnabled(opticalCenterEnabled);
|
|
|
|
attributes.recycle();
|
|
|
|
setCompoundDrawablePadding(iconPadding);
|
|
updateIcon(/* needsIconReset= */ icon != null);
|
|
}
|
|
|
|
private void initializeSizeAnimation() {
|
|
widthIncreaseSpringAnimation = new SpringAnimation(this, WIDTH_INCREASE);
|
|
widthIncreaseSpringAnimation.setSpring(createSpringForce());
|
|
}
|
|
|
|
private SpringForce createSpringForce() {
|
|
return MotionUtils.resolveThemeSpringForce(
|
|
getContext(),
|
|
R.attr.motionSpringFastSpatial,
|
|
R.style.Motion_Material3_Spring_Standard_Fast_Spatial);
|
|
}
|
|
|
|
private boolean maybeRunAfterWidthAnimation(Runnable action) {
|
|
if (widthIncreaseSpringAnimation != null && widthIncreaseSpringAnimation.isRunning()) {
|
|
post(
|
|
() -> {
|
|
action.run();
|
|
recoverOriginalLayoutParams();
|
|
requestLayout();
|
|
});
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
@NonNull
|
|
@SuppressLint("KotlinPropertyAccess")
|
|
String getA11yClassName() {
|
|
if (!TextUtils.isEmpty(accessibilityClassName)) {
|
|
return accessibilityClassName;
|
|
}
|
|
// Use the platform widget classes so Talkback can recognize this as a button.
|
|
return (isCheckable() ? CompoundButton.class : Button.class).getName();
|
|
}
|
|
|
|
@RestrictTo(LIBRARY_GROUP)
|
|
public void setA11yClassName(@Nullable String className) {
|
|
accessibilityClassName = className;
|
|
}
|
|
|
|
@Override
|
|
public void onInitializeAccessibilityNodeInfo(@NonNull AccessibilityNodeInfo info) {
|
|
super.onInitializeAccessibilityNodeInfo(info);
|
|
info.setClassName(getA11yClassName());
|
|
info.setCheckable(isCheckable());
|
|
info.setChecked(isChecked());
|
|
info.setClickable(isClickable());
|
|
}
|
|
|
|
@Override
|
|
public void onInitializeAccessibilityEvent(@NonNull AccessibilityEvent accessibilityEvent) {
|
|
super.onInitializeAccessibilityEvent(accessibilityEvent);
|
|
accessibilityEvent.setClassName(getA11yClassName());
|
|
accessibilityEvent.setChecked(isChecked());
|
|
}
|
|
|
|
@NonNull
|
|
@Override
|
|
public Parcelable onSaveInstanceState() {
|
|
Parcelable superState = super.onSaveInstanceState();
|
|
SavedState savedState = new SavedState(superState);
|
|
savedState.checked = checked;
|
|
return savedState;
|
|
}
|
|
|
|
@Override
|
|
public void onRestoreInstanceState(@Nullable Parcelable state) {
|
|
if (!(state instanceof SavedState)) {
|
|
super.onRestoreInstanceState(state);
|
|
return;
|
|
}
|
|
SavedState savedState = (SavedState) state;
|
|
super.onRestoreInstanceState(savedState.getSuperState());
|
|
setChecked(savedState.checked);
|
|
}
|
|
|
|
/**
|
|
* This should be accessed via {@link
|
|
* androidx.core.view.ViewCompat#setBackgroundTintList(android.view.View, ColorStateList)}
|
|
*
|
|
* @hide
|
|
*/
|
|
@RestrictTo(LIBRARY_GROUP)
|
|
@Override
|
|
public void setSupportBackgroundTintList(@Nullable ColorStateList tint) {
|
|
if (isUsingOriginalBackground()) {
|
|
materialButtonHelper.setSupportBackgroundTintList(tint);
|
|
} else {
|
|
// If default MaterialButton background has been overwritten, we will let AppCompatButton
|
|
// handle the tinting
|
|
super.setSupportBackgroundTintList(tint);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* This should be accessed via {@link android.view.View#getBackgroundTintList()}
|
|
*
|
|
* @hide
|
|
*/
|
|
@RestrictTo(LIBRARY_GROUP)
|
|
@Override
|
|
@Nullable
|
|
public ColorStateList getSupportBackgroundTintList() {
|
|
if (isUsingOriginalBackground()) {
|
|
return materialButtonHelper.getSupportBackgroundTintList();
|
|
} else {
|
|
// If default MaterialButton background has been overwritten, we will let AppCompatButton
|
|
// handle the tinting
|
|
// return null;
|
|
return super.getSupportBackgroundTintList();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* This should be accessed via {@link
|
|
* androidx.core.view.ViewCompat#setBackgroundTintMode(android.view.View, PorterDuff.Mode)}
|
|
*
|
|
* @hide
|
|
*/
|
|
@RestrictTo(LIBRARY_GROUP)
|
|
@Override
|
|
public void setSupportBackgroundTintMode(@Nullable PorterDuff.Mode tintMode) {
|
|
if (isUsingOriginalBackground()) {
|
|
materialButtonHelper.setSupportBackgroundTintMode(tintMode);
|
|
} else {
|
|
// If default MaterialButton background has been overwritten, we will let AppCompatButton
|
|
// handle the tint Mode
|
|
super.setSupportBackgroundTintMode(tintMode);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* This should be accessed via {@link android.view.View#getBackgroundTintMode()}
|
|
*
|
|
* @hide
|
|
*/
|
|
@RestrictTo(LIBRARY_GROUP)
|
|
@Override
|
|
@Nullable
|
|
public PorterDuff.Mode getSupportBackgroundTintMode() {
|
|
if (isUsingOriginalBackground()) {
|
|
return materialButtonHelper.getSupportBackgroundTintMode();
|
|
} else {
|
|
// If default MaterialButton background has been overwritten, we will let AppCompatButton
|
|
// handle the tint mode
|
|
return super.getSupportBackgroundTintMode();
|
|
}
|
|
}
|
|
|
|
@Override
|
|
public void setBackgroundTintList(@Nullable ColorStateList tintList) {
|
|
setSupportBackgroundTintList(tintList);
|
|
}
|
|
|
|
@Nullable
|
|
@Override
|
|
public ColorStateList getBackgroundTintList() {
|
|
return getSupportBackgroundTintList();
|
|
}
|
|
|
|
@Override
|
|
public void setBackgroundTintMode(@Nullable Mode tintMode) {
|
|
setSupportBackgroundTintMode(tintMode);
|
|
}
|
|
|
|
@Nullable
|
|
@Override
|
|
public Mode getBackgroundTintMode() {
|
|
return getSupportBackgroundTintMode();
|
|
}
|
|
|
|
@Override
|
|
public void setBackgroundColor(@ColorInt int color) {
|
|
if (isUsingOriginalBackground()) {
|
|
materialButtonHelper.setBackgroundColor(color);
|
|
} else {
|
|
// If default MaterialButton background has been overwritten, we will let View handle
|
|
// setting the background color.
|
|
super.setBackgroundColor(color);
|
|
}
|
|
}
|
|
|
|
@Override
|
|
public void setBackground(@NonNull Drawable background) {
|
|
setBackgroundDrawable(background);
|
|
}
|
|
|
|
@Override
|
|
public void setBackgroundResource(@DrawableRes int backgroundResourceId) {
|
|
Drawable background = null;
|
|
if (backgroundResourceId != 0) {
|
|
background = AppCompatResources.getDrawable(getContext(), backgroundResourceId);
|
|
}
|
|
setBackgroundDrawable(background);
|
|
}
|
|
|
|
@Override
|
|
public void setBackgroundDrawable(@NonNull Drawable background) {
|
|
if (isUsingOriginalBackground()) {
|
|
if (background != this.getBackground()) {
|
|
Log.w(
|
|
LOG_TAG,
|
|
"MaterialButton manages its own background to control elevation, shape, color and"
|
|
+ " states. Consider using backgroundTint, shapeAppearance and other attributes"
|
|
+ " where available. A custom background will ignore these attributes and you"
|
|
+ " should consider handling interaction states such as pressed, focused and"
|
|
+ " disabled");
|
|
materialButtonHelper.setBackgroundOverwritten();
|
|
super.setBackgroundDrawable(background);
|
|
} else {
|
|
// ViewCompat.setBackgroundTintList() and setBackgroundTintMode() call setBackground() on
|
|
// the view in API 21, since background state doesn't automatically update in API 21. We
|
|
// capture this case here, and update our background without replacing it or re-tinting it.
|
|
getBackground().setState(background.getState());
|
|
}
|
|
} else {
|
|
super.setBackgroundDrawable(background);
|
|
}
|
|
}
|
|
|
|
@Override
|
|
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
|
|
super.onLayout(changed, left, top, right, bottom);
|
|
// Workaround for API 21 ripple bug (possibly internal in GradientDrawable)
|
|
if (VERSION.SDK_INT == VERSION_CODES.LOLLIPOP && materialButtonHelper != null) {
|
|
materialButtonHelper.updateMaskBounds(bottom - top, right - left);
|
|
}
|
|
updateIconPosition(getMeasuredWidth(), getMeasuredHeight());
|
|
|
|
int curOrientation = getResources().getConfiguration().orientation;
|
|
if (orientation != curOrientation) {
|
|
orientation = curOrientation;
|
|
originalWidth = UNSET;
|
|
}
|
|
if (originalWidth == UNSET) {
|
|
originalWidth = getMeasuredWidth();
|
|
// The width morph leverage the width of the layout params. However, it's not available if
|
|
// layout_weight is used. We need to hardcode the width here. The original layout params will
|
|
// be preserved for the correctness of distribution when buttons are added or removed into the
|
|
// group programmatically.
|
|
if (originalLayoutParams == null
|
|
&& getParent() instanceof MaterialButtonGroup
|
|
&& ((MaterialButtonGroup) getParent()).getButtonSizeChange() != null) {
|
|
originalLayoutParams = (LayoutParams) getLayoutParams();
|
|
LayoutParams newLayoutParams = new LayoutParams(originalLayoutParams);
|
|
newLayoutParams.width = (int) originalWidth;
|
|
setLayoutParams(newLayoutParams);
|
|
}
|
|
}
|
|
|
|
if (allowedWidthDecrease == UNSET) {
|
|
int localIconSizeAndPadding =
|
|
icon == null
|
|
? 0
|
|
: getIconPadding() + (iconSize == 0 ? icon.getIntrinsicWidth() : iconSize);
|
|
allowedWidthDecrease = getMeasuredWidth() - getTextLayoutWidth() - localIconSizeAndPadding;
|
|
}
|
|
|
|
if (originalPaddingStart == UNSET) {
|
|
originalPaddingStart = getPaddingStart();
|
|
}
|
|
if (originalPaddingEnd == UNSET) {
|
|
originalPaddingEnd = getPaddingEnd();
|
|
}
|
|
isInHorizontalButtonGroup = isInHorizontalButtonGroup();
|
|
}
|
|
|
|
void recoverOriginalLayoutParams() {
|
|
if (originalLayoutParams != null) {
|
|
setLayoutParams(originalLayoutParams);
|
|
originalLayoutParams = null;
|
|
originalWidth = UNSET;
|
|
}
|
|
}
|
|
|
|
@Override
|
|
public void setWidth(@Px int pixels) {
|
|
originalWidth = UNSET;
|
|
super.setWidth(pixels);
|
|
}
|
|
|
|
@Override
|
|
protected void onTextChanged(CharSequence charSequence, int i, int i1, int i2) {
|
|
super.onTextChanged(charSequence, i, i1, i2);
|
|
updateIconPosition(getMeasuredWidth(), getMeasuredHeight());
|
|
}
|
|
|
|
@Override
|
|
protected void onAttachedToWindow() {
|
|
super.onAttachedToWindow();
|
|
|
|
if (isUsingOriginalBackground()) {
|
|
MaterialShapeUtils.setParentAbsoluteElevation(
|
|
this, materialButtonHelper.getMaterialShapeDrawable());
|
|
}
|
|
}
|
|
|
|
@Override
|
|
public void setElevation(float elevation) {
|
|
super.setElevation(elevation);
|
|
if (isUsingOriginalBackground()) {
|
|
materialButtonHelper.getMaterialShapeDrawable().setElevation(elevation);
|
|
}
|
|
}
|
|
|
|
@Override
|
|
public void refreshDrawableState() {
|
|
super.refreshDrawableState();
|
|
if (this.icon != null) {
|
|
final int[] state = getDrawableState();
|
|
boolean changed = icon.setState(state);
|
|
|
|
// Force the view to draw if icon state has changed.
|
|
if (changed) {
|
|
invalidate();
|
|
}
|
|
}
|
|
}
|
|
|
|
@Override
|
|
public void setText(CharSequence text, BufferType type) {
|
|
originalWidth = UNSET;
|
|
super.setText(text, type);
|
|
}
|
|
|
|
@Override
|
|
public void setTextAppearance(Context context, int resId) {
|
|
originalWidth = UNSET;
|
|
super.setTextAppearance(context, resId);
|
|
}
|
|
|
|
@Override
|
|
public void setTextSize(int unit, float size) {
|
|
originalWidth = UNSET;
|
|
super.setTextSize(unit, size);
|
|
}
|
|
|
|
@Override
|
|
public void setTextAlignment(int textAlignment) {
|
|
super.setTextAlignment(textAlignment);
|
|
updateIconPosition(getMeasuredWidth(), getMeasuredHeight());
|
|
}
|
|
|
|
/**
|
|
* This method and {@link #getActualTextAlignment()} is modified from Android framework TextView's
|
|
* private method getLayoutAlignment(). Please note that the logic here assumes the actual text
|
|
* direction is the same as the layout direction, which is not always the case, especially when
|
|
* the text mixes different languages. However, this is probably the best we can do for now,
|
|
* unless we have a good way to detect the final text direction being used by TextView.
|
|
*/
|
|
private Alignment getGravityTextAlignment() {
|
|
switch (getGravity() & Gravity.RELATIVE_HORIZONTAL_GRAVITY_MASK) {
|
|
case CENTER_HORIZONTAL:
|
|
return Alignment.ALIGN_CENTER;
|
|
case END:
|
|
case RIGHT:
|
|
return Alignment.ALIGN_OPPOSITE;
|
|
case START:
|
|
case LEFT:
|
|
default:
|
|
return Alignment.ALIGN_NORMAL;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* This method and {@link #getGravityTextAlignment()} is modified from Android framework
|
|
* TextView's private method getLayoutAlignment(). Please note that the logic here assumes the
|
|
* actual text direction is the same as the layout direction, which is not always the case,
|
|
* especially when the text mixes different languages. However, this is probably the best we can
|
|
* do for now, unless we have a good way to detect the final text direction being used by
|
|
* TextView.
|
|
*/
|
|
private Alignment getActualTextAlignment() {
|
|
switch (getTextAlignment()) {
|
|
case TEXT_ALIGNMENT_GRAVITY:
|
|
return getGravityTextAlignment();
|
|
case TEXT_ALIGNMENT_CENTER:
|
|
return Alignment.ALIGN_CENTER;
|
|
case TEXT_ALIGNMENT_TEXT_END:
|
|
case TEXT_ALIGNMENT_VIEW_END:
|
|
return Alignment.ALIGN_OPPOSITE;
|
|
case TEXT_ALIGNMENT_TEXT_START:
|
|
case TEXT_ALIGNMENT_VIEW_START:
|
|
case TEXT_ALIGNMENT_INHERIT:
|
|
default:
|
|
return Alignment.ALIGN_NORMAL;
|
|
}
|
|
}
|
|
|
|
private void updateIconPosition(int buttonWidth, int buttonHeight) {
|
|
if (icon == null || getLayout() == null) {
|
|
return;
|
|
}
|
|
|
|
if (isIconStart() || isIconEnd()) {
|
|
iconTop = 0;
|
|
|
|
Alignment textAlignment = getActualTextAlignment();
|
|
if (iconGravity == ICON_GRAVITY_START
|
|
|| iconGravity == ICON_GRAVITY_END
|
|
|| (iconGravity == ICON_GRAVITY_TEXT_START && textAlignment == Alignment.ALIGN_NORMAL)
|
|
|| (iconGravity == ICON_GRAVITY_TEXT_END && textAlignment == Alignment.ALIGN_OPPOSITE)) {
|
|
iconLeft = 0;
|
|
updateIcon(/* needsIconReset= */ false);
|
|
return;
|
|
}
|
|
|
|
int localIconSize = iconSize == 0 ? icon.getIntrinsicWidth() : iconSize;
|
|
int availableWidth =
|
|
buttonWidth
|
|
- getTextLayoutWidth()
|
|
- getPaddingEnd()
|
|
- localIconSize
|
|
- iconPadding
|
|
- getPaddingStart();
|
|
int newIconLeft =
|
|
textAlignment == Alignment.ALIGN_CENTER ? availableWidth / 2 : availableWidth;
|
|
|
|
// Only flip the bound value if either isLayoutRTL() or iconGravity is textEnd, but not both
|
|
if (isLayoutRTL() != (iconGravity == ICON_GRAVITY_TEXT_END)) {
|
|
newIconLeft = -newIconLeft;
|
|
}
|
|
|
|
if (iconLeft != newIconLeft) {
|
|
iconLeft = newIconLeft;
|
|
updateIcon(/* needsIconReset= */ false);
|
|
}
|
|
} else if (isIconTop()) {
|
|
iconLeft = 0;
|
|
if (iconGravity == ICON_GRAVITY_TOP) {
|
|
iconTop = 0;
|
|
updateIcon(/* needsIconReset= */ false);
|
|
return;
|
|
}
|
|
|
|
int localIconSize = iconSize == 0 ? icon.getIntrinsicHeight() : iconSize;
|
|
int newIconTop =
|
|
max(
|
|
0, // Always put the icon on top if the content height is taller than the button.
|
|
(buttonHeight
|
|
- getTextHeight()
|
|
- getPaddingTop()
|
|
- localIconSize
|
|
- iconPadding
|
|
- getPaddingBottom())
|
|
/ 2);
|
|
|
|
if (iconTop != newIconTop) {
|
|
iconTop = newIconTop;
|
|
updateIcon(/* needsIconReset= */ false);
|
|
}
|
|
}
|
|
}
|
|
|
|
private int getTextLayoutWidth() {
|
|
float maxWidth = 0;
|
|
int lineCount = getLineCount();
|
|
for (int line = 0; line < lineCount; line++) {
|
|
maxWidth = max(maxWidth, getLayout().getLineWidth(line));
|
|
}
|
|
return (int) ceil(maxWidth);
|
|
}
|
|
|
|
private int getTextHeight() {
|
|
if (getLineCount() > 1) {
|
|
// If it's multi-line, return the internal text layout's height.
|
|
return getLayout().getHeight();
|
|
}
|
|
Paint textPaint = getPaint();
|
|
String buttonText = getText().toString();
|
|
if (getTransformationMethod() != null) {
|
|
// if text is transformed, add that transformation to to ensure correct calculation
|
|
// of icon padding.
|
|
buttonText = getTransformationMethod().getTransformation(buttonText, this).toString();
|
|
}
|
|
|
|
Rect bounds = new Rect();
|
|
textPaint.getTextBounds(buttonText, 0, buttonText.length(), bounds);
|
|
|
|
return min(bounds.height(), getLayout().getHeight());
|
|
}
|
|
|
|
private boolean isLayoutRTL() {
|
|
return getLayoutDirection() == View.LAYOUT_DIRECTION_RTL;
|
|
}
|
|
|
|
/**
|
|
* Update the button's background without changing the background state in {@link
|
|
* MaterialButtonHelper}. This should be used when we initially set the background drawable
|
|
* created by {@link MaterialButtonHelper}.
|
|
*
|
|
* @param background Background to set on this button
|
|
*/
|
|
void setInternalBackground(Drawable background) {
|
|
super.setBackgroundDrawable(background);
|
|
}
|
|
|
|
@Override
|
|
public void setCompoundDrawablePadding(@Px int padding) {
|
|
if (getCompoundDrawablePadding() != padding) {
|
|
originalWidth = UNSET;
|
|
}
|
|
super.setCompoundDrawablePadding(padding);
|
|
}
|
|
|
|
/**
|
|
* Sets the padding between the button icon and the button text, if icon is present.
|
|
*
|
|
* @param iconPadding Padding between the button icon and the button text, if icon is present.
|
|
* @attr ref com.google.android.material.R.styleable#MaterialButton_iconPadding
|
|
* @see #getIconPadding()
|
|
*/
|
|
public void setIconPadding(@Px int iconPadding) {
|
|
if (this.iconPadding != iconPadding) {
|
|
this.iconPadding = iconPadding;
|
|
setCompoundDrawablePadding(iconPadding);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Gets the padding between the button icon and the button text, if icon is present.
|
|
*
|
|
* @return Padding between the button icon and the button text, if icon is present.
|
|
* @attr ref com.google.android.material.R.styleable#MaterialButton_iconPadding
|
|
* @see #setIconPadding(int)
|
|
*/
|
|
@Attribute("com.google.android.material:iconPadding")
|
|
@Px
|
|
public int getIconPadding() {
|
|
return iconPadding;
|
|
}
|
|
|
|
/**
|
|
* Sets the width and height of the icon. Use 0 to use source Drawable size.
|
|
*
|
|
* @param iconSize new dimension for width and height of the icon in pixels.
|
|
* @attr ref com.google.android.material.R.styleable#MaterialButton_iconSize
|
|
* @see #getIconSize()
|
|
*/
|
|
public void setIconSize(@Px int iconSize) {
|
|
if (iconSize < 0) {
|
|
throw new IllegalArgumentException("iconSize cannot be less than 0");
|
|
}
|
|
|
|
if (this.iconSize != iconSize) {
|
|
if (maybeRunAfterWidthAnimation(() -> setIconSize(iconSize))) {
|
|
return;
|
|
}
|
|
originalWidth = UNSET;
|
|
this.iconSize = iconSize;
|
|
updateIcon(/* needsIconReset= */ true);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns the size of the icon if it was set.
|
|
*
|
|
* @return Returns the size of the icon if it was set in pixels, 0 otherwise.
|
|
* @attr ref com.google.android.material.R.styleable#MaterialButton_iconSize
|
|
* @see #setIconSize(int)
|
|
*/
|
|
@Px
|
|
public int getIconSize() {
|
|
return iconSize;
|
|
}
|
|
|
|
/**
|
|
* Sets the icon to show for this button. By default, this icon will be shown on the left side of
|
|
* the button.
|
|
*
|
|
* @param icon Drawable to use for the button's icon.
|
|
* @attr ref com.google.android.material.R.styleable#MaterialButton_icon
|
|
* @see #setIconResource(int)
|
|
* @see #getIcon()
|
|
*/
|
|
public void setIcon(@Nullable Drawable icon) {
|
|
if (this.icon != icon) {
|
|
if (maybeRunAfterWidthAnimation(() -> setIcon(icon))) {
|
|
return;
|
|
}
|
|
originalWidth = UNSET;
|
|
this.icon = icon;
|
|
updateIcon(/* needsIconReset= */ true);
|
|
updateIconPosition(getMeasuredWidth(), getMeasuredHeight());
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Sets the icon drawable resource to show for this button. By default, this icon will be shown on
|
|
* the left side of the button.
|
|
*
|
|
* @param iconResourceId Drawable resource ID to use for the button's icon.
|
|
* @attr ref com.google.android.material.R.styleable#MaterialButton_icon
|
|
* @see #setIcon(Drawable)
|
|
* @see #getIcon()
|
|
*/
|
|
public void setIconResource(@DrawableRes int iconResourceId) {
|
|
Drawable icon = null;
|
|
if (iconResourceId != 0) {
|
|
icon = AppCompatResources.getDrawable(getContext(), iconResourceId);
|
|
}
|
|
setIcon(icon);
|
|
}
|
|
|
|
/**
|
|
* Gets the icon shown for this button, if present.
|
|
*
|
|
* @return Icon shown for this button, if present.
|
|
* @attr ref com.google.android.material.R.styleable#MaterialButton_icon
|
|
* @see #setIcon(Drawable)
|
|
* @see #setIconResource(int)
|
|
*/
|
|
public Drawable getIcon() {
|
|
return icon;
|
|
}
|
|
|
|
/**
|
|
* Sets the tint list for the icon shown for this button.
|
|
*
|
|
* @param iconTint Tint list for the icon shown for this button.
|
|
* @attr ref com.google.android.material.R.styleable#MaterialButton_iconTint
|
|
* @see #setIconTintResource(int)
|
|
* @see #getIconTint()
|
|
*/
|
|
public void setIconTint(@Nullable ColorStateList iconTint) {
|
|
if (this.iconTint != iconTint) {
|
|
this.iconTint = iconTint;
|
|
updateIcon(/* needsIconReset= */ false);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Sets the tint list color resource for the icon shown for this button.
|
|
*
|
|
* @param iconTintResourceId Tint list color resource for the icon shown for this button.
|
|
* @attr ref com.google.android.material.R.styleable#MaterialButton_iconTint
|
|
* @see #setIconTint(ColorStateList)
|
|
* @see #getIconTint()
|
|
*/
|
|
public void setIconTintResource(@ColorRes int iconTintResourceId) {
|
|
setIconTint(AppCompatResources.getColorStateList(getContext(), iconTintResourceId));
|
|
}
|
|
|
|
/**
|
|
* Gets the tint list for the icon shown for this button.
|
|
*
|
|
* @return Tint list for the icon shown for this button.
|
|
* @attr ref com.google.android.material.R.styleable#MaterialButton_iconTint
|
|
* @see #setIconTint(ColorStateList)
|
|
* @see #setIconTintResource(int)
|
|
*/
|
|
public ColorStateList getIconTint() {
|
|
return iconTint;
|
|
}
|
|
|
|
/**
|
|
* Sets the tint mode for the icon shown for this button.
|
|
*
|
|
* @param iconTintMode Tint mode for the icon shown for this button.
|
|
* @attr ref com.google.android.material.R.styleable#MaterialButton_iconTintMode
|
|
* @see #getIconTintMode()
|
|
*/
|
|
public void setIconTintMode(Mode iconTintMode) {
|
|
if (this.iconTintMode != iconTintMode) {
|
|
this.iconTintMode = iconTintMode;
|
|
updateIcon(/* needsIconReset= */ false);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Gets the tint mode for the icon shown for this button.
|
|
*
|
|
* @return Tint mode for the icon shown for this button.
|
|
* @attr ref com.google.android.material.R.styleable#MaterialButton_iconTintMode
|
|
* @see #setIconTintMode(Mode)
|
|
*/
|
|
public Mode getIconTintMode() {
|
|
return iconTintMode;
|
|
}
|
|
|
|
/**
|
|
* Updates the icon, icon tint, and icon tint mode for this button.
|
|
*
|
|
* @param needsIconReset Whether to force the drawable to be set
|
|
*/
|
|
private void updateIcon(boolean needsIconReset) {
|
|
if (icon != null) {
|
|
icon = DrawableCompat.wrap(icon).mutate();
|
|
icon.setTintList(iconTint);
|
|
if (iconTintMode != null) {
|
|
icon.setTintMode(iconTintMode);
|
|
}
|
|
|
|
int width = iconSize != 0 ? iconSize : icon.getIntrinsicWidth();
|
|
int height = iconSize != 0 ? iconSize : icon.getIntrinsicHeight();
|
|
icon.setBounds(iconLeft, iconTop, iconLeft + width, iconTop + height);
|
|
icon.setVisible(true, needsIconReset);
|
|
}
|
|
|
|
// Forced icon update
|
|
if (needsIconReset) {
|
|
resetIconDrawable();
|
|
return;
|
|
}
|
|
|
|
// Otherwise only update if the icon or the position has changed
|
|
Drawable[] existingDrawables = getCompoundDrawablesRelative();
|
|
Drawable drawableStart = existingDrawables[0];
|
|
Drawable drawableTop = existingDrawables[1];
|
|
Drawable drawableEnd = existingDrawables[2];
|
|
boolean hasIconChanged =
|
|
(isIconStart() && drawableStart != icon)
|
|
|| (isIconEnd() && drawableEnd != icon)
|
|
|| (isIconTop() && drawableTop != icon);
|
|
|
|
if (hasIconChanged) {
|
|
resetIconDrawable();
|
|
}
|
|
}
|
|
|
|
private void resetIconDrawable() {
|
|
if (isIconStart()) {
|
|
setCompoundDrawablesRelative(icon, null, null, null);
|
|
} else if (isIconEnd()) {
|
|
setCompoundDrawablesRelative(null, null, icon, null);
|
|
} else if (isIconTop()) {
|
|
setCompoundDrawablesRelative(null, icon, null, null);
|
|
}
|
|
}
|
|
|
|
private boolean isIconStart() {
|
|
return iconGravity == ICON_GRAVITY_START || iconGravity == ICON_GRAVITY_TEXT_START;
|
|
}
|
|
|
|
private boolean isIconEnd() {
|
|
return iconGravity == ICON_GRAVITY_END || iconGravity == ICON_GRAVITY_TEXT_END;
|
|
}
|
|
|
|
private boolean isIconTop() {
|
|
return iconGravity == ICON_GRAVITY_TOP || iconGravity == ICON_GRAVITY_TEXT_TOP;
|
|
}
|
|
|
|
/**
|
|
* Sets the ripple color for this button.
|
|
*
|
|
* @param rippleColor Color to use for the ripple.
|
|
* @attr ref com.google.android.material.R.styleable#MaterialButton_rippleColor
|
|
* @see #setRippleColorResource(int)
|
|
* @see #getRippleColor()
|
|
*/
|
|
public void setRippleColor(@Nullable ColorStateList rippleColor) {
|
|
if (isUsingOriginalBackground()) {
|
|
materialButtonHelper.setRippleColor(rippleColor);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Sets the ripple color resource for this button.
|
|
*
|
|
* @param rippleColorResourceId Color resource to use for the ripple.
|
|
* @attr ref com.google.android.material.R.styleable#MaterialButton_rippleColor
|
|
* @see #setRippleColor(ColorStateList)
|
|
* @see #getRippleColor()
|
|
*/
|
|
public void setRippleColorResource(@ColorRes int rippleColorResourceId) {
|
|
if (isUsingOriginalBackground()) {
|
|
setRippleColor(AppCompatResources.getColorStateList(getContext(), rippleColorResourceId));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Gets the ripple color for this button.
|
|
*
|
|
* @return The color used for the ripple.
|
|
* @attr ref com.google.android.material.R.styleable#MaterialButton_rippleColor
|
|
* @see #setRippleColor(ColorStateList)
|
|
* @see #setRippleColorResource(int)
|
|
*/
|
|
@Nullable
|
|
public ColorStateList getRippleColor() {
|
|
return isUsingOriginalBackground() ? materialButtonHelper.getRippleColor() : null;
|
|
}
|
|
|
|
/**
|
|
* Sets the stroke color for this button. Both stroke color and stroke width must be set for a
|
|
* stroke to be drawn.
|
|
*
|
|
* @param strokeColor Color to use for the stroke.
|
|
* @attr ref com.google.android.material.R.styleable#MaterialButton_strokeColor
|
|
* @see #setStrokeColorResource(int)
|
|
* @see #getStrokeColor()
|
|
*/
|
|
public void setStrokeColor(@Nullable ColorStateList strokeColor) {
|
|
if (isUsingOriginalBackground()) {
|
|
materialButtonHelper.setStrokeColor(strokeColor);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Sets the stroke color resource for this button. Both stroke color and stroke width must be set
|
|
* for a stroke to be drawn.
|
|
*
|
|
* @param strokeColorResourceId Color resource to use for the stroke.
|
|
* @attr ref com.google.android.material.R.styleable#MaterialButton_strokeColor
|
|
* @see #setStrokeColor(ColorStateList)
|
|
* @see #getStrokeColor()
|
|
*/
|
|
public void setStrokeColorResource(@ColorRes int strokeColorResourceId) {
|
|
if (isUsingOriginalBackground()) {
|
|
setStrokeColor(AppCompatResources.getColorStateList(getContext(), strokeColorResourceId));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Gets the stroke color for this button.
|
|
*
|
|
* @return The color used for the stroke.
|
|
* @attr ref com.google.android.material.R.styleable#MaterialButton_strokeColor
|
|
* @see #setStrokeColor(ColorStateList)
|
|
* @see #setStrokeColorResource(int)
|
|
*/
|
|
public ColorStateList getStrokeColor() {
|
|
return isUsingOriginalBackground() ? materialButtonHelper.getStrokeColor() : null;
|
|
}
|
|
|
|
/**
|
|
* Sets the stroke width for this button. Both stroke color and stroke width must be set for a
|
|
* stroke to be drawn.
|
|
*
|
|
* @param strokeWidth Stroke width for this button.
|
|
* @attr ref com.google.android.material.R.styleable#MaterialButton_strokeWidth
|
|
* @see #setStrokeWidthResource(int)
|
|
* @see #getStrokeWidth()
|
|
*/
|
|
public void setStrokeWidth(@Px int strokeWidth) {
|
|
if (isUsingOriginalBackground()) {
|
|
materialButtonHelper.setStrokeWidth(strokeWidth);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Sets the stroke width dimension resource for this button. Both stroke color and stroke width
|
|
* must be set for a stroke to be drawn.
|
|
*
|
|
* @param strokeWidthResourceId Stroke width dimension resource for this button.
|
|
* @attr ref com.google.android.material.R.styleable#MaterialButton_strokeWidth
|
|
* @see #setStrokeWidth(int)
|
|
* @see #getStrokeWidth()
|
|
*/
|
|
public void setStrokeWidthResource(@DimenRes int strokeWidthResourceId) {
|
|
if (isUsingOriginalBackground()) {
|
|
setStrokeWidth(getResources().getDimensionPixelSize(strokeWidthResourceId));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Gets the stroke width for this button.
|
|
*
|
|
* @return Stroke width for this button.
|
|
* @attr ref com.google.android.material.R.styleable#MaterialButton_strokeWidth
|
|
* @see #setStrokeWidth(int)
|
|
* @see #setStrokeWidthResource(int)
|
|
*/
|
|
@Px
|
|
public int getStrokeWidth() {
|
|
return isUsingOriginalBackground() ? materialButtonHelper.getStrokeWidth() : 0;
|
|
}
|
|
|
|
/**
|
|
* Sets the corner radius for this button.
|
|
*
|
|
* @param cornerRadius Corner radius for this button.
|
|
* @attr ref com.google.android.material.R.styleable#MaterialButton_cornerRadius
|
|
* @see #setCornerRadiusResource(int)
|
|
* @see #getCornerRadius()
|
|
*/
|
|
public void setCornerRadius(@Px int cornerRadius) {
|
|
if (isUsingOriginalBackground()) {
|
|
materialButtonHelper.setCornerRadius(cornerRadius);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Sets the corner radius dimension resource for this button.
|
|
*
|
|
* @param cornerRadiusResourceId Corner radius dimension resource for this button.
|
|
* @attr ref com.google.android.material.R.styleable#MaterialButton_cornerRadius
|
|
* @see #setCornerRadius(int)
|
|
* @see #getCornerRadius()
|
|
*/
|
|
public void setCornerRadiusResource(@DimenRes int cornerRadiusResourceId) {
|
|
if (isUsingOriginalBackground()) {
|
|
setCornerRadius(getResources().getDimensionPixelSize(cornerRadiusResourceId));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Gets the corner radius for this button.
|
|
*
|
|
* @return Corner radius for this button.
|
|
* @attr ref com.google.android.material.R.styleable#MaterialButton_cornerRadius
|
|
* @see #setCornerRadius(int)
|
|
* @see #setCornerRadiusResource(int)
|
|
*/
|
|
@Px
|
|
public int getCornerRadius() {
|
|
return isUsingOriginalBackground() ? materialButtonHelper.getCornerRadius() : 0;
|
|
}
|
|
|
|
/**
|
|
* Gets the icon gravity for this button
|
|
*
|
|
* @return Icon gravity of the button.
|
|
* @attr ref com.google.android.material.R.styleable#MaterialButton_iconGravity
|
|
* @see #setIconGravity(int)
|
|
*/
|
|
@IconGravity
|
|
public int getIconGravity() {
|
|
return iconGravity;
|
|
}
|
|
|
|
/**
|
|
* Sets the icon gravity for this button
|
|
*
|
|
* @attr ref com.google.android.material.R.styleable#MaterialButton_iconGravity
|
|
* @param iconGravity icon gravity for this button
|
|
* @see #getIconGravity()
|
|
*/
|
|
public void setIconGravity(@IconGravity int iconGravity) {
|
|
if (this.iconGravity != iconGravity) {
|
|
this.iconGravity = iconGravity;
|
|
updateIconPosition(getMeasuredWidth(), getMeasuredHeight());
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Sets the button bottom inset
|
|
*
|
|
* @attr ref com.google.android.material.R.styleable#MaterialButton_android_insetBottom
|
|
* @see #getInsetBottom()
|
|
*/
|
|
public void setInsetBottom(@Dimension int insetBottom) {
|
|
materialButtonHelper.setInsetBottom(insetBottom);
|
|
}
|
|
|
|
/**
|
|
* Gets the bottom inset for this button
|
|
*
|
|
* @attr ref com.google.android.material.R.styleable#MaterialButton_android_insetBottom
|
|
* @see #setInsetTop(int)
|
|
*/
|
|
@Dimension
|
|
public int getInsetBottom() {
|
|
return materialButtonHelper.getInsetBottom();
|
|
}
|
|
|
|
/**
|
|
* Sets the button top inset
|
|
*
|
|
* @attr ref com.google.android.material.R.styleable#MaterialButton_android_insetTop
|
|
* @see #getInsetBottom()
|
|
*/
|
|
public void setInsetTop(@Dimension int insetTop) {
|
|
materialButtonHelper.setInsetTop(insetTop);
|
|
}
|
|
|
|
/**
|
|
* Gets the top inset for this button
|
|
*
|
|
* @attr ref com.google.android.material.R.styleable#MaterialButton_android_insetTop
|
|
* @see #setInsetTop(int)
|
|
*/
|
|
@Dimension
|
|
public int getInsetTop() {
|
|
return materialButtonHelper.getInsetTop();
|
|
}
|
|
|
|
@Override
|
|
protected int[] onCreateDrawableState(int extraSpace) {
|
|
final int[] drawableState = super.onCreateDrawableState(extraSpace + 2);
|
|
|
|
if (isCheckable()) {
|
|
mergeDrawableStates(drawableState, CHECKABLE_STATE_SET);
|
|
}
|
|
|
|
if (isChecked()) {
|
|
mergeDrawableStates(drawableState, CHECKED_STATE_SET);
|
|
}
|
|
|
|
return drawableState;
|
|
}
|
|
|
|
/**
|
|
* Add a listener that will be invoked when the checked state of this MaterialButton changes. See
|
|
* {@link OnCheckedChangeListener}.
|
|
*
|
|
* <p>Components that add a listener should take care to remove it when finished via {@link
|
|
* #removeOnCheckedChangeListener(OnCheckedChangeListener)}.
|
|
*
|
|
* @param listener listener to add
|
|
*/
|
|
public void addOnCheckedChangeListener(@NonNull OnCheckedChangeListener listener) {
|
|
onCheckedChangeListeners.add(listener);
|
|
}
|
|
|
|
/**
|
|
* Remove a listener that was previously added via {@link
|
|
* #addOnCheckedChangeListener(OnCheckedChangeListener)}.
|
|
*
|
|
* @param listener listener to remove
|
|
*/
|
|
public void removeOnCheckedChangeListener(@NonNull OnCheckedChangeListener listener) {
|
|
onCheckedChangeListeners.remove(listener);
|
|
}
|
|
|
|
/** Remove all previously added {@link OnCheckedChangeListener}s. */
|
|
public void clearOnCheckedChangeListeners() {
|
|
onCheckedChangeListeners.clear();
|
|
}
|
|
|
|
@Override
|
|
public void setChecked(boolean checked) {
|
|
setCheckedInternal(checked);
|
|
}
|
|
|
|
private void setCheckedInternal(boolean checked) {
|
|
if (isCheckable() && this.checked != checked) {
|
|
this.checked = checked;
|
|
|
|
refreshDrawableState();
|
|
|
|
// Report checked state change to the parent toggle group, if there is one
|
|
if (getParent() instanceof MaterialButtonToggleGroup) {
|
|
((MaterialButtonToggleGroup) getParent()).onButtonCheckedStateChanged(this, this.checked);
|
|
}
|
|
|
|
// Avoid infinite recursions if setChecked() is called from a listener
|
|
if (broadcasting) {
|
|
return;
|
|
}
|
|
|
|
broadcasting = true;
|
|
for (OnCheckedChangeListener listener : onCheckedChangeListeners) {
|
|
listener.onCheckedChanged(this, this.checked);
|
|
}
|
|
broadcasting = false;
|
|
}
|
|
}
|
|
|
|
@Override
|
|
public boolean isChecked() {
|
|
return checked;
|
|
}
|
|
|
|
@Override
|
|
public void toggle() {
|
|
setChecked(!checked);
|
|
}
|
|
|
|
@Override
|
|
public boolean performClick() {
|
|
if (isEnabled() && materialButtonHelper.isToggleCheckedStateOnClick()) {
|
|
toggle();
|
|
}
|
|
|
|
return super.performClick();
|
|
}
|
|
|
|
/**
|
|
* Returns whether or not clicking the button will toggle the checked state.
|
|
*
|
|
* @see #setToggleCheckedStateOnClick(boolean)
|
|
* @attr ref R.styleable#toggleCheckedStateOnClick
|
|
*/
|
|
public boolean isToggleCheckedStateOnClick() {
|
|
return materialButtonHelper.isToggleCheckedStateOnClick();
|
|
}
|
|
|
|
/**
|
|
* Sets whether or not to toggle the button checked state on click.
|
|
*
|
|
* @param toggleCheckedStateOnClick whether or not to toggle the checked state on click.
|
|
* @attr ref R.styleable#toggleCheckedStateOnClick
|
|
*/
|
|
public void setToggleCheckedStateOnClick(boolean toggleCheckedStateOnClick) {
|
|
materialButtonHelper.setToggleCheckedStateOnClick(toggleCheckedStateOnClick);
|
|
}
|
|
|
|
/**
|
|
* Returns whether this MaterialButton is checkable.
|
|
*
|
|
* @see #setCheckable(boolean)
|
|
* @attr ref com.google.android.material.R.styleable#MaterialButton_android_checkable
|
|
*/
|
|
public boolean isCheckable() {
|
|
return materialButtonHelper != null && materialButtonHelper.isCheckable();
|
|
}
|
|
|
|
/**
|
|
* Sets whether this MaterialButton is checkable.
|
|
*
|
|
* @param checkable Whether this button is checkable.
|
|
* @attr ref com.google.android.material.R.styleable#MaterialButton_android_checkable
|
|
*/
|
|
public void setCheckable(boolean checkable) {
|
|
if (isUsingOriginalBackground()) {
|
|
materialButtonHelper.setCheckable(checkable);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Sets the {@link ShapeAppearanceModel} used for this {@link MaterialButton}'s original
|
|
* drawables.
|
|
*
|
|
* @throws IllegalStateException if the MaterialButton's background has been overwritten.
|
|
*/
|
|
@Override
|
|
public void setShapeAppearanceModel(@NonNull ShapeAppearanceModel shapeAppearanceModel) {
|
|
if (isUsingOriginalBackground()) {
|
|
materialButtonHelper.setShapeAppearance(shapeAppearanceModel);
|
|
} else {
|
|
throw new IllegalStateException(
|
|
"Attempted to set ShapeAppearanceModel on a MaterialButton which has an overwritten"
|
|
+ " background.");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns the {@link ShapeAppearanceModel} used for this {@link MaterialButton}'s original
|
|
* drawables.
|
|
*
|
|
* <p>This {@link ShapeAppearanceModel} can be modified to change the component's shape.
|
|
*
|
|
* @throws IllegalStateException if the MaterialButton's background has been overwritten.
|
|
*/
|
|
@NonNull
|
|
@Override
|
|
public ShapeAppearanceModel getShapeAppearanceModel() {
|
|
if (isUsingOriginalBackground()) {
|
|
return materialButtonHelper.getShapeAppearanceModel();
|
|
} else {
|
|
throw new IllegalStateException(
|
|
"Attempted to get ShapeAppearanceModel from a MaterialButton which has an overwritten"
|
|
+ " background.");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Sets the {@link ShapeAppearance} used for this {@link MaterialButton}'s original
|
|
* drawables.
|
|
*
|
|
* @throws IllegalStateException if the MaterialButton's background has been overwritten.
|
|
* @hide
|
|
*/
|
|
@RestrictTo(LIBRARY_GROUP)
|
|
public void setShapeAppearance(
|
|
@NonNull ShapeAppearance shapeAppearance) {
|
|
if (isUsingOriginalBackground()) {
|
|
if (materialButtonHelper.getCornerSpringForce() == null && shapeAppearance.isStateful()) {
|
|
materialButtonHelper.setCornerSpringForce(createSpringForce());
|
|
}
|
|
materialButtonHelper.setShapeAppearance(shapeAppearance);
|
|
} else {
|
|
throw new IllegalStateException(
|
|
"Attempted to set ShapeAppearance on a MaterialButton which has an"
|
|
+ " overwritten background.");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns the {@link ShapeAppearance} used for this {@link MaterialButton}'s
|
|
* original drawables.
|
|
*
|
|
* <p>This {@link ShapeAppearance} can be modified to change the component's shape.
|
|
*
|
|
* @throws IllegalStateException if the MaterialButton's background has been overwritten.
|
|
* @hide
|
|
*/
|
|
@NonNull
|
|
@RestrictTo(LIBRARY_GROUP)
|
|
public ShapeAppearance getShapeAppearance() {
|
|
if (isUsingOriginalBackground()) {
|
|
return materialButtonHelper.getShapeAppearance();
|
|
} else {
|
|
throw new IllegalStateException(
|
|
"Attempted to get ShapeAppearance from a MaterialButton which has an"
|
|
+ " overwritten background.");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Sets the corner spring force for this {@link MaterialButton}.
|
|
*
|
|
* @param springForce The new {@link SpringForce} object.
|
|
* @hide
|
|
*/
|
|
@RestrictTo(LIBRARY_GROUP)
|
|
public void setCornerSpringForce(@NonNull SpringForce springForce) {
|
|
materialButtonHelper.setCornerSpringForce(springForce);
|
|
}
|
|
|
|
/**
|
|
* Returns the corner spring force for this {@link MaterialButton}.
|
|
*
|
|
* @hide
|
|
*/
|
|
@Nullable
|
|
@RestrictTo(LIBRARY_GROUP)
|
|
public SpringForce getCornerSpringForce() {
|
|
return materialButtonHelper.getCornerSpringForce();
|
|
}
|
|
|
|
/**
|
|
* Register a callback to be invoked when the pressed state of this button changes. This callback
|
|
* is used for internal purpose only.
|
|
*/
|
|
void setOnPressedChangeListenerInternal(@Nullable OnPressedChangeListener listener) {
|
|
onPressedChangeListenerInternal = listener;
|
|
}
|
|
|
|
@Override
|
|
public void setPressed(boolean pressed) {
|
|
if (onPressedChangeListenerInternal != null) {
|
|
onPressedChangeListenerInternal.onPressedChanged(this, pressed);
|
|
}
|
|
super.setPressed(pressed);
|
|
maybeAnimateSize(/* skipAnimation= */ false);
|
|
}
|
|
|
|
private boolean isUsingOriginalBackground() {
|
|
return materialButtonHelper != null && !materialButtonHelper.isBackgroundOverwritten();
|
|
}
|
|
|
|
void setShouldDrawSurfaceColorStroke(boolean shouldDrawSurfaceColorStroke) {
|
|
if (isUsingOriginalBackground()) {
|
|
materialButtonHelper.setShouldDrawSurfaceColorStroke(shouldDrawSurfaceColorStroke);
|
|
}
|
|
}
|
|
|
|
private void maybeAnimateSize(boolean skipAnimation) {
|
|
if (sizeChange == null) {
|
|
return;
|
|
}
|
|
if (widthIncreaseSpringAnimation == null) {
|
|
initializeSizeAnimation();
|
|
}
|
|
if (isInHorizontalButtonGroup) {
|
|
// Animate width.
|
|
int widthChange =
|
|
min(
|
|
calculateEffectiveWidthChangeMax(),
|
|
sizeChange
|
|
.getSizeChangeForState(getDrawableState())
|
|
.widthChange
|
|
.getChange(getWidth()));
|
|
widthIncreaseSpringAnimation.animateToFinalPosition(widthChange);
|
|
if (skipAnimation) {
|
|
widthIncreaseSpringAnimation.skipToEnd();
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Returns the effective width change max based on the width change direction. */
|
|
private int calculateEffectiveWidthChangeMax() {
|
|
switch (widthChangeDirection) {
|
|
case BOTH:
|
|
return this.widthChangeMax;
|
|
case START:
|
|
case END:
|
|
return this.widthChangeMax / 2;
|
|
case NONE:
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
private boolean isInHorizontalButtonGroup() {
|
|
return getParent() instanceof MaterialButtonGroup
|
|
&& ((MaterialButtonGroup) getParent()).getOrientation() == LinearLayout.HORIZONTAL;
|
|
}
|
|
|
|
void setSizeChange(@NonNull StateListSizeChange sizeChange) {
|
|
if (this.sizeChange != sizeChange) {
|
|
this.sizeChange = sizeChange;
|
|
maybeAnimateSize(/* skipAnimation= */ true);
|
|
}
|
|
}
|
|
|
|
void setWidthChangeMax(@Px int widthChangeMax) {
|
|
if (this.widthChangeMax != widthChangeMax) {
|
|
this.widthChangeMax = widthChangeMax;
|
|
maybeAnimateSize(/* skipAnimation= */ true);
|
|
}
|
|
}
|
|
|
|
void setWidthChangeDirection(@NonNull WidthChangeDirection widthChangeDirection) {
|
|
if (this.widthChangeDirection != widthChangeDirection) {
|
|
this.widthChangeDirection = widthChangeDirection;
|
|
maybeAnimateSize(/* skipAnimation= */ true);
|
|
}
|
|
}
|
|
|
|
@Px
|
|
int getAllowedWidthDecrease() {
|
|
return allowedWidthDecrease;
|
|
}
|
|
|
|
private float getDisplayedWidthIncrease() {
|
|
return displayedWidthIncrease;
|
|
}
|
|
|
|
private void setDisplayedWidthIncrease(float widthIncrease) {
|
|
if (displayedWidthIncrease != widthIncrease) {
|
|
displayedWidthIncrease = widthIncrease;
|
|
updatePaddingsAndSizeForWidthAnimation();
|
|
invalidate();
|
|
// Report width changed to the parent group.
|
|
if (getParent() instanceof MaterialButtonGroup) {
|
|
((MaterialButtonGroup) getParent())
|
|
.onButtonWidthChanged(this, (int) displayedWidthIncrease);
|
|
}
|
|
}
|
|
}
|
|
|
|
void setDisplayedWidthDecrease(int widthDecrease) {
|
|
displayedWidthDecrease = min(widthDecrease, allowedWidthDecrease);
|
|
updatePaddingsAndSizeForWidthAnimation();
|
|
invalidate();
|
|
}
|
|
|
|
/**
|
|
* Sets whether to enable the optical center feature.
|
|
*
|
|
* @param opticalCenterEnabled whether to enable optical centering.
|
|
* @see #isOpticalCenterEnabled()
|
|
*/
|
|
public void setOpticalCenterEnabled(boolean opticalCenterEnabled) {
|
|
if (this.opticalCenterEnabled != opticalCenterEnabled) {
|
|
this.opticalCenterEnabled = opticalCenterEnabled;
|
|
if (opticalCenterEnabled) {
|
|
materialButtonHelper.setCornerSizeChangeListener(
|
|
(diffX) -> {
|
|
int opticalCenterShift = (int) (diffX * OPTICAL_CENTER_RATIO);
|
|
if (this.opticalCenterShift != opticalCenterShift) {
|
|
this.opticalCenterShift = opticalCenterShift;
|
|
updatePaddingsAndSizeForWidthAnimation();
|
|
invalidate();
|
|
}
|
|
});
|
|
} else {
|
|
materialButtonHelper.setCornerSizeChangeListener(null);
|
|
}
|
|
// Perform the optical center shift calculation using a post, as the calculation depends on
|
|
// the button being fully laid out.
|
|
post(
|
|
() -> {
|
|
opticalCenterShift = getOpticalCenterShift();
|
|
updatePaddingsAndSizeForWidthAnimation();
|
|
invalidate();
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns whether the optical center feature is enabled.
|
|
*
|
|
* @see #setOpticalCenterEnabled(boolean)
|
|
*/
|
|
public boolean isOpticalCenterEnabled() {
|
|
return opticalCenterEnabled;
|
|
}
|
|
|
|
private void updatePaddingsAndSizeForWidthAnimation() {
|
|
int widthChange = (int) (displayedWidthIncrease - displayedWidthDecrease);
|
|
int paddingStartChange = widthChange / 2 + opticalCenterShift;
|
|
getLayoutParams().width = (int) (originalWidth + widthChange);
|
|
setPaddingRelative(
|
|
originalPaddingStart + paddingStartChange,
|
|
getPaddingTop(),
|
|
originalPaddingEnd + widthChange - paddingStartChange,
|
|
getPaddingBottom());
|
|
}
|
|
|
|
private int getOpticalCenterShift() {
|
|
if (opticalCenterEnabled && isInHorizontalButtonGroup) {
|
|
MaterialShapeDrawable materialShapeDrawable = materialButtonHelper.getMaterialShapeDrawable();
|
|
if (materialShapeDrawable != null) {
|
|
return (int) (materialShapeDrawable.getCornerSizeDiffX() * OPTICAL_CENTER_RATIO);
|
|
}
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
// ******************* Properties *******************
|
|
|
|
private static final FloatPropertyCompat<MaterialButton> WIDTH_INCREASE =
|
|
new FloatPropertyCompat<MaterialButton>("widthIncrease") {
|
|
@Override
|
|
public float getValue(MaterialButton button) {
|
|
return button.getDisplayedWidthIncrease();
|
|
}
|
|
|
|
@Override
|
|
public void setValue(MaterialButton button, float value) {
|
|
button.setDisplayedWidthIncrease(value);
|
|
}
|
|
};
|
|
|
|
static class SavedState extends AbsSavedState {
|
|
|
|
boolean checked;
|
|
|
|
public SavedState(Parcelable superState) {
|
|
super(superState);
|
|
}
|
|
|
|
public SavedState(@NonNull Parcel source, ClassLoader loader) {
|
|
super(source, loader);
|
|
if (loader == null) {
|
|
loader = getClass().getClassLoader();
|
|
}
|
|
readFromParcel(source);
|
|
}
|
|
|
|
@Override
|
|
public void writeToParcel(@NonNull Parcel out, int flags) {
|
|
super.writeToParcel(out, flags);
|
|
out.writeInt(checked ? 1 : 0);
|
|
}
|
|
|
|
private void readFromParcel(@NonNull Parcel in) {
|
|
checked = in.readInt() == 1;
|
|
}
|
|
|
|
public static final Creator<SavedState> CREATOR =
|
|
new ClassLoaderCreator<SavedState>() {
|
|
@NonNull
|
|
@Override
|
|
public SavedState createFromParcel(@NonNull Parcel in, ClassLoader loader) {
|
|
return new SavedState(in, loader);
|
|
}
|
|
|
|
@NonNull
|
|
@Override
|
|
public SavedState createFromParcel(@NonNull Parcel in) {
|
|
return new SavedState(in, null);
|
|
}
|
|
|
|
@NonNull
|
|
@Override
|
|
public SavedState[] newArray(int size) {
|
|
return new SavedState[size];
|
|
}
|
|
};
|
|
}
|
|
}
|