Android RecyclerView ItemTouchHelper revert swipe and restore view holder
Asked Answered
S

13

80

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.

Scavenge answered 3/8, 2015 at 12:28 Comment(2)
Have you got answer for this?Perfectionism
@Perfectionism yes, please see below.Scavenge
H
142

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.

His answered 22/8, 2015 at 17:59 Comment(14)
Works most of the times, but there are times where this is not working.Statism
Though this solution seems to work. After making few swipe deletes, if you scroll the list and comeback to same position, you might see few list elements missing. For me @Kustal Kaan Bilgin 's solution works better than this.Statism
Can you reproduce this? I might switch to a different solution if this causes problesms.His
I can't reproduce these errors--it seems to work perfectly.Rubel
it's working,@Statism will please provide the case where this fail?Disfigurement
It works, but I have a problem. When I call notifyItemChanged it produces a "flash" in the view. There is a way to skip this "flash" @His ?Blok
@Blok How complicated is your view? Try this with something basic, maybe even a static view with an empty onBindViewHolder. In my vase it worked fine and the view would simply slide back into position. My view was a pretty simple FrameLayout with a few TextViews. The flash could be caused by your view being rebound, maybe an image load.His
This plays a fade animation for me instead of a translate animation.Croquette
This might be a really late reply. But Ive been trying to do the bounce back animation for almost 2 days. This is the best solution I found so far.Crackbrained
This doesn't work if the adapter is bound to LiveData. I found that calling notifyDataSetChanged() to cancel the swipe works but you won't get the animation of the item sliding back into place. Calling notifyDataSetChanged() also does not cause the recyclerview to lose its currently scrolled position (as it normally would if it were not bound using LiveData).Matronize
@AndroidDev https://mcmap.net/q/259013/-android-recyclerview-itemtouchhelper-revert-swipe-and-restore-view-holder worked for me when using LiveDataPhotothermic
You saved me twice... ThanksBrominate
It only works for live data when there is only one item in the list.Euphrasy
I really dislike this solution because it does not return to it's position smoothly, but rather violently...Xenomorphic
S
40

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());
 }
Sapajou answered 2/1, 2018 at 8:34 Comment(2)
I was asking a confirmation before suppress, with cancel possibility. After searching and trying above solutions, it was not really satisfied, thus i tried yours. Then I saw i did exactly what you said, in my code, as a parameterized callback... And the problem was that i was NOT calling the callback method on "false" to activate this part of code. Thus, you saved me.Utility
I had to run @jimmy0251's refresh recoomendation above from a Fragment method because my onSwiped() changes the background color. Worked like a charm!Arkhangelsk
S
29

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.

Scavenge answered 7/8, 2015 at 7:51 Comment(5)
Would you please give some details of this solution.Can't get it yetBivens
It´s assumption is valid even nowadays?Viviennevivify
" ... assumes that every swiped out item will eventually get removed from the recycler view.." maybe it was 100 years ago ))Farris
@Farris not quite. Approximately 6 years ago if you check the date of the answer.Scavenge
1.2.1 version still not fixedGerius
M
27

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.

Mertz answered 20/5, 2016 at 8:57 Comment(4)
If I wasn't married, I would marry you. Thanks man, this is the only solution which is working for me.Akkerman
Your solution makes the row un-swipe without any animation. It is easier and avoids some boilerplate codes which come with notifyItemChanged depending on your code, but it's not visually appealing.Lowercase
I agree with RJFares. It works, and in some cases, it's better to have a workaround than nothing at all. However, jimmy0251 solution works like a charm, AND with animationsUtility
Thanks. I'm using DiffUtil for a my recyclerview, (as well as some LiveData/MVVM layering), and notifyItemChanged OR notifyDataSetChanged did not work, although it did work fine in a more simple setting. This certainly does the trick, thanks!Brewing
P
5

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)

Photothermic answered 23/8, 2019 at 8:31 Comment(1)
calling notifyItemChanged does work with a list adapter. You might be looking for this code: fun refreshListItem(item: ListItem) { val position = adapter.currentList.indexOf(item) adapter.notifyItemChanged(position) }Delilahdelimit
I
5

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.

