如何在RecyclerView的排序(notifyDataSetChanged)期间提供自定义动画

22

目前,在使用默认的动画程序android.support.v7.widget.DefaultItemAnimator时,这是我在排序期间得到的结果。

DefaultItemAnimator动画视频: https://youtu.be/EccI7RUcdbg

public void sortAndNotifyDataSetChanged() {
    int i0 = 0;
    int i1 = models.size() - 1;

    while (i0 < i1) {
        DemoModel o0 = models.get(i0);
        DemoModel o1 = models.get(i1);

        models.set(i0, o1);
        models.set(i1, o0);

        i0++;
        i1--;

        //break;
    }

    // adapter is created via adapter = new RecyclerViewDemoAdapter(models, mRecyclerView, this);
    adapter.notifyDataSetChanged();
}

然而,在排序时,我不想使用默认的动画(notifyDataSetChanged),而是提供以下自定义动画。旧项目将通过右侧滑出,新项目将向上滑动。

期望的动画视频:https://youtu.be/9aQTyM7K4B0

没有使用RecyclerView如何实现这样的动画效果

几年前,我使用LinearLayout+View实现了这种效果,因为当时我们还没有RecyclerView

以下是设置动画的方法

PropertyValuesHolder alpha = PropertyValuesHolder.ofFloat("alpha", 1.0f, 0f);
PropertyValuesHolder translationX = PropertyValuesHolder.ofFloat("translationX", 0f, (float) width);
ObjectAnimator animOut = ObjectAnimator.ofPropertyValuesHolder(this, alpha, translationX);

animOut.setDuration(duration);
animOut.setInterpolator(accelerateInterpolator);
animOut.addListener(new AnimatorListenerAdapter() {
    public void onAnimationEnd(Animator anim) {
        final View view = (View) ((ObjectAnimator) anim).getTarget();

        Message message = (Message)view.getTag(R.id.TAG_MESSAGE_ID);
        if (message == null) {
            return;
        }

        view.setAlpha(0f);
        view.setTranslationX(0);
        NewsListFragment.this.refreshUI(view, message);
        final Animation animation = AnimationUtils.loadAnimation(NewsListFragment.this.getActivity(),
            R.anim.slide_up);
        animation.setAnimationListener(new Animation.AnimationListener() {
            @Override
            public void onAnimationStart(Animation animation) {

            }

            @Override
            public void onAnimationEnd(Animation animation) {
                view.setVisibility(View.VISIBLE);
                view.setTag(R.id.TAG_MESSAGE_ID, null);
            }

            @Override
            public void onAnimationRepeat(Animation animation) {

            }
        });
        view.startAnimation(animation);
    }
});

layoutTransition.setAnimator(LayoutTransition.DISAPPEARING, animOut);

this.nowLinearLayout.setLayoutTransition(layoutTransition);

而这就是动画如何被触发的方式。

// messageView is view being added earlier in nowLinearLayout
for (int i = 0, ei = messageViews.size(); i < ei; i++) {
    View messageView = messageViews.get(i);
    messageView.setTag(R.id.TAG_MESSAGE_ID, messages.get(i));
    messageView.setVisibility(View.INVISIBLE);
}

我在想,如何在RecylerView中实现同样的效果?


1
你想在没有或者有 RecyclerView 的情况下实现这个动画吗? - Andreas Wenger
我想使用 RecyclerView 实现这个动画。 - Cheok Yan Cheng
项目是否应该总是向右滑动,即使它们之后仍然存在?还是只有不再可见的项目才会滑出? - Andreas Wenger
所有项目始终存在,唯一的变化是它们在排序后的顺序。 - Cheok Yan Cheng
得到你想要的东西非常困难。你必须创建自己的 LayoutManager,重写 onLayoutChildren 并从 supportsPredictiveItemAnimations() 返回 true。因此,在调用 notifyDataSetChanged 时,您必须正确地布置 recyclerView 的子项以进行动画。 - user5703518
2个回答

10

首先:

  • 此解决方案假定在数据集更改后仍可见的项目也会向右滑动,然后再从底部滑入(至少这是我理解您所要求的)。
  • 由于这个要求,我找不到一个简单而好的解决方案(至少在第一次迭代期间)。我找到的唯一方法是欺骗适配器-并与框架斗争,使其执行不打算执行的操作。这就是为什么第一部分(正常工作方式)描述了如何使用RecyclerView默认方式实现漂亮的动画效果。第二部分描述了解决方案,即如何在数据集更改后强制所有项目执行滑出/滑入动画。
  • 后来我找到了一个更好的解决方案,它不需要用随机ID欺骗适配器(请跳转到更新版本的底部)。

