Android RecyclerView ItemTouchHelper 恢复滑动和恢复视图持有者
Android RecyclerView ItemTouchHelper revert swipe and restore view holder
有没有办法在完成滑动并在ItemTouchHelper.Callback
实例?我让 RecyclerView
、ItemTouchHelper
和 ItemTouchHelper.Callback
实例完美地协同工作,我只需要恢复滑动操作并且 不 删除滑动的项目在某些情况下。
Google 的 ItemTouchHelper
实现假定 每个 刷出的项目最终都会从回收站视图中删除,但事实可能并非如此在某些应用程序中。
RecoverAnimation
是 ItemTouchHelper
中的嵌套 class,用于管理 swiped/dragged 项的触摸动画。虽然顾名思义只是恢复项的位置,但实际上只有class用来恢复(取消swipe/drag)和 替换(滑动时移出或拖动时替换)项目。奇怪的命名。
RecoverAnimation
中有一个名为 mIsPendingCleanup
的布尔值 属性,ItemTouchHelper
使用它来确定项目是否待删除。因此 ItemTouchHelper
,在将 RecoverAnimation
附加到项目后,在成功滑出后设置此 属性,并且只要此动画不会从恢复动画列表中删除 属性 已设置。问题是,mIsPendingCleanup
将始终为滑出的项目设置,导致该项目的 RecoverAnimation
永远不会从动画列表中删除。因此,即使您在成功滑动后恢复了项目的位置,它也会在您触摸它时立即发送回滑动出的位置 - 因为 RecoverAnimation 会导致动画从最新滑动出的位置开始。
不幸的是,解决方法是将 ItemTouchHelper
class 源代码复制到与支持库中相同的包中,并删除 mIsPendingCleanup
属性来自 RecoverAnimation
class。我不确定 Google 是否可以接受,我还没有将更新发布到 Play Store 以查看它是否会导致拒绝,但您可以从中找到 class 源代码支持库 v22.2.1 并在 https://gist.github.com/kukabi/f46e1c0503d2806acbe2.
进行上述修复
经过一番摸索,我找到了解决办法。在您的适配器上调用 notifyItemChanged
。这将使滑出的视图动画回到其原始位置。
此问题的肮脏解决方法解决方案是通过调用 ItemTouchHelper::attachToRecyclerView(RecyclerView)
两次重新附加 ItemTouchHelper,然后调用私有方法 ItemTouchHelper::destroyCallbacks()
。 destroyCallbacks()
删除项目装饰和所有侦听器,但也清除所有 RecoverAnimations。
请注意,我们需要先调用 itemTouchHelper.attachToRecyclerView(null)
来欺骗 ItemTouchHelper
认为第二次调用 itemTouchHelper.attachToRecyclerView(recyclerView)
是一个新的回收器视图。
有关详细信息,请查看 ItemTouchHelper
here.
的源代码
解决方法示例:
RecyclerView recyclerView = findViewById(R.id.recycler_view);
ItemTouchHelper itemTouchHelper = new ItemTouchHelper(callback);
...
// Workaround to reset swiped out views
itemTouchHelper.attachToRecyclerView(null);
itemTouchHelper.attachToRecyclerView(recyclerView);
将其视为一种肮脏的解决方法,因为此方法使用了 ItemTouchHelper
.
的内部未记录的实现细节
更新:
来自ItemTouchHelper::attachToRecyclerView(RecyclerView)
的documentation:
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.
在参数文档中:
The RecyclerView instance to which you want to add this helper or null if you want to remove ItemTouchHelper from the current RecyclerView.
所以至少它是部分记录的。
在您的适配器上调用 notifyDataSetChanged 以使向后滑动工作一致
您应该覆盖 ItemTouchHelper.Callback
中的 onSwiped
方法并刷新该特定项目。
@Override
public void onSwiped(RecyclerView.ViewHolder viewHolder,
int direction) {
adapter.notifyItemChanged(viewHolder.getAdapterPosition());
}
在使用 LiveData
向 ListAdapter
提供列表的情况下,调用 notifyItemChanged
不起作用。但是,我发现了一个错误的解决方法,它涉及将 ItemTouchHelper
重新附加到 onSwiped
回调中的回收器视图
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)
使用最新的 anndroidX 包我仍然有这个问题,所以我需要稍微调整@jimmy0251 解决方案以正确重置项目(他的解决方案只适用于第一次滑动)。
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
clipAdapter.notifyItemChanged(viewHolder.adapterPosition)
itemTouchHelper.startSwipe(viewHolder)
}
请注意,startSwipe()
会正确重置项目的恢复动画。
onSwiped 从不调用,总是还原
override fun getSwipeThreshold(viewHolder: RecyclerView.ViewHolder): Float {
return 1f
}
override fun getSwipeEscapeVelocity(defaultValue: Float): Float {
return Float.MAX_VALUE
}
@Павел Карпычев 解决方案实际上几乎是正确的
notifyItemChanged
的问题在于它会执行额外的动画,并且可能会与 onDraw
中的装饰重叠,因此只需向后滑动干净,这就是您可以做的:
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;
}
}
}
请注意,这会启用正常滑动(“swipeOut”)或有限滑动,具体取决于 shouldSwipeOut
由于大多数 ItemTouchHelper
成员都有 private-package 访问修饰符,我们不想复制 2000 行 class 只是为了更改一行,让我们指出我们的打包为 androidx.recyclerview.widget
.
当滑动发生时(mCallback.onSwiped
),我们可以恢复滑动视图的初始状态。
mCallback.onSwiped
仅从 postDispatchSwipe
方法中调用,因此之后我们注入我们的视图恢复 (recoverOnSwiped
),它会清除滑动视图中的任何滑动效果和动画。
@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)
}
}
}
})
}
}
有没有办法在完成滑动并在ItemTouchHelper.Callback
实例?我让 RecyclerView
、ItemTouchHelper
和 ItemTouchHelper.Callback
实例完美地协同工作,我只需要恢复滑动操作并且 不 删除滑动的项目在某些情况下。
Google 的 ItemTouchHelper
实现假定 每个 刷出的项目最终都会从回收站视图中删除,但事实可能并非如此在某些应用程序中。
RecoverAnimation
是 ItemTouchHelper
中的嵌套 class,用于管理 swiped/dragged 项的触摸动画。虽然顾名思义只是恢复项的位置,但实际上只有class用来恢复(取消swipe/drag)和 替换(滑动时移出或拖动时替换)项目。奇怪的命名。
RecoverAnimation
中有一个名为 mIsPendingCleanup
的布尔值 属性,ItemTouchHelper
使用它来确定项目是否待删除。因此 ItemTouchHelper
,在将 RecoverAnimation
附加到项目后,在成功滑出后设置此 属性,并且只要此动画不会从恢复动画列表中删除 属性 已设置。问题是,mIsPendingCleanup
将始终为滑出的项目设置,导致该项目的 RecoverAnimation
永远不会从动画列表中删除。因此,即使您在成功滑动后恢复了项目的位置,它也会在您触摸它时立即发送回滑动出的位置 - 因为 RecoverAnimation 会导致动画从最新滑动出的位置开始。
不幸的是,解决方法是将 ItemTouchHelper
class 源代码复制到与支持库中相同的包中,并删除 mIsPendingCleanup
属性来自 RecoverAnimation
class。我不确定 Google 是否可以接受,我还没有将更新发布到 Play Store 以查看它是否会导致拒绝,但您可以从中找到 class 源代码支持库 v22.2.1 并在 https://gist.github.com/kukabi/f46e1c0503d2806acbe2.
经过一番摸索,我找到了解决办法。在您的适配器上调用 notifyItemChanged
。这将使滑出的视图动画回到其原始位置。
此问题的肮脏解决方法解决方案是通过调用 ItemTouchHelper::attachToRecyclerView(RecyclerView)
两次重新附加 ItemTouchHelper,然后调用私有方法 ItemTouchHelper::destroyCallbacks()
。 destroyCallbacks()
删除项目装饰和所有侦听器,但也清除所有 RecoverAnimations。
请注意,我们需要先调用 itemTouchHelper.attachToRecyclerView(null)
来欺骗 ItemTouchHelper
认为第二次调用 itemTouchHelper.attachToRecyclerView(recyclerView)
是一个新的回收器视图。
有关详细信息,请查看 ItemTouchHelper
here.
解决方法示例:
RecyclerView recyclerView = findViewById(R.id.recycler_view);
ItemTouchHelper itemTouchHelper = new ItemTouchHelper(callback);
...
// Workaround to reset swiped out views
itemTouchHelper.attachToRecyclerView(null);
itemTouchHelper.attachToRecyclerView(recyclerView);
将其视为一种肮脏的解决方法,因为此方法使用了 ItemTouchHelper
.
更新:
来自ItemTouchHelper::attachToRecyclerView(RecyclerView)
的documentation:
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.
在参数文档中:
The RecyclerView instance to which you want to add this helper or null if you want to remove ItemTouchHelper from the current RecyclerView.
所以至少它是部分记录的。
在您的适配器上调用 notifyDataSetChanged 以使向后滑动工作一致
您应该覆盖 ItemTouchHelper.Callback
中的 onSwiped
方法并刷新该特定项目。
@Override
public void onSwiped(RecyclerView.ViewHolder viewHolder,
int direction) {
adapter.notifyItemChanged(viewHolder.getAdapterPosition());
}
在使用 LiveData
向 ListAdapter
提供列表的情况下,调用 notifyItemChanged
不起作用。但是,我发现了一个错误的解决方法,它涉及将 ItemTouchHelper
重新附加到 onSwiped
回调中的回收器视图
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)
使用最新的 anndroidX 包我仍然有这个问题,所以我需要稍微调整@jimmy0251 解决方案以正确重置项目(他的解决方案只适用于第一次滑动)。
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
clipAdapter.notifyItemChanged(viewHolder.adapterPosition)
itemTouchHelper.startSwipe(viewHolder)
}
请注意,startSwipe()
会正确重置项目的恢复动画。
onSwiped 从不调用,总是还原
override fun getSwipeThreshold(viewHolder: RecyclerView.ViewHolder): Float {
return 1f
}
override fun getSwipeEscapeVelocity(defaultValue: Float): Float {
return Float.MAX_VALUE
}
@Павел Карпычев 解决方案实际上几乎是正确的
notifyItemChanged
的问题在于它会执行额外的动画,并且可能会与 onDraw
中的装饰重叠,因此只需向后滑动干净,这就是您可以做的:
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;
}
}
}
请注意,这会启用正常滑动(“swipeOut”)或有限滑动,具体取决于 shouldSwipeOut
由于大多数 ItemTouchHelper
成员都有 private-package 访问修饰符,我们不想复制 2000 行 class 只是为了更改一行,让我们指出我们的打包为 androidx.recyclerview.widget
.
当滑动发生时(mCallback.onSwiped
),我们可以恢复滑动视图的初始状态。
mCallback.onSwiped
仅从 postDispatchSwipe
方法中调用,因此之后我们注入我们的视图恢复 (recoverOnSwiped
),它会清除滑动视图中的任何滑动效果和动画。
@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)
}
}
}
})
}
}