Detect animation finish in Android's RecyclerView
Asked Answered
C

12

41

The RecyclerView, unlike to ListView, doesn't have a simple way to set an empty view to it, so one has to manage it manually, making empty view visible in case of adapter's item count is 0.

Implementing this, at first I tried to call empty view logic right after modifying underlaying structure (ArrayList in my case), for example:

btnRemoveFirst.setOnClickListener(new View.OnClickListener() {
    @Override
    public void onClick(View view) {
        devices.remove(0); // remove item from ArrayList
        adapter.notifyItemRemoved(0); // notify RecyclerView's adapter
        updateEmptyView();
    }
});

It does the thing, but has a drawback: when the last element is being removed, empty view appears before animation of removing is finished, immediately after removal. So I decided to wait until end of animation and then update UI.

To my surprise, I couldn't find a good way to listen for animation events in RecyclerView. First thing coming to mind is to use isRunning method like this:

btnRemoveFirst.setOnClickListener(new View.OnClickListener() {
    @Override
    public void onClick(View view) {
        devices.remove(0); // remove item from ArrayList
        adapter.notifyItemRemoved(0); // notify RecyclerView's adapter
        recyclerView.getItemAnimator().isRunning(new RecyclerView.ItemAnimator.ItemAnimatorFinishedListener() {
            @Override
            public void onAnimationsFinished() {
                updateEmptyView();
            }
        });
    }
});

Unfortunately, callback in this case runs immediately, because at that moment inner ItemAnimator still isn't in the "running" state. So, the questions are: how to properly use ItemAnimator.isRunning() method and is there a better way to achieve the desired result, i.e. show empty view after removal animation of the single element is finished?

Chaperon answered 14/11, 2015 at 16:27 Comment(0)
C
22

Currently the only working way I've found to solve this problem is to extend ItemAnimator and pass it to RecyclerView like this:

recyclerView.setItemAnimator(new DefaultItemAnimator() {
    @Override
    public void onAnimationFinished(RecyclerView.ViewHolder viewHolder) {
        updateEmptyView();
    }
});

But this technique is not universal, because I have to extend from concrete ItemAnimator implementation being used by RecyclerView. In case of private inner CoolItemAnimator inside CoolRecyclerView, my method will not work at all.


PS: My colleague suggested to wrap ItemAnimator inside the decorator in a following manner:

recyclerView.setItemAnimator(new ListenableItemAnimator(recyclerView.getItemAnimator()));

It would be nice, despite seems like overkill for a such trivial task, but creating the decorator in this case is not possible anyway, because ItemAnimator has a method setListener() which is package protected so I obviously can't wrap it, as well as several final methods.

Chaperon answered 14/11, 2015 at 16:27 Comment(2)
Hey Roman. Did you ever come across a more centralized solution to this problem?Chante
@Ryan: Unfortunately, no. But I had no research of this problem since the time of my answer.Chaperon
R
18

I have a little bit more generic case where I want to detect when the recycler view have finished animating completely when one or many items are removed or added at the same time.

I've tried Roman Petrenko's answer, but it does not work in this case. The problem is that onAnimationFinished is called for each entry in the recycler view. Most entries have not changed so onAnimationFinished is called more or less instantaneous. But for additions and removals the animation takes a little while so there it's called later.

This leads to at least two problems. Assume you have a method called doStuff() that you want to run when the animation is done.

  1. If you simply call doStuff() in onAnimationFinished you will call it once for every item in the recycler view which might not be what you want to do.

  2. If you just call doStuff() the first time onAnimationFinished is called you may be calling this long before the last animation has been completed.

If you could know how many items there are to be animated you could make sure you call doStuff() when the last animation finishes. But I have not found any way of knowing how many remaining animations there are queued up.

My solution to this problem is to let the recycler view first start animating by using new Handler().post(), then set up a listener with isRunning() that is called when the animation is ready. After that it repeats the process until all views have been animated.

void changeAdapterData() {
    // ...
    // Changes are made to the data held by the adapter
    recyclerView.getAdapter().notifyDataSetChanged();

    // The recycler view have not started animating yet, so post a message to the
    // message queue that will be run after the recycler view have started animating.
    new Handler().post(waitForAnimationsToFinishRunnable);
}

