chromium/chrome/browser/hub/internal/android/java/src/org/chromium/chrome/browser/hub/ShrinkExpandHubLayoutAnimatorProvider.java

// Copyright 2023 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

package org.chromium.chrome.browser.hub;

import static org.chromium.chrome.browser.hub.HubLayoutConstants.FADE_DURATION_MS;

import android.animation.AnimatorSet;
import android.animation.ObjectAnimator;
import android.animation.RectEvaluator;
import android.graphics.Bitmap;
import android.os.SystemClock;
import android.view.View;
import android.view.animation.Interpolator;
import android.widget.ImageView;

import androidx.annotation.ColorInt;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;

import org.chromium.base.Callback;
import org.chromium.base.metrics.RecordHistogram;
import org.chromium.base.supplier.SyncOneshotSupplier;
import org.chromium.base.supplier.SyncOneshotSupplierImpl;
import org.chromium.ui.animation.AnimationPerformanceTracker;
import org.chromium.ui.animation.AnimationPerformanceTracker.AnimationMetrics;
import org.chromium.ui.interpolators.Interpolators;

import java.lang.ref.WeakReference;
import java.util.function.DoubleConsumer;

/** {@link HubLayoutAnimatorProvider} for shrink, expand, and new tab animations. */
public class ShrinkExpandHubLayoutAnimatorProvider implements HubLayoutAnimatorProvider {
    /**
     * Utility class for the bitmap callback. This retains weak references to an image view to
     * supply a bitmap to and a runnable to execute once fulfilled. Weak references are necessary in
     * the event this callback somehow gets stuck in native thumbnail capture code and a reference
     * to it is held for an extended duration. If this happens a fallback animator will run and it
     * is desirable for the view and runnable to be available for garbage collection.
     */
    @VisibleForTesting()
    static class ImageViewWeakRefBitmapCallback implements Callback<Bitmap> {
        private final WeakReference<ImageView> mViewRef;
        private final WeakReference<Runnable> mOnFinishedRunnableRef;

        ImageViewWeakRefBitmapCallback(ImageView view, Runnable onFinishedRunnable) {
            mViewRef = new WeakReference<>(view);
            mOnFinishedRunnableRef = new WeakReference<>(onFinishedRunnable);
        }

        @Override
        public void onResult(Bitmap bitmap) {
            ImageView view = mViewRef.get();

            // If the view is null a fallback animation is already happening we don't need to
            // invoke the runnable.
            if (view == null) return;
            view.setImageBitmap(bitmap);

            Runnable runnable = mOnFinishedRunnableRef.get();
            if (runnable == null) return;
            runnable.run();
        }
    }

    private final @Nullable AnimationPerformanceTracker mAnimationTracker;
    private final long mCreationTime = SystemClock.elapsedRealtime();
    private final @HubLayoutAnimationType int mAnimationType;
    private final @NonNull HubContainerView mHubContainerView;
    private final @NonNull SyncOneshotSupplierImpl<HubLayoutAnimator> mAnimatorSupplier;

    private final @NonNull SyncOneshotSupplier<ShrinkExpandAnimationData> mAnimationDataSupplier;
    private final @Nullable ImageViewWeakRefBitmapCallback mBitmapCallback;
    private final long mDurationMs;
    private final DoubleConsumer mOnAlphaChange;

    private boolean mWasForcedToFinish;
    private @Nullable ShrinkExpandImageView mShrinkExpandImageView;
    private boolean mLayoutSatisfied;

    /**
     * Keep an explicit reference to this because {@link ObjectAnimator} only keeps a {@link
     * WeakReference}. This class will outlive the animation.
     */
    private @Nullable ShrinkExpandAnimator mShrinkExpandAnimator;

    /**
     * Creates a shrink, expand, or new tab animation.
     *
     * @param animationType The {@link HubLayoutAnimationType} of this animation.
     * @param needsBitmap Whether this animation will require a bitmap callback.
     * @param hubContainerView The {@link HubContainerView} to animate.
     * @param animationDataSupplier The supplier for {@link ShrinkExpandAnimationData} to use for
     *     the animation.
     * @param backgroundColor The background color to use for new tab animations or if the thumbnail
     *     doesn't cover the animating area.
     * @param durationMs The duration in milliseconds of the animation.
     * @param onAlphaChange Observer to notify when alpha changes during animations.
     */
    public ShrinkExpandHubLayoutAnimatorProvider(
            @HubLayoutAnimationType int animationType,
            boolean needsBitmap,
            @NonNull HubContainerView hubContainerView,
            @NonNull SyncOneshotSupplier<ShrinkExpandAnimationData> animationDataSupplier,
            @ColorInt int backgroundColor,
            long durationMs,
            DoubleConsumer onAlphaChange) {
        assert animationType == HubLayoutAnimationType.EXPAND_NEW_TAB
                        || animationType == HubLayoutAnimationType.EXPAND_TAB
                        || animationType == HubLayoutAnimationType.SHRINK_TAB
                : "Invalid shrink expand HubLayoutAnimationType: " + animationType;
        mAnimationType = animationType;
        mHubContainerView = hubContainerView;
        mAnimatorSupplier = new SyncOneshotSupplierImpl<>();
        mAnimationDataSupplier = animationDataSupplier;
        mDurationMs = durationMs;
        mOnAlphaChange = onAlphaChange;

        mShrinkExpandImageView = new ShrinkExpandImageView(hubContainerView.getContext());
        mShrinkExpandImageView.setVisibility(View.INVISIBLE);
        mShrinkExpandImageView.setBackgroundColor(backgroundColor);
        mHubContainerView.addView(mShrinkExpandImageView);

        mBitmapCallback =
                needsBitmap
                        ? new ImageViewWeakRefBitmapCallback(
                                mShrinkExpandImageView, this::maybeSupplyAnimation)
                        : null;

        if (animationType == HubLayoutAnimationType.SHRINK_TAB
                || animationType == HubLayoutAnimationType.EXPAND_TAB) {
            mAnimationTracker = new AnimationPerformanceTracker();
            mAnimationTracker.addListener(this::recordAnimationMetrics);
        } else {
            mAnimationTracker = null;
        }

        mAnimationDataSupplier.onAvailable(this::onAnimationDataAvailable);
    }