正常工作方式

要启用动画,您需要告诉RecyclerView数据集已更改(以便它知道应该运行哪种动画)。这可以通过两种方式完成:

1) Simple Version: 我们需要设置 adapter.setHasStableIds(true); 并通过 Adapter 中的 public long getItemId(int position) 方法提供项目的 id 给 RecyclerViewRecyclerView 利用这些 id 来确定在调用 adapter.notifyDataSetChanged(); 时哪些项目已经被移除、添加或移动。

2) Advanced Version: 而不是调用 adapter.notifyDataSetChanged();,您也可以明确说明数据集的更改方式。 Adapter 提供了多个方法,如 adapter.notifyItemChanged(int position)adapter.notifyItemInserted(int position) 等来描述数据集中的更改。

触发反映数据集更改的动画由 ItemAnimator 管理。 RecyclerView 已经配备了一个很好的默认 DefaultItemAnimator。此外,还可以使用自定义的 ItemAnimator 定义自定义动画行为。

实现滑出(向右),滑入(向下)的策略

向右滑动是应该播放的动画,如果数据集中的项目被删除。从底部滑动的动画应该播放给添加到数据集中的项目。正如在开头提到的,我假设所有元素都希望向右滑出并从底部滑入,即使它们在数据集更改之前和之后可见。通常情况下,RecyclerView会为保持可见的项目播放更改/移动动画。然而,因为我们想利用删除/添加动画来处理所有项目,所以我们需要欺骗适配器,让它认为在更改后只有新元素存在,所有先前可用的项目都已被删除。这可以通过为适配器中的每个项目提供随机ID来实现:

@Override
public long getItemId(int position) {
    return Math.round(Math.random() * Long.MAX_VALUE);
}

现在我们需要提供一个自定义的ItemAnimator,以管理添加/删除项目的动画。所呈现的SlidingAnimator的结构非常类似于RecyclerView提供的android.support.v7.widget.DefaultItemAnimator。同时注意这是一个概念的证明,在任何应用程序中使用之前都应进行调整:

public class SlidingAnimator extends SimpleItemAnimator {
    List<RecyclerView.ViewHolder> pendingAdditions = new ArrayList<>();
    List<RecyclerView.ViewHolder> pendingRemovals = new ArrayList<>();

    @Override
    public void runPendingAnimations() {
        final List<RecyclerView.ViewHolder> additionsTmp = pendingAdditions;
        List<RecyclerView.ViewHolder> removalsTmp = pendingRemovals;
        pendingAdditions = new ArrayList<>();
        pendingRemovals = new ArrayList<>();

        for (RecyclerView.ViewHolder removal : removalsTmp) {
            // run the pending remove animation
            animateRemoveImpl(removal);
        }
        removalsTmp.clear();

        if (!additionsTmp.isEmpty()) {
            Runnable adder = new Runnable() {
                public void run() {
                    for (RecyclerView.ViewHolder addition : additionsTmp) {
                        // run the pending add animation
                        animateAddImpl(addition);
                    }
                    additionsTmp.clear();
                }
            };
            // play the add animation after the remove animation finished
            ViewCompat.postOnAnimationDelayed(additionsTmp.get(0).itemView, adder, getRemoveDuration());
        }
    }

    @Override
    public boolean animateAdd(RecyclerView.ViewHolder holder) {
        pendingAdditions.add(holder);
        // translate the new items vertically so that they later slide in from the bottom
        holder.itemView.setTranslationY(300);
        // also make them invisible
        holder.itemView.setAlpha(0);
        // this requests the execution of runPendingAnimations()
        return true;
    }

    @Override
    public boolean animateRemove(final RecyclerView.ViewHolder holder) {
        pendingRemovals.add(holder);
        // this requests the execution of runPendingAnimations()
        return true;
    }

    private void animateAddImpl(final RecyclerView.ViewHolder holder) {
        View view = holder.itemView;
        final ViewPropertyAnimatorCompat anim = ViewCompat.animate(view);
        anim
                // undo the translation we applied in animateAdd
                .translationY(0)
                // undo the alpha we applied in animateAdd
                .alpha(1)
                .setDuration(getAddDuration())
                .setInterpolator(new DecelerateInterpolator())
                .setListener(new ViewPropertyAnimatorListener() {
                    @Override
                    public void onAnimationStart(View view) {
                        dispatchAddStarting(holder);
                    }

                    @Override
                    public void onAnimationEnd(View view) {
                        anim.setListener(null);
                        dispatchAddFinished(holder);
                        // cleanup
                        view.setTranslationY(0);
                        view.setAlpha(1);
                    }

                    @Override
                    public void onAnimationCancel(View view) {
                    }
                }).start();
    }