private Runnable waitForAnimationsToFinishRunnable = new Runnable() {
    @Override
    public void run() {
        waitForAnimationsToFinish();
    }
};

// When the data in the recycler view is changed all views are animated. If the
// recycler view is animating, this method sets up a listener that is called when the
// current animation finishes. The listener will call this method again once the
// animation is done.
private void waitForAnimationsToFinish() {
    if (recyclerView.isAnimating()) {
        // The recycler view is still animating, try again when the animation has finished.
        recyclerView.getItemAnimator().isRunning(animationFinishedListener);
        return;
    }

    // The recycler view have animated all it's views
    onRecyclerViewAnimationsFinished();
}

// Listener that is called whenever the recycler view have finished animating one view.
private RecyclerView.ItemAnimator.ItemAnimatorFinishedListener animationFinishedListener =
        new RecyclerView.ItemAnimator.ItemAnimatorFinishedListener() {
    @Override
    public void onAnimationsFinished() {
        // The current animation have finished and there is currently no animation running,
        // but there might still be more items that will be animated after this method returns.
        // Post a message to the message queue for checking if there are any more
        // animations running.
        new Handler().post(waitForAnimationsToFinishRunnable);
    }
};

// The recycler view is done animating, it's now time to doStuff().
private void onRecyclerViewAnimationsFinished() {
    doStuff();
}
Ramses answered 19/10, 2017 at 12:7 Comment(5)
It works perfectly, plus I tried when the method notifyItemRemoved(position) and it works too regards.Rub
Pretty elegant solution. Thank you.Edith
Thanks so much for this @RamsesGlobetrotter
@Ramses it say - new Handler() implement MethodAnzac
The default constructor of Handler is deprecated these days I think new Handler(Looper.getMainLooper()) might work better.Ramses
N
5

Check from latest androidx.recyclerview:recyclerview:1.2.0 inside ItemAnimator method:

boolean isRunning(@Nullable ItemAnimatorFinishedListener listener)

Example (Kotlin):

recyclerView.itemAnimator?.isRunning {
    // do whatever you need to
}
Nur answered 23/5, 2021 at 5:17 Comment(0)
G
3

What worked for me is the following:

  • detect that a view holder was removed
  • in this case, register a listener to be notified when dispatchAnimationsFinished() is called
  • when all animations are finished, call a listener to perform the task (updateEmptyView())

public class CompareItemAnimator extends DefaultItemAnimator implements RecyclerView.ItemAnimator.ItemAnimatorFinishedListener {

private OnItemAnimatorListener mOnItemAnimatorListener;

public interface OnItemAnimatorListener {
    void onAnimationsFinishedOnItemRemoved();
}

@Override
public void onAnimationsFinished() {
    if (mOnItemAnimatorListener != null) {
        mOnItemAnimatorListener.onAnimationsFinishedOnItemRemoved();
    }
}

public void setOnItemAnimatorListener(OnItemAnimatorListener onItemAnimatorListener) {
    mOnItemAnimatorListener = onItemAnimatorListener;
}

@Override
public void onRemoveFinished(RecyclerView.ViewHolder viewHolder) {
    isRunning(this);
}}
Gareth answered 4/7, 2016 at 14:43 Comment(0)
P
3

Here's a little Kotlin extension method that builds on the answer by nibarius.

fun RecyclerView.executeAfterAllAnimationsAreFinished(
    callback: (RecyclerView) -> Unit
) = post(
    object : Runnable {
        override fun run() {
            if (isAnimating) {
                // itemAnimator is guaranteed to be non-null after isAnimating() returned true
                itemAnimator!!.isRunning {
                    post(this)
                }
            } else {
                callback(this@executeAfterAllAnimationsAreFinished)
            }
        }
    }
)
Pesthouse answered 4/6, 2019 at 15:1 Comment(1)
didn't work. callback gets called immediately before animationAril
B
1

Extending Roman Petrenko's answer, if you are using androidx recycler view with kotlin, you can do something like that:

        taskListRecycler.apply {
            itemAnimator = object : DefaultItemAnimator() {
                override fun onAddFinished(item: RecyclerView.ViewHolder?) {
                    super.onAddFinished(item)
                    //Extend
                }

                override fun onRemoveFinished(item: RecyclerView.ViewHolder?) {
                    super.onRemoveFinished(item)
                    //Extend
                }
            }
            layoutManager = LinearLayoutManager(context)
            adapter = taskListAdapter
        }
