chromium/components/browser_ui/widget/android/java/src/org/chromium/components/browser_ui/widget/highlight/PulseDrawable.java

// Copyright 2017 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.highlight;

import android.content.Context;
import android.graphics.Canvas;
import android.graphics.ColorFilter;
import android.graphics.Paint;
import android.graphics.PixelFormat;
import android.graphics.Rect;
import android.graphics.RectF;
import android.graphics.drawable.Animatable;
import android.graphics.drawable.Drawable;
import android.os.SystemClock;
import android.view.animation.Interpolator;

import androidx.annotation.ColorInt;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.Px;
import androidx.core.view.animation.PathInterpolatorCompat;

import org.chromium.base.MathUtils;
import org.chromium.components.browser_ui.styles.SemanticColorUtils;
import org.chromium.components.browser_ui.widget.R;
import org.chromium.ui.interpolators.Interpolators;

/**
 * A custom {@link Drawable} that will animate a pulse using the {@link PulseInterpolator}.  Meant
 * to be created with a {@link Painter} that does the actual drawing work based on the pulse
 * interpolation value.
 */
public class PulseDrawable extends Drawable implements Animatable {
    private static final long PULSE_DURATION_MS = 2500;
    private static final long FRAME_RATE = 60;

    /**
     * Informs the PulseDrawable about whether it can continue pulsing, and specifies a callback to
     * be run when the PulseDrawable is finished pulsing.
     */
    public interface PulseEndAuthority {
        /**
         * Called at the end of one pulse animation, to decide whether the PulseDrawable can pulse
         * again.
         *
         * @return True iff the PulseDrawable can continue pulsing.
         */
        boolean canPulseAgain();
    }

    /** A PulseEndAuthority which allows the PulseDrawable to pulse forever. */
    private static class EndlessPulser implements PulseEndAuthority {
        // PulseEndAuthority implementation.

        @Override
        public boolean canPulseAgain() {
            return true;
        }
    }

    /**
     * An interface that does the actual drawing work for this {@link Drawable}.  Not meant to be
     * stateful, as this could be shared across multiple instances of this drawable if it gets
     * copied or mutated.
     */
    private interface Painter {
        /**
         * Called when this drawable updates it's pulse interpolation.  Should mutate the drawable
         * as necessary.  This is responsible for invalidating this {@link Drawable} if something
         * needs to be redrawn.
         *
         * @param drawable      The {@link PulseDrawable} that is updated.
         * @param interpolation The current progress of whatever is being pulsed.
         */
        void modifyDrawable(PulseDrawable drawable, float interpolation);

        /**
         * Called when this {@link PulseDrawable} needs to draw.  Should perform any draw operation
         * for the specific type of pulse.
         * @param drawable      The calling {@link PulseDrawable}.
         * @param paint         A {@link Paint} object to use.  This will automatically have the
         *                      color set.
         * @param canvas        The {@link Canvas} to draw to.
         * @param interpolation The current progress of whatever is being pulsed.
         */
        void draw(PulseDrawable drawable, Paint paint, Canvas canvas, float interpolation);
    }

    /** Interface for calculating the max and min bounds in a pulsing circle. */
    public interface Bounds {
        /**
         * Calculates the maximum radius of a pulsing circle.
         * @param bounds the bounds of the canvas.
         * @return floating point maximum radius.
         */
        float getMaxRadiusPx(Rect bounds);

        /**
         * @param bounds the bounds of the canvas.
         * @return floating point minimum radius.
         */
        float getMinRadiusPx(Rect bounds);
    }

    private static Painter createCirclePainter(Bounds boundsFn) {
        return new Painter() {
            @Override
            public void modifyDrawable(PulseDrawable drawable, float interpolation) {
                drawable.invalidateSelf();
            }

            @Override
            public void draw(
                    PulseDrawable drawable, Paint paint, Canvas canvas, float interpolation) {
                Rect bounds = drawable.getBounds();

                float minRadiusPx = boundsFn.getMinRadiusPx(bounds);
                float maxRadiusPx = boundsFn.getMaxRadiusPx(bounds);
                float radius =
                        MathUtils.interpolate(minRadiusPx, maxRadiusPx, 1.0f - interpolation);

                canvas.drawCircle(bounds.exactCenterX(), bounds.exactCenterY(), radius, paint);
            }
        };
    }

