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.
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.
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. –
His 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());
}
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.
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.
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)
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 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.
onSwiped never call, always revert
override fun getSwipeThreshold(viewHolder: RecyclerView.ViewHolder): Float {
return 1f
}
override fun getSwipeEscapeVelocity(defaultValue: Float): Float {
return Float.MAX_VALUE
}
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)
}
}
}
})
}
}
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 –
Paulettepauley 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()
}
}
Call notifyDataSetChanged on your adapter to make the swipe back work consistent
@Павел Карпычев 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
shouldSwipeOut
????? You havn't declare it anywhere... –
Farris 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.
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);
© 2022 - 2024 — McMap. All rights reserved.