0

Currently I'm at the end of my ideas on following issue with LinearLayoutManagers and RecyclerViews on Android:

What scenario I wanted to achieve

A horizontal RecyclerView on which the user can swipe very fast without any limitations on fling. The items being fullscreen sized making them as big as the recyclerview itself. When the fling has stopped or the user stops manually, the recycler should scroll to one item (mimicing a viewPager a bit) (I'm using support revision 25.1.0)

code snippets

The Pager-class itself

public class VelocityPager extends RecyclerView {

    private int mCurrentItem = 0;

    @NonNull
    private LinearLayoutManager mLayoutManager;

    @Nullable
    private OnPageChangeListener mOnPageChangeListener = null;

    @NonNull
    private Rect mViewRect = new Rect();

    @NonNull
    private OnScrollListener mOnScrollListener = new OnScrollListener() {

        private int mLastItem = 0;

        @Override
        public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
            if (mOnPageChangeListener == null) return;
            mCurrentItem = mLayoutManager.findFirstVisibleItemPosition();
            final View view = mLayoutManager.findViewByPosition(mCurrentItem);
            view.getLocalVisibleRect(mViewRect);
            final float offset = (float) mViewRect.left / ((View) view.getParent()).getWidth();
            mOnPageChangeListener.onPageScrolled(mCurrentItem, offset, 0);
            if (mCurrentItem != mLastItem) {
                mOnPageChangeListener.onPageSelected(mCurrentItem);
                mLastItem = mCurrentItem;
            }
        }

        @Override
        public void onScrollStateChanged(RecyclerView recyclerView, int newState) {
            if (mOnPageChangeListener == null) return;
            mOnPageChangeListener.onPageScrollStateChanged(newState);
        }

    };

    public VelocityPager(@NonNull Context context) {
        this(context, null);
    }

    public VelocityPager(@NonNull Context context, @Nullable AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public VelocityPager(@NonNull Context context, @Nullable AttributeSet attrs, int defStyle) {
        super(context, attrs, defStyle);
        mLayoutManager = createLayoutManager();
        init();
    }

    @NonNull
    private LinearLayoutManager createLayoutManager() {
        return new LinearLayoutManager(getContext(), LinearLayoutManager.HORIZONTAL, false);
    }

    @Override
    protected void onAttachedToWindow() {
        super.onAttachedToWindow();
        addOnScrollListener(mOnScrollListener);
    }

    @Override
    protected void onDetachedFromWindow() {
        super.onDetachedFromWindow();
        removeOnScrollListener(mOnScrollListener);
    }

    @Override
    public void onScrollStateChanged(int state) {
        // If you tap on the phone while the RecyclerView is scrolling it will stop in the middle.
        // This code fixes this. This code is not strictly necessary but it improves the behaviour.
        if (state == SCROLL_STATE_IDLE) {
            LinearLayoutManager linearLayoutManager = (LinearLayoutManager) getLayoutManager();

            int screenWidth = Resources.getSystem().getDisplayMetrics().widthPixels;

            // views on the screen
            int lastVisibleItemPosition = linearLayoutManager.findLastVisibleItemPosition();
            View lastView = linearLayoutManager.findViewByPosition(lastVisibleItemPosition);
            int firstVisibleItemPosition = linearLayoutManager.findFirstVisibleItemPosition();
            View firstView = linearLayoutManager.findViewByPosition(firstVisibleItemPosition);

            // distance we need to scroll
            int leftMargin = (screenWidth - lastView.getWidth()) / 2;
            int rightMargin = (screenWidth - firstView.getWidth()) / 2 + firstView.getWidth();
            int leftEdge = lastView.getLeft();
            int rightEdge = firstView.getRight();
            int scrollDistanceLeft = leftEdge - leftMargin;
            int scrollDistanceRight = rightMargin - rightEdge;

            if (leftEdge > screenWidth / 2) {
                smoothScrollBy(-scrollDistanceRight, 0);
            } else if (rightEdge < screenWidth / 2) {
                smoothScrollBy(scrollDistanceLeft, 0);
            }
        }
    }

    private void init() {
        setLayoutManager(mLayoutManager);
        setItemAnimator(new DefaultItemAnimator());
        setHasFixedSize(true);
    }

    public void setCurrentItem(int index, boolean smoothScroll) {
        if (mOnPageChangeListener != null) {
            mOnPageChangeListener.onPageSelected(index);
        }
        if (smoothScroll) smoothScrollToPosition(index);
        if (!smoothScroll) scrollToPosition(index);
    }

    public int getCurrentItem() {
        return mCurrentItem;
    }

    public void setOnPageChangeListener(@Nullable OnPageChangeListener onPageChangeListener) {
        mOnPageChangeListener = onPageChangeListener;
    }

    public interface OnPageChangeListener {

        /**
         * This method will be invoked when the current page is scrolled, either as part
         * of a programmatically initiated smooth scroll or a user initiated touch scroll.
         *
         * @param position             Position index of the first page currently being displayed.
         *                             Page position+1 will be visible if positionOffset is nonzero.
         * @param positionOffset       Value from [0, 1) indicating the offset from the page at position.
         * @param positionOffsetPixels Value in pixels indicating the offset from position.
         */
        void onPageScrolled(int position, float positionOffset, int positionOffsetPixels);

        /**
         * This method will be invoked when a new page becomes selected. Animation is not
         * necessarily complete.
         *
         * @param position Position index of the new selected page.
         */
        void onPageSelected(int position);

        /**
         * Called when the scroll state changes. Useful for discovering when the user
         * begins dragging, when the pager is automatically settling to the current page,
         * or when it is fully stopped/idle.
         *
         * @param state The new scroll state.
         * @see VelocityPager#SCROLL_STATE_IDLE
         * @see VelocityPager#SCROLL_STATE_DRAGGING
         * @see VelocityPager#SCROLL_STATE_SETTLING
         */
        void onPageScrollStateChanged(int state);

    }

}

The item's xml layout

(Note: the root view has to be clickable for other purposes inside the app)

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:clickable="true">

    <LinearLayout
        android:id="@+id/icon_container_top"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_alignParentEnd="true"
        android:layout_alignParentRight="true"
        android:layout_alignParentTop="true"
        android:layout_gravity="top|end"
        android:layout_marginEnd="16dp"
        android:layout_marginRight="16dp"
        android:layout_marginTop="16dp"
        android:alpha="0"
        android:background="@drawable/info_background"
        android:orientation="horizontal"
        android:padding="4dp"
        tools:alpha="1">

        <ImageView
            android:id="@+id/delete"
            style="@style/SelectableItemBackground"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:clickable="true"
            android:contentDescription="@string/desc_delete"
            android:padding="12dp"
            android:src="@drawable/ic_delete_white_24dp"
            android:tint="@color/icons" />

    </LinearLayout>

    <LinearLayout
        android:id="@+id/icon_container_bottom"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_alignParentEnd="true"
        android:layout_alignParentRight="true"
        android:layout_centerVertical="true"
        android:layout_marginBottom="16dp"
        android:layout_marginEnd="16dp"
        android:layout_marginRight="16dp"
        android:alpha="0"
        android:background="@drawable/info_background"
        android:orientation="vertical"
        android:padding="4dp"
        tools:alpha="1">

        <ImageView
            android:id="@+id/size"
            style="@style/SelectableItemBackground"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:clickable="true"
            android:contentDescription="@string/desc_size"
            android:padding="12dp"
            android:src="@drawable/ic_straighten_white_24dp"
            android:tint="@color/icons" />

        <ImageView
            android:id="@+id/palette"
            style="@style/SelectableItemBackground"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:clickable="true"
            android:contentDescription="@string/desc_palette"
            android:padding="12dp"
            android:src="@drawable/ic_palette_white_24dp"
            android:tint="@color/icons" />

    </LinearLayout>
</RelativeLayout>

The xml layout with the pager itself

(Quite nested? Might be a cause of the problem? I don't know... )

<?xml version="1.0" encoding="utf-8"?>
<android.support.v4.widget.DrawerLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/drawer"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:fitsSystemWindows="true"
    tools:openDrawer="end">

    <SwipeRefreshLayout
        android:id="@+id/refresh_layout"
        android:layout_width="match_parent"
        android:layout_height="match_parent">

        <android.support.design.widget.CoordinatorLayout
            android:id="@+id/coordinator"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:fitsSystemWindows="false">

            <FrameLayout
                android:id="@+id/container"
                android:layout_width="match_parent"
                android:layout_height="match_parent" />

            <com.my.example.OptionalViewPager
                android:id="@+id/view_pager"
                android:layout_width="match_parent"
                android:layout_height="match_parent"
                android:scrollbars="horizontal"
                app:layout_behavior="com.my.example.MoveUpBehavior" />

            <android.support.v7.widget.Toolbar
                android:id="@+id/toolbar"
                android:layout_width="match_parent"
                android:layout_height="?attr/actionBarSize"
                android:background="@android:color/transparent"
                android:clickable="false"
                android:fitsSystemWindows="false"
                app:contentInsetLeft="0dp"
                app:contentInsetStart="0dp"
                app:contentInsetStartWithNavigation="0dp"
                app:layout_collapseMode="pin"
                app:navigationIcon="@drawable/ic_menu_white_24dp" />

        </android.support.design.widget.CoordinatorLayout>

    </SwipeRefreshLayout>

    <include layout="@layout/layout_drawer" />

</android.support.v4.widget.DrawerLayout>

part of my adapter that is relevant for ViewHolders

@Override
    public int getItemCount() {
        return dataset.size();
    }

    @Override
    public MyViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
        Log.v("Adapter", "CreateViewHolder");
        final LayoutInflater layoutInflater = LayoutInflater.from(parent.getContext());
        final View rootView = layoutInflater.inflate(R.layout.page, parent, false);
        return new MyViewHolder(rootView);
    }

    @Override
    public void onBindViewHolder(MyViewHolder page, int position) {
        Log.v("Adapter", String.format("BindViewHolder(%d)", position));
        final ViewData viewData = dataset.get(position);
        page.bind(viewData);
        listener.onViewAdded(position, viewData.getData());
    }

    @Override
    public void onViewRecycled(MyViewHolder page) {
        if (page.getData() == null) return;
        listener.onViewRemoved(page.getData().id);
    }

    @Override
    public int getItemViewType(int position) {
        return 0;
    }

The ViewHolder

public class MyViewHolder extends RecyclerView.ViewHolder implements MyListener {

    @BindView(R.id.info_container)
    ViewGroup mInfoContainer;

    @BindView(R.id.icon_container_top)
    ViewGroup mIconContainerTop;

    @BindView(R.id.icon_container_bottom)
    ViewGroup mIconContainerBottom;

    @BindView(R.id.info_rows)
    ViewGroup mInfoRows;

    @BindView(R.id.loading)
    View mIcLoading;

    @BindView(R.id.sync_status)
    View mIcSyncStatus;

    @BindView(R.id.delete)
    View mIcDelete;

    @BindView(R.id.ic_fav)
    View mIcFavorite;

    @BindView(R.id.size)
    View mIcSize;

    @BindView(R.id.palette)
    View mIcPalette;

    @BindView(R.id.name)
    TextView mName;

    @BindView(R.id.length)
    TextView mLength;

    @BindView(R.id.threads)
    TextView mThreads;

    @BindView(R.id.price)
    TextView mPrice;

    @Nullable
    private MyModel mModel = null;

    @Nullable
    private Activity mActivity;

    public MyViewHolder(View itemView) {
        super(itemView);
        ButterKnife.bind(this, itemView);
        mActivity= (Activity) itemView.getContext();
        if (mActivity!= null) mActivity.addMyListener(this);
    }

    @OnClick(R.id.delete)
    protected void clickDeleteBtn() {
        if (mActivity == null || mActivity.getMode() != Mode.EDIT) return;
        if (mModel == null) return;
        Animations.pop(mIcDelete);
        final int modelId = mModel.id;
        if (mModel.delete()) {
            mActivity.delete(modelId);
        }
    }

    @OnClick(R.id.size)
    protected void clickSizeBtn() {
        if (mActivity== null) return;
        mActivity.setUIMode(Mode.EDIT_SIZE);
        Animations.pop(mIcSize);
    }

    @OnClick(R.id.palette)
    protected void clickPaletteBtn() {
        if (mActivity== null) return;
        mActivity.setUIMode(Mode.EDIT_LENGTH);
        Animations.pop(mIcPalette);
    }

    private void initModelViews() {
        if (mData == null) return;
        final Locale locale = Locale.getDefault();
        mName.setValue(String.format(locale, "Model#%d", mModel.id));
        mLength.setValue(Html.fromHtml(String.format(locale, itemView.getContext().getString(R.string.template_length), mModel.meters)));
    }

    /**
     * set the icon container to be off screen at the beginning
     */
    private void prepareViews() {
        new ExpectAnim().expect(mIconContainerTop).toBe(outOfScreen(Gravity.END), visible())
                .toAnimation()
                .setNow();
        new ExpectAnim().expect(mIconContainerBottom).toBe(outOfScreen(Gravity.END), visible())
                .toAnimation()
                .setNow();

    }

    @Nullable
    public MyModel getData() {
        return mModel;
    }

    private void enableEdit() {
        new ExpectAnim()
                .expect(mIconContainerBottom)
                .toBe(atItsOriginalPosition())
                .toAnimation()
                .start();
    }

    private void disableEdit() {
        new ExpectAnim()
                .expect(mIconContainerBottom)
                .toBe(outOfScreen(Gravity.END))
                .toAnimation()
                .start();
    }

    private void enableInfo() {
        new ExpectAnim()
                .expect(mInfoContainer)
                .toBe(atItsOriginalPosition())
                .toAnimation()
                .start();
    }

    private void disableInfo() {
        new ExpectAnim()
                .expect(mInfoContainer)
                .toBe(outOfScreen(Gravity.BOTTOM))
                .toAnimation()
                .start();
    }

    private void enableDelete() {
        if (mIconContainerTop == null) return;
        new ExpectAnim()
                .expect(mIconContainerTop)
                .toBe(atItsOriginalPosition(), visible())
                .toAnimation()
                .start();
    }

    private void disableDelete() {
        if (mIconContainerTop == null) return;
        new ExpectAnim()
                .expect(mIconContainerTop)
                .toBe(outOfScreen(Gravity.END), invisible())
                .toAnimation()
                .start();
    }

    public void bind(@NonNull final ViewData viewData) {
        mModel = viewData.getData();
        prepareViews();
        initModelViews();
    }

}

So, here's my issue with these!

When intializing the adapter I insert about 15 to 17 items via an observable. This seems to be correct:

Logging for initializing is okay

but when swiping horizontally the recyclerView's callbacks seem to be totally messed up and produce weird results:

Messed up loggings

Do you see that the recycler does not try to recycle old viewHolders at all? The image just shows a small portion of the "spamming" that is going on. Sometimes it will create a new viewHolder even more than two times for the same position while I scroll the recycler slowly!

onBind spam

Another side problem is: The listener currently should allow me to pass the bind / recycle events to an underlying game engine which will create destroy entities on the screen. Due the excessive spamming of the events it will currently create those entities also excessively!

I excpected the Recycler to create a new ViewHolder for the first (let's say in my example 17) times and then just reuse the items how it should.

Please help, I'm stuck on this problem for 2 days now and I'm frustrated after searching people with same issues but without luck. Thank you!

TheWhiteLlama
  • 1,276
  • 1
  • 18
  • 31
  • I think there's a library for that https://github.com/rubensousa/RecyclerViewSnap – Eugen Pechanec Mar 28 '17 at 10:53
  • And even without any libraries, there's the official [`SnapHelper`](https://developer.android.com/reference/android/support/v7/widget/SnapHelper.html) inside the support lib. – Egor Mar 28 '17 at 10:57
  • Please attach the code for `MyViewHolder` as well. – Egor Mar 28 '17 at 11:03
  • yes, just use `android.support.v7.widget.LinearSnapHelper`, thats all – pskink Mar 28 '17 at 11:03
  • @Egor SnapHelper is an abstract class. Indeed there is an official LinearSnapHelper implementation but it only snaps a view to center of RecyclerView. The unofficial GravitySnapHelper allows snapping to start or end of RecyclerView as well. – Eugen Pechanec Mar 28 '17 at 11:10
  • @EugenPechanec `LSH` docs say: "The implementation will snap the center of the target child view to the center of the attached RecyclerView. If you intend to change this behavior then override calculateDistanceToFinalSnap(RecyclerView.LayoutManager, View)." – pskink Mar 28 '17 at 11:12
  • @pskink That's what I said. And sure enough you can do that. Or you can use a library *which is already programmed* that does it for you so you can focus on more important things. I believe we've provided OP the necessary tools. – Eugen Pechanec Mar 28 '17 at 11:16
  • All SnapHelpers I've tried seem to limit the maximum scroll speed at an unaccaptable amount :( – TheWhiteLlama Mar 28 '17 at 11:26
  • @TheWhiteLlama You should include that in the question next time. Knowing what you have already tried and why it's not good enough helps us find a better solution. – Eugen Pechanec Mar 28 '17 at 11:49

2 Answers2

2

When the fling has stopped or the user stops manually, the recycler should scroll to one item (mimicing a viewPager a bit)

  • Use the official LinearSnapHelper which snaps center of child view to center of RecyclerView.
  • Use a GravitySnapHelper library which can also snap to start of or end of RecyclerView, just like Google Play store does.

Both of these solutions are applied similarly:

new LinearSnapHelper().attachToRecyclerView(recyclerView);

A horizontal RecyclerView on which the user can swipe very fast without any limitations on fling.

"Without limitations" translates to "infinite speed" meaning a fling would instantly jump to target position. That's probably not what you want.

After going through SnapHelper source I found out that there is a rule: one inch takes 100 milliseconds to scroll. You can override this behavior.

final SnapHelper snapHelper = new LinearSnapHelper() {
    @Override
    protected float calculateSpeedPerPixel(DisplayMetrics displayMetrics) {
        return MILLISECONDS_PER_INCH / displayMetrics.densityDpi;
    }
};
snapHelper.attachToRecyclerView(recyclerView);

That's the default speed (where MILLISECONDS_PER_INCH = 100). Experiment and find out what fits your needs, start with "one inch takes 50 ms to scroll" and so on.

Eugen Pechanec
  • 37,669
  • 7
  • 103
  • 124
2

There's obviously a problem with ViewHolder recycling. I'm guessing the animations you're running inside MyViewHolder might prevent RecyclerView from recycling holders properly. Make sure you cancel animations at some point, e.g. in RecyclerView.Adapter#onViewDetachedFromWindow().

After you've fixed this, I suggest you follow @EugenPechanec's suggestion to reduce the amount of custom calculations done in the OnScrollListeners. It's better to rely on support library classes and tweak the behavior a little.

Egor
  • 39,695
  • 10
  • 113
  • 130
  • Thanks Egor! I've used the provided onFailedToRecycleView to cancel all possible item animations! I appretiate it, now it works like a charm! – TheWhiteLlama Mar 28 '17 at 12:01
  • 1
    Great to hear that. Still, the fact that `onFailedToRecycleView()` was called indicates that there was an error, so you might try to cancel the animations at an earlier step - this is just an improvement suggestion. – Egor Mar 28 '17 at 12:02
  • onViewDetachedFromWindow() sadly does not work while it does when using onFailedToRecycleView() – TheWhiteLlama Mar 28 '17 at 12:07
  • 1
    Can recommend https://www.youtube.com/watch?v=imsr8NrIAMs&ab_channel=AndroidDevelopers on animations inside RecyclerView. Basically you should do all animation based on a `payload` in a custom `ItemAnimator`. I heve never implemented this so I can't provide a full fledged answer. – Eugen Pechanec Mar 28 '17 at 12:13
  • @EugenPechanec, Good point, `ItemAnimator`s are a better place for animations. – Egor Mar 28 '17 at 12:15