| // Copyright 2014 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.banners; |
| |
| import android.animation.ObjectAnimator; |
| import android.app.Activity; |
| import android.app.PendingIntent; |
| import android.content.ActivityNotFoundException; |
| import android.content.ContentResolver; |
| import android.content.Context; |
| import android.content.Intent; |
| import android.content.IntentSender; |
| import android.content.pm.PackageManager; |
| import android.content.res.Configuration; |
| import android.content.res.Resources; |
| import android.graphics.Rect; |
| import android.os.Looper; |
| import android.util.AttributeSet; |
| import android.util.Log; |
| import android.view.LayoutInflater; |
| import android.view.MotionEvent; |
| import android.view.View; |
| import android.view.ViewConfiguration; |
| import android.view.ViewGroup; |
| import android.widget.Button; |
| import android.widget.ImageButton; |
| import android.widget.ImageView; |
| import android.widget.TextView; |
| |
| import org.chromium.base.ApiCompatibilityUtils; |
| import org.chromium.chrome.R; |
| import org.chromium.content.browser.ContentViewCore; |
| import org.chromium.ui.base.LocalizationUtils; |
| import org.chromium.ui.base.WindowAndroid; |
| import org.chromium.ui.base.WindowAndroid.IntentCallback; |
| |
| /** |
| * Lays out a banner for showing info about an app on the Play Store. |
| * The banner mimics the appearance of a Google Now card using a background Drawable with a shadow. |
| * |
| * PADDING CALCULATIONS |
| * The banner has three different types of padding that need to be accounted for: |
| * 1) The background Drawable of the banner looks like card with a drop shadow. The Drawable |
| * defines a padding around the card that solely encompasses the space occupied by the drop |
| * shadow. |
| * 2) The card itself needs to have padding so that the widgets don't abut the borders of the card. |
| * This is defined as mPaddingCard, and is equally applied to all four sides. |
| * 3) Controls other than the icon are further constrained by mPaddingControls, which applies only |
| * to the bottom and end margins. |
| * See {@link #AppBannerView.onMeasure(int, int)} for details. |
| * |
| * MARGIN CALCULATIONS |
| * Margin calculations for the banner are complicated by the background Drawable's drop shadows, |
| * since the drop shadows are meant to be counted as being part of the margin. To deal with this, |
| * the margins are calculated by deducting the background Drawable's padding from the margins |
| * defined by the XML files. |
| * |
| * EVEN MORE LAYOUT QUIRKS |
| * The layout of the banner, which includes its widget sizes, may change when the screen is rotated |
| * to account for less screen real estate. This means that all of the View's widgets and cached |
| * dimensions must be rebuilt from scratch. |
| */ |
| public class AppBannerView extends SwipableOverlayView |
| implements View.OnClickListener, InstallerDelegate.Observer, IntentCallback { |
| private static final String TAG = "AppBannerView"; |
| |
| /** |
| * Class that is alerted about things happening to the BannerView. |
| */ |
| public static interface Observer { |
| /** |
| * Called when the banner is removed from the hierarchy. |
| * @param banner Banner being dismissed. |
| */ |
| public void onBannerRemoved(AppBannerView banner); |
| |
| /** |
| * Called when the user manually closes a banner. |
| * @param banner Banner being blocked. |
| * @param url URL of the page that requested the banner. |
| * @param packageName Name of the app's package. |
| */ |
| public void onBannerBlocked(AppBannerView banner, String url, String packageName); |
| |
| /** |
| * Called when the banner begins to be dismissed. |
| * @param banner Banner being closed. |
| * @param dismissType Type of dismissal performed. |
| */ |
| public void onBannerDismissEvent(AppBannerView banner, int dismissType); |
| |
| /** |
| * Called when an install event has occurred. |
| */ |
| public void onBannerInstallEvent(AppBannerView banner, int eventType); |
| |
| /** |
| * Called when the banner needs to have an Activity started for a result. |
| * @param banner Banner firing the event. |
| * @param intent Intent to fire. |
| */ |
| public boolean onFireIntent(AppBannerView banner, PendingIntent intent); |
| } |
| |
| // Installation states. |
| private static final int INSTALL_STATE_NOT_INSTALLED = 0; |
| private static final int INSTALL_STATE_INSTALLING = 1; |
| private static final int INSTALL_STATE_INSTALLED = 2; |
| |
| // XML layout for the BannerView. |
| private static final int BANNER_LAYOUT = R.layout.app_banner_view; |
| |
| // True if the layout is in left-to-right layout mode (regular mode). |
| private final boolean mIsLayoutLTR; |
| |
| // Class to alert about BannerView events. |
| private AppBannerView.Observer mObserver; |
| |
| // Information about the package. Shouldn't ever be null after calling {@link #initialize()}. |
| private AppData mAppData; |
| |
| // Views comprising the app banner. |
| private ImageView mIconView; |
| private TextView mTitleView; |
| private Button mInstallButtonView; |
| private RatingView mRatingView; |
| private View mLogoView; |
| private View mBannerHighlightView; |
| private ImageButton mCloseButtonView; |
| |
| // Dimension values. |
| private int mDefinedMaxWidth; |
| private int mPaddingCard; |
| private int mPaddingControls; |
| private int mMarginLeft; |
| private int mMarginRight; |
| private int mMarginBottom; |
| private int mTouchSlop; |
| |
| // Highlight variables. |
| private boolean mIsBannerPressed; |
| private float mInitialXForHighlight; |
| |
| // Initial padding values. |
| private final Rect mBackgroundDrawablePadding; |
| |
| // Install tracking. |
| private boolean mWasInstallDialogShown; |
| private InstallerDelegate mInstallTask; |
| private int mInstallState; |
| |
| /** |
| * Creates a BannerView and adds it to the given ContentViewCore. |
| * @param contentViewCore ContentViewCore to display the AppBannerView for. |
| * @param observer Class that is alerted for AppBannerView events. |
| * @param data Data about the app. |
| * @return The created banner. |
| */ |
| public static AppBannerView create( |
| ContentViewCore contentViewCore, Observer observer, AppData data) { |
| Context context = contentViewCore.getContext().getApplicationContext(); |
| AppBannerView banner = |
| (AppBannerView) LayoutInflater.from(context).inflate(BANNER_LAYOUT, null); |
| banner.initialize(observer, data); |
| banner.addToView(contentViewCore); |
| return banner; |
| } |
| |
| /** |
| * Creates a BannerView from an XML layout. |
| */ |
| public AppBannerView(Context context, AttributeSet attrs) { |
| super(context, attrs); |
| mIsLayoutLTR = !LocalizationUtils.isLayoutRtl(); |
| |
| // Store the background Drawable's padding. The background used for banners is a 9-patch, |
| // which means that it already defines padding. We need to take it into account when adding |
| // even more padding to the inside of it. |
| mBackgroundDrawablePadding = new Rect(); |
| mBackgroundDrawablePadding.left = ApiCompatibilityUtils.getPaddingStart(this); |
| mBackgroundDrawablePadding.right = ApiCompatibilityUtils.getPaddingEnd(this); |
| mBackgroundDrawablePadding.top = getPaddingTop(); |
| mBackgroundDrawablePadding.bottom = getPaddingBottom(); |
| |
| mInstallState = INSTALL_STATE_NOT_INSTALLED; |
| } |
| |
| /** |
| * Initialize the banner with information about the package. |
| * @param observer Class to alert about changes to the banner. |
| * @param data Information about the app being advertised. |
| */ |
| private void initialize(Observer observer, AppData data) { |
| mObserver = observer; |
| mAppData = data; |
| initializeControls(); |
| } |
| |
| private void initializeControls() { |
| // Cache the banner dimensions, adjusting margins for drop shadows defined in the background |
| // Drawable. |
| Resources res = getResources(); |
| mDefinedMaxWidth = res.getDimensionPixelSize(R.dimen.app_banner_max_width); |
| mPaddingCard = res.getDimensionPixelSize(R.dimen.app_banner_padding); |
| mPaddingControls = res.getDimensionPixelSize(R.dimen.app_banner_padding_controls); |
| mMarginLeft = res.getDimensionPixelSize(R.dimen.app_banner_margin_sides) |
| - mBackgroundDrawablePadding.left; |
| mMarginRight = res.getDimensionPixelSize(R.dimen.app_banner_margin_sides) |
| - mBackgroundDrawablePadding.right; |
| mMarginBottom = res.getDimensionPixelSize(R.dimen.app_banner_margin_bottom) |
| - mBackgroundDrawablePadding.bottom; |
| if (getLayoutParams() != null) { |
| MarginLayoutParams params = (MarginLayoutParams) getLayoutParams(); |
| params.leftMargin = mMarginLeft; |
| params.rightMargin = mMarginRight; |
| params.bottomMargin = mMarginBottom; |
| } |
| |
| // Pull out all of the controls we are expecting. |
| mIconView = (ImageView) findViewById(R.id.app_icon); |
| mTitleView = (TextView) findViewById(R.id.app_title); |
| mInstallButtonView = (Button) findViewById(R.id.app_install_button); |
| mRatingView = (RatingView) findViewById(R.id.app_rating); |
| mLogoView = findViewById(R.id.store_logo); |
| mBannerHighlightView = findViewById(R.id.banner_highlight); |
| mCloseButtonView = (ImageButton) findViewById(R.id.close_button); |
| |
| assert mIconView != null; |
| assert mTitleView != null; |
| assert mInstallButtonView != null; |
| assert mLogoView != null; |
| assert mRatingView != null; |
| assert mBannerHighlightView != null; |
| assert mCloseButtonView != null; |
| |
| // Set up the buttons to fire an event. |
| mInstallButtonView.setOnClickListener(this); |
| mCloseButtonView.setOnClickListener(this); |
| |
| // Configure the controls with the package information. |
| mTitleView.setText(mAppData.title()); |
| mIconView.setImageDrawable(mAppData.icon()); |
| mRatingView.initialize(mAppData.rating()); |
| setAccessibilityInformation(); |
| |
| // Determine how much the user can drag sideways before their touch is considered a scroll. |
| mTouchSlop = ViewConfiguration.get(getContext()).getScaledTouchSlop(); |
| |
| // Set up the install button. |
| updateButtonStatus(); |
| } |
| |
| /** |
| * Creates a succinct description about the app being advertised. |
| */ |
| private void setAccessibilityInformation() { |
| String bannerText = getContext().getString( |
| R.string.app_banner_view_accessibility, mAppData.title(), mAppData.rating()); |
| setContentDescription(bannerText); |
| } |
| |
| @Override |
| public void onClick(View view) { |
| if (mObserver == null) return; |
| |
| // Only allow the button to be clicked when the banner's in a neutral position. |
| if (Math.abs(getTranslationX()) > ZERO_THRESHOLD |
| || Math.abs(getTranslationY()) > ZERO_THRESHOLD) { |
| return; |
| } |
| |
| if (view == mInstallButtonView) { |
| // Check that nothing happened in the background to change the install state of the app. |
| int previousState = mInstallState; |
| updateButtonStatus(); |
| if (mInstallState != previousState) return; |
| |
| // Ignore button clicks when the app is installing. |
| if (mInstallState == INSTALL_STATE_INSTALLING) return; |
| |
| mInstallButtonView.setEnabled(false); |
| |
| if (mInstallState == INSTALL_STATE_NOT_INSTALLED) { |
| // The user initiated an install. Track it happening only once. |
| if (!mWasInstallDialogShown) { |
| mObserver.onBannerInstallEvent(this, AppBannerMetricsIds.INSTALL_TRIGGERED); |
| mWasInstallDialogShown = true; |
| } |
| |
| if (mObserver.onFireIntent(this, mAppData.installIntent())) { |
| // Temporarily hide the banner. |
| createVerticalSnapAnimation(false); |
| } else { |
| Log.e(TAG, "Failed to fire install intent."); |
| dismiss(AppBannerMetricsIds.DISMISS_ERROR); |
| } |
| } else if (mInstallState == INSTALL_STATE_INSTALLED) { |
| // The app is installed. Open it. |
| try { |
| Intent appIntent = getAppLaunchIntent(); |
| if (appIntent != null) getContext().startActivity(appIntent); |
| } catch (ActivityNotFoundException e) { |
| Log.e(TAG, "Failed to find app package: " + mAppData.packageName()); |
| } |
| |
| dismiss(AppBannerMetricsIds.DISMISS_APP_OPEN); |
| } |
| } else if (view == mCloseButtonView) { |
| if (mObserver != null) { |
| mObserver.onBannerBlocked(this, mAppData.siteUrl(), mAppData.packageName()); |
| } |
| |
| dismiss(AppBannerMetricsIds.DISMISS_CLOSE_BUTTON); |
| } |
| } |
| |
| @Override |
| protected void onViewSwipedAway() { |
| if (mObserver == null) return; |
| mObserver.onBannerDismissEvent(this, AppBannerMetricsIds.DISMISS_BANNER_SWIPE); |
| mObserver.onBannerBlocked(this, mAppData.siteUrl(), mAppData.packageName()); |
| } |
| |
| @Override |
| protected void onViewClicked() { |
| // Send the user to the app's Play store page. |
| try { |
| IntentSender sender = mAppData.detailsIntent().getIntentSender(); |
| getContext().startIntentSender(sender, new Intent(), 0, 0, 0); |
| } catch (IntentSender.SendIntentException e) { |
| Log.e(TAG, "Failed to launch details intent."); |
| } |
| |
| dismiss(AppBannerMetricsIds.DISMISS_BANNER_CLICK); |
| } |
| |
| @Override |
| protected void onViewPressed(MotionEvent event) { |
| // Highlight the banner when the user has held it for long enough and doesn't move. |
| mInitialXForHighlight = event.getRawX(); |
| mIsBannerPressed = true; |
| mBannerHighlightView.setVisibility(View.VISIBLE); |
| } |
| |
| @Override |
| public void onIntentCompleted(WindowAndroid window, int resultCode, |
| ContentResolver contentResolver, Intent data) { |
| if (isDismissed()) return; |
| |
| createVerticalSnapAnimation(true); |
| if (resultCode == Activity.RESULT_OK) { |
| // The user chose to install the app. Watch the PackageManager to see when it finishes |
| // installing it. |
| mObserver.onBannerInstallEvent(this, AppBannerMetricsIds.INSTALL_STARTED); |
| |
| PackageManager pm = getContext().getPackageManager(); |
| mInstallTask = |
| new InstallerDelegate(Looper.getMainLooper(), pm, this, mAppData.packageName()); |
| mInstallTask.start(); |
| mInstallState = INSTALL_STATE_INSTALLING; |
| } |
| updateButtonStatus(); |
| } |
| |
| |
| @Override |
| public void onInstallFinished(InstallerDelegate monitor, boolean success) { |
| if (isDismissed() || mInstallTask != monitor) return; |
| |
| if (success) { |
| // Let the user open the app from here. |
| mObserver.onBannerInstallEvent(this, AppBannerMetricsIds.INSTALL_COMPLETED); |
| mInstallState = INSTALL_STATE_INSTALLED; |
| updateButtonStatus(); |
| } else { |
| dismiss(AppBannerMetricsIds.DISMISS_INSTALL_TIMEOUT); |
| } |
| } |
| |
| @Override |
| protected ViewGroup.MarginLayoutParams createLayoutParams() { |
| // Define the margin around the entire banner that accounts for the drop shadow. |
| ViewGroup.MarginLayoutParams params = super.createLayoutParams(); |
| params.setMargins(mMarginLeft, 0, mMarginRight, mMarginBottom); |
| return params; |
| } |
| |
| /** |
| * Removes this View from its parent and alerts any observers of the dismissal. |
| * @return Whether or not the View was successfully dismissed. |
| */ |
| @Override |
| boolean removeFromParent() { |
| if (super.removeFromParent()) { |
| mObserver.onBannerRemoved(this); |
| destroy(); |
| return true; |
| } |
| |
| return false; |
| } |
| |
| /** |
| * Dismisses the banner. |
| * @param eventType Event that triggered the dismissal. See {@link AppBannerMetricsIds}. |
| */ |
| public void dismiss(int eventType) { |
| if (isDismissed() || mObserver == null) return; |
| |
| dismiss(eventType == AppBannerMetricsIds.DISMISS_CLOSE_BUTTON); |
| mObserver.onBannerDismissEvent(this, eventType); |
| } |
| |
| /** |
| * Destroys the Banner. |
| */ |
| public void destroy() { |
| if (!isDismissed()) dismiss(AppBannerMetricsIds.DISMISS_ERROR); |
| |
| if (mInstallTask != null) { |
| mInstallTask.cancel(); |
| mInstallTask = null; |
| } |
| } |
| |
| /** |
| * Updates the install button (install state, text, color, etc.). |
| */ |
| void updateButtonStatus() { |
| if (mInstallButtonView == null) return; |
| |
| // Determine if the saved install status of the app is out of date. |
| // It is not easily possible to detect if an app is in the process of being installed, so we |
| // can't properly transition to that state from here. |
| if (getAppLaunchIntent() == null) { |
| if (mInstallState == INSTALL_STATE_INSTALLED) { |
| mInstallState = INSTALL_STATE_NOT_INSTALLED; |
| } |
| } else { |
| mInstallState = INSTALL_STATE_INSTALLED; |
| } |
| |
| // Update what the button looks like. |
| Resources res = getResources(); |
| int fgColor; |
| String text; |
| if (mInstallState == INSTALL_STATE_INSTALLED) { |
| ApiCompatibilityUtils.setBackgroundForView(mInstallButtonView, |
| res.getDrawable(R.drawable.app_banner_button_open)); |
| fgColor = res.getColor(R.color.app_banner_open_button_fg); |
| text = res.getString(R.string.app_banner_open); |
| } else { |
| ApiCompatibilityUtils.setBackgroundForView(mInstallButtonView, |
| res.getDrawable(R.drawable.app_banner_button_install)); |
| fgColor = res.getColor(R.color.app_banner_install_button_fg); |
| if (mInstallState == INSTALL_STATE_NOT_INSTALLED) { |
| text = mAppData.installButtonText(); |
| mInstallButtonView.setContentDescription( |
| getContext().getString(R.string.app_banner_install_accessibility, text)); |
| } else { |
| text = res.getString(R.string.app_banner_installing); |
| } |
| } |
| |
| mInstallButtonView.setTextColor(fgColor); |
| mInstallButtonView.setText(text); |
| mInstallButtonView.setEnabled(mInstallState != INSTALL_STATE_INSTALLING); |
| } |
| |
| /** |
| * Determine how big an icon needs to be for the Layout. |
| * @param context Context to grab resources from. |
| * @return How big the icon is expected to be, in pixels. |
| */ |
| static int getIconSize(Context context) { |
| return context.getResources().getDimensionPixelSize(R.dimen.app_banner_icon_size); |
| } |
| |
| /** |
| * Passes all touch events through to the parent. |
| */ |
| @Override |
| public boolean onTouchEvent(MotionEvent event) { |
| int action = event.getActionMasked(); |
| if (mIsBannerPressed) { |
| // Mimic Google Now card behavior, where the card stops being highlighted if the user |
| // scrolls a bit to the side. |
| float xDifference = Math.abs(event.getRawX() - mInitialXForHighlight); |
| if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL |
| || (action == MotionEvent.ACTION_MOVE && xDifference > mTouchSlop)) { |
| mIsBannerPressed = false; |
| mBannerHighlightView.setVisibility(View.INVISIBLE); |
| } |
| } |
| |
| return super.onTouchEvent(event); |
| } |
| |
| /** |
| * Fade the banner back into view. |
| */ |
| @Override |
| protected void onAttachedToWindow() { |
| super.onAttachedToWindow(); |
| ObjectAnimator.ofFloat(this, "alpha", getAlpha(), 1.f).setDuration( |
| MS_ANIMATION_DURATION).start(); |
| setVisibility(VISIBLE); |
| } |
| |
| /** |
| * Immediately hide the banner to avoid having them show up in snapshots. |
| */ |
| @Override |
| protected void onDetachedFromWindow() { |
| super.onDetachedFromWindow(); |
| setAlpha(0.0f); |
| setVisibility(INVISIBLE); |
| } |
| |
| /** |
| * Watch for changes in the available screen height, which triggers a complete recreation of the |
| * banner widgets. This is mainly due to the fact that the Nexus 7 has a smaller banner defined |
| * for its landscape versus its portrait layouts. |
| */ |
| @Override |
| protected void onConfigurationChanged(Configuration config) { |
| super.onConfigurationChanged(config); |
| |
| if (isDismissed()) return; |
| |
| // If the card's maximum width hasn't changed, the individual views can't have, either. |
| int newDefinedWidth = getResources().getDimensionPixelSize(R.dimen.app_banner_max_width); |
| if (mDefinedMaxWidth == newDefinedWidth) return; |
| |
| // Cannibalize another version of this layout to get Views using the new resources and |
| // sizes. |
| while (getChildCount() > 0) removeViewAt(0); |
| mIconView = null; |
| mTitleView = null; |
| mInstallButtonView = null; |
| mRatingView = null; |
| mLogoView = null; |
| mBannerHighlightView = null; |
| |
| AppBannerView cannibalized = |
| (AppBannerView) LayoutInflater.from(getContext()).inflate(BANNER_LAYOUT, null); |
| while (cannibalized.getChildCount() > 0) { |
| View child = cannibalized.getChildAt(0); |
| cannibalized.removeViewAt(0); |
| addView(child); |
| } |
| initializeControls(); |
| requestLayout(); |
| } |
| |
| @Override |
| public void onWindowFocusChanged(boolean hasWindowFocus) { |
| if (hasWindowFocus) updateButtonStatus(); |
| } |
| |
| /** |
| * @return Intent to launch the app that is being promoted. |
| */ |
| private Intent getAppLaunchIntent() { |
| String packageName = mAppData.packageName(); |
| PackageManager packageManager = getContext().getPackageManager(); |
| return packageManager.getLaunchIntentForPackage(packageName); |
| } |
| |
| /** |
| * Measures the banner and its children Views for the given space. |
| * |
| * DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD |
| * DPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPD |
| * DP...... cPD |
| * DP...... TITLE----------------------- XcPD |
| * DP.ICON. ***** cPD |
| * DP...... LOGO BUTTONcPD |
| * DP...... cccccccccccccccccccccccccccccccPD |
| * DPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPD |
| * DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD |
| * |
| * The three paddings mentioned in the class Javadoc are denoted by: |
| * D) Drop shadow padding. |
| * P) Inner card padding. |
| * c) Control padding. |
| * |
| * Measurement for components of the banner are performed assuming that components are laid out |
| * inside of the banner's background as follows: |
| * 1) A maximum width is enforced on the banner to keep the whole thing on screen and keep it a |
| * reasonable size. |
| * 2) The icon takes up the left side of the banner. |
| * 3) The install button occupies the bottom-right of the banner. |
| * 4) The Google Play logo occupies the space to the left of the button. |
| * 5) The rating is assigned space above the logo and below the title. |
| * 6) The close button (if visible) sits in the top right of the banner. |
| * 7) The title is assigned whatever space is left and sits on top of the tallest stack of |
| * controls. |
| * |
| * See {@link #android.view.View.onMeasure(int, int)} for the parameters. |
| */ |
| @Override |
| protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { |
| // Enforce a maximum width on the banner, which is defined as the smallest of: |
| // 1) The smallest width for the device (in either landscape or portrait mode). |
| // 2) The defined maximum width in the dimens.xml files. |
| // 3) The width passed in through the MeasureSpec. |
| Resources res = getResources(); |
| float density = res.getDisplayMetrics().density; |
| int screenSmallestWidth = (int) (res.getConfiguration().smallestScreenWidthDp * density); |
| int specWidth = MeasureSpec.getSize(widthMeasureSpec); |
| int bannerWidth = Math.min(Math.min(specWidth, mDefinedMaxWidth), screenSmallestWidth); |
| |
| // Track how much space is available inside the banner's card-shaped background Drawable. |
| // To calculate this, we need to account for both the padding of the background (which |
| // is occupied by the card's drop shadows) as well as the padding defined on the inside of |
| // the card. |
| int bgPaddingWidth = mBackgroundDrawablePadding.left + mBackgroundDrawablePadding.right; |
| int bgPaddingHeight = mBackgroundDrawablePadding.top + mBackgroundDrawablePadding.bottom; |
| final int maxControlWidth = bannerWidth - bgPaddingWidth - (mPaddingCard * 2); |
| |
| // Control height is constrained to provide a reasonable aspect ratio. |
| // In practice, the only controls which can cause an issue are the title and the install |
| // button, since they have strings that can change size according to user preference. The |
| // other controls are all defined to be a certain height. |
| int specHeight = MeasureSpec.getSize(heightMeasureSpec); |
| int reasonableHeight = maxControlWidth / 4; |
| int paddingHeight = bgPaddingHeight + (mPaddingCard * 2); |
| final int maxControlHeight = Math.min(specHeight, reasonableHeight) - paddingHeight; |
| final int maxStackedControlHeight = maxControlWidth / 3; |
| |
| // Determine how big each component wants to be. The icon is measured separately because |
| // it is not stacked with the other controls. |
| measureChildForSpace(mIconView, maxControlWidth, maxControlHeight); |
| for (int i = 0; i < getChildCount(); i++) { |
| if (getChildAt(i) != mIconView) { |
| measureChildForSpace(getChildAt(i), maxControlWidth, maxStackedControlHeight); |
| } |
| } |
| |
| // Determine how tall the banner needs to be to fit everything by calculating the combined |
| // height of the stacked controls. There are three competing stacks to measure: |
| // 1) The icon. |
| // 2) The app title + control padding + star rating + store logo. |
| // 3) The app title + control padding + install button. |
| // The control padding is extra padding that applies only to the non-icon widgets. |
| // The close button does not get counted as part of a stack. |
| int iconStackHeight = getHeightWithMargins(mIconView); |
| int logoStackHeight = getHeightWithMargins(mTitleView) + mPaddingControls |
| + getHeightWithMargins(mRatingView) + getHeightWithMargins(mLogoView); |
| int buttonStackHeight = getHeightWithMargins(mTitleView) + mPaddingControls |
| + getHeightWithMargins(mInstallButtonView); |
| int biggestStackHeight = |
| Math.max(iconStackHeight, Math.max(logoStackHeight, buttonStackHeight)); |
| |
| // The icon hugs the banner's starting edge, from the top of the banner to the bottom. |
| final int iconSize = biggestStackHeight; |
| measureChildForSpaceExactly(mIconView, iconSize, iconSize); |
| |
| // The rest of the content is laid out to the right of the icon. |
| // Additional padding is defined for non-icon content on the end and bottom. |
| final int contentWidth = |
| maxControlWidth - getWidthWithMargins(mIconView) - mPaddingControls; |
| final int contentHeight = biggestStackHeight - mPaddingControls; |
| measureChildForSpace(mLogoView, contentWidth, contentHeight); |
| |
| // Restrict the button size to prevent overrunning the Google Play logo. |
| int remainingButtonWidth = |
| maxControlWidth - getWidthWithMargins(mLogoView) - getWidthWithMargins(mIconView); |
| mInstallButtonView.setMaxWidth(remainingButtonWidth); |
| measureChildForSpace(mInstallButtonView, contentWidth, contentHeight); |
| |
| // Measure the star rating, which sits below the title and above the logo. |
| final int ratingWidth = contentWidth; |
| final int ratingHeight = contentHeight - getHeightWithMargins(mLogoView); |
| measureChildForSpace(mRatingView, ratingWidth, ratingHeight); |
| |
| // The close button sits to the right of the title and above the install button. |
| final int closeWidth = contentWidth; |
| final int closeHeight = contentHeight - getHeightWithMargins(mInstallButtonView); |
| measureChildForSpace(mCloseButtonView, closeWidth, closeHeight); |
| |
| // The app title spans the top of the banner and sits on top of the other controls, and to |
| // the left of the close button. The computation for the width available to the title is |
| // complicated by how the button sits in the corner and absorbs the padding that would |
| // normally be there. |
| int biggerStack = Math.max(getHeightWithMargins(mInstallButtonView), |
| getHeightWithMargins(mLogoView) + getHeightWithMargins(mRatingView)); |
| final int titleWidth = contentWidth - getWidthWithMargins(mCloseButtonView) + mPaddingCard; |
| final int titleHeight = contentHeight - biggerStack; |
| measureChildForSpace(mTitleView, titleWidth, titleHeight); |
| |
| // Set the measured dimensions for the banner. The banner's height is defined by the |
| // tallest stack of components, the padding of the banner's card background, and the extra |
| // padding around the banner's components. |
| int bannerPadding = mBackgroundDrawablePadding.top + mBackgroundDrawablePadding.bottom |
| + (mPaddingCard * 2); |
| int bannerHeight = biggestStackHeight + bannerPadding; |
| setMeasuredDimension(bannerWidth, bannerHeight); |
| |
| // Make the banner highlight view be the exact same size as the banner's card background. |
| final int cardWidth = bannerWidth - bgPaddingWidth; |
| final int cardHeight = bannerHeight - bgPaddingHeight; |
| measureChildForSpaceExactly(mBannerHighlightView, cardWidth, cardHeight); |
| } |
| |
| /** |
| * Lays out the controls according to the algorithm in {@link #onMeasure}. |
| * See {@link #android.view.View.onLayout(boolean, int, int, int, int)} for the parameters. |
| */ |
| @Override |
| protected void onLayout(boolean changed, int l, int t, int r, int b) { |
| super.onLayout(changed, l, t, r, b); |
| int top = mBackgroundDrawablePadding.top; |
| int bottom = getMeasuredHeight() - mBackgroundDrawablePadding.bottom; |
| int start = mBackgroundDrawablePadding.left; |
| int end = getMeasuredWidth() - mBackgroundDrawablePadding.right; |
| |
| // The highlight overlay covers the entire banner (minus drop shadow padding). |
| mBannerHighlightView.layout(start, top, end, bottom); |
| |
| // Lay out the close button in the top-right corner. Padding that would normally go to the |
| // card is applied to the close button so that it has a bigger touch target. |
| if (mCloseButtonView.getVisibility() == VISIBLE) { |
| int closeWidth = mCloseButtonView.getMeasuredWidth(); |
| int closeTop = |
| top + ((MarginLayoutParams) mCloseButtonView.getLayoutParams()).topMargin; |
| int closeBottom = closeTop + mCloseButtonView.getMeasuredHeight(); |
| int closeRight = mIsLayoutLTR ? end : (getMeasuredWidth() - end + closeWidth); |
| int closeLeft = closeRight - closeWidth; |
| mCloseButtonView.layout(closeLeft, closeTop, closeRight, closeBottom); |
| } |
| |
| // Apply the padding for the rest of the widgets. |
| top += mPaddingCard; |
| bottom -= mPaddingCard; |
| start += mPaddingCard; |
| end -= mPaddingCard; |
| |
| // Lay out the icon. |
| int iconWidth = mIconView.getMeasuredWidth(); |
| int iconLeft = mIsLayoutLTR ? start : (getMeasuredWidth() - start - iconWidth); |
| mIconView.layout(iconLeft, top, iconLeft + iconWidth, top + mIconView.getMeasuredHeight()); |
| start += getWidthWithMargins(mIconView); |
| |
| // Factor in the additional padding, which is only tacked onto the end and bottom. |
| end -= mPaddingControls; |
| bottom -= mPaddingControls; |
| |
| // Lay out the app title text. |
| int titleWidth = mTitleView.getMeasuredWidth(); |
| int titleTop = top + ((MarginLayoutParams) mTitleView.getLayoutParams()).topMargin; |
| int titleBottom = titleTop + mTitleView.getMeasuredHeight(); |
| int titleLeft = mIsLayoutLTR ? start : (getMeasuredWidth() - start - titleWidth); |
| mTitleView.layout(titleLeft, titleTop, titleLeft + titleWidth, titleBottom); |
| |
| // The mock shows the margin eating into the descender area of the TextView. |
| int textBaseline = mTitleView.getLineBounds(mTitleView.getLineCount() - 1, null); |
| top = titleTop + textBaseline |
| + ((MarginLayoutParams) mTitleView.getLayoutParams()).bottomMargin; |
| |
| // Lay out the app rating below the title. |
| int starWidth = mRatingView.getMeasuredWidth(); |
| int starTop = top + ((MarginLayoutParams) mRatingView.getLayoutParams()).topMargin; |
| int starBottom = starTop + mRatingView.getMeasuredHeight(); |
| int starLeft = mIsLayoutLTR ? start : (getMeasuredWidth() - start - starWidth); |
| mRatingView.layout(starLeft, starTop, starLeft + starWidth, starBottom); |
| |
| // Lay out the logo in the bottom-left. |
| int logoWidth = mLogoView.getMeasuredWidth(); |
| int logoBottom = bottom - ((MarginLayoutParams) mLogoView.getLayoutParams()).bottomMargin; |
| int logoTop = logoBottom - mLogoView.getMeasuredHeight(); |
| int logoLeft = mIsLayoutLTR ? start : (getMeasuredWidth() - start - logoWidth); |
| mLogoView.layout(logoLeft, logoTop, logoLeft + logoWidth, logoBottom); |
| |
| // Lay out the install button in the bottom-right corner. |
| int buttonHeight = mInstallButtonView.getMeasuredHeight(); |
| int buttonWidth = mInstallButtonView.getMeasuredWidth(); |
| int buttonRight = mIsLayoutLTR ? end : (getMeasuredWidth() - end + buttonWidth); |
| int buttonLeft = buttonRight - buttonWidth; |
| mInstallButtonView.layout(buttonLeft, bottom - buttonHeight, buttonRight, bottom); |
| } |
| |
| /** |
| * Measures a child for the given space, accounting for defined heights and margins. |
| * @param child View to measure. |
| * @param availableWidth Available width for the view. |
| * @param availableHeight Available height for the view. |
| */ |
| private void measureChildForSpace(View child, int availableWidth, int availableHeight) { |
| // Handle margins. |
| availableWidth -= getMarginWidth(child); |
| availableHeight -= getMarginHeight(child); |
| |
| // Account for any layout-defined dimensions for the view. |
| int childWidth = child.getLayoutParams().width; |
| int childHeight = child.getLayoutParams().height; |
| if (childWidth >= 0) availableWidth = Math.min(availableWidth, childWidth); |
| if (childHeight >= 0) availableHeight = Math.min(availableHeight, childHeight); |
| |
| int widthSpec = MeasureSpec.makeMeasureSpec(availableWidth, MeasureSpec.AT_MOST); |
| int heightSpec = MeasureSpec.makeMeasureSpec(availableHeight, MeasureSpec.AT_MOST); |
| child.measure(widthSpec, heightSpec); |
| } |
| |
| /** |
| * Forces a child to exactly occupy the given space. |
| * @param child View to measure. |
| * @param availableWidth Available width for the view. |
| * @param availableHeight Available height for the view. |
| */ |
| private void measureChildForSpaceExactly(View child, int availableWidth, int availableHeight) { |
| int widthSpec = MeasureSpec.makeMeasureSpec(availableWidth, MeasureSpec.EXACTLY); |
| int heightSpec = MeasureSpec.makeMeasureSpec(availableHeight, MeasureSpec.EXACTLY); |
| child.measure(widthSpec, heightSpec); |
| } |
| |
| /** |
| * Calculates how wide the margins are for the given View. |
| * @param view View to measure. |
| * @return Measured width of the margins. |
| */ |
| private static int getMarginWidth(View view) { |
| MarginLayoutParams params = (MarginLayoutParams) view.getLayoutParams(); |
| return params.leftMargin + params.rightMargin; |
| } |
| |
| /** |
| * Calculates how wide the given View has been measured to be, including its margins. |
| * @param view View to measure. |
| * @return Measured width of the view plus its margins. |
| */ |
| private static int getWidthWithMargins(View view) { |
| return view.getMeasuredWidth() + getMarginWidth(view); |
| } |
| |
| /** |
| * Calculates how tall the margins are for the given View. |
| * @param view View to measure. |
| * @return Measured height of the margins. |
| */ |
| private static int getMarginHeight(View view) { |
| MarginLayoutParams params = (MarginLayoutParams) view.getLayoutParams(); |
| return params.topMargin + params.bottomMargin; |
| } |
| |
| /** |
| * Calculates how tall the given View has been measured to be, including its margins. |
| * @param view View to measure. |
| * @return Measured height of the view plus its margins. |
| */ |
| private static int getHeightWithMargins(View view) { |
| return view.getMeasuredHeight() + getMarginHeight(view); |
| } |
| } |