sjwall/MaterialTapTargetPrompt

View on GitHub
library/src/main/java/uk/co/samuelwall/materialtaptargetprompt/MaterialTapTargetPrompt.java

Summary

Maintainability
B
4 hrs
Test Coverage
/*
 * Copyright (C) 2016-2019 Samuel Wall
 *
 * 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 uk.co.samuelwall.materialtaptargetprompt;

import android.animation.Animator;
import android.animation.ValueAnimator;
import android.app.Activity;
import android.app.Dialog;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Path;
import android.graphics.Point;
import android.graphics.PointF;
import android.graphics.Rect;
import android.graphics.RectF;
import android.graphics.Region;
import android.graphics.drawable.Drawable;
import android.os.Build;
import android.text.TextUtils;
import android.view.KeyEvent;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewTreeObserver;
import android.view.accessibility.AccessibilityEvent;
import android.view.accessibility.AccessibilityManager;
import android.view.accessibility.AccessibilityNodeInfo;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import androidx.fragment.app.DialogFragment;
import androidx.fragment.app.Fragment;
import uk.co.samuelwall.materialtaptargetprompt.extras.PromptOptions;

/**
 * A Material Design tap target onboarding implementation.
 * <div class="special reference">
 * <p>For more information about onboarding and tap targets, read the
 * <a href="https://www.google.com/design/spec/growth-communications/onboarding.html">Onboarding</a>
 * Material Design guidelines.</p>
 * </div>
 */
public class MaterialTapTargetPrompt
{

    /**
     * Prompt has yet to be shown.
     */
    public static final int STATE_NOT_SHOWN = 0;

    /**
     * Prompt is reveal animation is running.
     */
    public static final int STATE_REVEALING = 1;

    /**
     * Prompt reveal animation has finished and the prompt is displayed.
     */
    public static final int STATE_REVEALED = 2;

    /**
     * The prompt target has been pressed in the focal area.
     */
    public static final int STATE_FOCAL_PRESSED = 3;

    /**
     * The prompt has been removed from view after the prompt has been pressed in the focal area.
     */
    public static final int STATE_FINISHED = 4;

    /**
     * The {@link #dismiss()} method has been called and the prompt is being removed from view.
     */
    public static final int STATE_DISMISSING = 5;

    /**
     * The prompt has been removed from view after the prompt has either been pressed somewhere
     * other than the prompt target or the system back button has been pressed.
     */
    public static final int STATE_DISMISSED = 6;

    /**
     * The {@link #finish()} method has been called and the prompt is being removed from view.
     */
    public static final int STATE_FINISHING = 7;

    /**
     * The prompt has been pressed outside the focal area.
     */
    public static final int STATE_NON_FOCAL_PRESSED = 8;

    /**
     * The prompt has been dismissed by the show for timeout.
     */
    public static final int STATE_SHOW_FOR_TIMEOUT = 9;

    /**
     * The prompt has been dismissed by the system back button being pressed.
     */
    public static final int STATE_BACK_BUTTON_PRESSED = 10;

    /**
     * The view that renders the prompt.
     */
    PromptView mView;

    /**
     * Used to calculate the animation progress for the reveal and dismiss animations.
     */
    @Nullable ValueAnimator mAnimationCurrent;

    /**
     * Used to calculate the animation progress for the idle breathing focal animation.
     */
    @Nullable ValueAnimator mAnimationFocalBreathing;

    /**
     * Used to calculate the animation progress for the idle white flash animation.
     */
    @Nullable ValueAnimator mAnimationFocalRipple;

    /**
     * The last percentage progress for idle animation.
     * Value between 1 to 0 inclusive.
     * Used in the idle animation to track when the animation should change direction.
     */
    float mFocalRippleProgress;

    /**
     * The prompt's current state.
     */
    int mState;

    /**
     * The system status bar height.
     */
    final float mStatusBarHeight;

    /**
     * Task used for triggering the prompt timeout.
     */
    final Runnable mTimeoutRunnable = () -> {
        // Emit the state change and dismiss the prompt
        onPromptStateChanged(STATE_SHOW_FOR_TIMEOUT);
        dismiss();
    };

    /**
     * Listener for the view layout changing.
     */
    @Nullable final ViewTreeObserver.OnGlobalLayoutListener mGlobalLayoutListener;

