/*
* Copyright (C) 2025 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.overflow;
import com.google.android.material.R;
import static com.google.android.material.theme.overlay.MaterialThemeOverlay.wrap;
import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.drawable.Drawable;
import android.graphics.drawable.InsetDrawable;
import android.os.Build.VERSION;
import android.os.Build.VERSION_CODES;
import androidx.appcompat.widget.PopupMenu;
import androidx.appcompat.widget.TintTypedArray;
import androidx.appcompat.widget.TooltipCompat;
import android.util.AttributeSet;
import android.view.Gravity;
import android.view.LayoutInflater;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
import android.widget.LinearLayout;
import androidx.annotation.DrawableRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.google.android.material.button.MaterialButton;
import com.google.android.material.button.MaterialButtonGroup.OverflowUtils;
import com.google.android.material.internal.ThemeEnforcement;
import com.google.android.material.resources.MaterialAttributes;
import java.util.LinkedHashSet;
import java.util.Set;
/**
* Provides an implementation of an overflow linear layout.
*
*
The OverflowLinearLayout will automatically hide/show its children depending on the current
* available screen space and/or the max size of its parent layout. If there is not enough space to
* show all children, the ones that do not fit will be put in an overflow menu, and an overflow
* button will be automatically added as the last child of the layout.
*
*
Note: if you'd like to hide/show children independently from this layout's decisions, you'll
* need to add/remove the desired view(s), instead of changing their visibility, as the
* OverflowLinearLayout will determine the final visibility value of its children.
*
*
The OverflowLinearLayout is commonly used with the {@link
* com.google.android.material.floatingtoolbar.FloatingToolbarLayout} and the {@link
* com.google.android.material.dockedtoolbar.DockedToolbarLayout}.
*/
public class OverflowLinearLayout extends LinearLayout {
private static final int DEF_STYLE_RES = R.style.Widget_Material3_OverflowLinearLayout;
@NonNull private final MaterialButton overflowButton;
private boolean overflowButtonAdded = false;
private final Set overflowViews = new LinkedHashSet<>();
public OverflowLinearLayout(@NonNull Context context) {
this(context, null);
}
public OverflowLinearLayout(@NonNull Context context, @Nullable AttributeSet attributeSet) {
this(context, attributeSet, R.attr.overflowLinearLayoutStyle);
}
public OverflowLinearLayout(
@NonNull Context context, @Nullable AttributeSet attributeSet, int defStyleAttr) {
super(wrap(context, attributeSet, defStyleAttr, DEF_STYLE_RES), attributeSet, defStyleAttr);
// Ensure we are using the correctly themed context rather than the context that was passed in.
context = getContext();
TintTypedArray attributes =
ThemeEnforcement.obtainTintedStyledAttributes(
context, attributeSet, R.styleable.OverflowLinearLayout, defStyleAttr, DEF_STYLE_RES);
Drawable overflowButtonDrawable =
attributes.getDrawable(R.styleable.OverflowLinearLayout_overflowButtonIcon);
attributes.recycle();
// Configurations of the overflow button.
overflowButton =
(MaterialButton)
LayoutInflater.from(context)
.inflate(R.layout.m3_overflow_linear_layout_overflow_button, this, false);
TooltipCompat.setTooltipText(overflowButton, getResources().getString(R.string.m3_overflow_linear_layout_button_tooltip_text));
setOverflowButtonIcon(overflowButtonDrawable);
if (overflowButton.getContentDescription() == null) {
overflowButton.setContentDescription(
context.getString(R.string.m3_overflow_linear_layout_button_content_description));
}
int overflowMenuStyle =
MaterialAttributes.resolveOrThrow(this, R.attr.overflowLinearLayoutPopupMenuStyle);
PopupMenu popupMenu;
if (VERSION.SDK_INT > VERSION_CODES.LOLLIPOP) {
popupMenu = new PopupMenu(getContext(), overflowButton, Gravity.CENTER, 0, overflowMenuStyle);
} else {
popupMenu = new PopupMenu(getContext(), overflowButton, Gravity.CENTER);
}
int overflowItemIconPadding =
context
.getResources()
.getDimensionPixelOffset(R.dimen.m3_overflow_item_icon_horizontal_padding);
overflowButton.setOnClickListener(
v -> handleOverflowButtonClick(popupMenu, overflowItemIconPadding));
}
/** Whether the OverflowLinearLayout currently has items overflowed. */
public boolean isOverflowed() {
return !overflowViews.isEmpty();
}
/** Returns the current set of overflowed views. */
@NonNull
public Set getOverflowedViews() {
return overflowViews;
}
/**
* Sets the icon to show for the overflow button.
*
* @param icon Drawable to use for the overflow button's icon.
* @attr ref com.google.android.material.R.styleable#OverflowLinearLayout_overflowButtonIcon
* @see #setOverflowButtonIconResource(int)
* @see #getOverflowButtonIcon()
*/
public void setOverflowButtonIcon(@Nullable Drawable icon) {
overflowButton.setIcon(icon);
}
/**
* Sets the icon to show for the overflow button.
*
* @param iconResourceId drawable resource ID to use for the overflow button's icon.
* @attr ref com.google.android.material.R.styleable#OverflowLinearLayout_overflowButtonIcon
* @see #setOverflowButtonIcon(Drawable)
* @see #getOverflowButtonIcon()
*/
public void setOverflowButtonIconResource(@DrawableRes int iconResourceId) {
overflowButton.setIconResource(iconResourceId);
}
/**
* Returns the icon shown for the overflow button, if present.
*
* @return the overflow button icon, if present.
* @attr ref com.google.android.material.R.styleable#OverflowLinearLayout_overflowButtonIcon
* @see #setOverflowButtonIcon(Drawable)
* @see #setOverflowButtonIconResource(int)
*/
@Nullable
public Drawable getOverflowButtonIcon() {
return overflowButton.getIcon();
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
boolean isHorizontal = getOrientation() == HORIZONTAL;
int childCountWithoutOverflowButton =
overflowButtonAdded ? getChildCount() - 1 : getChildCount();
int atMostSize =
isHorizontal
? MeasureSpec.getSize(widthMeasureSpec)
: MeasureSpec.getSize(heightMeasureSpec);
int childrenSize = 0;
int overflowButtonSize =
getOverflowButtonSize(isHorizontal, overflowButton, widthMeasureSpec, heightMeasureSpec);
overflowButton.setVisibility(GONE);
overflowViews.clear();
boolean shouldShowOverflow = false;
for (int childIndex = 0; childIndex < childCountWithoutOverflowButton; childIndex++) {
View child = getChildAt(childIndex);
child.setVisibility(VISIBLE);
int childSize = getChildSize(isHorizontal, child, widthMeasureSpec, heightMeasureSpec);
if (childrenSize + childSize + overflowButtonSize > atMostSize) {
// Add views to be overflowed here in case overflow happens so that we don't have to loop
// over the children again. Here we're also accounting for the overflow button size, to make
// sure it'll fit in the layout we might have to remove extra buttons.
overflowViews.add(child);
}
// Overflow actually happens if adding this child makes it go beyond the atMostSize.
if (childrenSize + childSize > atMostSize) {
shouldShowOverflow = true;
int removedIndex = childIndex + 1;
// Finish looping through the children and adding remaining overflowed views.
while (removedIndex < childCountWithoutOverflowButton) {
overflowViews.add(getChildAt(removedIndex));
removedIndex++;
}
break;
} else {
childrenSize += childSize;
}
}
if (shouldShowOverflow) {
for (View view : overflowViews) {
view.setVisibility(GONE);
}
if (!overflowButtonAdded) {
// Add overflow button here so it's the last button of the layout.
addView(overflowButton);
overflowButtonAdded = true;
}
overflowButton.setVisibility(VISIBLE);
} else {
overflowButton.setVisibility(GONE);
// Make sure overflowViews is empty.
overflowViews.clear();
}
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
}
private int getChildSize(
boolean isHorizontal, View child, int widthMeasureSpec, int heightMeasureSpec) {
measureChild(child, widthMeasureSpec, heightMeasureSpec);
LayoutParams lp = (LayoutParams) child.getLayoutParams();
int childSize =
isHorizontal
? (child.getMeasuredWidth() + lp.leftMargin + lp.rightMargin)
: (child.getMeasuredHeight() + lp.topMargin + lp.bottomMargin);
// Child measured size may be zero in some cases, like if its final size is being determined by
// layout weight, so use minimum size instead for such cases.
if (childSize == 0) {
childSize =
isHorizontal
? (child.getMinimumWidth() + lp.leftMargin + lp.rightMargin)
: (child.getMinimumHeight() + lp.topMargin + lp.bottomMargin);
}
return childSize;
}
private int getOverflowButtonSize(
boolean isHorizontal, View button, int widthMeasureSpec, int heightMeasureSpec) {
measureChild(button, widthMeasureSpec, heightMeasureSpec);
LinearLayout.LayoutParams lp = (LinearLayout.LayoutParams) button.getLayoutParams();
return isHorizontal
? (button.getMeasuredWidth() + lp.leftMargin + lp.rightMargin)
: (button.getMeasuredHeight() + lp.topMargin + lp.bottomMargin);
}
private void handleOverflowButtonClick(PopupMenu popupMenu, int overflowItemIconPadding) {
popupMenu.getMenu().clear();
popupMenu.setForceShowIcon(true);
// Set up each item of the overflow menu.
for (View view : overflowViews) {
OverflowLinearLayout.LayoutParams lp =
(OverflowLinearLayout.LayoutParams) view.getLayoutParams();
CharSequence text = OverflowUtils.getMenuItemText(view, lp.overflowText);
MenuItem item = popupMenu.getMenu().add(text);
Drawable icon = lp.overflowIcon;
if (icon != null) {
item.setIcon(
new InsetDrawable(icon, overflowItemIconPadding, 0, overflowItemIconPadding, 0));
}
if (view instanceof MaterialButton) {
MaterialButton button = (MaterialButton) view;
item.setCheckable(button.isCheckable());
item.setChecked(button.isChecked());
}
item.setEnabled(view.isEnabled());
item.setOnMenuItemClickListener(
menuItem -> {
view.performClick();
if (item.isCheckable()) {
item.setChecked(!item.isChecked());
}
return true;
});
}
popupMenu.show();
}
@Override
@NonNull
protected OverflowLinearLayout.LayoutParams generateDefaultLayoutParams() {
if (getOrientation() == HORIZONTAL) {
return new OverflowLinearLayout.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
} else {
return new OverflowLinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
}
}
@Override
@NonNull
public LayoutParams generateLayoutParams(@Nullable AttributeSet attrs) {
return new LayoutParams(getContext(), attrs);
}
@Override
@NonNull
protected OverflowLinearLayout.LayoutParams generateLayoutParams(
@NonNull ViewGroup.LayoutParams p) {
if (p instanceof LayoutParams) {
return new OverflowLinearLayout.LayoutParams(p);
} else if (p instanceof LinearLayout.LayoutParams) {
return new OverflowLinearLayout.LayoutParams((LinearLayout.LayoutParams) p);
} else if (p instanceof MarginLayoutParams) {
return new OverflowLinearLayout.LayoutParams((MarginLayoutParams) p);
} else {
return new OverflowLinearLayout.LayoutParams(p);
}
}
@Override
protected boolean checkLayoutParams(@NonNull ViewGroup.LayoutParams p) {
return p instanceof OverflowLinearLayout.LayoutParams;
}
/** A {@link LinearLayout.LayoutParams} implementation for {@link OverflowLinearLayout}. */
public static class LayoutParams extends LinearLayout.LayoutParams {
@Nullable public Drawable overflowIcon = null;
@Nullable public CharSequence overflowText = null;
/**
* Creates a new set of layout parameters. The values are extracted from the supplied attributes
* set and context.
*
* @param context the application environment
* @param attrs the set of attributes from which to extract the layout parameters' values
*/
public LayoutParams(@NonNull Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
TypedArray attributes =
context.obtainStyledAttributes(attrs, R.styleable.OverflowLinearLayout_Layout);
overflowIcon =
attributes.getDrawable(R.styleable.OverflowLinearLayout_Layout_layout_overflowIcon);
overflowText =
attributes.getText(R.styleable.OverflowLinearLayout_Layout_layout_overflowText);
attributes.recycle();
}
public LayoutParams(int width, int height) {
super(width, height);
}
public LayoutParams(int width, int height, float weight) {
super(width, height, weight);
}
/**
* Creates a new set of layout parameters with the specified width, height, weight, overflow
* icon and overflow text.
*
* @param width the width, either {@link #MATCH_PARENT}, {@link #WRAP_CONTENT} or a fixed size
* in pixels
* @param height the height, either {@link #MATCH_PARENT}, {@link #WRAP_CONTENT} or a fixed size
* in pixels
* @param weight the weight
* @param overflowIcon the overflow icon drawable
* @param overflowText the overflow text char sequence
*/
public LayoutParams(
int width,
int height,
float weight,
@Nullable Drawable overflowIcon,
@Nullable CharSequence overflowText) {
super(width, height, weight);
this.overflowIcon = overflowIcon;
this.overflowText = overflowText;
}
public LayoutParams(@NonNull ViewGroup.LayoutParams p) {
super(p);
}
public LayoutParams(@NonNull MarginLayoutParams source) {
super(source);
}
public LayoutParams(@NonNull LinearLayout.LayoutParams source) {
super(source);
}
/**
* Copy constructor. Clones the values of the source.
*
* @param source The layout params to copy from.
*/
public LayoutParams(@NonNull LayoutParams source) {
super(source);
this.overflowText = source.overflowText;
this.overflowIcon = source.overflowIcon;
}
}
}