Search code examples
androidandroid-recyclerviewandroid-support-libraryhorizontal-scrollinglinearlayoutmanager

RecyclerView - Horizontal LinearLayoutManager create / bind methods called way too often


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!


Solution

  • 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.