Move window/view visibility handling from LottieAnimationView to LottieDrawable (#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.
diff --git a/lottie/src/main/java/com/airbnb/lottie/LottieAnimationView.java b/lottie/src/main/java/com/airbnb/lottie/LottieAnimationView.java
index 7e2c6c4..908e2e5 100644
--- a/lottie/src/main/java/com/airbnb/lottie/LottieAnimationView.java
+++ b/lottie/src/main/java/com/airbnb/lottie/LottieAnimationView.java
@@ -94,9 +94,6 @@
   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.
@@ -166,7 +163,6 @@
 
     setFallbackResource(ta.getResourceId(R.styleable.LottieAnimationView_lottie_fallbackRes, 0));
     if (ta.getBoolean(R.styleable.LottieAnimationView_lottie_autoPlay, false)) {
-      wasAnimatingWhenDetached = true;
       autoPlay = true;
     }
 
@@ -268,7 +264,7 @@
     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();
@@ -300,57 +296,11 @@
     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 (!isInEditMode() && autoPlay) {
+      lottieDrawable.playAnimation();
     }
-    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;
-    }
-    super.onDetachedFromWindow();
   }
 
   /**
@@ -615,12 +565,8 @@
    */
   @MainThread
   public void playAnimation() {
-    if (isShown()) {
-      lottieDrawable.playAnimation();
-      computeRenderMode();
-    } else {
-      playAnimationWhenShown = true;
-    }
+    lottieDrawable.playAnimation();
+    computeRenderMode();
   }
 
   /**
@@ -629,13 +575,8 @@
    */
   @MainThread
   public void resumeAnimation() {
-    if (isShown()) {
-      lottieDrawable.resumeAnimation();
-      computeRenderMode();
-    } else {
-      playAnimationWhenShown = false;
-      wasAnimatingWhenNotShown = true;
-    }
+    lottieDrawable.resumeAnimation();
+    computeRenderMode();
   }
 
   /**
@@ -980,9 +921,6 @@
 
   @MainThread
   public void cancelAnimation() {
-    wasAnimatingWhenDetached = false;
-    wasAnimatingWhenNotShown = false;
-    playAnimationWhenShown = false;
     lottieDrawable.cancelAnimation();
     computeRenderMode();
   }
@@ -990,9 +928,6 @@
   @MainThread
   public void pauseAnimation() {
     autoPlay = false;
-    wasAnimatingWhenDetached = false;
-    wasAnimatingWhenNotShown = false;
-    playAnimationWhenShown = false;
     lottieDrawable.pauseAnimation();
     computeRenderMode();
   }
diff --git a/lottie/src/main/java/com/airbnb/lottie/LottieDrawable.java b/lottie/src/main/java/com/airbnb/lottie/LottieDrawable.java
index 5882ef2..ee9602f 100644
--- a/lottie/src/main/java/com/airbnb/lottie/LottieDrawable.java
+++ b/lottie/src/main/java/com/airbnb/lottie/LottieDrawable.java
@@ -63,15 +63,28 @@
     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() {
@@ -352,6 +365,9 @@
   public void clearComposition() {
     if (animator.isRunning()) {
       animator.cancel();
+      if (!isVisible()) {
+        onVisibleAction = OnVisibleAction.NONE;
+      }
     }
     composition = null;
     compositionLayer = null;
@@ -478,11 +494,18 @@
     }
 
     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;
+      }
     }
   }
 
@@ -490,6 +513,9 @@
   public void endAnimation() {
     lazyCompositionTasks.clear();
     animator.endAnimation();
+    if (!isVisible()) {
+      onVisibleAction = OnVisibleAction.NONE;
+    }
   }
 
   /**
@@ -504,11 +530,18 @@
     }
 
     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;
+      }
     }
   }
 
@@ -841,6 +874,14 @@
     return animator.isRunning();
   }
 
+  boolean isAnimatingOrWillAnimateOnVisible() {
+    if (isVisible()) {
+      return animator.isRunning();
+    } else {
+      return onVisibleAction == OnVisibleAction.PLAY || onVisibleAction == OnVisibleAction.RESUME;
+    }
+  }
+
   private boolean animationsEnabled() {
     return systemAnimationsEnabled || ignoreSystemAnimationsDisabled;
   }
@@ -930,11 +971,17 @@
   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)
@@ -1111,6 +1158,29 @@
     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.
diff --git a/sample/src/androidTest/java/com/airbnb/lottie/samples/FragmentVisibilityTests.kt b/sample/src/androidTest/java/com/airbnb/lottie/samples/FragmentVisibilityTests.kt
index f8f8001..dbfbfed 100644
--- a/sample/src/androidTest/java/com/airbnb/lottie/samples/FragmentVisibilityTests.kt
+++ b/sample/src/androidTest/java/com/airbnb/lottie/samples/FragmentVisibilityTests.kt
@@ -141,8 +141,7 @@
         }
 
         val scenario1 = launchFragmentInContainer<TestFragment>()
-        // Wait for the animation view.
-        onView(withId(R.id.animation_view))
+        onIdle()
 
         // Launch a new activity
         scenario1.onFragment { fragment ->
@@ -370,10 +369,7 @@
 
                         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)) {}
                             }
                         }
@@ -418,6 +414,7 @@
         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) }
     }