Invoke answered 29/2, 2020 at 17:54 Comment(4)
The working answer was give 5 years after the question was posted. Unbelievable! 🙂Sliver
Not working, The view is restored but when I try to swipe in another element, is starts swiping in the reseted one.Rattletrap
I have exactly the same issue as @Rattletrap with this solution. The next swipe will end up on the wrong item.Signpost
Works properly tested with live data.Meetly
D
2

onSwiped never call, always revert

override fun getSwipeThreshold(viewHolder: RecyclerView.ViewHolder): Float {
    return 1f
}
override fun getSwipeEscapeVelocity(defaultValue: Float): Float {
    return Float.MAX_VALUE
}
Danndanna answered 19/5, 2020 at 8:7 Comment(1)
There is just one problem with this that onSwiped is never actually called. We do wanna register an event when a partial swipe has happend.Hendry
P
1

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)
                    }
                }
            }
        })
    }
}
Paulettepauley answered 17/2, 2022 at 6:29 Comment(2)
This will just reset the view to the origin place without any animation.Gerius
@Gerius just play around with viewHolder inside recoverOnSwiped and apply whatever animation you want. mCallback.clearView(mRecyclerView, viewHolder) simply restores elevation and resets translationX and translationY to 0 -- see sourcesPaulettepauley
C
1

Solution is based on JanPollacke's answer. The problem is, that notifying an item change does not work with ListAdapter or when using DiffUtil manually. And resetting the ItemTouchHelper does look bad, because it has no animation.

So here's my final solution, it will solve the problem in all cases (with or without diff util usage) and gives you a beautiful reverse animation if you want to allow to cancel/undo a delete inside the onSwiped event.

override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
    val allowDelete = false // or show a dialog and ask for confirmation or whatever logic you need

    if (allowDelete) {
        adapter.remove(viewHolder.bindingAdapterPosition)
    } else {
        // start the inverse animation and reset the internal swipe state AFTERWARDS
        viewHolder.itemView
            .animate()
            .translationX(0f)
            .withEndAction {
                itemTouchHelper.attachToRecyclerView(null)
                itemTouchHelper.attachToRecyclerView(recyclerView)
            }
             .start()
    }
}
Coincidence answered 7/10, 2022 at 17:22 Comment(1)
Duration should be specific, it can be calculate by calling getAnimationDurationGerius
B
0

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

Birecree answered 19/12, 2017 at 9:14 Comment(0)
F
0

@Павел Карпычев 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

Fudge answered 20/6, 2021 at 14:24 Comment(2)
shouldSwipeOut ????? You havn't declare it anywhere...Farris
Depends on your item, either you set it to false if you never want it to be swiped out or you do some logic (through the recyclerView you can get the adapter and through the viewHolder the position)Fudge
T
0

Call notifyItemChanged on adapter works for me.

See https://mcmap.net/q/259013/-android-recyclerview-itemtouchhelper-revert-swipe-and-restore-view-holder for more informations.

Tessellated answered 28/8, 2022 at 8:30 Comment(0)
M
0

Unfortunately notifyItemChanged didn't work for me (could be a bug in RecyclerView?), so I tried using notifyItemRemoved followed by notifyItemInserted, which did work but was breaking the animation, so after digging more into the RecyclerView and ItemTouchHelper classes, I found a solution that works by simulating a touch event on the swiped item, to revert the swipe action with the intended animation:

View itemView = recyclerView.findViewHolderForAdapterPosition(position).itemView;
float x = itemView.getX() + itemView.getWidth() / 2f;
float y = itemView.getY() + itemView.getHeight() / 2f;
long evtTime = System.currentTimeMillis();
MotionEvent motionEventDown = MotionEvent.obtain(evtTime, evtTime, MotionEvent.ACTION_DOWN, x, y, 0);
MotionEvent motionEventUp = MotionEvent.obtain(evtTime, evtTime, MotionEvent.ACTION_UP, x, y, 0);
recyclerView.onInterceptTouchEvent(motionEventDown);
recyclerView.onTouchEvent(motionEventUp);
Metempsychosis answered 12/9, 2023 at 21:48 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.