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 @@

-Video 1 -Screenshot 1 -Screenshot 2
+Video 1 +Screenshot 1 +Screenshot 2
TapTargetView

-[![Download](https://api.bintray.com/packages/keepsafesoftware/Android/TapTargetView/images/download.svg) ](https://bintray.com/keepsafesoftware/Android/TapTargetView/_latestVersion) +[![Maven Central](https://maven-badges.herokuapp.com/maven-central/com.getkeepsafe.taptargetview/taptargetview/badge.svg)](https://maven-badges.herokuapp.com/maven-central/com.getkeepsafe.taptargetview/taptargetview) +[![Release](https://img.shields.io/github/tag/KeepSafe/TapTargetView.svg?label=jitpack)](https://jitpack.io/#KeepSafe/TapTargetView) -An implementation of tap targets from [Google's Material Design guidelines on feature discovery](https://material.google.com/growth-communications/feature-discovery.html#feature-discovery-design). +An implementation of tap targets from [Google's Material Design guidelines on feature discovery](https://material.io/archive/guidelines/growth-communications/feature-discovery.html). - **Min SDK:** 15 + **Min SDK:** 14 + + [JavaDoc](https://javadoc.jitpack.io/com/github/KeepSafe/TapTargetView/latest/javadoc/) ## Installation -TapTargetView is distributed using [jcenter](https://bintray.com/keepsafesoftware/Android/ReLinker/view). +TapTargetView is distributed using [MavenCentral](https://search.maven.org/artifact/com.getkeepsafe.taptargetview/taptargetview). ```groovy repositories { - jcenter() + mavenCentral() } dependencies { - compile 'com.getkeepsafe.taptargetview:taptargetview:1.0.0' + implementation 'com.getkeepsafe.taptargetview:taptargetview:x.x.x' } ``` +If you wish, you may also use TapTargetView with [jitpack](https://jitpack.io/#KeepSafe/TapTargetView). +For snapshots, please follow the instructions [here](https://jitpack.io/#KeepSafe/TapTargetView/-SNAPSHOT). + ## Usage -TapTargetView utilizes a builder to configure how it looks and behaves. Here is the full list of options when using TapTargetView. The only required options are specifying a title, description and target. +### Simple usage ```java -new TapTargetView.Builder(Activity) // The activity that hosts this view - .title(@StringRes int) // Specify the title text - .title(String) - .description(@StringRes int) // Specify the description text - .description(String) - .listener(Listener) // Specify a listener that can listen for clicks and long clicks - .outerCircleColor(@ColorRes int) // Specify a color for the outer circle - .targetCircleColor(@ColorRes int) // Specify a color for the inner circle surrounding the target view - .textColor(@ColorRes int) // Specify a color for the text - .textTypeface(Typeface) // Specify a custom typeface to use for the text - .dimColor(@ColorRes int) // If set, will dim behind the view with 30% opacity of the given color - .tintTarget(boolean) // Whether to tint the target view's color - .drawShadow(boolean) // Whether to draw the drop shadow - .cancelable(boolean) // Whether tapping outside the outer circle dismisses the view - .showFor(targetView); +TapTargetView.showFor(this, // `this` is an Activity + TapTarget.forView(findViewById(R.id.target), "This is a target", "We have the best targets, believe me") + // All options below are optional + .outerCircleColor(R.color.red) // Specify a color for the outer circle + .outerCircleAlpha(0.96f) // Specify the alpha amount for the outer circle + .targetCircleColor(R.color.white) // Specify a color for the target circle + .titleTextSize(20) // Specify the size (in sp) of the title text + .titleTextColor(R.color.white) // Specify the color of the title text + .descriptionTextSize(10) // Specify the size (in sp) of the description text + .descriptionTextColor(R.color.red) // Specify the color of the description text + .textColor(R.color.blue) // Specify a color for both the title and description text + .textTypeface(Typeface.SANS_SERIF) // Specify a typeface for the text + .dimColor(R.color.black) // If set, will dim behind the view with 30% opacity of the given color + .drawShadow(true) // Whether to draw a drop shadow or not + .cancelable(false) // Whether tapping outside the outer circle dismisses the view + .tintTarget(true) // Whether to tint the target view's color + .transparentTarget(false) // Specify whether the target is transparent (displays the content underneath) + .icon(Drawable) // Specify a custom drawable to draw as the target + .targetRadius(60), // Specify the target radius (in dp) + new TapTargetView.Listener() { // The listener can listen for regular clicks, long clicks or cancels + @Override + public void onTargetClick(TapTargetView view) { + super.onTargetClick(view); // This call is optional + doSomething(); + } + }); ``` +You may also choose to target your own custom `Rect` with `TapTarget.forBounds(Rect, ...)` + +Additionally, each color can be specified via a `@ColorRes` or a `@ColorInt`. Functions that have the suffix `Int` take a `@ColorInt`. + +*Tip: When targeting a Toolbar item, be careful with Proguard and ensure you're keeping certain fields. See [#180](https://github.com/KeepSafe/TapTargetView/issues/180)* + +### Sequences + +You can easily create a sequence of tap targets with `TapTargetSequence`: + +```java +new TapTargetSequence(this) + .targets( + TapTarget.forView(findViewById(R.id.never), "Gonna"), + TapTarget.forView(findViewById(R.id.give), "You", "Up") + .dimColor(android.R.color.never) + .outerCircleColor(R.color.gonna) + .targetCircleColor(R.color.let) + .textColor(android.R.color.you), + TapTarget.forBounds(rickTarget, "Down", ":^)") + .cancelable(false) + .icon(rick)) + .listener(new TapTargetSequence.Listener() { + // This listener will tell us when interesting(tm) events happen in regards + // to the sequence + @Override + public void onSequenceFinish() { + // Yay + } + + @Override + public void onSequenceStep(TapTarget lastTarget, boolean targetClicked) { + // Perform action for the current target + } + + @Override + public void onSequenceCanceled(TapTarget lastTarget) { + // Boo + } + }); +``` + +A sequence is started via a call to `start()` on the `TapTargetSequence` instance + +For more examples of usage, please look at the included sample app. + +### Tutorials +- [raywenderlich.com](https://www.raywenderlich.com/5194-taptargetview-for-android-tutorial) + +## Third Party Bindings + +### React Native +Thanks to @prscX, you may now use this library with [React Native](https://github.com/facebook/react-native) via the module [here](https://github.com/prscX/react-native-taptargetview) + +### NativeScript +Thanks to @hamdiwanis, you may now use this library with [NativeScript](https://nativescript.org) via the plugin [here](https://github.com/hamdiwanis/nativescript-app-tour) + +### Xamarin +Thanks to @btripp, you may now use this library via a Xamarin Binding located [here](https://www.nuget.org/packages/Xamarin.TapTargetView). + ## License Copyright 2016 Keepsafe Software Inc. diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..eea7280 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,75 @@ +How To Release +============== + +Due to Maven Central's very particular requirements, the release process is a bit +elaborate and requires a good deal of local configuration. This guide should walk +you through it. It won't do anyone outside of KeepSafe any good, but the workflow +is representative of just about any project deploying via Sonatype. + +We currently deploy to Maven Central (via Sonatype's OSS Nexus instance). + +### Prerequisites + +1. A *published* GPG code-signing key +1. A Sonatype Nexus OSS account with permission to publish in com.getkeepsafe +1. Permission to push directly to https://github.com/KeepSafe/TapTargetView + +### Setup + +1. Add your GPG key to your github profile - this is required + for github to know that your commits and tags are "verified". +1. Configure your code-signing key in ~/.gradle/gradle.properties: + ```gradle + signing.keyId= + signing.password= + signing.secretKeyRingFile=/path/to/your/secring.gpg + ``` +1. Create a token for your Sonatype credentials. [Source](https://community.sonatype.com/t/401-content-access-is-protected-by-token-authentication-failure-while-performing-maven-release/12741/4) + ``` + 1. Go to https://oss.sonatype.org/ and login + 2. Go to profile + 3. Change the pulldown from “Summary” to “User Token” + 4. Click on “Access User Token” + ``` +1. Configure your Sonatype credentials in ~/.gradle/gradle.properties: + ```gradle + mavenCentralUsername= + mavenCentralPassword= + SONATYPE_STAGING_PROFILE=com.getkeepsafe + ``` +1. Configure git with your codesigning key; make sure it's the same as the one + you use to sign binaries (i.e. it's the same one you added to gradle.properties): + ```bash + # Do this for the TapTargetView repo only + git config user.email "your@email.com" + git config user.signingKey "your-key-id" + ``` +1. Add your GPG key to `~/.gnupg` + +### Pushing a build + +1. Edit gradle.properties, update the VERSION property for the new version release +1. Edit changelog, add relevant changes, note the date and new version (follow the existing pattern) +1. Add new `## [Unreleased]` header for next release +1. Verify that the everything works: + ```bash + ./gradlew clean check + ``` +1. Make a *signed* commit: + ```bash + git commit -S -m "Release version X.Y.Z" + ``` +1. Make a *signed* tag: + ```bash + git tag -s -a X.Y.Z + ``` +1. Publish to Release: + ```bash + ./gradlew :taptargetview:publishAndReleaseToMavenCentral --no-configuration-cache + ``` +1. Wait until that's done. It takes a while to publish and be available in [MavenCentral](https://repo.maven.apache.org/maven2/com/getkeepsafe/). Monitor until the latest published version is visible. +1. Hooray, we're in Maven Central now! +1. Push all of our work to Github to make it official. Check previous [releases](https://github.com/KeepSafe/TapTargetView/releases) and edit tag release changes: + ```bash + git push --tags origin master + ``` diff --git a/app/.gitignore b/app/.gitignore deleted file mode 100644 index 796b96d..0000000 --- a/app/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/build diff --git a/app/build.gradle b/app/build.gradle index e3ef132..3b566f1 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,16 +1,19 @@ -apply plugin: 'com.android.application' +plugins { + alias(libs.plugins.android.application) +} android { - compileSdkVersion 24 - buildToolsVersion "24.0.2" + namespace 'com.getkeepsafe.taptargetviewsample' + compileSdk libs.versions.compileSdk.get().toInteger() defaultConfig { - applicationId "com.getkeepsafe.taptargetviewsample" - minSdkVersion 21 - targetSdkVersion 24 + applicationId 'com.getkeepsafe.taptargetviewsample' + minSdkVersion libs.versions.minSdk.get().toInteger() + targetSdkVersion libs.versions.compileSdk.get().toInteger() versionCode 1 - versionName "1.0" + versionName '1.0' } + buildTypes { release { minifyEnabled false @@ -20,7 +23,8 @@ android { } dependencies { - compile project(':taptargetview') - compile 'com.android.support:appcompat-v7:24.2.0' - compile 'com.android.support:design:24.2.0' + implementation project(':taptargetview') + implementation libs.androidx.appcompat + implementation libs.material + implementation libs.stetho } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 45cda12..fbda0a4 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,20 +1,23 @@ + package="com.getkeepsafe.taptargetviewsample"> - + - + - + - \ No newline at end of file + diff --git a/app/src/main/java/com/getkeepsafe/taptargetviewsample/MainActivity.java b/app/src/main/java/com/getkeepsafe/taptargetviewsample/MainActivity.java index b02313f..fad5496 100644 --- a/app/src/main/java/com/getkeepsafe/taptargetviewsample/MainActivity.java +++ b/app/src/main/java/com/getkeepsafe/taptargetviewsample/MainActivity.java @@ -1,64 +1,131 @@ package com.getkeepsafe.taptargetviewsample; +import android.content.DialogInterface; +import android.graphics.Rect; +import android.graphics.Typeface; +import android.graphics.drawable.Drawable; import android.os.Bundle; -import android.support.v7.app.AppCompatActivity; + +import androidx.core.content.ContextCompat; +import androidx.appcompat.app.AlertDialog; +import androidx.appcompat.app.AppCompatActivity; +import androidx.appcompat.widget.Toolbar; +import androidx.core.view.WindowCompat; + +import android.text.SpannableString; +import android.text.style.StyleSpan; +import android.text.style.UnderlineSpan; +import android.util.Log; +import android.view.Display; import android.widget.TextView; +import android.widget.Toast; +import com.getkeepsafe.taptargetview.TapTarget; +import com.getkeepsafe.taptargetview.TapTargetSequence; import com.getkeepsafe.taptargetview.TapTargetView; public class MainActivity extends AppCompatActivity { - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - setContentView(R.layout.activity_main); - new TapTargetView.Builder(this) - .title("Hello, world!") - .description("This is the sample app for TapTargetView") - .tintTarget(false) - .listener(new TapTargetView.Listener() { - @Override - public void onTargetClick(TapTargetView view) { - super.onTargetClick(view); - educateBackButton(); - } + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + WindowCompat.setDecorFitsSystemWindows(getWindow(), false); + setContentView(R.layout.activity_main); - @Override - public void onTargetCancel(TapTargetView view) { - super.onTargetCancel(view); - educateBackButton(); - } - }).showFor(findViewById(R.id.fab)); - } + final Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar); + toolbar.inflateMenu(R.menu.menu_main); + toolbar.setNavigationIcon(ContextCompat.getDrawable(this, R.drawable.ic_arrow_back_white_24dp)); - private void educateBackButton() { - new TapTargetView.Builder(MainActivity.this) - .title("This is the back button") - .description("It allows you to go back, sometimes.") - .listener(new TapTargetView.Listener() { - @Override - public void onTargetClick(TapTargetView view) { - super.onTargetClick(view); - educateSearchButton(); - } - }) - .showFor(findViewById(R.id.back)); - } + // We load a drawable and create a location to show a tap target here + // We need the display to get the width and height at this point in time + final Display display = getWindowManager().getDefaultDisplay(); + // Load our little droid guy + final Drawable droid = ContextCompat.getDrawable(this, R.drawable.ic_android_black_24dp); + // Tell our droid buddy where we want him to appear + final Rect droidTarget = new Rect(0, 0, droid.getIntrinsicWidth() * 2, droid.getIntrinsicHeight() * 2); + // Using deprecated methods makes you look way cool + droidTarget.offset(display.getWidth() / 2, display.getHeight() / 2); - private void educateSearchButton() { - new TapTargetView.Builder(MainActivity.this) - .title("This is a search icon") - .description("As you can see, it has gotten pretty dark around here...") + final SpannableString sassyDesc = new SpannableString("It allows you to go back, sometimes"); + sassyDesc.setSpan(new StyleSpan(Typeface.ITALIC), sassyDesc.length() - "sometimes".length(), sassyDesc.length(), 0); + + // We have a sequence of targets, so lets build it! + final TapTargetSequence sequence = new TapTargetSequence(this) + .targets( + // This tap target will target the back button, we just need to pass its containing toolbar + TapTarget.forToolbarNavigationIcon(toolbar, "This is the back button", sassyDesc).id(1), + // Likewise, this tap target will target the search button + TapTarget.forToolbarMenuItem(toolbar, R.id.search, "This is a search icon", "As you can see, it has gotten pretty dark around here...") .dimColor(android.R.color.black) .outerCircleColor(R.color.colorAccent) .targetCircleColor(android.R.color.black) + .transparentTarget(true) .textColor(android.R.color.black) - .listener(new TapTargetView.Listener() { - @Override - public void onTargetClick(TapTargetView view) { - super.onTargetClick(view); - ((TextView) findViewById(R.id.educated)).setText("Congratulations! You're educated now!"); - } - }) - .showFor(findViewById(R.id.search)); - } + .id(2), + // You can also target the overflow button in your toolbar + TapTarget.forToolbarOverflow(toolbar, "This will show more options", "But they're not useful :(").id(3), + // This tap target will target our droid buddy at the given target rect + TapTarget.forBounds(droidTarget, "Oh look!", "You can point to any part of the screen. You also can't cancel this one!") + .cancelable(false) + .icon(droid) + .id(4) + ) + .listener(new TapTargetSequence.Listener() { + // This listener will tell us when interesting(tm) events happen in regards + // to the sequence + @Override + public void onSequenceFinish() { + ((TextView) findViewById(R.id.educated)).setText("Congratulations! You're educated now!"); + } + + @Override + public void onSequenceStep(TapTarget lastTarget, boolean targetClicked) { + Log.d("TapTargetView", "Clicked on " + lastTarget.id()); + } + + @Override + public void onSequenceCanceled(TapTarget lastTarget) { + final AlertDialog dialog = new AlertDialog.Builder(MainActivity.this) + .setTitle("Uh oh") + .setMessage("You canceled the sequence") + .setPositiveButton("Oops", null).show(); + TapTargetView.showFor(dialog, + TapTarget.forView(dialog.getButton(DialogInterface.BUTTON_POSITIVE), "Uh oh!", "You canceled the sequence at step " + lastTarget.id()) + .cancelable(false) + .tintTarget(false), new TapTargetView.Listener() { + @Override + public void onTargetClick(TapTargetView view) { + super.onTargetClick(view); + dialog.dismiss(); + } + }); + } + }); + + // You don't always need a sequence, and for that there's a single time tap target + final SpannableString spannedDesc = new SpannableString("This is the sample app for TapTargetView"); + spannedDesc.setSpan(new UnderlineSpan(), spannedDesc.length() - "TapTargetView".length(), spannedDesc.length(), 0); + TapTargetView.showFor(this, TapTarget.forView(findViewById(R.id.fab), "Hello, world!", spannedDesc) + .cancelable(false) + .drawShadow(true) + .titleTextDimen(R.dimen.title_text_size) + .tintTarget(false), new TapTargetView.Listener() { + @Override + public void onTargetClick(TapTargetView view) { + super.onTargetClick(view); + // .. which evidently starts the sequence we defined earlier + sequence.start(); + } + + @Override + public void onOuterCircleClick(TapTargetView view) { + super.onOuterCircleClick(view); + Toast.makeText(view.getContext(), "You clicked the outer circle!", Toast.LENGTH_SHORT).show(); + } + + @Override + public void onTargetDismissed(TapTargetView view, boolean userInitiated) { + Log.d("TapTargetViewSample", "You dismissed me :("); + } + }); + } } diff --git a/app/src/main/java/com/getkeepsafe/taptargetviewsample/SampleApplication.java b/app/src/main/java/com/getkeepsafe/taptargetviewsample/SampleApplication.java new file mode 100644 index 0000000..8df680c --- /dev/null +++ b/app/src/main/java/com/getkeepsafe/taptargetviewsample/SampleApplication.java @@ -0,0 +1,13 @@ +package com.getkeepsafe.taptargetviewsample; + +import android.app.Application; + +import com.facebook.stetho.Stetho; + +public class SampleApplication extends Application { + @Override + public void onCreate() { + super.onCreate(); + Stetho.initializeWithDefaults(this); + } +} diff --git a/app/src/main/res/drawable/ic_android_black_24dp.xml b/app/src/main/res/drawable/ic_android_black_24dp.xml new file mode 100644 index 0000000..401cbf6 --- /dev/null +++ b/app/src/main/res/drawable/ic_android_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_arrow_back_black_24dp.xml b/app/src/main/res/drawable/ic_arrow_back_white_24dp.xml similarity index 89% rename from app/src/main/res/drawable/ic_arrow_back_black_24dp.xml rename to app/src/main/res/drawable/ic_arrow_back_white_24dp.xml index beafea3..718b33e 100644 --- a/app/src/main/res/drawable/ic_arrow_back_black_24dp.xml +++ b/app/src/main/res/drawable/ic_arrow_back_white_24dp.xml @@ -4,6 +4,6 @@ android:viewportWidth="24.0" android:viewportHeight="24.0"> diff --git a/app/src/main/res/drawable/ic_search_black_24dp.xml b/app/src/main/res/drawable/ic_search_white_24dp.xml similarity index 92% rename from app/src/main/res/drawable/ic_search_black_24dp.xml rename to app/src/main/res/drawable/ic_search_white_24dp.xml index affc7ba..5fc46e0 100644 --- a/app/src/main/res/drawable/ic_search_black_24dp.xml +++ b/app/src/main/res/drawable/ic_search_white_24dp.xml @@ -4,6 +4,6 @@ android:viewportWidth="24.0" android:viewportHeight="24.0"> diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index bf0acf2..5c20522 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -1,6 +1,5 @@ - - - - - - - - - - - - + android:layout_height="wrap_content" + android:background="@android:color/transparent" + android:fitsSystemWindows="true" + android:theme="@style/Theme.AppCompat" /> + android:padding="16dp"> + android:theme="@style/Theme.AppCompat" /> + android:theme="@style/Theme.AppCompat" /> - + android:layout_marginRight="16dp" + android:layout_marginBottom="50dp" + android:src="@drawable/ic_directions_car_black_24dp" /> @@ -87,6 +60,6 @@ style="@style/TextAppearance.AppCompat.Display1" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:padding="16dp"/> + android:padding="16dp" /> diff --git a/app/src/main/res/menu/menu_main.xml b/app/src/main/res/menu/menu_main.xml new file mode 100644 index 0000000..1699a95 --- /dev/null +++ b/app/src/main/res/menu/menu_main.xml @@ -0,0 +1,11 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values-v21/styles.xml b/app/src/main/res/values-v21/styles.xml new file mode 100644 index 0000000..8bdcfc4 --- /dev/null +++ b/app/src/main/res/values-v21/styles.xml @@ -0,0 +1,13 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index 47c8224..9d4fa70 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -2,4 +2,5 @@ 16dp 16dp + 20dp diff --git a/art/screenshot1.png b/art/screenshot1.png deleted file mode 100644 index 631c035..0000000 Binary files a/art/screenshot1.png and /dev/null differ diff --git a/art/screenshot2.png b/art/screenshot2.png deleted file mode 100644 index 300e2a2..0000000 Binary files a/art/screenshot2.png and /dev/null differ diff --git a/art/video.gif b/art/video.gif deleted file mode 100644 index 1210401..0000000 Binary files a/art/video.gif and /dev/null differ diff --git a/build.gradle b/build.gradle index 3e734c3..7ce70d3 100644 --- a/build.gradle +++ b/build.gradle @@ -1,20 +1,5 @@ -buildscript { - repositories { - jcenter() - } - dependencies { - classpath 'com.android.tools.build:gradle:2.1.3' - classpath 'com.github.dcendents:android-maven-gradle-plugin:1.5' - classpath 'com.jfrog.bintray.gradle:gradle-bintray-plugin:1.6' - } -} - -allprojects { - repositories { - jcenter() - } -} - -task clean(type: Delete) { - delete rootProject.buildDir -} +plugins { + alias(libs.plugins.android.application) apply false + alias(libs.plugins.android.library) apply false + alias(libs.plugins.mavenpublish) apply false +} \ No newline at end of file diff --git a/gradle.properties b/gradle.properties index 1d3591c..9376818 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,18 +1,25 @@ -# Project-wide Gradle settings. +android.enableJetifier=true +android.useAndroidX=true -# IDE (e.g. Android Studio) users: -# Gradle settings configured through the IDE *will override* -# any settings specified in this file. +GROUP=com.getkeepsafe.taptargetview +VERSION_NAME=1.15.0 +POM_ARTIFACT_ID=taptargetview -# For more details on how to configure your build environment visit -# http://www.gradle.org/docs/current/userguide/build_environment.html +POM_NAME=TapTargetView +POM_PACKAGING=aar -# Specifies the JVM arguments used for the daemon process. -# The setting is particularly useful for tweaking memory settings. -# Default value: -Xmx10248m -XX:MaxPermSize=256m -# org.gradle.jvmargs=-Xmx2048m -XX:MaxPermSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 +POM_DESCRIPTION=An implementation of tap targets from the Material Design guidelines for feature discovery +POM_INCEPTION_YEAR=2016 -# When configured, Gradle will run in incubating parallel mode. -# This option should only be used with decoupled projects. More details, visit -# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects -# org.gradle.parallel=true \ No newline at end of file +POM_URL=https://github.com/KeepSafe/TapTargetView +POM_SCM_URL=https://github.com/KeepSafe/TapTargetView +POM_SCM_CONNECTION=scm:git:git://github.com/KeepSafe/TapTargetView.git +POM_SCM_DEV_CONNECTION=scm:git:ssh://git@github.com:KeepSafe/TapTargetView.git + +POM_LICENCE_NAME=The Apache Software License, Version 2.0 +POM_LICENCE_URL=http://www.apache.org/licenses/LICENSE-2.0.txt +POM_LICENCE_DIST=repo + +POM_DEVELOPER_ID=keepsafe +POM_DEVELOPER_NAME=KeepSafe Software, Inc. +POM_DEVELOPER_URL=https://github.com/KeepSafe/ \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..33ae78b --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,23 @@ +[versions] +compileSdk = "30" +minSdk = "14" + +androidGradlePlugin = "8.5.1" +androidxAnnotation = "1.2.0" +androidxAppcompat = "1.3.0" +androidxCore = "1.6.0" +material = "1.4.0" +mavenpublish = "0.29.0" +stetho = "1.5.0" + +[libraries] +androidx-annotation = { group = "androidx.annotation", name = "annotation", version.ref = "androidxAnnotation" } +androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "androidxAppcompat" } +androidx-core = { group = "androidx.core", name = "core", version.ref = "androidxCore" } +material = { group = "com.google.android.material", name = "material", version.ref = "material" } +stetho = { group = "com.facebook.stetho", name = "stetho", version.ref = "stetho" } + +[plugins] +android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" } +android-library = { id = "com.android.library", version.ref = "androidGradlePlugin" } +mavenpublish = { id = "com.vanniktech.maven.publish", version.ref = "mavenpublish" } \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 13372ae..28861d2 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index cdd1928..2c2a6be 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ -#Sun Sep 11 6:24:34 PST 2016 +#Mon Jul 15 20:56:48 EDT 2024 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-2.14.1-all.zip diff --git a/gradlew b/gradlew index 9d82f78..cccdd3d 100755 --- a/gradlew +++ b/gradlew @@ -1,4 +1,4 @@ -#!/usr/bin/env bash +#!/usr/bin/env sh ############################################################################## ## @@ -6,20 +6,38 @@ ## ############################################################################## -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS="" +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null APP_NAME="Gradle" APP_BASE_NAME=`basename "$0"` +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS="" + # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD="maximum" -warn ( ) { +warn () { echo "$*" } -die ( ) { +die () { echo echo "$*" echo @@ -30,6 +48,7 @@ die ( ) { cygwin=false msys=false darwin=false +nonstop=false case "`uname`" in CYGWIN* ) cygwin=true @@ -40,26 +59,11 @@ case "`uname`" in MINGW* ) msys=true ;; + NONSTOP* ) + nonstop=true + ;; esac -# Attempt to set APP_HOME -# Resolve links: $0 may be a link -PRG="$0" -# Need this for relative symlinks. -while [ -h "$PRG" ] ; do - ls=`ls -ld "$PRG"` - link=`expr "$ls" : '.*-> \(.*\)$'` - if expr "$link" : '/.*' > /dev/null; then - PRG="$link" - else - PRG=`dirname "$PRG"`"/$link" - fi -done -SAVED="`pwd`" -cd "`dirname \"$PRG\"`/" >/dev/null -APP_HOME="`pwd -P`" -cd "$SAVED" >/dev/null - CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar # Determine the Java command to use to start the JVM. @@ -85,7 +89,7 @@ location of your Java installation." fi # Increase the maximum file descriptors if we can. -if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then MAX_FD_LIMIT=`ulimit -H -n` if [ $? -eq 0 ] ; then if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then @@ -150,11 +154,19 @@ if $cygwin ; then esac fi -# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules -function splitJvmOpts() { - JVM_OPTS=("$@") +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " } -eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS -JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME" +APP_ARGS=$(save "$@") + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong +if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then + cd "$(dirname "$0")" +fi -exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@" +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat index aec9973..e95643d 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -8,14 +8,14 @@ @rem Set local scope for the variables with windows NT shell if "%OS%"=="Windows_NT" setlocal -@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -set DEFAULT_JVM_OPTS= - set DIRNAME=%~dp0 if "%DIRNAME%" == "" set DIRNAME=. set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS= + @rem Find java.exe if defined JAVA_HOME goto findJavaFromJavaHome @@ -46,10 +46,9 @@ echo location of your Java installation. goto fail :init -@rem Get command-line arguments, handling Windowz variants +@rem Get command-line arguments, handling Windows variants if not "%OS%" == "Windows_NT" goto win9xME_args -if "%@eval[2+2]" == "4" goto 4NT_args :win9xME_args @rem Slurp the command line arguments. @@ -60,11 +59,6 @@ set _SKIP=2 if "x%~1" == "x" goto execute set CMD_LINE_ARGS=%* -goto execute - -:4NT_args -@rem Get arguments from the 4NT Shell from JP Software -set CMD_LINE_ARGS=%$ :execute @rem Setup the command line diff --git a/settings.gradle b/settings.gradle index b05efe7..146d176 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1 +1,17 @@ +pluginManagement { + repositories { + gradlePluginPortal() + google() + mavenCentral() + } +} + +dependencyResolutionManagement { + repositoriesMode = RepositoriesMode.FAIL_ON_PROJECT_REPOS + repositories { + google() + mavenCentral() + } +} + include ':app', ':taptargetview' diff --git a/taptargetview/.gitignore b/taptargetview/.gitignore deleted file mode 100644 index 796b96d..0000000 --- a/taptargetview/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/build diff --git a/taptargetview/build.gradle b/taptargetview/build.gradle index 3cd6961..8637cb0 100644 --- a/taptargetview/build.gradle +++ b/taptargetview/build.gradle @@ -1,100 +1,39 @@ -apply plugin: 'com.android.library' -apply plugin: 'com.github.dcendents.android-maven' -apply plugin: 'com.jfrog.bintray' - -def libname = 'TapTargetView' -group = 'com.getkeepsafe.taptargetview' -version = '1.0.1' -description = 'An implementation of tap targets from the Material Design guidelines for feature discovery' +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.mavenpublish) +} android { - compileSdkVersion 24 - buildToolsVersion "24.0.2" + namespace 'com.getkeepsafe.taptargetview' + compileSdk libs.versions.compileSdk.get().toInteger() defaultConfig { - minSdkVersion 15 - versionCode 1 - versionName project.version - } - buildTypes { - release { - minifyEnabled false - } + minSdkVersion libs.versions.minSdk.get().toInteger() } } dependencies { - compile 'com.android.support:support-annotations:24.2.0' -} - -install { - repositories.mavenInstaller { - pom.project { - name libname - description project.description - url "https://github.com/KeepSafe/$libname" - inceptionYear 2016 - - packaging 'aar' - groupId project.group - artifactId 'taptargetview' - version project.version - - licenses { - license { - name 'The Apache Software License, Version 2.0' - url 'http://www.apache.org/licenses/LICENSE-2.0.txt' - distribution 'repo' - } - } - scm { - connection "https://github.com/KeepSafe/${libname}.git" - url "https://github.com/KeepSafe/$libname" - } - developers { - developer { - name 'Keepsafe' - } - } - } - } -} - -bintray { - user = project.hasProperty('bintray.user') ? project.property('bintray.user') : '' - key = project.hasProperty('bintray.apikey') ? project.property('bintray.apikey') : '' - configurations = ['archives'] - pkg { - repo = 'Android' - name = libname - userOrg = 'keepsafesoftware' - licenses = ['Apache-2.0'] - vcsUrl = "https://github.com/KeepSafe/${libname}.git" - - version { - name = project.version - desc = project.description - released = new Date() - vcsTag = project.version - } - } + api libs.androidx.annotation + api libs.androidx.appcompat + implementation libs.androidx.core } // build a jar with source files -task sourcesJar(type: Jar) { +tasks.register('sourcesJar', Jar) { from android.sourceSets.main.java.srcDirs - classifier = 'sources' + archiveClassifier = 'sources' } -task javadoc(type: Javadoc) { - failOnError false +tasks.register('javadoc', Javadoc) { + failOnError false source = android.sourceSets.main.java.sourceFiles classpath += project.files(android.getBootClasspath().join(File.pathSeparator)) } // build a jar with javadoc -task javadocJar(type: Jar, dependsOn: javadoc) { - classifier = 'javadoc' +tasks.register('javadocJar', Jar) { + dependsOn javadoc + archiveClassifier = 'javadoc' from javadoc.destinationDir } @@ -102,3 +41,12 @@ artifacts { archives sourcesJar archives javadocJar } + +import com.vanniktech.maven.publish.AndroidSingleVariantLibrary +import com.vanniktech.maven.publish.SonatypeHost + +mavenPublishing { + configure(new AndroidSingleVariantLibrary("release", true, true)) + publishToMavenCentral(SonatypeHost.DEFAULT, true) + signAllPublications() +} \ No newline at end of file diff --git a/taptargetview/src/main/java/com/getkeepsafe/taptargetview/FloatValueAnimatorBuilder.java b/taptargetview/src/main/java/com/getkeepsafe/taptargetview/FloatValueAnimatorBuilder.java index 4b2d2ac..6389158 100644 --- a/taptargetview/src/main/java/com/getkeepsafe/taptargetview/FloatValueAnimatorBuilder.java +++ b/taptargetview/src/main/java/com/getkeepsafe/taptargetview/FloatValueAnimatorBuilder.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,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 targets; + private boolean active; + + @Nullable + private TapTargetView currentView; + + Listener listener; + boolean considerOuterCircleCanceled; + boolean continueOnCancel; + + public interface Listener { + /** Called when there are no more tap targets to display */ + void onSequenceFinish(); + + /** + * Called when moving onto the next tap target. + * @param lastTarget The last displayed target + * @param targetClicked Whether the last displayed target was clicked (this will always be true + * unless you have set {@link #continueOnCancel(boolean)} and the user + * clicks outside of the target + */ + void onSequenceStep(TapTarget lastTarget, boolean targetClicked); + + /** + * Called when the user taps outside of the current target, the target is cancelable, and + * {@link #continueOnCancel(boolean)} is not set. + * @param lastTarget The last displayed target + */ + void onSequenceCanceled(TapTarget lastTarget); + } + + public TapTargetSequence(Activity activity) { + if (activity == null) throw new IllegalArgumentException("Activity is null"); + this.activity = activity; + this.dialog = null; + this.targets = new LinkedList<>(); + } + + public TapTargetSequence(Dialog dialog) { + if (dialog == null) throw new IllegalArgumentException("Given null Dialog"); + this.dialog = dialog; + this.activity = null; + this.targets = new LinkedList<>(); + } + + /** Adds the given targets, in order, to the pending queue of {@link TapTarget}s */ + public TapTargetSequence targets(List targets) { + this.targets.addAll(targets); + return this; + } + + /** Adds the given targets, in order, to the pending queue of {@link TapTarget}s */ + public TapTargetSequence targets(TapTarget... targets) { + Collections.addAll(this.targets, targets); + return this; + } + + /** Adds the given target to the pending queue of {@link TapTarget}s */ + public TapTargetSequence target(TapTarget target) { + this.targets.add(target); + return this; + } + + /** Whether or not to continue the sequence when a {@link TapTarget} is canceled **/ + public TapTargetSequence continueOnCancel(boolean status) { + this.continueOnCancel = status; + return this; + } + + /** Whether or not to consider taps on the outer circle as a cancellation **/ + public TapTargetSequence considerOuterCircleCanceled(boolean status) { + this.considerOuterCircleCanceled = status; + return this; + } + + /** Specify the listener for this sequence **/ + public TapTargetSequence listener(Listener listener) { + this.listener = listener; + return this; + } + + /** Immediately starts the sequence and displays the first target from the queue **/ + @UiThread + public void start() { + if (targets.isEmpty() || active) { + return; + } + + active = true; + showNext(); + } + + /** Immediately starts the sequence from the given targetId's position in the queue */ + public void startWith(int targetId) { + if (active) { + return; + } + + while (targets.peek() != null && targets.peek().id() != targetId) { + targets.poll(); + } + + TapTarget peekedTarget = targets.peek(); + if (peekedTarget == null || peekedTarget.id() != targetId) { + throw new IllegalStateException("Given target " + targetId + " not in sequence"); + } + + start(); + } + + /** Immediately starts the sequence at the specified zero-based index in the queue */ + public void startAt(int index) { + if (active) { + return; + } + + if (index < 0 || index >= targets.size()) { + throw new IllegalArgumentException("Given invalid index " + index); + } + + final int expectedSize = targets.size() - index; + while (targets.peek() != null && targets.size() != expectedSize) { + targets.poll(); + } + + if (targets.size() != expectedSize) { + throw new IllegalStateException("Given index " + index + " not in sequence"); + } + + start(); + } + + /** + * Cancels the sequence, if the current target is cancelable. + * When the sequence is canceled, the current target is dismissed and the remaining targets are + * removed from the sequence. + * @return whether the sequence was canceled or not + */ + @UiThread + public boolean cancel() { + if (!active || currentView == null || !currentView.cancelable) { + return false; + } + currentView.dismiss(false); + active = false; + targets.clear(); + if (listener != null) { + listener.onSequenceCanceled(currentView.target); + } + return true; + } + + void showNext() { + try { + TapTarget tapTarget = targets.remove(); + if (activity != null) { + currentView = TapTargetView.showFor(activity, tapTarget, tapTargetListener); + } else { + currentView = TapTargetView.showFor(dialog, tapTarget, tapTargetListener); + } + } catch (NoSuchElementException e) { + currentView = null; + // No more targets + if (listener != null) { + listener.onSequenceFinish(); + } + } + } + + private final TapTargetView.Listener tapTargetListener = new TapTargetView.Listener() { + @Override + public void onTargetClick(TapTargetView view) { + super.onTargetClick(view); + if (listener != null) { + listener.onSequenceStep(view.target, true); + } + showNext(); + } + + @Override + public void onOuterCircleClick(TapTargetView view) { + if (considerOuterCircleCanceled) { + onTargetCancel(view); + } + } + + @Override + public void onTargetCancel(TapTargetView view) { + super.onTargetCancel(view); + if (continueOnCancel) { + if (listener != null) { + listener.onSequenceStep(view.target, false); + } + showNext(); + } else { + if (listener != null) { + listener.onSequenceCanceled(view.target); + } + } + } + }; +} diff --git a/taptargetview/src/main/java/com/getkeepsafe/taptargetview/TapTargetView.java b/taptargetview/src/main/java/com/getkeepsafe/taptargetview/TapTargetView.java index d0acb84..a93d57a 100644 --- a/taptargetview/src/main/java/com/getkeepsafe/taptargetview/TapTargetView.java +++ b/taptargetview/src/main/java/com/getkeepsafe/taptargetview/TapTargetView.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. @@ -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 possibleViews = new ArrayList<>(1); + toolbar.findViewsWithText(possibleViews, sentinel, View.FIND_VIEWS_WITH_CONTENT_DESCRIPTION); + + if (!hadContentDescription) { + toolbar.setNavigationContentDescription(null); + } + + if (possibleViews.size() > 0) { + return possibleViews.get(0); + } + + // If that doesn't work, we try to grab it via matching its drawable + final Drawable navigationIcon = toolbar.getNavigationIcon(); + if (navigationIcon == null) { + throw new IllegalStateException("Toolbar does not have a navigation view set!"); + } + + final int size = toolbar.getChildCount(); + for (int i = 0; i < size; ++i) { + final View child = toolbar.getChildAt(i); + if (child instanceof ImageButton) { + final Drawable childDrawable = ((ImageButton) child).getDrawable(); + if (childDrawable == navigationIcon) { + return child; + } + } + } + + throw new IllegalStateException("Could not find navigation view for Toolbar!"); + } + + private static View findOverflowView(Object instance) { + final ToolbarProxy toolbar = proxyOf(instance); + + // First we try to find the overflow menu view via drawable matching + final Drawable overflowDrawable = toolbar.getOverflowIcon(); + if (overflowDrawable != null) { + final Stack parents = new Stack<>(); + parents.push((ViewGroup) toolbar.internalToolbar()); + while (!parents.empty()) { + ViewGroup parent = parents.pop(); + final int size = parent.getChildCount(); + for (int i = 0; i < size; ++i) { + final View child = parent.getChildAt(i); + if (child instanceof ViewGroup) { + parents.push((ViewGroup) child); + continue; + } + if (child instanceof ImageView) { + final Drawable childDrawable = ((ImageView) child).getDrawable(); + if (childDrawable == overflowDrawable) { + return child; + } + } + } + } + } + + // If that doesn't work, we fall-back to our last resort solution: Reflection + // Toolbars contain an "ActionMenuView" which in turn contains an "ActionMenuPresenter". + // The "ActionMenuPresenter" then holds a reference to an "OverflowMenuButton" which is the + // desired target + try { + final Object actionMenuView = ReflectUtil.getPrivateField(toolbar.internalToolbar(), "mMenuView"); + final Object actionMenuPresenter = ReflectUtil.getPrivateField(actionMenuView, "mPresenter"); + return (View) ReflectUtil.getPrivateField(actionMenuPresenter, "mOverflowButton"); + } catch (NoSuchFieldException e) { + throw new IllegalStateException("Could not find overflow view for Toolbar!", e); + } catch (IllegalAccessException e) { + throw new IllegalStateException("Unable to access overflow view for Toolbar!", e); + } + } + + private interface ToolbarProxy { + CharSequence getNavigationContentDescription(); + + void setNavigationContentDescription(CharSequence description); + + void findViewsWithText(ArrayList out, CharSequence toFind, int flags); + + Drawable getNavigationIcon(); + + @Nullable + Drawable getOverflowIcon(); + + int getChildCount(); + + View getChildAt(int position); + + Object internalToolbar(); + } + + private static class SupportToolbarProxy implements ToolbarProxy { + private final Toolbar toolbar; + + SupportToolbarProxy(Toolbar toolbar) { + this.toolbar = toolbar; + } + + @Override + public CharSequence getNavigationContentDescription() { + return toolbar.getNavigationContentDescription(); + } + + @Override + public void setNavigationContentDescription(CharSequence description) { + toolbar.setNavigationContentDescription(description); + } + + @Override + public void findViewsWithText(ArrayList out, CharSequence toFind, int flags) { + toolbar.findViewsWithText(out, toFind, flags); + } + + @Override + public Drawable getNavigationIcon() { + return toolbar.getNavigationIcon(); + } + + @Override + public Drawable getOverflowIcon() { + return toolbar.getOverflowIcon(); + } + + @Override + public int getChildCount() { + return toolbar.getChildCount(); + } + + @Override + public View getChildAt(int position) { + return toolbar.getChildAt(position); + } + + @Override + public Object internalToolbar() { + return toolbar; + } + } + + @TargetApi(Build.VERSION_CODES.LOLLIPOP) + private static class StandardToolbarProxy implements ToolbarProxy { + private final android.widget.Toolbar toolbar; + + StandardToolbarProxy(android.widget.Toolbar toolbar) { + this.toolbar = toolbar; + } + + @Override + public CharSequence getNavigationContentDescription() { + return toolbar.getNavigationContentDescription(); + } + + @Override + public void setNavigationContentDescription(CharSequence description) { + toolbar.setNavigationContentDescription(description); + } + + @Override + public void findViewsWithText(ArrayList out, CharSequence toFind, int flags) { + toolbar.findViewsWithText(out, toFind, flags); + } + + @Override + public Drawable getNavigationIcon() { + return toolbar.getNavigationIcon(); + } + + @Nullable + @Override + public Drawable getOverflowIcon() { + if (Build.VERSION.SDK_INT >= 23) { + return toolbar.getOverflowIcon(); + } + + return null; + } + + @Override + public int getChildCount() { + return toolbar.getChildCount(); + } + + @Override + public View getChildAt(int position) { + return toolbar.getChildAt(position); + } + + @Override + public Object internalToolbar() { + return toolbar; + } + } +} diff --git a/taptargetview/src/main/java/com/getkeepsafe/taptargetview/UiUtil.java b/taptargetview/src/main/java/com/getkeepsafe/taptargetview/UiUtil.java index db7590f..b34ec09 100644 --- a/taptargetview/src/main/java/com/getkeepsafe/taptargetview/UiUtil.java +++ b/taptargetview/src/main/java/com/getkeepsafe/taptargetview/UiUtil.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. @@ -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 } + } }