    @Override
    public @HubLayoutAnimationType int getPlannedAnimationType() {
        return mAnimationType;
    }

    @Override
    public @NonNull SyncOneshotSupplier<HubLayoutAnimator> getAnimatorSupplier() {
        return mAnimatorSupplier;
    }

    @Override
    public void supplyAnimatorNow() {
        if (mAnimatorSupplier.hasValue()) return;

        supplyFallbackAnimator();
    }

    @Override
    public @Nullable Callback<Bitmap> getThumbnailCallback() {
        return mBitmapCallback;
    }

    public ShrinkExpandImageView getImageViewForTesting() {
        return mShrinkExpandImageView;
    }

    private void onAnimationDataAvailable(ShrinkExpandAnimationData animationData) {
        if (mShrinkExpandImageView == null || mAnimatorSupplier.hasValue()) return;

        // Preserve the bitmap because it might have been supplied before the animation data.
        mShrinkExpandImageView.resetKeepingBitmap(animationData.getInitialRect());

        if (animationData.shouldUseFallbackAnimation()) {
            supplyFallbackAnimator();
            return;
        }

        mShrinkExpandImageView.runOnNextLayout(
                () -> {
                    mLayoutSatisfied = true;
                    maybeSupplyAnimation();
                });
    }

    private void maybeSupplyAnimation() {
        if (mShrinkExpandImageView == null || mAnimatorSupplier.hasValue()) return;

        boolean bitmapSatisfied =
                mBitmapCallback == null || mShrinkExpandImageView.getBitmap() != null;
        if (!bitmapSatisfied || !mLayoutSatisfied) return;

        supplyAnimator();
    }

    private void supplyFallbackAnimator() {
        if (mAnimationType == HubLayoutAnimationType.EXPAND_NEW_TAB) {
            assert mAnimationDataSupplier.hasValue()
                    : "For new tab animation the data should already be supplied.";
            // This is only possible if layout fails to happen, still try to use the normal
            // animation since after a draw pass things should catch up.
            supplyAnimator();
            return;
        }

        resetState();

        if (mAnimationType == HubLayoutAnimationType.SHRINK_TAB) {
            mAnimatorSupplier.set(
                    FadeHubLayoutAnimationFactory.createFadeInAnimator(
                            mHubContainerView, FADE_DURATION_MS, mOnAlphaChange));
        } else if (mAnimationType == HubLayoutAnimationType.EXPAND_TAB) {
            mAnimatorSupplier.set(
                    FadeHubLayoutAnimationFactory.createFadeOutAnimator(
                            mHubContainerView, FADE_DURATION_MS, mOnAlphaChange));
        } else {
            assert false : "Not reached.";
            // If in production we somehow get here just skip animating entirely.
            mAnimatorSupplier.set(
                    new HubLayoutAnimator(HubLayoutAnimationType.NONE, new AnimatorSet(), null));
        }
    }