    /**
     * Creates a {@link PulseDrawable} that will fill the bounds with a pulsing color.
     * @param context The {@link Context} under which the drawable is created.
     * @param cornerRadius The corner radius in pixels of the highlight rectangle, 0 may be passed
     *         if the rectangle should not be rounded.
     * @param highlightExtension How far in pixels the highlight should be extended past the bounds
     *         of the view. 0 should be passed if there should be no extension.
     * @param pulseEndAuthority The {@link PulseEndAuthority} associated with this drawable.
     * @return A new {@link PulseDrawable} instance.
     */
    public static PulseDrawable createRoundedRectangle(
            Context context,
            @Px int cornerRadius,
            @Px int highlightExtension,
            PulseEndAuthority pulseEndAuthority) {
        Painter painter =
                new Painter() {
                    @Override
                    public void modifyDrawable(PulseDrawable drawable, float interpolation) {
                        drawable.setAlpha((int) MathUtils.interpolate(12, 75, interpolation));
                    }

                    @Override
                    public void draw(
                            PulseDrawable drawable,
                            Paint paint,
                            Canvas canvas,
                            float interpolation) {
                        Rect bounds = drawable.getBounds();
                        if (highlightExtension != 0) {
                            bounds =
                                    new Rect(
                                            bounds.left - highlightExtension,
                                            bounds.top - highlightExtension,
                                            bounds.right + highlightExtension,
                                            bounds.bottom + highlightExtension);
                        }
                        canvas.drawRoundRect(new RectF(bounds), cornerRadius, cornerRadius, paint);
                    }
                };

        return new PulseDrawable(
                context, Interpolators.FAST_OUT_SLOW_IN_INTERPOLATOR, painter, pulseEndAuthority);
    }

    /**
     * Creates a {@link PulseDrawable} that will fill the bounds with a pulsing color. The {@link
     * PulseDrawable} will continue pulsing forever (if this is not the desired behavior, please use
     * {@link PulseEndAuthority}).
     * @param context The {@link Context} under which the drawable is created.
     * @param cornerRadius The corner radius in pixels of the highlight rectangle.
     * @param highlightExtension How far in pixels the highlight should be extended past the bounds
     *         of the view. 0 should be passed if there should be no extension.
     * @return A new {@link PulseDrawable} instance.
     */
    public static PulseDrawable createRoundedRectangle(
            Context context, @Px int cornerRadius, @Px int highlightExtension) {
        return createRoundedRectangle(
                context, cornerRadius, highlightExtension, new EndlessPulser());
    }

    /**
     * Creates a {@link PulseDrawable} that will fill the bounds with a pulsing color. The {@link
     * PulseDrawable} will continue pulsing forever (if this is not the desired behavior, please use
     * {@link PulseEndAuthority}).
     * @param context The {@link Context} under which the drawable is created.
     * @return A new {@link PulseDrawable} instance.
     */
    public static PulseDrawable createRectangle(Context context) {
        return createRoundedRectangle(context, 0, /* highlightExtension= */ 0);
    }

    /**
     * Creates a {@link PulseDrawable} that will draw a pulsing circle inside the bounds.
     * @param context The {@link Context} under which the drawable is created.
     * @param pulseEndAuthority The {@link PulseEndAuthority} associated with this drawable. If
     *         null, the default implementation is used.
     * @return A new {@link PulseDrawable} instance.
     */
    public static PulseDrawable createCircle(
            Context context, @Nullable PulseEndAuthority pulseEndAuthority) {
        final int startingPulseRadiusPx =
                context.getResources().getDimensionPixelSize(R.dimen.iph_pulse_baseline_radius);

        return createCustomCircle(
                context,
                new Bounds() {
                    @Override
                    public float getMaxRadiusPx(Rect bounds) {
                        return Math.min(
                                startingPulseRadiusPx * 1.2f,
                                Math.min(bounds.width(), bounds.height()) / 2.f);
                    }

                    @Override
                    public float getMinRadiusPx(Rect bounds) {
                        return Math.min(
                                startingPulseRadiusPx,
                                Math.min(bounds.width(), bounds.height()) / 2.f);
                    }
                },
                pulseEndAuthority != null ? pulseEndAuthority : new EndlessPulser());
    }

