Android RecyclerView ItemTouchHelper revert swipe and restore view holder

AndroidSwipeAndroid Recyclerview

Android Problem Overview


Is there a way to revert a swipe action and restore the view holder to its initial position after the swipe is completed and onSwiped is called on the ItemTouchHelper.Callback instance? I got the RecyclerView, ItemTouchHelper and ItemTouchHelper.Callback instances to work together perfectly, I just need to revert the swipe action and not remove the swiped item in some cases.

Android Solutions


Solution 1 - Android

After some random poking I found a solution. Call notifyItemChanged on you adapter. This will make the swiped out view animate back into it's original position.

Solution 2 - Android

You should override onSwiped method in ItemTouchHelper.Callback and refresh that particular item.

 @Override
 public void onSwiped(RecyclerView.ViewHolder viewHolder,
     int direction) {
     adapter.notifyItemChanged(viewHolder.getAdapterPosition());
 }

Solution 3 - Android

Google's ItemTouchHelper implementation assumes that every swiped out item will eventually get removed from the recycler view, whereas it might not be the case in some applications.

RecoverAnimation is a nested class in ItemTouchHelper that manages the touch animation of the swiped/dragged items. Although the name implies that it only recovers the position of items, it's actually the only class that is used to recover (cancel swipe/drag) and replace (move out on swipe or replace on drag) items. Strange naming.

There's a boolean property named mIsPendingCleanup in RecoverAnimation, which ItemTouchHelper uses to figure out whether the item is pending removal. So ItemTouchHelper, after attaching a RecoverAnimation to the item, sets this property after a successful swipe out, and the animation does not get removed from the list of recover animations as long as this property is set. The problem is that, mIsPendingCleanup will always be set for a swiped out item, causing the RecoverAnimation for the item to never be removed from the list of animations. So even if you recover the item's position after a successul swipe, it will be sent back to the swiped-out position as soon as you touch it - because the RecoverAnimation will cause the animation start from the latest swiped-out position.

Solution to this is unfortunately to copy the ItemTouchHelper class source code into the same package as it is in the support library, and remove the mIsPendingCleanup property from the RecoverAnimation class. I'm not sure if this is acceptable by Google, and I haven't posted the update to Play Store yet to see whether it will cause a reject, but you may find the class source code from support library v22.2.1 with the above mentioned fix at https://gist.github.com/kukabi/f46e1c0503d2806acbe2.

Solution 4 - Android

A dirty workaround solution for this problem is to re-attach the ItemTouchHelper by calling ItemTouchHelper::attachToRecyclerView(RecyclerView) twice, which then calls the private method ItemTouchHelper::destroyCallbacks(). destroyCallbacks() removes item decoration and all listeners but also clears all RecoverAnimations.

Note that we need to call itemTouchHelper.attachToRecyclerView(null) first to trick ItemTouchHelper into thinking that the second call to itemTouchHelper.attachToRecyclerView(recyclerView) is a new recycler view.

For further details take a look into the source code of ItemTouchHelper here.

Example of workaround:

RecyclerView recyclerView = findViewById(R.id.recycler_view);
ItemTouchHelper itemTouchHelper = new ItemTouchHelper(callback);

...
// Workaround to reset swiped out views
itemTouchHelper.attachToRecyclerView(null);
itemTouchHelper.attachToRecyclerView(recyclerView);

Consider it as a dirty workaround because this method uses internal, undocumented implementation detail of ItemTouchHelper.

Update:

From the documentation of ItemTouchHelper::attachToRecyclerView(RecyclerView):

> If TouchHelper is already attached to a RecyclerView, it will first detach from the previous one. You can call this method with null to detach it from the current RecyclerView.

and in the parameters documentation:

> The RecyclerView instance to which you want to add this helper or null if you want to remove ItemTouchHelper from the current RecyclerView.

So at least it is partly documented.

Solution 5 - Android

