Как обеспечить пользовательскую анимацию во время сортировки (notifyDataSetChanged) в RecyclerView

В настоящее время, используя аниматор по умолчанию 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

Как мне добиться такой анимации без RecylerView

Несколько лет назад я добился этого эффекта, используя 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?


person Cheok Yan Cheng    schedule 19.03.2016    source источник
comment
Вы не хотите получить эту анимацию без или с RecyclerView?   -  person Andreas Wenger    schedule 21.03.2016
comment
Я хочу добиться этой анимации с помощью переработчика.   -  person Cheok Yan Cheng    schedule 21.03.2016
comment
Должны ли элементы всегда скользить вправо, даже если после этого они все еще присутствуют? Или должны выскальзывать только те элементы, которые больше не видны?   -  person Andreas Wenger    schedule 21.03.2016
comment
Все предметы всегда в наличии. Единственными изменениями является их порядок после сортировки.   -  person Cheok Yan Cheng    schedule 23.03.2016
comment
Получить именно то, что вы хотите, очень сложно. вам нужно создать свой собственный LayoutManager и переопределить onLayoutChildren и вернуть true из supportsPredictiveItemAnimations(). Поэтому при вызове notifyDataSetChanged вы должны правильно разместить дочерние элементы recyclerView, чтобы сделать анимацию.   -  person    schedule 27.03.2016


Ответы (2)


Вот еще одно направление, на которое вы можете обратить внимание, если вы не хотите, чтобы ваша прокрутка сбрасывалась при каждой сортировке (демонстрационный проект 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(), поэтому вам придется скопировать код и изменить этот метод или использовать отражение. Или вы можете создать свой собственный класс ItemAnimator, как это сделал @Andreas Wenger в своем примере SlidingAnimator. Суть здесь в том, чтобы реализовать 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();
}