Beamends answered 17/5, 2020 at 1:53 Comment(0)
A
1

There is a method in the ItemAnimator class that is called when all item animations are finished:

    /**
     * Method which returns whether there are any item animations currently running.
     * This method can be used to determine whether to delay other actions until
     * animations end.
     *
     * @return true if there are any item animations currently running, false otherwise.
     */
     public abstract boolean isRunning();

You can override it to detect when all item animations have ended:

recyclerView.itemAnimator = object : DefaultItemAnimator() {
    override fun isRunning(): Boolean {
        val isAnimationRunning = super.isRunning()
        if(!isAnimationRunning) {
            // YOUR CODE
        }
        return isAnimationRunning
    }
}
Acropolis answered 7/7, 2020 at 21:35 Comment(1)
Interesting thing. This method is getting called 3 times for me. 2 when entering activity, and once when it finished exiting. Although, it does seem to be reliable otherwise. I'll just add a flag to let it run code only once.Timepleaser
B
0

To expand on Roman Petrenko's answer, I don't have a truly universal answer either, but I did find the Factory pattern to be a helpful way to at least clean up some of the cruft that is this issue.

public class ItemAnimatorFactory {

    public interface OnAnimationEndedCallback{
        void onAnimationEnded();
    }
    public static RecyclerView.ItemAnimator getAnimationCallbackItemAnimator(OnAnimationEndedCallback callback){
        return new FadeInAnimator() {
            @Override
            public void onAnimationFinished(RecyclerView.ViewHolder viewHolder) {
                callback.onAnimationEnded();
                super.onAnimationEnded(viewHolder);
            }
        };
    }
}

In my case, I'm using a library which provides a FadeInAnimator that I was already using. I use Roman's solution in the factory method to hook into the onAnimationEnded event, then pass the event back up the chain.

Then, when I'm configuring my recyclerview, I specify the callback to be my method for updating the view based on the recyclerview item count:

mRecyclerView.setItemAnimator(ItemAnimatorFactory.getAnimationCallbackItemAnimator(this::checkSize));

Again, it's not totally universal across all any and all ItemAnimators, but it at least "consolidates the cruft", so if you have multiple different item animators, you can just implement a factory method here following the same pattern, and then your recyclerview configuration is just specifying which ItemAnimator you want.

Biometrics answered 15/5, 2016 at 22:10 Comment(0)
C
0

In my situation, I wanted to delete a bunch of items (and add new ones) after an animation ended. But the isAnimating Event is trigged for each holder, so @SqueezyMo's function wouldn't do the trick to execute my action simultaneously on all items. Thus, I implemented a Listener in my Animator with a method to check if the last animation was done.

Animator

class ClashAnimator(private val listener: Listener) : DefaultItemAnimator() {

    internal var winAnimationsMap: MutableMap<RecyclerView.ViewHolder, AnimatorSet> =
        HashMap()
    internal var exitAnimationsMap: MutableMap<RecyclerView.ViewHolder, AnimatorSet> =
        HashMap()

    private var lastAddAnimatedItem = -2

    override fun canReuseUpdatedViewHolder(viewHolder: RecyclerView.ViewHolder): Boolean {
        return true
    }

    interface Listener {
        fun dispatchRemoveAnimationEnded()
    }

    private fun dispatchChangeFinishedIfAllAnimationsEnded(holder: ClashAdapter.ViewHolder) {
        if (winAnimationsMap.containsKey(holder) || exitAnimationsMap.containsKey(holder)) {
            return
        }
        listener.dispatchRemoveAnimationEnded() //here I dispatch the Event to my Fragment

        dispatchAnimationFinished(holder)
    }

    ...
}

Fragment

class HomeFragment : androidx.fragment.app.Fragment(), Injectable, ClashAdapter.Listener, ClashAnimator.Listener {
    ...
    override fun dispatchRemoveAnimationEnded() {
        mAdapter.removeClash() //will execute animateRemove
        mAdapter.addPhotos(photos.subList(0,2), picDimens[1]) //will execute animateAdd
    }
}
Cheder answered 19/6, 2019 at 20:9 Comment(0)
V
0