    /**
     * Creates a {@link PulseDrawable} that will draw a pulsing circle inside the bounds. The {@link
     * PulseDrawable} will continue pulsing forever (if this is not the desired behavior, please use
     * {@link PulseEndAuthority}).
     * @param context The {@link Context} under which the drawable is created.
     * @return A new {@link PulseDrawable} instance.
     */
    public static PulseDrawable createCircle(Context context) {
        return createCircle(context, new EndlessPulser());
    }

    /**
     * Creates a {@link PulseDrawable} that will draw a pulsing circle as large as possible inside
     * the bounds.
     * @param context The {@link Context} under which the drawable is created.
     * @param boundsfn Defines size of the pulsing circle.
     * @param pulseEndAuthority The {@link PulseEndAuthority} associated with this drawable. If
     *         null, the default implementation is used.
     * @return A new {@link PulseDrawable} instance.
     */
    public static PulseDrawable createCustomCircle(
            Context context, Bounds boundsfn, @Nullable PulseEndAuthority pulseEndAuthority) {
        Painter painter = createCirclePainter(boundsfn);

        PulseDrawable drawable =
                new PulseDrawable(
                        context,
                        PathInterpolatorCompat.create(.8f, 0.f, .6f, 1.f),
                        painter,
                        pulseEndAuthority != null ? pulseEndAuthority : new EndlessPulser());
        drawable.setAlpha(76);
        return drawable;
    }

    /**
     * Creates a {@link PulseDrawable} that will draw a pulsing circle as large as possible inside
     * the bounds.
     * @param context The {@link Context} under which the drawable is created.
     * @return A new {@link PulseDrawable} instance.
     */
    public static PulseDrawable createCustomCircle(Context context, Bounds boundsfn) {
        return createCustomCircle(context, boundsfn, new EndlessPulser());
    }

    private final Runnable mNextFrame =
            new Runnable() {
                @Override
                public void run() {
                    stepPulse();
                    if (mRunning) {
                        scheduleSelf(mNextFrame, SystemClock.uptimeMillis() + 1000 / FRAME_RATE);
                    }
                }
            };

    private final Paint mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
    private final Rect mInset = new Rect();
    private final Rect mOriginalBounds = new Rect();
    private final Rect mInsetBounds = new Rect();

    private PulseState mState;
    private boolean mMutated;
    private boolean mRunning;
    private long mLastUpdateTime;

    private final PulseEndAuthority mPulseEndAuthority;

    /**
     * Creates a new {@link PulseDrawable} instance.
     * @param context The {@link Context} under which the drawable is created.
     * @param interpolator An {@link Interpolator} that defines how the pulse will fade in and out.
     * @param painter      The {@link Painter} that will be responsible for drawing the pulse.
     * @param pulseEndAuthority The {@link PulseEndAuthority} that is associated with this drawable.
     */
    private PulseDrawable(
            Context context,
            Interpolator interpolator,
            Painter painter,
            PulseEndAuthority pulseEndAuthority) {
        this(new PulseState(interpolator, painter), pulseEndAuthority);
        setUseLightPulseColor(context, false);
    }

    private PulseDrawable(PulseState state, PulseEndAuthority pulseEndAuthority) {
        mState = state;
        mPulseEndAuthority = pulseEndAuthority;
    }

    private PulseDrawable(PulseState state) {
        this(state, new EndlessPulser());
    }

    /**
     * @param context The {@link Context} for accessing colors.
     * @param useLightPulseColor Whether or not to use a light or dark color for the pulse.
     * */
    public void setUseLightPulseColor(Context context, boolean useLightPulseColor) {
        @ColorInt
        int color =
                useLightPulseColor
                        ? context.getColor(R.color.default_icon_color_blue_light)
                        : SemanticColorUtils.getDefaultIconColorAccent1(context);
        if (mState.color == color) return;

        int alpha = getAlpha();
        mState.color = mState.drawColor = color;
        setAlpha(alpha);
        invalidateSelf();
    }

    /** How much to inset the bounds of this {@link Drawable} by. */
    public void setInset(int left, int top, int right, int bottom) {
        mInset.set(left, top, right, bottom);
        if (!mOriginalBounds.isEmpty()) setBounds(mOriginalBounds);
    }

    // Animatable implementation.
    @Override
    public void start() {
        if (mRunning) {
            unscheduleSelf(mNextFrame);
            scheduleSelf(mNextFrame, SystemClock.uptimeMillis() + 1000 / FRAME_RATE);
        } else {
            mRunning = true;
            if (mState.startTime == 0) {
                mState.startTime = SystemClock.uptimeMillis();
                mLastUpdateTime = mState.startTime;
            }
            mNextFrame.run();
        }
    }

