79

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.

kukabi
  • 1,273
  • 1
  • 9
  • 14

12 Answers12

140

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.

DariusL
  • 4,007
  • 5
  • 32
  • 44
  • 5
    Works most of the times, but there are times where this is not working. – jaga Nov 12 '15 at 08:41
  • 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. – jaga Nov 19 '15 at 10:58
  • Can you reproduce this? I might switch to a different solution if this causes problesms. – DariusL Mar 02 '16 at 14:14
  • I can't reproduce these errors--it seems to work perfectly. – SMBiggs Jul 20 '16 at 05:51
  • it's working,@jaga will please provide the case where this fail? – Gopal Oct 24 '16 at 09:14
  • 3
    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" @DariusL ? – RoberV Nov 15 '16 at 08:27
  • @RoberV 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 `TextView`s. The flash could be caused by your view being rebound, maybe an image load. – DariusL Dec 09 '16 at 06:38
  • 6
    This plays a fade animation for me instead of a translate animation. – Saket May 26 '17 at 02:54
  • 2
    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. – CodeAndWave Jun 27 '17 at 08:06
  • 1
    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). – Johann Jul 01 '19 at 11:19
  • @AndroidDev https://stackoverflow.com/a/57622476/2546973 worked for me when using LiveData – Carl B Aug 23 '19 at 08:32
  • You saved me twice... Thanks – Deepak Kumar Sep 04 '19 at 11:46
  • It only works for live data when there is only one item in the list. – Suri Oct 15 '19 at 03:08
  • 3
    I really dislike this solution because it does not return to it's position smoothly, but rather violently... – Vucko Apr 07 '20 at 19:04
39

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());
 }
jimmy0251
  • 16,293
  • 10
  • 36
  • 39
  • 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. – Feuby Aug 24 '18 at 19:06
  • I had to run @jimmy0251's refresh recoomendation above from a Fragment method because my onSwiped() changes the background color. Worked like a charm! – AJW Jun 02 '19 at 21:40
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.

kukabi
  • 1,273
  • 1
  • 9
  • 14
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.

Community
  • 1
  • 1
Jan Bollacke
  • 279
  • 3
  • 4
  • 3
    If I wasn't married, I would marry you. Thanks man, this is the only solution which is working for me. – andrea.rinaldi Oct 11 '16 at 14:51
  • 2
    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. – usernotnull Jul 17 '17 at 19:18
  • 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 animations – Feuby Aug 24 '18 at 19:04
  • 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! – sbirksted Jul 23 '20 at 01:07
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)

Carl B
  • 368
  • 3
  • 7
  • 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) }` – Carson Holzheimer Dec 18 '19 at 23:16
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.

joecks
  • 4,539
  • 37
  • 48
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
}
  • 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. – Bawender Yandra Jul 11 '21 at 12:28
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)
                    }
                }
            }
        })
    }
}
khoben
  • 108
  • 1
  • 8
  • This will just reset the view to the origin place without any animation. – initialjie Jan 02 '23 at 06:58
  • @initialjie 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 sources](https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:recyclerview/recyclerview/src/main/java/androidx/recyclerview/widget/ItemTouchUIUtilImpl.java;l=89;drc=b0204e3f351b48665740578ff7aa610ac5c81447) – khoben Jan 03 '23 at 11:00
0

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

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

ueen
  • 692
  • 3
  • 9
  • 21
  • `shouldSwipeOut` ????? You havn't declare it anywhere... – ekashking Jul 02 '21 at 07:07
  • 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) – ueen Jul 03 '21 at 08:51
0

Call notifyItemChanged on adapter works for me.

See https://stackoverflow.com/a/32159154/8820118 for more informations.

nosTa
  • 607
  • 6
  • 16
younes
  • 1
  • 3
0

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()
    }
}
prom85
  • 16,896
  • 17
  • 122
  • 242