| // 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.suggestions; |
| |
| import android.animation.Animator; |
| import android.animation.AnimatorListenerAdapter; |
| import android.animation.AnimatorSet; |
| import android.animation.ObjectAnimator; |
| import android.annotation.SuppressLint; |
| import android.content.Context; |
| import android.content.res.Configuration; |
| import android.content.res.Resources; |
| import android.graphics.Canvas; |
| import android.support.annotation.Nullable; |
| import android.support.v4.view.ViewCompat; |
| import android.support.v4.view.animation.FastOutLinearInInterpolator; |
| import android.support.v7.view.ContextThemeWrapper; |
| import android.support.v7.widget.LinearLayoutManager; |
| import android.support.v7.widget.RecyclerView; |
| import android.support.v7.widget.helper.ItemTouchHelper; |
| import android.util.AttributeSet; |
| import android.view.GestureDetector; |
| import android.view.MotionEvent; |
| import android.view.View; |
| import android.view.ViewGroup; |
| import android.view.animation.Interpolator; |
| import android.view.inputmethod.EditorInfo; |
| import android.view.inputmethod.InputConnection; |
| |
| import org.chromium.chrome.R; |
| import org.chromium.chrome.browser.native_page.ContextMenuManager; |
| import org.chromium.chrome.browser.ntp.cards.CardViewHolder; |
| import org.chromium.chrome.browser.ntp.cards.NewTabPageAdapter; |
| import org.chromium.chrome.browser.ntp.cards.NewTabPageViewHolder; |
| import org.chromium.chrome.browser.ntp.cards.ScrollToLoadListener; |
| import org.chromium.chrome.browser.widget.displaystyle.UiConfig; |
| |
| import java.util.ArrayList; |
| import java.util.Collections; |
| import java.util.HashMap; |
| import java.util.List; |
| import java.util.Map; |
| import java.util.Set; |
| |
| /** |
| * Simple wrapper on top of a RecyclerView that will acquire focus when tapped. Ensures the |
| * New Tab page receives focus when clicked. |
| */ |
| public class SuggestionsRecyclerView extends RecyclerView { |
| 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; |
| |
| // The ScrollToLoadListener triggers loading more content when the user is near the end. |
| @Nullable private ScrollToLoadListener mScrollToLoadListener; |
| |
| /** |
| * 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<>(); |
| |
| /** |
| * Whether the {@link SuggestionsRecyclerView} and its children should react to touch events. |
| */ |
| private boolean mTouchEnabled = true; |
| |
| /** The ui config for this view. */ |
| private UiConfig mUiConfig; |
| |
| /** The context menu manager for this view. */ |
| private ContextMenuManager mContextMenuManager; |
| |
| private boolean mIsCardBeingSwiped; |
| |
| public SuggestionsRecyclerView(Context context) { |
| this(context, null); |
| } |
| |
| @SuppressWarnings("RestrictTo") |
| public SuggestionsRecyclerView(Context context, AttributeSet attrs) { |
| super(new ContextThemeWrapper(context, R.style.NewTabPageRecyclerView), attrs); |
| |
| Resources res = getContext().getResources(); |
| setBackgroundColor(SuggestionsConfig.getBackgroundColor(res)); |
| setLayoutParams(new LayoutParams( |
| ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)); |
| setFocusable(true); |
| setFocusableInTouchMode(true); |
| setContentDescription(res.getString(R.string.accessibility_new_tab_page)); |
| |
| 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); |
| setHasFixedSize(true); |
| |
| ItemTouchHelper helper = new ItemTouchHelper(new ItemTouchCallbacks()); |
| helper.attachToRecyclerView(this); |
| |
| addOnScrollListener(new SuggestionsMetrics.ScrollEventReporter()); |
| } |
| |
| public boolean isFirstItemVisible() { |
| return mLayoutManager.findFirstVisibleItemPosition() == 0; |
| } |
| |
| /** |
| * Sets whether the {@link SuggestionsRecyclerView} and its children should react to touch |
| * events. |
| */ |
| public void setTouchEnabled(boolean enabled) { |
| mTouchEnabled = enabled; |
| } |
| |
| /** |
| * @return Whether the {@link SuggestionsRecyclerView} and its children should react to touch |
| * events. |
| */ |
| protected boolean getTouchEnabled() { |
| return mTouchEnabled; |
| } |
| |
| @Override |
| public boolean onInterceptTouchEvent(MotionEvent ev) { |
| mGestureDetector.onTouchEvent(ev); |
| if (!getTouchEnabled()) return true; |
| return super.onInterceptTouchEvent(ev); |
| } |
| |
| @Override |
| public boolean dispatchTouchEvent(MotionEvent ev) { |
| if (ev.getActionMasked() == MotionEvent.ACTION_UP |
| || ev.getActionMasked() == MotionEvent.ACTION_CANCEL) { |
| setLayoutFrozen(false); |
| } |
| return super.dispatchTouchEvent(ev); |
| } |
| |
| @SuppressLint("ClickableViewAccessibility") |
| @Override |
| public boolean onTouchEvent(MotionEvent ev) { |
| if (!getTouchEnabled()) 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 onConfigurationChanged(Configuration newConfig) { |
| super.onConfigurationChanged(newConfig); |
| |
| // When the viewport configuration changes, we want to update the display style so that the |
| // observers are aware of the new available space. Another moment to do this update could |
| // be through a OnLayoutChangeListener, but then we get notified of the change after the |
| // layout pass, which means that the new style will only be visible after layout happens |
| // again. We prefer updating here to avoid having to require that additional layout pass. |
| if (mUiConfig != null) mUiConfig.updateDisplayStyle(); |
| |
| // Close the Context Menu as it may have moved (https://crbug.com/642688). |
| if (mContextMenuManager != null) mContextMenuManager.closeContextMenu(); |
| } |
| |
| @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) getChildViewHolder(view)).updateLayoutParams(); |
| } |
| super.onLayout(changed, l, t, r, b); |
| } |
| |
| public void init(UiConfig uiConfig, ContextMenuManager contextMenuManager) { |
| mUiConfig = uiConfig; |
| mContextMenuManager = contextMenuManager; |
| } |
| |
| public NewTabPageAdapter getNewTabPageAdapter() { |
| return (NewTabPageAdapter) getAdapter(); |
| } |
| |
| public LinearLayoutManager getLinearLayoutManager() { |
| return mLayoutManager; |
| } |
| |
| /** 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 = 0; |
| List<ViewHolder> siblings = getDismissalGroupViewHolders(viewHolder); |
| for (ViewHolder siblingViewHolder : siblings) { |
| dismissedHeight += siblingViewHolder.itemView.getHeight(); |
| } |
| |
| mCompensationHeightMap.put(viewHolder, dismissedHeight); |
| mCompensationHeight += dismissedHeight; |
| } |
| |
| /** Called when an item has finished being removed from the view. */ |
| public void onItemDismissFinished(ViewHolder viewHolder) { |
| if (!mCompensationHeightMap.containsKey(viewHolder)) return; |
| |
| mCompensationHeight -= mCompensationHeightMap.remove(viewHolder); |
| |
| assert mCompensationHeight >= 0; |
| } |
| |
| /** |
| * 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} and reset |
| * in {@link CardViewHolder#onBindViewHolder()}. |
| */ |
| public void dismissItemWithAnimation(final ViewHolder viewHolder) { |
| List<ViewHolder> siblings = getDismissalGroupViewHolders(viewHolder); |
| if (siblings.isEmpty()) return; |
| |
| List<Animator> animations = new ArrayList<>(); |
| for (ViewHolder dismissSibling : siblings) { |
| addDismissalAnimators(animations, dismissSibling.itemView); |
| } |
| |
| AnimatorSet animation = new AnimatorSet(); |
| animation.playTogether(animations); |
| animation.setDuration(DISMISS_ANIMATION_TIME_MS); |
| animation.setInterpolator(DISMISS_INTERPOLATOR); |
| animation.addListener(new AnimatorListenerAdapter() { |
| @Override |
| public void onAnimationStart(Animator animation) { |
| SuggestionsRecyclerView.this.onItemDismissStarted(viewHolder); |
| } |
| |
| @Override |
| public void onAnimationEnd(Animator animation) { |
| // It is possible that by the time the animation ends, we navigated away from the |
| // container and it got destroyed. In that case, abort. (https://crbug.com/668945) |
| if (!ViewCompat.isAttachedToWindow(viewHolder.itemView)) return; |
| |
| dismissItemInternal(viewHolder); |
| SuggestionsRecyclerView.this.onItemDismissFinished(viewHolder); |
| } |
| }); |
| animation.start(); |
| } |
| |
| private void dismissItemInternal(ViewHolder viewHolder) { |
| // Re-check the position in case the adapter has changed. |
| final int position = viewHolder.getAdapterPosition(); |
| if (position == RecyclerView.NO_POSITION) { |
| // The item does not exist anymore, so ignore. |
| return; |
| } |
| getNewTabPageAdapter().dismissItem(position, removedItemTitle -> { |
| announceForAccessibility(getResources().getString( |
| R.string.ntp_accessibility_item_removed, removedItemTitle)); |
| if (mScrollToLoadListener != null) mScrollToLoadListener.onItemDismissed(); |
| }); |
| } |
| |
| /** |
| * @param animations in/out list holding the animators to play. |
| * @param view view to animate. |
| */ |
| private void addDismissalAnimators(List<Animator> animations, View view) { |
| animations.add(ObjectAnimator.ofFloat(view, View.ALPHA, 0f)); |
| animations.add(ObjectAnimator.ofFloat(view, View.TRANSLATION_X, (float) view.getWidth())); |
| } |
| |
| /** |
| * Update the view's state as it is being swiped away. Any changes to the animation here should |
| * be reflected also in {@link #dismissItemWithAnimation(ViewHolder)} 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. |
| */ |
| private void updateViewStateForDismiss(float dX, ViewHolder viewHolder) { |
| viewHolder.itemView.setTranslationX(dX); |
| |
| float input = Math.abs(dX) / viewHolder.itemView.getMeasuredWidth(); |
| float alpha = 1 - DISMISS_INTERPOLATOR.getInterpolation(input); |
| viewHolder.itemView.setAlpha(alpha); |
| } |
| |
| /** |
| * Resets a card's properties affected by swipe to dismiss. Intended to be used as |
| * {@link NewTabPageViewHolder.PartialBindCallback} |
| */ |
| public static void resetForDismissCallback(NewTabPageViewHolder holder) { |
| ((CardViewHolder) holder).getRecyclerView().updateViewStateForDismiss(0, holder); |
| } |
| |
| /** |
| * Sets the ScrollToLoadListener for the RecyclerView. |
| */ |
| public void setScrollToLoadListener(@Nullable ScrollToLoadListener scrollToLoadListener) { |
| mScrollToLoadListener = scrollToLoadListener; |
| addOnScrollListener(mScrollToLoadListener); |
| } |
| |
| /** |
| * Clears the currently registered ScrollToLoadListener. |
| */ |
| public void clearScrollToLoadListener() { |
| if (mScrollToLoadListener == null) return; |
| |
| removeOnScrollListener(mScrollToLoadListener); |
| mScrollToLoadListener = null; |
| } |
| |
| private class ItemTouchCallbacks extends ItemTouchHelper.Callback { |
| @Override |
| public void onSwiped(ViewHolder viewHolder, int direction) { |
| onItemDismissStarted(viewHolder); |
| SuggestionsMetrics.recordCardSwipedAway(); |
| dismissItemInternal(viewHolder); |
| } |
| |
| @Override |
| public void clearView(RecyclerView recyclerView, ViewHolder viewHolder) { |
| // clearView() is called when an interaction with the item is finished, which does |
| // not mean that the user went all the way and dismissed the item before releasing it. |
| // We need to check that the item has been removed. |
| if (viewHolder.getAdapterPosition() == RecyclerView.NO_POSITION) { |
| onItemDismissFinished(viewHolder); |
| } |
| |
| super.clearView(recyclerView, viewHolder); |
| } |
| |
| @Override |
| public boolean onMove(RecyclerView recyclerView, ViewHolder viewHolder, ViewHolder target) { |
| assert false; // Drag and drop not supported, the method will never be called. |
| return false; |
| } |
| |
| @Override |
| public int getMovementFlags(RecyclerView recyclerView, ViewHolder viewHolder) { |
| int swipeFlags = 0; |
| if (((NewTabPageViewHolder) viewHolder).isDismissable()) { |
| swipeFlags = ItemTouchHelper.START | ItemTouchHelper.END; |
| } |
| |
| return makeMovementFlags(0 /* dragFlags */, swipeFlags); |
| } |
| |
| @Override |
| public void onChildDraw(Canvas c, RecyclerView recyclerView, ViewHolder viewHolder, |
| float dX, float dY, int actionState, boolean isCurrentlyActive) { |
| mIsCardBeingSwiped = isCurrentlyActive && dX != 0.f; |
| |
| // In some cases a removed child may call this method when unrelated items are |
| // interacted with (https://crbug.com/664466, b/32900699), but in that case |
| // getSiblingDismissalViewHolders() below will return an empty list. |
| |
| // We use our own implementation of the dismissal animation, so we don't call the |
| // parent implementation. (by default it changes the translation-X and elevation) |
| for (ViewHolder siblingViewHolder : getDismissalGroupViewHolders(viewHolder)) { |
| updateViewStateForDismiss(dX, siblingViewHolder); |
| } |
| } |
| } |
| |
| /** |
| * Tells if one of card views is being swiped now. |
| * @return {@code true} if a card view is being swiped. |
| */ |
| public boolean isCardBeingSwiped() { |
| return mIsCardBeingSwiped; |
| } |
| |
| private List<ViewHolder> getDismissalGroupViewHolders(ViewHolder viewHolder) { |
| int position = viewHolder.getAdapterPosition(); |
| if (position == NO_POSITION) return Collections.emptyList(); |
| |
| List<ViewHolder> viewHolders = new ArrayList<>(); |
| Set<Integer> dismissalRange = getNewTabPageAdapter().getItemDismissalGroup(position); |
| for (int i : dismissalRange) { |
| ViewHolder siblingViewHolder = findViewHolderForAdapterPosition(i); |
| if (siblingViewHolder == null) continue; |
| |
| viewHolders.add(siblingViewHolder); |
| } |
| return viewHolders; |
| } |
| } |