Tuesday, March 14, 2017

TL:DR:
If you ever plan on using Frame Animations (animation-list) to animate a list item in a recycler view make sure you stop the animation in ALL cases or it will be very easy to leak it.

DETAILS:
For one of my projects I had to add a loading list item in a recycler view so the user would see something pretty while they wait for the list to populate.

So I went ahead and created an animation-list,
<?xml version="1.0" encoding="utf-8">
android="http://schemas.android.com/apk/res/android" android:oneshot="false">
    <android:drawable="@drawable/tile_loading_01" android:duration="@integer/content_loading_anim" >
    <android:drawable="@drawable/tile_loading_02" android:duration="@integer/content_loading_anim" >
    <android:drawable="@drawable/tile_loading_03" android:duration="@integer/content_loading_anim" >
[...]

a new view holder for this list item
class ContentLoadingViewHolder extends RecyclerView.ViewHolder {
    private AnimationDrawable loopAnimation = null;
  
    ContentLoadingViewHolder(View itemView) {
        super(itemView);
        ImageView image = (ImageView) itemView.findViewById(R.id.illustration);
        if (image != null) {
            loopAnimation = (AnimationDrawable) image.getDrawable();
        }
    }

    void startAnimation() {
        Log.d("Starting animation - loopAnimation: " + loopAnimation
            + " isRunning: " + (loopAnimation != null ? loopAnimation.isRunning() : "null"));
        if (loopAnimation != null && !loopAnimation.isRunning()) {
            loopAnimation.start();
        }
    }

    void stopAnimation() {
        Log.d("Stopping animation - loopAnimation: " + loopAnimation
            + " isRunning: " + (loopAnimation != null ? loopAnimation.isRunning() : "null"));
        if (loopAnimation != null && loopAnimation.isRunning()) {
            loopAnimation.stop();        }
        }
    }

 in my adapter I added the call to start the animation in onbindview
@Override
@MainThread
public void onBindViewHolder(final RecyclerView.ViewHolder viewHolder, int unreliablePosition) {
    if (viewHolder instanceof ContentLoadingViewHolder) {
        ((ContentLoadingViewHolder)viewHolder).startAnimation();
    }
}

and stop it in onViewRecycled
@Override
public void onViewRecycled(RecyclerView.ViewHolder holder) {
    clearAnimationIfRunning(holder);
    super.onViewRecycled(holder);
}

At this point you might think. Ok I'm good this should work. And for the most part it does but it will possibly leak your animation if you don't also stop it in all these other cases:
@Override
public void onDetachedFromRecyclerView(RecyclerView recyclerView) {
    clearAllAnimations();
    super.onDetachedFromRecyclerView(recyclerView);
}

@Override
public void onViewRecycled(RecyclerView.ViewHolder holder) {
    clearAnimationIfRunning(holder);
    super.onViewRecycled(holder);
}

@Override
public boolean onFailedToRecycleView(RecyclerView.ViewHolder holder) {
    clearAnimationIfRunning(holder);
    return super.onFailedToRecycleView(holder);
}

void clearAllAnimations() {
    Log.d("Clearing all animations in the suggestion adapter.");
    // clear all animation in recycler view
    int count = recyclerView.getChildCount();
    for (int i = 0; i < count; ++i) {
        try {
            RecyclerView.ViewHolder holder = recyclerView.findViewHolderForAdapterPosition(i);
            clearAnimationIfRunning(holder);
        } catch (Throwable t) {
            Log.e("Unable to clear animations");
        }
    }

    // clear all animation if left pending in recycler view pool
    RecyclerView.RecycledViewPool pool = recyclerView.getRecycledViewPool();
    if (pool != null) {
        while (true) {
            RecyclerView.ViewHolder holder = pool.getRecycledView(LOADING.ordinal());
            if (holder != null) {
                clearAnimationIfRunning(holder);
            } else {
                break;
            }
        }
    }
}

private void clearAnimationIfRunning(RecyclerView.ViewHolder holder) {
    if (holder != null && holder instanceof ContentLoadingViewHolder) {
        ((ContentLoadingViewHolder)holder).stopAnimation();
    }
}

See the recycler might have failed to recycle the view in which case you need to catch it in "onFailedToRecycleView" and the user might tap the recent apps button and swipe the app closed and for that case you will catch it in onDetachedFromRecyclerView.

Lastly, if you're using this in a fragment, you also want to stop animations when the fragment gets detached:
@Override
public void onDetach() {
    if (adapter != null) {
        adapter.clearAllAnimations();
    }
    super.onDetach();
}

Hope this helps keep your app leak free.