Skip to content

Commit

Permalink
Move window/view visibility handling from LottieAnimationView to Lott…
Browse files Browse the repository at this point in the history
…ieDrawable (#1981)

Previously, all of the logic to pause/resume Lottie animations on events such as hiding a view, backgrounding an app, etc. were handled by LottieAnimationView. This logic works fine for the default cause. However, LottieDrawable had no notion of visibility handling itself. As a result, if somebody were to use LottieDrawable on its own, they would have to get the lifecycle exactly right or else they could risk leaking animators and impacting the user's battery life.

This PR combines all of the logic into Drawable.setVisible. This also simplifies things because it is a single API vs views that have to deal with window attachment and visibility changes.

I ran all existing FragmentVisibilityTests and they all pass and the intention is to maintain backwards compatibility.
  • Loading branch information
gpeal committed Jan 14, 2022
1 parent c9cb92f commit f79c12f
Show file tree
Hide file tree
Showing 3 changed files with 83 additions and 81 deletions.
79 changes: 7 additions & 72 deletions lottie/src/main/java/com/airbnb/lottie/LottieAnimationView.java
Expand Up @@ -94,9 +94,6 @@ public void onResult(Throwable result) {
private String animationName;
private @RawRes int animationResId;

private boolean playAnimationWhenShown = false;
private boolean wasAnimatingWhenNotShown = false;
private boolean wasAnimatingWhenDetached = false;
/**
* When we set a new composition, we set LottieDrawable to null then back again so that ImageView re-checks its bounds.
* However, this causes the drawable to get unscheduled briefly. Normally, we would pause the animation but in this case, we don't want to.
Expand Down Expand Up @@ -166,7 +163,6 @@ private void init(@Nullable AttributeSet attrs, @AttrRes int defStyleAttr) {

setFallbackResource(ta.getResourceId(R.styleable.LottieAnimationView_lottie_fallbackRes, 0));
if (ta.getBoolean(R.styleable.LottieAnimationView_lottie_autoPlay, false)) {
wasAnimatingWhenDetached = true;
autoPlay = true;
}

Expand Down Expand Up @@ -268,7 +264,7 @@ private void init(@Nullable AttributeSet attrs, @AttrRes int defStyleAttr) {
ss.animationName = animationName;
ss.animationResId = animationResId;
ss.progress = lottieDrawable.getProgress();
ss.isAnimating = lottieDrawable.isAnimating() || (!ViewCompat.isAttachedToWindow(this) && wasAnimatingWhenDetached);
ss.isAnimating = lottieDrawable.isAnimatingOrWillAnimateOnVisible();
ss.imageAssetsFolder = lottieDrawable.getImageAssetsFolder();
ss.repeatMode = lottieDrawable.getRepeatMode();
ss.repeatCount = lottieDrawable.getRepeatCount();
Expand Down Expand Up @@ -300,57 +296,11 @@ private void init(@Nullable AttributeSet attrs, @AttrRes int defStyleAttr) {
setRepeatCount(ss.repeatCount);
}

@Override
protected void onVisibilityChanged(@NonNull View changedView, int visibility) {
// This can happen on older versions of Android because onVisibilityChanged gets called from the
// constructor of View so this will get called before lottieDrawable gets initialized.
// https://github.com/airbnb/lottie-android/issues/1143
// A simple null check on lottieDrawable would not work because when using Proguard optimization, a
// null check on a final field gets removed. As "usually" final fields cannot be null.
// However because this is called by super (View) before the initializer of the LottieAnimationView
// is called, it actually can be null here.
// Working around this by using a non final boolean that is set to true after the class initializer
// has run.
if (!isInitialized) {
return;
}
if (isShown()) {
if (wasAnimatingWhenNotShown) {
resumeAnimation();
} else if (playAnimationWhenShown) {
playAnimation();
}
wasAnimatingWhenNotShown = false;
playAnimationWhenShown = false;
} else {
if (isAnimating()) {
pauseAnimation();
wasAnimatingWhenNotShown = true;
}
}
}

@Override protected void onAttachedToWindow() {
super.onAttachedToWindow();
if (!isInEditMode() && (autoPlay || wasAnimatingWhenDetached)) {
playAnimation();
// Autoplay from xml should only apply once.
autoPlay = false;
wasAnimatingWhenDetached = false;
}
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
// This is needed to mimic newer platform behavior.
// https://stackoverflow.com/a/53625860/715633
onVisibilityChanged(this, getVisibility());
}
}

@Override protected void onDetachedFromWindow() {
if (isAnimating()) {
cancelAnimation();
wasAnimatingWhenDetached = true;
if (!isInEditMode() && autoPlay) {
lottieDrawable.playAnimation();
}
super.onDetachedFromWindow();
}

/**
Expand Down Expand Up @@ -615,12 +565,8 @@ public boolean hasMatte() {
*/
@MainThread
public void playAnimation() {
if (isShown()) {
lottieDrawable.playAnimation();
computeRenderMode();
} else {
playAnimationWhenShown = true;
}
lottieDrawable.playAnimation();
computeRenderMode();
}

/**
Expand All @@ -629,13 +575,8 @@ public void playAnimation() {
*/
@MainThread
public void resumeAnimation() {
if (isShown()) {
lottieDrawable.resumeAnimation();
computeRenderMode();
} else {
playAnimationWhenShown = false;
wasAnimatingWhenNotShown = true;
}
lottieDrawable.resumeAnimation();
computeRenderMode();
}

/**
Expand Down Expand Up @@ -980,19 +921,13 @@ public float getScale() {

@MainThread
public void cancelAnimation() {
wasAnimatingWhenDetached = false;
wasAnimatingWhenNotShown = false;
playAnimationWhenShown = false;
lottieDrawable.cancelAnimation();
computeRenderMode();
}

@MainThread
public void pauseAnimation() {
autoPlay = false;
wasAnimatingWhenDetached = false;
wasAnimatingWhenNotShown = false;
playAnimationWhenShown = false;
lottieDrawable.pauseAnimation();
computeRenderMode();
}
Expand Down
76 changes: 73 additions & 3 deletions lottie/src/main/java/com/airbnb/lottie/LottieDrawable.java
Expand Up @@ -63,15 +63,28 @@ private interface LazyCompositionTask {
void run(LottieComposition composition);
}

/**
* Internal record keeping of the desired play state when {@link #isVisible()} transitions to or is false.
*
* If the animation was playing when it becomes invisible or play/pause is called on it while it is invisible, it will
* store the state and then take the appropriate action when the drawable becomes visible again.
*/
private enum OnVisibleAction {
NONE,
PLAY,
RESUME,
}

private LottieComposition composition;
private final LottieValueAnimator animator = new LottieValueAnimator();
private float scale = 1f;

//Call animationsEnabled() instead of using these fields directly
// Call animationsEnabled() instead of using these fields directly.
private boolean systemAnimationsEnabled = true;
private boolean ignoreSystemAnimationsDisabled = false;

private boolean safeMode = false;
private OnVisibleAction onVisibleAction = OnVisibleAction.NONE;

private final ArrayList<LazyCompositionTask> lazyCompositionTasks = new ArrayList<>();
private final ValueAnimator.AnimatorUpdateListener progressUpdateListener = new ValueAnimator.AnimatorUpdateListener() {
Expand Down Expand Up @@ -352,6 +365,9 @@ private void buildCompositionLayer() {
public void clearComposition() {
if (animator.isRunning()) {
animator.cancel();
if (!isVisible()) {
onVisibleAction = OnVisibleAction.NONE;
}
}
composition = null;
compositionLayer = null;
Expand Down Expand Up @@ -478,18 +494,28 @@ public void playAnimation() {
}

if (animationsEnabled() || getRepeatCount() == 0) {
animator.playAnimation();
if (isVisible()) {
animator.playAnimation();
} else {
onVisibleAction = OnVisibleAction.PLAY;
}
}
if (!animationsEnabled()) {
setFrame((int) (getSpeed() < 0 ? getMinFrame() : getMaxFrame()));
animator.endAnimation();
if (!isVisible()) {
onVisibleAction = OnVisibleAction.NONE;
}
}
}

@MainThread
public void endAnimation() {
lazyCompositionTasks.clear();
animator.endAnimation();
if (!isVisible()) {
onVisibleAction = OnVisibleAction.NONE;
}
}

/**
Expand All @@ -504,11 +530,18 @@ public void resumeAnimation() {
}

if (animationsEnabled() || getRepeatCount() == 0) {
animator.resumeAnimation();
if (isVisible()) {
animator.resumeAnimation();
} else {
onVisibleAction = OnVisibleAction.RESUME;
}
}
if (!animationsEnabled()) {
setFrame((int) (getSpeed() < 0 ? getMinFrame() : getMaxFrame()));
animator.endAnimation();
if (!isVisible()) {
onVisibleAction = OnVisibleAction.NONE;
}
}
}

Expand Down Expand Up @@ -841,6 +874,14 @@ public boolean isAnimating() {
return animator.isRunning();
}

boolean isAnimatingOrWillAnimateOnVisible() {
if (isVisible()) {
return animator.isRunning();
} else {
return onVisibleAction == OnVisibleAction.PLAY || onVisibleAction == OnVisibleAction.RESUME;
}
}

private boolean animationsEnabled() {
return systemAnimationsEnabled || ignoreSystemAnimationsDisabled;
}
Expand Down Expand Up @@ -930,11 +971,17 @@ public LottieComposition getComposition() {
public void cancelAnimation() {
lazyCompositionTasks.clear();
animator.cancel();
if (!isVisible()) {
onVisibleAction = OnVisibleAction.NONE;
}
}

public void pauseAnimation() {
lazyCompositionTasks.clear();
animator.pauseAnimation();
if (!isVisible()) {
onVisibleAction = OnVisibleAction.NONE;
}
}

@FloatRange(from = 0f, to = 1f)
Expand Down Expand Up @@ -1111,6 +1158,29 @@ private Context getContext() {
return null;
}

@Override public boolean setVisible(boolean visible, boolean restart) {
// Sometimes, setVisible(false) gets called twice in a row. If we don't check wasNotVisibleAlready, we could
// wind up clearing the onVisibleAction value for the second call.
boolean wasNotVisibleAlready = !isVisible();
boolean ret = super.setVisible(visible, restart);

if (visible) {
if (onVisibleAction == OnVisibleAction.PLAY) {
playAnimation();
} else if (onVisibleAction == OnVisibleAction.RESUME) {
resumeAnimation();
}
} else {
if (animator.isRunning()) {
pauseAnimation();
onVisibleAction = OnVisibleAction.RESUME;
} else if (!wasNotVisibleAlready) {
onVisibleAction = OnVisibleAction.NONE;
}
}
return ret;
}

/**
* These Drawable.Callback methods proxy the calls so that this is the drawable that is
* actually invalidated, not a child one which will not pass the view's validateDrawable check.
Expand Down
Expand Up @@ -141,8 +141,7 @@ class FragmentVisibilityTests {
}

val scenario1 = launchFragmentInContainer<TestFragment>()
// Wait for the animation view.
onView(withId(R.id.animation_view))
onIdle()

// Launch a new activity
scenario1.onFragment { fragment ->
Expand Down Expand Up @@ -370,10 +369,7 @@ class FragmentVisibilityTests {

override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
return when (viewType) {
0 -> object : RecyclerView.ViewHolder(
LottieAnimationView(parent.context)
.apply { id = R.id.animation_view }
) {}
0 -> object : RecyclerView.ViewHolder(LottieAnimationView(parent.context).apply { id = R.id.animation_view }) {}
else -> object : RecyclerView.ViewHolder(TextView(parent.context)) {}
}
}
Expand Down Expand Up @@ -418,6 +414,7 @@ class FragmentVisibilityTests {
scenario.onFragment { assertFalse(it.animationView!!.isAnimating) }
scenario.onFragment { it.requireView().scrollBy(0, 10_000) }
scenario.onFragment { it.requireView().scrollBy(0, -10_000) }
// Animation already ended. Making sure it isn't playing again.
scenario.onFragment { assertFalse(it.animationView!!.isAnimating) }
}

Expand Down

0 comments on commit f79c12f

Please sign in to comment.