kukabi
kukabi

Reputation: 1303

Android RecyclerView ItemTouchHelper revert swipe and restore view holder

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.

Upvotes: 81

Views: 41366

Answers (13)

S01ds
S01ds

Reputation: 370

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);

Upvotes: 0

prom85
prom85

Reputation: 17838

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()
    }
}

Upvotes: 1

younes
younes

Reputation: 1

Call notifyItemChanged on adapter works for me.

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

Upvotes: 0

khoben
khoben

Reputation: 108

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)
                    }
                }
            }
        })
    }
}

Upvotes: 1

ueen
ueen

Reputation: 692

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

Upvotes: 0

onSwiped never call, always revert

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

Upvotes: 2

joecks
joecks

Reputation: 4637

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.

Upvotes: 5

Carl B
Carl B

Reputation: 368

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)

Upvotes: 5

jimmy0251
jimmy0251

Reputation: 16463

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());
 }

Upvotes: 40

Jegannath Kandasamy
Jegannath Kandasamy

Reputation: 9

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

Upvotes: 0

Jan Bollacke
Jan Bollacke

Reputation: 279

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.

Upvotes: 27

DariusL
DariusL

Reputation: 4077

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.

Upvotes: 143

kukabi
kukabi

Reputation: 1303

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.

Upvotes: 29

Related Questions