// Copyright 2016 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.ntp.cards; import android.support.annotation.CallSuper; import android.support.annotation.DrawableRes; import android.support.v4.view.animation.FastOutSlowInInterpolator; import android.support.v7.widget.RecyclerView; import android.view.ContextMenu; import android.view.ContextMenu.ContextMenuInfo; import android.view.LayoutInflater; import android.view.View; import android.view.View.OnClickListener; import android.view.ViewGroup; import android.view.animation.Interpolator; import org.chromium.chrome.R; import org.chromium.chrome.browser.ntp.UiConfig; import org.chromium.chrome.browser.util.MathUtils; import org.chromium.chrome.browser.util.ViewUtils; /** * Holder for a generic card. * * Specific behaviors added to the cards: * * - Cards can peek above the fold if there is enough space. * * - When peeking, tapping on cards will make them request a scroll up (see * {@link NewTabPageRecyclerView#scrollToFirstCard()}). Tap events in non-peeking state will be * routed through {@link #onCardTapped()} for subclasses to override. * * - Cards will get some lateral margins when the viewport is sufficiently wide. * (see {@link UiConfig#DISPLAY_STYLE_WIDE}) * * Note: If a subclass overrides {@link #onBindViewHolder()}, it should call the * parent implementation to reset the private state when a card is recycled. */ public class CardViewHolder extends NewTabPageViewHolder { private static final Interpolator TRANSITION_INTERPOLATOR = new FastOutSlowInInterpolator(); /** Value used for max peeking card height and padding. */ private final int mMaxPeekPadding; /** * Due to the card background being a 9 patch file - the card border shadow will be part of * the card width and height. This value will be used to adjust values to account for the * borders. */ private final int mCards9PatchAdjustment; private final NewTabPageRecyclerView mRecyclerView; private final UiConfig mUiConfig; private final MarginResizer mMarginResizer; /** * To what extent the card is "peeking". 0 means the card is not peeking at all and spans the * full width of its parent. 1 means it is fully peeking and will be shown with a margin. */ private float mPeekingPercentage; @DrawableRes private int mBackground; /** * @param layoutId resource id of the layout to inflate and to use as card. * @param recyclerView ViewGroup that will contain the newly created view. * @param uiConfig The NTP UI configuration object used to adjust the card UI. */ public CardViewHolder( int layoutId, final NewTabPageRecyclerView recyclerView, UiConfig uiConfig) { super(inflateView(layoutId, recyclerView)); mCards9PatchAdjustment = recyclerView.getResources().getDimensionPixelSize( R.dimen.snippets_card_9_patch_adjustment); mMaxPeekPadding = recyclerView.getResources().getDimensionPixelSize( R.dimen.snippets_padding_and_peeking_card_height); mRecyclerView = recyclerView; itemView.setOnClickListener(new OnClickListener() { @Override public void onClick(View v) { if (isPeeking()) { recyclerView.scrollToFirstCard(); } else { onCardTapped(); } } }); itemView.setOnCreateContextMenuListener(new View.OnCreateContextMenuListener() { @Override public void onCreateContextMenu(ContextMenu menu, View view, ContextMenuInfo menuInfo) { if (!isPeeking()) { CardViewHolder.this.createContextMenu(menu); } } }); mUiConfig = uiConfig; mMarginResizer = MarginResizer.createWithViewAdapter(itemView, mUiConfig); // Configure the resizer to use negative margins on regular display to balance out the // lateral shadow of the card 9-patch and avoid a rounded corner effect. mMarginResizer.setMargins(-mCards9PatchAdjustment); } /** * Called when the NTP cards adapter is requested to update the currently visible ViewHolder * with data. */ @CallSuper protected void onBindViewHolder() { // Reset the peek status to avoid recycled view holders to be peeking at the wrong moment. if (getAdapterPosition() != mRecyclerView.getNewTabPageAdapter().getFirstCardPosition()) { // Not the first card, we can't peek anyway. setPeekingPercentage(0f); } else { mRecyclerView.updatePeekingCard(this); } // Reset the transparency and translation in case a dismissed card is being recycled. itemView.setAlpha(1f); itemView.setTranslationX(0f); // Make sure we use the right background. updateLayoutParams(); } @Override public void updateLayoutParams() { // Nothing to do for dismissed cards. if (getAdapterPosition() == RecyclerView.NO_POSITION) return; // Each card has the full elevation effect (the shadow) in the 9-patch. If the next item is // a card a negative bottom margin is set so the next card is overlaid slightly on top of // this one and hides the bottom shadow. boolean hasCardAbove = isCard(mRecyclerView.getAdapter().getItemViewType(getAdapterPosition() - 1)); boolean hasCardBelow = isCard(mRecyclerView.getAdapter().getItemViewType(getAdapterPosition() + 1)); getParams().bottomMargin = hasCardBelow ? -mCards9PatchAdjustment : 0; @DrawableRes int selectedBackground = selectBackground(hasCardAbove, hasCardBelow); if (mBackground != selectedBackground) { mBackground = selectedBackground; ViewUtils.setNinePatchBackgroundResource(itemView, selectedBackground); } } /** * Change the width, padding and child opacity of the card to give a smooth transition as the * user scrolls. * @param availableSpace space (pixels) available between the bottom of the screen and the * above-the-fold section, where the card can peek. * @param canPeek whether the screen size allows having a peeking card. */ public void updatePeek(int availableSpace, boolean canPeek) { float peekingPercentage; if (!canPeek) { peekingPercentage = 0f; } else { // If 1 padding unit (|mMaxPeekPadding|) is visible, the card is fully peeking. This is // reduced as the card is scrolled up, until 2 padding units are visible and the card is // not peeking anymore at all. Anything not between 0 and 1 is clamped. peekingPercentage = MathUtils.clamp(2f - (float) availableSpace / mMaxPeekPadding, 0f, 1f); } setPeekingPercentage(peekingPercentage); } /** * @return Whether the card is peeking. */ public boolean isPeeking() { return mPeekingPercentage > 0f; } /** * Override this to react when the card is tapped. This method will not be called if the card is * currently peeking. */ protected void onCardTapped() {} /** * Override this to provide a context menu for the card. This method will not be called if the * card is currently peeking. * @param menu The menu to add menu items to. */ protected void createContextMenu(ContextMenu menu) {} private void setPeekingPercentage(float peekingPercentage) { if (mPeekingPercentage == peekingPercentage) return; mPeekingPercentage = peekingPercentage; int peekPadding = (int) (mMaxPeekPadding * TRANSITION_INTERPOLATOR.getInterpolation(1f - peekingPercentage)); // Modify the padding so as the margin increases, the padding decreases, keeping the card's // contents in the same position. The top and bottom remain the same. int lateralPadding; if (mUiConfig.getCurrentDisplayStyle() != UiConfig.DISPLAY_STYLE_WIDE) { lateralPadding = peekPadding; } else { lateralPadding = mMaxPeekPadding; } itemView.setPadding(lateralPadding, mMaxPeekPadding, lateralPadding, mMaxPeekPadding); // Adjust the margins by |mCards9PatchAdjustment| so the card width // is the actual width not including the elevation shadow, so we can have full bleed. mMarginResizer.setMargins(mMaxPeekPadding - (peekPadding + mCards9PatchAdjustment)); // Set the opacity of the card content to be 0 when peeking and 1 when full width. int itemViewChildCount = ((ViewGroup) itemView).getChildCount(); for (int i = 0; i < itemViewChildCount; ++i) { View snippetChild = ((ViewGroup) itemView).getChildAt(i); snippetChild.setAlpha(peekPadding / (float) mMaxPeekPadding); } } private static View inflateView(int resourceId, ViewGroup parent) { return LayoutInflater.from(parent.getContext()).inflate(resourceId, parent, false); } public static boolean isCard(@NewTabPageItem.ViewType int type) { switch (type) { case NewTabPageItem.VIEW_TYPE_SNIPPET: case NewTabPageItem.VIEW_TYPE_STATUS: case NewTabPageItem.VIEW_TYPE_ACTION: case NewTabPageItem.VIEW_TYPE_PROMO: return true; case NewTabPageItem.VIEW_TYPE_ABOVE_THE_FOLD: case NewTabPageItem.VIEW_TYPE_HEADER: case NewTabPageItem.VIEW_TYPE_SPACING: case NewTabPageItem.VIEW_TYPE_PROGRESS: case NewTabPageItem.VIEW_TYPE_FOOTER: return false; default: assert false; } return false; } @DrawableRes protected int selectBackground(boolean hasCardAbove, boolean hasCardBelow) { if (hasCardAbove && hasCardBelow) return R.drawable.ntp_card_middle; if (!hasCardAbove && hasCardBelow) return R.drawable.ntp_card_top; if (hasCardAbove && !hasCardBelow) return R.drawable.ntp_card_bottom; return R.drawable.ntp_card_single; } protected NewTabPageRecyclerView getRecyclerView() { return mRecyclerView; } }