QuotaAwareAnimationSet.java

/*
 * Copyright 2022 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package androidx.wear.protolayout.renderer.dynamicdata;

import android.os.Handler;
import android.os.Looper;
import android.view.View;
import android.view.animation.Animation;
import android.view.animation.Animation.AnimationListener;
import android.view.animation.AnimationSet;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.UiThread;
import androidx.core.os.HandlerCompat;
import androidx.wear.protolayout.expression.pipeline.QuotaManager;

import java.util.concurrent.atomic.AtomicBoolean;

/**
 * Wrapper for AnimationSet that is aware of quota. Animation will be played only if given quota
 * manager allows. Any existing listeners on wrapped {@link AnimationSet} will be replaced.
 */
final class QuotaAwareAnimationSet {
    @NonNull private final AnimationSet mAnimationSet;
    @NonNull private final QuotaManager mQuotaManager;

    @NonNull private final View mAssociatedView;
    @NonNull private final QuotaReleasingAnimationListener mListener;

    @Nullable private final Runnable mOnAnimationEnd;
    private final long mCommonDelay;
    @NonNull private final Handler mUiHandler;

    // Suppress initialization warnings here. These are only used inside of methods, and this class
    // is final, so these cannot actually be referenced while the class is under initialization.
    @SuppressWarnings("methodref.receiver.bound")
    @NonNull
    private final Runnable mTryAcquireQuotaAndStartAnimation =
            this::tryAcquireQuotaAndStartAnimation;

    QuotaAwareAnimationSet(
            @NonNull AnimationSet animation,
            @NonNull QuotaManager quotaManager,
            @NonNull View associatedView) {
        this(animation, quotaManager, associatedView, /* onAnimationEnd= */ null);
    }

    QuotaAwareAnimationSet(
            @NonNull AnimationSet animation,
            @NonNull QuotaManager quotaManager,
            @NonNull View associatedView,
            @Nullable Runnable onAnimationEnd) {
        this.mAnimationSet = animation;
        this.mQuotaManager = quotaManager;
        this.mAssociatedView = associatedView;
        this.mOnAnimationEnd = onAnimationEnd;
        this.mUiHandler = new Handler(Looper.getMainLooper());

        // AnimationSet contains multiple animation, of which each of them can have set start delay,
        // that is offset. To prevent consuming quota before animation is due to be played, we're
        // going to get the minimum starting offset among animations in the set and implement
        // delaying starting animation set for that period of time. This way, quota will be consumed
        // when the earliest animation in the set should be played. In order to preserve set delay
        // in animations, each animation in the set will have their delay updated relatively to the
        // minimum delay.

        // Getting minimum offset
        this.mCommonDelay =
                mAnimationSet.getAnimations().stream()
                        .mapToLong(Animation::getStartOffset)
                        .min()
                        .orElse(0L);

        // Updating children offsets.
        mAnimationSet
                .getAnimations()
                .forEach(anim -> anim.setStartOffset(anim.getStartOffset() - this.mCommonDelay));

        mListener =
                new QuotaReleasingAnimationListener(
                        mQuotaManager, animation.getAnimations().size(), onAnimationEnd);
        this.mAnimationSet.setAnimationListener(mListener);
    }

    /**
     * Tries to start animations in the given set. Animation will try to start after the delay it
     * has set.
     *
     * <p>The Runnables {@code beforeAnimationStart} and {@code onAnimationEnd} will still be run
     * even if animation could not start due to quota being unavailable.
     */
    @UiThread
    void tryStartAnimation(@NonNull Runnable beforeAnimationStart) {
        // Don't start new animation if there are already running ones.
        if (getRunningAnimationCount() > 0) {
            return;
        }

        beforeAnimationStart.run();
        // We are implementing start offset ourselves, because we don't want quota to be consumed
        // before animation is running.
        if (mCommonDelay > 0) {
            if (!HandlerCompat.hasCallbacks(mUiHandler, mTryAcquireQuotaAndStartAnimation)) {
                mUiHandler.postDelayed(mTryAcquireQuotaAndStartAnimation, mCommonDelay);
            }
        } else {
            tryAcquireQuotaAndStartAnimation();
        }
    }

    @UiThread
    private void tryAcquireQuotaAndStartAnimation() {
        if (mQuotaManager.tryAcquireQuota(mAnimationSet.getAnimations().size())) {
            mListener.mIsUsingQuota.set(true);
            mAssociatedView.startAnimation(mAnimationSet);
        } else if (mOnAnimationEnd != null) {
            mOnAnimationEnd.run();
        }
        // No need to jump to an end of animation, because if animation is not played, the changed
        // node will be replaced in its place, the same way as if it'd be when content transition is
        // not set.
    }

    /** Cancels all animation in this set and notifies the listener on the same thread. */
    @UiThread
    void cancelAnimations() {
        mAnimationSet.cancel();
        mListener.onAnimationEnd(mAnimationSet);
        mUiHandler.removeCallbacks(mTryAcquireQuotaAndStartAnimation);
    }

    /** Returns the number of currently running animations. */
    int getRunningAnimationCount() {
        return (mAnimationSet.hasStarted() && !mAnimationSet.hasEnded())
                ? mAnimationSet.getAnimations().size()
                : 0;
    }

    private static final class QuotaReleasingAnimationListener implements AnimationListener {

        @Nullable private final Runnable mOnAnimationEnd;
        @NonNull private final QuotaManager mQuotaManager;
        private final int mAnimationNum;
        @NonNull final AtomicBoolean mIsUsingQuota = new AtomicBoolean(false);

        QuotaReleasingAnimationListener(
                @NonNull QuotaManager mQuotaManager,
                int animationNum,
                @Nullable Runnable mOnAnimationEnd) {
            this.mOnAnimationEnd = mOnAnimationEnd;
            this.mQuotaManager = mQuotaManager;
            this.mAnimationNum = animationNum;
        }

        @Override
        @UiThread
        public void onAnimationStart(@NonNull Animation animation) {}

        @Override
        @UiThread
        public void onAnimationEnd(@NonNull Animation animation) {
            if (mIsUsingQuota.compareAndSet(true, false)) {
                mQuotaManager.releaseQuota(mAnimationNum);

                if (mOnAnimationEnd != null) {
                    mOnAnimationEnd.run();
                }
            }
        }

        @Override
        @UiThread
        public void onAnimationRepeat(@NonNull Animation animation) {}
    }
}