org.chromium.chrome.browser.ntp.cards.NewTabPageRecyclerView.java Source code

Java tutorial

Introduction

Here is the source code for org.chromium.chrome.browser.ntp.cards.NewTabPageRecyclerView.java

Source

// 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);
    }
}