// Copyright 2015 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. package org.chromium.chrome.browser.infobar; import android.content.Context; import android.content.res.Resources; import android.graphics.Paint; import android.support.v7.widget.SwitchCompat; import android.text.method.LinkMovementMethod; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.view.ViewGroup.LayoutParams; import android.widget.ArrayAdapter; import android.widget.ImageView; import android.widget.LinearLayout; import android.widget.RatingBar; import android.widget.Spinner; import android.widget.TextView; import org.chromium.base.ApiCompatibilityUtils; import org.chromium.base.VisibleForTesting; import org.chromium.chrome.R; import org.chromium.chrome.browser.widget.DualControlLayout; /** * 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. */ 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) LayoutInflater.from(getContext()) .inflate(R.layout.infobar_control_spinner_drop_down, parent, false); } 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) LayoutInflater.from(getContext()) .inflate(R.layout.infobar_control_spinner_view, parent, false); } // 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()}. */ InfoBarControlLayout(Context context) { super(context); 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) { assert getLayoutParams().height == LayoutParams.WRAP_CONTENT : "Height of this layout cannot be constrained."; 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 = ApiCompatibilityUtils.isLayoutRtl(this); // 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); } } /** * 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) { LinearLayout layout = (LinearLayout) LayoutInflater.from(getContext()).inflate( R.layout.infobar_control_icon_with_description, this, false); addView(layout, new ControlLayoutParams()); ImageView iconView = (ImageView) layout.findViewById(R.id.control_icon); iconView.setImageResource(iconResourceId); if (iconColorId != 0) { iconView.setColorFilter(ApiCompatibilityUtils.getColor(getResources(), iconColorId)); } // The primary message text is always displayed. TextView primaryView = (TextView) layout.findViewById(R.id.control_message); primaryView.setText(primaryMessage); // 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); } 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) LayoutInflater.from(getContext()).inflate( R.layout.infobar_control_toggle, this, false); 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( ApiCompatibilityUtils.getColor(getResources(), 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 standard spinner and adds it to the layout. */ public <T> Spinner addSpinner(int spinnerId, ArrayAdapter<T> arrayAdapter) { Spinner spinner = (Spinner) LayoutInflater.from(getContext()).inflate( R.layout.infobar_control_spinner, this, false); 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) { ControlLayoutParams params = new ControlLayoutParams(); params.mMustBeFullWidth = true; TextView descriptionView = (TextView) LayoutInflater.from(getContext()).inflate( R.layout.infobar_control_description, this, false); addView(descriptionView, params); descriptionView.setText(message); descriptionView.setMovementMethod(LinkMovementMethod.getInstance()); return descriptionView; } /** * Creates and adds a control that shows a review rating score. * * @param rating Fractional rating out of 5 stars. */ public View addRatingBar(float rating) { View ratingLayout = LayoutInflater.from(getContext()).inflate( R.layout.infobar_control_rating, this, false); addView(ratingLayout, new ControlLayoutParams()); RatingBar ratingView = (RatingBar) ratingLayout.findViewById(R.id.control_rating); ratingView.setRating(rating); return ratingView; } /** * 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) LayoutInflater.from(getContext()).inflate( R.layout.infobar_control_message, this, false); 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(); } }