Note the action won't be called if there are no animations

fun RecyclerView.onDefaultAnimationFinished(action: () -> Unit, scope: CoroutineScope) {
var startedWaiting = false

fun waitForAllAnimations() {
    if (!isAnimating) {
        action()
        return
    }
    scope.launch(Dispatchers.IO) {
        delay(25)
    }

    scope.launch(Dispatchers.Main) {
        waitForAllAnimations()
    }
}

itemAnimator = object : DefaultItemAnimator() {
    override fun onAnimationFinished(viewHolder: RecyclerView.ViewHolder) {
        super.onAnimationFinished(viewHolder)
        if (!startedWaiting)
            waitForAllAnimations()

        startedWaiting = true
    }
}

}

Vinegarette answered 1/6, 2021 at 14:2 Comment(0)
R
0

My answer is more helpful if you don't know when an animation starts.

fun RecyclerView.executeAfterAllAnimationsAreFinished(
    callback: (RecyclerView) -> Unit
) {
    var started = false
    post(
        object : Runnable {
            override fun run() {
                when {
                    isAnimating && !started -> {
                        started = true
                        itemAnimator?.isRunning { post(this) }
                    }
                    !isAnimating && started -> callback(this@executeAfterAllAnimationsAreFinished)
                    !started -> post(this)
                }
            }
        }
    )
}
Resplendent answered 14/12, 2023 at 15:49 Comment(0)
D
-1

In scenarios like these where the API is designed so poorly for something as trivial as this I just smartly brute-force it.

You can always just run a background task or Thread that periodically polls if the animator is running and when it's not running, execute the code.

If you're a fan of RxJava, you can use this extension function I made:

/**
 * Executes the code provided by [onNext] once as soon as the provided [predicate] is true.
 * All this is done on a background thread and notified on the main thread just like
 * [androidObservable].
 */
inline fun <reified T> T.doInBackgroundOnceWhen(
        crossinline predicate: (T) -> Boolean,
        period: Number = 100,
        timeUnit: java.util.concurrent.TimeUnit =
                java.util.concurrent.TimeUnit.MILLISECONDS,
        crossinline onNext: T.() -> Unit): Disposable {
    var done = false
    return Observable.interval(period.toLong(), timeUnit, Schedulers.computation())
            .observeOn(AndroidSchedulers.mainThread())
            .subscribeOn(Schedulers.computation())
            .takeWhile { !done }
            .subscribe {
                if (predicate(this)) {
                    onNext(this)
                    done = true
                }
            }
}

In your case you can just do:

recyclerView.doInBackgroundOnceWhen(
    predicate = { adapter.isEmpty && !recyclerView.itemAnimator.isRunning },
    period = 17, timeUnit = TimeUnit.MILLISECONDS) {
    updateEmptyView()
}

What this does is it checks if the predicate is satisfied every 17 milliseconds, and if so will execute the onNext block. (17 millis for 60fps)

This is computationally expensive and inefficient... but it gets the job done.

My current preferred way of doing these things is by making use of Android's native Choreographer which allows you to execute callbacks on the next frame, whenever that may be.

Using Android Choreographer:

/**
 * Uses [Choreographer] to evaluate the [predicate] every frame, if true will execute [onNextFrame]
 * once and discard the callback.
 * 
 * This runs on the main thread!
 */
inline fun doOnceChoreographed(crossinline predicate: (frameTimeNanos: Long) -> Boolean,
                               crossinline onNextFrame: (frameTimeNanos: Long) -> Unit) {
    var callback: (Long) -> Unit = {}
    callback = {
        if (predicate(it)) {
            onNextFrame(it)
            Choreographer.getInstance().removeFrameCallback(callback)
            callback = {}
        } else Choreographer.getInstance().postFrameCallback(callback)
    }
    Choreographer.getInstance().postFrameCallback(callback)
}

A word of warning, this is executed on the main thread unlike with the RxJava implementation.

You can then easily call it like so:

doOnceChoreographed(predicate = { adapter.isEmpty && !recyclerView.itemAnimator.isRunning }) {
    updateEmptyView()
}
Doncaster answered 20/5, 2020 at 10:14 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.