With the latest anndroidX packages I still have this issue, so I needed to adjust @jimmy0251 solution a bit to reset the item correctly (his solution would only work for the first swipe).

 override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
                clipAdapter.notifyItemChanged(viewHolder.adapterPosition)
                itemTouchHelper.startSwipe(viewHolder)
            }

Note that startSwipe() resets the item's recovery animations correctly.

Solution 6 - Android

In the case of using LiveData to provide a list to a ListAdapter, calling notifyItemChanged does not work. However, I found a fugly workaround which involves re-attaching the ItemTouchHelper to the recycler view in onSwiped callback as such

val recyclerView = someRecyclerViewInYourCode

var itemTouchHelper: ItemTouchHelper? = null

val itemTouchCallback = object : ItemTouchHelper.Callback {
    override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction:Int) {
        itemTouchHelper?.attachToRecyclerView(null)
        itemTouchHelper?.attachToRecyclerView(recyclerView)
    }
}

itemTouchHelper = ItemTouchHelper(itemTouchCallback)

itemTouchHelper.attachToRecyclerView(recyclerView)

Solution 7 - Android

onSwiped never call, always revert

override fun getSwipeThreshold(viewHolder: RecyclerView.ViewHolder): Float {
    return 1f
}
override fun getSwipeEscapeVelocity(defaultValue: Float): Float {
    return Float.MAX_VALUE
}

Solution 8 - Android

Since most of the ItemTouchHelper members have a private-package access modifier, and we don't want to copy a 2000 line class just to change one line, let's point our package as androidx.recyclerview.widget.

When a swipe occurs (mCallback.onSwiped), we can restore the initial state of the swiped view. mCallback.onSwiped is only called from the postDispatchSwipe method, so after that we inject our view restore (recoverOnSwiped), which clears any swiped effects and animation from the swiped view.

@file:Suppress("PackageDirectoryMismatch")

package androidx.recyclerview.widget

import android.annotation.SuppressLint

/**
 * [ItemTouchHelper] with recover viewHolder's itemView from clean up
 */
class RecoveredItemTouchHelper(callback: Callback, private val withRecover: Boolean = true) : ItemTouchHelper(callback) {

    private fun recoverOnSwiped(viewHolder: RecyclerView.ViewHolder) {
        // clear any swipe effects from [viewHolder]
        endRecoverAnimation(viewHolder, false)
        if (mPendingCleanup.remove(viewHolder.itemView)) {
            mCallback.clearView(mRecyclerView, viewHolder)
        }
        if (mOverdrawChild == viewHolder.itemView) {
            mOverdrawChild = null
            mOverdrawChildPosition = -1
        }
        viewHolder.itemView.requestLayout()
    }

    @Suppress("DEPRECATED_IDENTITY_EQUALS")
    @SuppressLint("VisibleForTests")
    internal override fun postDispatchSwipe(anim: RecoverAnimation, swipeDir: Int) {
        // wait until animations are complete.
        mRecyclerView.post(object : Runnable {
            override fun run() {
                if (mRecyclerView != null && mRecyclerView.isAttachedToWindow
                    && !anim.mOverridden
                    && (anim.mViewHolder.absoluteAdapterPosition !== RecyclerView.NO_POSITION)
                ) {
                    val animator = mRecyclerView.itemAnimator
                    // if animator is running or we have other active recover animations, we try
                    // not to call onSwiped because DefaultItemAnimator is not good at merging
                    // animations. Instead, we wait and batch.
                    if ((animator == null || !animator.isRunning(null))
                        && !hasRunningRecoverAnim()
                    ) {
                        mCallback.onSwiped(anim.mViewHolder, swipeDir)
                        if (withRecover) {
                            // recover swiped
                            recoverOnSwiped(anim.mViewHolder)
                        }
                    } else {
                        mRecyclerView.post(this)
                    }
                }
            }
        })
    }
}

Solution 9 - Android

Call notifyDataSetChanged on your adapter to make the swipe back work consistent

Solution 10 - Android

@Павел Карпычев solution is actually almost correct

the problem with notifyItemChanged is that it does additional animations and might overlap with the decorations from onDraw, so to do just a clean slide back, thats what you can do:

public class SimpleSwipeCallback extends ItemTouchHelper.SimpleCallback {

    boolean swipeOutEnabled = true;
    int swipeDir = 0;

    public SimpleSwipeCallback() {
        super(0, ItemTouchHelper.RIGHT | ItemTouchHelper.LEFT);
    }

    @Override
    public boolean onMove(@NonNull RecyclerView recyclerView, @NonNull RecyclerView.ViewHolder viewHolder, @NonNull RecyclerView.ViewHolder target) {
        return false;
    }

    @Override
    public void onSwiped(@NonNull RecyclerView.ViewHolder viewHolder, int swipeDir) {
        //Do action
    }

    @Override
    public void onChildDraw(Canvas c, RecyclerView recyclerView,
                            RecyclerView.ViewHolder viewHolder,
                            float dx, float dy, int actionState, boolean isCurrentlyActive) {

            //check if it should swipe out
            boolean shouldSwipeOut = //TODO;
            if (actionState == ItemTouchHelper.ACTION_STATE_SWIPE && (!shouldSwipeOut) {
                swipeOutEnabled = false;

                //Limit swipe
                int maxMovement = recyclerView.getWidth() / 3;

                //swipe right : left
                float sign = dx > 0 ? 1 : -1;

                float limitMovement = Math.min(maxMovement, sign * dx); // Only move to maxMovement

                float displacementPercentage = limitMovement / maxMovement;

                //limited threshold
                boolean swipeThreshold = displacementPercentage == 1;

                // Move slower when getting near the middle
                dx = sign * maxMovement * (float) Math.sin((Math.PI / 2) * displacementPercentage);

                if (isCurrentlyActive) {
                    int dir = dx > 0 ? ItemTouchHelper.RIGHT : ItemTouchHelper.LEFT;
                    swipeDir = swipeThreshold ? dir : 0;
                }
            } else {
                swipeOutEnabled = true;
            }

         //do decoration

        super.onChildDraw(c, recyclerView, viewHolder, dx, dy, actionState, isCurrentlyActive);
    }

    @Override
    public float getSwipeEscapeVelocity(float defaultValue) {
        return swipeOutEnabled ? defaultValue : Float.MAX_VALUE;
    }

    @Override
    public float getSwipeVelocityThreshold(float defaultValue) {
        return swipeOutEnabled ? defaultValue : 0;
    }

    @Override
    public float getSwipeThreshold(RecyclerView.ViewHolder viewHolder) {
        return swipeOutEnabled ? 0.6f : 1.0f;
    }

    @Override
    public void clearView(@NonNull RecyclerView recyclerView, @NonNull RecyclerView.ViewHolder viewHolder) {
        super.clearView(recyclerView, viewHolder);

        if (swipeDir != 0) {
            onSwiped(viewHolder, swipeDir);
            swipeDir = 0;
        }
    }
}

Note that this enables either a normal swipe ("swipeOut") or a limited swipe, depending on shouldSwipeOut

Attributions

All content for this solution is sourced from the original question on Stackoverflow.

The content on this page is licensed under the Attribution-ShareAlike 4.0 International (CC BY-SA 4.0) license.

Content TypeOriginal AuthorOriginal Content on Stackoverflow
QuestionkukabiView Question on Stackoverflow
Solution 1 - AndroidDariusLView Answer on Stackoverflow
Solution 2 - Androidjimmy0251View Answer on Stackoverflow
Solution 3 - AndroidkukabiView Answer on Stackoverflow
Solution 4 - AndroidJan BollackeView Answer on Stackoverflow
Solution 5 - AndroidjoecksView Answer on Stackoverflow
Solution 6 - AndroidCarl BView Answer on Stackoverflow
Solution 7 - AndroidПавел КарпычевView Answer on Stackoverflow
Solution 8 - AndroidkhobenView Answer on Stackoverflow
Solution 9 - AndroidJegannath KandasamyView Answer on Stackoverflow
Solution 10 - AndroidueenView Answer on Stackoverflow