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