Вот демонстрационное изображение с работающей прокруткой и похожей анимацией /a> 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);
person varren    schedule 24.03.2016
comment
Ваш подход хорош, но я вижу только одну незначительную проблему, которая может привести к снижению производительности или возможному сбою, и это когда вы сообщаете адаптеру: adapter.notifyItemRangeChanged(0, mItems.size()); Вы устанавливаете огромный диапазон, а так как диапазон слишком велик; RecyclerView не сможет повторно использовать ни одно из этих представлений. Получение элементов MAX может быть полезно для небольшого набора данных в вашем адаптере. Моя рекомендация обрабатывать большой диапазон: уведомлять адаптер о том, какой элемент имеет изменения, или, если вы хотите получить их все, вам нужно изменить размер кеша по умолчанию. - person Carlos; 25.03.2016
comment
@Карлос, о да, спасибо, ты прав на 100%. У меня было это в моем первоначальном коммите репозитория git, но я забыл добавить конечный код после некоторой очистки /varren/animationdemo/MainActivity.java" rel="nofollow noreferrer">github.com/varren/RecyclerViewScrollAnimation/blob/ Сейчас будет редактировать сообщение - person varren; 25.03.2016
comment
Спасибо @Verren за ваше быстрое обновление, которое очень помогает нашему сообществу разработчиков :) - person Carlos; 25.03.2016
comment
Я предпочитаю это решение (с использованием уведомления об изменении) по сравнению с уведомлением об удалении + уведомлением о добавлении. Поскольку событие изменения больше подходит для действия сортировки. Я тестировал это решение. Это работает до сих пор для моего случая. - person Cheok Yan Cheng; 28.03.2016
comment
Обратите внимание, что change не является подходящим действием при изменении порядка элементов данных. В документации для notifyItemChanged указано: Это событие изменения элемента, а не событие структурного изменения. Это указывает на то, что любое отражение данных в позиции устарело и должно быть обновлено. Элемент в позиции сохраняет ту же идентичность. [1] notifyItemMoved является подходящим действием для сообщения Adapter. [1] developer.android.com /reference/android/support/v7/widget/, интервал) - person Andreas Wenger; 29.03.2016
comment
Кроме того, ограничение notifyItemRangeChanged только видимыми элементами является опасной оптимизацией. Вы всегда должны уведомлять Adapter обо всех изменениях, произошедших в наборе данных. RecyclerView например, иногда уже подготавливает представления для дополнительных элементов, которые еще не видны, чтобы улучшить производительность прокрутки. По умолчанию LinearLayoutManager отображает 1 дополнительную страницу элементов при плавной прокрутке и 0 в противном случае. developer.android.com/reference/android/support/ v7/виджет/ - person Andreas Wenger; 29.03.2016
comment
@AndreasWenger У вас есть несколько хороших моментов, но что означает structural change? Да, приведенный выше код сломается, если у нас будет более 1 типа ViewHolder, но у нас есть только 1 тип, поэтому можно с уверенностью сказать, что все элементы изменили свое значение, и при сортировке было 0 структурных изменений. Элемент в позиции сохраняет ту же идентичность (и идентичность относится к типу объекта нашего набора данных. В моем примере все они имеют идентификатор String). Адаптер не знает даже о существовании наших объектов. Ему важны только представления, и все они в нашем случае однотипны. - person varren; 29.03.2016
comment
@AndreasWenger Об оптимизации: это определенно ускоряет работу, и я не думаю, что мы должны уведомлять адаптер об изменениях элементов, если их сейчас нет на экране. Я еще раз посмотрел на исходный код и из того, что я вижу, для случая, когда ничего не перемещается, не добавляется и не удаляется: что делает notify...(): при вызове он смотрит, какие элементы отображаются на экране в момент момент его вызова и делает недействительными дочерние представления для видимых позиций. (Это почти стандартный способ) - person varren; 29.03.2016
comment
@varren, вы можете посмотреть, как документация определяет structural change developer.android.com/reference/android/support/v7/widget/. Насколько я понимаю, это нацелено на логические элементы данных, которые отображаются в RecyclerView, а не в результирующих типах представлений. Вот почему я думаю, что notifyItemChanged следует использовать только в том случае, если представление одного из логических элементов данных в RecyclerView было изменено. - person Andreas Wenger; 30.03.2016
comment
@varren Можете ли вы указать мне источник, который вы искали для поведения notify...()? Если RecyclerView уже ограничивает аннулирование дочерних элементов видимыми элементами, я не понимаю, почему вам также нужно делать это вручную. - person Andreas Wenger; 30.03.2016
comment
Я понимаю, что это решение не работает, если у нас есть setHasStableIds и правильная реализация для getItemId. Эти штуки необходимы, если нам также нужно добавить и удалить анимацию, чтобы она работала. - person Cheok Yan Cheng; 02.04.2016
comment
@CheokYanCheng Привет, извини за задержку. Подхватил лихорадку (я отредактирую этот пост, когда поправлюсь). На данный момент я внес изменения в репозиторий git для более общего случая с setHasStableIds тем же подходом, но другим ItemAnimator. github.com/varren/RecyclerViewScrollAnimation/commits/master Если у вас стабильный идентификатор, добавьте /удалить, но не изменить анимацию будет запускаться даже для notifyItemRangeChanged(). Таким образом, вы должны реализовать все типы анимации. youtu.be/5Ujdn7sEEgU демо для последнего коммита git - person varren; 04.04.2016

Прежде всего:

  • Это решение предполагает, что элементы, которые все еще видны после изменения набора данных, также скользят вправо, а затем снова скользят снизу (это, по крайней мере, то, о чем вы просите)
  • Из-за этого требования я не смог найти простого и красивого решения этой проблемы (по крайней мере, на первой итерации). Единственный способ, который я нашел, это обмануть адаптер — и заставить фреймворк сделать что-то, для чего он не предназначен. Вот почему первая часть (Как это обычно работает) описывает, как добиться красивой анимации с помощью RecyclerView способа по умолчанию. Во второй части описывается решение, как обеспечить анимацию выскальзывания/вставки для всех элементов после изменения набора данных.
  • Позже я нашел лучшее решение, которое не требует обмана адаптера случайными идентификаторами (перейдите к нижней части для обновленной версии).