    private void animateRemoveImpl(final RecyclerView.ViewHolder holder) {
        View view = holder.itemView;
        final ViewPropertyAnimatorCompat anim = ViewCompat.animate(view);
        anim
                // translate horizontally to provide slide out to right
                .translationX(view.getWidth())
                // fade out
                .alpha(0)
                .setDuration(getRemoveDuration())
                .setInterpolator(new AccelerateInterpolator())
                .setListener(new ViewPropertyAnimatorListener() {
                    @Override
                    public void onAnimationStart(View view) {
                        dispatchRemoveStarting(holder);
                    }

                    @Override
                    public void onAnimationEnd(View view) {
                        anim.setListener(null);
                        dispatchRemoveFinished(holder);
                        // cleanup
                        view.setTranslationX(0);
                        view.setAlpha(1);
                    }

                    @Override
                    public void onAnimationCancel(View view) {
                    }
                }).start();
    }


    @Override
    public boolean animateMove(RecyclerView.ViewHolder holder, int fromX, int fromY, int toX, int toY) {
        // don't handle animateMove because there should only be add/remove animations
        dispatchMoveFinished(holder);
        return false;
    }
    @Override
    public boolean animateChange(RecyclerView.ViewHolder oldHolder, RecyclerView.ViewHolder newHolder, int fromLeft, int fromTop, int toLeft, int toTop) {
        // don't handle animateChange because there should only be add/remove animations
        if (newHolder != null) {
            dispatchChangeFinished(newHolder, false);
        }
        dispatchChangeFinished(oldHolder, true);
        return false;
    }
    @Override
    public void endAnimation(RecyclerView.ViewHolder item) { }
    @Override
    public void endAnimations() { }
    @Override
    public boolean isRunning() { return false; }
}

这是最终结果:

enter image description here

更新:再次阅读文章后,我找到了更好的解决方案

这个更新的解决方案不需要通过使用随机ID来欺骗适配器,让其认为所有项目都已被删除,只有新项目被添加。如果我们应用2)高级版本 - 如何通知适配器数据集更改,我们只需告诉adapter所有先前的项目都已被删除,并添加了所有新项目:

int oldSize = oldItems.size();
oldItems.clear();
// Notify the adapter all previous items were removed
notifyItemRangeRemoved(0, oldSize);

oldItems.addAll(items);
// Notify the adapter all the new items were added
notifyItemRangeInserted(0, items.size());

// don't call notifyDataSetChanged
//notifyDataSetChanged();

之前介绍的 SlidingAnimator 仍然需要用来动画化变化。

抱歉回复晚了。我会在这几天内测试它。请问,滚动位置是通过删除和重新添加的技术来保留的吗? - Cheok Yan Cheng
嗨,Andreas,我已经测试了你的答案。然而,移除项目并重新插入它们的技巧并不是很有效。它们无法保留当前的滚动位置。当所有项目都被移除时,滚动条将重置为0。 - Cheok Yan Cheng
嗨Cheok。关于滚动位置的问题,我也在使用RecyclerView时遇到了一个错误。这是我目前解决此问题的方法https://dev59.com/hV4c5IYBdhLWcg3wXZYB#35797621。我希望这也能为您解决问题。 - Andreas Wenger
在您的情况下,RecyclerView 滚动到顶部不是一个“错误”。尽管如此,链接的解决方法应该能够帮助您保留滚动位置。 - Andreas Wenger
1
太好了!在我的情况下,“简单版本”已经足够了。 - Rafael

10

如果你不想在每次排序时重置滚动位置,那么你可以尝试另一种方法 (GITHUB演示项目):

使用某种RecyclerView.ItemAnimator,但不要重新编写animateAdd()animateRemove()函数,而是实现animateChange()animateChangeImpl()。 在排序后,您可以调用adapter.notifyItemRangeChanged(0, mItems.size());来触发动画。 因此,触发动画的代码看起来非常简单:

for (int i = 0, j = mItems.size() - 1; i < j; i++, j--)
    Collections.swap(mItems, i, j);

adapter.notifyItemRangeChanged(0, mItems.size());

