// 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.animation.Animator;
import android.animation.AnimatorListenerAdapter;
import android.animation.AnimatorSet;
import android.animation.ObjectAnimator;
import android.content.Context;
import android.content.res.Resources;
import android.graphics.Region;
import android.support.v4.view.animation.FastOutLinearInInterpolator;
import android.support.v7.widget.LinearLayoutManager;
import android.support.v7.widget.RecyclerView;
import android.util.AttributeSet;
import android.view.GestureDetector;
import android.view.MotionEvent;
import android.view.View;
import android.view.animation.Interpolator;
import android.view.inputmethod.EditorInfo;
import android.view.inputmethod.InputConnection;
import org.chromium.base.Log;
import org.chromium.chrome.R;
import org.chromium.chrome.browser.ntp.NewTabPageLayout;
import org.chromium.chrome.browser.ntp.snippets.SectionHeaderViewHolder;
import org.chromium.chrome.browser.ntp.snippets.SnippetArticle;
import org.chromium.chrome.browser.util.ViewUtils;
import java.util.HashMap;
import java.util.Map;
/**
* Simple wrapper on top of a RecyclerView that will acquire focus when tapped. Ensures the
* New Tab page receives focus when clicked.
*/
public class NewTabPageRecyclerView extends RecyclerView {
private static final String TAG = "NtpCards";
private static final Interpolator DISMISS_INTERPOLATOR = new FastOutLinearInInterpolator();
private static final int DISMISS_ANIMATION_TIME_MS = 300;
private final GestureDetector mGestureDetector;
private final LinearLayoutManager mLayoutManager;
private final int mToolbarHeight;
private final int mMaxHeaderHeight;
/**
* Total height of the items being dismissed. Tracked to allow the bottom space to compensate
* for their removal animation and avoid moving the scroll position.
*/
private int mCompensationHeight;
/**
* Height compensation value for each item being dismissed. Since dismissals sometimes include
* sibling elements, and these don't get the standard treatment, we track the total height
* associated with the element the user interacted with.
*/
private final Map<ViewHolder, Integer> mCompensationHeightMap = new HashMap<>();
/** View used to calculate the position of the cards' snap point. */
private View mAboveTheFoldView;
/** Whether the RecyclerView and its children should react to touch events. */
private boolean mTouchEnabled = true;
/** Whether the above-the-fold left space for a peeking card to be displayed. */
private boolean mHasSpaceForPeekingCard;
/**
* Constructor needed to inflate from XML.
*/
public NewTabPageRecyclerView(Context context, AttributeSet attrs) {
super(context, attrs);
mGestureDetector =
new GestureDetector(getContext(), new GestureDetector.SimpleOnGestureListener() {
@Override
public boolean onSingleTapUp(MotionEvent e) {
boolean retVal = super.onSingleTapUp(e);
requestFocus();
return retVal;
}
});
mLayoutManager = new LinearLayoutManager(getContext());
setLayoutManager(mLayoutManager);
Resources res = context.getResources();
mToolbarHeight = res.getDimensionPixelSize(R.dimen.toolbar_height_no_shadow)
+ res.getDimensionPixelSize(R.dimen.toolbar_progress_bar_height);
mMaxHeaderHeight = res.getDimensionPixelSize(R.dimen.snippets_article_header_height);
}
public boolean isFirstItemVisible() {
return mLayoutManager.findFirstVisibleItemPosition() == 0;
}
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
mGestureDetector.onTouchEvent(ev);
if (!mTouchEnabled) return true;
return super.onInterceptTouchEvent(ev);
}
public void setTouchEnabled(boolean enabled) {
mTouchEnabled = enabled;
}
@Override
public boolean onTouchEvent(MotionEvent ev) {
if (!mTouchEnabled) return false;
// Action down would already have been handled in onInterceptTouchEvent
if (ev.getActionMasked() != MotionEvent.ACTION_DOWN) {
mGestureDetector.onTouchEvent(ev);
}
return super.onTouchEvent(ev);
}
@Override
public void focusableViewAvailable(View v) {
// To avoid odd jumps during NTP animation transitions, we do not attempt to give focus
// to child views if this scroll view already has focus.
if (hasFocus()) return;
super.focusableViewAvailable(v);
}
@Override
public InputConnection onCreateInputConnection(EditorInfo outAttrs) {
// Fixes landscape transitions when unfocusing the URL bar: crbug.com/288546
outAttrs.imeOptions = EditorInfo.IME_FLAG_NO_FULLSCREEN;
return super.onCreateInputConnection(outAttrs);
}
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
int numberViews = getChildCount();
for (int i = 0; i < numberViews; ++i) {
View view = getChildAt(i);
NewTabPageViewHolder viewHolder = (NewTabPageViewHolder) getChildViewHolder(view);
if (viewHolder == null) return;
viewHolder.updateLayoutParams();
}
super.onLayout(changed, l, t, r, b);
}
public void setAboveTheFoldView(View aboveTheFoldView) {
mAboveTheFoldView = aboveTheFoldView;
}
public void setHasSpaceForPeekingCard(boolean hasSpaceForPeekingCard) {
mHasSpaceForPeekingCard = hasSpaceForPeekingCard;
}
/** Scroll up from the cards' current position and snap to present the first one. */
public void scrollToFirstCard() {
// Offset the target scroll by the height of the omnibox (the top padding).
final int targetScroll = mAboveTheFoldView.getHeight() - mAboveTheFoldView.getPaddingTop();
// If (somehow) the peeking card is tapped while midway through the transition,
// we need to account for how much we have already scrolled.
smoothScrollBy(0, targetScroll - computeVerticalScrollOffset());
}
/**
* Updates the space added at the end of the list to make sure the above/below the fold
* distinction can be preserved.
*/
public void refreshBottomSpacing() {
ViewHolder bottomSpacingViewHolder = findBottomSpacer();
// It might not be in the layout yet if it's not visible or ready to be displayed.
if (bottomSpacingViewHolder == null) return;
assert bottomSpacingViewHolder.getItemViewType() == NewTabPageItem.VIEW_TYPE_SPACING;
bottomSpacingViewHolder.itemView.requestLayout();
}
/**
* Calculates the height of the bottom spacing item, such that there is always enough content
* below the fold to push the header up to to the top of the screen.
*/
int calculateBottomSpacing() {
int aboveTheFoldPosition = getNewTabPageAdapter().getAboveTheFoldPosition();
int firstVisiblePos = mLayoutManager.findFirstVisibleItemPosition();
if (aboveTheFoldPosition == RecyclerView.NO_POSITION
|| firstVisiblePos == RecyclerView.NO_POSITION) {
return 0;
}
// We have enough items to fill the view, since the above-the-fold item is not even visible.
if (firstVisiblePos > aboveTheFoldPosition) {
return 0;
}
ViewHolder lastContentItem = findLastContentItem();
ViewHolder aboveTheFold = findViewHolderForAdapterPosition(aboveTheFoldPosition);
int bottomSpacing = getHeight() - mToolbarHeight;
if (lastContentItem == null || aboveTheFold == null) {
// This can happen in several cases, where some elements are not visible and the
// RecyclerView didn't already attach them. We handle it by just adding space to make
// sure that we never run out and force the UI to jump around and get stuck in a
// position that breaks the animations. The height will be properly adjusted at the
// next pass. Known cases that make it necessary:
// - The card list is refreshed while the NTP is not shown, for example when changing
// the sync settings.
// - Dismissing a snippet and having the status card coming to take its place.
// - Refresh while being below the fold, for example by tapping the status card.
if (aboveTheFold != null) bottomSpacing -= aboveTheFold.itemView.getBottom();
Log.w(TAG, "The RecyclerView items are not attached, can't determine the content "
+ "height: snap=%s, last=%s. Using full height: %d ",
aboveTheFold, lastContentItem, bottomSpacing);
} else {
int contentHeight =
lastContentItem.itemView.getBottom() - aboveTheFold.itemView.getBottom();
bottomSpacing -= contentHeight - mCompensationHeight;
}
return Math.max(0, bottomSpacing);
}
public void updatePeekingCardAndHeader() {
NewTabPageLayout aboveTheFoldView = findAboveTheFoldView();
if (aboveTheFoldView == null) return;
SectionHeaderViewHolder header = findFirstHeader();
if (header == null) return;
header.updateDisplay(computeVerticalScrollOffset(), mHasSpaceForPeekingCard);
CardViewHolder firstCard = findFirstCard();
if (firstCard != null) updatePeekingCard(firstCard);
// Update the space at the bottom, which needs to know about the height of the header.
refreshBottomSpacing();
}
/**
* Updates the peeking state of the provided card. Relies on the dimensions of the header to
* be correct, prefer {@link #updatePeekingCardAndHeader} that updates both together.
*/
public void updatePeekingCard(CardViewHolder peekingCard) {
SectionHeaderViewHolder header = findFirstHeader();
if (header == null) {
// No header, we must have scrolled quite far. Fallback to a non animated (full bleed)
// card.
peekingCard.updatePeek(0, /* shouldAnimate */ false);
return;
}
// If we have the card offset field trial enabled, don't peek at all.
if (CardsVariationParameters.getFirstCardOffsetDp() != 0) {
peekingCard.updatePeek(0, /* shouldAnimate */ false);
return;
}
// Here we consider that if the header is animating (is not completely expanded), the card
// should as well. In that case, the space below the header is what we have available.
boolean shouldAnimate = header.itemView.getHeight() < mMaxHeaderHeight;
peekingCard.updatePeek(getHeight() - header.itemView.getBottom(), shouldAnimate);
}
public NewTabPageAdapter getNewTabPageAdapter() {
return (NewTabPageAdapter) getAdapter();
}
public LinearLayoutManager getLinearLayoutManager() {
return mLayoutManager;
}
/**
* Returns the approximate adapter position that the user has scrolled to. The purpose of this
* value is that it can be stored and later retrieved to restore a scroll position that is
* familiar to the user, showing (part of) the same content the user was previously looking at.
* This position is valid for that purpose regardless of device orientation changes. Note that
* if the underlying data has changed in the meantime, different content would be shown for this
* position.
*/
public int getScrollPosition() {
return mLayoutManager.findFirstVisibleItemPosition();
}
/**
* Finds the view holder for the first header.
* @return The {@code ViewHolder} of the header, or null if it is not present.
*/
private SectionHeaderViewHolder findFirstHeader() {
int firstHeaderPosition = getNewTabPageAdapter().getFirstHeaderPosition();
if (firstHeaderPosition == RecyclerView.NO_POSITION) return null;
ViewHolder viewHolder = findViewHolderForAdapterPosition(firstHeaderPosition);
if (!(viewHolder instanceof SectionHeaderViewHolder)) return null;
return (SectionHeaderViewHolder) viewHolder;
}
/**
* Finds the view holder for the first card.
* @return The {@code ViewHolder} for the first card, or null if it is not present.
*/
private CardViewHolder findFirstCard() {
int firstCardPosition = getNewTabPageAdapter().getFirstCardPosition();
if (firstCardPosition == RecyclerView.NO_POSITION) return null;
ViewHolder viewHolder = findViewHolderForAdapterPosition(firstCardPosition);
if (!(viewHolder instanceof CardViewHolder)) return null;
return (CardViewHolder) viewHolder;
}
/**
* Finds the view holder for the last content item: the footer.
* @return The {@code ViewHolder} of the last content item, or null if it is not present.
*/
private ViewHolder findLastContentItem() {
int lastContentItemPosition = getNewTabPageAdapter().getLastContentItemPosition();
if (lastContentItemPosition == RecyclerView.NO_POSITION) return null;
ViewHolder viewHolder = findViewHolderForAdapterPosition(lastContentItemPosition);
if (viewHolder instanceof Footer.ViewHolder) return viewHolder;
return null;
}
/**
* Finds the view holder for the bottom spacer.
* @return The {@code ViewHolder} of the bottom spacer, or null if it is not present.
*/
private ViewHolder findBottomSpacer() {
int bottomSpacerPosition = getNewTabPageAdapter().getBottomSpacerPosition();
if (bottomSpacerPosition == RecyclerView.NO_POSITION) return null;
return findViewHolderForAdapterPosition(bottomSpacerPosition);
}
/**
* Finds the above the fold view.
* @return The View for above the fold or null, if it is not present.
*/
public NewTabPageLayout findAboveTheFoldView() {
int aboveTheFoldPosition = getNewTabPageAdapter().getAboveTheFoldPosition();
if (aboveTheFoldPosition == RecyclerView.NO_POSITION) return null;
ViewHolder viewHolder = findViewHolderForAdapterPosition(aboveTheFoldPosition);
if (viewHolder == null) return null;
View view = viewHolder.itemView;
if (!(view instanceof NewTabPageLayout)) return null;
return (NewTabPageLayout) view;
}
/** Called when an item is in the process of being removed from the view. */
public void onItemDismissStarted(ViewHolder viewHolder) {
assert !mCompensationHeightMap.containsKey(viewHolder);
int dismissedHeight = viewHolder.itemView.getHeight();
ViewHolder siblingViewHolder = getNewTabPageAdapter().getDismissSibling(viewHolder);
if (siblingViewHolder != null) {
dismissedHeight += siblingViewHolder.itemView.getHeight();
}
mCompensationHeightMap.put(viewHolder, dismissedHeight);
mCompensationHeight += dismissedHeight;
refreshBottomSpacing();
}
/** Called when an item has finished being removed from the view. */
public void onItemDismissFinished(ViewHolder viewHolder) {
assert mCompensationHeightMap.containsKey(viewHolder);
mCompensationHeight -= mCompensationHeightMap.remove(viewHolder);
assert mCompensationHeight >= 0;
refreshBottomSpacing();
}
/**
* If the RecyclerView is currently scrolled to between regionStart and regionEnd, smooth scroll
* out of the region. flipPoint is the threshold used to decide which bound of the region to
* scroll to. It returns whether the view was scrolled.
*/
private boolean scrollOutOfRegion(int regionStart, int flipPoint, int regionEnd) {
final int currentScroll = computeVerticalScrollOffset();
if (currentScroll < regionStart || currentScroll > regionEnd) return false;
if (currentScroll < flipPoint) {
smoothScrollBy(0, regionStart - currentScroll);
} else {
smoothScrollBy(0, regionEnd - currentScroll);
}
return true;
}
/**
* If the RecyclerView is currently scrolled to between regionStart and regionEnd, smooth scroll
* out of the region to the nearest edge.
*/
private boolean scrollOutOfRegion(int regionStart, int regionEnd) {
return scrollOutOfRegion(regionStart, (regionStart + regionEnd) / 2, regionEnd);
}
/**
* Snaps the scroll point of the RecyclerView to prevent the user from scrolling to midway
* through a transition and to allow peeking card behaviour.
*/
public void snapScroll(View fakeBox, int parentScrollY, int parentHeight) {
// Snap scroll to prevent resting in the middle of the omnibox transition.
final int searchBoxTransitionLength = getResources()
.getDimensionPixelSize(R.dimen.ntp_search_box_transition_length);
int fakeBoxUpperBound = fakeBox.getTop() + fakeBox.getPaddingTop();
if (scrollOutOfRegion(fakeBoxUpperBound - searchBoxTransitionLength, fakeBoxUpperBound)) {
// The snap scrolling regions should never overlap.
return;
}
// Snap scroll to prevent resting in the middle of the peeking card transition
// and to allow the peeking card to peek a bit before snapping back.
CardViewHolder peekingCardViewHolder = findFirstCard();
if (peekingCardViewHolder != null && isFirstItemVisible()) {
if (!mHasSpaceForPeekingCard) return;
ViewHolder firstHeaderViewHolder = findFirstHeader();
// It is possible to have a card but no header, for example the sign in promo.
// That one does not peek.
if (firstHeaderViewHolder == null) return;
View peekingCardView = peekingCardViewHolder.itemView;
View headerView = firstHeaderViewHolder.itemView;
final int peekingHeight = getResources().getDimensionPixelSize(
R.dimen.snippets_padding_and_peeking_card_height);
// |A + B - C| gives the offset of the peeking card relative to the Recycler View,
// so scrolling to this point would put the peeking card at the top of the
// screen. Remove the |headerView| height which gets dynamically increased with
// scrolling.
// |A + B - C - D| will scroll us so that the peeking card is just off the bottom
// of the screen.
// Finally, we get |A + B - C - D + E| because the transition starts from the
// peeking card's resting point, which is |E| from the bottom of the screen.
int start = peekingCardView.getTop() // A.
+ parentScrollY // B.
- headerView.getHeight() // C.
- parentHeight // D.
+ peekingHeight; // E.
// The height of the region in which the the peeking card will snap.
int snapScrollHeight = peekingHeight + headerView.getHeight();
scrollOutOfRegion(start,
start + snapScrollHeight,
start + snapScrollHeight);
}
}
@Override
public boolean gatherTransparentRegion(Region region) {
ViewUtils.gatherTransparentRegionsForOpaqueView(this, region);
return true;
}
/**
* Animates the card being swiped to the right as if the user had dismissed it. Any changes to
* the animation here should be reflected also in
* {@link #updateViewStateForDismiss(float, ViewHolder)} and reset in
* {@link CardViewHolder#onBindViewHolder()}.
* @param suggestion The item to be dismissed.
*/
public void dismissItemWithAnimation(SnippetArticle suggestion) {
// We need to recompute the position, as it might have changed.
final int position = getNewTabPageAdapter().getSuggestionPosition(suggestion);
if (position == RecyclerView.NO_POSITION) {
// The item does not exist anymore, so ignore.
return;
}
final View itemView = mLayoutManager.findViewByPosition(position);
if (itemView == null) {
// The view is not visible anymore, skip the animation.
getNewTabPageAdapter().dismissItem(position);
return;
}
final ViewHolder viewHolder = getChildViewHolder(itemView);
if (!((NewTabPageViewHolder) viewHolder).isDismissable()) {
// The item is not dismissable (anymore), so ignore.
return;
}
AnimatorSet animation = new AnimatorSet();
animation.playTogether(ObjectAnimator.ofFloat(itemView, View.ALPHA, 0f),
ObjectAnimator.ofFloat(itemView, View.TRANSLATION_X, (float) itemView.getWidth()));
animation.setDuration(DISMISS_ANIMATION_TIME_MS);
animation.setInterpolator(DISMISS_INTERPOLATOR);
animation.addListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationStart(Animator animation) {
NewTabPageRecyclerView.this.onItemDismissStarted(viewHolder);
}
@Override
public void onAnimationEnd(Animator animation) {
getNewTabPageAdapter().dismissItem(position);
NewTabPageRecyclerView.this.onItemDismissFinished(viewHolder);
}
});
animation.start();
}
/**
* Update the view's state as it is being swiped away. Any changes to the animation here should
* be reflected also in {@link #dismissItemWithAnimation(SnippetArticle)} and reset in
* {@link CardViewHolder#onBindViewHolder()}.
* @param dX The amount of horizontal displacement caused by user's action.
* @param viewHolder The view holder containing the view to be updated.
*/
public void updateViewStateForDismiss(float dX, ViewHolder viewHolder) {
if (!((NewTabPageViewHolder) viewHolder).isDismissable()) return;
viewHolder.itemView.setTranslationX(dX);
float input = Math.abs(dX) / viewHolder.itemView.getMeasuredWidth();
float alpha = 1 - DISMISS_INTERPOLATOR.getInterpolation(input);
viewHolder.itemView.setAlpha(alpha);
}
}