chromium/components/browser_ui/widget/android/java/src/org/chromium/components/browser_ui/widget/highlight/ViewHighlighter.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.drawable.Drawable;
import android.graphics.drawable.LayerDrawable;
import android.view.View;

import androidx.annotation.Nullable;
import androidx.annotation.Px;
import androidx.core.view.ViewCompat;

import org.chromium.components.browser_ui.widget.R;

/**
 * A helper class to draw an overlay layer on the top of a view to enable highlighting. The overlay
 * layer can be specified to be a circle or a rectangle.
 */
public class ViewHighlighter {
    /**
     * Represents the delay between when menu anchor/toolbar handle/expand button was tapped and
     * when the menu or bottom sheet opened up. Unfortunately this is sensitive because if it is too
     * small, we might clear the state before the menu item got a chance to be highlighted. If it is
     * too large, user might tap somewhere else and then open the menu/bottom sheet to see the UI
     * still highlighted.
     */
    public static final int IPH_MIN_DELAY_BETWEEN_TWO_HIGHLIGHTS = 200;

    /**
     * Allows its associated PulseDrawable to pulse a specified number of times, then turns off the
     * PulseDrawable highlight.
     */
    private static class NumberPulser implements PulseDrawable.PulseEndAuthority {
        private final View mView;
        private int mNumPulsesRemaining;

        NumberPulser(View view, int numPulses) {
            mView = view;
            mNumPulsesRemaining = numPulses;
        }

        @Override
        public boolean canPulseAgain() {
            mNumPulsesRemaining--;
            if (mNumPulsesRemaining == 0) ViewHighlighter.turnOffHighlight(mView);
            return mNumPulsesRemaining > 0;
        }
    }

    /** Possible highlight shapes. */
    public enum HighlightShape {
        CIRCLE,
        RECTANGLE;
    }

    /** Params for highlight customization. */
    public static class HighlightParams {
        private final HighlightShape mShape;
        // If true, the highlight will respect the view's padding. If false, it will be
        // centered within view's bounding box.
        private boolean mBoundsRespectPadding;
        // Used if drawable should pulse only a given number of times.
        private int mNumPulses;
        // Only valid for HighlightShape.CIRCLE.
        // Used to customize the size of pulse if needed.
        @Nullable private PulseDrawable.Bounds mCircleRadius;
        // Only valid for HighlightShape.RECTANGLE The corner radius od rectangle in pixels. Used
        // to created a rounded rectangle.
        @Px private int mCornerRadius;
        // How far the highlight should extend past the bounds of the view.
        @Px private int mHighlightExtension;

        public HighlightParams(HighlightShape shape) {
            mShape = shape;
        }

        /** @return shape of the highlight */
        public HighlightShape getShape() {
            return mShape;
        }

        /**
         * @param respectPadding whether the highlight should respect the view's padding or be
         * centered in its bounding box
         */
        public void setBoundsRespectPadding(boolean respectPadding) {
            mBoundsRespectPadding = respectPadding;
        }

        /**
         * @return if true, the highlight will respect the view's padding. If false, it will be
         *         centered within view's bounding box
         */
        public boolean getBoundsRespectPadding() {
            return mBoundsRespectPadding;
        }

        /**
         * Only supported for {@code HighlightShape#CIRCLE}.
         * @param radius custom definition of the size of highlight's pulse
         */
        public void setCircleRadius(PulseDrawable.Bounds radius) {
            assert mShape == HighlightShape.CIRCLE;
            mCircleRadius = radius;
        }

        /** @return custom definition of the size of highlight's pulse or null of not set */
        @Nullable
        public PulseDrawable.Bounds getCircleRadius() {
            return mCircleRadius;
        }

        /**
         *  Only supported for {@code HighlightShape#RECTANGLE}.
         *  @param radius value in pixels of a corner radius of a rounded rectangle highlight
         */
        public void setCornerRadius(@Px int radius) {
            assert mShape == HighlightShape.RECTANGLE;
            mCornerRadius = radius;
        }

        /** @return value in pixels of a corner radius of a rounded rectangle highlight */
        public @Px int getCornerRadius() {
            return mCornerRadius;
        }

        /**
         * @param highlightExtension How far the highlight should be extended past the bounds of the
         *        view.
         */
        public void setHighlightExtension(@Px int highlightExtension) {
            mHighlightExtension = highlightExtension;
        }

        /**
         * @return Value in pixels of how far the highlight should extend past the bounds of the
         *         view.
         */
        public @Px int getHighlightExtension() {
            return mHighlightExtension;
        }

        /** @param num set if drawable should pulse only a certain number of times */
        public void setNumPulses(int num) {
            assert num > 0;
            mNumPulses = num;
        }