    /**
     * Default constructor.
     *
     * @param promptOptions The options used to create the prompt.
     */
    MaterialTapTargetPrompt(final PromptOptions promptOptions)
    {
        final ResourceFinder resourceFinder = promptOptions.getResourceFinder();
        mView = new PromptView(resourceFinder.getContext());
        mView.mPrompt = this;
        mView.mPromptOptions = promptOptions;
        mView.setContentDescription(promptOptions.getContentDescription());
        mView.mPromptTouchedListener = new PromptView.PromptTouchedListener()
        {
            @Override
            public void onFocalPressed()
            {
                if (!isDismissing())
                {
                    onPromptStateChanged(STATE_FOCAL_PRESSED);
                    if (mView.mPromptOptions.getAutoFinish())
                    {
                        finish();
                    }
                }
            }

            @Override
            public void onNonFocalPressed()
            {
                if (!isDismissing())
                {
                    onPromptStateChanged(STATE_NON_FOCAL_PRESSED);
                    if (mView.mPromptOptions.getAutoDismiss())
                    {
                        dismiss();
                    }
                }
            }

            @Override
            public void onBackButtonPressed()
            {
                if (!isDismissing())
                {
                    onPromptStateChanged(STATE_BACK_BUTTON_PRESSED);
                    onPromptStateChanged(STATE_NON_FOCAL_PRESSED);
                    if (mView.mPromptOptions.getAutoDismiss())
                    {
                        dismiss();
                    }
                }
            }
        };

        Rect rect = new Rect();
        resourceFinder.getPromptParentView().getWindowVisibleDisplayFrame(rect);
        mStatusBarHeight = mView.mPromptOptions.getIgnoreStatusBar() ? 0 : rect.top;

        mGlobalLayoutListener = () -> {
            final View targetView = mView.mPromptOptions.getTargetView();
            if (targetView != null)
            {
                final boolean isTargetAttachedToWindow;
                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT)
                {
                    isTargetAttachedToWindow = targetView.isAttachedToWindow();
                }
                else
                {
                    isTargetAttachedToWindow = targetView.getWindowToken() != null;
                }

                if (!isTargetAttachedToWindow)
                {
                    return;
                }
            }
            prepare();

            if (mAnimationCurrent == null)
            {
                // Force a relayout to update the view's location
                updateAnimation(1, 1);
            }
        };
    }

    /**
     * Displays the prompt.
     */
    public void show()
    {
        if (isStarting())
        {
            return;
        }

        final ViewGroup parent = mView.mPromptOptions.getResourceFinder().getPromptParentView();

        // If dismissing or the prompt already exists in the parent view
        if (isDismissing() || parent.findViewById(R.id.material_target_prompt_view) != null)
        {
            cleanUpPrompt(mState);
        }

        parent.addView(mView);
        addGlobalLayoutListener();
        onPromptStateChanged(STATE_REVEALING);
        prepare();
        startRevealAnimation();
    }

    /**
     * Displays the prompt for a maximum amount of time.
     *
     * @param millis The number of milliseconds to show the prompt for.
     */
    public void showFor(long millis)
    {
        mView.postDelayed(mTimeoutRunnable, millis);
        show();
    }

    /**
     * Cancel the show for timer if it has been created.
     */
    public void cancelShowForTimer()
    {
        mView.removeCallbacks(mTimeoutRunnable);
    }

    /**
     * Get the current state of the prompt.
     *
     * @see #STATE_NOT_SHOWN
     * @see #STATE_REVEALING
     * @see #STATE_REVEALED
     * @see #STATE_FOCAL_PRESSED
     * @see #STATE_NON_FOCAL_PRESSED
     * @see #STATE_BACK_BUTTON_PRESSED
     * @see #STATE_FINISHING
     * @see #STATE_FINISHED
     * @see #STATE_DISMISSING
     * @see #STATE_DISMISSED
     */
    public int getState()
    {
        return mState;
    }

    /**
     * Is the current state {@link #STATE_REVEALING} or {@link #STATE_REVEALED}.
     *
     * @return True if revealing or revealed.
     */
    boolean isStarting()
    {
        return mState == STATE_REVEALING || mState == STATE_REVEALED;
    }

    /**
     * Is the current state {@link #STATE_DISMISSING} or {@link #STATE_FINISHING}.
     *
     * @return True if dismissing or finishing.
     */
    boolean isDismissing()
    {
        return mState == STATE_DISMISSING || mState == STATE_FINISHING;
    }

    /**
     * Is the current state {@link #STATE_DISMISSED} or {@link #STATE_FINISHED}.
     *
     * @return True if dismissed or finished.
     */
    boolean isDismissed()
    {
        return mState == STATE_DISMISSED || mState == STATE_FINISHED;
    }

    /**
     * Is the current state neither {@link #STATE_REVEALED} or {@link #STATE_REVEALING}.
     *
     * @return True if not revealed or revealing.
     */
    boolean isComplete()
    {
        return mState == STATE_NOT_SHOWN || isDismissing() || isDismissed();
    }

    /**
     * Adds layout listener to view parent to capture layout changes.
     */
    void addGlobalLayoutListener()
    {
        final ViewTreeObserver viewTreeObserver = ((ViewGroup) mView.getParent()).getViewTreeObserver();
        if (viewTreeObserver.isAlive())
        {
            viewTreeObserver.addOnGlobalLayoutListener(mGlobalLayoutListener);
        }
    }

    /**
     * Removes global layout listener added in {@link #addGlobalLayoutListener()}.
     */
    void removeGlobalLayoutListener()
    {
        final ViewGroup parent = (ViewGroup) mView.getParent();
        if (parent == null)
        {
            return;
        }
        final ViewTreeObserver viewTreeObserver = ((ViewGroup) mView.getParent()).getViewTreeObserver();
        if (viewTreeObserver.isAlive())
        {
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN)
            {
                viewTreeObserver.removeOnGlobalLayoutListener(mGlobalLayoutListener);
            }
            else
            {
                viewTreeObserver.removeGlobalOnLayoutListener(mGlobalLayoutListener);
            }
        }
    }

    /**
     * Removes the prompt from view, using a expand and fade animation.
     * <p>
     * This is treated as if the user has touched the target focal point.
     */
    public void finish()
    {
        if (isComplete())
        {
            return;
        }
        cancelShowForTimer();
        cleanUpAnimation();
        mAnimationCurrent = ValueAnimator.ofFloat(1f, 0f);
        mAnimationCurrent.setDuration(225);
        mAnimationCurrent.setInterpolator(mView.mPromptOptions.getAnimationInterpolator());
        mAnimationCurrent.addUpdateListener(animation -> {
            final float value = (float) animation.getAnimatedValue();
            updateAnimation(1f + ((1f - value) / 4), value);
        });
        mAnimationCurrent.addListener(new AnimatorListener()
        {
            @Override
            public void onAnimationEnd(Animator animation)
            {
                cleanUpPrompt(STATE_FINISHED);
                mView.sendAccessibilityEvent(AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED);
            }
        });
        onPromptStateChanged(STATE_FINISHING);
        mAnimationCurrent.start();
    }

    /**
     * Removes the prompt from view, using a contract and fade animation.
     * <p>
     * This is treated as if the user has touched outside the target focal point.
     */
    public void dismiss()
    {
        if (isComplete())
        {
            return;
        }
        cancelShowForTimer();
        cleanUpAnimation();
        mAnimationCurrent = ValueAnimator.ofFloat(1f, 0f);
        mAnimationCurrent.setDuration(225);
        mAnimationCurrent.setInterpolator(mView.mPromptOptions.getAnimationInterpolator());
        mAnimationCurrent.addUpdateListener(animation -> {
            final float value = (float) animation.getAnimatedValue();
            updateAnimation(value, value);
        });
        mAnimationCurrent.addListener(new AnimatorListener()
        {
            @Override
            public void onAnimationEnd(Animator animation)
            {
                cleanUpPrompt(STATE_DISMISSED);
                mView.sendAccessibilityEvent(AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED);
            }
        });
        onPromptStateChanged(STATE_DISMISSING);
        mAnimationCurrent.start();
    }

    /**
     * Removes the prompt from view and triggers the {@link #onPromptStateChanged(int)} event.
     */
    void cleanUpPrompt(final int state)
    {
        cleanUpAnimation();
        removeGlobalLayoutListener();
        final ViewGroup parent = (ViewGroup) mView.getParent();
        if (parent != null)
        {
            parent.removeView(mView);
        }
        if (isDismissing())
        {
            onPromptStateChanged(state);
        }
    }

    /**
     * Stops any current animation and removes references to it.
     */
    void cleanUpAnimation()
    {
        if (mAnimationCurrent != null)
        {
            mAnimationCurrent.removeAllUpdateListeners();
            mAnimationCurrent.removeAllListeners();
            mAnimationCurrent.cancel();
            mAnimationCurrent = null;
        }
        if (mAnimationFocalRipple != null)
        {
            mAnimationFocalRipple.removeAllUpdateListeners();
            mAnimationFocalRipple.cancel();
            mAnimationFocalRipple = null;
        }
        if (mAnimationFocalBreathing != null)
        {
            mAnimationFocalBreathing.removeAllUpdateListeners();
            mAnimationFocalBreathing.cancel();
            mAnimationFocalBreathing = null;
        }
    }

    /**
     * Starts the animation to reveal the prompt.
     */
    void startRevealAnimation()
    {
        updateAnimation(0, 0);
        cleanUpAnimation();
        mAnimationCurrent = ValueAnimator.ofFloat(0f, 1f);
        mAnimationCurrent.setInterpolator(mView.mPromptOptions.getAnimationInterpolator());
        mAnimationCurrent.setDuration(225);
        mAnimationCurrent.addUpdateListener(animation -> {
            final float value = (float) animation.getAnimatedValue();
            updateAnimation(value, value);
        });
        mAnimationCurrent.addListener(new AnimatorListener()
        {
            @Override
            public void onAnimationEnd(@NonNull Animator animation)
            {
                animation.removeAllListeners();
                updateAnimation(1, 1);
                cleanUpAnimation();
                if (mView.mPromptOptions.getIdleAnimationEnabled())
                {
                    startIdleAnimations();
                }
                onPromptStateChanged(STATE_REVEALED);

                mView.requestFocus();
                mView.sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_FOCUSED);
            }
        });
        mAnimationCurrent.start();
    }

    /**
     * Starts the prompt idle animations.
     */
    void startIdleAnimations()
    {
        cleanUpAnimation();
        mAnimationFocalBreathing = ValueAnimator.ofFloat(1, 1.1f, 1);
        mAnimationFocalBreathing.setInterpolator(mView.mPromptOptions.getAnimationInterpolator());
        mAnimationFocalBreathing.setDuration(1000);
        mAnimationFocalBreathing.setStartDelay(225);
        mAnimationFocalBreathing.setRepeatCount(ValueAnimator.INFINITE);
        mAnimationFocalBreathing.addUpdateListener(new ValueAnimator.AnimatorUpdateListener()
        {
            boolean direction = true;

            @Override
            public void onAnimationUpdate(@NonNull ValueAnimator animation)
            {
                final float newFocalFraction = (Float) animation.getAnimatedValue();
                boolean newDirection = direction;
                if (newFocalFraction < mFocalRippleProgress && direction)
                {
                    newDirection = false;
                }
                else if (newFocalFraction > mFocalRippleProgress && !direction)
                {
                    newDirection = true;
                }
                if (newDirection != direction && !newDirection)
                {
                    mAnimationFocalRipple.start();
                }
                direction = newDirection;
                mFocalRippleProgress = newFocalFraction;
                mView.mPromptOptions.getPromptFocal().update(mView.mPromptOptions, newFocalFraction, 1);
                mView.invalidate();
            }
        });

        mAnimationFocalRipple = ValueAnimator.ofFloat(1.1f, 1.6f);
        mAnimationFocalRipple.setInterpolator(mView.mPromptOptions.getAnimationInterpolator());
        mAnimationFocalRipple.setDuration(500);
        mAnimationFocalRipple.addUpdateListener(animation -> {
            final float value = (float) animation.getAnimatedValue();
            mView.mPromptOptions.getPromptFocal().updateRipple(value, (1.6f - value) * 2);
        });
        mAnimationFocalBreathing.start();
    }

    /**
     * Updates the positioning and alpha values using the animation values.
     *
     * @param revealModifier The amount to modify the reveal size, between 0 and 1.
     * @param alphaModifier  The amount to modify the alpha value, between 0 and 1.
     */
    void updateAnimation(final float revealModifier, final float alphaModifier)
    {
        if (mView.getParent() == null)
        {
            return;
        }
        mView.mPromptOptions.getPromptText().update(mView.mPromptOptions, revealModifier, alphaModifier);
        if (mView.mIconDrawable != null)
        {
            mView.mIconDrawable.setAlpha((int) (255f * alphaModifier));
        }
        mView.mPromptOptions.getPromptFocal().update(mView.mPromptOptions, revealModifier, alphaModifier);
        mView.mPromptOptions.getPromptBackground().update(mView.mPromptOptions, revealModifier, alphaModifier);
        mView.invalidate();
    }

    /**
     * Update the focal and text positioning.
     */
    void prepare()
    {
        final View targetRenderView = mView.mPromptOptions.getTargetRenderView();
        if (targetRenderView == null)
        {
            mView.mTargetRenderView = mView.mPromptOptions.getTargetView();
        }
        else
        {
            mView.mTargetRenderView = targetRenderView;
        }
        updateClipBounds();
        final View targetView = mView.mPromptOptions.getTargetView();
        if (targetView != null)
        {
            final int[] viewPosition = new int[2];
            mView.getLocationInWindow(viewPosition);
            mView.mPromptOptions.getPromptFocal().prepare(mView.mPromptOptions, targetView, viewPosition);
        }
        else
        {
            final PointF targetPosition = mView.mPromptOptions.getTargetPosition();
            mView.mPromptOptions.getPromptFocal().prepare(mView.mPromptOptions, targetPosition.x, targetPosition.y);
        }
        mView.mPromptOptions.getPromptText().prepare(mView.mPromptOptions, mView.mClipToBounds, mView.mClipBounds);
        mView.mPromptOptions.getPromptBackground().prepare(mView.mPromptOptions, mView.mClipToBounds, mView.mClipBounds);
        updateIconPosition();
    }

    /**
     * Update the icon drawable position or target render view position.
     */
    void updateIconPosition()
    {
        mView.mIconDrawable = mView.mPromptOptions.getIconDrawable();
        if (mView.mIconDrawable != null)
        {
            final RectF mFocalBounds = mView.mPromptOptions.getPromptFocal().getBounds();
            mView.mIconDrawableLeft = mFocalBounds.centerX()
                    - (mView.mIconDrawable.getIntrinsicWidth() / 2);
            mView.mIconDrawableTop = mFocalBounds.centerY()
                    - (mView.mIconDrawable.getIntrinsicHeight() / 2);
        }
        else if (mView.mTargetRenderView != null)
        {
            final int[] viewPosition = new int[2];
            mView.getLocationInWindow(viewPosition);
            final int[] targetPosition = new int[2];
            mView.mTargetRenderView.getLocationInWindow(targetPosition);

            mView.mIconDrawableLeft = targetPosition[0] - viewPosition[0] - mView.mTargetRenderView.getScrollX();
            mView.mIconDrawableTop = targetPosition[1] - viewPosition[1] - mView.mTargetRenderView.getScrollY();
        }
    }

    /**
     * Update the bounds that the prompt is clip to.
     */
    void updateClipBounds()
    {
        final View clipToView = mView.mPromptOptions.getClipToView();
        if (clipToView != null)
        {
            mView.mClipToBounds = true;

            //Reset the top to 0
            mView.mClipBounds.set(0, 0, 0, 0);

            //Find the location of the clip view on the screen
            final Point offset = new Point();
            clipToView.getGlobalVisibleRect(mView.mClipBounds, offset);

            if (offset.y == 0)
            {
                mView.mClipBounds.top += mStatusBarHeight;
            }
        }
        else
        {
            mView.mPromptOptions.getResourceFinder().getPromptParentView().getGlobalVisibleRect(mView.mClipBounds, new Point());
            mView.mClipToBounds = false;
        }
    }

    /**
     * Handles emitting the prompt state changed events.
     *
     * @param state The state that the prompt is now in.
     */
    protected void onPromptStateChanged(final int state)
    {
        mState = state;
        mView.mPromptOptions.onPromptStateChanged(this, state);
        mView.mPromptOptions.onExtraPromptStateChanged(this, state);
    }

    /**
     * Creates a prompt with the supplied options.
     *
     * @param promptOptions The options to use to create the prompt.
     * @return The created prompt.
     */
    @NonNull
    public static MaterialTapTargetPrompt createDefault(@NonNull final PromptOptions promptOptions)
    {
        return new MaterialTapTargetPrompt(promptOptions);
    }

    /**
     * View used to render the tap target.
     */
    @VisibleForTesting
    public static class PromptView extends View
    {
        /*int padding;
        Paint paddingPaint = new Paint();
        Paint itemPaint = new Paint();*/
        Drawable mIconDrawable;
        float mIconDrawableLeft;
        float mIconDrawableTop;
        PromptTouchedListener mPromptTouchedListener;
        Rect mClipBounds = new Rect();
        View mTargetRenderView;
        MaterialTapTargetPrompt mPrompt;
        PromptOptions mPromptOptions;
        boolean mClipToBounds;
        AccessibilityManager mAccessibilityManager;

        /**
         * Create a new prompt view.
         *
         * @param context The context that the view is created in.
         */
        public PromptView(final Context context)
        {
            super(context);
            setId(R.id.material_target_prompt_view);
            setFocusableInTouchMode(true);
            requestFocus();

            // Hardware acceleration for clipping to a path is not supported on SDK < 18
            if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN_MR2)
            {
                // Disable hardware acceleration
                setLayerType(View.LAYER_TYPE_SOFTWARE, null);
            }

            /*paddingPaint.setColor(Color.GREEN);
            paddingPaint.setAlpha(100);
            itemPaint.setColor(Color.BLUE);
            itemPaint.setAlpha(100);*/

            setAccessibilityDelegate(new AccessibilityDelegate());
            mAccessibilityManager = (AccessibilityManager) context.getSystemService(Context.ACCESSIBILITY_SERVICE);

            if (mAccessibilityManager.isEnabled()) {
                setupAccessibilityClickListener();
            }
        }

        @Override
        protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec)
        {
            // Stop the prompt increasing the parent size by setting it to the parents size.
            final View parent = (View) this.getParent();
            this.setMeasuredDimension(parent.getMeasuredWidth(), parent.getMeasuredHeight());
        }

        @Override
        public void onDraw(final Canvas canvas)
        {
            if (mClipToBounds)
            {
                canvas.clipRect(mClipBounds);
            }

            //Draw the backgrounds, clipping the focal path so we don't draw over it.
            final Path focalPath = mPromptOptions.getPromptFocal().getPath();
            if (focalPath != null)
            {
                canvas.save();
                canvas.clipPath(focalPath, Region.Op.DIFFERENCE);
            }

            mPromptOptions.getPromptBackground().draw(canvas);

            if (focalPath != null)
            {
                canvas.restore();
            }

            //Draw the focal
            mPromptOptions.getPromptFocal().draw(canvas);

            /*canvas.drawRect(mPrimaryTextLeft - padding, mPrimaryTextTop, mPrimaryTextLeft, mPrimaryTextTop + mSecondaryTextOffsetTop + mSecondaryTextLayout.getHeight(), paddingPaint);
            canvas.drawRect(mTextBounds, itemPaint);
            canvas.drawRect(mTextBounds.right, mPrimaryTextTop, mTextBounds.right + padding, mPrimaryTextTop + mSecondaryTextOffsetTop + mSecondaryTextLayout.getHeight(), paddingPaint);*/

            //Draw the icon
            if (mIconDrawable != null)
            {
                canvas.translate(mIconDrawableLeft, mIconDrawableTop);
                mIconDrawable.draw(canvas);
                canvas.translate(-mIconDrawableLeft, -mIconDrawableTop);
            }
            else if (mTargetRenderView != null)
            {
                canvas.translate(mIconDrawableLeft, mIconDrawableTop);
                mTargetRenderView.draw(canvas);
                canvas.translate(-mIconDrawableLeft, -mIconDrawableTop);
            }

            //Draw the text
            Path backgroundPath = mPromptOptions.getPromptBackground().getPath();
            if (backgroundPath != null)
            {
                canvas.save();
                canvas.clipPath(backgroundPath, Region.Op.INTERSECT);
            }
            mPromptOptions.getPromptText().draw(canvas);
            if (backgroundPath != null)
            {
                canvas.restore();
            }
        }

        @Override
        public boolean onHoverEvent(MotionEvent event) {
            if (mAccessibilityManager.isTouchExplorationEnabled() && event.getPointerCount() == 1) {
                final int action = event.getAction();
                switch (action) {
                    case MotionEvent.ACTION_HOVER_ENTER: {
                        event.setAction(MotionEvent.ACTION_DOWN);
                    } break;
                    case MotionEvent.ACTION_HOVER_MOVE: {
                        event.setAction(MotionEvent.ACTION_MOVE);
                    } break;
                    case MotionEvent.ACTION_HOVER_EXIT: {
                        event.setAction(MotionEvent.ACTION_UP);
                    } break;
                }
                return onTouchEvent(event);
            }
            return super.onHoverEvent(event);
        }

        @Override
        public boolean onTouchEvent(MotionEvent event)
        {
            final float x = event.getX();
            final float y = event.getY();
            //If the touch point is within the prompt background stop the event from passing through it
            boolean captureEvent = (!mClipToBounds || mClipBounds.contains((int) x, (int) y))
                    && mPromptOptions.getPromptBackground().contains(x, y);
            //If the touch event was at least in the background and in the focal
            if (captureEvent && mPromptOptions.getPromptFocal().contains(x, y))
            {
                //Override allowing the touch event to pass through the view with the user defined value
                captureEvent = mPromptOptions.getCaptureTouchEventOnFocal();
                if (mPromptTouchedListener != null)
                {
                    mPromptTouchedListener.onFocalPressed();
                }
            }
            else
            {
                // If the prompt background was not touched
                if (!captureEvent)
                {
                    captureEvent = mPromptOptions.getCaptureTouchEventOutsidePrompt();
                }
                if (mPromptTouchedListener != null)
                {
                    mPromptTouchedListener.onNonFocalPressed();
                }
            }
            return captureEvent;
        }

        @Override
        public boolean dispatchKeyEventPreIme(KeyEvent event)
        {
            if (mPromptOptions.getBackButtonDismissEnabled()
                    && event.getKeyCode() == KeyEvent.KEYCODE_BACK)
            {
                KeyEvent.DispatcherState state = getKeyDispatcherState();
                if (state != null)
                {
                    if (event.getAction() == KeyEvent.ACTION_DOWN
                            && event.getRepeatCount() == 0)
                    {
                        state.startTracking(event, this);
                        return true;
                    }
                    else if (event.getAction() == KeyEvent.ACTION_UP
                            && !event.isCanceled() && state.isTracking(event))
                    {
                        if (mPromptTouchedListener != null)
                        {
                            mPromptTouchedListener.onBackButtonPressed();
                        }
                        return mPromptOptions.getAutoDismiss()
                                || super.dispatchKeyEventPreIme(event);
                    }
                }
            }

            return super.dispatchKeyEventPreIme(event);
        }

        @Override
        protected void onDetachedFromWindow()
        {
            super.onDetachedFromWindow();
            mPrompt.cleanUpAnimation();
        }

        @Override
        public CharSequence getAccessibilityClassName()
        {
            return PromptView.class.getName();
        }


        @VisibleForTesting
        public PromptOptions getPromptOptions()
        {
            return mPromptOptions;
        }

        /**
         * When AccessibilityManager is enabled, the prompt view can be dismissed by double-tap.
         * The event is also passed as onClick() to the target view, when available.
         */
        private void setupAccessibilityClickListener()
        {
            setClickable(true);
            setOnClickListener(view -> {
                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.ICE_CREAM_SANDWICH_MR1)
                {
                    final View targetView = mPromptOptions.getTargetView();
                    if (targetView != null)
                    {
                        targetView.callOnClick();
                    }
                }

                mPrompt.finish();
            });
        }

        /**
         * Interface definition for a callback to be invoked when a {@link PromptView} is touched.
         */
        public interface PromptTouchedListener
        {
            /**
             * Called when the focal is pressed.
             */
            void onFocalPressed();

            /**
             * Called when anywhere outside the focal is pressed or the system back button is
             * pressed.
             */
            void onNonFocalPressed();

            /**
             * Called when the system back button is pressed.
             */
            void onBackButtonPressed();
        }

        class AccessibilityDelegate extends View.AccessibilityDelegate {

            @Override
            public void onInitializeAccessibilityNodeInfo(View host, AccessibilityNodeInfo info)
            {
                super.onInitializeAccessibilityNodeInfo(host, info);

                @Nullable final Package viewPackage = PromptView.this.getClass().getPackage();
                if (viewPackage != null)
                {
                    info.setPackageName(viewPackage.getName());
                }
                info.setSource(host);
                info.setClickable(true);
                info.setEnabled(true);
                info.setChecked(false);
                info.setFocusable(true);
                info.setFocused(true);

                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1)
                {
                    info.setLabelFor(mPromptOptions.getTargetView());
                }

                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT)
                {
                    info.setDismissable(true);
                }

                info.setContentDescription(mPromptOptions.getContentDescription());
                info.setText(mPromptOptions.getContentDescription());
            }

            @Override
            public void onPopulateAccessibilityEvent(View host, AccessibilityEvent event)
            {
                super.onPopulateAccessibilityEvent(host, event);

                final CharSequence contentDescription = mPromptOptions.getContentDescription();
                if (!TextUtils.isEmpty(contentDescription))
                {
                    event.getText().add(contentDescription);
                }
            }
        }
    }

    /**
     * A builder to create a {@link MaterialTapTargetPrompt} instance.
     */
    public static class Builder extends PromptOptions<Builder>
    {
        /**
         * Creates a builder for a tap target prompt that uses the default tap target prompt theme.
         *
         * @param fragment the fragment to show the prompt within.
         * @see #Builder(Fragment, int)
         */
        public Builder(@NonNull final Fragment fragment)
        {
            this(fragment, 0);
        }

        /**
         * Creates a builder for a material tap target prompt that uses an explicit theme resource.
         * <p>
         * The {@code themeResId} may be specified as {@code 0} to use the parent {@code context}'s
         * resolved value for {@link uk.co.samuelwall.materialtaptargetprompt.R.attr#MaterialTapTargetPromptTheme}.
         *
         * @param fragment   the fragment to show the prompt within.
         * @param themeResId the resource ID of the theme against which to inflate this dialog, or
         *                   {@code 0} to use the parent {@code context}'s default material tap
         *                   target prompt theme
         */
        public Builder(@NonNull final Fragment fragment, int themeResId)
        {
            this(new SupportFragmentResourceFinder(fragment), themeResId);
        }

        /**
         * Creates a builder for a tap target prompt that uses the default tap target prompt theme.
         *
         * @param dialogFragment the dialog fragment to show the prompt within.
         * @see #Builder(DialogFragment, int)
         */
        public Builder(@NonNull final DialogFragment dialogFragment)
        {
            this(dialogFragment, 0);
        }

        /**
         * Creates a builder for a material tap target prompt that uses an explicit theme resource.
         * <p>
         * The {@code themeResId} may be specified as {@code 0} to use the parent {@code context}'s
         * resolved value for {@link uk.co.samuelwall.materialtaptargetprompt.R.attr#MaterialTapTargetPromptTheme}.
         *
         * @param dialogFragment the dialog fragment to show the prompt within.
         * @param themeResId     the resource ID of the theme against which to inflate this dialog,
         *                       or {@code 0} to use the parent {@code context}'s default material
         *                       tap target prompt theme
         */
        public Builder(@NonNull final DialogFragment dialogFragment, int themeResId)
        {
            this(new SupportFragmentResourceFinder(dialogFragment), themeResId);
        }

        /**
         * Creates a builder for a tap target prompt that uses the default tap target prompt theme.
         *
         * @param dialog the dialog to show the prompt within.
         */
        public Builder(@NonNull final Dialog dialog)
        {
            this(dialog, 0);
        }

        /**
         * Creates a builder for a material tap target prompt that uses an explicit theme resource.
         * <p>
         * The {@code themeResId} may be specified as {@code 0} to use the parent {@code context}'s
         * resolved value for {@link uk.co.samuelwall.materialtaptargetprompt.R.attr#MaterialTapTargetPromptTheme}.
         *
         * @param dialog     the dialog to show the prompt within.
         * @param themeResId the resource ID of the theme against which to inflate this dialog, or
         *                   {@code 0} to use the parent {@code context}'s default material tap
         *                   target prompt theme
         */
        public Builder(@NonNull final Dialog dialog, int themeResId)
        {
            this(new DialogResourceFinder(dialog), themeResId);
        }

        /**
         * Creates a builder for a tap target prompt that uses the default tap target prompt theme.
         *
         * @param activity the activity to show the prompt within.
         */
        public Builder(@NonNull final Activity activity)
        {
            this(activity, 0);
        }

        /**
         * Creates a builder for a material tap target prompt that uses an explicit theme resource.
         * <p>
         * The {@code themeResId} may be specified as {@code 0} to use the parent {@code context}'s
         * resolved value for {@link uk.co.samuelwall.materialtaptargetprompt.R.attr#MaterialTapTargetPromptTheme}.
         *
         * @param activity   the activity to show the prompt within.
         * @param themeResId the resource ID of the theme against which to inflate this dialog, or
         *                   {@code 0} to use the parent {@code context}'s default material tap
         *                   target prompt theme
         */
        public Builder(@NonNull final Activity activity, int themeResId)
        {
            this(new ActivityResourceFinder(activity), themeResId);
        }

        /**
         * Creates a builder for a material tap target prompt that uses an explicit theme resource.
         * <p>
         * The {@code themeResId} may be specified as {@code 0} to use the parent {@code context}'s
         * resolved value for {@link uk.co.samuelwall.materialtaptargetprompt.R.attr#MaterialTapTargetPromptTheme}.
         *
         * @param resourceFinder The {@link ResourceFinder} used to find views and resources.
         * @param themeResId     the resource ID of the theme against which to inflate this dialog,
         *                       or {@code 0} to use the parent {@code context}'s default material
         *                       tap target prompt theme
         */
        public Builder(@NonNull final ResourceFinder resourceFinder, int themeResId)
        {
            super(resourceFinder);
            load(themeResId);
        }
    }

    /**
     * Interface definition for a callback to be invoked when a prompts state changes.
     */
    public interface PromptStateChangeListener
    {
        /**
         * Called when the prompts state changes.
         *
         * @param prompt The prompt which state has changed.
         * @param state  can be either {@link #STATE_REVEALING}, {@link #STATE_REVEALED}, {@link
         *               #STATE_FOCAL_PRESSED}, {@link #STATE_FINISHED}, {@link #STATE_DISMISSING},
         *               {@link #STATE_DISMISSED}
         */
        void onPromptStateChanged(@NonNull final MaterialTapTargetPrompt prompt, final int state);
    }

    static class AnimatorListener implements Animator.AnimatorListener
    {

        @Override
        public void onAnimationStart(Animator animation)
        {

        }

        @Override
        public void onAnimationEnd(Animator animation)
        {

        }

        @Override
        public void onAnimationCancel(Animator animation)
        {

        }

        @Override
        public void onAnimationRepeat(Animator animation)
        {

        }
    }
}