    private void supplyAnimator() {
        // A fallback animation has already triggered.
        if (mAnimatorSupplier.hasValue()) return;

        assert mAnimationDataSupplier.hasValue();

        View toolbarView = mHubContainerView.findViewById(R.id.hub_toolbar);
        boolean isShrink = mAnimationType == HubLayoutAnimationType.SHRINK_TAB;
        float initialAlpha = isShrink ? 0.0f : 1.0f;
        float finalAlpha = isShrink ? 1.0f : 0.0f;
        ObjectAnimator fadeAnimator =
                ObjectAnimator.ofFloat(toolbarView, View.ALPHA, initialAlpha, finalAlpha);
        fadeAnimator.setInterpolator(Interpolators.FAST_OUT_LINEAR_IN_INTERPOLATOR);
        fadeAnimator.addUpdateListener(
                animation -> {
                    if (animation.getAnimatedValue() instanceof Float animationAlpha) {
                        mOnAlphaChange.accept(animationAlpha);
                    }
                });

        ShrinkExpandAnimationData animationData = mAnimationDataSupplier.get();
        mShrinkExpandAnimator =
                new ShrinkExpandAnimator(
                        mShrinkExpandImageView,
                        animationData.getInitialRect(),
                        animationData.getFinalRect());
        mShrinkExpandAnimator.setThumbnailSizeForOffset(animationData.getThumbnailSize());
        mShrinkExpandAnimator.setRect(animationData.getInitialRect());

        ObjectAnimator shrinkExpandAnimator =
                ObjectAnimator.ofObject(
                        mShrinkExpandAnimator,
                        ShrinkExpandAnimator.RECT,
                        new RectEvaluator(),
                        animationData.getInitialRect(),
                        animationData.getFinalRect());
        shrinkExpandAnimator.setInterpolator(getInterpolator(mAnimationType));
        if (mAnimationTracker != null) {
            shrinkExpandAnimator.addUpdateListener(ignored -> mAnimationTracker.onUpdate());
        }

        // TODO(crbug.com/40285429): Add the ability to change corner radii of the
        // ShrinkExpandImageView
        // via ShrinkExpandAnimator as part of the animation. For radii use data supplied through
        // ShrinkExpandAnimationData.
        // * Near circular -> 0 for new tab.
        // * 0 -> TabThumbnailView radii for shrink.
        // * TabThumbnailView radii -> 0 for expand.

        AnimatorSet animatorSet = new AnimatorSet();
        animatorSet.playTogether(shrinkExpandAnimator, fadeAnimator);
        animatorSet.setDuration(mDurationMs);

        HubLayoutAnimationListener listener =
                new HubLayoutAnimationListener() {
                    @Override
                    public void beforeStart() {
                        toolbarView.setAlpha(initialAlpha);
                        mOnAlphaChange.accept(initialAlpha);
                        mHubContainerView.setVisibility(View.VISIBLE);
                        mShrinkExpandImageView.setVisibility(View.VISIBLE);
                        if (mAnimationTracker != null) mAnimationTracker.onStart();
                    }

                    @Override
                    public void onEnd(boolean wasForcedToFinish) {
                        // At this point the mShrinkExpandImageView is located at
                        // animationData#getFinalRect(); however, its layout params still has its
                        // dimensions as those from animationData#getInitialRect(). This is because
                        // the animator applies translate and scale transformations rather than
                        // manipulating the layout parameters of the view. Here we want to
                        // explicitly set the layout params of the view to match getFinalRect().
                        // This is effectively a no-op in production as the view will be removed
                        // before the next layout pass. However, it makes testing easier because
                        // the layout dimensions match the expected final state rather than being
                        // transformed from the initial layout parameters.
                        mShrinkExpandImageView.resetKeepingBitmap(animationData.getFinalRect());

                        if (mAnimationTracker != null) {
                            mWasForcedToFinish = wasForcedToFinish;
                            mAnimationTracker.onEnd();
                        }
                    }

                    @Override
                    public void afterEnd() {
                        resetState();
                        // Reset the toolbar to the default alpha of 1. For future animations this
                        // will be updated again. At this point the Hub is either gone or visible
                        // so the correct alpha is 1 regardless of the animation direction.
                        toolbarView.setAlpha(1.0f);
                        mOnAlphaChange.accept(finalAlpha);
                    }
                };

        mAnimatorSupplier.set(new HubLayoutAnimator(mAnimationType, animatorSet, listener));
    }

    /**
     * Cleans up any leftover state that may exist on the {@link HubContainerView} after the
     * animation is finished or the animation is aborted.
     */
    private void resetState() {
        mHubContainerView.removeView(mShrinkExpandImageView);
        mShrinkExpandImageView.setImageBitmap(null);
        mShrinkExpandImageView = null;
    }

    private static Interpolator getInterpolator(@HubLayoutAnimationType int animationType) {
        if (animationType == HubLayoutAnimationType.EXPAND_NEW_TAB) {
            return Interpolators.STANDARD_INTERPOLATOR;
        }
        return Interpolators.EMPHASIZED;
    }

    private void recordAnimationMetrics(AnimationMetrics metrics) {
        if (mWasForcedToFinish || metrics.getFrameCount() == 0) return;

        long totalDurationMs = metrics.getLastFrameTimeMs() - mCreationTime;

        assert mAnimationType != HubLayoutAnimationType.EXPAND_NEW_TAB;
        String suffix = mAnimationType == HubLayoutAnimationType.SHRINK_TAB ? ".Shrink" : ".Expand";

        RecordHistogram.recordCount100Histogram(
                "GridTabSwitcher.FramePerSecond" + suffix,
                Math.round(metrics.getFramesPerSecond()));
        RecordHistogram.recordTimesHistogram(
                "GridTabSwitcher.MaxFrameInterval" + suffix, metrics.getMaxFrameIntervalMs());
        RecordHistogram.recordTimesHistogram(
                "Android.GridTabSwitcher.Animation.TotalDuration" + suffix, totalDurationMs);
        RecordHistogram.recordTimesHistogram(
                "Android.GridTabSwitcher.Animation.FirstFrameLatency" + suffix,
                metrics.getFirstFrameLatencyMs());
    }
}