Android RecyclerView ItemTouchHelper 恢复滑动并还原视图持有者

80

是否有一种方法可以在完成滑动并在ItemTouchHelper.Callback实例上调用onSwiped后,撤消滑动操作并将视图持有者恢复到其初始位置?我已经把RecyclerViewItemTouchHelperItemTouchHelper.Callback实例完美地结合起来了,我只需要恢复滑动操作,在某些情况下不要删除已滑动的项目。


你有这个问题的答案吗? - maveroid
@maveroid 是的,请看下面。 - kukabi
13个回答

140
在一些随机尝试后,我找到了解决方案。在适配器上调用notifyItemChanged方法。这将使滑出视图以动画方式返回其原始位置。

5
大部分情况下有效,但有时会出现无法正常工作的情况。 - jaga
3
可以,但我有一个问题。当我调用notifyItemChanged时,会在视图中产生一个“闪烁”。@DariusL,有没有办法跳过这个“闪烁”? - RoberV
6
我会为我执行淡出动画而不是平移动画。 - Saket
2
这可能是一个非常晚的回复。但我已经尝试了近两天来实现反弹动画。这是我目前找到的最佳解决方案。 - CodeAndWave
3
我非常不喜欢这个解决方案,因为它不能平稳地返回原来的位置,而是相当粗暴。 - Vucko
显示剩余9条评论

40

你应该重写 ItemTouchHelper.Callback 中的 onSwiped 方法,并刷新特定的项。

 @Override
 public void onSwiped(RecyclerView.ViewHolder viewHolder,
     int direction) {
     adapter.notifyItemChanged(viewHolder.getAdapterPosition());
 }

在抑制之前,我要求确认,并提供取消选项。经过搜索和尝试上述解决方案后,我并不是真正满意,因此我尝试了你的方法。然后我发现我在我的代码中确切地按照你所说的方式进行了参数化回调...问题在于我没有在“false”上调用回调方法以激活代码的这一部分。因此,你救了我。 - Feuby
我必须从一个Fragment方法中运行@jimmy0251的刷新建议,因为我的onSwiped()方法改变了背景颜色。效果很好! - AJW

29

谷歌的ItemTouchHelper实现假设每个被滑出去的项最终都将从回收视图中删除,但在某些应用程序中可能并非如此。

RecoverAnimationItemTouchHelper中的一个嵌套类,它管理被滑动/拖动项的触摸动画。尽管名称暗示它仅恢复项目的位置,但实际上它是唯一用于恢复(取消滑动/拖动)和替换(在滑动时移出或在拖动时替换)项目的类。命名有点奇怪。

RecoverAnimation中有一个名为mIsPendingCleanup的布尔属性,ItemTouchHelper使用它来确定该项是否等待删除。因此,在将RecoverAnimation附加到该项后,ItemTouchHelper在成功滑出后设置此属性,并且只要设置了此属性,该动画就不会从恢复动画列表中删除。问题在于,对于已滑出的项,mIsPendingCleanup始终设置,导致该项的RecoverAnimation永远不会从动画列表中删除。因此,即使您在成功滑动后恢复了该项的位置,只要触摸该项,它就会立即返回到已滑出的位置-因为RecoverAnimation将导致动画从最新的已滑出位置开始。

解决方案是将ItemTouchHelper类的源代码复制到与支持库相同的包中,并从RecoverAnimation类中删除mIsPendingCleanup属性。我不确定Google是否接受这个解决方案,我还没有将更新发布到Play商店,以查看是否会被拒绝,但你可以在https://gist.github.com/kukabi/f46e1c0503d2806acbe2上找到具有上述修复的支持库v22.2.1的类源代码。

请您能否提供一些关于这个解决方案的详细信息?我还没有理解它。 - Clxy
5
这个假设现在仍然有效吗? - Richard Lee
“...假设每个被滑动删除的项目最终都将从回收站中移除…”也许是100年前的事)) - ekashking
@ekashking 不完全正确。如果你查看回答的日期,大约是6年前。 - kukabi
1.2.1 版本仍未修复。 - initialjie

27

对于这个问题的一个笨拙的解决办法是通过两次调用ItemTouchHelper::attachToRecyclerView(RecyclerView)重新附加ItemTouchHelper,然后调用私有方法ItemTouchHelper::destroyCallbacks()destroyCallbacks()会删除项目装饰和所有侦听器,还会清除所有RecoverAnimations。

