blob: f56c2d9a4d30e7ce8613231346a3d9199265b3d9 [file] [log] [blame]
// 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;
}
}