chromium/components/browser_ui/widget/android/java/src/org/chromium/components/browser_ui/widget/scrim/ScrimMediator.java

// Copyright 2020 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.components.browser_ui.widget.scrim;

import android.animation.Animator;
import android.animation.AnimatorListenerAdapter;
import android.animation.ValueAnimator;
import android.view.MotionEvent;

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

import org.chromium.base.MathUtils;
import org.chromium.components.browser_ui.widget.animation.CancelAwareAnimatorListener;
import org.chromium.ui.interpolators.Interpolators;
import org.chromium.ui.modelutil.PropertyKey;
import org.chromium.ui.modelutil.PropertyModel;
import org.chromium.ui.modelutil.PropertyObservable;
import org.chromium.ui.modelutil.PropertyObservable.PropertyObserver;

/** This class holds the animation and related business logic for the scrim. */
class ScrimMediator implements ScrimCoordinator.TouchEventDelegate {
    /** A callback that is run when the scrim has completely hidden. */
    private final @NonNull Runnable mScrimHiddenRunnable;

    /** A means of changing the system UI color. */
    private final @Nullable ScrimCoordinator.SystemUiScrimDelegate mSystemUiScrimDelegate;

    private final PropertyObserver<PropertyKey> mOnModelChange = this::onModelChange;

    /** The animator for fading the view in. */
    private ValueAnimator mOverlayFadeInAnimator;

    /** The animator for fading the view out. */
    private ValueAnimator mOverlayFadeOutAnimator;

    /** The active animator (if any). */
    private Animator mOverlayAnimator;

    /** The model for the scrim component. */
    private @Nullable PropertyModel mModel;

    /** Whether the scrim is currently visible. */
    private boolean mCurrentVisibility;

    /** If true, {@code mActiveParams.eventFilter} is set, but never had an event passed to it. */
    private boolean mIsNewEventFilter;

    /** Whether the scrim is in the process of hiding or is currently hidden. */
    private boolean mIsHidingOrHidden;

    private boolean mDisableAnimationForTesting;

    /**
     * @param scrimHiddenRunnable A mechanism for hiding the scrim.
     * @param systemUiScrimDelegate A means of changing the scrim over the system UI.
     */
    ScrimMediator(
            @NonNull Runnable scrimHiddenRunnable,
            @Nullable ScrimCoordinator.SystemUiScrimDelegate systemUiScrimDelegate) {
        mScrimHiddenRunnable = scrimHiddenRunnable;
        mSystemUiScrimDelegate = systemUiScrimDelegate;
    }

    /** Triggers a fade in of the scrim creating a new animation if necessary. */
    void showScrim(@NonNull PropertyModel model, int animDurationMs) {
        // ALPHA is a protected property for this component that will only get added to the model
        // if ScrimProperties is used to build it.
        assert model.getAllProperties().contains(ScrimProperties.ALPHA)
                : "Use ScrimProperties to build the model used to show the scrim.";

        // Check the anchor here rather than in the model since clearing the scrim params
        // internally allows the anchor to be null.
        assert model.get(ScrimProperties.ANCHOR_VIEW) != null
                : "The anchor for the scrim cannot be null.";

        if (mModel != null && mSystemUiScrimDelegate != null) {
            mModel.removeObserver(mOnModelChange);
        }
        mModel = model;
        mIsHidingOrHidden = false;
        int fadeDurationMs = getAnimationDuration(animDurationMs);

        // Pass the current scrim color to the SystemUiScrimDelegate.
        if (mSystemUiScrimDelegate != null
                && model.getAllSetProperties().contains(ScrimProperties.BACKGROUND_COLOR)) {
            @ColorInt int color = model.get(ScrimProperties.BACKGROUND_COLOR);
            mSystemUiScrimDelegate.setScrimColor(color);
            mModel.addObserver(mOnModelChange);
        }

        // Make sure alpha is reset to 0 since the model may be reused.
        setAlphaInternal(0.f);

        if (mOverlayFadeInAnimator == null) {
            mOverlayFadeInAnimator = ValueAnimator.ofFloat(0, 1);
            mOverlayFadeInAnimator.setDuration(fadeDurationMs);
            mOverlayFadeInAnimator.setInterpolator(Interpolators.LINEAR_OUT_SLOW_IN_INTERPOLATOR);
            mOverlayFadeInAnimator.addListener(
                    new CancelAwareAnimatorListener() {
                        @Override
                        public void onEnd(Animator animation) {
                            mOverlayAnimator = null;
                        }

                        @Override
                        public void onCancel(Animator animation) {
                            setAlphaInternal(0.f);
                            onEnd(animation);
                        }
                    });
            mOverlayFadeInAnimator.addUpdateListener(
                    animation -> {
                        setAlphaInternal((float) animation.getAnimatedValue());
                    });
        }

        if (model.getAllSetProperties().contains(ScrimProperties.GESTURE_DETECTOR)) {
            mIsNewEventFilter = model.get(ScrimProperties.GESTURE_DETECTOR) != null;
        }
        mOverlayFadeInAnimator.setFloatValues(mModel.get(ScrimProperties.ALPHA), 1f);
        runFadeAnimation(mOverlayFadeInAnimator);
    }