对于动画代码,您可以使用android.support.v7.widget.DefaultItemAnimator,但是该类具有私有的animateChangeImpl()方法,因此您必须复制粘贴代码并更改此方法或使用反射。或者您可以像@Andreas Wenger在他的SlidingAnimator示例中所做的那样创建自己的ItemAnimator类。重点是实现animateChangeImpl方法,与您的代码类似,这里有2个动画: 1)将旧视图向右滑动
private void animateChangeImpl(final ChangeInfo changeInfo) {
    final RecyclerView.ViewHolder oldHolder = changeInfo.oldHolder;
    final View view = oldHolder == null ? null : oldHolder.itemView;
    final RecyclerView.ViewHolder newHolder = changeInfo.newHolder;
    final View newView = newHolder != null ? newHolder.itemView : null;

    if (view == null) return;
    mChangeAnimations.add(oldHolder);

    final ViewPropertyAnimatorCompat animOut = ViewCompat.animate(view)
            .setDuration(getChangeDuration())
            .setInterpolator(interpolator)
            .translationX(view.getRootView().getWidth())
            .alpha(0);

    animOut.setListener(new VpaListenerAdapter() {
        @Override
        public void onAnimationStart(View view) {
            dispatchChangeStarting(oldHolder, true);
        }

        @Override
        public void onAnimationEnd(View view) {
            animOut.setListener(null);
            ViewCompat.setAlpha(view, 1);
            ViewCompat.setTranslationX(view, 0);
            dispatchChangeFinished(oldHolder, true);
            mChangeAnimations.remove(oldHolder);

            dispatchFinishedWhenDone();

            // starting 2-nd (Slide Up) animation
            if (newView != null)
                animateChangeInImpl(newHolder, newView);
        }
    }).start();
}

2) 上滑新视图

private void animateChangeInImpl(final RecyclerView.ViewHolder newHolder,
                                 final View newView) {

    // setting starting pre-animation params for view
    ViewCompat.setTranslationY(newView, newView.getHeight());
    ViewCompat.setAlpha(newView, 0);

    mChangeAnimations.add(newHolder);

    final ViewPropertyAnimatorCompat animIn = ViewCompat.animate(newView)
            .setDuration(getChangeDuration())
            .translationY(0)
            .alpha(1);

    animIn.setListener(new VpaListenerAdapter() {
        @Override
        public void onAnimationStart(View view) {
            dispatchChangeStarting(newHolder, false);
        }

        @Override
        public void onAnimationEnd(View view) {
            animIn.setListener(null);
            ViewCompat.setAlpha(newView, 1);
            ViewCompat.setTranslationY(newView, 0);
            dispatchChangeFinished(newHolder, false);
            mChangeAnimations.remove(newHolder);
            dispatchFinishedWhenDone();
        }
    }).start();
}

这是一个带有工作滚动和类似动画的演示图片: https://i.gyazo.com/04f4b767ea61569c00d3b4a4a86795ce.gif https://i.gyazo.com/57a52b8477a361c383d44664392db0be.gif 编辑: 为了加快RecyclerView的性能,你可能想使用以下内容代替adapter.notifyItemRangeChanged(0, mItems.size());:
LinearLayoutManager layoutManager = (LinearLayoutManager) recyclerView.getLayoutManager();
int firstVisible = layoutManager.findFirstVisibleItemPosition();
int lastVisible = layoutManager.findLastVisibleItemPosition();
int itemsChanged = lastVisible - firstVisible + 1; 
// + 1 because we start count items from 0

adapter.notifyItemRangeChanged(firstVisible, itemsChanged);

感谢@Verren的快速更新,这对我们的开发社区非常有帮助 :) - Carlos
我更喜欢使用“通知更改”这种解决方案,而不是“通知删除+通知添加”。因为“更改”事件更适合排序操作。我已经测试过这个解决方案,在我的情况下效果很好。 - Cheok Yan Cheng
@AndreasWenger,你提出了一些好的观点,但是“结构性变化”是什么意思?是的,如果我们有多种ViewHolder类型,上面的代码将会出错,但是我们只有一种类型,所以可以非常肯定地说,所有元素都改变了它们的值,并且在排序时没有进行任何结构性变化。 该位置的项目保留相同的标识符(标识符是我们数据集对象类型的标识符,在我的示例中,它们都具有字符串标识符)。适配器甚至不知道我们的对象的存在。他只关心视图,而在我们的情况下,所有视图都是相同类型的。 - varren
@AndreasWenger 关于优化:它确实可以加快速度,如果屏幕上没有这些项目,我认为我们不应该通知适配器有关更改的信息。我再次查看了源代码,并从中发现,在没有任何移动、添加或删除的情况下,notify...() 的作用是:当调用时,它查看当前屏幕上显示的项目,并使可见位置的子视图无效。(这基本上是标准方式) - varren
@varren,你能告诉我你查看的 notify...() 行为的源码吗?如果 RecyclerView 已经将子项无效化的限制范围限制在可见项内,我不明白为什么你还需要手动执行此操作。 - Andreas Wenger
显示剩余7条评论

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