diff --git a/taptargetview/src/main/AndroidManifest.xml b/taptargetview/src/main/AndroidManifest.xml index 05033b8..90e9fee 100644 --- a/taptargetview/src/main/AndroidManifest.xml +++ b/taptargetview/src/main/AndroidManifest.xml @@ -1 +1 @@ - + diff --git a/taptargetview/src/main/java/com/getkeepsafe/taptargetview/FloatValueAnimatorBuilder.java b/taptargetview/src/main/java/com/getkeepsafe/taptargetview/FloatValueAnimatorBuilder.java index 6389158..ddf6f31 100644 --- a/taptargetview/src/main/java/com/getkeepsafe/taptargetview/FloatValueAnimatorBuilder.java +++ b/taptargetview/src/main/java/com/getkeepsafe/taptargetview/FloatValueAnimatorBuilder.java @@ -28,14 +28,6 @@ class FloatValueAnimatorBuilder { EndListener endListener; - interface UpdateListener { - void onUpdate(float lerpTime); - } - - interface EndListener { - void onEnd(); - } - protected FloatValueAnimatorBuilder() { this(false); } @@ -95,4 +87,12 @@ public void onAnimationEnd(Animator animation) { return animator; } + + interface UpdateListener { + void onUpdate(float lerpTime); + } + + interface EndListener { + void onEnd(); + } } diff --git a/taptargetview/src/main/java/com/getkeepsafe/taptargetview/ReflectUtil.java b/taptargetview/src/main/java/com/getkeepsafe/taptargetview/ReflectUtil.java index c9d84af..193e5bd 100644 --- a/taptargetview/src/main/java/com/getkeepsafe/taptargetview/ReflectUtil.java +++ b/taptargetview/src/main/java/com/getkeepsafe/taptargetview/ReflectUtil.java @@ -1,31 +1,33 @@ -/** - * Copyright 2016 Keepsafe Software, Inc. - *

- * 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 com.getkeepsafe.taptargetview; - -import java.lang.reflect.Field; - -class ReflectUtil { - ReflectUtil() { - } - - /** Returns the value of the given private field from the source object **/ - static Object getPrivateField(Object source, String fieldName) - throws NoSuchFieldException, IllegalAccessException { - final Field objectField = source.getClass().getDeclaredField(fieldName); - objectField.setAccessible(true); - return objectField.get(source); - } -} +/** + * Copyright 2016 Keepsafe Software, Inc. + *

+ * 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 com.getkeepsafe.taptargetview; + +import java.lang.reflect.Field; + +class ReflectUtil { + ReflectUtil() { + } + + /** + * Returns the value of the given private field from the source object + **/ + static Object getPrivateField(Object source, String fieldName) + throws NoSuchFieldException, IllegalAccessException { + final Field objectField = source.getClass().getDeclaredField(fieldName); + objectField.setAccessible(true); + return objectField.get(source); + } +} diff --git a/taptargetview/src/main/java/com/getkeepsafe/taptargetview/TapTarget.java b/taptargetview/src/main/java/com/getkeepsafe/taptargetview/TapTarget.java index aeb0ab9..ec4efa3 100644 --- a/taptargetview/src/main/java/com/getkeepsafe/taptargetview/TapTarget.java +++ b/taptargetview/src/main/java/com/getkeepsafe/taptargetview/TapTarget.java @@ -19,14 +19,15 @@ import android.graphics.Rect; import android.graphics.Typeface; import android.graphics.drawable.Drawable; +import android.view.View; + import androidx.annotation.ColorInt; import androidx.annotation.ColorRes; import androidx.annotation.DimenRes; import androidx.annotation.IdRes; import androidx.annotation.Nullable; -import androidx.core.content.ContextCompat; import androidx.appcompat.widget.Toolbar; -import android.view.View; +import androidx.core.content.ContextCompat; /** * Describes the properties and options for a {@link TapTargetView}. @@ -40,463 +41,588 @@ * @see ViewTapTarget ViewTapTarget for targeting standard Android views */ public class TapTarget { - final CharSequence title; - @Nullable - final CharSequence description; - - float outerCircleAlpha = 0.96f; - int targetRadius = 44; - - Rect bounds; - Drawable icon; - Typeface titleTypeface; - Typeface descriptionTypeface; - - @ColorRes - private int outerCircleColorRes = -1; - @ColorRes - private int targetCircleColorRes = -1; - @ColorRes - private int dimColorRes = -1; - @ColorRes - private int titleTextColorRes = -1; - @ColorRes - private int descriptionTextColorRes = -1; - - private Integer outerCircleColor = null; - private Integer targetCircleColor = null; - private Integer dimColor = null; - private Integer titleTextColor = null; - private Integer descriptionTextColor = null; - - @DimenRes - private int titleTextDimen = -1; - @DimenRes - private int descriptionTextDimen = -1; - - private int titleTextSize = 20; - private int descriptionTextSize = 18; - int id = -1; - - boolean drawShadow = false; - boolean cancelable = true; - boolean tintTarget = true; - boolean transparentTarget = false; - float descriptionTextAlpha = 0.54f; - - /** - * Return a tap target for the overflow button from the given toolbar - *

- * Note: This is currently experimental, use at your own risk - */ - public static TapTarget forToolbarOverflow(Toolbar toolbar, CharSequence title) { - return forToolbarOverflow(toolbar, title, null); - } - - /** Return a tap target for the overflow button from the given toolbar - *

- * Note: This is currently experimental, use at your own risk - */ - public static TapTarget forToolbarOverflow(Toolbar toolbar, CharSequence title, - @Nullable CharSequence description) { - return new ToolbarTapTarget(toolbar, false, title, description); - } - - /** Return a tap target for the overflow button from the given toolbar - *

- * Note: This is currently experimental, use at your own risk - */ - public static TapTarget forToolbarOverflow(android.widget.Toolbar toolbar, CharSequence title) { - return forToolbarOverflow(toolbar, title, null); - } - - /** Return a tap target for the overflow button from the given toolbar - *

- * Note: This is currently experimental, use at your own risk - */ - public static TapTarget forToolbarOverflow(android.widget.Toolbar toolbar, CharSequence title, - @Nullable CharSequence description) { - return new ToolbarTapTarget(toolbar, false, title, description); - } - - /** Return a tap target for the navigation button (back, up, etc) from the given toolbar **/ - public static TapTarget forToolbarNavigationIcon(Toolbar toolbar, CharSequence title) { - return forToolbarNavigationIcon(toolbar, title, null); - } - - /** Return a tap target for the navigation button (back, up, etc) from the given toolbar **/ - public static TapTarget forToolbarNavigationIcon(Toolbar toolbar, CharSequence title, - @Nullable CharSequence description) { - return new ToolbarTapTarget(toolbar, true, title, description); - } - - /** Return a tap target for the navigation button (back, up, etc) from the given toolbar **/ - public static TapTarget forToolbarNavigationIcon(android.widget.Toolbar toolbar, CharSequence title) { - return forToolbarNavigationIcon(toolbar, title, null); - } - - /** Return a tap target for the navigation button (back, up, etc) from the given toolbar **/ - public static TapTarget forToolbarNavigationIcon(android.widget.Toolbar toolbar, CharSequence title, - @Nullable CharSequence description) { - return new ToolbarTapTarget(toolbar, true, title, description); - } - - /** Return a tap target for the menu item from the given toolbar **/ - public static TapTarget forToolbarMenuItem(Toolbar toolbar, @IdRes int menuItemId, - CharSequence title) { - return forToolbarMenuItem(toolbar, menuItemId, title, null); - } - - /** Return a tap target for the menu item from the given toolbar **/ - public static TapTarget forToolbarMenuItem(Toolbar toolbar, @IdRes int menuItemId, - CharSequence title, @Nullable CharSequence description) { - return new ToolbarTapTarget(toolbar, menuItemId, title, description); - } - - /** Return a tap target for the menu item from the given toolbar **/ - public static TapTarget forToolbarMenuItem(android.widget.Toolbar toolbar, @IdRes int menuItemId, - CharSequence title) { - return forToolbarMenuItem(toolbar, menuItemId, title, null); - } - - /** Return a tap target for the menu item from the given toolbar **/ - public static TapTarget forToolbarMenuItem(android.widget.Toolbar toolbar, @IdRes int menuItemId, - CharSequence title, @Nullable CharSequence description) { - return new ToolbarTapTarget(toolbar, menuItemId, title, description); - } - - /** Return a tap target for the specified view **/ - public static TapTarget forView(View view, CharSequence title) { - return forView(view, title, null); - } - - /** Return a tap target for the specified view **/ - public static TapTarget forView(View view, CharSequence title, @Nullable CharSequence description) { - return new ViewTapTarget(view, title, description); - } - - /** Return a tap target for the specified bounds **/ - public static TapTarget forBounds(Rect bounds, CharSequence title) { - return forBounds(bounds, title, null); - } - - /** Return a tap target for the specified bounds **/ - public static TapTarget forBounds(Rect bounds, CharSequence title, @Nullable CharSequence description) { - return new TapTarget(bounds, title, description); - } - - protected TapTarget(Rect bounds, CharSequence title, @Nullable CharSequence description) { - this(title, description); - if (bounds == null) { - throw new IllegalArgumentException("Cannot pass null bounds or title"); - } - - this.bounds = bounds; - } - - protected TapTarget(CharSequence title, @Nullable CharSequence description) { - if (title == null) { - throw new IllegalArgumentException("Cannot pass null title"); - } - - this.title = title; - this.description = description; - } - - /** Specify whether the target should be transparent **/ - public TapTarget transparentTarget(boolean transparent) { - this.transparentTarget = transparent; - return this; - } - - /** Specify the color resource for the outer circle **/ - public TapTarget outerCircleColor(@ColorRes int color) { - this.outerCircleColorRes = color; - return this; - } - - /** Specify the color value for the outer circle **/ - // TODO(Hilal): In v2, this API should be cleaned up / torched - public TapTarget outerCircleColorInt(@ColorInt int color) { - this.outerCircleColor = color; - return this; - } - - /** Specify the alpha value [0.0, 1.0] of the outer circle **/ - public TapTarget outerCircleAlpha(float alpha) { - if (alpha < 0.0f || alpha > 1.0f) { - throw new IllegalArgumentException("Given an invalid alpha value: " + alpha); - } - this.outerCircleAlpha = alpha; - return this; - } - - /** Specify the color resource for the target circle **/ - public TapTarget targetCircleColor(@ColorRes int color) { - this.targetCircleColorRes = color; - return this; - } - - /** Specify the color value for the target circle **/ - // TODO(Hilal): In v2, this API should be cleaned up / torched - public TapTarget targetCircleColorInt(@ColorInt int color) { - this.targetCircleColor = color; - return this; - } - - /** Specify the color resource for all text **/ - public TapTarget textColor(@ColorRes int color) { - this.titleTextColorRes = color; - this.descriptionTextColorRes = color; - return this; - } - - /** Specify the color value for all text **/ - // TODO(Hilal): In v2, this API should be cleaned up / torched - public TapTarget textColorInt(@ColorInt int color) { - this.titleTextColor = color; - this.descriptionTextColor = color; - return this; - } - - /** Specify the color resource for the title text **/ - public TapTarget titleTextColor(@ColorRes int color) { - this.titleTextColorRes = color; - return this; - } - - /** Specify the color value for the title text **/ - // TODO(Hilal): In v2, this API should be cleaned up / torched - public TapTarget titleTextColorInt(@ColorInt int color) { - this.titleTextColor = color; - return this; - } - - /** Specify the color resource for the description text **/ - public TapTarget descriptionTextColor(@ColorRes int color) { - this.descriptionTextColorRes = color; - return this; - } - - /** Specify the color value for the description text **/ - // TODO(Hilal): In v2, this API should be cleaned up / torched - public TapTarget descriptionTextColorInt(@ColorInt int color) { - this.descriptionTextColor = color; - return this; - } - - /** Specify the typeface for all text **/ - public TapTarget textTypeface(Typeface typeface) { - if (typeface == null) throw new IllegalArgumentException("Cannot use a null typeface"); - titleTypeface = typeface; - descriptionTypeface = typeface; - return this; - } - - /** Specify the typeface for title text **/ - public TapTarget titleTypeface(Typeface titleTypeface) { - if (titleTypeface == null) throw new IllegalArgumentException("Cannot use a null typeface"); - this.titleTypeface = titleTypeface; - return this; - } - - /** Specify the typeface for description text **/ - public TapTarget descriptionTypeface(Typeface descriptionTypeface) { - if (descriptionTypeface == null) throw new IllegalArgumentException("Cannot use a null typeface"); - this.descriptionTypeface = descriptionTypeface; - return this; - } - - /** Specify the text size for the title in SP **/ - public TapTarget titleTextSize(int sp) { - if (sp < 0) throw new IllegalArgumentException("Given negative text size"); - this.titleTextSize = sp; - return this; - } - - /** Specify the text size for the description in SP **/ - public TapTarget descriptionTextSize(int sp) { - if (sp < 0) throw new IllegalArgumentException("Given negative text size"); - this.descriptionTextSize = sp; - return this; - } - - /** - * Specify the text size for the title via a dimen resource - *

- * Note: If set, this value will take precedence over the specified sp size - */ - public TapTarget titleTextDimen(@DimenRes int dimen) { - this.titleTextDimen = dimen; - return this; - } - - /** Specify the alpha value [0.0, 1.0] of the description text **/ - public TapTarget descriptionTextAlpha(float descriptionTextAlpha) { - if (descriptionTextAlpha < 0 || descriptionTextAlpha > 1f) { - throw new IllegalArgumentException("Given an invalid alpha value: " + descriptionTextAlpha); - } - this.descriptionTextAlpha = descriptionTextAlpha; - return this; - } - - /** - * Specify the text size for the description via a dimen resource - *

- * Note: If set, this value will take precedence over the specified sp size - */ - public TapTarget descriptionTextDimen(@DimenRes int dimen) { - this.descriptionTextDimen = dimen; - return this; - } - - /** - * Specify the color resource to use as a dim effect - *

- * Note: The given color will have its opacity modified to 30% automatically - */ - public TapTarget dimColor(@ColorRes int color) { - this.dimColorRes = color; - return this; - } - - /** - * Specify the color value to use as a dim effect - *

- * Note: The given color will have its opacity modified to 30% automatically - */ - // TODO(Hilal): In v2, this API should be cleaned up / torched - public TapTarget dimColorInt(@ColorInt int color) { - this.dimColor = color; - return this; - } - - /** Specify whether or not to draw a drop shadow around the outer circle **/ - public TapTarget drawShadow(boolean draw) { - this.drawShadow = draw; - return this; - } - - /** Specify whether or not the target should be cancelable **/ - public TapTarget cancelable(boolean status) { - this.cancelable = status; - return this; - } - - /** Specify whether to tint the target's icon with the outer circle's color **/ - public TapTarget tintTarget(boolean tint) { - this.tintTarget = tint; - return this; - } - - /** Specify the icon that will be drawn in the center of the target bounds **/ - public TapTarget icon(Drawable icon) { - return icon(icon, false); - } - - /** - * Specify the icon that will be drawn in the center of the target bounds - * @param hasSetBounds Whether the drawable already has its bounds correctly set. If the - * drawable does not have its bounds set, then the following bounds will - * be applied:
- * (0, 0, intrinsic-width, intrinsic-height) - */ - public TapTarget icon(Drawable icon, boolean hasSetBounds) { - if (icon == null) throw new IllegalArgumentException("Cannot use null drawable"); - this.icon = icon; - - if (!hasSetBounds) { - this.icon.setBounds(new Rect(0, 0, this.icon.getIntrinsicWidth(), this.icon.getIntrinsicHeight())); - } - - return this; - } - - /** Specify a unique identifier for this target. **/ - public TapTarget id(int id) { - this.id = id; - return this; - } - - /** Specify the target radius in dp. **/ - public TapTarget targetRadius(int targetRadius) { - this.targetRadius = targetRadius; - return this; - } - - /** Return the id associated with this tap target **/ - public int id() { - return id; - } - - /** - * In case your target needs time to be ready (laid out in your view, not created, etc), the - * runnable passed here will be invoked when the target is ready. - */ - public void onReady(Runnable runnable) { - runnable.run(); - } - - /** - * Returns the target bounds. Throws an exception if they are not set - * (target may not be ready) - *

- * This will only be called internally when {@link #onReady(Runnable)} invokes its runnable - */ - public Rect bounds() { - if (bounds == null) { - throw new IllegalStateException("Requesting bounds that are not set! Make sure your target is ready"); - } - return bounds; - } - - @Nullable - Integer outerCircleColorInt(Context context) { - return colorResOrInt(context, outerCircleColor, outerCircleColorRes); - } - - @Nullable - Integer targetCircleColorInt(Context context) { - return colorResOrInt(context, targetCircleColor, targetCircleColorRes); - } - - @Nullable - Integer dimColorInt(Context context) { - return colorResOrInt(context, dimColor, dimColorRes); - } - - @Nullable - Integer titleTextColorInt(Context context) { - return colorResOrInt(context, titleTextColor, titleTextColorRes); - } - - @Nullable - Integer descriptionTextColorInt(Context context) { - return colorResOrInt(context, descriptionTextColor, descriptionTextColorRes); - } - - int titleTextSizePx(Context context) { - return dimenOrSize(context, titleTextSize, titleTextDimen); - } - - int descriptionTextSizePx(Context context) { - return dimenOrSize(context, descriptionTextSize, descriptionTextDimen); - } - - @Nullable - private Integer colorResOrInt(Context context, @Nullable Integer value, @ColorRes int resource) { - if (resource != -1) { - return ContextCompat.getColor(context, resource); - } - - return value; - } - - private int dimenOrSize(Context context, int size, @DimenRes int dimen) { - if (dimen != -1) { - return context.getResources().getDimensionPixelSize(dimen); - } - - return UiUtil.sp(context, size); - } + final CharSequence title; + @Nullable + final CharSequence description; + + float outerCircleAlpha = 0.96f; + int targetRadius = 44; + int targetPadding = 20; + + Rect bounds; + Drawable icon; + Typeface titleTypeface; + Typeface descriptionTypeface; + int id = -1; + boolean drawShadow = false; + boolean cancelable = true; + boolean tintTarget = true; + boolean drawOval = false; + boolean transparentTarget = false; + float descriptionTextAlpha = 0.54f; + @ColorRes + private int outerCircleColorRes = -1; + @ColorRes + private int targetTintColorRes = -1; + @ColorRes + private int targetCircleColorRes = -1; + @ColorRes + private int dimColorRes = -1; + @ColorRes + private int titleTextColorRes = -1; + @ColorRes + private int descriptionTextColorRes = -1; + private Integer outerCircleColor = null; + private Integer targetTintColor = null; + private Integer targetCircleColor = null; + private Integer dimColor = null; + private Integer titleTextColor = null; + private Integer descriptionTextColor = null; + @DimenRes + private int titleTextDimen = -1; + @DimenRes + private int descriptionTextDimen = -1; + private int titleTextSize = 20; + private int descriptionTextSize = 18; + + protected TapTarget(Rect bounds, CharSequence title, @Nullable CharSequence description) { + this(title, description); + if (bounds == null) { + throw new IllegalArgumentException("Cannot pass null bounds or title"); + } + + this.bounds = bounds; + } + + protected TapTarget(CharSequence title, @Nullable CharSequence description) { + if (title == null) { + throw new IllegalArgumentException("Cannot pass null title"); + } + + this.title = title; + this.description = description; + } + + /** + * Return a tap target for the overflow button from the given toolbar + *

+ * Note: This is currently experimental, use at your own risk + */ + public static TapTarget forToolbarOverflow(Toolbar toolbar, CharSequence title) { + return forToolbarOverflow(toolbar, title, null); + } + + /** + * Return a tap target for the overflow button from the given toolbar + *

+ * Note: This is currently experimental, use at your own risk + */ + public static TapTarget forToolbarOverflow(Toolbar toolbar, CharSequence title, + @Nullable CharSequence description) { + return new ToolbarTapTarget(toolbar, false, title, description); + } + + /** + * Return a tap target for the overflow button from the given toolbar + *

+ * Note: This is currently experimental, use at your own risk + */ + public static TapTarget forToolbarOverflow(android.widget.Toolbar toolbar, CharSequence title) { + return forToolbarOverflow(toolbar, title, null); + } + + /** + * Return a tap target for the overflow button from the given toolbar + *

+ * Note: This is currently experimental, use at your own risk + */ + public static TapTarget forToolbarOverflow(android.widget.Toolbar toolbar, CharSequence title, + @Nullable CharSequence description) { + return new ToolbarTapTarget(toolbar, false, title, description); + } + + /** + * Return a tap target for the navigation button (back, up, etc) from the given toolbar + **/ + public static TapTarget forToolbarNavigationIcon(Toolbar toolbar, CharSequence title) { + return forToolbarNavigationIcon(toolbar, title, null); + } + + /** + * Return a tap target for the navigation button (back, up, etc) from the given toolbar + **/ + public static TapTarget forToolbarNavigationIcon(Toolbar toolbar, CharSequence title, + @Nullable CharSequence description) { + return new ToolbarTapTarget(toolbar, true, title, description); + } + + /** + * Return a tap target for the navigation button (back, up, etc) from the given toolbar + **/ + public static TapTarget forToolbarNavigationIcon(android.widget.Toolbar toolbar, CharSequence title) { + return forToolbarNavigationIcon(toolbar, title, null); + } + + /** + * Return a tap target for the navigation button (back, up, etc) from the given toolbar + **/ + public static TapTarget forToolbarNavigationIcon(android.widget.Toolbar toolbar, CharSequence title, + @Nullable CharSequence description) { + return new ToolbarTapTarget(toolbar, true, title, description); + } + + /** + * Return a tap target for the menu item from the given toolbar + **/ + public static TapTarget forToolbarMenuItem(Toolbar toolbar, @IdRes int menuItemId, + CharSequence title) { + return forToolbarMenuItem(toolbar, menuItemId, title, null); + } + + /** + * Return a tap target for the menu item from the given toolbar + **/ + public static TapTarget forToolbarMenuItem(Toolbar toolbar, @IdRes int menuItemId, + CharSequence title, @Nullable CharSequence description) { + return new ToolbarTapTarget(toolbar, menuItemId, title, description); + } + + /** + * Return a tap target for the menu item from the given toolbar + **/ + public static TapTarget forToolbarMenuItem(android.widget.Toolbar toolbar, @IdRes int menuItemId, + CharSequence title) { + return forToolbarMenuItem(toolbar, menuItemId, title, null); + } + + /** + * Return a tap target for the menu item from the given toolbar + **/ + public static TapTarget forToolbarMenuItem(android.widget.Toolbar toolbar, @IdRes int menuItemId, + CharSequence title, @Nullable CharSequence description) { + return new ToolbarTapTarget(toolbar, menuItemId, title, description); + } + + /** + * Return a tap target for the specified view + **/ + public static TapTarget forView(View view, CharSequence title) { + return forView(view, title, null); + } + + /** + * Return a tap target for the specified view + **/ + public static TapTarget forView(View view, CharSequence title, @Nullable CharSequence description) { + return new ViewTapTarget(view, title, description); + } + + /** + * Return a tap target for the specified bounds + **/ + public static TapTarget forBounds(Rect bounds, CharSequence title) { + return forBounds(bounds, title, null); + } + + /** + * Return a tap target for the specified bounds + **/ + public static TapTarget forBounds(Rect bounds, CharSequence title, @Nullable CharSequence description) { + return new TapTarget(bounds, title, description); + } + + /** + * Specify whether the target should be transparent + **/ + public TapTarget transparentTarget(boolean transparent) { + this.transparentTarget = transparent; + return this; + } + + /** + * Specify the color resource for the outer circle + **/ + public TapTarget outerCircleColor(@ColorRes int color) { + this.outerCircleColorRes = color; + return this; + } + + /** + * Specify the color value for the outer circle + **/ + // TODO(Hilal): In v2, this API should be cleaned up / torched + public TapTarget outerCircleColorInt(@ColorInt int color) { + this.outerCircleColor = color; + return this; + } + + /** + * Specify the color resource for the target + **/ + public TapTarget targetTintColor(@ColorRes int color) { + this.targetTintColorRes = color; + return this; + } + + /** + * Specify the color value for the target + **/ + public TapTarget targetTintColorInt(@ColorInt int color) { + this.targetTintColor = color; + return this; + } + + /** + * Specify the alpha value [0.0, 1.0] of the outer circle + **/ + public TapTarget outerCircleAlpha(float alpha) { + if (alpha < 0.0f || alpha > 1.0f) { + throw new IllegalArgumentException("Given an invalid alpha value: " + alpha); + } + this.outerCircleAlpha = alpha; + return this; + } + + /** + * Specify the color resource for the target circle + **/ + public TapTarget targetCircleColor(@ColorRes int color) { + this.targetCircleColorRes = color; + return this; + } + + /** + * Specify the color value for the target circle + **/ + // TODO(Hilal): In v2, this API should be cleaned up / torched + public TapTarget targetCircleColorInt(@ColorInt int color) { + this.targetCircleColor = color; + return this; + } + + /** + * Specify the color resource for all text + **/ + public TapTarget textColor(@ColorRes int color) { + this.titleTextColorRes = color; + this.descriptionTextColorRes = color; + return this; + } + + /** + * Specify the color value for all text + **/ + // TODO(Hilal): In v2, this API should be cleaned up / torched + public TapTarget textColorInt(@ColorInt int color) { + this.titleTextColor = color; + this.descriptionTextColor = color; + return this; + } + + /** + * Specify the color resource for the title text + **/ + public TapTarget titleTextColor(@ColorRes int color) { + this.titleTextColorRes = color; + return this; + } + + /** + * Specify the color value for the title text + **/ + // TODO(Hilal): In v2, this API should be cleaned up / torched + public TapTarget titleTextColorInt(@ColorInt int color) { + this.titleTextColor = color; + return this; + } + + /** + * Specify the color resource for the description text + **/ + public TapTarget descriptionTextColor(@ColorRes int color) { + this.descriptionTextColorRes = color; + return this; + } + + /** + * Specify the color value for the description text + **/ + // TODO(Hilal): In v2, this API should be cleaned up / torched + public TapTarget descriptionTextColorInt(@ColorInt int color) { + this.descriptionTextColor = color; + return this; + } + + /** + * Specify the typeface for all text + **/ + public TapTarget textTypeface(Typeface typeface) { + if (typeface == null) throw new IllegalArgumentException("Cannot use a null typeface"); + titleTypeface = typeface; + descriptionTypeface = typeface; + return this; + } + + /** + * Specify the typeface for title text + **/ + public TapTarget titleTypeface(Typeface titleTypeface) { + if (titleTypeface == null) throw new IllegalArgumentException("Cannot use a null typeface"); + this.titleTypeface = titleTypeface; + return this; + } + + /** + * Specify the typeface for description text + **/ + public TapTarget descriptionTypeface(Typeface descriptionTypeface) { + if (descriptionTypeface == null) + throw new IllegalArgumentException("Cannot use a null typeface"); + this.descriptionTypeface = descriptionTypeface; + return this; + } + + /** + * Specify the text size for the title in SP + **/ + public TapTarget titleTextSize(int sp) { + if (sp < 0) throw new IllegalArgumentException("Given negative text size"); + this.titleTextSize = sp; + return this; + } + + /** + * Specify the text size for the description in SP + **/ + public TapTarget descriptionTextSize(int sp) { + if (sp < 0) throw new IllegalArgumentException("Given negative text size"); + this.descriptionTextSize = sp; + return this; + } + + /** + * Specify the text size for the title via a dimen resource + *

+ * Note: If set, this value will take precedence over the specified sp size + */ + public TapTarget titleTextDimen(@DimenRes int dimen) { + this.titleTextDimen = dimen; + return this; + } + + /** + * Specify the alpha value [0.0, 1.0] of the description text + **/ + public TapTarget descriptionTextAlpha(float descriptionTextAlpha) { + if (descriptionTextAlpha < 0 || descriptionTextAlpha > 1f) { + throw new IllegalArgumentException("Given an invalid alpha value: " + descriptionTextAlpha); + } + this.descriptionTextAlpha = descriptionTextAlpha; + return this; + } + + /** + * Specify the text size for the description via a dimen resource + *

+ * Note: If set, this value will take precedence over the specified sp size + */ + public TapTarget descriptionTextDimen(@DimenRes int dimen) { + this.descriptionTextDimen = dimen; + return this; + } + + /** + * Specify the color resource to use as a dim effect + *

+ * Note: The given color will have its opacity modified to 30% automatically + */ + public TapTarget dimColor(@ColorRes int color) { + this.dimColorRes = color; + return this; + } + + /** + * Specify the color value to use as a dim effect + *

+ * Note: The given color will have its opacity modified to 30% automatically + */ + // TODO(Hilal): In v2, this API should be cleaned up / torched + public TapTarget dimColorInt(@ColorInt int color) { + this.dimColor = color; + return this; + } + + /** + * Specify whether or not to draw a drop shadow around the outer circle + **/ + public TapTarget drawShadow(boolean draw) { + this.drawShadow = draw; + return this; + } + + /** + * Specify whether or not the target should be cancelable + **/ + public TapTarget cancelable(boolean status) { + this.cancelable = status; + return this; + } + + /** + * Specify whether to tint the target's icon with the outer circle's color + **/ + public TapTarget tintTarget(boolean tint) { + this.tintTarget = tint; + return this; + } + + /** + * Specify whether to draw the target as oval + * This config invalidate targetRadius + **/ + public TapTarget drawOval(boolean drawOval) { + + if (targetRadius != 44) + throw new IllegalStateException("drawOval can't be used combined with targetRadius"); + + this.drawOval = drawOval; + return this; + } + + /** + * Specify the icon that will be drawn in the center of the target bounds + **/ + public TapTarget icon(Drawable icon) { + return icon(icon, false); + } + + /** + * Specify the icon that will be drawn in the center of the target bounds + * + * @param hasSetBounds Whether the drawable already has its bounds correctly set. If the + * drawable does not have its bounds set, then the following bounds will + * be applied:
+ * (0, 0, intrinsic-width, intrinsic-height) + */ + public TapTarget icon(Drawable icon, boolean hasSetBounds) { + if (icon == null) throw new IllegalArgumentException("Cannot use null drawable"); + this.icon = icon; + + if (!hasSetBounds) { + this.icon.setBounds(new Rect(0, 0, this.icon.getIntrinsicWidth(), this.icon.getIntrinsicHeight())); + } + + return this; + } + + /** + * Specify a unique identifier for this target. + **/ + public TapTarget id(int id) { + this.id = id; + return this; + } + + /** + * Specify the target radius in dp. + **/ + public TapTarget targetRadius(int targetRadius) { + + if (drawOval) + throw new IllegalStateException("targetRadius can't be used combined with drawOval"); + + this.targetRadius = targetRadius; + return this; + } + + /** + * Specify the target radius in dp. + **/ + public TapTarget targetPadding(int targetPadding) { + this.targetPadding = targetPadding; + return this; + } + + /** + * Return the id associated with this tap target + **/ + public int id() { + return id; + } + + /** + * In case your target needs time to be ready (laid out in your view, not created, etc), the + * runnable passed here will be invoked when the target is ready. + */ + public void onReady(Runnable runnable) { + runnable.run(); + } + + /** + * Returns the target bounds. Throws an exception if they are not set + * (target may not be ready) + *

+ * This will only be called internally when {@link #onReady(Runnable)} invokes its runnable + */ + public Rect bounds() { + if (bounds == null) { + throw new IllegalStateException("Requesting bounds that are not set! Make sure your target is ready"); + } + return bounds; + } + + @Nullable + Integer outerCircleColorInt(Context context) { + return colorResOrInt(context, outerCircleColor, outerCircleColorRes); + } + + @Nullable + Integer targetTintColorInt(Context context) { + return colorResOrInt(context, targetTintColor, targetTintColorRes); + } + + @Nullable + Integer targetCircleColorInt(Context context) { + return colorResOrInt(context, targetCircleColor, targetCircleColorRes); + } + + @Nullable + Integer dimColorInt(Context context) { + return colorResOrInt(context, dimColor, dimColorRes); + } + + @Nullable + Integer titleTextColorInt(Context context) { + return colorResOrInt(context, titleTextColor, titleTextColorRes); + } + + @Nullable + Integer descriptionTextColorInt(Context context) { + return colorResOrInt(context, descriptionTextColor, descriptionTextColorRes); + } + + int titleTextSizePx(Context context) { + return dimenOrSize(context, titleTextSize, titleTextDimen); + } + + int descriptionTextSizePx(Context context) { + return dimenOrSize(context, descriptionTextSize, descriptionTextDimen); + } + + @Nullable + private Integer colorResOrInt(Context context, @Nullable Integer value, @ColorRes int resource) { + if (resource != -1) { + return ContextCompat.getColor(context, resource); + } + + return value; + } + + private int dimenOrSize(Context context, int size, @DimenRes int dimen) { + if (dimen != -1) { + return context.getResources().getDimensionPixelSize(dimen); + } + + return UiUtil.sp(context, size); + } } diff --git a/taptargetview/src/main/java/com/getkeepsafe/taptargetview/TapTargetSequence.java b/taptargetview/src/main/java/com/getkeepsafe/taptargetview/TapTargetSequence.java index 23a33b2..eb6ff09 100644 --- a/taptargetview/src/main/java/com/getkeepsafe/taptargetview/TapTargetSequence.java +++ b/taptargetview/src/main/java/com/getkeepsafe/taptargetview/TapTargetSequence.java @@ -17,6 +17,7 @@ import android.app.Activity; import android.app.Dialog; + import androidx.annotation.Nullable; import androidx.annotation.UiThread; @@ -32,38 +33,49 @@ * Internally, a FIFO queue is held to dictate which {@link TapTarget} will be shown. */ public class TapTargetSequence { - private final @Nullable Activity activity; - private final @Nullable Dialog dialog; + private final @Nullable + Activity activity; + private final @Nullable + Dialog dialog; private final Queue targets; - private boolean active; - - @Nullable - private TapTargetView currentView; - Listener listener; boolean considerOuterCircleCanceled; boolean continueOnCancel; + private boolean active; + @Nullable + private TapTargetView currentView; + private final TapTargetView.Listener tapTargetListener = new TapTargetView.Listener() { + @Override + public void onTargetClick(TapTargetView view) { + super.onTargetClick(view); + if (listener != null) { + listener.onSequenceStep(view.target, true); + } + showNext(); + } - public interface Listener { - /** Called when there are no more tap targets to display */ - void onSequenceFinish(); - - /** - * Called when moving onto the next tap target. - * @param lastTarget The last displayed target - * @param targetClicked Whether the last displayed target was clicked (this will always be true - * unless you have set {@link #continueOnCancel(boolean)} and the user - * clicks outside of the target - */ - void onSequenceStep(TapTarget lastTarget, boolean targetClicked); + @Override + public void onOuterCircleClick(TapTargetView view) { + if (considerOuterCircleCanceled) { + onTargetCancel(view); + } + } - /** - * Called when the user taps outside of the current target, the target is cancelable, and - * {@link #continueOnCancel(boolean)} is not set. - * @param lastTarget The last displayed target - */ - void onSequenceCanceled(TapTarget lastTarget); - } + @Override + public void onTargetCancel(TapTargetView view) { + super.onTargetCancel(view); + if (continueOnCancel) { + if (listener != null) { + listener.onSequenceStep(view.target, false); + } + showNext(); + } else { + if (listener != null) { + listener.onSequenceCanceled(view.target); + } + } + } + }; public TapTargetSequence(Activity activity) { if (activity == null) throw new IllegalArgumentException("Activity is null"); @@ -79,43 +91,57 @@ public TapTargetSequence(Dialog dialog) { this.targets = new LinkedList<>(); } - /** Adds the given targets, in order, to the pending queue of {@link TapTarget}s */ + /** + * Adds the given targets, in order, to the pending queue of {@link TapTarget}s + */ public TapTargetSequence targets(List targets) { this.targets.addAll(targets); return this; } - /** Adds the given targets, in order, to the pending queue of {@link TapTarget}s */ + /** + * Adds the given targets, in order, to the pending queue of {@link TapTarget}s + */ public TapTargetSequence targets(TapTarget... targets) { Collections.addAll(this.targets, targets); return this; } - /** Adds the given target to the pending queue of {@link TapTarget}s */ + /** + * Adds the given target to the pending queue of {@link TapTarget}s + */ public TapTargetSequence target(TapTarget target) { this.targets.add(target); return this; } - /** Whether or not to continue the sequence when a {@link TapTarget} is canceled **/ + /** + * Whether or not to continue the sequence when a {@link TapTarget} is canceled + **/ public TapTargetSequence continueOnCancel(boolean status) { this.continueOnCancel = status; return this; } - /** Whether or not to consider taps on the outer circle as a cancellation **/ + /** + * Whether or not to consider taps on the outer circle as a cancellation + **/ public TapTargetSequence considerOuterCircleCanceled(boolean status) { this.considerOuterCircleCanceled = status; return this; } - /** Specify the listener for this sequence **/ + /** + * Specify the listener for this sequence + **/ public TapTargetSequence listener(Listener listener) { this.listener = listener; return this; } - /** Immediately starts the sequence and displays the first target from the queue **/ + /** + * Immediately starts the sequence and displays the first target from the queue + **/ @UiThread public void start() { if (targets.isEmpty() || active) { @@ -126,7 +152,9 @@ public void start() { showNext(); } - /** Immediately starts the sequence from the given targetId's position in the queue */ + /** + * Immediately starts the sequence from the given targetId's position in the queue + */ public void startWith(int targetId) { if (active) { return; @@ -144,7 +172,9 @@ public void startWith(int targetId) { start(); } - /** Immediately starts the sequence at the specified zero-based index in the queue */ + /** + * Immediately starts the sequence at the specified zero-based index in the queue + */ public void startAt(int index) { if (active) { return; @@ -170,6 +200,7 @@ public void startAt(int index) { * Cancels the sequence, if the current target is cancelable. * When the sequence is canceled, the current target is dismissed and the remaining targets are * removed from the sequence. + * * @return whether the sequence was canceled or not */ @UiThread @@ -203,36 +234,28 @@ void showNext() { } } - private final TapTargetView.Listener tapTargetListener = new TapTargetView.Listener() { - @Override - public void onTargetClick(TapTargetView view) { - super.onTargetClick(view); - if (listener != null) { - listener.onSequenceStep(view.target, true); - } - showNext(); - } + public interface Listener { + /** + * Called when there are no more tap targets to display + */ + void onSequenceFinish(); - @Override - public void onOuterCircleClick(TapTargetView view) { - if (considerOuterCircleCanceled) { - onTargetCancel(view); - } - } + /** + * Called when moving onto the next tap target. + * + * @param lastTarget The last displayed target + * @param targetClicked Whether the last displayed target was clicked (this will always be true + * unless you have set {@link #continueOnCancel(boolean)} and the user + * clicks outside of the target + */ + void onSequenceStep(TapTarget lastTarget, boolean targetClicked); - @Override - public void onTargetCancel(TapTargetView view) { - super.onTargetCancel(view); - if (continueOnCancel) { - if (listener != null) { - listener.onSequenceStep(view.target, false); - } - showNext(); - } else { - if (listener != null) { - listener.onSequenceCanceled(view.target); - } - } - } - }; + /** + * Called when the user taps outside of the current target, the target is cancelable, and + * {@link #continueOnCancel(boolean)} is not set. + * + * @param lastTarget The last displayed target + */ + void onSequenceCanceled(TapTarget lastTarget); + } } diff --git a/taptargetview/src/main/java/com/getkeepsafe/taptargetview/TapTargetView.java b/taptargetview/src/main/java/com/getkeepsafe/taptargetview/TapTargetView.java index c21735d..48f4eb6 100644 --- a/taptargetview/src/main/java/com/getkeepsafe/taptargetview/TapTargetView.java +++ b/taptargetview/src/main/java/com/getkeepsafe/taptargetview/TapTargetView.java @@ -33,11 +33,11 @@ import android.graphics.PorterDuffColorFilter; import android.graphics.PorterDuffXfermode; import android.graphics.Rect; +import android.graphics.RectF; import android.graphics.Region; import android.graphics.Typeface; import android.graphics.drawable.Drawable; import android.os.Build; -import androidx.annotation.Nullable; import android.text.DynamicLayout; import android.text.Layout; import android.text.SpannableStringBuilder; @@ -55,6 +55,8 @@ import android.view.WindowManager; import android.view.animation.AccelerateDecelerateInterpolator; +import androidx.annotation.Nullable; + /** * TapTargetView implements a feature discovery paradigm following Google's Material Design * guidelines. @@ -67,981 +69,1107 @@ */ @SuppressLint("ViewConstructor") public class TapTargetView extends View { - private boolean isDismissed = false; - private boolean isDismissing = false; - private boolean isInteractable = true; - - final int TARGET_PADDING; - final int TARGET_RADIUS; - final int TARGET_PULSE_RADIUS; - final int TEXT_PADDING; - final int TEXT_SPACING; - final int TEXT_MAX_WIDTH; - final int TEXT_POSITIONING_BIAS; - final int CIRCLE_PADDING; - final int GUTTER_DIM; - final int SHADOW_DIM; - final int SHADOW_JITTER_DIM; - - @Nullable - final ViewGroup boundingParent; - final ViewManager parent; - final TapTarget target; - final Rect targetBounds; - - final TextPaint titlePaint; - final TextPaint descriptionPaint; - final Paint outerCirclePaint; - final Paint outerCircleShadowPaint; - final Paint targetCirclePaint; - final Paint targetCirclePulsePaint; - - CharSequence title; - @Nullable - StaticLayout titleLayout; - @Nullable - CharSequence description; - @Nullable - StaticLayout descriptionLayout; - boolean isDark; - boolean debug; - boolean shouldTintTarget; - boolean shouldDrawShadow; - boolean cancelable; - boolean visible; - - // Debug related variables - @Nullable - SpannableStringBuilder debugStringBuilder; - @Nullable - DynamicLayout debugLayout; - @Nullable - TextPaint debugTextPaint; - @Nullable - Paint debugPaint; - - // Drawing properties - Rect drawingBounds; - Rect textBounds; - - Path outerCirclePath; - float outerCircleRadius; - int calculatedOuterCircleRadius; - int[] outerCircleCenter; - int outerCircleAlpha; - - float targetCirclePulseRadius; - int targetCirclePulseAlpha; - - float targetCircleRadius; - int targetCircleAlpha; - - int textAlpha; - int dimColor; - - float lastTouchX; - float lastTouchY; - - int topBoundary; - int bottomBoundary; - - Bitmap tintedTarget; - - Listener listener; - - @Nullable - ViewOutlineProvider outlineProvider; - - public static TapTargetView showFor(Activity activity, TapTarget target) { - return showFor(activity, target, null); - } - - public static TapTargetView showFor(Activity activity, TapTarget target, Listener listener) { - if (activity == null) throw new IllegalArgumentException("Activity is null"); - - final ViewGroup decor = (ViewGroup) activity.getWindow().getDecorView(); - final ViewGroup.LayoutParams layoutParams = new ViewGroup.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT); - final ViewGroup content = (ViewGroup) decor.findViewById(android.R.id.content); - final TapTargetView tapTargetView = new TapTargetView(activity, decor, content, target, listener); - decor.addView(tapTargetView, layoutParams); - - return tapTargetView; - } - - public static TapTargetView showFor(Dialog dialog, TapTarget target) { - return showFor(dialog, target, null); - } - - public static TapTargetView showFor(Dialog dialog, TapTarget target, Listener listener) { - if (dialog == null) throw new IllegalArgumentException("Dialog is null"); - - final Context context = dialog.getContext(); - final WindowManager windowManager = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE); - final WindowManager.LayoutParams params = new WindowManager.LayoutParams(); - params.type = WindowManager.LayoutParams.TYPE_APPLICATION; - params.format = PixelFormat.RGBA_8888; - params.flags = 0; - params.gravity = Gravity.START | Gravity.TOP; - params.x = 0; - params.y = 0; - params.width = WindowManager.LayoutParams.MATCH_PARENT; - params.height = WindowManager.LayoutParams.MATCH_PARENT; - - final TapTargetView tapTargetView = new TapTargetView(context, windowManager, null, target, listener); - windowManager.addView(tapTargetView, params); - - return tapTargetView; - } - - public static class Listener { - /** Signals that the user has clicked inside of the target **/ - public void onTargetClick(TapTargetView view) { - view.dismiss(true); - } - /** Signals that the user has long clicked inside of the target **/ - public void onTargetLongClick(TapTargetView view) { - onTargetClick(view); + private final float ovalPercentFactor = 1.5F; + + private boolean isDismissed = false; + private boolean isDismissing = false; + private boolean isInteractable = true; + + int TARGET_RADIUS; + final int TARGET_PADDING; + final int TARGET_PULSE_RADIUS; + final int TEXT_PADDING; + final int TEXT_SPACING; + final int TEXT_MAX_WIDTH; + final int TEXT_POSITIONING_BIAS; + final int CIRCLE_PADDING; + final int GUTTER_DIM; + final int SHADOW_DIM; + final int SHADOW_JITTER_DIM; + + @Nullable + final ViewGroup boundingParent; + final ViewManager parent; + final TapTarget target; + final Rect targetBounds; + + final TextPaint titlePaint; + final TextPaint descriptionPaint; + final Paint outerCirclePaint; + final Paint outerCircleShadowPaint; + final Paint targetCirclePaint; + final Paint targetCirclePulsePaint; + + final RectF targetOvalRect; + final RectF targetOvalPulseRect; + + CharSequence title; + @Nullable + StaticLayout titleLayout; + @Nullable + CharSequence description; + @Nullable + StaticLayout descriptionLayout; + boolean isDark; + boolean debug; + boolean shouldTintTarget; + boolean shouldDrawShadow; + boolean cancelable; + boolean visible; + + // Debug related variables + @Nullable + SpannableStringBuilder debugStringBuilder; + @Nullable + DynamicLayout debugLayout; + @Nullable + TextPaint debugTextPaint; + @Nullable + Paint debugPaint; + + // Drawing properties + Rect drawingBounds; + Rect textBounds; + + Path outerCirclePath; + float outerCircleRadius; + int calculatedOuterCircleRadius; + int[] outerCircleCenter; + int outerCircleAlpha; + + float targetOvalPulseScaleFactor; + float targetCirclePulseRadius; + int targetCirclePulseAlpha; + + float targetOvalScaleFactor; + float targetCircleRadius; + int targetCircleAlpha; + + int textAlpha; + int dimColor; + + float lastTouchX; + float lastTouchY; + + int topBoundary; + int bottomBoundary; + + Bitmap tintedTarget; + + Listener listener; + + @Nullable + ViewOutlineProvider outlineProvider; + + public static TapTargetView showFor(Activity activity, TapTarget target) { + return showFor(activity, target, null); } - /** If cancelable, signals that the user has clicked outside of the outer circle **/ - public void onTargetCancel(TapTargetView view) { - view.dismiss(false); - } + public static TapTargetView showFor(Activity activity, TapTarget target, Listener listener) { + if (activity == null) throw new IllegalArgumentException("Activity is null"); - /** Signals that the user clicked on the outer circle portion of the tap target **/ - public void onOuterCircleClick(TapTargetView view) { - // no-op as default + final ViewGroup decor = (ViewGroup) activity.getWindow().getDecorView(); + final ViewGroup.LayoutParams layoutParams = new ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT); + final ViewGroup content = (ViewGroup) decor.findViewById(android.R.id.content); + final TapTargetView tapTargetView = new TapTargetView(activity, decor, content, target, listener); + decor.addView(tapTargetView, layoutParams); + + return tapTargetView; } - /** - * Signals that the tap target has been dismissed - * @param userInitiated Whether the user caused this action - */ - public void onTargetDismissed(TapTargetView view, boolean userInitiated) { + public static TapTargetView showFor(Dialog dialog, TapTarget target) { + return showFor(dialog, target, null); } - } - final FloatValueAnimatorBuilder.UpdateListener expandContractUpdateListener = new FloatValueAnimatorBuilder.UpdateListener() { - @Override - public void onUpdate(float lerpTime) { - final float newOuterCircleRadius = calculatedOuterCircleRadius * lerpTime; - final boolean expanding = newOuterCircleRadius > outerCircleRadius; - if (!expanding) { - // When contracting we need to invalidate the old drawing bounds. Otherwise - // you will see artifacts as the circle gets smaller - calculateDrawingBounds(); - } - - final float targetAlpha = target.outerCircleAlpha * 255; - outerCircleRadius = newOuterCircleRadius; - outerCircleAlpha = (int) Math.min(targetAlpha, (lerpTime * 1.5f * targetAlpha)); - outerCirclePath.reset(); - outerCirclePath.addCircle(outerCircleCenter[0], outerCircleCenter[1], outerCircleRadius, Path.Direction.CW); - - targetCircleAlpha = (int) Math.min(255.0f, (lerpTime * 1.5f * 255.0f)); - - if (expanding) { - targetCircleRadius = TARGET_RADIUS * Math.min(1.0f, lerpTime * 1.5f); - } else { - targetCircleRadius = TARGET_RADIUS * lerpTime; - targetCirclePulseRadius *= lerpTime; - } - - textAlpha = (int) (delayedLerp(lerpTime, 0.7f) * 255); - - if (expanding) { - calculateDrawingBounds(); - } - - invalidateViewAndOutline(drawingBounds); + public static TapTargetView showFor(Dialog dialog, TapTarget target, Listener listener) { + if (dialog == null) throw new IllegalArgumentException("Dialog is null"); + + final Context context = dialog.getContext(); + final WindowManager windowManager = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE); + final WindowManager.LayoutParams params = new WindowManager.LayoutParams(); + params.type = WindowManager.LayoutParams.TYPE_APPLICATION; + params.format = PixelFormat.RGBA_8888; + params.flags = 0; + params.gravity = Gravity.START | Gravity.TOP; + params.x = 0; + params.y = 0; + params.width = WindowManager.LayoutParams.MATCH_PARENT; + params.height = WindowManager.LayoutParams.MATCH_PARENT; + + final TapTargetView tapTargetView = new TapTargetView(context, windowManager, null, target, listener); + windowManager.addView(tapTargetView, params); + + return tapTargetView; } - }; - final ValueAnimator expandAnimation = new FloatValueAnimatorBuilder() - .duration(250) - .delayBy(250) - .interpolator(new AccelerateDecelerateInterpolator()) - .onUpdate(new FloatValueAnimatorBuilder.UpdateListener() { - @Override - public void onUpdate(float lerpTime) { - expandContractUpdateListener.onUpdate(lerpTime); - } - }) - .onEnd(new FloatValueAnimatorBuilder.EndListener() { - @Override - public void onEnd() { - pulseAnimation.start(); - isInteractable = true; + public static class Listener { + /** + * Signals that the user has clicked inside of the target + **/ + public void onTargetClick(TapTargetView view) { + view.dismiss(true); } - }) - .build(); - - final ValueAnimator pulseAnimation = new FloatValueAnimatorBuilder() - .duration(1000) - .repeat(ValueAnimator.INFINITE) - .interpolator(new AccelerateDecelerateInterpolator()) - .onUpdate(new FloatValueAnimatorBuilder.UpdateListener() { - @Override - public void onUpdate(float lerpTime) { - final float pulseLerp = delayedLerp(lerpTime, 0.5f); - targetCirclePulseRadius = (1.0f + pulseLerp) * TARGET_RADIUS; - targetCirclePulseAlpha = (int) ((1.0f - pulseLerp) * 255); - targetCircleRadius = TARGET_RADIUS + halfwayLerp(lerpTime) * TARGET_PULSE_RADIUS; - if (outerCircleRadius != calculatedOuterCircleRadius) { - outerCircleRadius = calculatedOuterCircleRadius; - } + /** + * Signals that the user has long clicked inside of the target + **/ + public void onTargetLongClick(TapTargetView view) { + onTargetClick(view); + } - calculateDrawingBounds(); - invalidateViewAndOutline(drawingBounds); + /** + * If cancelable, signals that the user has clicked outside of the outer circle + **/ + public void onTargetCancel(TapTargetView view) { + view.dismiss(false); } - }) - .build(); - final ValueAnimator dismissAnimation = new FloatValueAnimatorBuilder(true) - .duration(250) - .interpolator(new AccelerateDecelerateInterpolator()) - .onUpdate(new FloatValueAnimatorBuilder.UpdateListener() { - @Override - public void onUpdate(float lerpTime) { - expandContractUpdateListener.onUpdate(lerpTime); + /** + * Signals that the user clicked on the outer circle portion of the tap target + **/ + public void onOuterCircleClick(TapTargetView view) { + // no-op as default } - }) - .onEnd(new FloatValueAnimatorBuilder.EndListener() { - @Override - public void onEnd() { - finishDismiss(true); + + /** + * Signals that the tap target has been dismissed + * + * @param userInitiated Whether the user caused this action + */ + public void onTargetDismissed(TapTargetView view, boolean userInitiated) { } - }) - .build(); + } - private final ValueAnimator dismissConfirmAnimation = new FloatValueAnimatorBuilder() - .duration(250) - .interpolator(new AccelerateDecelerateInterpolator()) - .onUpdate(new FloatValueAnimatorBuilder.UpdateListener() { + final FloatValueAnimatorBuilder.UpdateListener expandContractUpdateListener = new FloatValueAnimatorBuilder.UpdateListener() { @Override public void onUpdate(float lerpTime) { - final float spedUpLerp = Math.min(1.0f, lerpTime * 2.0f); - outerCircleRadius = calculatedOuterCircleRadius * (1.0f + (spedUpLerp * 0.2f)); - outerCircleAlpha = (int) ((1.0f - spedUpLerp) * target.outerCircleAlpha * 255.0f); - outerCirclePath.reset(); - outerCirclePath.addCircle(outerCircleCenter[0], outerCircleCenter[1], outerCircleRadius, Path.Direction.CW); - targetCircleRadius = (1.0f - lerpTime) * TARGET_RADIUS; - targetCircleAlpha = (int) ((1.0f - lerpTime) * 255.0f); - targetCirclePulseRadius = (1.0f + lerpTime) * TARGET_RADIUS; - targetCirclePulseAlpha = (int) ((1.0f - lerpTime) * targetCirclePulseAlpha); - textAlpha = (int) ((1.0f - spedUpLerp) * 255.0f); - calculateDrawingBounds(); - invalidateViewAndOutline(drawingBounds); - } - }) - .onEnd(new FloatValueAnimatorBuilder.EndListener() { - @Override - public void onEnd() { - finishDismiss(true); - } - }) - .build(); - - private ValueAnimator[] animators = new ValueAnimator[] - {expandAnimation, pulseAnimation, dismissConfirmAnimation, dismissAnimation}; - - private final ViewTreeObserver.OnGlobalLayoutListener globalLayoutListener; - - /** - * This constructor should only be used directly for very specific use cases not covered by - * the static factory methods. - * - * @param context The host context - * @param parent The parent that this TapTargetView will become a child of. This parent should - * allow the largest possible area for this view to utilize - * @param boundingParent Optional. Will be used to calculate boundaries if needed. For example, - * if your view is added to the decor view of your Window, then you want - * to adjust for system ui like the navigation bar or status bar, and so - * you would pass in the content view (which doesn't include system ui) - * here. - * @param target The {@link TapTarget} to target - * @param userListener Optional. The {@link Listener} instance for this view - */ - public TapTargetView(final Context context, - final ViewManager parent, - @Nullable final ViewGroup boundingParent, - final TapTarget target, - @Nullable final Listener userListener) { - super(context); - if (target == null) throw new IllegalArgumentException("Target cannot be null"); - - this.target = target; - this.parent = parent; - this.boundingParent = boundingParent; - this.listener = userListener != null ? userListener : new Listener(); - this.title = target.title; - this.description = target.description; - - TARGET_PADDING = UiUtil.dp(context, 20); - CIRCLE_PADDING = UiUtil.dp(context, 40); - TARGET_RADIUS = UiUtil.dp(context, target.targetRadius); - TEXT_PADDING = UiUtil.dp(context, 40); - TEXT_SPACING = UiUtil.dp(context, 8); - TEXT_MAX_WIDTH = UiUtil.dp(context, 360); - TEXT_POSITIONING_BIAS = UiUtil.dp(context, 20); - GUTTER_DIM = UiUtil.dp(context, 88); - SHADOW_DIM = UiUtil.dp(context, 8); - SHADOW_JITTER_DIM = UiUtil.dp(context, 1); - TARGET_PULSE_RADIUS = (int) (0.1f * TARGET_RADIUS); - - outerCirclePath = new Path(); - targetBounds = new Rect(); - drawingBounds = new Rect(); - - titlePaint = new TextPaint(); - titlePaint.setTextSize(target.titleTextSizePx(context)); - titlePaint.setTypeface(Typeface.create("sans-serif-medium", Typeface.NORMAL)); - titlePaint.setAntiAlias(true); - - descriptionPaint = new TextPaint(); - descriptionPaint.setTextSize(target.descriptionTextSizePx(context)); - descriptionPaint.setTypeface(Typeface.create(Typeface.SANS_SERIF, Typeface.NORMAL)); - descriptionPaint.setAntiAlias(true); - descriptionPaint.setAlpha((int) (0.54f * 255.0f)); - - outerCirclePaint = new Paint(); - outerCirclePaint.setAntiAlias(true); - outerCirclePaint.setAlpha((int) (target.outerCircleAlpha * 255.0f)); - - outerCircleShadowPaint = new Paint(); - outerCircleShadowPaint.setAntiAlias(true); - outerCircleShadowPaint.setAlpha(50); - outerCircleShadowPaint.setStyle(Paint.Style.STROKE); - outerCircleShadowPaint.setStrokeWidth(SHADOW_JITTER_DIM); - outerCircleShadowPaint.setColor(Color.BLACK); - - targetCirclePaint = new Paint(); - targetCirclePaint.setAntiAlias(true); - - targetCirclePulsePaint = new Paint(); - targetCirclePulsePaint.setAntiAlias(true); - - applyTargetOptions(context); - - final boolean hasKitkat = Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT; - final boolean translucentStatusBar; - final boolean translucentNavigationBar; - final boolean layoutNoLimits; - - if (context instanceof Activity) { - Activity activity = (Activity) context; - final int flags = activity.getWindow().getAttributes().flags; - translucentStatusBar = hasKitkat && (flags & WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS) != 0; - translucentNavigationBar = hasKitkat && (flags & WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION) != 0; - layoutNoLimits = (flags & WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS) != 0; - } else { - translucentStatusBar = false; - translucentNavigationBar = false; - layoutNoLimits = false; - } + final float newOuterCircleRadius = calculatedOuterCircleRadius * lerpTime; + final boolean expanding = newOuterCircleRadius > outerCircleRadius; + if (!expanding) { + // When contracting we need to invalidate the old drawing bounds. Otherwise + // you will see artifacts as the circle gets smaller + calculateDrawingBounds(); + } - globalLayoutListener = new ViewTreeObserver.OnGlobalLayoutListener() { - @Override - public void onGlobalLayout() { - if (isDismissing) { - return; - } - updateTextLayouts(); - target.onReady(new Runnable() { - @Override - public void run() { - final int[] offset = new int[2]; - - targetBounds.set(target.bounds()); - - getLocationOnScreen(offset); - targetBounds.offset(-offset[0], -offset[1]); - - if (boundingParent != null) { - final WindowManager windowManager - = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE); - final DisplayMetrics displayMetrics = new DisplayMetrics(); - windowManager.getDefaultDisplay().getMetrics(displayMetrics); - - final Rect rect = new Rect(); - boundingParent.getWindowVisibleDisplayFrame(rect); - int[] parentLocation = new int[2]; - boundingParent.getLocationInWindow(parentLocation); - - if (translucentStatusBar) { - rect.top = parentLocation[1]; - } - if (translucentNavigationBar) { - rect.bottom = parentLocation[1] + boundingParent.getHeight(); - } - - // We bound the boundaries to be within the screen's coordinates to - // handle the case where the flag FLAG_LAYOUT_NO_LIMITS is set - if (layoutNoLimits) { - topBoundary = Math.max(0, rect.top); - bottomBoundary = Math.min(rect.bottom, displayMetrics.heightPixels); - } else { - topBoundary = rect.top; - bottomBoundary = rect.bottom; - } + final float targetAlpha = target.outerCircleAlpha * 255; + outerCircleRadius = newOuterCircleRadius; + outerCircleAlpha = (int) Math.min(targetAlpha, (lerpTime * 1.5f * targetAlpha)); + outerCirclePath.reset(); + outerCirclePath.addCircle(outerCircleCenter[0], outerCircleCenter[1], outerCircleRadius, Path.Direction.CW); + + targetCircleAlpha = (int) Math.min(255.0f, (lerpTime * 1.5f * 255.0f)); + + targetOvalScaleFactor = lerpTime; + + if (expanding) { + targetCircleRadius = TARGET_RADIUS * Math.min(1.0f, lerpTime * 1.5f); + } else { + targetCircleRadius = TARGET_RADIUS * lerpTime; + targetCirclePulseRadius *= lerpTime; + targetOvalPulseScaleFactor = targetOvalScaleFactor * lerpTime; } - drawTintedTarget(); - requestFocus(); - calculateDimensions(); + textAlpha = (int) (delayedLerp(lerpTime, 0.7f) * 255); - startExpandAnimation(); - } - }); - } - }; + if (expanding) { + calculateDrawingBounds(); + } - getViewTreeObserver().addOnGlobalLayoutListener(globalLayoutListener); - - setFocusableInTouchMode(true); - setClickable(true); - setOnClickListener(new OnClickListener() { - @Override - public void onClick(View v) { - if (listener == null || outerCircleCenter == null || !isInteractable) return; - - final boolean clickedInTarget = - distance(targetBounds.centerX(), targetBounds.centerY(), (int) lastTouchX, (int) lastTouchY) <= targetCircleRadius; - final double distanceToOuterCircleCenter = distance(outerCircleCenter[0], outerCircleCenter[1], - (int) lastTouchX, (int) lastTouchY); - final boolean clickedInsideOfOuterCircle = distanceToOuterCircleCenter <= outerCircleRadius; - - if (clickedInTarget) { - isInteractable = false; - listener.onTargetClick(TapTargetView.this); - } else if (clickedInsideOfOuterCircle) { - listener.onOuterCircleClick(TapTargetView.this); - } else if (cancelable) { - isInteractable = false; - listener.onTargetCancel(TapTargetView.this); + invalidateViewAndOutline(drawingBounds); } - } - }); + }; - setOnLongClickListener(new OnLongClickListener() { - @Override - public boolean onLongClick(View v) { - if (listener == null) return false; + final ValueAnimator expandAnimation = new FloatValueAnimatorBuilder() + .duration(250) + .delayBy(250) + .interpolator(new AccelerateDecelerateInterpolator()) + .onUpdate(new FloatValueAnimatorBuilder.UpdateListener() { + @Override + public void onUpdate(float lerpTime) { + expandContractUpdateListener.onUpdate(lerpTime); + } + }) + .onEnd(new FloatValueAnimatorBuilder.EndListener() { + @Override + public void onEnd() { + pulseAnimation.start(); + isInteractable = true; + } + }) + .build(); + + final ValueAnimator pulseAnimation = new FloatValueAnimatorBuilder() + .duration(1000) + .repeat(ValueAnimator.INFINITE) + .interpolator(new AccelerateDecelerateInterpolator()) + .onUpdate(new FloatValueAnimatorBuilder.UpdateListener() { + @Override + public void onUpdate(float lerpTime) { + final float pulseLerp = delayedLerp(lerpTime, 0.5f); + targetCirclePulseRadius = (1.0f + pulseLerp) * TARGET_RADIUS; + targetOvalPulseScaleFactor = lerpTime; + targetCirclePulseAlpha = (int) ((1.0f - pulseLerp) * 255); + targetCircleRadius = TARGET_RADIUS + halfwayLerp(lerpTime) * TARGET_PULSE_RADIUS; + targetOvalScaleFactor = halfwayLerp(lerpTime); + + if (outerCircleRadius != calculatedOuterCircleRadius) { + outerCircleRadius = calculatedOuterCircleRadius; + } + + calculateDrawingBounds(); + invalidateViewAndOutline(drawingBounds); + } + }) + .build(); + + final ValueAnimator dismissAnimation = new FloatValueAnimatorBuilder(true) + .duration(250) + .interpolator(new AccelerateDecelerateInterpolator()) + .onUpdate(new FloatValueAnimatorBuilder.UpdateListener() { + @Override + public void onUpdate(float lerpTime) { + expandContractUpdateListener.onUpdate(lerpTime); + } + }) + .onEnd(new FloatValueAnimatorBuilder.EndListener() { + @Override + public void onEnd() { + finishDismiss(true); + } + }) + .build(); + + private final ValueAnimator dismissConfirmAnimation = new FloatValueAnimatorBuilder() + .duration(250) + .interpolator(new AccelerateDecelerateInterpolator()) + .onUpdate(new FloatValueAnimatorBuilder.UpdateListener() { + @Override + public void onUpdate(float lerpTime) { + final float spedUpLerp = Math.min(1.0f, lerpTime * 2.0f); + outerCircleRadius = calculatedOuterCircleRadius * (1.0f + (spedUpLerp * 0.2f)); + targetOvalScaleFactor = spedUpLerp * 0.2f; + outerCircleAlpha = (int) ((1.0f - spedUpLerp) * target.outerCircleAlpha * 255.0f); + outerCirclePath.reset(); + outerCirclePath.addCircle(outerCircleCenter[0], outerCircleCenter[1], outerCircleRadius, Path.Direction.CW); + targetCircleRadius = (1.0f - lerpTime) * TARGET_RADIUS; + targetOvalPulseScaleFactor = lerpTime; + targetCircleAlpha = (int) ((1.0f - lerpTime) * 255.0f); + targetCirclePulseRadius = (1.0f + lerpTime) * TARGET_RADIUS; + targetCirclePulseAlpha = (int) ((1.0f - lerpTime) * targetCirclePulseAlpha); + textAlpha = (int) ((1.0f - spedUpLerp) * 255.0f); + calculateDrawingBounds(); + invalidateViewAndOutline(drawingBounds); + } + }) + .onEnd(new FloatValueAnimatorBuilder.EndListener() { + @Override + public void onEnd() { + finishDismiss(true); + } + }) + .build(); + + private ValueAnimator[] animators = new ValueAnimator[] + {expandAnimation, pulseAnimation, dismissConfirmAnimation, dismissAnimation}; + + private final ViewTreeObserver.OnGlobalLayoutListener globalLayoutListener; - if (targetBounds.contains((int) lastTouchX, (int) lastTouchY)) { - listener.onTargetLongClick(TapTargetView.this); - return true; + /** + * This constructor should only be used directly for very specific use cases not covered by + * the static factory methods. + * + * @param context The host context + * @param parent The parent that this TapTargetView will become a child of. This parent should + * allow the largest possible area for this view to utilize + * @param boundingParent Optional. Will be used to calculate boundaries if needed. For example, + * if your view is added to the decor view of your Window, then you want + * to adjust for system ui like the navigation bar or status bar, and so + * you would pass in the content view (which doesn't include system ui) + * here. + * @param target The {@link TapTarget} to target + * @param userListener Optional. The {@link Listener} instance for this view + */ + public TapTargetView(final Context context, + final ViewManager parent, + @Nullable final ViewGroup boundingParent, + final TapTarget target, + @Nullable final Listener userListener) { + super(context); + if (target == null) throw new IllegalArgumentException("Target cannot be null"); + + this.target = target; + this.parent = parent; + this.boundingParent = boundingParent; + this.listener = userListener != null ? userListener : new Listener(); + this.title = target.title; + this.description = target.description; + + TARGET_PADDING = UiUtil.dp(context, target.targetPadding); + CIRCLE_PADDING = UiUtil.dp(context, 40); + TARGET_RADIUS = UiUtil.dp(context, target.targetRadius); + TEXT_PADDING = UiUtil.dp(context, 40); + TEXT_SPACING = UiUtil.dp(context, 8); + TEXT_MAX_WIDTH = UiUtil.dp(context, 360); + TEXT_POSITIONING_BIAS = UiUtil.dp(context, 20); + GUTTER_DIM = UiUtil.dp(context, 88); + SHADOW_DIM = UiUtil.dp(context, 8); + SHADOW_JITTER_DIM = UiUtil.dp(context, 1); + TARGET_PULSE_RADIUS = (int) (0.1f * TARGET_RADIUS); + + outerCirclePath = new Path(); + targetBounds = new Rect(); + drawingBounds = new Rect(); + + titlePaint = new TextPaint(); + titlePaint.setTextSize(target.titleTextSizePx(context)); + titlePaint.setTypeface(Typeface.create("sans-serif-medium", Typeface.NORMAL)); + titlePaint.setAntiAlias(true); + + descriptionPaint = new TextPaint(); + descriptionPaint.setTextSize(target.descriptionTextSizePx(context)); + descriptionPaint.setTypeface(Typeface.create(Typeface.SANS_SERIF, Typeface.NORMAL)); + descriptionPaint.setAntiAlias(true); + descriptionPaint.setAlpha((int) (0.54f * 255.0f)); + + outerCirclePaint = new Paint(); + outerCirclePaint.setAntiAlias(true); + outerCirclePaint.setAlpha((int) (target.outerCircleAlpha * 255.0f)); + + outerCircleShadowPaint = new Paint(); + outerCircleShadowPaint.setAntiAlias(true); + outerCircleShadowPaint.setAlpha(50); + outerCircleShadowPaint.setStyle(Paint.Style.STROKE); + outerCircleShadowPaint.setStrokeWidth(SHADOW_JITTER_DIM); + outerCircleShadowPaint.setColor(Color.BLACK); + + targetCirclePaint = new Paint(); + targetCirclePaint.setAntiAlias(true); + + targetCirclePulsePaint = new Paint(); + targetCirclePulsePaint.setAntiAlias(true); + + targetOvalRect = new RectF( + targetBounds.left * ovalPercentFactor, + targetBounds.top * ovalPercentFactor, + targetBounds.right * ovalPercentFactor, + targetBounds.bottom * ovalPercentFactor + ); + targetOvalPulseRect = new RectF( + targetBounds.left * ovalPercentFactor, + targetBounds.top * ovalPercentFactor, + targetBounds.right * ovalPercentFactor, + targetBounds.bottom * ovalPercentFactor + ); + + applyTargetOptions(context); + + final boolean hasKitkat = Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT; + final boolean translucentStatusBar; + final boolean translucentNavigationBar; + final boolean layoutNoLimits; + + if (context instanceof Activity) { + Activity activity = (Activity) context; + final int flags = activity.getWindow().getAttributes().flags; + translucentStatusBar = hasKitkat && (flags & WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS) != 0; + translucentNavigationBar = hasKitkat && (flags & WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION) != 0; + layoutNoLimits = (flags & WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS) != 0; + } else { + translucentStatusBar = false; + translucentNavigationBar = false; + layoutNoLimits = false; } - return false; - } - }); - } - - private void startExpandAnimation() { - if (!visible) { - isInteractable = false; - expandAnimation.start(); - visible = true; - } - } - - protected void applyTargetOptions(Context context) { - shouldTintTarget = !target.transparentTarget && target.tintTarget; - shouldDrawShadow = target.drawShadow; - cancelable = target.cancelable; - - // We can't clip out portions of a view outline, so if the user specified a transparent - // target, we need to fallback to drawing a jittered shadow approximation - if (shouldDrawShadow && Build.VERSION.SDK_INT >= 21 && !target.transparentTarget) { - outlineProvider = new ViewOutlineProvider() { - @TargetApi(Build.VERSION_CODES.LOLLIPOP) - @Override - public void getOutline(View view, Outline outline) { - if (outerCircleCenter == null) return; - outline.setOval( - (int) (outerCircleCenter[0] - outerCircleRadius), (int) (outerCircleCenter[1] - outerCircleRadius), - (int) (outerCircleCenter[0] + outerCircleRadius), (int) (outerCircleCenter[1] + outerCircleRadius)); - outline.setAlpha(outerCircleAlpha / 255.0f); - if (Build.VERSION.SDK_INT >= 22) { - outline.offset(0, SHADOW_DIM); - } - } - }; + globalLayoutListener = new ViewTreeObserver.OnGlobalLayoutListener() { + @Override + public void onGlobalLayout() { + if (isDismissing) { + return; + } + updateTextLayouts(); + target.onReady(new Runnable() { + @Override + public void run() { + final int[] offset = new int[2]; + + targetBounds.set(target.bounds()); + if (target.drawOval) { + float targetSize = Math.max( + targetWidth(), targetHeight() + ) + 2 * TARGET_PADDING; + TARGET_RADIUS = (int) targetSize / 5; + } + + getLocationOnScreen(offset); + targetBounds.offset(-offset[0], -offset[1]); + + if (boundingParent != null) { + final WindowManager windowManager + = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE); + final DisplayMetrics displayMetrics = new DisplayMetrics(); + windowManager.getDefaultDisplay().getMetrics(displayMetrics); + + final Rect rect = new Rect(); + boundingParent.getWindowVisibleDisplayFrame(rect); + int[] parentLocation = new int[2]; + boundingParent.getLocationInWindow(parentLocation); + + if (translucentStatusBar) { + rect.top = parentLocation[1]; + } + if (translucentNavigationBar) { + rect.bottom = parentLocation[1] + boundingParent.getHeight(); + } + + // We bound the boundaries to be within the screen's coordinates to + // handle the case where the flag FLAG_LAYOUT_NO_LIMITS is set + if (layoutNoLimits) { + topBoundary = Math.max(0, rect.top); + bottomBoundary = Math.min(rect.bottom, displayMetrics.heightPixels); + } else { + topBoundary = rect.top; + bottomBoundary = rect.bottom; + } + } + + drawTintedTarget(); + requestFocus(); + calculateDimensions(); + + startExpandAnimation(); + } + }); + } + }; + + getViewTreeObserver().addOnGlobalLayoutListener(globalLayoutListener); + + setFocusableInTouchMode(true); + setClickable(true); + setOnClickListener(new OnClickListener() { + @Override + public void onClick(View v) { + if (listener == null || outerCircleCenter == null || !isInteractable) return; + + boolean clickedInTarget; + + if (target.drawOval) { + clickedInTarget = inOval( + (int) lastTouchX, + (int) lastTouchY, + targetBounds.centerX(), + targetBounds.centerY(), + (int) (targetOvalRect.bottom - targetOvalRect.top), + (int) (targetOvalRect.right - targetOvalRect.left) + ); + } else { + clickedInTarget = distance(targetBounds.centerX(), + targetBounds.centerY(), + (int) lastTouchX, + (int) lastTouchY + ) <= targetCircleRadius; + } + + final double distanceToOuterCircleCenter = distance(outerCircleCenter[0], outerCircleCenter[1], + (int) lastTouchX, (int) lastTouchY); + final boolean clickedInsideOfOuterCircle = distanceToOuterCircleCenter <= outerCircleRadius; + + if (clickedInTarget) { + isInteractable = false; + listener.onTargetClick(TapTargetView.this); + } else if (clickedInsideOfOuterCircle) { + listener.onOuterCircleClick(TapTargetView.this); + } else if (cancelable) { + isInteractable = false; + listener.onTargetCancel(TapTargetView.this); + } + } + }); - setOutlineProvider(outlineProvider); - setElevation(SHADOW_DIM); - } + setOnLongClickListener(new OnLongClickListener() { + @Override + public boolean onLongClick(View v) { + if (listener == null) return false; + + if (targetBounds.contains((int) lastTouchX, (int) lastTouchY)) { + listener.onTargetLongClick(TapTargetView.this); + return true; + } - if (shouldDrawShadow && outlineProvider == null && Build.VERSION.SDK_INT < 18) { - setLayerType(LAYER_TYPE_SOFTWARE, null); - } else { - setLayerType(LAYER_TYPE_HARDWARE, null); + return false; + } + }); } - final Resources.Theme theme = context.getTheme(); - isDark = UiUtil.themeIntAttr(context, "isLightTheme") == 0; + private Boolean inOval(int x, int y, int h, int k, int b, int a) { - final Integer outerCircleColor = target.outerCircleColorInt(context); - if (outerCircleColor != null) { - outerCirclePaint.setColor(outerCircleColor); - } else if (theme != null) { - outerCirclePaint.setColor(UiUtil.themeIntAttr(context, "colorPrimary")); - } else { - outerCirclePaint.setColor(Color.WHITE); - } + float checkpoint = checkpoint(h, k, x, y, a / 2f, b / 2f); - final Integer targetCircleColor = target.targetCircleColorInt(context); - if (targetCircleColor != null) { - targetCirclePaint.setColor(targetCircleColor); - } else { - targetCirclePaint.setColor(isDark ? Color.BLACK : Color.WHITE); - } + if (checkpoint > 1) + return false; + else if (checkpoint == 1) + return true; + else + return true; - if (target.transparentTarget) { - targetCirclePaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.CLEAR)); } - targetCirclePulsePaint.setColor(targetCirclePaint.getColor()); + private float checkpoint(float h, float k, float x, float y, float a, float b) { - final Integer targetDimColor = target.dimColorInt(context); - if (targetDimColor != null) { - dimColor = UiUtil.setAlpha(targetDimColor, 0.3f); - } else { - dimColor = -1; - } + // checking the equation of + // ellipse with the given point + double p = (Math.pow((x - h), 2.0) / Math.pow(a, 2.0)) + + (Math.pow((y - k), 2.0) / Math.pow(b, 2.0)); - final Integer titleTextColor = target.titleTextColorInt(context); - if (titleTextColor != null) { - titlePaint.setColor(titleTextColor); - } else { - titlePaint.setColor(isDark ? Color.BLACK : Color.WHITE); - } + return (float) p; - final Integer descriptionTextColor = target.descriptionTextColorInt(context); - if (descriptionTextColor != null) { - descriptionPaint.setColor(descriptionTextColor); - } else { - descriptionPaint.setColor(titlePaint.getColor()); } - if (target.titleTypeface != null) { - titlePaint.setTypeface(target.titleTypeface); + private void startExpandAnimation() { + if (!visible) { + isInteractable = false; + expandAnimation.start(); + visible = true; + } } - if (target.descriptionTypeface != null) { - descriptionPaint.setTypeface(target.descriptionTypeface); - } - } + protected void applyTargetOptions(Context context) { + shouldTintTarget = !target.transparentTarget && target.tintTarget; + shouldDrawShadow = target.drawShadow; + cancelable = target.cancelable; + + // We can't clip out portions of a view outline, so if the user specified a transparent + // target, we need to fallback to drawing a jittered shadow approximation + if (shouldDrawShadow && Build.VERSION.SDK_INT >= 21 && !target.transparentTarget) { + outlineProvider = new ViewOutlineProvider() { + @TargetApi(Build.VERSION_CODES.LOLLIPOP) + @Override + public void getOutline(View view, Outline outline) { + if (outerCircleCenter == null) return; + outline.setOval( + (int) (outerCircleCenter[0] - outerCircleRadius), (int) (outerCircleCenter[1] - outerCircleRadius), + (int) (outerCircleCenter[0] + outerCircleRadius), (int) (outerCircleCenter[1] + outerCircleRadius)); + outline.setAlpha(outerCircleAlpha / 255.0f); + if (Build.VERSION.SDK_INT >= 22) { + outline.offset(0, SHADOW_DIM); + } + } + }; + + setOutlineProvider(outlineProvider); + setElevation(SHADOW_DIM); + } - @Override - protected void onDetachedFromWindow() { - super.onDetachedFromWindow(); - onDismiss(false); - } + if (shouldDrawShadow && outlineProvider == null && Build.VERSION.SDK_INT < 18) { + setLayerType(LAYER_TYPE_SOFTWARE, null); + } else { + setLayerType(LAYER_TYPE_HARDWARE, null); + } - void onDismiss(boolean userInitiated) { - if (isDismissed) return; + final Resources.Theme theme = context.getTheme(); + isDark = UiUtil.themeIntAttr(context, "isLightTheme") == 0; - isDismissing = false; - isDismissed = true; + final Integer outerCircleColor = target.outerCircleColorInt(context); + if (outerCircleColor != null) { + outerCirclePaint.setColor(outerCircleColor); + } else if (theme != null) { + outerCirclePaint.setColor(UiUtil.themeIntAttr(context, "colorPrimary")); + } else { + outerCirclePaint.setColor(Color.WHITE); + } - for (final ValueAnimator animator : animators) { - animator.cancel(); - animator.removeAllUpdateListeners(); - } + final Integer targetCircleColor = target.targetCircleColorInt(context); + if (targetCircleColor != null) { + targetCirclePaint.setColor(targetCircleColor); + } else { + targetCirclePaint.setColor(isDark ? Color.BLACK : Color.WHITE); + } - ViewUtil.removeOnGlobalLayoutListener(getViewTreeObserver(), globalLayoutListener); - visible = false; + if (target.transparentTarget) { + targetCirclePaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.CLEAR)); + } - if (listener != null) { - listener.onTargetDismissed(this, userInitiated); - } - } + targetCirclePulsePaint.setColor(targetCirclePaint.getColor()); - @Override - protected void onDraw(Canvas c) { - if (isDismissed || outerCircleCenter == null) return; + final Integer targetDimColor = target.dimColorInt(context); + if (targetDimColor != null) { + dimColor = UiUtil.setAlpha(targetDimColor, 0.3f); + } else { + dimColor = -1; + } - if (topBoundary > 0 && bottomBoundary > 0) { - c.clipRect(0, topBoundary, getWidth(), bottomBoundary); - } + final Integer titleTextColor = target.titleTextColorInt(context); + if (titleTextColor != null) { + titlePaint.setColor(titleTextColor); + } else { + titlePaint.setColor(isDark ? Color.BLACK : Color.WHITE); + } - if (dimColor != -1) { - c.drawColor(dimColor); - } + final Integer descriptionTextColor = target.descriptionTextColorInt(context); + if (descriptionTextColor != null) { + descriptionPaint.setColor(descriptionTextColor); + } else { + descriptionPaint.setColor(titlePaint.getColor()); + } - int saveCount; - outerCirclePaint.setAlpha(outerCircleAlpha); - if (shouldDrawShadow && outlineProvider == null) { - saveCount = c.save(); - { - c.clipPath(outerCirclePath, Region.Op.DIFFERENCE); - drawJitteredShadow(c); - } - c.restoreToCount(saveCount); - } - c.drawCircle(outerCircleCenter[0], outerCircleCenter[1], outerCircleRadius, outerCirclePaint); + if (target.titleTypeface != null) { + titlePaint.setTypeface(target.titleTypeface); + } - targetCirclePaint.setAlpha(targetCircleAlpha); - if (targetCirclePulseAlpha > 0) { - targetCirclePulsePaint.setAlpha(targetCirclePulseAlpha); - c.drawCircle(targetBounds.centerX(), targetBounds.centerY(), - targetCirclePulseRadius, targetCirclePulsePaint); - } - c.drawCircle(targetBounds.centerX(), targetBounds.centerY(), - targetCircleRadius, targetCirclePaint); - - saveCount = c.save(); - { - c.translate(textBounds.left, textBounds.top); - titlePaint.setAlpha(textAlpha); - if (titleLayout != null) { - titleLayout.draw(c); - } - - if (descriptionLayout != null && titleLayout != null) { - c.translate(0, titleLayout.getHeight() + TEXT_SPACING); - descriptionPaint.setAlpha((int) (target.descriptionTextAlpha * textAlpha)); - descriptionLayout.draw(c); - } - } - c.restoreToCount(saveCount); - - saveCount = c.save(); - { - if (tintedTarget != null) { - c.translate(targetBounds.centerX() - tintedTarget.getWidth() / 2, - targetBounds.centerY() - tintedTarget.getHeight() / 2); - c.drawBitmap(tintedTarget, 0, 0, targetCirclePaint); - } else if (target.icon != null) { - c.translate(targetBounds.centerX() - target.icon.getBounds().width() / 2, - targetBounds.centerY() - target.icon.getBounds().height() / 2); - target.icon.setAlpha(targetCirclePaint.getAlpha()); - target.icon.draw(c); - } + if (target.descriptionTypeface != null) { + descriptionPaint.setTypeface(target.descriptionTypeface); + } } - c.restoreToCount(saveCount); - if (debug) { - drawDebugInformation(c); + @Override + protected void onDetachedFromWindow() { + super.onDetachedFromWindow(); + onDismiss(false); } - } - - @Override - public boolean onTouchEvent(MotionEvent e) { - lastTouchX = e.getX(); - lastTouchY = e.getY(); - return super.onTouchEvent(e); - } - - @Override - public boolean onKeyDown(int keyCode, KeyEvent event) { - if (isVisible() && cancelable && keyCode == KeyEvent.KEYCODE_BACK) { - event.startTracking(); - return true; + + void onDismiss(boolean userInitiated) { + if (isDismissed) return; + + isDismissing = false; + isDismissed = true; + + for (final ValueAnimator animator : animators) { + animator.cancel(); + animator.removeAllUpdateListeners(); + } + + ViewUtil.removeOnGlobalLayoutListener(getViewTreeObserver(), globalLayoutListener); + visible = false; + + if (listener != null) { + listener.onTargetDismissed(this, userInitiated); + } } - return false; - } + @Override + protected void onDraw(Canvas c) { + if (isDismissed || outerCircleCenter == null) return; + + if (topBoundary > 0 && bottomBoundary > 0) { + c.clipRect(0, topBoundary, getWidth(), bottomBoundary); + } + + if (dimColor != -1) { + c.drawColor(dimColor); + } + + int saveCount; + outerCirclePaint.setAlpha(outerCircleAlpha); + if (shouldDrawShadow && outlineProvider == null) { + saveCount = c.save(); + { + c.clipPath(outerCirclePath, Region.Op.DIFFERENCE); + drawJitteredShadow(c); + } + c.restoreToCount(saveCount); + } + c.drawCircle(outerCircleCenter[0], outerCircleCenter[1], outerCircleRadius, outerCirclePaint); + + targetCirclePaint.setAlpha(targetCircleAlpha); + if (targetCirclePulseAlpha > 0 && !target.drawOval) { + targetCirclePulsePaint.setAlpha(targetCirclePulseAlpha); + c.drawCircle(targetBounds.centerX(), targetBounds.centerY(), + targetCirclePulseRadius, targetCirclePulsePaint); + } else if (targetCirclePulseAlpha > 0 && target.drawOval) { + targetCirclePulsePaint.setAlpha(targetCirclePulseAlpha); + targetCirclePulsePaint.setStyle(Paint.Style.FILL_AND_STROKE); + targetOvalPulseRect.set( + targetBounds.left - TARGET_PADDING - (TARGET_PADDING * targetOvalPulseScaleFactor * 2), + targetBounds.top - TARGET_PADDING - (TARGET_PADDING * targetOvalPulseScaleFactor * 2), + targetBounds.right + TARGET_PADDING + (TARGET_PADDING * targetOvalPulseScaleFactor * 2), + targetBounds.bottom + TARGET_PADDING + (TARGET_PADDING * targetOvalPulseScaleFactor * 2) + ); + + c.drawOval(targetOvalPulseRect, targetCirclePulsePaint); + } + + if (!target.drawOval) { + c.drawCircle(targetBounds.centerX(), targetBounds.centerY(), + targetCircleRadius, targetCirclePaint); + } else { + targetCirclePaint.setStyle(Paint.Style.FILL_AND_STROKE); + targetOvalRect.set( + targetBounds.left - TARGET_PADDING - (TARGET_PADDING * targetOvalScaleFactor), + targetBounds.top - TARGET_PADDING - (TARGET_PADDING * targetOvalScaleFactor), + targetBounds.right + TARGET_PADDING + (TARGET_PADDING * targetOvalScaleFactor), + targetBounds.bottom + TARGET_PADDING + (TARGET_PADDING * targetOvalScaleFactor) + ); + + c.drawOval(targetOvalRect, targetCirclePaint); + } - @Override - public boolean onKeyUp(int keyCode, KeyEvent event) { - if (isVisible() && isInteractable && cancelable - && keyCode == KeyEvent.KEYCODE_BACK && event.isTracking() && !event.isCanceled()) { - isInteractable = false; + saveCount = c.save(); + { + c.translate(textBounds.left, textBounds.top); + titlePaint.setAlpha(textAlpha); + if (titleLayout != null) { + titleLayout.draw(c); + } - if (listener != null) { - listener.onTargetCancel(this); - } else { - new Listener().onTargetCancel(this); - } + if (descriptionLayout != null && titleLayout != null) { + c.translate(0, titleLayout.getHeight() + TEXT_SPACING); + descriptionPaint.setAlpha((int) (target.descriptionTextAlpha * textAlpha)); + descriptionLayout.draw(c); + } + } + c.restoreToCount(saveCount); + + saveCount = c.save(); + { + if (tintedTarget != null) { + c.translate(targetBounds.centerX() - tintedTarget.getWidth() / 2, + targetBounds.centerY() - tintedTarget.getHeight() / 2); + c.drawBitmap(tintedTarget, 0, 0, targetCirclePaint); + } else if (target.icon != null) { + c.translate(targetBounds.centerX() - target.icon.getBounds().width() / 2, + targetBounds.centerY() - target.icon.getBounds().height() / 2); + target.icon.setAlpha(targetCirclePaint.getAlpha()); + target.icon.draw(c); + } + } + c.restoreToCount(saveCount); - return true; + if (debug) { + drawDebugInformation(c); + } } - return false; - } - - /** - * Dismiss this view - * @param tappedTarget If the user tapped the target or not - * (results in different dismiss animations) - */ - public void dismiss(boolean tappedTarget) { - isDismissing = true; - pulseAnimation.cancel(); - expandAnimation.cancel(); - if (!visible || outerCircleCenter == null) { - finishDismiss(tappedTarget); - return; + private float targetWidth() { + return targetBounds.right - targetBounds.left; } - if (tappedTarget) { - dismissConfirmAnimation.start(); - } else { - dismissAnimation.start(); + + private float targetHeight() { + return targetBounds.bottom - targetBounds.top; } - } - - private void finishDismiss(boolean userInitiated) { - onDismiss(userInitiated); - ViewUtil.removeView(parent, TapTargetView.this); - } - - /** Specify whether to draw a wireframe around the view, useful for debugging **/ - public void setDrawDebug(boolean status) { - if (debug != status) { - debug = status; - postInvalidate(); + + @Override + public boolean onTouchEvent(MotionEvent e) { + lastTouchX = e.getX(); + lastTouchY = e.getY(); + return super.onTouchEvent(e); } - } - - /** Returns whether this view is visible or not **/ - public boolean isVisible() { - return !isDismissed && visible; - } - - void drawJitteredShadow(Canvas c) { - final float baseAlpha = 0.20f * outerCircleAlpha; - outerCircleShadowPaint.setStyle(Paint.Style.FILL_AND_STROKE); - outerCircleShadowPaint.setAlpha((int) baseAlpha); - c.drawCircle(outerCircleCenter[0], outerCircleCenter[1] + SHADOW_DIM, outerCircleRadius, outerCircleShadowPaint); - outerCircleShadowPaint.setStyle(Paint.Style.STROKE); - final int numJitters = 7; - for (int i = numJitters - 1; i > 0; --i) { - outerCircleShadowPaint.setAlpha((int) ((i / (float) numJitters) * baseAlpha)); - c.drawCircle(outerCircleCenter[0], outerCircleCenter[1] + SHADOW_DIM , - outerCircleRadius + (numJitters - i) * SHADOW_JITTER_DIM , outerCircleShadowPaint); + + @Override + public boolean onKeyDown(int keyCode, KeyEvent event) { + if (isVisible() && cancelable && keyCode == KeyEvent.KEYCODE_BACK) { + event.startTracking(); + return true; + } + + return false; } - } - - void drawDebugInformation(Canvas c) { - if (debugPaint == null) { - debugPaint = new Paint(); - debugPaint.setARGB(255, 255, 0, 0); - debugPaint.setStyle(Paint.Style.STROKE); - debugPaint.setStrokeWidth(UiUtil.dp(getContext(), 1)); + + @Override + public boolean onKeyUp(int keyCode, KeyEvent event) { + if (isVisible() && isInteractable && cancelable + && keyCode == KeyEvent.KEYCODE_BACK && event.isTracking() && !event.isCanceled()) { + isInteractable = false; + + if (listener != null) { + listener.onTargetCancel(this); + } else { + new Listener().onTargetCancel(this); + } + + return true; + } + + return false; } - if (debugTextPaint == null) { - debugTextPaint = new TextPaint(); - debugTextPaint.setColor(0xFFFF0000); - debugTextPaint.setTextSize(UiUtil.sp(getContext(), 16)); + /** + * Dismiss this view + * + * @param tappedTarget If the user tapped the target or not + * (results in different dismiss animations) + */ + public void dismiss(boolean tappedTarget) { + isDismissing = true; + pulseAnimation.cancel(); + expandAnimation.cancel(); + if (!visible || outerCircleCenter == null) { + finishDismiss(tappedTarget); + return; + } + if (tappedTarget) { + dismissConfirmAnimation.start(); + } else { + dismissAnimation.start(); + } } - // Draw wireframe - debugPaint.setStyle(Paint.Style.STROKE); - c.drawRect(textBounds, debugPaint); - c.drawRect(targetBounds, debugPaint); - c.drawCircle(outerCircleCenter[0], outerCircleCenter[1], 10, debugPaint); - c.drawCircle(outerCircleCenter[0], outerCircleCenter[1], calculatedOuterCircleRadius - CIRCLE_PADDING, debugPaint); - c.drawCircle(targetBounds.centerX(), targetBounds.centerY(), TARGET_RADIUS + TARGET_PADDING, debugPaint); - - // Draw positions and dimensions - debugPaint.setStyle(Paint.Style.FILL); - final String debugText = - "Text bounds: " + textBounds.toShortString() + "\n" + - "Target bounds: " + targetBounds.toShortString() + "\n" + - "Center: " + outerCircleCenter[0] + " " + outerCircleCenter[1] + "\n" + - "View size: " + getWidth() + " " + getHeight() + "\n" + - "Target bounds: " + targetBounds.toShortString(); - - if (debugStringBuilder == null) { - debugStringBuilder = new SpannableStringBuilder(debugText); - } else { - debugStringBuilder.clear(); - debugStringBuilder.append(debugText); + private void finishDismiss(boolean userInitiated) { + onDismiss(userInitiated); + ViewUtil.removeView(parent, TapTargetView.this); } - if (debugLayout == null) { - debugLayout = new DynamicLayout(debugText, debugTextPaint, getWidth(), Layout.Alignment.ALIGN_NORMAL, 1.0f, 0.0f, false); + /** + * Specify whether to draw a wireframe around the view, useful for debugging + **/ + public void setDrawDebug(boolean status) { + if (debug != status) { + debug = status; + postInvalidate(); + } } - final int saveCount = c.save(); - { - debugPaint.setARGB(220, 0, 0, 0); - c.translate(0.0f, topBoundary); - c.drawRect(0.0f, 0.0f, debugLayout.getWidth(), debugLayout.getHeight(), debugPaint); - debugPaint.setARGB(255, 255, 0, 0); - debugLayout.draw(c); + /** + * Returns whether this view is visible or not + **/ + public boolean isVisible() { + return !isDismissed && visible; } - c.restoreToCount(saveCount); - } - - void drawTintedTarget() { - final Drawable icon = target.icon; - if (!shouldTintTarget || icon == null) { - tintedTarget = null; - return; + + void drawJitteredShadow(Canvas c) { + final float baseAlpha = 0.20f * outerCircleAlpha; + outerCircleShadowPaint.setStyle(Paint.Style.FILL_AND_STROKE); + outerCircleShadowPaint.setAlpha((int) baseAlpha); + c.drawCircle(outerCircleCenter[0], outerCircleCenter[1] + SHADOW_DIM, outerCircleRadius, outerCircleShadowPaint); + outerCircleShadowPaint.setStyle(Paint.Style.STROKE); + final int numJitters = 7; + for (int i = numJitters - 1; i > 0; --i) { + outerCircleShadowPaint.setAlpha((int) ((i / (float) numJitters) * baseAlpha)); + c.drawCircle(outerCircleCenter[0], outerCircleCenter[1] + SHADOW_DIM, + outerCircleRadius + (numJitters - i) * SHADOW_JITTER_DIM, outerCircleShadowPaint); + } } - if (tintedTarget != null) return; - - tintedTarget = Bitmap.createBitmap(icon.getIntrinsicWidth(), icon.getIntrinsicHeight(), - Bitmap.Config.ARGB_8888); - final Canvas canvas = new Canvas(tintedTarget); - icon.setColorFilter(new PorterDuffColorFilter( - outerCirclePaint.getColor(), PorterDuff.Mode.SRC_ATOP)); - icon.draw(canvas); - icon.setColorFilter(null); - } - - void updateTextLayouts() { - final int textWidth = Math.min(getWidth(), TEXT_MAX_WIDTH) - TEXT_PADDING * 2; - if (textWidth <= 0) { - return; + void drawDebugInformation(Canvas c) { + if (debugPaint == null) { + debugPaint = new Paint(); + debugPaint.setARGB(255, 255, 0, 0); + debugPaint.setStyle(Paint.Style.STROKE); + debugPaint.setStrokeWidth(UiUtil.dp(getContext(), 1)); + } + + if (debugTextPaint == null) { + debugTextPaint = new TextPaint(); + debugTextPaint.setColor(0xFFFF0000); + debugTextPaint.setTextSize(UiUtil.sp(getContext(), 16)); + } + + // Draw wireframe + debugPaint.setStyle(Paint.Style.STROKE); + c.drawRect(textBounds, debugPaint); + c.drawRect(targetBounds, debugPaint); + c.drawCircle(outerCircleCenter[0], outerCircleCenter[1], 10, debugPaint); + c.drawCircle(outerCircleCenter[0], outerCircleCenter[1], calculatedOuterCircleRadius - CIRCLE_PADDING, debugPaint); + c.drawCircle(targetBounds.centerX(), targetBounds.centerY(), TARGET_RADIUS + TARGET_PADDING, debugPaint); + + // Draw positions and dimensions + debugPaint.setStyle(Paint.Style.FILL); + final String debugText = + "Text bounds: " + textBounds.toShortString() + "\n" + + "Target bounds: " + targetBounds.toShortString() + "\n" + + "Center: " + outerCircleCenter[0] + " " + outerCircleCenter[1] + "\n" + + "View size: " + getWidth() + " " + getHeight() + "\n" + + "Target bounds: " + targetBounds.toShortString(); + + if (debugStringBuilder == null) { + debugStringBuilder = new SpannableStringBuilder(debugText); + } else { + debugStringBuilder.clear(); + debugStringBuilder.append(debugText); + } + + if (debugLayout == null) { + debugLayout = new DynamicLayout(debugText, debugTextPaint, getWidth(), Layout.Alignment.ALIGN_NORMAL, 1.0f, 0.0f, false); + } + + final int saveCount = c.save(); + { + debugPaint.setARGB(220, 0, 0, 0); + c.translate(0.0f, topBoundary); + c.drawRect(0.0f, 0.0f, debugLayout.getWidth(), debugLayout.getHeight(), debugPaint); + debugPaint.setARGB(255, 255, 0, 0); + debugLayout.draw(c); + } + c.restoreToCount(saveCount); } - titleLayout = new StaticLayout(title, titlePaint, textWidth, - Layout.Alignment.ALIGN_NORMAL, 1.0f, 0.0f, false); + void drawTintedTarget() { + final Drawable icon = target.icon; + if (!shouldTintTarget || icon == null) { + tintedTarget = null; + return; + } + + if (tintedTarget != null) return; + + tintedTarget = Bitmap.createBitmap(icon.getIntrinsicWidth(), icon.getIntrinsicHeight(), + Bitmap.Config.ARGB_8888); + final Canvas canvas = new Canvas(tintedTarget); + + Integer targetTintColor = target.targetTintColorInt(getContext()); + if (targetTintColor == null) + targetTintColor = outerCirclePaint.getColor(); - if (description != null) { - descriptionLayout = new StaticLayout(description, descriptionPaint, textWidth, - Layout.Alignment.ALIGN_NORMAL, 1.0f, 0.0f, false); - } else { - descriptionLayout = null; + icon.setColorFilter(new PorterDuffColorFilter( + targetTintColor, PorterDuff.Mode.SRC_ATOP)); + icon.draw(canvas); + icon.setColorFilter(null); } - } - float halfwayLerp(float lerp) { - if (lerp < 0.5f) { - return lerp / 0.5f; + void updateTextLayouts() { + final int textWidth = Math.min(getWidth(), TEXT_MAX_WIDTH) - TEXT_PADDING * 2; + if (textWidth <= 0) { + return; + } + + titleLayout = new StaticLayout(title, titlePaint, textWidth, + Layout.Alignment.ALIGN_NORMAL, 1.0f, 0.0f, false); + + if (description != null) { + descriptionLayout = new StaticLayout(description, descriptionPaint, textWidth, + Layout.Alignment.ALIGN_NORMAL, 1.0f, 0.0f, false); + } else { + descriptionLayout = null; + } } - return (1.0f - lerp) / 0.5f; - } + float halfwayLerp(float lerp) { + if (lerp < 0.5f) { + return lerp / 0.5f; + } - float delayedLerp(float lerp, float threshold) { - if (lerp < threshold) { - return 0.0f; + return (1.0f - lerp) / 0.5f; } - return (lerp - threshold) / (1.0f - threshold); - } + float delayedLerp(float lerp, float threshold) { + if (lerp < threshold) { + return 0.0f; + } - void calculateDimensions() { - textBounds = getTextBounds(); - outerCircleCenter = getOuterCircleCenterPoint(); - calculatedOuterCircleRadius = getOuterCircleRadius(outerCircleCenter[0], outerCircleCenter[1], textBounds, targetBounds); - } + return (lerp - threshold) / (1.0f - threshold); + } - void calculateDrawingBounds() { - if (outerCircleCenter == null) { - // Called dismiss before we got a chance to display the tap target - // So we have no center -> cant determine the drawing bounds - return; + void calculateDimensions() { + textBounds = getTextBounds(); + outerCircleCenter = getOuterCircleCenterPoint(); + calculatedOuterCircleRadius = getOuterCircleRadius(outerCircleCenter[0], outerCircleCenter[1], textBounds, targetBounds); } - drawingBounds.left = (int) Math.max(0, outerCircleCenter[0] - outerCircleRadius); - drawingBounds.top = (int) Math.min(0, outerCircleCenter[1] - outerCircleRadius); - drawingBounds.right = (int) Math.min(getWidth(), - outerCircleCenter[0] + outerCircleRadius + CIRCLE_PADDING); - drawingBounds.bottom = (int) Math.min(getHeight(), - outerCircleCenter[1] + outerCircleRadius + CIRCLE_PADDING); - } - - int getOuterCircleRadius(int centerX, int centerY, Rect textBounds, Rect targetBounds) { - final int targetCenterX = targetBounds.centerX(); - final int targetCenterY = targetBounds.centerY(); - final int expandedRadius = (int) (1.1f * TARGET_RADIUS); - final Rect expandedBounds = new Rect(targetCenterX, targetCenterY, targetCenterX, targetCenterY); - expandedBounds.inset(-expandedRadius, -expandedRadius); - - final int textRadius = maxDistanceToPoints(centerX, centerY, textBounds); - final int targetRadius = maxDistanceToPoints(centerX, centerY, expandedBounds); - return Math.max(textRadius, targetRadius) + CIRCLE_PADDING; - } - - Rect getTextBounds() { - final int totalTextHeight = getTotalTextHeight(); - final int totalTextWidth = getTotalTextWidth(); - - final int possibleTop = targetBounds.centerY() - TARGET_RADIUS - TARGET_PADDING - totalTextHeight; - final int top; - if (possibleTop > topBoundary) { - top = possibleTop; - } else { - top = targetBounds.centerY() + TARGET_RADIUS + TARGET_PADDING; + + void calculateDrawingBounds() { + if (outerCircleCenter == null) { + // Called dismiss before we got a chance to display the tap target + // So we have no center -> cant determine the drawing bounds + return; + } + drawingBounds.left = (int) Math.max(0, outerCircleCenter[0] - outerCircleRadius); + drawingBounds.top = (int) Math.min(0, outerCircleCenter[1] - outerCircleRadius); + drawingBounds.right = (int) Math.min(getWidth(), + outerCircleCenter[0] + outerCircleRadius + CIRCLE_PADDING); + drawingBounds.bottom = (int) Math.min(getHeight(), + outerCircleCenter[1] + outerCircleRadius + CIRCLE_PADDING); + } + + int getOuterCircleRadius(int centerX, int centerY, Rect textBounds, Rect targetBounds) { + final int targetCenterX = targetBounds.centerX(); + final int targetCenterY = targetBounds.centerY(); + final int expandedRadius = (int) (1.1f * TARGET_RADIUS); + final Rect expandedBounds = new Rect(targetCenterX, targetCenterY, targetCenterX, targetCenterY); + expandedBounds.inset(-expandedRadius, -expandedRadius); + + final int textRadius = maxDistanceToPoints(centerX, centerY, textBounds); + final int targetRadius = maxDistanceToPoints(centerX, centerY, expandedBounds); + return Math.max(textRadius, targetRadius) + CIRCLE_PADDING; } - final int relativeCenterDistance = (getWidth() / 2) - targetBounds.centerX(); - final int bias = relativeCenterDistance < 0 ? -TEXT_POSITIONING_BIAS : TEXT_POSITIONING_BIAS; - final int left = Math.max(TEXT_PADDING, targetBounds.centerX() - bias - totalTextWidth); - final int right = Math.min(getWidth() - TEXT_PADDING, left + totalTextWidth); - return new Rect(left, top, right, top + totalTextHeight); - } + Rect getTextBounds() { + final int totalTextHeight = getTotalTextHeight(); + final int totalTextWidth = getTotalTextWidth(); - int[] getOuterCircleCenterPoint() { - if (inGutter(targetBounds.centerY())) { - return new int[]{targetBounds.centerX(), targetBounds.centerY()}; + final int possibleTop = targetBounds.centerY() - TARGET_RADIUS - TARGET_PADDING - totalTextHeight; + final int top; + if (possibleTop > topBoundary) { + top = possibleTop; + } else { + top = targetBounds.centerY() + TARGET_RADIUS + TARGET_PADDING; + } + + final int relativeCenterDistance = (getWidth() / 2) - targetBounds.centerX(); + final int bias = relativeCenterDistance < 0 ? -TEXT_POSITIONING_BIAS : TEXT_POSITIONING_BIAS; + final int left = Math.max(TEXT_PADDING, targetBounds.centerX() - bias - totalTextWidth); + final int right = Math.min(getWidth() - TEXT_PADDING, left + totalTextWidth); + return new Rect(left, top, right, top + totalTextHeight); } - final int targetRadius = Math.max(targetBounds.width(), targetBounds.height()) / 2 + TARGET_PADDING; - final int totalTextHeight = getTotalTextHeight(); + int[] getOuterCircleCenterPoint() { + if (inGutter(targetBounds.centerY())) { + return new int[]{targetBounds.centerX(), targetBounds.centerY()}; + } - final boolean onTop = targetBounds.centerY() - TARGET_RADIUS - TARGET_PADDING - totalTextHeight > 0; + final int targetRadius = Math.max(targetBounds.width(), targetBounds.height()) / 2 + TARGET_PADDING; + final int totalTextHeight = getTotalTextHeight(); - final int left = Math.min(textBounds.left, targetBounds.left - targetRadius); - final int right = Math.max(textBounds.right, targetBounds.right + targetRadius); - final int titleHeight = titleLayout == null ? 0 : titleLayout.getHeight(); - final int centerY = onTop ? - targetBounds.centerY() - TARGET_RADIUS - TARGET_PADDING - totalTextHeight + titleHeight - : - targetBounds.centerY() + TARGET_RADIUS + TARGET_PADDING + titleHeight; + final boolean onTop = targetBounds.centerY() - TARGET_RADIUS - TARGET_PADDING - totalTextHeight > 0; - return new int[] { (left + right) / 2, centerY }; - } + final int left = Math.min(textBounds.left, targetBounds.left - targetRadius); + final int right = Math.max(textBounds.right, targetBounds.right + targetRadius); + final int titleHeight = titleLayout == null ? 0 : titleLayout.getHeight(); + final int centerY = onTop ? + targetBounds.centerY() - TARGET_RADIUS - TARGET_PADDING - totalTextHeight + titleHeight + : + targetBounds.centerY() + TARGET_RADIUS + TARGET_PADDING + titleHeight; - int getTotalTextHeight() { - if (titleLayout == null) { - return 0; + return new int[]{(left + right) / 2, centerY}; } - if (descriptionLayout == null) { - return titleLayout.getHeight() + TEXT_SPACING; + int getTotalTextHeight() { + if (titleLayout == null) { + return 0; + } + + if (descriptionLayout == null) { + return titleLayout.getHeight() + TEXT_SPACING; + } + + return titleLayout.getHeight() + descriptionLayout.getHeight() + TEXT_SPACING; } - return titleLayout.getHeight() + descriptionLayout.getHeight() + TEXT_SPACING; - } + int getTotalTextWidth() { + if (titleLayout == null) { + return 0; + } + + if (descriptionLayout == null) { + return titleLayout.getWidth(); + } - int getTotalTextWidth() { - if (titleLayout == null) { - return 0; + return Math.max(titleLayout.getWidth(), descriptionLayout.getWidth()); } - if (descriptionLayout == null) { - return titleLayout.getWidth(); + boolean inGutter(int y) { + if (bottomBoundary > 0) { + return y < GUTTER_DIM || y > bottomBoundary - GUTTER_DIM; + } else { + return y < GUTTER_DIM || y > getHeight() - GUTTER_DIM; + } } - return Math.max(titleLayout.getWidth(), descriptionLayout.getWidth()); - } + int maxDistanceToPoints(int x1, int y1, Rect bounds) { + final double tl = distance(x1, y1, bounds.left, bounds.top); + final double tr = distance(x1, y1, bounds.right, bounds.top); + final double bl = distance(x1, y1, bounds.left, bounds.bottom); + final double br = distance(x1, y1, bounds.right, bounds.bottom); + return (int) Math.max(tl, Math.max(tr, Math.max(bl, br))); + } - boolean inGutter(int y) { - if (bottomBoundary > 0) { - return y < GUTTER_DIM || y > bottomBoundary - GUTTER_DIM; - } else { - return y < GUTTER_DIM || y > getHeight() - GUTTER_DIM; + double distance(int x1, int y1, int x2, int y2) { + return Math.sqrt(Math.pow(x2 - x1, 2) + Math.pow(y2 - y1, 2)); } - } - - int maxDistanceToPoints(int x1, int y1, Rect bounds) { - final double tl = distance(x1, y1, bounds.left, bounds.top); - final double tr = distance(x1, y1, bounds.right, bounds.top); - final double bl = distance(x1, y1, bounds.left, bounds.bottom); - final double br = distance(x1, y1, bounds.right, bounds.bottom); - return (int) Math.max(tl, Math.max(tr, Math.max(bl, br))); - } - - double distance(int x1, int y1, int x2, int y2) { - return Math.sqrt(Math.pow(x2 - x1, 2) + Math.pow(y2 - y1, 2)); - } - - void invalidateViewAndOutline(Rect bounds) { - invalidate(bounds); - if (outlineProvider != null && Build.VERSION.SDK_INT >= 21) { - invalidateOutline(); + + void invalidateViewAndOutline(Rect bounds) { + invalidate(bounds); + if (outlineProvider != null && Build.VERSION.SDK_INT >= 21) { + invalidateOutline(); + } } - } } diff --git a/taptargetview/src/main/java/com/getkeepsafe/taptargetview/ToolbarTapTarget.java b/taptargetview/src/main/java/com/getkeepsafe/taptargetview/ToolbarTapTarget.java index 266d7ac..e11e7f3 100644 --- a/taptargetview/src/main/java/com/getkeepsafe/taptargetview/ToolbarTapTarget.java +++ b/taptargetview/src/main/java/com/getkeepsafe/taptargetview/ToolbarTapTarget.java @@ -1,270 +1,271 @@ -/** - * Copyright 2016 Keepsafe Software, Inc. - *

- * 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 com.getkeepsafe.taptargetview; - -import android.annotation.TargetApi; -import android.graphics.drawable.Drawable; -import android.os.Build; -import androidx.annotation.IdRes; -import androidx.annotation.Nullable; -import androidx.appcompat.widget.Toolbar; -import android.text.TextUtils; -import android.view.View; -import android.view.ViewGroup; -import android.widget.ImageButton; -import android.widget.ImageView; - -import java.util.ArrayList; -import java.util.Stack; - -class ToolbarTapTarget extends ViewTapTarget { - ToolbarTapTarget(Toolbar toolbar, @IdRes int menuItemId, - CharSequence title, @Nullable CharSequence description) { - super(toolbar.findViewById(menuItemId), title, description); - } - - ToolbarTapTarget(android.widget.Toolbar toolbar, @IdRes int menuItemId, - CharSequence title, @Nullable CharSequence description) { - super(toolbar.findViewById(menuItemId), title, description); - } - - ToolbarTapTarget(Toolbar toolbar, boolean findNavView, - CharSequence title, @Nullable CharSequence description) { - super(findNavView ? findNavView(toolbar) : findOverflowView(toolbar), title, description); - } - - ToolbarTapTarget(android.widget.Toolbar toolbar, boolean findNavView, - CharSequence title, @Nullable CharSequence description) { - super(findNavView ? findNavView(toolbar) : findOverflowView(toolbar), title, description); - } - - private static ToolbarProxy proxyOf(Object instance) { - if (instance == null) { - throw new IllegalArgumentException("Given null instance"); - } - - if (instance instanceof Toolbar) { - return new SupportToolbarProxy((Toolbar) instance); - } else if (instance instanceof android.widget.Toolbar) { - return new StandardToolbarProxy((android.widget.Toolbar) instance); - } - - throw new IllegalStateException("Couldn't provide proper toolbar proxy instance"); - } - - private static View findNavView(Object instance) { - final ToolbarProxy toolbar = proxyOf(instance); - - // First we try to find the view via its content description - final CharSequence currentDescription = toolbar.getNavigationContentDescription(); - final boolean hadContentDescription = !TextUtils.isEmpty(currentDescription); - final CharSequence sentinel = hadContentDescription ? currentDescription : "taptarget-findme"; - toolbar.setNavigationContentDescription(sentinel); - - final ArrayList possibleViews = new ArrayList<>(1); - toolbar.findViewsWithText(possibleViews, sentinel, View.FIND_VIEWS_WITH_CONTENT_DESCRIPTION); - - if (!hadContentDescription) { - toolbar.setNavigationContentDescription(null); - } - - if (possibleViews.size() > 0) { - return possibleViews.get(0); - } - - // If that doesn't work, we try to grab it via matching its drawable - final Drawable navigationIcon = toolbar.getNavigationIcon(); - if (navigationIcon == null) { - throw new IllegalStateException("Toolbar does not have a navigation view set!"); - } - - final int size = toolbar.getChildCount(); - for (int i = 0; i < size; ++i) { - final View child = toolbar.getChildAt(i); - if (child instanceof ImageButton) { - final Drawable childDrawable = ((ImageButton) child).getDrawable(); - if (childDrawable == navigationIcon) { - return child; - } - } - } - - throw new IllegalStateException("Could not find navigation view for Toolbar!"); - } - - private static View findOverflowView(Object instance) { - final ToolbarProxy toolbar = proxyOf(instance); - - // First we try to find the overflow menu view via drawable matching - final Drawable overflowDrawable = toolbar.getOverflowIcon(); - if (overflowDrawable != null) { - final Stack parents = new Stack<>(); - parents.push((ViewGroup) toolbar.internalToolbar()); - while (!parents.empty()) { - ViewGroup parent = parents.pop(); - final int size = parent.getChildCount(); - for (int i = 0; i < size; ++i) { - final View child = parent.getChildAt(i); - if (child instanceof ViewGroup) { - parents.push((ViewGroup) child); - continue; - } - if (child instanceof ImageView) { - final Drawable childDrawable = ((ImageView) child).getDrawable(); - if (childDrawable == overflowDrawable) { - return child; - } - } - } - } - } - - // If that doesn't work, we fall-back to our last resort solution: Reflection - // Toolbars contain an "ActionMenuView" which in turn contains an "ActionMenuPresenter". - // The "ActionMenuPresenter" then holds a reference to an "OverflowMenuButton" which is the - // desired target - try { - final Object actionMenuView = ReflectUtil.getPrivateField(toolbar.internalToolbar(), "mMenuView"); - final Object actionMenuPresenter = ReflectUtil.getPrivateField(actionMenuView, "mPresenter"); - return (View) ReflectUtil.getPrivateField(actionMenuPresenter, "mOverflowButton"); - } catch (NoSuchFieldException e) { - throw new IllegalStateException("Could not find overflow view for Toolbar!", e); - } catch (IllegalAccessException e) { - throw new IllegalStateException("Unable to access overflow view for Toolbar!", e); - } - } - - private interface ToolbarProxy { - CharSequence getNavigationContentDescription(); - - void setNavigationContentDescription(CharSequence description); - - void findViewsWithText(ArrayList out, CharSequence toFind, int flags); - - Drawable getNavigationIcon(); - - @Nullable - Drawable getOverflowIcon(); - - int getChildCount(); - - View getChildAt(int position); - - Object internalToolbar(); - } - - private static class SupportToolbarProxy implements ToolbarProxy { - private final Toolbar toolbar; - - SupportToolbarProxy(Toolbar toolbar) { - this.toolbar = toolbar; - } - - @Override - public CharSequence getNavigationContentDescription() { - return toolbar.getNavigationContentDescription(); - } - - @Override - public void setNavigationContentDescription(CharSequence description) { - toolbar.setNavigationContentDescription(description); - } - - @Override - public void findViewsWithText(ArrayList out, CharSequence toFind, int flags) { - toolbar.findViewsWithText(out, toFind, flags); - } - - @Override - public Drawable getNavigationIcon() { - return toolbar.getNavigationIcon(); - } - - @Override - public Drawable getOverflowIcon() { - return toolbar.getOverflowIcon(); - } - - @Override - public int getChildCount() { - return toolbar.getChildCount(); - } - - @Override - public View getChildAt(int position) { - return toolbar.getChildAt(position); - } - - @Override - public Object internalToolbar() { - return toolbar; - } - } - - @TargetApi(Build.VERSION_CODES.LOLLIPOP) - private static class StandardToolbarProxy implements ToolbarProxy { - private final android.widget.Toolbar toolbar; - - StandardToolbarProxy(android.widget.Toolbar toolbar) { - this.toolbar = toolbar; - } - - @Override - public CharSequence getNavigationContentDescription() { - return toolbar.getNavigationContentDescription(); - } - - @Override - public void setNavigationContentDescription(CharSequence description) { - toolbar.setNavigationContentDescription(description); - } - - @Override - public void findViewsWithText(ArrayList out, CharSequence toFind, int flags) { - toolbar.findViewsWithText(out, toFind, flags); - } - - @Override - public Drawable getNavigationIcon() { - return toolbar.getNavigationIcon(); - } - - @Nullable - @Override - public Drawable getOverflowIcon() { - if (Build.VERSION.SDK_INT >= 23) { - return toolbar.getOverflowIcon(); - } - - return null; - } - - @Override - public int getChildCount() { - return toolbar.getChildCount(); - } - - @Override - public View getChildAt(int position) { - return toolbar.getChildAt(position); - } - - @Override - public Object internalToolbar() { - return toolbar; - } - } -} +/** + * Copyright 2016 Keepsafe Software, Inc. + *

+ * 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 com.getkeepsafe.taptargetview; + +import android.annotation.TargetApi; +import android.graphics.drawable.Drawable; +import android.os.Build; +import android.text.TextUtils; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageButton; +import android.widget.ImageView; + +import androidx.annotation.IdRes; +import androidx.annotation.Nullable; +import androidx.appcompat.widget.Toolbar; + +import java.util.ArrayList; +import java.util.Stack; + +class ToolbarTapTarget extends ViewTapTarget { + ToolbarTapTarget(Toolbar toolbar, @IdRes int menuItemId, + CharSequence title, @Nullable CharSequence description) { + super(toolbar.findViewById(menuItemId), title, description); + } + + ToolbarTapTarget(android.widget.Toolbar toolbar, @IdRes int menuItemId, + CharSequence title, @Nullable CharSequence description) { + super(toolbar.findViewById(menuItemId), title, description); + } + + ToolbarTapTarget(Toolbar toolbar, boolean findNavView, + CharSequence title, @Nullable CharSequence description) { + super(findNavView ? findNavView(toolbar) : findOverflowView(toolbar), title, description); + } + + ToolbarTapTarget(android.widget.Toolbar toolbar, boolean findNavView, + CharSequence title, @Nullable CharSequence description) { + super(findNavView ? findNavView(toolbar) : findOverflowView(toolbar), title, description); + } + + private static ToolbarProxy proxyOf(Object instance) { + if (instance == null) { + throw new IllegalArgumentException("Given null instance"); + } + + if (instance instanceof Toolbar) { + return new SupportToolbarProxy((Toolbar) instance); + } else if (instance instanceof android.widget.Toolbar) { + return new StandardToolbarProxy((android.widget.Toolbar) instance); + } + + throw new IllegalStateException("Couldn't provide proper toolbar proxy instance"); + } + + private static View findNavView(Object instance) { + final ToolbarProxy toolbar = proxyOf(instance); + + // First we try to find the view via its content description + final CharSequence currentDescription = toolbar.getNavigationContentDescription(); + final boolean hadContentDescription = !TextUtils.isEmpty(currentDescription); + final CharSequence sentinel = hadContentDescription ? currentDescription : "taptarget-findme"; + toolbar.setNavigationContentDescription(sentinel); + + final ArrayList possibleViews = new ArrayList<>(1); + toolbar.findViewsWithText(possibleViews, sentinel, View.FIND_VIEWS_WITH_CONTENT_DESCRIPTION); + + if (!hadContentDescription) { + toolbar.setNavigationContentDescription(null); + } + + if (possibleViews.size() > 0) { + return possibleViews.get(0); + } + + // If that doesn't work, we try to grab it via matching its drawable + final Drawable navigationIcon = toolbar.getNavigationIcon(); + if (navigationIcon == null) { + throw new IllegalStateException("Toolbar does not have a navigation view set!"); + } + + final int size = toolbar.getChildCount(); + for (int i = 0; i < size; ++i) { + final View child = toolbar.getChildAt(i); + if (child instanceof ImageButton) { + final Drawable childDrawable = ((ImageButton) child).getDrawable(); + if (childDrawable == navigationIcon) { + return child; + } + } + } + + throw new IllegalStateException("Could not find navigation view for Toolbar!"); + } + + private static View findOverflowView(Object instance) { + final ToolbarProxy toolbar = proxyOf(instance); + + // First we try to find the overflow menu view via drawable matching + final Drawable overflowDrawable = toolbar.getOverflowIcon(); + if (overflowDrawable != null) { + final Stack parents = new Stack<>(); + parents.push((ViewGroup) toolbar.internalToolbar()); + while (!parents.empty()) { + ViewGroup parent = parents.pop(); + final int size = parent.getChildCount(); + for (int i = 0; i < size; ++i) { + final View child = parent.getChildAt(i); + if (child instanceof ViewGroup) { + parents.push((ViewGroup) child); + continue; + } + if (child instanceof ImageView) { + final Drawable childDrawable = ((ImageView) child).getDrawable(); + if (childDrawable == overflowDrawable) { + return child; + } + } + } + } + } + + // If that doesn't work, we fall-back to our last resort solution: Reflection + // Toolbars contain an "ActionMenuView" which in turn contains an "ActionMenuPresenter". + // The "ActionMenuPresenter" then holds a reference to an "OverflowMenuButton" which is the + // desired target + try { + final Object actionMenuView = ReflectUtil.getPrivateField(toolbar.internalToolbar(), "mMenuView"); + final Object actionMenuPresenter = ReflectUtil.getPrivateField(actionMenuView, "mPresenter"); + return (View) ReflectUtil.getPrivateField(actionMenuPresenter, "mOverflowButton"); + } catch (NoSuchFieldException e) { + throw new IllegalStateException("Could not find overflow view for Toolbar!", e); + } catch (IllegalAccessException e) { + throw new IllegalStateException("Unable to access overflow view for Toolbar!", e); + } + } + + private interface ToolbarProxy { + CharSequence getNavigationContentDescription(); + + void setNavigationContentDescription(CharSequence description); + + void findViewsWithText(ArrayList out, CharSequence toFind, int flags); + + Drawable getNavigationIcon(); + + @Nullable + Drawable getOverflowIcon(); + + int getChildCount(); + + View getChildAt(int position); + + Object internalToolbar(); + } + + private static class SupportToolbarProxy implements ToolbarProxy { + private final Toolbar toolbar; + + SupportToolbarProxy(Toolbar toolbar) { + this.toolbar = toolbar; + } + + @Override + public CharSequence getNavigationContentDescription() { + return toolbar.getNavigationContentDescription(); + } + + @Override + public void setNavigationContentDescription(CharSequence description) { + toolbar.setNavigationContentDescription(description); + } + + @Override + public void findViewsWithText(ArrayList out, CharSequence toFind, int flags) { + toolbar.findViewsWithText(out, toFind, flags); + } + + @Override + public Drawable getNavigationIcon() { + return toolbar.getNavigationIcon(); + } + + @Override + public Drawable getOverflowIcon() { + return toolbar.getOverflowIcon(); + } + + @Override + public int getChildCount() { + return toolbar.getChildCount(); + } + + @Override + public View getChildAt(int position) { + return toolbar.getChildAt(position); + } + + @Override + public Object internalToolbar() { + return toolbar; + } + } + + @TargetApi(Build.VERSION_CODES.LOLLIPOP) + private static class StandardToolbarProxy implements ToolbarProxy { + private final android.widget.Toolbar toolbar; + + StandardToolbarProxy(android.widget.Toolbar toolbar) { + this.toolbar = toolbar; + } + + @Override + public CharSequence getNavigationContentDescription() { + return toolbar.getNavigationContentDescription(); + } + + @Override + public void setNavigationContentDescription(CharSequence description) { + toolbar.setNavigationContentDescription(description); + } + + @Override + public void findViewsWithText(ArrayList out, CharSequence toFind, int flags) { + toolbar.findViewsWithText(out, toFind, flags); + } + + @Override + public Drawable getNavigationIcon() { + return toolbar.getNavigationIcon(); + } + + @Nullable + @Override + public Drawable getOverflowIcon() { + if (Build.VERSION.SDK_INT >= 23) { + return toolbar.getOverflowIcon(); + } + + return null; + } + + @Override + public int getChildCount() { + return toolbar.getChildCount(); + } + + @Override + public View getChildAt(int position) { + return toolbar.getChildAt(position); + } + + @Override + public Object internalToolbar() { + return toolbar; + } + } +} diff --git a/taptargetview/src/main/java/com/getkeepsafe/taptargetview/UiUtil.java b/taptargetview/src/main/java/com/getkeepsafe/taptargetview/UiUtil.java index b34ec09..3be1b32 100644 --- a/taptargetview/src/main/java/com/getkeepsafe/taptargetview/UiUtil.java +++ b/taptargetview/src/main/java/com/getkeepsafe/taptargetview/UiUtil.java @@ -17,28 +17,31 @@ import android.content.Context; import android.content.res.Resources; - -import androidx.annotation.ColorRes; -import androidx.annotation.DimenRes; import android.util.TypedValue; class UiUtil { UiUtil() { } - /** Returns the given pixel value in dp **/ + /** + * Returns the given pixel value in dp + **/ static int dp(Context context, int val) { return (int) TypedValue.applyDimension( - TypedValue.COMPLEX_UNIT_DIP, val, context.getResources().getDisplayMetrics()); + TypedValue.COMPLEX_UNIT_DIP, val, context.getResources().getDisplayMetrics()); } - /** Returns the given pixel value in sp **/ + /** + * Returns the given pixel value in sp + **/ static int sp(Context context, int val) { return (int) TypedValue.applyDimension( - TypedValue.COMPLEX_UNIT_SP, val, context.getResources().getDisplayMetrics()); + TypedValue.COMPLEX_UNIT_SP, val, context.getResources().getDisplayMetrics()); } - /** Returns the value of the desired theme integer attribute, or -1 if not found **/ + /** + * Returns the value of the desired theme integer attribute, or -1 if not found + **/ static int themeIntAttr(Context context, String attr) { final Resources.Theme theme = context.getTheme(); if (theme == null) { @@ -57,7 +60,9 @@ static int themeIntAttr(Context context, String attr) { return value.data; } - /** Modifies the alpha value of the given ARGB color **/ + /** + * Modifies the alpha value of the given ARGB color + **/ static int setAlpha(int argb, float alpha) { if (alpha > 1.0f) { alpha = 1.0f; diff --git a/taptargetview/src/main/java/com/getkeepsafe/taptargetview/ViewTapTarget.java b/taptargetview/src/main/java/com/getkeepsafe/taptargetview/ViewTapTarget.java index 045ae58..0031f45 100644 --- a/taptargetview/src/main/java/com/getkeepsafe/taptargetview/ViewTapTarget.java +++ b/taptargetview/src/main/java/com/getkeepsafe/taptargetview/ViewTapTarget.java @@ -19,9 +19,10 @@ import android.graphics.Canvas; import android.graphics.Rect; import android.graphics.drawable.BitmapDrawable; -import androidx.annotation.Nullable; import android.view.View; +import androidx.annotation.Nullable; + class ViewTapTarget extends TapTarget { final View view; @@ -42,7 +43,7 @@ public void run() { final int[] location = new int[2]; view.getLocationOnScreen(location); bounds = new Rect(location[0], location[1], - location[0] + view.getWidth(), location[1] + view.getHeight()); + location[0] + view.getWidth(), location[1] + view.getHeight()); if (icon == null && view.getWidth() > 0 && view.getHeight() > 0) { final Bitmap viewBitmap = Bitmap.createBitmap(view.getWidth(), view.getHeight(), Bitmap.Config.ARGB_8888); diff --git a/taptargetview/src/main/java/com/getkeepsafe/taptargetview/ViewUtil.java b/taptargetview/src/main/java/com/getkeepsafe/taptargetview/ViewUtil.java index 44c9a9a..03ce33d 100644 --- a/taptargetview/src/main/java/com/getkeepsafe/taptargetview/ViewUtil.java +++ b/taptargetview/src/main/java/com/getkeepsafe/taptargetview/ViewUtil.java @@ -16,66 +16,71 @@ package com.getkeepsafe.taptargetview; import android.os.Build; -import androidx.core.view.ViewCompat; import android.view.View; import android.view.ViewManager; import android.view.ViewTreeObserver; -class ViewUtil { - ViewUtil() { - } - - /** Returns whether or not the view has been laid out **/ - private static boolean isLaidOut(View view) { - return ViewCompat.isLaidOut(view) && view.getWidth() > 0 && view.getHeight() > 0; - } +import androidx.core.view.ViewCompat; - /** Executes the given {@link java.lang.Runnable} when the view is laid out **/ - static void onLaidOut(final View view, final Runnable runnable) { - if (isLaidOut(view)) { - runnable.run(); - return; +class ViewUtil { + ViewUtil() { } - final ViewTreeObserver observer = view.getViewTreeObserver(); - observer.addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() { - @Override - public void onGlobalLayout() { - final ViewTreeObserver trueObserver; + /** + * Returns whether or not the view has been laid out + **/ + private static boolean isLaidOut(View view) { + return ViewCompat.isLaidOut(view) && view.getWidth() > 0 && view.getHeight() > 0; + } - if (observer.isAlive()) { - trueObserver = observer; - } else { - trueObserver = view.getViewTreeObserver(); + /** + * Executes the given {@link java.lang.Runnable} when the view is laid out + **/ + static void onLaidOut(final View view, final Runnable runnable) { + if (isLaidOut(view)) { + runnable.run(); + return; } - removeOnGlobalLayoutListener(trueObserver, this); + final ViewTreeObserver observer = view.getViewTreeObserver(); + observer.addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() { + @Override + public void onGlobalLayout() { + final ViewTreeObserver trueObserver; - runnable.run(); - } - }); - } + if (observer.isAlive()) { + trueObserver = observer; + } else { + trueObserver = view.getViewTreeObserver(); + } - @SuppressWarnings("deprecation") - static void removeOnGlobalLayoutListener(ViewTreeObserver observer, - ViewTreeObserver.OnGlobalLayoutListener listener) { - if (Build.VERSION.SDK_INT >= 16) { - observer.removeOnGlobalLayoutListener(listener); - } else { - observer.removeGlobalOnLayoutListener(listener); + removeOnGlobalLayoutListener(trueObserver, this); + + runnable.run(); + } + }); } - } - static void removeView(ViewManager parent, View child) { - if (parent == null || child == null) { - return; + @SuppressWarnings("deprecation") + static void removeOnGlobalLayoutListener(ViewTreeObserver observer, + ViewTreeObserver.OnGlobalLayoutListener listener) { + if (Build.VERSION.SDK_INT >= 16) { + observer.removeOnGlobalLayoutListener(listener); + } else { + observer.removeGlobalOnLayoutListener(listener); + } } - try { - parent.removeView(child); - } catch (Exception ignored) { - // This catch exists for modified versions of Android that have a buggy ViewGroup - // implementation. See b.android.com/77639, #121 and #49 + static void removeView(ViewManager parent, View child) { + if (parent == null || child == null) { + return; + } + + try { + parent.removeView(child); + } catch (Exception ignored) { + // This catch exists for modified versions of Android that have a buggy ViewGroup + // implementation. See b.android.com/77639, #121 and #49 + } } - } }