// Copyright 2015 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
package org.chromium.components.infobars;
import android.content.Context;
import android.content.res.Resources;
import android.graphics.Bitmap;
import android.graphics.Paint;
import android.text.method.LinkMovementMethod;
import android.util.AttributeSet;
import android.util.TypedValue;
import android.view.Gravity;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ArrayAdapter;
import android.widget.ImageView;
import android.widget.LinearLayout;
import android.widget.Spinner;
import android.widget.TextView;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import androidx.appcompat.widget.SwitchCompat;
import androidx.core.content.res.ResourcesCompat;
import org.chromium.base.StrictModeContext;
import org.chromium.components.browser_ui.widget.DualControlLayout;
import org.chromium.components.browser_ui.widget.RadioButtonLayout;
import org.chromium.ui.widget.ChromeImageView;
import java.util.List;
/**
* Lays out a group of controls (e.g. switches, spinners, or additional text) for InfoBars that need
* more than the normal pair of buttons.
*
* This class works with the {@link InfoBarLayout} to define a standard set of controls with
* standardized spacings and text styling that gets laid out in grid form: https://crbug.com/543205
*
* Manually specified margins on the children managed by this layout are EXPLICITLY ignored to
* enforce a uniform margin between controls across all InfoBar types. Do NOT circumvent this
* restriction with creative layout definitions. If the layout algorithm doesn't work for your new
* InfoBar, convince Chrome for Android's UX team to amend the master spec and then change the
* layout algorithm to match.
*
* TODO(dfalcantara): The line spacing multiplier is applied to all lines in JB & KK, even if the
* TextView has only one line. This throws off vertical alignment. Find a
* solution that hopefully doesn't involve subclassing the TextView.
*/
public final class InfoBarControlLayout extends ViewGroup {
/**
* ArrayAdapter that automatically determines what size make its Views to accommodate all of
* its potential values.
* @param <T> Type of object that the ArrayAdapter stores.
*/
public static final class InfoBarArrayAdapter<T> extends ArrayAdapter<T> {
private final String mLabel;
private int mMinWidthRequiredForValues;
public InfoBarArrayAdapter(Context context, String label) {
super(context, R.layout.infobar_control_spinner_drop_down);
mLabel = label;
}
public InfoBarArrayAdapter(Context context, T[] objects) {
super(context, R.layout.infobar_control_spinner_drop_down, objects);
mLabel = null;
}
@Override
public View getDropDownView(int position, View convertView, ViewGroup parent) {
TextView view;
if (convertView instanceof TextView) {
view = (TextView) convertView;
} else {
view =
(TextView)
inflateLayout(
getContext(),
R.layout.infobar_control_spinner_drop_down,
parent);
}
view.setText(getItem(position).toString());
return view;
}
@Override
public DualControlLayout getView(int position, View convertView, ViewGroup parent) {
DualControlLayout view;
if (convertView instanceof DualControlLayout) {
view = (DualControlLayout) convertView;
} else {
view =
(DualControlLayout)
inflateLayout(
getContext(),
R.layout.infobar_control_spinner_view,
parent);
}
// Set up the spinner label. The text it displays won't change.
TextView labelView = (TextView) view.getChildAt(0);
labelView.setText(mLabel);
// Because the values can be of different widths, the TextView may expand or shrink.
// Enforcing a minimum width prevents the layout from doing so as the user swaps values,
// preventing unwanted layout passes.
TextView valueView = (TextView) view.getChildAt(1);
valueView.setText(getItem(position).toString());
valueView.setMinimumWidth(mMinWidthRequiredForValues);
return view;
}
/**
* Computes and records the minimum width required to display any of the values without
* causing another layout pass when switching values.
*/
int computeMinWidthRequiredForValues() {
DualControlLayout layout = getView(0, null, null);
TextView container = (TextView) layout.getChildAt(1);
Paint textPaint = container.getPaint();
float longestLanguageWidth = 0;
for (int i = 0; i < getCount(); i++) {
float width = textPaint.measureText(getItem(i).toString());
longestLanguageWidth = Math.max(longestLanguageWidth, width);
}
mMinWidthRequiredForValues = (int) Math.ceil(longestLanguageWidth);
return mMinWidthRequiredForValues;
}
/** Explicitly sets the minimum width required to display all of the values. */
void setMinWidthRequiredForValues(int requiredWidth) {
mMinWidthRequiredForValues = requiredWidth;
}
}
/** Extends the regular LayoutParams by determining where a control should be located. */
@VisibleForTesting
static final class ControlLayoutParams extends LayoutParams {
public int start;
public int top;
public int columnsRequired;
private boolean mMustBeFullWidth;
/**
* Stores values required for laying out this ViewGroup's children.
*
* This is set up as a private method to mitigate attempts at adding controls to the layout
* that aren't provided by the InfoBarControlLayout.
*/
private ControlLayoutParams() {
super(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT);
}
}
private final int mMarginBetweenRows;
private final int mMarginBetweenColumns;
/** Do not call this method directly; use {@link InfoBarLayout#addControlLayout()}. */
public InfoBarControlLayout(Context context) {
this(context, null);
}
public InfoBarControlLayout(Context context, AttributeSet attrs) {
super(context, attrs);
Resources resources = context.getResources();
mMarginBetweenRows =
resources.getDimensionPixelSize(R.dimen.infobar_control_margin_between_rows);
mMarginBetweenColumns =
resources.getDimensionPixelSize(R.dimen.infobar_control_margin_between_columns);
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
int fullWidth =
MeasureSpec.getMode(widthMeasureSpec) == MeasureSpec.UNSPECIFIED
? Integer.MAX_VALUE
: MeasureSpec.getSize(widthMeasureSpec);
int columnWidth = Math.max(0, (fullWidth - mMarginBetweenColumns) / 2);
int atMostFullWidthSpec = MeasureSpec.makeMeasureSpec(fullWidth, MeasureSpec.AT_MOST);
int exactlyFullWidthSpec = MeasureSpec.makeMeasureSpec(fullWidth, MeasureSpec.EXACTLY);
int exactlyColumnWidthSpec = MeasureSpec.makeMeasureSpec(columnWidth, MeasureSpec.EXACTLY);
int unspecifiedSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED);
// Figure out how many columns each child requires.
for (int i = 0; i < getChildCount(); i++) {
View child = getChildAt(i);
measureChild(child, atMostFullWidthSpec, unspecifiedSpec);
if (child.getMeasuredWidth() <= columnWidth
&& !getControlLayoutParams(child).mMustBeFullWidth) {
getControlLayoutParams(child).columnsRequired = 1;
} else {
getControlLayoutParams(child).columnsRequired = 2;
}
}
// Pack all the children as tightly into rows as possible without changing their ordering.
// Stretch out column-width controls if either it is the last control or the next one is
// a full-width control.
for (int i = 0; i < getChildCount(); i++) {
ControlLayoutParams lp = getControlLayoutParams(getChildAt(i));
if (i == getChildCount() - 1) {
lp.columnsRequired = 2;
} else {
ControlLayoutParams nextLp = getControlLayoutParams(getChildAt(i + 1));
if (lp.columnsRequired + nextLp.columnsRequired > 2) {
// This control is too big to place with the next child.
lp.columnsRequired = 2;
} else {
// This and the next control fit on the same line. Skip placing the next child.
i++;
}
}
}
// Measure all children, assuming they all have to fit within the width of the layout.
// Height is unconstrained.
for (int i = 0; i < getChildCount(); i++) {
View child = getChildAt(i);
ControlLayoutParams lp = getControlLayoutParams(child);
int spec = lp.columnsRequired == 1 ? exactlyColumnWidthSpec : exactlyFullWidthSpec;
measureChild(child, spec, unspecifiedSpec);
}
// Pack all the children as tightly into rows as possible without changing their ordering.
int layoutHeight = 0;
int nextChildStart = 0;
int nextChildTop = 0;
int currentRowHeight = 0;
int columnsAvailable = 2;
for (int i = 0; i < getChildCount(); i++) {
View child = getChildAt(i);
ControlLayoutParams lp = getControlLayoutParams(child);
// If there isn't enough room left for the control, move to the next row.
if (columnsAvailable < lp.columnsRequired) {
layoutHeight += currentRowHeight + mMarginBetweenRows;
nextChildStart = 0;
nextChildTop = layoutHeight;
currentRowHeight = 0;
columnsAvailable = 2;
}
lp.top = nextChildTop;
lp.start = nextChildStart;
currentRowHeight = Math.max(currentRowHeight, child.getMeasuredHeight());
columnsAvailable -= lp.columnsRequired;
nextChildStart += lp.columnsRequired * (columnWidth + mMarginBetweenColumns);
}
// Compute the ViewGroup's height, accounting for the final row's height.
layoutHeight += currentRowHeight;
setMeasuredDimension(
resolveSize(fullWidth, widthMeasureSpec),
resolveSize(layoutHeight, heightMeasureSpec));
}
@Override
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
int width = right - left;
boolean isRtl = getLayoutDirection() == LAYOUT_DIRECTION_RTL;
// Child positions were already determined during the measurement pass.
for (int childIndex = 0; childIndex < getChildCount(); childIndex++) {
View child = getChildAt(childIndex);
int childLeft = getControlLayoutParams(child).start;
if (isRtl) childLeft = width - childLeft - child.getMeasuredWidth();
int childTop = getControlLayoutParams(child).top;
int childRight = childLeft + child.getMeasuredWidth();
int childBottom = childTop + child.getMeasuredHeight();
child.layout(childLeft, childTop, childRight, childBottom);
}
}
@Override
protected LayoutParams generateDefaultLayoutParams() {
return new ControlLayoutParams();
}
@Override
public LayoutParams generateLayoutParams(AttributeSet attrs) {
return generateDefaultLayoutParams();
}
@Override
protected LayoutParams generateLayoutParams(LayoutParams p) {
return generateDefaultLayoutParams();
}
/**
* Adds a center justified image.
*
* -----------------------------------------------------
* | IMAGE |
* -----------------------------------------------------
*
* @param imageResourceId Resource ID of the image.
*/
public View addLeadImage(int imageResourceId) {
ChromeImageView imageView = new ChromeImageView(getContext());
LinearLayout.LayoutParams lp =
new LinearLayout.LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT);
lp.gravity = Gravity.CENTER;
imageView.setLayoutParams(lp);
addView(imageView, new ControlLayoutParams());
imageView.setImageResource(imageResourceId);
return imageView;
}
/**
* Adds an icon with a descriptive message to the Title.
*
* -----------------------------------------------------
* | ICON | TITLE MESSAGE |
* -----------------------------------------------------
* If an icon is not provided, the ImageView that would normally show it is hidden.
*
* @param iconResourceId ID of the drawable to use for the icon.
* @param titleMessage Message to display on Infobar title.
*/
public View addIconTitle(int iconResourceId, CharSequence titleMessage) {
LinearLayout layout =
(LinearLayout)
inflateLayout(getContext(), R.layout.infobar_control_icon_with_title, this);
addView(layout, new ControlLayoutParams());
ImageView iconView = (ImageView) layout.findViewById(R.id.control_title_icon);
iconView.setImageResource(iconResourceId);
TextView titleView = (TextView) layout.findViewById(R.id.control_title);
titleView.setText(titleMessage);
titleView.setTextSize(
TypedValue.COMPLEX_UNIT_PX,
getContext().getResources().getDimension(R.dimen.infobar_text_size));
return layout;
}
/**
* Adds an icon with a descriptive message to the layout.
*
* -----------------------------------------------------
* | ICON | PRIMARY MESSAGE SECONDARY MESSAGE |
* -----------------------------------------------------
* If an icon is not provided, the ImageView that would normally show it is hidden.
*
* @param iconResourceId ID of the drawable to use for the icon.
* @param iconColorId ID of the tint color for the icon, or 0 for default.
* @param primaryMessage Message to display for the toggle.
* @param secondaryMessage Additional descriptive text for the toggle. May be null.
*/
public View addIcon(
int iconResourceId,
int iconColorId,
CharSequence primaryMessage,
CharSequence secondaryMessage) {
return addIcon(
iconResourceId,
iconColorId,
primaryMessage,
secondaryMessage,
R.dimen.infobar_text_size);
}
/**
* Adds an icon with a descriptive message to the layout.
*
* -----------------------------------------------------
* | ICON | PRIMARY MESSAGE SECONDARY MESSAGE |
* -----------------------------------------------------
* If an icon is not provided, the ImageView that would normally show it is hidden.
*
* @param iconResourceId ID of the drawable to use for the icon.
* @param iconColorId ID of the tint color for the icon, or 0 for default.
* @param primaryMessage Message to display for the toggle.
* @param secondaryMessage Additional descriptive text for the toggle. May be null.
* @param resourceId Size of resource id to be applied to primaryMessage
* and secondaryMessage.
*/
public View addIcon(
int iconResourceId,
int iconColorId,
CharSequence primaryMessage,
CharSequence secondaryMessage,
int resourceId) {
LinearLayout layout =
(LinearLayout)
inflateLayout(
getContext(), R.layout.infobar_control_icon_with_description, this);
addView(layout, new ControlLayoutParams());
ImageView iconView = (ImageView) layout.findViewById(R.id.control_icon);
iconView.setImageResource(iconResourceId);
if (iconColorId != 0) {
iconView.setColorFilter(getContext().getColor(iconColorId));
}
// The primary message text is always displayed.
TextView primaryView = (TextView) layout.findViewById(R.id.control_message);
primaryView.setText(primaryMessage);
primaryView.setTextSize(
TypedValue.COMPLEX_UNIT_PX, getContext().getResources().getDimension(resourceId));
// The secondary message text is optional.
TextView secondaryView = (TextView) layout.findViewById(R.id.control_secondary_message);
if (secondaryMessage == null) {
layout.removeView(secondaryView);
} else {
secondaryView.setText(secondaryMessage);
secondaryView.setTextSize(
TypedValue.COMPLEX_UNIT_PX,
getContext().getResources().getDimension(resourceId));
}
return layout;
}
/**
* Adds an icon with a descriptive message to the layout.
*
* -----------------------------------------------------
* | ICON | PRIMARY MESSAGE SECONDARY MESSAGE |
* -----------------------------------------------------
* If an icon is not provided, the ImageView that would normally show it is hidden.
*
* @param iconBitmap Bitmap image of the icon.
* @param iconColorId ID of the tint color for the icon, or 0 for default.
* @param primaryMessage Message to display for the toggle.
* @param secondaryMessage Additional descriptive text for the toggle. May be null.
* @param resourceId Size of resource id to be applied to primaryMessage
* and secondaryMessage.
*/
public View addIcon(
Bitmap iconBitmap,
int iconColorId,
CharSequence primaryMessage,
CharSequence secondaryMessage,
int resourceId) {
LinearLayout layout =
(LinearLayout)
inflateLayout(
getContext(), R.layout.infobar_control_icon_with_description, this);
addView(layout, new ControlLayoutParams());
ImageView iconView = (ImageView) layout.findViewById(R.id.control_icon);
iconView.setImageBitmap(iconBitmap);
if (iconColorId != 0) {
iconView.setColorFilter(getContext().getColor(iconColorId));
}
// The primary message text is always displayed.
TextView primaryView = (TextView) layout.findViewById(R.id.control_message);
primaryView.setText(primaryMessage);
primaryView.setTextSize(
TypedValue.COMPLEX_UNIT_PX, getContext().getResources().getDimension(resourceId));
// The secondary message text is optional.
TextView secondaryView = (TextView) layout.findViewById(R.id.control_secondary_message);
if (secondaryMessage == null) {
layout.removeView(secondaryView);
} else {
secondaryView.setText(secondaryMessage);
secondaryView.setTextSize(
TypedValue.COMPLEX_UNIT_PX,
getContext().getResources().getDimension(resourceId));
}
return layout;
}
/**
* Creates a standard toggle switch and adds it to the layout.
*
* -------------------------------------------------
* | ICON | MESSAGE | TOGGLE |
* -------------------------------------------------
* If an icon is not provided, the ImageView that would normally show it is hidden.
*
* @param iconResourceId ID of the drawable to use for the icon, or 0 to hide the ImageView.
* @param iconColorId ID of the tint color for the icon, or 0 for default.
* @param toggleMessage Message to display for the toggle.
* @param toggleId ID to use for the toggle.
* @param isChecked Whether the toggle should start off checked.
*/
public View addSwitch(
int iconResourceId,
int iconColorId,
CharSequence toggleMessage,
int toggleId,
boolean isChecked) {
LinearLayout switchLayout =
(LinearLayout) inflateLayout(getContext(), R.layout.infobar_control_toggle, this);
addView(switchLayout, new ControlLayoutParams());
ImageView iconView = (ImageView) switchLayout.findViewById(R.id.control_icon);
if (iconResourceId == 0) {
switchLayout.removeView(iconView);
} else {
iconView.setImageResource(iconResourceId);
if (iconColorId != 0) {
iconView.setColorFilter(getContext().getColor(iconColorId));
}
}
TextView messageView = (TextView) switchLayout.findViewById(R.id.control_message);
messageView.setText(toggleMessage);
SwitchCompat switchView =
(SwitchCompat) switchLayout.findViewById(R.id.control_toggle_switch);
switchView.setId(toggleId);
switchView.setChecked(isChecked);
return switchLayout;
}
/**
* Creates a set of standard radio buttons and adds it to the layout.
*
* @param messages Messages to display for the options.
* @param tags Optional list of tags to attach to the buttons.
*/
public RadioButtonLayout addRadioButtons(List<CharSequence> messages, @Nullable List<?> tags) {
ControlLayoutParams params = new ControlLayoutParams();
params.mMustBeFullWidth = true;
RadioButtonLayout radioLayout = new RadioButtonLayout(getContext());
radioLayout.addOptions(messages, tags);
addView(radioLayout, params);
return radioLayout;
}
/** Creates a standard spinner and adds it to the layout. */
public <T> Spinner addSpinner(int spinnerId, ArrayAdapter<T> arrayAdapter) {
Spinner spinner =
(Spinner) inflateLayout(getContext(), R.layout.infobar_control_spinner, this);
spinner.setAdapter(arrayAdapter);
addView(spinner, new ControlLayoutParams());
spinner.setId(spinnerId);
return spinner;
}
/** Creates and adds a full-width control with additional text describing what an InfoBar is for. */
public View addDescription(CharSequence message) {
return addDescription(message, ResourcesCompat.ID_NULL);
}
/**
* Creates and adds a full-width control with additional text describing what an InfoBar is for.
* This method overload allows setting text appearance.
*/
public View addDescription(CharSequence message, int textAppearanceId) {
ControlLayoutParams params = new ControlLayoutParams();
params.mMustBeFullWidth = true;
TextView descriptionView =
(TextView) inflateLayout(getContext(), R.layout.dialog_control_description, this);
addView(descriptionView, params);
descriptionView.setText(message);
if (textAppearanceId != ResourcesCompat.ID_NULL) {
descriptionView.setTextAppearance(getContext(), textAppearanceId);
}
descriptionView.setMovementMethod(LinkMovementMethod.getInstance());
return descriptionView;
}
/**
* Do NOT call this method directly from outside {@link InfoBarLayout#InfoBarLayout()}.
*
* Adds a full-width control showing the main InfoBar message. For other text, you should call
* {@link InfoBarControlLayout#addDescription(CharSequence)} instead.
*/
TextView addMainMessage(CharSequence mainMessage) {
ControlLayoutParams params = new ControlLayoutParams();
params.mMustBeFullWidth = true;
TextView messageView =
(TextView) inflateLayout(getContext(), R.layout.infobar_control_message, this);
addView(messageView, params);
messageView.setText(mainMessage);
messageView.setMovementMethod(LinkMovementMethod.getInstance());
return messageView;
}
/** @return The {@link ControlLayoutParams} for the given child. */
@VisibleForTesting
static ControlLayoutParams getControlLayoutParams(View child) {
return (ControlLayoutParams) child.getLayoutParams();
}
private static View inflateLayout(Context context, int layoutId, ViewGroup root) {
// LayoutInflater may trigger accessing the disk.
try (StrictModeContext ignored = StrictModeContext.allowDiskReads()) {
return LayoutInflater.from(context).inflate(layoutId, root, false);
}
}
}