Как это обычно работает

Чтобы включить анимацию, вам нужно сообщить RecyclerView, как изменился набор данных (чтобы он знал, какие анимации следует запускать). Это можно сделать двумя способами:

1) Простая версия: нам нужно установить adapter.setHasStableIds(true); и предоставить идентификаторы ваших товаров через public long getItemId(int position) в вашем Adapter в RecyclerView. RecyclerView использует эти идентификаторы, чтобы выяснить, какие элементы были удалены/добавлены/перемещены во время вызова adapter.notifyDataSetChanged();.

2) Расширенная версия: вместо вызова adapter.notifyDataSetChanged(); вы также можете явно указать, как изменился набор данных. Adapter предоставляет несколько методов, таких как adapter.notifyItemChanged(int position),adapter.notifyItemInserted(int position),... для описания изменений в наборе данных.

Анимации, которые запускаются для отражения изменений в наборе данных, управляются ItemAnimator. RecyclerView уже оснащен хорошим DefaultItemAnimator по умолчанию. Кроме того, можно определить пользовательское поведение анимации с помощью пользовательского ItemAnimator.

Стратегия реализации слайда наружу (справа), слайда внутрь (внизу)

Слайд справа — это анимация, которая должна воспроизводиться, если элементы удаляются из набора данных. Анимация слайда снизу должна воспроизводиться для элементов, которые были добавлены в набор данных. Как упоминалось в начале, я предполагаю, что желательно, чтобы все элементы выдвигались вправо и вдвигались снизу. Даже если они видны до и после изменения набора данных. Обычно RecyclerView воспроизводит анимацию изменения/перемещения таких элементов, которые остаются видимыми. Однако, поскольку мы хотим использовать анимацию удаления/добавления для всех элементов, нам нужно обмануть адаптер, заставив его думать, что после изменения есть только новые элементы, а все ранее доступные элементы были удалены. Этого можно добиться, указав случайный идентификатор для каждого элемента в адаптере:

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

Теперь нам нужно предоставить пользовательский ItemAnimator, который управляет анимацией для добавленных/удаленных элементов. Структура представленного SlidingAnimator очень похожа на структуру android.support.v7.widget.DefaultItemAnimator, поставляемую с RecyclerView. Также обратите внимание, что это доказательство концепции и должно быть скорректировано перед использованием в любом приложении:

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

Это окончательный результат:

введите здесь описание изображения

Обновление: при повторном чтении сообщения я нашел лучшее решение.

Это обновленное решение не требует, чтобы адаптер с помощью случайных идентификаторов думал, что все элементы были удалены, а добавлены только новые элементы. Если мы применим 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 по-прежнему необходим для анимации изменений.

person Andreas Wenger    schedule 21.03.2016
comment
Извините за поздний ответ. На днях протестирую. Могу ли я узнать, сохраняется ли положение прокрутки с помощью метода удаления и повторного добавления? - person Cheok Yan Cheng; 23.03.2016
comment
Привет, Андреас, я проверил твой ответ. Однако трюк, который удаляет элементы и вставляет их заново, работает не очень хорошо. Они не сохраняют текущую позицию прокрутки. Когда все предметы будут удалены, прокрутка сбросится на 0. - person Cheok Yan Cheng; 24.03.2016
comment
Однако в вашем случае это не ошибка в RecyclerView, которая прокручивается вверх. Тем не менее связанный обходной путь должен помочь вам сохранить положение прокрутки. - person Andreas Wenger; 25.03.2016
comment
Большой! 1) Simple Version в моем случае было более чем достаточно. - person Rafael; 02.02.2017