diff --git a/lib/java/com/google/android/material/button/MaterialButton.java b/lib/java/com/google/android/material/button/MaterialButton.java index bea00ad7c9d..66a08f74400 100644 --- a/lib/java/com/google/android/material/button/MaterialButton.java +++ b/lib/java/com/google/android/material/button/MaterialButton.java @@ -246,8 +246,11 @@ interface OnPressedChangeListener { private int orientation = UNSET; private float originalWidth = UNSET; + private float originalHeight = UNSET; @Px private int originalPaddingStart = UNSET; + @Px private int originalPaddingTop = UNSET; @Px private int originalPaddingEnd = UNSET; + @Px private int originalPaddingBottom = UNSET; @Nullable private LayoutParams originalLayoutParams; @@ -258,11 +261,16 @@ interface OnPressedChangeListener { // Fields for size morphing. @Px int allowedWidthDecrease = UNSET; + @Px int allowedHeightDecrease = UNSET; @Nullable StateListSizeChange sizeChange; @Px int widthChangeMax; + @Px int heightChangeMax; private float displayedWidthIncrease; private float displayedWidthDecrease; + private float displayedHeightIncrease; + private float displayedHeightDecrease; @Nullable private SpringAnimation widthIncreaseSpringAnimation; + @Nullable private SpringAnimation heightIncreaseSpringAnimation; public MaterialButton(@NonNull Context context) { this(context, null /* attrs */); @@ -324,11 +332,16 @@ public MaterialButton(@NonNull Context context, @Nullable AttributeSet attrs, in updateIcon(/* needsIconReset= */ icon != null); } - private void initializeSizeAnimation() { + private void initializeWidthAnimation() { widthIncreaseSpringAnimation = new SpringAnimation(this, WIDTH_INCREASE); widthIncreaseSpringAnimation.setSpring(createSpringForce()); } + private void initializeHeightAnimation() { + heightIncreaseSpringAnimation = new SpringAnimation(this, HEIGHT_INCREASE); + heightIncreaseSpringAnimation.setSpring(createSpringForce()); + } + private SpringForce createSpringForce() { return MotionUtils.resolveThemeSpringForce( getContext(), @@ -544,6 +557,7 @@ protected void onLayout(boolean changed, int left, int top, int right, int botto if (orientation != curOrientation) { orientation = curOrientation; originalWidth = UNSET; + originalHeight = UNSET; } if (originalWidth == UNSET) { originalWidth = getMeasuredWidth(); @@ -560,6 +574,21 @@ && getParent() instanceof MaterialButtonGroup setLayoutParams(newLayoutParams); } } + if (originalHeight == UNSET) { + originalHeight = getMeasuredHeight(); + // The height morph leverage the height of the layout params. However, it's not available if + // layout_weight is used. We need to hardcode the height 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.height = (int) originalHeight; + setLayoutParams(newLayoutParams); + } + } if (allowedWidthDecrease == UNSET) { int localIconSizeAndPadding = @@ -568,13 +597,26 @@ && getParent() instanceof MaterialButtonGroup : getIconPadding() + (iconSize == 0 ? icon.getIntrinsicWidth() : iconSize); allowedWidthDecrease = getMeasuredWidth() - getTextLayoutWidth() - localIconSizeAndPadding; } + if (allowedHeightDecrease == UNSET) { + int localIconSizeAndPadding = + icon == null + ? 0 + : getIconPadding() + (iconSize == 0 ? icon.getIntrinsicHeight() : iconSize); + allowedHeightDecrease = getMeasuredHeight() - getTextLayoutHeight() - localIconSizeAndPadding; + } if (originalPaddingStart == UNSET) { originalPaddingStart = getPaddingStart(); } + if (originalPaddingTop == UNSET) { + originalPaddingTop = getPaddingTop(); + } if (originalPaddingEnd == UNSET) { originalPaddingEnd = getPaddingEnd(); } + if (originalPaddingBottom == UNSET) { + originalPaddingBottom = getPaddingBottom(); + } isInHorizontalButtonGroup = isInHorizontalButtonGroup(); } @@ -583,6 +625,7 @@ void recoverOriginalLayoutParams() { setLayoutParams(originalLayoutParams); originalLayoutParams = null; originalWidth = UNSET; + originalHeight = UNSET; } } @@ -592,6 +635,12 @@ public void setWidth(@Px int pixels) { super.setWidth(pixels); } + @Override + public void setHeight(@Px int pixels) { + originalHeight = UNSET; + super.setHeight(pixels); + } + @Override protected void onTextChanged(CharSequence charSequence, int i, int i1, int i2) { super.onTextChanged(charSequence, i, i1, i2); @@ -756,6 +805,16 @@ private int getTextLayoutWidth() { return (int) ceil(maxWidth); } + private int getTextLayoutHeight() { + float maxHeight = 0; + int lineCount = getLineCount(); + for (int line = 0; line < lineCount; line++) { + int height = getLayout().getLineBottom(line) - getLayout().getLineTop(line); + maxHeight = max(maxHeight, height); + } + return (int) ceil(maxHeight); + } + private int getTextHeight() { if (getLineCount() > 1) { // If it's multi-line, return the internal text layout's height. @@ -1520,9 +1579,14 @@ private void maybeAnimateSize(boolean skipAnimation) { if (sizeChange == null) { return; } + if (widthIncreaseSpringAnimation == null) { - initializeSizeAnimation(); + initializeWidthAnimation(); } + if (heightIncreaseSpringAnimation == null) { + initializeHeightAnimation(); + } + if (isInHorizontalButtonGroup) { // Animate width. int widthChange = @@ -1536,6 +1600,19 @@ private void maybeAnimateSize(boolean skipAnimation) { if (skipAnimation) { widthIncreaseSpringAnimation.skipToEnd(); } + } else { + // Animate height. + int heightChange = + min( + heightChangeMax, + sizeChange + .getSizeChangeForState(getDrawableState()) + .heightChange + .getChange(getHeight())); + heightIncreaseSpringAnimation.animateToFinalPosition(heightChange); + if (skipAnimation) { + heightIncreaseSpringAnimation.skipToEnd(); + } } } @@ -1586,6 +1663,41 @@ void setDisplayedWidthDecrease(int widthDecrease) { invalidate(); } + void setHeightChangeMax(@Px int heightChangeMax) { + if (this.heightChangeMax != heightChangeMax) { + this.heightChangeMax = heightChangeMax; + maybeAnimateSize(/* skipAnimation= */ true); + } + } + + @Px + int getAllowedHeightDecrease() { + return allowedHeightDecrease; + } + + private float getDisplayedHeightIncrease() { + return displayedHeightIncrease; + } + + private void setDisplayedHeightIncrease(float heightIncrease) { + if (displayedHeightIncrease != heightIncrease) { + displayedHeightIncrease = heightIncrease; + updatePaddingsAndSizeForHeightAnimation(); + invalidate(); + // Report width changed to the parent group. + if (getParent() instanceof MaterialButtonGroup) { + ((MaterialButtonGroup) getParent()) + .onButtonHeightChanged(this, (int) displayedHeightIncrease); + } + } + } + + void setDisplayedHeightDecrease(int heightDecrease) { + displayedHeightDecrease = min(heightDecrease, allowedHeightDecrease); + updatePaddingsAndSizeForHeightAnimation(); + invalidate(); + } + /** * Sets whether to enable the optical center feature. * @@ -1639,6 +1751,17 @@ private void updatePaddingsAndSizeForWidthAnimation() { getPaddingBottom()); } + private void updatePaddingsAndSizeForHeightAnimation() { + int heightChange = (int) (displayedHeightIncrease - displayedHeightDecrease); + int paddingTopChange = heightChange / 2; + getLayoutParams().height = (int) (originalHeight + heightChange); + setPaddingRelative( + originalPaddingStart, + originalPaddingTop + paddingTopChange, + originalPaddingEnd, + originalPaddingBottom + heightChange - paddingTopChange); + } + private int getOpticalCenterShift() { if (opticalCenterEnabled && isInHorizontalButtonGroup) { MaterialShapeDrawable materialShapeDrawable = materialButtonHelper.getMaterialShapeDrawable(); @@ -1663,6 +1786,18 @@ public void setValue(MaterialButton button, float value) { button.setDisplayedWidthIncrease(value); } }; + private static final FloatPropertyCompat HEIGHT_INCREASE = + new FloatPropertyCompat("heightIncrease") { + @Override + public float getValue(MaterialButton button) { + return button.getDisplayedHeightIncrease(); + } + + @Override + public void setValue(MaterialButton button, float value) { + button.setDisplayedHeightIncrease(value); + } + }; static class SavedState extends AbsSavedState { diff --git a/lib/java/com/google/android/material/button/MaterialButtonGroup.java b/lib/java/com/google/android/material/button/MaterialButtonGroup.java index b86883cfd10..be529cf6bc0 100644 --- a/lib/java/com/google/android/material/button/MaterialButtonGroup.java +++ b/lib/java/com/google/android/material/button/MaterialButtonGroup.java @@ -56,6 +56,7 @@ import com.google.android.material.resources.MaterialAttributes; import com.google.android.material.shape.AbsoluteCornerSize; import com.google.android.material.shape.CornerSize; +import com.google.android.material.shape.RelativeCornerSize; import com.google.android.material.shape.ShapeAppearance; import com.google.android.material.shape.ShapeAppearanceModel; import com.google.android.material.shape.StateListCornerSize; @@ -720,6 +721,30 @@ void onButtonWidthChanged(@NonNull MaterialButton button, int increaseSize) { } } + void onButtonHeightChanged(@NonNull MaterialButton button, int increaseSize) { + int buttonIndex = indexOfChild(button); + if (buttonIndex < 0) { + return; + } + MaterialButton prevVisibleButton = getPrevVisibleChildButton(buttonIndex); + MaterialButton nextVisibleButton = getNextVisibleChildButton(buttonIndex); + if (prevVisibleButton == null && nextVisibleButton == null) { + return; + } + if (prevVisibleButton == null) { + nextVisibleButton.setDisplayedHeightDecrease(increaseSize); + } + if (nextVisibleButton == null) { + prevVisibleButton.setDisplayedHeightDecrease(increaseSize); + } + if (prevVisibleButton != null && nextVisibleButton != null) { + // If there are two neighbors, each neighbor will absorb half of the expanded amount. + prevVisibleButton.setDisplayedHeightDecrease(increaseSize / 2); + // We want to avoid one pixel missing due to the casting, when increaseSize is odd. + nextVisibleButton.setDisplayedHeightDecrease((increaseSize + 1) / 2); + } + } + /** * Adjusts the max amount of size to expand for each child button. So that it won't squeeze its * neighbors too much to cause text truncation; and the expansion amount per edge is same for all @@ -732,36 +757,51 @@ private void adjustChildSizeChange() { return; } int widthIncreaseOnSingleEdge = Integer.MAX_VALUE; + int heightIncreaseOnSingleEdge = Integer.MAX_VALUE; for (int i = firstVisibleChildIndex; i <= lastVisibleChildIndex; i++) { if (!isChildVisible(i)) { continue; } - // Calculates the allowed width increase for each child button with consideration of the max - // allowed width decrease of its neighbors. + // Calculates the allowed width and height increase for each child button with consideration + // of the max allowed width and height decrease of its neighbors. int widthIncrease = getButtonAllowedWidthIncrease(i); + int heightIncrease = getButtonAllowedHeightIncrease(i); // If the button expands on both edges, the width increase on each edge should be half of - // the total width increase. Calculates the minimum width increase on each edge, so that all - // buttons won't squeeze their neighbors too much. + // the total width increase. Calculates the minimum width and height increase on each edge, so + // that all buttons won't squeeze their neighbors too much. widthIncreaseOnSingleEdge = min( widthIncreaseOnSingleEdge, i != firstVisibleChildIndex && i != lastVisibleChildIndex ? widthIncrease / 2 : widthIncrease); + heightIncreaseOnSingleEdge = + min( + heightIncreaseOnSingleEdge, + i != firstVisibleChildIndex && i != lastVisibleChildIndex + ? heightIncrease / 2 + : heightIncrease); } for (int i = firstVisibleChildIndex; i <= lastVisibleChildIndex; i++) { if (!isChildVisible(i)) { continue; } getChildButton(i).setSizeChange(buttonSizeChange); - // If the button expands on both edges, the total width increase should be double of the - // width increase on each edge. + // If the button expands on both horizontal edges, the total width increase should be double + // of the width increase on each edge. getChildButton(i) .setWidthChangeMax( i == firstVisibleChildIndex || i == lastVisibleChildIndex ? widthIncreaseOnSingleEdge : widthIncreaseOnSingleEdge * 2); + // If the button expands on both vertical edges, the total height increase should be double of + // the height increase on each edge. + getChildButton(i) + .setHeightChangeMax( + i == firstVisibleChildIndex || i == lastVisibleChildIndex + ? heightIncreaseOnSingleEdge + : heightIncreaseOnSingleEdge * 2); } } @@ -787,6 +827,28 @@ private int getButtonAllowedWidthIncrease(int index) { return min(widthIncrease, prevButtonAllowedWidthDecrease + nextButtonAllowedWidthDecrease); } + /** + * Returns the allowed height increase for a child button. + * + *

The allowed height increase is the smaller amount of the max height increase of the button + * in all states and the total allowed height decrease of its neighbors. + */ + private int getButtonAllowedHeightIncrease(int index) { + if (!isChildVisible(index) || buttonSizeChange == null) { + return 0; + } + MaterialButton currentButton = getChildButton(index); + int heightIncrease = max(0, buttonSizeChange.getMaxHeightChange(currentButton.getHeight())); + // Checking neighbors' allowed height decrease. + MaterialButton prevVisibleButton = getPrevVisibleChildButton(index); + int prevButtonAllowedHeightDecrease = + prevVisibleButton == null ? 0 : prevVisibleButton.getAllowedHeightDecrease(); + MaterialButton nextVisibleButton = getNextVisibleChildButton(index); + int nextButtonAllowedHeightDecrease = + nextVisibleButton == null ? 0 : nextVisibleButton.getAllowedHeightDecrease(); + return min(heightIncrease, prevButtonAllowedHeightDecrease + nextButtonAllowedHeightDecrease); + } + // ================ Getters and setters =================== @Override diff --git a/lib/java/com/google/android/material/button/res/xml/m3_button_group_child_size_change.xml b/lib/java/com/google/android/material/button/res/xml/m3_button_group_child_size_change.xml index 67619239e6c..01a80d719b6 100644 --- a/lib/java/com/google/android/material/button/res/xml/m3_button_group_child_size_change.xml +++ b/lib/java/com/google/android/material/button/res/xml/m3_button_group_child_size_change.xml @@ -16,6 +16,6 @@ --> - - + + diff --git a/lib/java/com/google/android/material/shape/StateListSizeChange.java b/lib/java/com/google/android/material/shape/StateListSizeChange.java index da25498ac8e..de99292845c 100644 --- a/lib/java/com/google/android/material/shape/StateListSizeChange.java +++ b/lib/java/com/google/android/material/shape/StateListSizeChange.java @@ -129,6 +129,19 @@ public int getMaxWidthChange(@Px int baseWidth) { return maxWidthChange; } + public int getMaxHeightChange(@Px int baseHeight) { + int maxHeightChange = -baseHeight; + for (int i = 0; i < stateCount; i++) { + SizeChange sizeChange = sizeChanges[i]; + if (sizeChange.heightChange.type == SizeChangeType.PIXELS) { + maxHeightChange = (int) max(maxHeightChange, sizeChange.heightChange.amount); + } else if (sizeChange.widthChange.type == SizeChangeType.PERCENT) { + maxHeightChange = (int) max(maxHeightChange, baseHeight * sizeChange.heightChange.amount); + } + } + return maxHeightChange; + } + private int indexOfStateSet(int[] stateSet) { final int[][] stateSpecs = this.stateSpecs; for (int i = 0; i < stateCount; i++) { @@ -165,6 +178,8 @@ private void loadSizeChangeFromItems( SizeChangeAmount widthChangeAmount = getSizeChangeAmount(a, R.styleable.StateListSizeChange_widthChange, null); + SizeChangeAmount heightChangeAmount = + getSizeChangeAmount(a, R.styleable.StateListSizeChange_heightChange, null); a.recycle(); @@ -174,12 +189,12 @@ private void loadSizeChangeFromItems( int[] stateSpec = new int[numAttrs]; for (int i = 0; i < numAttrs; i++) { final int stateResId = attrs.getAttributeNameResource(i); - if (stateResId != R.attr.widthChange) { + if (stateResId != R.attr.widthChange && stateResId != R.attr.heightChange) { stateSpec[j++] = attrs.getAttributeBooleanValue(i, false) ? stateResId : -stateResId; } } stateSpec = StateSet.trimStateSet(stateSpec, j); - addStateSizeChange(stateSpec, new SizeChange(widthChangeAmount)); + addStateSizeChange(stateSpec, new SizeChange(widthChangeAmount, heightChangeAmount)); } } @@ -226,13 +241,16 @@ private void growArray(int oldSize, int newSize) { /** A collection of all values needed in a size change. */ public static class SizeChange { @Nullable public SizeChangeAmount widthChange; + @Nullable public SizeChangeAmount heightChange; - SizeChange(@Nullable SizeChangeAmount widthChange) { + SizeChange(@Nullable SizeChangeAmount widthChange, @Nullable SizeChangeAmount heightChange) { this.widthChange = widthChange; + this.heightChange = heightChange; } SizeChange(@NonNull SizeChange other) { this.widthChange = new SizeChangeAmount(other.widthChange.type, other.widthChange.amount); + this.heightChange = new SizeChangeAmount(other.heightChange.type, other.heightChange.amount); } } diff --git a/lib/java/com/google/android/material/shape/res/values/attrs.xml b/lib/java/com/google/android/material/shape/res/values/attrs.xml index 753f269a818..5c6946a9b01 100644 --- a/lib/java/com/google/android/material/shape/res/values/attrs.xml +++ b/lib/java/com/google/android/material/shape/res/values/attrs.xml @@ -72,6 +72,8 @@ + + diff --git a/lib/javatests/com/google/android/material/shape/StateListSizeChangeTest.java b/lib/javatests/com/google/android/material/shape/StateListSizeChangeTest.java index d24b5e4b5de..1b94098ea98 100644 --- a/lib/javatests/com/google/android/material/shape/StateListSizeChangeTest.java +++ b/lib/javatests/com/google/android/material/shape/StateListSizeChangeTest.java @@ -67,11 +67,17 @@ public void testCreateStateList() { SizeChange defaultSizeChange = stateListSizeChange.getDefaultSizeChange(); assertEquals(PERCENT, pressedSizeChange.widthChange.type); + assertEquals(PERCENT, pressedSizeChange.heightChange.type); assertEquals(0.15, pressedSizeChange.widthChange.amount, FLOAT_TOLERANCE); + assertEquals(0.15, pressedSizeChange.heightChange.amount, FLOAT_TOLERANCE); assertEquals(PIXELS, unspecifiedStateSizeChange.widthChange.type); + assertEquals(PIXELS, unspecifiedStateSizeChange.heightChange.type); assertEquals(0, unspecifiedStateSizeChange.widthChange.amount, FLOAT_TOLERANCE); + assertEquals(0, unspecifiedStateSizeChange.heightChange.amount, FLOAT_TOLERANCE); assertEquals(PIXELS, defaultSizeChange.widthChange.type); + assertEquals(PIXELS, defaultSizeChange.heightChange.type); assertEquals(0, defaultSizeChange.widthChange.amount, FLOAT_TOLERANCE); + assertEquals(0, defaultSizeChange.heightChange.amount, FLOAT_TOLERANCE); } @Test @@ -91,11 +97,17 @@ public void testCreateStateListWithoutDefault() { SizeChange defaultSizeChange = stateListSizeChange.getDefaultSizeChange(); assertEquals(PERCENT, pressedSizeChange.widthChange.type); + assertEquals(PERCENT, pressedSizeChange.heightChange.type); assertEquals(0.15, pressedSizeChange.widthChange.amount, FLOAT_TOLERANCE); + assertEquals(0.15, pressedSizeChange.heightChange.amount, FLOAT_TOLERANCE); assertEquals(PERCENT, unspecifiedStateSizeChange.widthChange.type); + assertEquals(PERCENT, unspecifiedStateSizeChange.heightChange.type); assertEquals(0.15, unspecifiedStateSizeChange.widthChange.amount, FLOAT_TOLERANCE); + assertEquals(0.15, unspecifiedStateSizeChange.heightChange.amount, FLOAT_TOLERANCE); assertEquals(PERCENT, defaultSizeChange.widthChange.type); + assertEquals(PERCENT, defaultSizeChange.heightChange.type); assertEquals(0.15, defaultSizeChange.widthChange.amount, FLOAT_TOLERANCE); + assertEquals(0.15, defaultSizeChange.heightChange.amount, FLOAT_TOLERANCE); } private AttributeSet setupAttributeSetForTest() { diff --git a/lib/javatests/com/google/android/material/shape/res/xml/state_list_size_change.xml b/lib/javatests/com/google/android/material/shape/res/xml/state_list_size_change.xml index 36030bd08a3..b3d133068ee 100644 --- a/lib/javatests/com/google/android/material/shape/res/xml/state_list_size_change.xml +++ b/lib/javatests/com/google/android/material/shape/res/xml/state_list_size_change.xml @@ -16,6 +16,6 @@ --> - - + + diff --git a/lib/javatests/com/google/android/material/shape/res/xml/state_list_size_change_without_default.xml b/lib/javatests/com/google/android/material/shape/res/xml/state_list_size_change_without_default.xml index 7e393b528fc..68d269b9b9c 100644 --- a/lib/javatests/com/google/android/material/shape/res/xml/state_list_size_change_without_default.xml +++ b/lib/javatests/com/google/android/material/shape/res/xml/state_list_size_change_without_default.xml @@ -16,5 +16,5 @@ --> - +