    @Override
    public void stop() {
        mRunning = false;
        mState.startTime = 0;
        unscheduleSelf(mNextFrame);
    }

    @Override
    public boolean isRunning() {
        return mRunning;
    }

    // Drawable implementation.
    // Overriding only this method because {@link Drawable#setBounds(Rect)} calls into this.
    @Override
    public void setBounds(int left, int top, int right, int bottom) {
        mOriginalBounds.set(left, top, right, bottom);
        mInsetBounds.set(
                left + mInset.left, top + mInset.top, right - mInset.right, bottom - mInset.bottom);
        super.setBounds(
                mInsetBounds.left, mInsetBounds.top, mInsetBounds.right, mInsetBounds.bottom);
    }

    @Override
    public void draw(@NonNull Canvas canvas) {
        mPaint.setColor(mState.drawColor);
        mState.painter.draw(this, mPaint, canvas, mState.progress);
    }

    @Override
    public void setAlpha(int alpha) {
        // Encode the alpha into the color.
        alpha += alpha >> 7; // make it 0..256
        final int baseAlpha = mState.color >>> 24;
        final int useAlpha = baseAlpha * alpha >> 8;
        final int useColor = (mState.color << 8 >>> 8) | (useAlpha << 24);
        if (mState.drawColor != useColor) {
            mState.drawColor = useColor;
            invalidateSelf();
        }
    }

    @Override
    public int getAlpha() {
        return mState.drawColor >>> 24;
    }

    @Override
    public void setColorFilter(ColorFilter colorFilter) {
        mPaint.setColorFilter(colorFilter);
    }

    @Override
    public int getOpacity() {
        return PixelFormat.TRANSLUCENT;
    }

    @Override
    public boolean setVisible(boolean visible, boolean restart) {
        final boolean changed = super.setVisible(visible, restart);
        if (visible) {
            if (changed || restart) start();
        } else {
            stop();
        }
        return changed;
    }

    @Override
    @NonNull
    public Drawable mutate() {
        if (!mMutated && super.mutate() == this) {
            mState = new PulseState(mState);
            mMutated = true;
        }
        return this;
    }

    @Override
    public ConstantState getConstantState() {
        return mState;
    }

    private void stepPulse() {
        long curTime = SystemClock.uptimeMillis();
        // If we are on a new pulse
        if ((mLastUpdateTime - mState.startTime) / PULSE_DURATION_MS
                != (curTime - mState.startTime) / PULSE_DURATION_MS) {
            if (!(mPulseEndAuthority.canPulseAgain())) {
                stop();
                return;
            }
        }
        long msIntoAnim = (curTime - mState.startTime) % PULSE_DURATION_MS;
        float timeProgress = ((float) msIntoAnim) / ((float) PULSE_DURATION_MS);
        mState.progress = mState.interpolator.getInterpolation(timeProgress);
        mState.painter.modifyDrawable(PulseDrawable.this, mState.progress);
        mLastUpdateTime = curTime;
    }

    /** The {@link ConstantState} subclass for this {@link PulseDrawable}. */
    static final class PulseState extends ConstantState {
        // Current Paint State.
        /** The current color, including alpha, to draw. */
        public int drawColor;

        /** The original color to draw (will not include updates from calls to setAlpha()). */
        public int color;

        // Current Animation State
        /** The time from {@link SystemClock#uptimeMillis} that this animation started at. */
        public long startTime;

        /** The current progress from 0 to 1 of the pulse. */
        public float progress;

        /** The {@link Interpolator} that makes the pulse and generates the progress. */
        public Interpolator interpolator;

        /**
         * The {@link Painter} object that is responsible for modifying and drawing this
         * {@link PulseDrawable}.
         */
        public Painter painter;

        PulseState(Interpolator interpolator, Painter painter) {
            this.interpolator = new PulseInterpolator(interpolator);
            this.painter = painter;
        }

        PulseState(PulseState other) {
            drawColor = other.drawColor;
            color = other.color;

            startTime = other.startTime;

            interpolator = other.interpolator;
            painter = other.painter;
        }

        @Override
        public Drawable newDrawable() {
            return new PulseDrawable(this);
        }

        @Override
        public int getChangingConfigurations() {
            return 0;
        }
    }
}