请注意,我们需要先调用itemTouchHelper.attachToRecyclerView(null)来欺骗ItemTouchHelper,让它认为第二次调用itemTouchHelper.attachToRecyclerView(recyclerView)是一个新的recycler view。

进一步了解详情,请查看ItemTouchHelper源代码这里

解决办法示例:

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)文档中:

如果TouchHelper已附加到RecyclerView,则它将首先从以前的RecyclerView中分离。您可以将此方法与null一起调用,以将其从当前RecyclerView中分离。

并在参数文档中:

要向其添加此帮助程序的RecyclerView实例,或者如果要从当前RecyclerView中删除ItemTouchHelper,则为null。

所以至少部分已经被记录下来了。


3
如果我没有结婚,我会和你结婚。谢谢,伙计,这是唯一对我有效的解决方案。 - andrea.rinaldi
3
你的解决方案使得行不能通过滑动移除,且没有任何动画效果。这种方法更加简便,避免了notifyItemChanged带来的样板代码,具体效果取决于你的代码实现方式,但不够美观。 - usernotnull
我同意RJFares的观点。它确实有效,而且在某些情况下,有一个解决方法总比没有好。然而,jimmy0251的解决方案非常完美,并且还带有动画效果。 - Feuby
谢谢。我正在使用DiffUtil来处理我的RecyclerView(以及一些LiveData/MVVM层),但notifyItemChanged或notifyDataSetChanged并没有起作用,尽管在更简单的设置中它表现得很好。这确实解决了问题,谢谢! - sbirksted

5

如果使用LiveDataListAdapter提供列表,调用notifyItemChanged不起作用。但是我发现一个丑陋的解决方法,涉及在onSwiped回调中重新附加ItemTouchHelper到recycler视图中

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 可以与列表适配器一起使用。您可能正在寻找以下代码:fun refreshListItem(item: ListItem) { val position = adapter.currentList.indexOf(item) adapter.notifyItemChanged(position) } - Carson Holzheimer

5

使用最新的AndroidX包仍然存在这个问题,所以我需要稍微调整@jimmy0251的解决方案来正确重置项目(他的解决方案只适用于第一次滑动)。

 override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
                clipAdapter.notifyItemChanged(viewHolder.adapterPosition)
                itemTouchHelper.startSwipe(viewHolder)
            }

请注意,startSwipe()会正确地重置该项的恢复动画。

1
五年后,这个问题终于得到了可行的答案。真是难以置信! - Eugene Kartoyev
3
无法工作,视图已恢复,但当我尝试在另一个元素中滑动时,它开始在重置的元素中滑动。 - Mateu
我与@Mateu遇到了完全相同的问题,使用这种解决方案后下一次滑动将会停留在错误的项目上。 - SiPe
正常工作,经过实时数据测试。 - Madhav

2

onSwiped从不调用,总是回滚

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

这里只有一个问题,即 onSwiped 实际上从未被调用。我们希望在发生部分滑动时注册事件。 - Bawender Yandra

1
解决方案基于JanPollacke的答案。问题在于,使用ListAdapter或手动使用DiffUtil时,通知项更改不起作用。而重置ItemTouchHelper看起来很糟糕,因为它没有动画。
所以这是我的最终解决方案,它将解决所有情况下的问题(使用或不使用diff util),并且如果您想允许在onSwiped事件中取消/撤消删除,则会给您带来美丽的反向动画。
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()
    }
}

持续时间应该是具体的,可以通过调用getAnimationDuration来计算。 - initialjie

1
由于大多数ItemTouchHelper成员都具有私有包访问修饰符,我们不想复制一个2000行的类来更改一行代码,因此让我们将我们的软件包指向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)
                    }
                }
            }
        })
    }
}

1
这将仅将视图重置到原点位置,没有任何动画效果。 - initialjie
@initialjie 在 recoverOnSwiped 中尝试使用 viewHolder 并应用任何你想要的动画。mCallback.clearView(mRecyclerView, viewHolder) 只是恢复了高度并将 translationX 和 translationY 重置为 0 -- 参见来源 - khoben

0

@Павел Карпычев 的解决方案实际上几乎是正确的

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

shouldSwipeOut你没有在任何地方声明... - ekashking
根据您的项目,如果您永远不想将其滑出,则将其设置为false;或者您可以通过适配器(通过recyclerView)和位置(通过viewHolder)执行一些逻辑。 - ueen

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接