1// Copyright 2014 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 5package org.chromium.chrome.browser.banners; 6 7import android.animation.ObjectAnimator; 8import android.app.Activity; 9import android.app.PendingIntent; 10import android.content.ActivityNotFoundException; 11import android.content.ContentResolver; 12import android.content.Context; 13import android.content.Intent; 14import android.content.IntentSender; 15import android.content.pm.PackageManager; 16import android.content.res.Configuration; 17import android.content.res.Resources; 18import android.graphics.Rect; 19import android.os.Looper; 20import android.util.AttributeSet; 21import android.util.Log; 22import android.view.LayoutInflater; 23import android.view.MotionEvent; 24import android.view.View; 25import android.view.ViewConfiguration; 26import android.view.ViewGroup; 27import android.widget.Button; 28import android.widget.ImageButton; 29import android.widget.ImageView; 30import android.widget.TextView; 31 32import org.chromium.base.ApiCompatibilityUtils; 33import org.chromium.chrome.R; 34import org.chromium.content.browser.ContentViewCore; 35import org.chromium.ui.base.LocalizationUtils; 36import org.chromium.ui.base.WindowAndroid; 37import org.chromium.ui.base.WindowAndroid.IntentCallback; 38 39/** 40 * Lays out a banner for showing info about an app on the Play Store. 41 * The banner mimics the appearance of a Google Now card using a background Drawable with a shadow. 42 * 43 * PADDING CALCULATIONS 44 * The banner has three different types of padding that need to be accounted for: 45 * 1) The background Drawable of the banner looks like card with a drop shadow. The Drawable 46 * defines a padding around the card that solely encompasses the space occupied by the drop 47 * shadow. 48 * 2) The card itself needs to have padding so that the widgets don't abut the borders of the card. 49 * This is defined as mPaddingCard, and is equally applied to all four sides. 50 * 3) Controls other than the icon are further constrained by mPaddingControls, which applies only 51 * to the bottom and end margins. 52 * See {@link #AppBannerView.onMeasure(int, int)} for details. 53 * 54 * MARGIN CALCULATIONS 55 * Margin calculations for the banner are complicated by the background Drawable's drop shadows, 56 * since the drop shadows are meant to be counted as being part of the margin. To deal with this, 57 * the margins are calculated by deducting the background Drawable's padding from the margins 58 * defined by the XML files. 59 * 60 * EVEN MORE LAYOUT QUIRKS 61 * The layout of the banner, which includes its widget sizes, may change when the screen is rotated 62 * to account for less screen real estate. This means that all of the View's widgets and cached 63 * dimensions must be rebuilt from scratch. 64 */ 65public class AppBannerView extends SwipableOverlayView 66 implements View.OnClickListener, InstallerDelegate.Observer, IntentCallback { 67 private static final String TAG = "AppBannerView"; 68 69 /** 70 * Class that is alerted about things happening to the BannerView. 71 */ 72 public static interface Observer { 73 /** 74 * Called when the banner is removed from the hierarchy. 75 * @param banner Banner being dismissed. 76 */ 77 public void onBannerRemoved(AppBannerView banner); 78 79 /** 80 * Called when the user manually closes a banner. 81 * @param banner Banner being blocked. 82 * @param url URL of the page that requested the banner. 83 * @param packageName Name of the app's package. 84 */ 85 public void onBannerBlocked(AppBannerView banner, String url, String packageName); 86 87 /** 88 * Called when the banner begins to be dismissed. 89 * @param banner Banner being closed. 90 * @param dismissType Type of dismissal performed. 91 */ 92 public void onBannerDismissEvent(AppBannerView banner, int dismissType); 93 94 /** 95 * Called when an install event has occurred. 96 */ 97 public void onBannerInstallEvent(AppBannerView banner, int eventType); 98 99 /** 100 * Called when the banner needs to have an Activity started for a result. 101 * @param banner Banner firing the event. 102 * @param intent Intent to fire. 103 */ 104 public boolean onFireIntent(AppBannerView banner, PendingIntent intent); 105 } 106 107 // Installation states. 108 private static final int INSTALL_STATE_NOT_INSTALLED = 0; 109 private static final int INSTALL_STATE_INSTALLING = 1; 110 private static final int INSTALL_STATE_INSTALLED = 2; 111 112 // XML layout for the BannerView. 113 private static final int BANNER_LAYOUT = R.layout.app_banner_view; 114 115 // True if the layout is in left-to-right layout mode (regular mode). 116 private final boolean mIsLayoutLTR; 117 118 // Class to alert about BannerView events. 119 private AppBannerView.Observer mObserver; 120 121 // Information about the package. Shouldn't ever be null after calling {@link #initialize()}. 122 private AppData mAppData; 123 124 // Views comprising the app banner. 125 private ImageView mIconView; 126 private TextView mTitleView; 127 private Button mInstallButtonView; 128 private RatingView mRatingView; 129 private View mLogoView; 130 private View mBannerHighlightView; 131 private ImageButton mCloseButtonView; 132 133 // Dimension values. 134 private int mDefinedMaxWidth; 135 private int mPaddingCard; 136 private int mPaddingControls; 137 private int mMarginLeft; 138 private int mMarginRight; 139 private int mMarginBottom; 140 private int mTouchSlop; 141 142 // Highlight variables. 143 private boolean mIsBannerPressed; 144 private float mInitialXForHighlight; 145 146 // Initial padding values. 147 private final Rect mBackgroundDrawablePadding; 148 149 // Install tracking. 150 private boolean mWasInstallDialogShown; 151 private InstallerDelegate mInstallTask; 152 private int mInstallState; 153 154 /** 155 * Creates a BannerView and adds it to the given ContentViewCore. 156 * @param contentViewCore ContentViewCore to display the AppBannerView for. 157 * @param observer Class that is alerted for AppBannerView events. 158 * @param data Data about the app. 159 * @return The created banner. 160 */ 161 public static AppBannerView create( 162 ContentViewCore contentViewCore, Observer observer, AppData data) { 163 Context context = contentViewCore.getContext().getApplicationContext(); 164 AppBannerView banner = 165 (AppBannerView) LayoutInflater.from(context).inflate(BANNER_LAYOUT, null); 166 banner.initialize(observer, data); 167 banner.addToView(contentViewCore); 168 return banner; 169 } 170 171 /** 172 * Creates a BannerView from an XML layout. 173 */ 174 public AppBannerView(Context context, AttributeSet attrs) { 175 super(context, attrs); 176 mIsLayoutLTR = !LocalizationUtils.isLayoutRtl(); 177 178 // Store the background Drawable's padding. The background used for banners is a 9-patch, 179 // which means that it already defines padding. We need to take it into account when adding 180 // even more padding to the inside of it. 181 mBackgroundDrawablePadding = new Rect(); 182 mBackgroundDrawablePadding.left = ApiCompatibilityUtils.getPaddingStart(this); 183 mBackgroundDrawablePadding.right = ApiCompatibilityUtils.getPaddingEnd(this); 184 mBackgroundDrawablePadding.top = getPaddingTop(); 185 mBackgroundDrawablePadding.bottom = getPaddingBottom(); 186 187 mInstallState = INSTALL_STATE_NOT_INSTALLED; 188 } 189 190 /** 191 * Initialize the banner with information about the package. 192 * @param observer Class to alert about changes to the banner. 193 * @param data Information about the app being advertised. 194 */ 195 private void initialize(Observer observer, AppData data) { 196 mObserver = observer; 197 mAppData = data; 198 initializeControls(); 199 } 200 201 private void initializeControls() { 202 // Cache the banner dimensions, adjusting margins for drop shadows defined in the background 203 // Drawable. 204 Resources res = getResources(); 205 mDefinedMaxWidth = res.getDimensionPixelSize(R.dimen.app_banner_max_width); 206 mPaddingCard = res.getDimensionPixelSize(R.dimen.app_banner_padding); 207 mPaddingControls = res.getDimensionPixelSize(R.dimen.app_banner_padding_controls); 208 mMarginLeft = res.getDimensionPixelSize(R.dimen.app_banner_margin_sides) 209 - mBackgroundDrawablePadding.left; 210 mMarginRight = res.getDimensionPixelSize(R.dimen.app_banner_margin_sides) 211 - mBackgroundDrawablePadding.right; 212 mMarginBottom = res.getDimensionPixelSize(R.dimen.app_banner_margin_bottom) 213 - mBackgroundDrawablePadding.bottom; 214 if (getLayoutParams() != null) { 215 MarginLayoutParams params = (MarginLayoutParams) getLayoutParams(); 216 params.leftMargin = mMarginLeft; 217 params.rightMargin = mMarginRight; 218 params.bottomMargin = mMarginBottom; 219 } 220 221 // Pull out all of the controls we are expecting. 222 mIconView = (ImageView) findViewById(R.id.app_icon); 223 mTitleView = (TextView) findViewById(R.id.app_title); 224 mInstallButtonView = (Button) findViewById(R.id.app_install_button); 225 mRatingView = (RatingView) findViewById(R.id.app_rating); 226 mLogoView = findViewById(R.id.store_logo); 227 mBannerHighlightView = findViewById(R.id.banner_highlight); 228 mCloseButtonView = (ImageButton) findViewById(R.id.close_button); 229 230 assert mIconView != null; 231 assert mTitleView != null; 232 assert mInstallButtonView != null; 233 assert mLogoView != null; 234 assert mRatingView != null; 235 assert mBannerHighlightView != null; 236 assert mCloseButtonView != null; 237 238 // Set up the buttons to fire an event. 239 mInstallButtonView.setOnClickListener(this); 240 mCloseButtonView.setOnClickListener(this); 241 242 // Configure the controls with the package information. 243 mTitleView.setText(mAppData.title()); 244 mIconView.setImageDrawable(mAppData.icon()); 245 mRatingView.initialize(mAppData.rating()); 246 setAccessibilityInformation(); 247 248 // Determine how much the user can drag sideways before their touch is considered a scroll. 249 mTouchSlop = ViewConfiguration.get(getContext()).getScaledTouchSlop(); 250 251 // Set up the install button. 252 updateButtonStatus(); 253 } 254 255 /** 256 * Creates a succinct description about the app being advertised. 257 */ 258 private void setAccessibilityInformation() { 259 String bannerText = getContext().getString( 260 R.string.app_banner_view_accessibility, mAppData.title(), mAppData.rating()); 261 setContentDescription(bannerText); 262 } 263 264 @Override 265 public void onClick(View view) { 266 if (mObserver == null) return; 267 268 // Only allow the button to be clicked when the banner's in a neutral position. 269 if (Math.abs(getTranslationX()) > ZERO_THRESHOLD 270 || Math.abs(getTranslationY()) > ZERO_THRESHOLD) { 271 return; 272 } 273 274 if (view == mInstallButtonView) { 275 // Check that nothing happened in the background to change the install state of the app. 276 int previousState = mInstallState; 277 updateButtonStatus(); 278 if (mInstallState != previousState) return; 279 280 // Ignore button clicks when the app is installing. 281 if (mInstallState == INSTALL_STATE_INSTALLING) return; 282 283 mInstallButtonView.setEnabled(false); 284 285 if (mInstallState == INSTALL_STATE_NOT_INSTALLED) { 286 // The user initiated an install. Track it happening only once. 287 if (!mWasInstallDialogShown) { 288 mObserver.onBannerInstallEvent(this, AppBannerMetricsIds.INSTALL_TRIGGERED); 289 mWasInstallDialogShown = true; 290 } 291 292 if (mObserver.onFireIntent(this, mAppData.installIntent())) { 293 // Temporarily hide the banner. 294 createVerticalSnapAnimation(false); 295 } else { 296 Log.e(TAG, "Failed to fire install intent."); 297 dismiss(AppBannerMetricsIds.DISMISS_ERROR); 298 } 299 } else if (mInstallState == INSTALL_STATE_INSTALLED) { 300 // The app is installed. Open it. 301 try { 302 Intent appIntent = getAppLaunchIntent(); 303 if (appIntent != null) getContext().startActivity(appIntent); 304 } catch (ActivityNotFoundException e) { 305 Log.e(TAG, "Failed to find app package: " + mAppData.packageName()); 306 } 307 308 dismiss(AppBannerMetricsIds.DISMISS_APP_OPEN); 309 } 310 } else if (view == mCloseButtonView) { 311 if (mObserver != null) { 312 mObserver.onBannerBlocked(this, mAppData.siteUrl(), mAppData.packageName()); 313 } 314 315 dismiss(AppBannerMetricsIds.DISMISS_CLOSE_BUTTON); 316 } 317 } 318 319 @Override 320 protected void onViewSwipedAway() { 321 if (mObserver == null) return; 322 mObserver.onBannerDismissEvent(this, AppBannerMetricsIds.DISMISS_BANNER_SWIPE); 323 mObserver.onBannerBlocked(this, mAppData.siteUrl(), mAppData.packageName()); 324 } 325 326 @Override 327 protected void onViewClicked() { 328 // Send the user to the app's Play store page. 329 try { 330 IntentSender sender = mAppData.detailsIntent().getIntentSender(); 331 getContext().startIntentSender(sender, new Intent(), 0, 0, 0); 332 } catch (IntentSender.SendIntentException e) { 333 Log.e(TAG, "Failed to launch details intent."); 334 } 335 336 dismiss(AppBannerMetricsIds.DISMISS_BANNER_CLICK); 337 } 338 339 @Override 340 protected void onViewPressed(MotionEvent event) { 341 // Highlight the banner when the user has held it for long enough and doesn't move. 342 mInitialXForHighlight = event.getRawX(); 343 mIsBannerPressed = true; 344 mBannerHighlightView.setVisibility(View.VISIBLE); 345 } 346 347 @Override 348 public void onIntentCompleted(WindowAndroid window, int resultCode, 349 ContentResolver contentResolver, Intent data) { 350 if (isDismissed()) return; 351 352 createVerticalSnapAnimation(true); 353 if (resultCode == Activity.RESULT_OK) { 354 // The user chose to install the app. Watch the PackageManager to see when it finishes 355 // installing it. 356 mObserver.onBannerInstallEvent(this, AppBannerMetricsIds.INSTALL_STARTED); 357 358 PackageManager pm = getContext().getPackageManager(); 359 mInstallTask = 360 new InstallerDelegate(Looper.getMainLooper(), pm, this, mAppData.packageName()); 361 mInstallTask.start(); 362 mInstallState = INSTALL_STATE_INSTALLING; 363 } 364 updateButtonStatus(); 365 } 366 367 368 @Override 369 public void onInstallFinished(InstallerDelegate monitor, boolean success) { 370 if (isDismissed() || mInstallTask != monitor) return; 371 372 if (success) { 373 // Let the user open the app from here. 374 mObserver.onBannerInstallEvent(this, AppBannerMetricsIds.INSTALL_COMPLETED); 375 mInstallState = INSTALL_STATE_INSTALLED; 376 updateButtonStatus(); 377 } else { 378 dismiss(AppBannerMetricsIds.DISMISS_INSTALL_TIMEOUT); 379 } 380 } 381 382 @Override 383 protected ViewGroup.MarginLayoutParams createLayoutParams() { 384 // Define the margin around the entire banner that accounts for the drop shadow. 385 ViewGroup.MarginLayoutParams params = super.createLayoutParams(); 386 params.setMargins(mMarginLeft, 0, mMarginRight, mMarginBottom); 387 return params; 388 } 389 390 /** 391 * Removes this View from its parent and alerts any observers of the dismissal. 392 * @return Whether or not the View was successfully dismissed. 393 */ 394 @Override 395 boolean removeFromParent() { 396 if (super.removeFromParent()) { 397 mObserver.onBannerRemoved(this); 398 destroy(); 399 return true; 400 } 401 402 return false; 403 } 404 405 /** 406 * Dismisses the banner. 407 * @param eventType Event that triggered the dismissal. See {@link AppBannerMetricsIds}. 408 */ 409 public void dismiss(int eventType) { 410 if (isDismissed() || mObserver == null) return; 411 412 dismiss(eventType == AppBannerMetricsIds.DISMISS_CLOSE_BUTTON); 413 mObserver.onBannerDismissEvent(this, eventType); 414 } 415 416 /** 417 * Destroys the Banner. 418 */ 419 public void destroy() { 420 if (!isDismissed()) dismiss(AppBannerMetricsIds.DISMISS_ERROR); 421 422 if (mInstallTask != null) { 423 mInstallTask.cancel(); 424 mInstallTask = null; 425 } 426 } 427 428 /** 429 * Updates the install button (install state, text, color, etc.). 430 */ 431 void updateButtonStatus() { 432 if (mInstallButtonView == null) return; 433 434 // Determine if the saved install status of the app is out of date. 435 // It is not easily possible to detect if an app is in the process of being installed, so we 436 // can't properly transition to that state from here. 437 if (getAppLaunchIntent() == null) { 438 if (mInstallState == INSTALL_STATE_INSTALLED) { 439 mInstallState = INSTALL_STATE_NOT_INSTALLED; 440 } 441 } else { 442 mInstallState = INSTALL_STATE_INSTALLED; 443 } 444 445 // Update what the button looks like. 446 Resources res = getResources(); 447 int fgColor; 448 String text; 449 if (mInstallState == INSTALL_STATE_INSTALLED) { 450 ApiCompatibilityUtils.setBackgroundForView(mInstallButtonView, 451 res.getDrawable(R.drawable.app_banner_button_open)); 452 fgColor = res.getColor(R.color.app_banner_open_button_fg); 453 text = res.getString(R.string.app_banner_open); 454 } else { 455 ApiCompatibilityUtils.setBackgroundForView(mInstallButtonView, 456 res.getDrawable(R.drawable.app_banner_button_install)); 457 fgColor = res.getColor(R.color.app_banner_install_button_fg); 458 if (mInstallState == INSTALL_STATE_NOT_INSTALLED) { 459 text = mAppData.installButtonText(); 460 mInstallButtonView.setContentDescription( 461 getContext().getString(R.string.app_banner_install_accessibility, text)); 462 } else { 463 text = res.getString(R.string.app_banner_installing); 464 } 465 } 466 467 mInstallButtonView.setTextColor(fgColor); 468 mInstallButtonView.setText(text); 469 mInstallButtonView.setEnabled(mInstallState != INSTALL_STATE_INSTALLING); 470 } 471 472 /** 473 * Determine how big an icon needs to be for the Layout. 474 * @param context Context to grab resources from. 475 * @return How big the icon is expected to be, in pixels. 476 */ 477 static int getIconSize(Context context) { 478 return context.getResources().getDimensionPixelSize(R.dimen.app_banner_icon_size); 479 } 480 481 /** 482 * Passes all touch events through to the parent. 483 */ 484 @Override 485 public boolean onTouchEvent(MotionEvent event) { 486 int action = event.getActionMasked(); 487 if (mIsBannerPressed) { 488 // Mimic Google Now card behavior, where the card stops being highlighted if the user 489 // scrolls a bit to the side. 490 float xDifference = Math.abs(event.getRawX() - mInitialXForHighlight); 491 if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL 492 || (action == MotionEvent.ACTION_MOVE && xDifference > mTouchSlop)) { 493 mIsBannerPressed = false; 494 mBannerHighlightView.setVisibility(View.INVISIBLE); 495 } 496 } 497 498 return super.onTouchEvent(event); 499 } 500 501 /** 502 * Fade the banner back into view. 503 */ 504 @Override 505 protected void onAttachedToWindow() { 506 super.onAttachedToWindow(); 507 ObjectAnimator.ofFloat(this, "alpha", getAlpha(), 1.f).setDuration( 508 MS_ANIMATION_DURATION).start(); 509 setVisibility(VISIBLE); 510 } 511 512 /** 513 * Immediately hide the banner to avoid having them show up in snapshots. 514 */ 515 @Override 516 protected void onDetachedFromWindow() { 517 super.onDetachedFromWindow(); 518 setAlpha(0.0f); 519 setVisibility(INVISIBLE); 520 } 521 522 /** 523 * Watch for changes in the available screen height, which triggers a complete recreation of the 524 * banner widgets. This is mainly due to the fact that the Nexus 7 has a smaller banner defined 525 * for its landscape versus its portrait layouts. 526 */ 527 @Override 528 protected void onConfigurationChanged(Configuration config) { 529 super.onConfigurationChanged(config); 530 531 if (isDismissed()) return; 532 533 // If the card's maximum width hasn't changed, the individual views can't have, either. 534 int newDefinedWidth = getResources().getDimensionPixelSize(R.dimen.app_banner_max_width); 535 if (mDefinedMaxWidth == newDefinedWidth) return; 536 537 // Cannibalize another version of this layout to get Views using the new resources and 538 // sizes. 539 while (getChildCount() > 0) removeViewAt(0); 540 mIconView = null; 541 mTitleView = null; 542 mInstallButtonView = null; 543 mRatingView = null; 544 mLogoView = null; 545 mBannerHighlightView = null; 546 547 AppBannerView cannibalized = 548 (AppBannerView) LayoutInflater.from(getContext()).inflate(BANNER_LAYOUT, null); 549 while (cannibalized.getChildCount() > 0) { 550 View child = cannibalized.getChildAt(0); 551 cannibalized.removeViewAt(0); 552 addView(child); 553 } 554 initializeControls(); 555 requestLayout(); 556 } 557 558 @Override 559 public void onWindowFocusChanged(boolean hasWindowFocus) { 560 if (hasWindowFocus) updateButtonStatus(); 561 } 562 563 /** 564 * @return Intent to launch the app that is being promoted. 565 */ 566 private Intent getAppLaunchIntent() { 567 String packageName = mAppData.packageName(); 568 PackageManager packageManager = getContext().getPackageManager(); 569 return packageManager.getLaunchIntentForPackage(packageName); 570 } 571 572 /** 573 * Measures the banner and its children Views for the given space. 574 * 575 * DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD 576 * DPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPD 577 * DP...... cPD 578 * DP...... TITLE----------------------- XcPD 579 * DP.ICON. ***** cPD 580 * DP...... LOGO BUTTONcPD 581 * DP...... cccccccccccccccccccccccccccccccPD 582 * DPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPD 583 * DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD 584 * 585 * The three paddings mentioned in the class Javadoc are denoted by: 586 * D) Drop shadow padding. 587 * P) Inner card padding. 588 * c) Control padding. 589 * 590 * Measurement for components of the banner are performed assuming that components are laid out 591 * inside of the banner's background as follows: 592 * 1) A maximum width is enforced on the banner to keep the whole thing on screen and keep it a 593 * reasonable size. 594 * 2) The icon takes up the left side of the banner. 595 * 3) The install button occupies the bottom-right of the banner. 596 * 4) The Google Play logo occupies the space to the left of the button. 597 * 5) The rating is assigned space above the logo and below the title. 598 * 6) The close button (if visible) sits in the top right of the banner. 599 * 7) The title is assigned whatever space is left and sits on top of the tallest stack of 600 * controls. 601 * 602 * See {@link #android.view.View.onMeasure(int, int)} for the parameters. 603 */ 604 @Override 605 protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { 606 // Enforce a maximum width on the banner, which is defined as the smallest of: 607 // 1) The smallest width for the device (in either landscape or portrait mode). 608 // 2) The defined maximum width in the dimens.xml files. 609 // 3) The width passed in through the MeasureSpec. 610 Resources res = getResources(); 611 float density = res.getDisplayMetrics().density; 612 int screenSmallestWidth = (int) (res.getConfiguration().smallestScreenWidthDp * density); 613 int specWidth = MeasureSpec.getSize(widthMeasureSpec); 614 int bannerWidth = Math.min(Math.min(specWidth, mDefinedMaxWidth), screenSmallestWidth); 615 616 // Track how much space is available inside the banner's card-shaped background Drawable. 617 // To calculate this, we need to account for both the padding of the background (which 618 // is occupied by the card's drop shadows) as well as the padding defined on the inside of 619 // the card. 620 int bgPaddingWidth = mBackgroundDrawablePadding.left + mBackgroundDrawablePadding.right; 621 int bgPaddingHeight = mBackgroundDrawablePadding.top + mBackgroundDrawablePadding.bottom; 622 final int maxControlWidth = bannerWidth - bgPaddingWidth - (mPaddingCard * 2); 623 624 // Control height is constrained to provide a reasonable aspect ratio. 625 // In practice, the only controls which can cause an issue are the title and the install 626 // button, since they have strings that can change size according to user preference. The 627 // other controls are all defined to be a certain height. 628 int specHeight = MeasureSpec.getSize(heightMeasureSpec); 629 int reasonableHeight = maxControlWidth / 4; 630 int paddingHeight = bgPaddingHeight + (mPaddingCard * 2); 631 final int maxControlHeight = Math.min(specHeight, reasonableHeight) - paddingHeight; 632 final int maxStackedControlHeight = maxControlWidth / 3; 633 634 // Determine how big each component wants to be. The icon is measured separately because 635 // it is not stacked with the other controls. 636 measureChildForSpace(mIconView, maxControlWidth, maxControlHeight); 637 for (int i = 0; i < getChildCount(); i++) { 638 if (getChildAt(i) != mIconView) { 639 measureChildForSpace(getChildAt(i), maxControlWidth, maxStackedControlHeight); 640 } 641 } 642 643 // Determine how tall the banner needs to be to fit everything by calculating the combined 644 // height of the stacked controls. There are three competing stacks to measure: 645 // 1) The icon. 646 // 2) The app title + control padding + star rating + store logo. 647 // 3) The app title + control padding + install button. 648 // The control padding is extra padding that applies only to the non-icon widgets. 649 // The close button does not get counted as part of a stack. 650 int iconStackHeight = getHeightWithMargins(mIconView); 651 int logoStackHeight = getHeightWithMargins(mTitleView) + mPaddingControls 652 + getHeightWithMargins(mRatingView) + getHeightWithMargins(mLogoView); 653 int buttonStackHeight = getHeightWithMargins(mTitleView) + mPaddingControls 654 + getHeightWithMargins(mInstallButtonView); 655 int biggestStackHeight = 656 Math.max(iconStackHeight, Math.max(logoStackHeight, buttonStackHeight)); 657 658 // The icon hugs the banner's starting edge, from the top of the banner to the bottom. 659 final int iconSize = biggestStackHeight; 660 measureChildForSpaceExactly(mIconView, iconSize, iconSize); 661 662 // The rest of the content is laid out to the right of the icon. 663 // Additional padding is defined for non-icon content on the end and bottom. 664 final int contentWidth = 665 maxControlWidth - getWidthWithMargins(mIconView) - mPaddingControls; 666 final int contentHeight = biggestStackHeight - mPaddingControls; 667 measureChildForSpace(mLogoView, contentWidth, contentHeight); 668 669 // Restrict the button size to prevent overrunning the Google Play logo. 670 int remainingButtonWidth = 671 maxControlWidth - getWidthWithMargins(mLogoView) - getWidthWithMargins(mIconView); 672 mInstallButtonView.setMaxWidth(remainingButtonWidth); 673 measureChildForSpace(mInstallButtonView, contentWidth, contentHeight); 674 675 // Measure the star rating, which sits below the title and above the logo. 676 final int ratingWidth = contentWidth; 677 final int ratingHeight = contentHeight - getHeightWithMargins(mLogoView); 678 measureChildForSpace(mRatingView, ratingWidth, ratingHeight); 679 680 // The close button sits to the right of the title and above the install button. 681 final int closeWidth = contentWidth; 682 final int closeHeight = contentHeight - getHeightWithMargins(mInstallButtonView); 683 measureChildForSpace(mCloseButtonView, closeWidth, closeHeight); 684 685 // The app title spans the top of the banner and sits on top of the other controls, and to 686 // the left of the close button. The computation for the width available to the title is 687 // complicated by how the button sits in the corner and absorbs the padding that would 688 // normally be there. 689 int biggerStack = Math.max(getHeightWithMargins(mInstallButtonView), 690 getHeightWithMargins(mLogoView) + getHeightWithMargins(mRatingView)); 691 final int titleWidth = contentWidth - getWidthWithMargins(mCloseButtonView) + mPaddingCard; 692 final int titleHeight = contentHeight - biggerStack; 693 measureChildForSpace(mTitleView, titleWidth, titleHeight); 694 695 // Set the measured dimensions for the banner. The banner's height is defined by the 696 // tallest stack of components, the padding of the banner's card background, and the extra 697 // padding around the banner's components. 698 int bannerPadding = mBackgroundDrawablePadding.top + mBackgroundDrawablePadding.bottom 699 + (mPaddingCard * 2); 700 int bannerHeight = biggestStackHeight + bannerPadding; 701 setMeasuredDimension(bannerWidth, bannerHeight); 702 703 // Make the banner highlight view be the exact same size as the banner's card background. 704 final int cardWidth = bannerWidth - bgPaddingWidth; 705 final int cardHeight = bannerHeight - bgPaddingHeight; 706 measureChildForSpaceExactly(mBannerHighlightView, cardWidth, cardHeight); 707 } 708 709 /** 710 * Lays out the controls according to the algorithm in {@link #onMeasure}. 711 * See {@link #android.view.View.onLayout(boolean, int, int, int, int)} for the parameters. 712 */ 713 @Override 714 protected void onLayout(boolean changed, int l, int t, int r, int b) { 715 super.onLayout(changed, l, t, r, b); 716 int top = mBackgroundDrawablePadding.top; 717 int bottom = getMeasuredHeight() - mBackgroundDrawablePadding.bottom; 718 int start = mBackgroundDrawablePadding.left; 719 int end = getMeasuredWidth() - mBackgroundDrawablePadding.right; 720 721 // The highlight overlay covers the entire banner (minus drop shadow padding). 722 mBannerHighlightView.layout(start, top, end, bottom); 723 724 // Lay out the close button in the top-right corner. Padding that would normally go to the 725 // card is applied to the close button so that it has a bigger touch target. 726 if (mCloseButtonView.getVisibility() == VISIBLE) { 727 int closeWidth = mCloseButtonView.getMeasuredWidth(); 728 int closeTop = 729 top + ((MarginLayoutParams) mCloseButtonView.getLayoutParams()).topMargin; 730 int closeBottom = closeTop + mCloseButtonView.getMeasuredHeight(); 731 int closeRight = mIsLayoutLTR ? end : (getMeasuredWidth() - end + closeWidth); 732 int closeLeft = closeRight - closeWidth; 733 mCloseButtonView.layout(closeLeft, closeTop, closeRight, closeBottom); 734 } 735 736 // Apply the padding for the rest of the widgets. 737 top += mPaddingCard; 738 bottom -= mPaddingCard; 739 start += mPaddingCard; 740 end -= mPaddingCard; 741 742 // Lay out the icon. 743 int iconWidth = mIconView.getMeasuredWidth(); 744 int iconLeft = mIsLayoutLTR ? start : (getMeasuredWidth() - start - iconWidth); 745 mIconView.layout(iconLeft, top, iconLeft + iconWidth, top + mIconView.getMeasuredHeight()); 746 start += getWidthWithMargins(mIconView); 747 748 // Factor in the additional padding, which is only tacked onto the end and bottom. 749 end -= mPaddingControls; 750 bottom -= mPaddingControls; 751 752 // Lay out the app title text. 753 int titleWidth = mTitleView.getMeasuredWidth(); 754 int titleTop = top + ((MarginLayoutParams) mTitleView.getLayoutParams()).topMargin; 755 int titleBottom = titleTop + mTitleView.getMeasuredHeight(); 756 int titleLeft = mIsLayoutLTR ? start : (getMeasuredWidth() - start - titleWidth); 757 mTitleView.layout(titleLeft, titleTop, titleLeft + titleWidth, titleBottom); 758 759 // The mock shows the margin eating into the descender area of the TextView. 760 int textBaseline = mTitleView.getLineBounds(mTitleView.getLineCount() - 1, null); 761 top = titleTop + textBaseline 762 + ((MarginLayoutParams) mTitleView.getLayoutParams()).bottomMargin; 763 764 // Lay out the app rating below the title. 765 int starWidth = mRatingView.getMeasuredWidth(); 766 int starTop = top + ((MarginLayoutParams) mRatingView.getLayoutParams()).topMargin; 767 int starBottom = starTop + mRatingView.getMeasuredHeight(); 768 int starLeft = mIsLayoutLTR ? start : (getMeasuredWidth() - start - starWidth); 769 mRatingView.layout(starLeft, starTop, starLeft + starWidth, starBottom); 770 771 // Lay out the logo in the bottom-left. 772 int logoWidth = mLogoView.getMeasuredWidth(); 773 int logoBottom = bottom - ((MarginLayoutParams) mLogoView.getLayoutParams()).bottomMargin; 774 int logoTop = logoBottom - mLogoView.getMeasuredHeight(); 775 int logoLeft = mIsLayoutLTR ? start : (getMeasuredWidth() - start - logoWidth); 776 mLogoView.layout(logoLeft, logoTop, logoLeft + logoWidth, logoBottom); 777 778 // Lay out the install button in the bottom-right corner. 779 int buttonHeight = mInstallButtonView.getMeasuredHeight(); 780 int buttonWidth = mInstallButtonView.getMeasuredWidth(); 781 int buttonRight = mIsLayoutLTR ? end : (getMeasuredWidth() - end + buttonWidth); 782 int buttonLeft = buttonRight - buttonWidth; 783 mInstallButtonView.layout(buttonLeft, bottom - buttonHeight, buttonRight, bottom); 784 } 785 786 /** 787 * Measures a child for the given space, accounting for defined heights and margins. 788 * @param child View to measure. 789 * @param availableWidth Available width for the view. 790 * @param availableHeight Available height for the view. 791 */ 792 private void measureChildForSpace(View child, int availableWidth, int availableHeight) { 793 // Handle margins. 794 availableWidth -= getMarginWidth(child); 795 availableHeight -= getMarginHeight(child); 796 797 // Account for any layout-defined dimensions for the view. 798 int childWidth = child.getLayoutParams().width; 799 int childHeight = child.getLayoutParams().height; 800 if (childWidth >= 0) availableWidth = Math.min(availableWidth, childWidth); 801 if (childHeight >= 0) availableHeight = Math.min(availableHeight, childHeight); 802 803 int widthSpec = MeasureSpec.makeMeasureSpec(availableWidth, MeasureSpec.AT_MOST); 804 int heightSpec = MeasureSpec.makeMeasureSpec(availableHeight, MeasureSpec.AT_MOST); 805 child.measure(widthSpec, heightSpec); 806 } 807 808 /** 809 * Forces a child to exactly occupy the given space. 810 * @param child View to measure. 811 * @param availableWidth Available width for the view. 812 * @param availableHeight Available height for the view. 813 */ 814 private void measureChildForSpaceExactly(View child, int availableWidth, int availableHeight) { 815 int widthSpec = MeasureSpec.makeMeasureSpec(availableWidth, MeasureSpec.EXACTLY); 816 int heightSpec = MeasureSpec.makeMeasureSpec(availableHeight, MeasureSpec.EXACTLY); 817 child.measure(widthSpec, heightSpec); 818 } 819 820 /** 821 * Calculates how wide the margins are for the given View. 822 * @param view View to measure. 823 * @return Measured width of the margins. 824 */ 825 private static int getMarginWidth(View view) { 826 MarginLayoutParams params = (MarginLayoutParams) view.getLayoutParams(); 827 return params.leftMargin + params.rightMargin; 828 } 829 830 /** 831 * Calculates how wide the given View has been measured to be, including its margins. 832 * @param view View to measure. 833 * @return Measured width of the view plus its margins. 834 */ 835 private static int getWidthWithMargins(View view) { 836 return view.getMeasuredWidth() + getMarginWidth(view); 837 } 838 839 /** 840 * Calculates how tall the margins are for the given View. 841 * @param view View to measure. 842 * @return Measured height of the margins. 843 */ 844 private static int getMarginHeight(View view) { 845 MarginLayoutParams params = (MarginLayoutParams) view.getLayoutParams(); 846 return params.topMargin + params.bottomMargin; 847 } 848 849 /** 850 * Calculates how tall the given View has been measured to be, including its margins. 851 * @param view View to measure. 852 * @return Measured height of the view plus its margins. 853 */ 854 private static int getHeightWithMargins(View view) { 855 return view.getMeasuredHeight() + getMarginHeight(view); 856 } 857} 858