    private void onModelChange(
            PropertyObservable<PropertyKey> source, @Nullable PropertyKey propertyKey) {
        assert mSystemUiScrimDelegate != null;
        if (propertyKey == ScrimProperties.BACKGROUND_COLOR) {
            @ColorInt int color = mModel.get(ScrimProperties.BACKGROUND_COLOR);
            mSystemUiScrimDelegate.setScrimColor(color);
        }
    }

    private int getAnimationDuration(int animDurationMs) {
        return mDisableAnimationForTesting ? 0 : animDurationMs;
    }

    /**
     * Triggers a fade out of the scrim creating a new animation if necessary.
     * @param animate Whether the scrim should fade out.
     * @param animDurationMs Duration for animation run.
     */
    void hideScrim(boolean animate, int animDurationMs) {
        assert mModel != null : "#hideScrim(...) was called on an inactive scrim!";
        if (mIsHidingOrHidden) {
            if (mOverlayAnimator != null && !animate) mOverlayAnimator.end();
            return;
        }

        int fadeDurationMs = getAnimationDuration(animDurationMs);

        if (mOverlayFadeOutAnimator == null) {
            mOverlayFadeOutAnimator = ValueAnimator.ofFloat(1, 0);
            mOverlayFadeOutAnimator.setDuration(fadeDurationMs);
            mOverlayFadeOutAnimator.setInterpolator(Interpolators.FAST_OUT_LINEAR_IN_INTERPOLATOR);
            mOverlayFadeOutAnimator.addListener(
                    new CancelAwareAnimatorListener() {
                        @Override
                        public void onEnd(Animator animation) {
                            // If the animation wasn't ended early, alpha will already be 0 and the
                            // model will be null as a result of #setAlphaInternal().
                            if (mModel != null) setAlphaInternal(0.f);
                            mOverlayAnimator = null;
                        }

                        @Override
                        public void onCancel(Animator animation) {
                            onEnd(animation);
                        }
                    });
            mOverlayFadeOutAnimator.addUpdateListener(
                    animation -> {
                        setAlphaInternal((float) animation.getAnimatedValue());
                    });
            mOverlayFadeOutAnimator.addListener(
                    new AnimatorListenerAdapter() {
                        @Override
                        public void onAnimationEnd(Animator animation) {
                            // Reset the scrim color stored in the SystemUiScrimDelegate.
                            if (mSystemUiScrimDelegate != null) {
                                mSystemUiScrimDelegate.setScrimColor(ScrimProperties.INVALID_COLOR);
                            }
                        }
                    });
        }

        mIsHidingOrHidden = true;
        mOverlayFadeOutAnimator.setFloatValues(mModel.get(ScrimProperties.ALPHA), 0f);
        runFadeAnimation(mOverlayFadeOutAnimator);
        if (!animate) mOverlayFadeOutAnimator.end();
    }