        /** @return if > 0 drawable should pulse exactly this number of times */
        public int getNumPulses() {
            return mNumPulses;
        }
    }

    /**
     * Attach a custom PulseDrawable as a highlight layer over the view.
     *
     * Will not highlight if the view is already highlighted.
     *
     * @param view The view to be highlighted.
     * @param pulseDrawable The highlight.
     */
    private static void attachViewAsHighlight(View view, PulseDrawable pulseDrawable) {
        boolean highlighted =
                view.getTag(R.id.highlight_state) != null
                        ? (boolean) view.getTag(R.id.highlight_state)
                        : false;
        if (highlighted) return;

        Drawable background = view.getBackground();
        if (background != null) {
            background = background.mutate();
        }

        Drawable[] layers =
                background == null
                        ? new Drawable[] {pulseDrawable}
                        : new Drawable[] {background, pulseDrawable};
        LayerDrawable drawable = new LayerDrawable(layers);
        view.setBackground(drawable);
        view.setTag(R.id.highlight_state, true);

        pulseDrawable.start();
    }

    /**
     * Create a highlight layer over the view.
     * Will not highlight if the view is already highlighted.
     *
     * @param view The view to be highlighted.
     * @param params Definition of the highlight.
     */
    public static void turnOnHighlight(View view, HighlightParams params) {
        if (view == null) return;

        PulseDrawable drawable = null;
        if (params.getShape() == HighlightShape.CIRCLE) {
            drawable =
                    createCircle(
                            view,
                            params.getNumPulses(),
                            params.getBoundsRespectPadding(),
                            params.getCircleRadius());
        } else {
            drawable =
                    createRectangle(
                            view,
                            params.getNumPulses(),
                            params.getBoundsRespectPadding(),
                            params.getCornerRadius(),
                            params.getHighlightExtension());
        }
        attachViewAsHighlight(view, drawable);
    }

    /**
     * Turns off the highlight from the view. The original background of the view is restored.
     * @param view The associated view.
     */
    public static void turnOffHighlight(View view) {
        if (view == null) return;

        boolean highlighted =
                view.getTag(R.id.highlight_state) != null
                        ? (boolean) view.getTag(R.id.highlight_state)
                        : false;
        if (!highlighted) return;
        view.setTag(R.id.highlight_state, false);

        Drawable existingBackground = view.getBackground();
        if (existingBackground instanceof LayerDrawable) {
            LayerDrawable layerDrawable = (LayerDrawable) existingBackground;
            if (layerDrawable.getNumberOfLayers() >= 2) {
                view.setBackground(layerDrawable.getDrawable(0));
            } else {
                view.setBackground(null);
            }
        }
    }

    /** Helper method to create a circular drawable from the values of {@code HighlightParams}. */
    private static PulseDrawable createCircle(
            View view,
            int numPulses,
            boolean boundsRespectPadding,
            @Nullable PulseDrawable.Bounds circleRadius) {
        PulseDrawable drawable = null;
        Context context = view.getContext();
        PulseDrawable.PulseEndAuthority pulseEndAuthority =
                numPulses > 0 ? new NumberPulser(view, numPulses) : null;
        if (circleRadius != null) {
            drawable = PulseDrawable.createCustomCircle(context, circleRadius, pulseEndAuthority);
        } else {
            drawable = PulseDrawable.createCircle(context, pulseEndAuthority);
        }
        if (boundsRespectPadding) {
            drawable.setInset(
                    ViewCompat.getPaddingStart(view),
                    view.getPaddingTop(),
                    ViewCompat.getPaddingEnd(view),
                    view.getPaddingBottom());
        }
        return drawable;
    }

    /** Helper method to create a rectangular drawable from the values of {@code HighlightParams}. */
    private static PulseDrawable createRectangle(
            View view,
            int numPulses,
            boolean boundsRespectPadding,
            @Px int cornerRadius,
            @Px int highlightExtension) {
        PulseDrawable drawable = null;
        Context context = view.getContext();

        if (numPulses != 0) {
            drawable =
                    PulseDrawable.createRoundedRectangle(
                            context,
                            cornerRadius,
                            highlightExtension,
                            new NumberPulser(view, numPulses));
        } else {
            drawable =
                    PulseDrawable.createRoundedRectangle(context, cornerRadius, highlightExtension);
        }

        if (boundsRespectPadding) {
            drawable.setInset(
                    ViewCompat.getPaddingStart(view),
                    view.getPaddingTop(),
                    ViewCompat.getPaddingEnd(view),
                    view.getPaddingBottom());
        }
        return drawable;
    }
}