Home | History | Annotate | Download | only in infobar
      1 // Copyright 2013 The Chromium Authors. All rights reserved.
      2 // Use of this source code is governed by a BSD-style license that can be
      3 // found in the LICENSE file.
      4 
      5 package org.chromium.chrome.browser.infobar;
      6 
      7 import android.content.Context;
      8 import android.content.res.Resources;
      9 import android.content.res.TypedArray;
     10 import android.graphics.Color;
     11 import android.graphics.drawable.Drawable;
     12 import android.text.TextUtils;
     13 import android.text.method.LinkMovementMethod;
     14 import android.view.Gravity;
     15 import android.view.LayoutInflater;
     16 import android.view.View;
     17 import android.view.ViewGroup;
     18 import android.widget.Button;
     19 import android.widget.ImageButton;
     20 import android.widget.ImageView;
     21 import android.widget.TextView;
     22 
     23 import org.chromium.base.ApiCompatibilityUtils;
     24 import org.chromium.chrome.R;
     25 
     26 /**
     27  * Layout that arranges an InfoBar's views. An InfoBarLayout consists of:
     28  * - A message describing the action that the user can take.
     29  * - A close button on the right side.
     30  * - (optional) An icon representing the infobar's purpose on the left side.
     31  * - (optional) Additional "custom" views (e.g. a checkbox and text, or a pair of spinners)
     32  * - (optional) One or two buttons with text at the bottom.
     33  *
     34  * When adding custom views, widths and heights defined in the LayoutParams will be ignored.
     35  * However, setting a minimum width in another way, like TextView.getMinWidth(), should still be
     36  * obeyed.
     37  *
     38  * Logic for what happens when things are clicked should be implemented by the InfoBarView.
     39  */
     40 public class InfoBarLayout extends ViewGroup implements View.OnClickListener {
     41 
     42     /**
     43      * Parameters used for laying out children.
     44      */
     45     private static class LayoutParams extends ViewGroup.LayoutParams {
     46 
     47         public int startMargin;
     48         public int endMargin;
     49         public int topMargin;
     50         public int bottomMargin;
     51 
     52         // Where this view will be laid out. These values are assigned in onMeasure() and used in
     53         // onLayout().
     54         public int start;
     55         public int top;
     56 
     57         LayoutParams(int startMargin, int topMargin, int endMargin, int bottomMargin) {
     58             super(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT);
     59             this.startMargin = startMargin;
     60             this.topMargin = topMargin;
     61             this.endMargin = endMargin;
     62             this.bottomMargin = bottomMargin;
     63         }
     64     }
     65 
     66     private static class Group {
     67         public View[] views;
     68 
     69         /**
     70          * The gravity of each view in Group. Must be either Gravity.START, Gravity.END, or
     71          * Gravity.FILL_HORIZONTAL.
     72          */
     73         public int gravity = Gravity.START;
     74 
     75         /** Whether the views are vertically stacked. */
     76         public boolean isStacked;
     77 
     78         void setHorizontalMode(int horizontalSpacing, int startMargin, int endMargin) {
     79             isStacked = false;
     80             for (int i = 0; i < views.length; i++) {
     81                 LayoutParams lp = (LayoutParams) views[i].getLayoutParams();
     82                 lp.startMargin = i == 0 ? startMargin : horizontalSpacing;
     83                 lp.topMargin = 0;
     84                 lp.endMargin = i == views.length - 1 ? endMargin : 0;
     85                 lp.bottomMargin = 0;
     86             }
     87 
     88         }
     89 
     90         void setVerticalMode(int verticalSpacing, int bottomMargin) {
     91             isStacked = true;
     92             for (int i = 0; i < views.length; i++) {
     93                 LayoutParams lp = (LayoutParams) views[i].getLayoutParams();
     94                 lp.startMargin = 0;
     95                 lp.topMargin = i == 0 ? 0 : verticalSpacing;
     96                 lp.endMargin = 0;
     97                 lp.bottomMargin = i == views.length - 1 ? bottomMargin : 0;
     98             }
     99         }
    100     }
    101 
    102     private static final int ROW_MAIN = 1;
    103     private static final int ROW_OTHER = 2;
    104 
    105     private final int mMargin;
    106     private final int mIconSize;
    107     private final int mMinWidth;
    108     private final int mAccentColor;
    109 
    110     private final InfoBarView mInfoBarView;
    111     private final TextView mMessageView;
    112     private final ImageButton mCloseButton;
    113     private ImageView mIconView;
    114 
    115     private Group mMainGroup;
    116     private Group mCustomGroup;
    117     private Group mButtonGroup;
    118 
    119     /**
    120      * These values are used during onMeasure() to track where the next view will be placed.
    121      *
    122      * mWidth is the infobar width.
    123      * [mStart, mEnd) is the range of unoccupied space on the current row.
    124      * mTop and mBottom are the top and bottom of the current row.
    125      *
    126      * These values, along with a view's gravity, are used to position the next view.
    127      * These values are updated after placing a view and after starting a new row.
    128      */
    129     private int mWidth;
    130     private int mStart;
    131     private int mEnd;
    132     private int mTop;
    133     private int mBottom;
    134 
    135     /**
    136      * Constructs a layout for the specified InfoBar. After calling this, be sure to set the
    137      * message, the buttons, and/or the custom content using setMessage(), setButtons(), and
    138      * setCustomContent().
    139      *
    140      * @param context The context used to render.
    141      * @param infoBarView InfoBarView that listens to events.
    142      * @param iconResourceId ID of the icon to use for the InfoBar.
    143      * @param message The message to show in the infobar.
    144      */
    145     public InfoBarLayout(Context context, InfoBarView infoBarView, int iconResourceId,
    146             CharSequence message) {
    147         super(context);
    148         mInfoBarView = infoBarView;
    149 
    150         // Grab the dimensions.
    151         Resources res = getResources();
    152         mMargin = res.getDimensionPixelOffset(R.dimen.infobar_margin);
    153         mIconSize = res.getDimensionPixelSize(R.dimen.infobar_icon_size);
    154         mMinWidth = res.getDimensionPixelSize(R.dimen.infobar_min_width);
    155         mAccentColor = res.getColor(R.color.infobar_accent_blue);
    156 
    157         // Set up the close button. Apply padding so it has a big touch target.
    158         mCloseButton = new ImageButton(context);
    159         mCloseButton.setId(R.id.infobar_close_button);
    160         mCloseButton.setImageResource(R.drawable.infobar_close_button);
    161         TypedArray a = getContext().obtainStyledAttributes(
    162                 new int [] {android.R.attr.selectableItemBackground});
    163         Drawable closeButtonBackground = a.getDrawable(0);
    164         a.recycle();
    165         ApiCompatibilityUtils.setBackgroundForView(mCloseButton, closeButtonBackground);
    166         mCloseButton.setPadding(mMargin, mMargin, mMargin, mMargin);
    167         mCloseButton.setOnClickListener(this);
    168         mCloseButton.setContentDescription(res.getString(R.string.infobar_close));
    169         mCloseButton.setLayoutParams(new LayoutParams(0, -mMargin, -mMargin, -mMargin));
    170         addView(mCloseButton);
    171 
    172         // Set up the icon.
    173         if (iconResourceId != 0) {
    174             mIconView = new ImageView(context);
    175             mIconView.setImageResource(iconResourceId);
    176             mIconView.setFocusable(false);
    177             mIconView.setLayoutParams(new LayoutParams(0, 0, mMargin / 2, 0));
    178             mIconView.getLayoutParams().width = mIconSize;
    179             mIconView.getLayoutParams().height = mIconSize;
    180         }
    181 
    182         // Set up the message view.
    183         mMessageView = (TextView) LayoutInflater.from(context).inflate(R.layout.infobar_text, null);
    184         mMessageView.setText(message, TextView.BufferType.SPANNABLE);
    185         mMessageView.setMovementMethod(LinkMovementMethod.getInstance());
    186         mMessageView.setLinkTextColor(mAccentColor);
    187         mMessageView.setLayoutParams(new LayoutParams(0, mMargin / 4, 0, 0));
    188 
    189         if (mIconView != null) {
    190             mMainGroup = addGroup(mIconView, mMessageView);
    191         } else {
    192             mMainGroup = addGroup(mMessageView);
    193         }
    194     }
    195 
    196     /**
    197      * Sets the message to show on the infobar.
    198      */
    199     public void setMessage(CharSequence message) {
    200         mMessageView.setText(message, TextView.BufferType.SPANNABLE);
    201     }
    202 
    203     /**
    204      * Sets the custom content of the infobar. These views will be displayed in addition to the
    205      * standard infobar controls (icon, text, buttons). Depending on the available space, view1 and
    206      * view2 will be laid out:
    207      *  - Side by side on the main row,
    208      *  - Side by side on a separate row, each taking up half the width of the infobar,
    209      *  - Stacked above each other on two separate rows, taking up the full width of the infobar.
    210      */
    211     public void setCustomContent(View view1, View view2) {
    212         mCustomGroup = addGroup(view1, view2);
    213     }
    214 
    215     /**
    216      * Sets the custom content of the infobar to a single view. This view will be displayed in
    217      * addition to the standard infobar controls. Depending on the available space, the view will be
    218      * displayed:
    219      *  - On the main row, start-aligned or end-aligned depending on whether there are also
    220      *    buttons on the main row, OR
    221      *  - On a separate row, start-aligned
    222      */
    223     public void setCustomContent(View view) {
    224         mCustomGroup = addGroup(view);
    225     }
    226 
    227     /**
    228      * Calls setButtons(primaryText, secondaryText, null).
    229      */
    230     public void setButtons(String primaryText, String secondaryText) {
    231         setButtons(primaryText, secondaryText, null);
    232     }
    233 
    234     /**
    235      * Adds one, two, or three buttons to the layout.
    236      *
    237      * @param primaryText Text for the primary button.
    238      * @param secondaryText Text for the secondary button, or null if there isn't a second button.
    239      * @param tertiaryText Text for the tertiary button, or null if there isn't a third button.
    240      */
    241     public void setButtons(String primaryText, String secondaryText, String tertiaryText) {
    242         if (TextUtils.isEmpty(primaryText)) return;
    243 
    244         LayoutInflater inflater = LayoutInflater.from(getContext());
    245         Button primaryButton = (Button) inflater.inflate(R.layout.infobar_button, null);
    246         primaryButton.setId(R.id.button_primary);
    247         primaryButton.setOnClickListener(this);
    248         primaryButton.setText(primaryText);
    249         primaryButton.setBackgroundResource(R.drawable.btn_infobar_blue);
    250         primaryButton.setTextColor(Color.WHITE);
    251 
    252         if (TextUtils.isEmpty(secondaryText)) {
    253             mButtonGroup = addGroup(primaryButton);
    254             return;
    255         }
    256 
    257         Button secondaryButton = (Button) inflater.inflate(R.layout.infobar_button, null);
    258         secondaryButton.setId(R.id.button_secondary);
    259         secondaryButton.setOnClickListener(this);
    260         secondaryButton.setText(secondaryText);
    261         secondaryButton.setTextColor(mAccentColor);
    262 
    263         if (TextUtils.isEmpty(tertiaryText)) {
    264             mButtonGroup = addGroup(secondaryButton, primaryButton);
    265             return;
    266         }
    267 
    268         Button tertiaryButton = (Button) inflater.inflate(R.layout.infobar_button, null);
    269         tertiaryButton.setId(R.id.button_tertiary);
    270         tertiaryButton.setOnClickListener(this);
    271         tertiaryButton.setText(tertiaryText);
    272         tertiaryButton.setPadding(mMargin / 2, tertiaryButton.getPaddingTop(), mMargin / 2,
    273                 tertiaryButton.getPaddingBottom());
    274         tertiaryButton.setTextColor(
    275                 getContext().getResources().getColor(R.color.infobar_tertiary_button_text));
    276 
    277         mButtonGroup = addGroup(tertiaryButton, secondaryButton, primaryButton);
    278     }
    279 
    280     /**
    281      * Adds a group of Views that are measured and laid out together.
    282      */
    283     private Group addGroup(View... views) {
    284         Group group = new Group();
    285         group.views = views;
    286 
    287         for (View v : views) {
    288             addView(v);
    289         }
    290         return group;
    291     }
    292 
    293     @Override
    294     protected LayoutParams generateDefaultLayoutParams() {
    295         return new LayoutParams(0, 0, 0, 0);
    296     }
    297 
    298     @Override
    299     protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
    300         // Place all the views in the positions already determined during onMeasure().
    301         int width = right - left;
    302         boolean isRtl = ApiCompatibilityUtils.isLayoutRtl(this);
    303 
    304         for (int i = 0; i < getChildCount(); i++) {
    305             View child = getChildAt(i);
    306             LayoutParams lp = (LayoutParams) child.getLayoutParams();
    307             int childLeft = lp.start;
    308             int childRight = lp.start + child.getMeasuredWidth();
    309 
    310             if (isRtl) {
    311                 int tmp = width - childRight;
    312                 childRight = width - childLeft;
    313                 childLeft = tmp;
    314             }
    315 
    316             child.layout(childLeft, lp.top, childRight, lp.top + child.getMeasuredHeight());
    317         }
    318     }
    319 
    320     /**
    321      * Measures *and* assigns positions to all of the views in the infobar. These positions are
    322      * saved in each view's LayoutParams (lp.start and lp.top) and used during onLayout(). All of
    323      * the interesting logic happens inside onMeasure(); onLayout() just assigns the already-
    324      * determined positions and mirrors everything for RTL, if needed.
    325      */
    326     @Override
    327     protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    328         assert getLayoutParams().height == LayoutParams.WRAP_CONTENT
    329                 : "InfoBar heights cannot be constrained.";
    330 
    331         // Measure all children without imposing any size constraints on them. This determines how
    332         // big each child wants to be.
    333         int unspecifiedSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED);
    334         for (int i = 0; i < getChildCount(); i++) {
    335             measureChild(getChildAt(i), unspecifiedSpec, unspecifiedSpec);
    336         }
    337 
    338         // Avoid overlapping views, division by zero, infinite heights, and other fun problems that
    339         // could arise with extremely narrow infobars.
    340         mWidth = Math.max(MeasureSpec.getSize(widthMeasureSpec), mMinWidth);
    341         mTop = mBottom = 0;
    342         placeGroups();
    343 
    344         setMeasuredDimension(mWidth, resolveSize(mBottom, heightMeasureSpec));
    345     }
    346 
    347     /**
    348      * Assigns positions to all of the views in the infobar. The icon, text, and close button are
    349      * placed on the main row. The custom content and finally the buttons are placed on the main row
    350      * if they fit. Otherwise, they go on their own rows.
    351      */
    352     private void placeGroups() {
    353         startRow();
    354         placeChild(mCloseButton, Gravity.END);
    355         placeGroup(mMainGroup);
    356 
    357         int customGroupWidth = 0;
    358         if (mCustomGroup != null) {
    359             updateCustomGroupForRow(ROW_MAIN);
    360             customGroupWidth = getWidthWithMargins(mCustomGroup);
    361         }
    362 
    363         int buttonGroupWidth = 0;
    364         if (mButtonGroup != null) {
    365             updateButtonGroupForRow(ROW_MAIN);
    366             buttonGroupWidth = getWidthWithMargins(mButtonGroup);
    367         }
    368 
    369         boolean customGroupOnMainRow = customGroupWidth <= availableWidth();
    370         boolean buttonGroupOnMainRow = customGroupWidth + buttonGroupWidth <= availableWidth();
    371 
    372         if (mCustomGroup != null) {
    373             if (customGroupOnMainRow) {
    374                 mCustomGroup.gravity = (mButtonGroup != null && buttonGroupOnMainRow)
    375                         ? Gravity.START : Gravity.END;
    376             } else {
    377                 startRow();
    378                 updateCustomGroupForRow(ROW_OTHER);
    379             }
    380             placeGroup(mCustomGroup);
    381         }
    382 
    383         if (mButtonGroup != null) {
    384             if (!buttonGroupOnMainRow) {
    385                 startRow();
    386                 updateButtonGroupForRow(ROW_OTHER);
    387 
    388                 // If the infobar consists of just a main row and a buttons row, the buttons must be
    389                 // at least 32dp below the bottom of the message text.
    390                 if (mCustomGroup == null) {
    391                     LayoutParams lp = (LayoutParams) mMessageView.getLayoutParams();
    392                     int messageBottom = lp.top + mMessageView.getMeasuredHeight();
    393                     mTop = Math.max(mTop, messageBottom + 2 * mMargin);
    394                 }
    395             }
    396             placeGroup(mButtonGroup);
    397         }
    398 
    399         startRow();
    400 
    401         // If everything fits on a single row, center everything vertically.
    402         if (buttonGroupOnMainRow) {
    403             int layoutHeight = mBottom;
    404             for (int i = 0; i < getChildCount(); i++) {
    405                 View child = getChildAt(i);
    406                 int extraSpace = layoutHeight - child.getMeasuredHeight();
    407                 LayoutParams lp = (LayoutParams) child.getLayoutParams();
    408                 lp.top = extraSpace / 2;
    409             }
    410         }
    411     }
    412 
    413     /**
    414      * Places a group of views on the current row, or stacks them over multiple rows if
    415      * group.isStacked is true. mStart, mEnd, and mBottom are updated to reflect the space taken by
    416      * the group.
    417      */
    418     private void placeGroup(Group group) {
    419         if (group.gravity == Gravity.END) {
    420             for (int i = group.views.length - 1; i >= 0; i--) {
    421                 placeChild(group.views[i], group.gravity);
    422                 if (group.isStacked && i != 0) startRow();
    423             }
    424         } else {  // group.gravity is Gravity.START or Gravity.FILL_HORIZONTAL
    425             for (int i = 0; i < group.views.length; i++) {
    426                 placeChild(group.views[i], group.gravity);
    427                 if (group.isStacked && i != group.views.length - 1) startRow();
    428             }
    429         }
    430     }
    431 
    432     /**
    433      * Places a single view on the current row, and updates the view's layout parameters to remember
    434      * its position. mStart, mEnd, and mBottom are updated to reflect the space taken by the view.
    435      */
    436     private void placeChild(View child, int gravity) {
    437         LayoutParams lp = (LayoutParams) child.getLayoutParams();
    438 
    439         int availableWidth = Math.max(0, mEnd - mStart - lp.startMargin - lp.endMargin);
    440         if (child.getMeasuredWidth() > availableWidth || gravity == Gravity.FILL_HORIZONTAL) {
    441             measureChildWithFixedWidth(child, availableWidth);
    442         }
    443 
    444         if (gravity == Gravity.START || gravity == Gravity.FILL_HORIZONTAL) {
    445             lp.start = mStart + lp.startMargin;
    446             mStart = lp.start + child.getMeasuredWidth() + lp.endMargin;
    447         } else {  // gravity == Gravity.END
    448             lp.start = mEnd - lp.endMargin - child.getMeasuredWidth();
    449             mEnd = lp.start - lp.startMargin;
    450         }
    451 
    452         lp.top = mTop + lp.topMargin;
    453         mBottom = Math.max(mBottom, lp.top + child.getMeasuredHeight() + lp.bottomMargin);
    454     }
    455 
    456     /**
    457      * Advances the current position to the next row and adds margins on the left, right, and top
    458      * of the new row.
    459      */
    460     private void startRow() {
    461         mStart = mMargin;
    462         mEnd = mWidth - mMargin;
    463         mTop = mBottom + mMargin;
    464         mBottom = mTop;
    465     }
    466 
    467     private int availableWidth() {
    468         return mEnd - mStart;
    469     }
    470 
    471     /**
    472      * @return The width of the group, including the items' margins.
    473      */
    474     private int getWidthWithMargins(Group group) {
    475         if (group.isStacked) return getWidthWithMargins(group.views[0]);
    476 
    477         int width = 0;
    478         for (View v : group.views) {
    479             width += getWidthWithMargins(v);
    480         }
    481         return width;
    482     }
    483 
    484     private int getWidthWithMargins(View child) {
    485         LayoutParams lp = (LayoutParams) child.getLayoutParams();
    486         return child.getMeasuredWidth() + lp.startMargin + lp.endMargin;
    487     }
    488 
    489     private void measureChildWithFixedWidth(View child, int width) {
    490         int widthSpec = MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY);
    491         int heightSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED);
    492         child.measure(widthSpec, heightSpec);
    493     }
    494 
    495     /**
    496      * The button group has different layout properties (margins, gravity, etc) when placed on the
    497      * main row as opposed to on a separate row. This updates the layout properties of the button
    498      * group to prepare for placing it on either the main row or a separate row.
    499      *
    500      * @param row One of ROW_MAIN or ROW_OTHER.
    501      */
    502     private void updateButtonGroupForRow(int row) {
    503         int startEndMargin = row == ROW_MAIN ? mMargin : 0;
    504         mButtonGroup.setHorizontalMode(mMargin / 2, startEndMargin, startEndMargin);
    505         mButtonGroup.gravity = Gravity.END;
    506 
    507         if (row == ROW_OTHER && mButtonGroup.views.length >= 2) {
    508             int extraWidth = availableWidth() - getWidthWithMargins(mButtonGroup);
    509             if (extraWidth < 0) {
    510                 // Group is too wide to fit on a single row, so stack the group items vertically.
    511                 mButtonGroup.setVerticalMode(mMargin / 2, 0);
    512                 mButtonGroup.gravity = Gravity.FILL_HORIZONTAL;
    513             } else if (mButtonGroup.views.length == 3) {
    514                 // Align tertiary button at the start and the other two buttons at the end.
    515                 ((LayoutParams) mButtonGroup.views[0].getLayoutParams()).endMargin += extraWidth;
    516             }
    517         }
    518     }
    519 
    520     /**
    521      * Analagous to updateButtonGroupForRow(), but for the custom group istead of the button group.
    522      */
    523     private void updateCustomGroupForRow(int row) {
    524         int startEndMargin = row == ROW_MAIN ? mMargin : 0;
    525         mCustomGroup.setHorizontalMode(mMargin, startEndMargin, startEndMargin);
    526         mCustomGroup.gravity = Gravity.START;
    527 
    528         if (row == ROW_OTHER && mCustomGroup.views.length == 2) {
    529             int extraWidth = availableWidth() - getWidthWithMargins(mCustomGroup);
    530             if (extraWidth < 0) {
    531                 // Group is too wide to fit on a single row, so stack the group items vertically.
    532                 mCustomGroup.setVerticalMode(0, mMargin);
    533                 mCustomGroup.gravity = Gravity.FILL_HORIZONTAL;
    534             } else {
    535                 // Expand the children to take up the entire row.
    536                 View view0 = mCustomGroup.views[0];
    537                 View view1 = mCustomGroup.views[1];
    538                 int extraWidth0 = extraWidth / 2;
    539                 int extraWidth1 = extraWidth - extraWidth0;
    540                 measureChildWithFixedWidth(view0, view0.getMeasuredWidth() + extraWidth0);
    541                 measureChildWithFixedWidth(view1, view1.getMeasuredWidth() + extraWidth1);
    542             }
    543         }
    544     }
    545 
    546     /**
    547      * Listens for View clicks.
    548      * Classes that override this function MUST call this one.
    549      * @param view View that was clicked on.
    550      */
    551     @Override
    552     public void onClick(View view) {
    553         // Disable the infobar controls unless the user clicked the tertiary button, which by
    554         // convention is the "learn more" link.
    555         if (view.getId() != R.id.button_tertiary) {
    556             mInfoBarView.setControlsEnabled(false);
    557         }
    558 
    559         if (view.getId() == R.id.infobar_close_button) {
    560             mInfoBarView.onCloseButtonClicked();
    561         } else if (view.getId() == R.id.button_primary) {
    562             mInfoBarView.onButtonClicked(true);
    563         } else if (view.getId() == R.id.button_secondary) {
    564             mInfoBarView.onButtonClicked(false);
    565         } else if (view.getId() == R.id.button_tertiary) {
    566             mInfoBarView.onLinkClicked();
    567         }
    568     }
    569 }
    570