    /**
     * Manually set the alpha for the scrim. This is exposed as part of the public API and should
     * not be called as part of animations as it cancels the currently running one.
     * @param alpha The new alpha for the scrim in range [0, 1].
     */
    void setAlpha(float alpha) {
        if (mOverlayAnimator != null) mOverlayAnimator.cancel();
        setAlphaInternal(alpha);
    }

    /**
     * This method actually changes the alpha and can be used for setting the alpha via animation.
     * @param alpha The new alpha for the scrim in range [0, 1].
     */
    private void setAlphaInternal(float alpha) {
        // TODO(mdjones): This null check is exclusively for Android K which has a slightly
        //                different order for animation events. Once deprecated we should remove it.
        if (mModel == null) return;
        if (MathUtils.areFloatsEqual(alpha, mModel.get(ScrimProperties.ALPHA))) return;
        mModel.set(ScrimProperties.ALPHA, alpha);
        if (mModel.get(ScrimProperties.AFFECTS_STATUS_BAR) && mSystemUiScrimDelegate != null) {
            mSystemUiScrimDelegate.setStatusBarScrimFraction(alpha);
        }
        if (mModel.getAllSetProperties().contains(ScrimProperties.AFFECTS_NAVIGATION_BAR)
                && mModel.get(ScrimProperties.AFFECTS_NAVIGATION_BAR)
                && mSystemUiScrimDelegate != null) {
            mSystemUiScrimDelegate.setNavigationBarScrimFraction(alpha);
        }

        boolean isVisible = alpha > Float.MIN_NORMAL;
        if (mModel.get(ScrimProperties.VISIBILITY_CALLBACK) != null
                && mCurrentVisibility != isVisible) {
            mModel.get(ScrimProperties.VISIBILITY_CALLBACK).onResult(isVisible);
        }
        mCurrentVisibility = isVisible;

        if (mIsHidingOrHidden && !isVisible && mModel != null) {
            if (mSystemUiScrimDelegate != null) {
                mModel.removeObserver(mOnModelChange);
            }
            mModel = null;
            mScrimHiddenRunnable.run();
        }
    }

    /**
     * Runs an animation for this view. If one is running, the existing one will be canceled.
     * @param fadeAnimation The animation to run.
     */
    private void runFadeAnimation(Animator fadeAnimation) {
        if (mOverlayAnimator == fadeAnimation && mOverlayAnimator.isRunning()) {
            return;
        } else if (mOverlayAnimator != null) {
            mOverlayAnimator.cancel();
        }
        mOverlayAnimator = fadeAnimation;
        mOverlayAnimator.start();
    }

    /** @return Whether the scrim is still active (has a non-null model). */
    boolean isActive() {
        return mModel != null;
    }

    /** Force the current animation to run to completion immediately. */
    void forceAnimationToFinish() {
        if (mOverlayAnimator != null) {
            mOverlayAnimator.end();
        }
    }

    /** "Destroy" the mediator and clean up any state. */
    void destroy() {
        // If the scrim was active, ending the animation will clean up any state, otherwise noop.
        forceAnimationToFinish();
    }

    void disableAnimationForTesting(boolean disable) {
        mDisableAnimationForTesting = disable;
    }

    @VisibleForTesting
    boolean areAnimationsRunning() {
        return mOverlayAnimator != null && mOverlayAnimator.isRunning();
    }

    @Override
    public boolean onTouchEvent(MotionEvent e) {
        if (mIsHidingOrHidden) return false;
        if (!mModel.getAllSetProperties().contains(ScrimProperties.GESTURE_DETECTOR)) return false;

        // Make sure the first event that goes through the filter is an ACTION_DOWN, even in the
        // case where the filter is added while a gesture is already in progress.
        if (mIsNewEventFilter && e.getActionMasked() != MotionEvent.ACTION_DOWN) {
            MotionEvent downEvent = MotionEvent.obtain(e);
            downEvent.setAction(MotionEvent.ACTION_DOWN);
            if (!mModel.get(ScrimProperties.GESTURE_DETECTOR).onTouchEvent(downEvent)) return false;
        }
        mIsNewEventFilter = false;
        return mModel.get(ScrimProperties.GESTURE_DETECTOR).onTouchEvent(e);
    }
}