diff --git a/CONTRIBUTING.md b/.github/CONTRIBUTING.md similarity index 100% rename from CONTRIBUTING.md rename to .github/CONTRIBUTING.md diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md new file mode 100644 index 0000000..82a7cbb --- /dev/null +++ b/.github/ISSUE_TEMPLATE.md @@ -0,0 +1,9 @@ +- [ ] I have verified the issue exists on the latest version +- [ ] I am able to reproduce it + +**Version used:** + +**Stack trace:** + +**Android version:** + diff --git a/.github/screenshot1.png b/.github/screenshot1.png new file mode 100644 index 0000000..64b4ad2 Binary files /dev/null and b/.github/screenshot1.png differ diff --git a/.github/screenshot2.png b/.github/screenshot2.png new file mode 100644 index 0000000..6904320 Binary files /dev/null and b/.github/screenshot2.png differ diff --git a/.github/video.gif b/.github/video.gif new file mode 100644 index 0000000..08509d8 Binary files /dev/null and b/.github/video.gif differ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..3d7f74e --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,25 @@ +# Changelog + + +## [Unreleased] + +## [1.15.0] - Released October 8, 2024 +- Added attribute for force centering tap targets (#409) + +## [1.14.0] - Released August 16, 2024 +- Modernize project build files (#407) +- Enable edge to edge on sample app (#407) +- Add setDrawBehindStatusBar and setDrawBehindNavigationBar (#407) + +## [1.13.3] - Released July 9, 2021 +- Removed JCenter dependencies and updated other build dependencies (#388) + +## [1.13.2] - Released March 10, 2021 +- Moved artifact publishing from JCenter to Maven Central (#385) \ No newline at end of file diff --git a/LICENSE b/LICENSE index 8dada3e..daf2abd 100644 --- a/LICENSE +++ b/LICENSE @@ -186,7 +186,7 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright {yyyy} {name of copyright owner} + 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. diff --git a/README.md b/README.md index f65d506..1f5f16e 100644 --- a/README.md +++ b/README.md @@ -1,54 +1,131 @@
-
-
+
+
* 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. @@ -16,6 +16,7 @@ package com.getkeepsafe.taptargetview; import android.animation.Animator; +import android.animation.AnimatorListenerAdapter; import android.animation.TimeInterpolator; import android.animation.ValueAnimator; @@ -23,84 +24,75 @@ * A small wrapper around {@link ValueAnimator} to provide a builder-like interface */ class FloatValueAnimatorBuilder { - final ValueAnimator animator; + final ValueAnimator animator; - EndListener endListener; + EndListener endListener; - interface UpdateListener { - void onUpdate(float lerpTime); - } - - interface EndListener { - void onEnd(); - } - - protected FloatValueAnimatorBuilder() { - this(false); - } - - protected FloatValueAnimatorBuilder(boolean reverse) { - if (reverse) { - this.animator = ValueAnimator.ofFloat(1.0f, 0.0f); - } else { - this.animator = ValueAnimator.ofFloat(0.0f, 1.0f); - } - } - - public FloatValueAnimatorBuilder delayBy(long millis) { - animator.setStartDelay(millis); - return this; - } - - public FloatValueAnimatorBuilder duration(long millis) { - animator.setDuration(millis); - return this; - } - - public FloatValueAnimatorBuilder interpolator(TimeInterpolator lerper) { - animator.setInterpolator(lerper); - return this; - } + interface UpdateListener { + void onUpdate(float lerpTime); + } - public FloatValueAnimatorBuilder repeat(int times) { - animator.setRepeatCount(times); - return this; - } + interface EndListener { + void onEnd(); + } - public FloatValueAnimatorBuilder onUpdate(final UpdateListener listener) { - animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { - @Override - public void onAnimationUpdate(ValueAnimator animation) { - listener.onUpdate((float) animation.getAnimatedValue()); - } - }); - return this; - } + protected FloatValueAnimatorBuilder() { + this(false); + } - public FloatValueAnimatorBuilder onEnd(final EndListener listener) { - this.endListener = listener; - return this; + protected FloatValueAnimatorBuilder(boolean reverse) { + if (reverse) { + this.animator = ValueAnimator.ofFloat(1.0f, 0.0f); + } else { + this.animator = ValueAnimator.ofFloat(0.0f, 1.0f); } - - public ValueAnimator build() { - if (endListener != null) { - animator.addListener(new Animator.AnimatorListener() { - @Override - public void onAnimationStart(Animator animation) {} - - @Override - public void onAnimationEnd(Animator animation) { - endListener.onEnd(); - } - - @Override - public void onAnimationCancel(Animator animation) {} - - @Override - public void onAnimationRepeat(Animator animation) {} - }); + } + + public FloatValueAnimatorBuilder delayBy(long millis) { + animator.setStartDelay(millis); + return this; + } + + public FloatValueAnimatorBuilder duration(long millis) { + animator.setDuration(millis); + return this; + } + + public FloatValueAnimatorBuilder interpolator(TimeInterpolator lerper) { + animator.setInterpolator(lerper); + return this; + } + + public FloatValueAnimatorBuilder repeat(int times) { + animator.setRepeatCount(times); + return this; + } + + public FloatValueAnimatorBuilder onUpdate(final UpdateListener listener) { + animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { + @Override + public void onAnimationUpdate(ValueAnimator animation) { + listener.onUpdate((float) animation.getAnimatedValue()); + } + }); + return this; + } + + public FloatValueAnimatorBuilder onEnd(final EndListener listener) { + this.endListener = listener; + return this; + } + + public ValueAnimator build() { + if (endListener != null) { + animator.addListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(Animator animation) { + endListener.onEnd(); } - - return animator; + }); } + + return animator; + } } diff --git a/taptargetview/src/main/java/com/getkeepsafe/taptargetview/ReflectUtil.java b/taptargetview/src/main/java/com/getkeepsafe/taptargetview/ReflectUtil.java new file mode 100644 index 0000000..c9d84af --- /dev/null +++ b/taptargetview/src/main/java/com/getkeepsafe/taptargetview/ReflectUtil.java @@ -0,0 +1,31 @@ +/** + * 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 new file mode 100644 index 0000000..b43a8b0 --- /dev/null +++ b/taptargetview/src/main/java/com/getkeepsafe/taptargetview/TapTarget.java @@ -0,0 +1,525 @@ +/** + * 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.content.Context; +import android.graphics.Rect; +import android.graphics.Typeface; +import android.graphics.drawable.Drawable; +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; + +/** + * Describes the properties and options for a {@link TapTargetView}. + *
+ * Each tap target describes a target via a pair of bounds and icon. The bounds dictate the + * location and touch area of the target, where the icon is what will be drawn within the center of + * the bounds. + *
+ * This class can be extended to support various target types. + * + * @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; + + boolean drawBehindStatusBar = true; + boolean drawBehindNavigationBar = true; + + boolean forceCenteredTarget = false; + + /** + * 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 draw behind the status bar. */ + public TapTarget setDrawBehindStatusBar(boolean drawBehindStatusBar) { + this.drawBehindStatusBar = drawBehindStatusBar; + return this; + } + + /** Specify whether the target should draw behind the navigation bar. */ + public TapTarget setDrawBehindNavigationBar(boolean drawBehindNavigationBar) { + this.drawBehindNavigationBar = drawBehindNavigationBar; + return this; + } + + /** Specify whether the target should be forced to center on the target view. */ + public TapTarget setForceCenteredTarget(boolean forceCenteredTarget) { + this.forceCenteredTarget = forceCenteredTarget; + return this; + } + + /** 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); + } +} diff --git a/taptargetview/src/main/java/com/getkeepsafe/taptargetview/TapTargetSequence.java b/taptargetview/src/main/java/com/getkeepsafe/taptargetview/TapTargetSequence.java new file mode 100644 index 0000000..23a33b2 --- /dev/null +++ b/taptargetview/src/main/java/com/getkeepsafe/taptargetview/TapTargetSequence.java @@ -0,0 +1,238 @@ +/** + * 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.app.Activity; +import android.app.Dialog; +import androidx.annotation.Nullable; +import androidx.annotation.UiThread; + +import java.util.Collections; +import java.util.LinkedList; +import java.util.List; +import java.util.NoSuchElementException; +import java.util.Queue; + +/** + * Displays a sequence of {@link TapTargetView}s. + *
+ * 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 Queue
* 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.
@@ -17,708 +17,1029 @@
import android.animation.ValueAnimator;
import android.annotation.SuppressLint;
+import android.annotation.TargetApi;
import android.app.Activity;
+import android.app.Dialog;
import android.content.Context;
import android.content.res.Resources;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Color;
+import android.graphics.Outline;
import android.graphics.Paint;
import android.graphics.Path;
+import android.graphics.PixelFormat;
import android.graphics.PorterDuff;
import android.graphics.PorterDuffColorFilter;
+import android.graphics.PorterDuffXfermode;
import android.graphics.Rect;
import android.graphics.Region;
import android.graphics.Typeface;
-import android.support.annotation.ColorRes;
-import android.support.annotation.StringRes;
+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;
import android.text.StaticLayout;
import android.text.TextPaint;
+import android.util.DisplayMetrics;
+import android.view.Gravity;
+import android.view.KeyEvent;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
+import android.view.ViewManager;
+import android.view.ViewOutlineProvider;
+import android.view.ViewTreeObserver;
+import android.view.WindowManager;
import android.view.animation.AccelerateDecelerateInterpolator;
/**
* TapTargetView implements a feature discovery paradigm following Google's Material Design
* guidelines.
*
- * This class should not be instantiated directly. Instead, please use the {@link Builder} class
- * instead.
+ * This class should not be instantiated directly. Instead, please use the
+ * {@link #showFor(Activity, TapTarget, Listener)} static factory method instead.
*
* More information can be found here:
* https://material.google.com/growth-communications/feature-discovery.html#feature-discovery-design
*/
@SuppressLint("ViewConstructor")
public class TapTargetView extends View {
- final int TARGET_PADDING;
- final int TARGET_RADIUS;
- final int TARGET_PULSE_RADIUS;
- final int TEXT_PADDING;
- final int TEXT_SPACING;
- final int CIRCLE_PADDING;
- final int GUTTER_DIM;
-
- final ViewGroup parent;
- final View target;
- final Rect targetBounds;
-
- final TextPaint titlePaint;
- final TextPaint descriptionPaint;
- final Paint outerCirclePaint;
- final Paint outerCircleShadowPaint;
- final Paint targetCirclePaint;
- final Paint targetCirclePulsePaint;
- final Paint debugPaint;
-
- String title;
- String description;
- StaticLayout titleLayout;
- StaticLayout descriptionLayout;
- boolean isDark;
- boolean debug;
- boolean shouldTintTarget;
- boolean shouldDrawShadow;
- boolean cancelable;
-
- // 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;
-
- Bitmap tintedTarget;
-
- Listener listener;
-
- public static class Listener {
- public void onTargetClick(TapTargetView view) {
- view.dismiss(true);
- }
+ 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 TEXT_SAFE_AREA_PADDING;
+ 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);
+ }
- public void onTargetLongClick(TapTargetView view) {
- view.dismiss(true);
- }
+ /** Signals that the user has long clicked inside of the target **/
+ public void onTargetLongClick(TapTargetView view) {
+ onTargetClick(view);
+ }
- public void onTargetCancel(TapTargetView view) {
- view.dismiss(false);
- }
+ /** If cancelable, signals that the user has clicked outside of the outer circle **/
+ public void onTargetCancel(TapTargetView view) {
+ view.dismiss(false);
}
- private final FloatValueAnimatorBuilder.UpdateListener expandContractUpdateListener = new FloatValueAnimatorBuilder.UpdateListener() {
- @Override
- public void onUpdate(float lerpTime) {
- final float targetAlpha = 0.96f * 255;
- outerCircleRadius = calculatedOuterCircleRadius * lerpTime;
- outerCircleAlpha = (int) Math.min(targetAlpha, (lerpTime * 1.5f * targetAlpha));
- outerCirclePath.reset();
- outerCirclePath.addCircle(outerCircleCenter[0], outerCircleCenter[1], outerCircleRadius, Path.Direction.CW);
+ /** Signals that the user clicked on the outer circle portion of the tap target **/
+ public void onOuterCircleClick(TapTargetView view) {
+ // no-op as default
+ }
- targetCircleAlpha = (int) Math.min(255.0f, (lerpTime * 1.5f * 255.0f));
- targetCircleRadius = TARGET_RADIUS * Math.min(1.0f, lerpTime * 1.5f);
+ /**
+ * Signals that the tap target has been dismissed
+ * @param userInitiated Whether the user caused this action
+ */
+ public void onTargetDismissed(TapTargetView view, boolean userInitiated) {
+ }
+ }
- textAlpha = (int) (delayedLerp(lerpTime, 0.7f) * 255);
+ 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);
+ }
+ };
- calculateDrawingBounds();
- invalidate();
+ 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);
}
- };
-
- private 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();
- }
- })
- .build();
-
- private 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;
- calculateDrawingBounds();
- invalidate();
- }
- })
- .build();
-
- private 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() {
- parent.removeView(TapTargetView.this);
- onDismiss();
- }
- })
- .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));
- outerCircleAlpha = (int) ((1.0f - spedUpLerp) * 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);
- textAlpha = (int) ((1.0f - spedUpLerp) * 255.0f);
- calculateDrawingBounds();
- invalidate();
- }
- })
- .onEnd(new FloatValueAnimatorBuilder.EndListener() {
- @Override
- public void onEnd() {
- parent.removeView(TapTargetView.this);
- onDismiss();
- }
- })
- .build();
-
- private ValueAnimator[] animators = new ValueAnimator[]
- {expandAnimation, pulseAnimation, dismissConfirmAnimation, dismissAnimation};
-
- public TapTargetView(final ViewGroup parent, final View target, String title, String description) {
- super(parent.getContext());
- if (target == null) throw new IllegalArgumentException("View cannot be null");
- if (title == null) throw new IllegalArgumentException("Title cannot be null");
- if (description == null) throw new IllegalArgumentException("Description cannot be null");
-
- this.target = target;
- this.title = title;
- this.description = description;
- this.parent = parent;
-
- final Context context = getContext();
- TARGET_PADDING = UiUtil.dp(context, 20);
- CIRCLE_PADDING = UiUtil.dp(context, 40);
- TARGET_RADIUS = UiUtil.dp(context, 44);
- TEXT_PADDING = UiUtil.dp(context, 40);
- TEXT_SPACING = UiUtil.dp(context, 8);
- GUTTER_DIM = UiUtil.dp(context, 88);
- TARGET_PULSE_RADIUS = (int) (0.1f * TARGET_RADIUS);
-
- outerCirclePath = new Path();
- targetBounds = new Rect();
- drawingBounds = new Rect();
-
- outerCirclePaint = new Paint();
- titlePaint = new TextPaint();
- descriptionPaint = new TextPaint();
-
- final Resources.Theme theme = context.getTheme();
- if (theme != null) {
- outerCirclePaint.setColor(UiUtil.themeIntAttr(context, "colorPrimary"));
- titlePaint.setColor(Color.WHITE);
- descriptionPaint.setColor(Color.WHITE);
-
- final int isLightTheme = UiUtil.themeIntAttr(context, "isLightTheme");
- if (isLightTheme != -1) {
- isDark = isLightTheme == 0;
- }
- } else {
- outerCirclePaint.setColor(Color.WHITE);
- titlePaint.setColor(Color.BLACK);
- descriptionPaint.setColor(Color.BLACK);
+ })
+ .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;
+ targetCirclePulseAlpha = (int) ((1.0f - pulseLerp) * 255);
+ targetCircleRadius = TARGET_RADIUS + halfwayLerp(lerpTime) * TARGET_PULSE_RADIUS;
- titlePaint.setTextSize(UiUtil.sp(context, 20));
- titlePaint.setTypeface(Typeface.create("sans-serif-medium", Typeface.NORMAL));
- titlePaint.setAntiAlias(true);
-
- descriptionPaint.setTextSize(UiUtil.sp(context, 18));
- descriptionPaint.setTypeface(Typeface.create(Typeface.SANS_SERIF, Typeface.NORMAL));
- descriptionPaint.setAntiAlias(true);
- descriptionPaint.setAlpha((int) (0.54f * 255.0f));
-
- outerCirclePaint.setAntiAlias(true);
- outerCirclePaint.setAlpha((int) (0.96f * 255.0f));
-
- outerCircleShadowPaint = new Paint();
- outerCircleShadowPaint.setAntiAlias(true);
- outerCircleShadowPaint.setAlpha(50);
- outerCircleShadowPaint.setShadowLayer(10.0f, 0.0f, 25.0f, Color.BLACK);
-
- targetCirclePaint = new Paint();
- targetCirclePaint.setAntiAlias(true);
- targetCirclePaint.setColor(isDark ? Color.BLACK : Color.WHITE);
+ if (outerCircleRadius != calculatedOuterCircleRadius) {
+ outerCircleRadius = calculatedOuterCircleRadius;
+ }
- targetCirclePulsePaint = new Paint();
- targetCirclePulsePaint.setAntiAlias(true);
- targetCirclePulsePaint.setColor(targetCirclePaint.getColor());
+ calculateDrawingBounds();
+ invalidateViewAndOutline(drawingBounds);
+ }
+ })
+ .build();
- debugPaint = new Paint();
- debugPaint.setColor(0xFFFF0000);
- debugPaint.setStyle(Paint.Style.STROKE);
+ 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();
- setLayerType(LAYER_TYPE_SOFTWARE, null);
+ 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));
+ 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);
+ TEXT_SAFE_AREA_PADDING = UiUtil.dp(getContext(), 10);
+ 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 layoutNoLimits;
+ if (context instanceof Activity) {
+ Activity activity = (Activity) context;
+ final int flags = activity.getWindow().getAttributes().flags;
+ layoutNoLimits = (flags & WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS) != 0;
+ } else {
+ layoutNoLimits = false;
+ }
- ViewUtil.onLaidOut(this, new Runnable() {
- @Override
- public void run() {
- final int[] targetLocation = new int[2];
- final int[] offset = new int[2];
+ 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);
+
+ final boolean canDrawBehindSystemBars = Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT;
+ if (target.drawBehindStatusBar && canDrawBehindSystemBars) {
+ rect.top = parentLocation[1];
+ }
+ if (target.drawBehindNavigationBar && canDrawBehindSystemBars) {
+ 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;
+ }
+ }
- target.getLocationOnScreen(targetLocation);
- targetBounds.set(targetLocation[0], targetLocation[1],
- targetLocation[0] + target.getWidth(), targetLocation[1] + target.getHeight());
+ drawTintedTarget();
+ requestFocus();
+ calculateDimensions();
- getLocationOnScreen(offset);
- targetBounds.offset(-offset[0], -offset[1]);
+ startExpandAnimation();
+ }
+ });
+ }
+ };
- final ViewGroup content = (ViewGroup) parent.findViewById(android.R.id.content);
- content.getLocationOnScreen(offset);
- topBoundary = offset[1];
+ 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);
+ }
+ }
+ });
- drawTintedTarget();
- calculateDimensions();
- expandAnimation.start();
- }
- });
+ setOnLongClickListener(new OnLongClickListener() {
+ @Override
+ public boolean onLongClick(View v) {
+ if (listener == null) return false;
- setClickable(true);
- setOnClickListener(new OnClickListener() {
- @Override
- public void onClick(View v) {
- if (listener == null) return;
-
- if (targetBounds.contains((int) lastTouchX, (int) lastTouchY)) {
- listener.onTargetClick(TapTargetView.this);
- } else if (cancelable && distance(outerCircleCenter[0], outerCircleCenter[1],
- (int) lastTouchX, (int) lastTouchY) > outerCircleRadius) {
- listener.onTargetCancel(TapTargetView.this);
- }
- }
- });
+ if (targetBounds.contains((int) lastTouchX, (int) lastTouchY)) {
+ listener.onTargetLongClick(TapTargetView.this);
+ return true;
+ }
- setOnLongClickListener(new OnLongClickListener() {
- @Override
- public boolean onLongClick(View v) {
- if (listener == null) return false;
+ return false;
+ }
+ });
+ }
- if (targetBounds.contains((int) lastTouchX, (int) lastTouchY)) {
- listener.onTargetLongClick(TapTargetView.this);
- return true;
- }
+ 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);
+ }
+ }
+ };
- return false;
- }
- });
+ setOutlineProvider(outlineProvider);
+ setElevation(SHADOW_DIM);
}
- @Override
- protected void onMeasure(int widthSpec, int heightSpec) {
- super.onMeasure(widthSpec, heightSpec);
- updateTextLayouts();
+ if (shouldDrawShadow && outlineProvider == null && Build.VERSION.SDK_INT < 18) {
+ setLayerType(LAYER_TYPE_SOFTWARE, null);
+ } else {
+ setLayerType(LAYER_TYPE_HARDWARE, null);
}
- @Override
- protected void onDetachedFromWindow() {
- super.onDetachedFromWindow();
- onDismiss();
+ final Resources.Theme theme = context.getTheme();
+ isDark = UiUtil.themeIntAttr(context, "isLightTheme") == 0;
+
+ 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);
}
- private void onDismiss() {
- 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);
}
- @Override
- protected void onDraw(Canvas c) {
- if (dimColor != -1) {
- c.drawColor(dimColor);
- }
+ if (target.transparentTarget) {
+ targetCirclePaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.CLEAR));
+ }
- int saveCount;
- c.clipRect(drawingBounds);
+ targetCirclePulsePaint.setColor(targetCirclePaint.getColor());
- outerCirclePaint.setAlpha(outerCircleAlpha);
- if (shouldDrawShadow) {
- saveCount = c.save(); {
- c.clipPath(outerCirclePath, Region.Op.DIFFERENCE);
- outerCircleShadowPaint.setAlpha((int) (0.20f * outerCircleAlpha));
- c.drawPath(outerCirclePath, outerCircleShadowPaint);
- } c.restoreToCount(saveCount);
- }
- c.drawPath(outerCirclePath, outerCirclePaint);
+ final Integer targetDimColor = target.dimColorInt(context);
+ if (targetDimColor != null) {
+ dimColor = UiUtil.setAlpha(targetDimColor, 0.3f);
+ } else {
+ dimColor = -1;
+ }
- 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.clipPath(outerCirclePath);
- c.translate(textBounds.left, textBounds.top);
- titlePaint.setAlpha(textAlpha);
- titleLayout.draw(c);
-
- c.translate(0, titleLayout.getHeight() + TEXT_SPACING);
- descriptionPaint.setAlpha((int) (0.54f * textAlpha));
- descriptionLayout.draw(c);
- } c.restoreToCount(saveCount);
-
- saveCount = c.save(); {
- c.translate(targetBounds.left, targetBounds.top);
- if (tintedTarget != null) {
- c.drawBitmap(tintedTarget, 0, 0, targetCirclePaint);
- } else {
- target.draw(c);
- }
- } c.restoreToCount(saveCount);
-
- if (debug) {
- 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);
- }
+ final Integer titleTextColor = target.titleTextColorInt(context);
+ if (titleTextColor != null) {
+ titlePaint.setColor(titleTextColor);
+ } else {
+ titlePaint.setColor(isDark ? Color.BLACK : Color.WHITE);
}
- @Override
- public boolean onTouchEvent(MotionEvent e) {
- super.onTouchEvent(e);
- lastTouchX = e.getX();
- lastTouchY = e.getY();
- return true;
- }
-
- public void dismiss(boolean tappedTarget) {
- pulseAnimation.cancel();
- expandAnimation.cancel();
- if (tappedTarget) {
- dismissConfirmAnimation.start();
- } else {
- dismissAnimation.start();
- }
+ final Integer descriptionTextColor = target.descriptionTextColorInt(context);
+ if (descriptionTextColor != null) {
+ descriptionPaint.setColor(descriptionTextColor);
+ } else {
+ descriptionPaint.setColor(titlePaint.getColor());
}
- public void setListener(Listener listener) {
- this.listener = listener;
+ if (target.titleTypeface != null) {
+ titlePaint.setTypeface(target.titleTypeface);
}
- public void setDrawDebug(boolean status) {
- if (debug != status) {
- debug = status;
- postInvalidate();
- }
+ if (target.descriptionTypeface != null) {
+ descriptionPaint.setTypeface(target.descriptionTypeface);
}
+ }
- private void drawTintedTarget() {
- if (!shouldTintTarget) {
- return;
- }
+ @Override
+ protected void onDetachedFromWindow() {
+ super.onDetachedFromWindow();
+ onDismiss(false);
+ }
- target.setDrawingCacheEnabled(true);
- final Bitmap cachedBitmap = target.getDrawingCache();
- if (cachedBitmap == null) {
- tintedTarget = null;
- return;
- }
+ void onDismiss(boolean userInitiated) {
+ if (isDismissed) return;
- tintedTarget = Bitmap.createBitmap(cachedBitmap.getWidth(), cachedBitmap.getHeight(),
- Bitmap.Config.ARGB_8888);
- final Canvas canvas = new Canvas(tintedTarget);
- final Paint tintPaint = new Paint();
- tintPaint.setColorFilter(new PorterDuffColorFilter(outerCirclePaint.getColor(),
- PorterDuff.Mode.SRC_ATOP));
- canvas.drawBitmap(cachedBitmap, 0, 0, tintPaint);
- }
+ isDismissing = false;
+ isDismissed = true;
- private void updateTextLayouts() {
- final int textWidth = getMeasuredWidth() - TEXT_PADDING * 2;
- titleLayout = new StaticLayout(title, titlePaint, textWidth,
- Layout.Alignment.ALIGN_NORMAL, 1.0f, 0.0f, false);
- descriptionLayout = new StaticLayout(description, descriptionPaint, textWidth,
- Layout.Alignment.ALIGN_NORMAL, 1.0f, 0.0f, false);
+ for (final ValueAnimator animator : animators) {
+ animator.cancel();
+ animator.removeAllUpdateListeners();
}
- private float halfwayLerp(float lerp) {
- if (lerp < 0.5f) {
- return lerp / 0.5f;
- }
+ ViewUtil.removeOnGlobalLayoutListener(getViewTreeObserver(), globalLayoutListener);
+ visible = false;
- return (1.0f - lerp) / 0.5f;
+ if (listener != null) {
+ listener.onTargetDismissed(this, userInitiated);
}
+ }
- private float delayedLerp(float lerp, float threshold) {
- if (lerp < threshold) {
- return 0.0f;
- }
+ @Override
+ protected void onDraw(Canvas c) {
+ if (isDismissed || outerCircleCenter == null) return;
- return (lerp - threshold) / (1.0f - threshold);
+ if (topBoundary > 0 && bottomBoundary > 0) {
+ c.clipRect(0, topBoundary, getWidth(), bottomBoundary);
}
- private void calculateDimensions() {
- textBounds = getTextBounds();
- outerCircleCenter = getOuterCircleCenterPoint();
- calculatedOuterCircleRadius = getOuterCircleRadius(outerCircleCenter[0], outerCircleCenter[1], textBounds, targetBounds);
+ if (dimColor != -1) {
+ c.drawColor(dimColor);
}
- private void calculateDrawingBounds() {
- 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 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);
- private int getOuterCircleRadius(int centerX, int centerY, Rect textBounds, Rect targetBounds) {
- final Rect expandedBounds = new Rect(targetBounds);
- expandedBounds.inset(-TARGET_PADDING / 2, -TARGET_PADDING / 2);
-
- final int textRadius = maxDistanceToPoints(centerX, centerY, textBounds);
- final int targetRadius = maxDistanceToPoints(centerX, centerY, expandedBounds);
- return Math.max(textRadius, targetRadius) + CIRCLE_PADDING;
+ targetCirclePaint.setAlpha(targetCircleAlpha);
+ if (targetCirclePulseAlpha > 0) {
+ targetCirclePulsePaint.setAlpha(targetCirclePulseAlpha);
+ c.drawCircle(targetBounds.centerX(), targetBounds.centerY(),
+ targetCirclePulseRadius, targetCirclePulsePaint);
}
-
- private Rect getTextBounds() {
- final int totalTextHeight = titleLayout.getHeight() + descriptionLayout.getHeight() + TEXT_SPACING;
- final int totalTextWidth = Math.max(titleLayout.getWidth(), descriptionLayout.getWidth());
-
- 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;
- }
-
- return new Rect(TEXT_PADDING, top, TEXT_PADDING + totalTextWidth, top + totalTextHeight);
+ 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);
+ }
+ }
+ c.restoreToCount(saveCount);
- private int[] getOuterCircleCenterPoint() {
- if (inGutter(targetBounds.centerY())) {
- return new int[] {targetBounds.centerX(), targetBounds.centerY()};
- }
+ if (debug) {
+ drawDebugInformation(c);
+ }
+ }
+
+ @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;
+ }
- final int targetRadius = Math.max(targetBounds.width(), targetBounds.height()) / 2 + TARGET_PADDING;
- final int totalTextHeight = titleLayout.getHeight() + descriptionLayout.getHeight() + TEXT_SPACING;
+ return false;
+ }
- final boolean onTop = targetBounds.centerY() - TARGET_RADIUS - TARGET_PADDING - totalTextHeight > 0;
+ @Override
+ public boolean onKeyUp(int keyCode, KeyEvent event) {
+ if (isVisible() && isInteractable && cancelable
+ && keyCode == KeyEvent.KEYCODE_BACK && event.isTracking() && !event.isCanceled()) {
+ isInteractable = false;
- final int left = Math.min(TEXT_PADDING, targetBounds.left - targetRadius);
- final int right = Math.max(getWidth() - TEXT_PADDING, targetBounds.right + targetRadius);
- final int centerY = onTop ?
- targetBounds.centerY() - TARGET_RADIUS - TARGET_PADDING - totalTextHeight + titleLayout.getHeight()
- :
- targetBounds.centerY() + TARGET_RADIUS + TARGET_PADDING + titleLayout.getHeight();
+ if (listener != null) {
+ listener.onTargetCancel(this);
+ } else {
+ new Listener().onTargetCancel(this);
+ }
- return new int[] {(left + right) / 2, centerY};
+ return true;
}
- private boolean inGutter(int y) {
- return y < GUTTER_DIM || y > getHeight() - GUTTER_DIM;
+ 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 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)));
+ if (tappedTarget) {
+ dismissConfirmAnimation.start();
+ } else {
+ dismissAnimation.start();
}
-
- private double distance(int x1, int y1, int x2, int y2) {
- return Math.sqrt(Math.pow(x2 - x1, 2) + Math.pow(y2 - y1, 2));
+ }
+
+ 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();
+ }
+ }
+
+ /** 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);
+ }
+ }
+
+ 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));
}
- @SuppressWarnings("unused")
- public static class Builder {
- private final Activity activity;
-
- private String title;
- private String description;
- private Typeface typeface;
- private Listener listener;
- @ColorRes
- private int outerCircleColor = -1;
- @ColorRes
- private int targetCircleColor = -1;
- @ColorRes
- private int dimColor = -1;
- @ColorRes
- private int textColor = -1;
+ if (debugTextPaint == null) {
+ debugTextPaint = new TextPaint();
+ debugTextPaint.setColor(0xFFFF0000);
+ debugTextPaint.setTextSize(UiUtil.sp(getContext(), 16));
+ }
- private boolean tintTarget = true;
- private boolean drawShadow = true;
- private boolean cancelable = true;
+ // 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);
+ }
- public Builder(Activity activity) {
- if (activity == null) throw new IllegalArgumentException("Activity is null");
- this.activity = activity;
- }
+ if (debugLayout == null) {
+ debugLayout = new DynamicLayout(debugText, debugTextPaint, getWidth(), Layout.Alignment.ALIGN_NORMAL, 1.0f, 0.0f, false);
+ }
- public Builder title(@StringRes int titleId) {
- return title(activity.getString(titleId));
- }
+ 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);
+ }
+
+ void drawTintedTarget() {
+ final Drawable icon = target.icon;
+ if (!shouldTintTarget || icon == null) {
+ tintedTarget = null;
+ return;
+ }
- public Builder title(String title) {
- if (title == null) throw new IllegalArgumentException("Null title");
- this.title = title;
- return this;
- }
+ 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;
+ }
- public Builder description(@StringRes int descriptionId) {
- return description(activity.getString(descriptionId));
- }
+ titleLayout = new StaticLayout(title, titlePaint, textWidth,
+ Layout.Alignment.ALIGN_NORMAL, 1.0f, 0.0f, false);
- public Builder description(String description) {
- if (description == null) throw new IllegalArgumentException("Null description");
- this.description = description;
- return this;
- }
+ if (description != null) {
+ descriptionLayout = new StaticLayout(description, descriptionPaint, textWidth,
+ Layout.Alignment.ALIGN_NORMAL, 1.0f, 0.0f, false);
+ } else {
+ descriptionLayout = null;
+ }
+ }
- public Builder listener(Listener listener) {
- if (listener == null) throw new IllegalArgumentException("Null listener");
- this.listener = listener;
- return this;
- }
+ float halfwayLerp(float lerp) {
+ if (lerp < 0.5f) {
+ return lerp / 0.5f;
+ }
- public Builder outerCircleColor(@ColorRes int color) {
- this.outerCircleColor = color;
- return this;
- }
+ return (1.0f - lerp) / 0.5f;
+ }
- public Builder targetCircleColor(@ColorRes int color) {
- this.targetCircleColor = color;
- return this;
- }
+ float delayedLerp(float lerp, float threshold) {
+ if (lerp < threshold) {
+ return 0.0f;
+ }
- public Builder textColor(@ColorRes int color) {
- this.textColor = color;
- return this;
- }
+ return (lerp - threshold) / (1.0f - threshold);
+ }
- public Builder textTypeface(Typeface typeface) {
- this.typeface = typeface;
- return this;
- }
+ void calculateDimensions() {
+ textBounds = getTextBounds();
+ outerCircleCenter = getOuterCircleCenterPoint();
+ calculatedOuterCircleRadius = getOuterCircleRadius(outerCircleCenter[0], outerCircleCenter[1], textBounds, targetBounds);
+ }
- public Builder dimColor(@ColorRes int color) {
- this.dimColor = color;
- return this;
- }
+ 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;
+ }
+
+ 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) {
+ Rect textSafeArea = new Rect();
+ getWindowVisibleDisplayFrame(textSafeArea);
+ textSafeArea.inset(0, TEXT_SAFE_AREA_PADDING);
+ top = Math.max(possibleTop, textSafeArea.top);
+ } else {
+ top = targetBounds.centerY() + TARGET_RADIUS + TARGET_PADDING;
+ }
- public Builder tintTarget(boolean tint) {
- this.tintTarget = tint;
- return this;
- }
+ 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);
+ }
- public Builder drawShadow(boolean draw) {
- this.drawShadow = draw;
- return this;
- }
+ int[] getOuterCircleCenterPoint() {
+ if (inGutter(targetBounds.centerY()) || target.forceCenteredTarget) {
+ return new int[]{targetBounds.centerX(), targetBounds.centerY()};
+ }
- public Builder cancelable(boolean status) {
- this.cancelable = status;
- return this;
- }
+ final int targetRadius = Math.max(targetBounds.width(), targetBounds.height()) / 2 + TARGET_PADDING;
+ final int totalTextHeight = getTotalTextHeight();
- public TapTargetView showFor(View view) {
- if (title == null || description == null) {
- throw new IllegalStateException("Null title or description");
- }
+ final boolean onTop = targetBounds.centerY() - TARGET_RADIUS - TARGET_PADDING - totalTextHeight > 0;
- final ViewGroup decor = (ViewGroup) activity.getWindow().getDecorView();
- final ViewGroup.LayoutParams layoutParams = new ViewGroup.LayoutParams(
- ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT);
- final TapTargetView tapTargetView = new TapTargetView(decor, view, title, description);
- decor.addView(tapTargetView, layoutParams);
+ 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;
- tapTargetView.shouldTintTarget = tintTarget;
- tapTargetView.shouldDrawShadow = drawShadow;
- tapTargetView.cancelable = cancelable;
+ return new int[] { (left + right) / 2, centerY };
+ }
- if (listener != null) {
- tapTargetView.setListener(listener);
- } else {
- tapTargetView.setListener(new Listener());
- }
+ int getTotalTextHeight() {
+ if (titleLayout == null) {
+ return 0;
+ }
- if (outerCircleColor != -1) {
- tapTargetView.outerCirclePaint.setColor(UiUtil.getColor(activity, outerCircleColor));
- }
+ if (descriptionLayout == null) {
+ return titleLayout.getHeight() + TEXT_SPACING;
+ }
- if (targetCircleColor != -1) {
- final int color = UiUtil.getColor(activity, targetCircleColor);
- tapTargetView.targetCirclePaint.setColor(color);
- tapTargetView.targetCirclePulsePaint.setColor(color);
- }
+ return titleLayout.getHeight() + descriptionLayout.getHeight() + TEXT_SPACING;
+ }
- if (dimColor != -1) {
- tapTargetView.dimColor = UiUtil.setAlpha(UiUtil.getColor(activity, dimColor), 0.3f);
- } else {
- tapTargetView.dimColor = -1;
- }
+ int getTotalTextWidth() {
+ if (titleLayout == null) {
+ return 0;
+ }
- if (textColor != -1) {
- tapTargetView.titlePaint.setColor(UiUtil.getColor(activity, textColor));
- tapTargetView.descriptionPaint.setColor(UiUtil.getColor(activity, textColor));
- }
+ if (descriptionLayout == null) {
+ return titleLayout.getWidth();
+ }
- if (typeface != null) {
- tapTargetView.titlePaint.setTypeface(typeface);
- tapTargetView.descriptionPaint.setTypeface(typeface);
- }
+ return Math.max(titleLayout.getWidth(), descriptionLayout.getWidth());
+ }
- return tapTargetView;
- }
+ boolean inGutter(int y) {
+ if (bottomBoundary > 0) {
+ return y < GUTTER_DIM || y > bottomBoundary - GUTTER_DIM;
+ } else {
+ return y < GUTTER_DIM || y > getHeight() - GUTTER_DIM;
+ }
+ }
+
+ 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();
}
-}
\ No newline at end of file
+ }
+}
diff --git a/taptargetview/src/main/java/com/getkeepsafe/taptargetview/ToolbarTapTarget.java b/taptargetview/src/main/java/com/getkeepsafe/taptargetview/ToolbarTapTarget.java
new file mode 100644
index 0000000..664ed39
--- /dev/null
+++ b/taptargetview/src/main/java/com/getkeepsafe/taptargetview/ToolbarTapTarget.java
@@ -0,0 +1,272 @@
+/**
+ * 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 java.util.ArrayList;
+import java.util.Stack;
+
+import androidx.annotation.IdRes;
+import androidx.annotation.Nullable;
+import androidx.appcompat.widget.Toolbar;
+
+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 (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP
+ && 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
* 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.
@@ -17,61 +17,54 @@
import android.content.Context;
import android.content.res.Resources;
-import android.os.Build;
+
+import androidx.annotation.ColorRes;
+import androidx.annotation.DimenRes;
import android.util.TypedValue;
class UiUtil {
- UiUtil() {}
-
- /** 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());
- }
+ UiUtil() {
+ }
- /** 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());
- }
+ /** 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());
+ }
- /** 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) {
- return -1;
- }
+ /** 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());
+ }
- final TypedValue value = new TypedValue();
- final int id = context.getResources().getIdentifier(attr, "attr", context.getPackageName());
-
- if (id == 0) {
- // Not found
- return -1;
- }
-
- theme.resolveAttribute(id, value, true);
- return value.data;
+ /** 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) {
+ return -1;
}
- /** Modifies the alpha value of the given ARGB color **/
- static int setAlpha(int argb, float alpha) {
- if (alpha > 1.0f) {
- alpha = 1.0f;
- } else if (alpha <= 0.0f) {
- alpha = 0.0f;
- }
+ final TypedValue value = new TypedValue();
+ final int id = context.getResources().getIdentifier(attr, "attr", context.getPackageName());
- return ((int) ((argb >>> 24) * alpha) << 24) | (argb & 0x00FFFFFF);
+ if (id == 0) {
+ // Not found
+ return -1;
}
- /** Compatibility wrapper for getting a color resource value **/
- static int getColor(Context context, int id) {
- if (Build.VERSION.SDK_INT >= 23) {
- return context.getColor(id);
- }
+ theme.resolveAttribute(id, value, true);
+ return value.data;
+ }
- //noinspection deprecation
- return context.getResources().getColor(id);
+ /** Modifies the alpha value of the given ARGB color **/
+ static int setAlpha(int argb, float alpha) {
+ if (alpha > 1.0f) {
+ alpha = 1.0f;
+ } else if (alpha <= 0.0f) {
+ alpha = 0.0f;
}
+
+ return ((int) ((argb >>> 24) * alpha) << 24) | (argb & 0x00FFFFFF);
+ }
}
diff --git a/taptargetview/src/main/java/com/getkeepsafe/taptargetview/ViewTapTarget.java b/taptargetview/src/main/java/com/getkeepsafe/taptargetview/ViewTapTarget.java
new file mode 100644
index 0000000..045ae58
--- /dev/null
+++ b/taptargetview/src/main/java/com/getkeepsafe/taptargetview/ViewTapTarget.java
@@ -0,0 +1,59 @@
+/**
+ * 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.graphics.Bitmap;
+import android.graphics.Canvas;
+import android.graphics.Rect;
+import android.graphics.drawable.BitmapDrawable;
+import androidx.annotation.Nullable;
+import android.view.View;
+
+class ViewTapTarget extends TapTarget {
+ final View view;
+
+ ViewTapTarget(View view, CharSequence title, @Nullable CharSequence description) {
+ super(title, description);
+ if (view == null) {
+ throw new IllegalArgumentException("Given null view to target");
+ }
+ this.view = view;
+ }
+
+ @Override
+ public void onReady(final Runnable runnable) {
+ ViewUtil.onLaidOut(view, new Runnable() {
+ @Override
+ public void run() {
+ // Cache bounds
+ final int[] location = new int[2];
+ view.getLocationOnScreen(location);
+ bounds = new Rect(location[0], location[1],
+ 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);
+ final Canvas canvas = new Canvas(viewBitmap);
+ view.draw(canvas);
+ icon = new BitmapDrawable(view.getContext().getResources(), viewBitmap);
+ icon.setBounds(0, 0, icon.getIntrinsicWidth(), icon.getIntrinsicHeight());
+ }
+
+ runnable.run();
+ }
+ });
+ }
+}
diff --git a/taptargetview/src/main/java/com/getkeepsafe/taptargetview/ViewUtil.java b/taptargetview/src/main/java/com/getkeepsafe/taptargetview/ViewUtil.java
index 1fb1a9c..44c9a9a 100644
--- a/taptargetview/src/main/java/com/getkeepsafe/taptargetview/ViewUtil.java
+++ b/taptargetview/src/main/java/com/getkeepsafe/taptargetview/ViewUtil.java
@@ -1,12 +1,12 @@
/**
* 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.
@@ -16,49 +16,66 @@
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() {}
+ ViewUtil() {
+ }
- /** Returns whether or not the view has been laid out **/
- static boolean isLaidOut(View view) {
- if (Build.VERSION.SDK_INT >= 19) {
- return view.isLaidOut();
- }
+ /** 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;
+ }
- return view.getWidth() > 0 && view.getHeight() > 0;
+ /** 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;
}
- /** 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;
+ final ViewTreeObserver observer = view.getViewTreeObserver();
+ observer.addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() {
+ @Override
+ public void onGlobalLayout() {
+ final ViewTreeObserver trueObserver;
+
+ if (observer.isAlive()) {
+ trueObserver = observer;
+ } else {
+ trueObserver = view.getViewTreeObserver();
}
- final ViewTreeObserver observer = view.getViewTreeObserver();
- observer.addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() {
- @Override
- public void onGlobalLayout() {
- final ViewTreeObserver trueObserver;
+ removeOnGlobalLayoutListener(trueObserver, this);
- if (observer.isAlive()) {
- trueObserver = observer;
- } else {
- trueObserver = view.getViewTreeObserver();
- }
+ runnable.run();
+ }
+ });
+ }
- if (Build.VERSION.SDK_INT >= 16) {
- trueObserver.removeOnGlobalLayoutListener(this);
- } else {
- //noinspection deprecation
- trueObserver.removeGlobalOnLayoutListener(this);
- }
+ @SuppressWarnings("deprecation")
+ static void removeOnGlobalLayoutListener(ViewTreeObserver observer,
+ ViewTreeObserver.OnGlobalLayoutListener listener) {
+ if (Build.VERSION.SDK_INT >= 16) {
+ observer.removeOnGlobalLayoutListener(listener);
+ } else {
+ observer.removeGlobalOnLayoutListener(listener);
+ }
+ }
+
+ static void removeView(ViewManager parent, View child) {
+ if (parent == null || child == null) {
+ return;
+ }
- runnable.run();
- }
- });
+ 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
}
+ }
}