diff --git a/README.md b/README.md index ec9eec25..d676719c 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,7 @@ Tooltip style can be customized in your style object: + diff --git a/app/build.gradle b/app/build.gradle index 7aa8daa4..797c534e 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,18 +1,19 @@ apply plugin: 'com.android.application' +apply plugin: 'kotlin-android' +apply plugin: 'kotlin-android-extensions' android { compileSdkVersion ANDROID_BUILD_SDK_VERSION as int - buildToolsVersion ANDROID_BUILD_TOOLS_VERSION defaultConfig { - minSdkVersion 14 + minSdkVersion 16 targetSdkVersion ANDROID_BUILD_TARGET_SDK_VERSION as int versionCode 1 versionName VERSION_NAME - jackOptions { - enabled false - } +// jackOptions { +// enabled false +// } } buildTypes { release { @@ -22,8 +23,8 @@ android { } compileOptions { - sourceCompatibility JavaVersion.VERSION_1_7 - targetCompatibility JavaVersion.VERSION_1_7 + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 } lintOptions { @@ -38,12 +39,15 @@ android { } dependencies { - compile fileTree(dir: 'libs', include: ['*.jar']) - compile project(':library') - compile 'com.android.support:appcompat-v7:24.1.1' - compile 'com.android.support:design:24.1.1' - compile 'com.android.support:recyclerview-v7:24.1.1' + implementation fileTree(dir: 'libs', include: ['*.jar']) + implementation project(':neofecttooltip') + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version" + + implementation "com.android.support:appcompat-v7:$ANDROID_BUILD_TOOLS_VERSION" + implementation "com.android.support:design:$ANDROID_BUILD_TOOLS_VERSION" + implementation "com.android.support:recyclerview-v7:$ANDROID_BUILD_TOOLS_VERSION" + implementation 'com.jakewharton.timber:timber:4.7.1' debugCompile 'com.squareup.leakcanary:leakcanary-android:1.3.1' releaseCompile 'com.squareup.leakcanary:leakcanary-android-no-op:1.3.1' } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index c574e36e..59182bd5 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -10,6 +10,16 @@ android:label="@string/app_name" android:theme="@style/AppTheme"> + + + + + + + + + val gravity = XTooltip.Gravity.valueOf(spinner_gravities.selectedItem.toString()) + val closePolicy = getClosePolicy() + val typeface = if (checkbox_font.isChecked) Typefaces[this, "fonts/at.ttc"] else null + val animation = if (checkbox_animation.isChecked) XTooltip.Animation.DEFAULT else null + val showDuration = if (text_duration.text.isNullOrEmpty()) 0 else text_duration.text.toString().toLong() + val fadeDuration = if (text_fade.text.isNullOrEmpty()) 0 else text_fade.text.toString().toLong() + val arrow = checkbox_arrow.isChecked + val overlay = checkbox_overlay.isChecked + val style = if (checkbox_style.isChecked) R.style.ToolTipAltStyle else null + val text = + if (text_tooltip.text.isNullOrEmpty()) "Lorem ipsum dolor" else text_tooltip.text!!.toString() + + Timber.v("gravity: $gravity") + Timber.v("closePolicy: $closePolicy") + + tooltip = XTooltip.Builder(this) + .anchor(button, 0, 0, false) + .text(text) + .styleId(style) + .typeface(typeface) + .maxWidth(metrics.widthPixels / 2) + .arrow(arrow) + .floatingAnimation(animation) + .closePolicy(closePolicy) + .showDuration(showDuration) + .fadeDuration(fadeDuration) + .overlay(overlay) + .create() + + tooltip + ?.doOnHidden { + tooltip = null + } + ?.doOnFailure { } + ?.doOnShown {} + ?.show(button, gravity, true) + } + + button2.setOnClickListener { + val fragment = TestDialogFragment.newInstance() + fragment.show(supportFragmentManager, "test_dialog_fragment") + } + } + + private fun getClosePolicy(): ClosePolicy { + val builder = ClosePolicy.Builder() + builder.inside(switch1.isChecked) + builder.outside(switch3.isChecked) + builder.consume(switch2.isChecked) + return builder.build() + } + + override fun onDestroy() { + Timber.i("onDestroy") + super.onDestroy() + tooltip?.dismiss() + } + +} diff --git a/app/src/main/java/it/sephiroth/android/library/mymodule/app/MainActivity2.java b/app/src/main/java/it/sephiroth/android/library/mymodule/app/MainActivity2.java index 4c5bf76b..a2e2a758 100644 --- a/app/src/main/java/it/sephiroth/android/library/mymodule/app/MainActivity2.java +++ b/app/src/main/java/it/sephiroth/android/library/mymodule/app/MainActivity2.java @@ -246,6 +246,7 @@ public void onClick(final View v) { .activateDelay(2000) .maxWidth(metrics.widthPixels / 2) .withCallback(this) + .alignment(Tooltip.Alignment.BOTTOM) .floatingAnimation(AnimationBuilder.DEFAULT) .build() ).show(); @@ -262,6 +263,7 @@ public void onClick(final View v) { .withArrow(true) .maxWidth(metrics.widthPixels / 2) .withCallback(this) + .alignment(Tooltip.Alignment.LEFT) .withStyleId(R.style.ToolTipLayoutDefaultStyle_Custom1) .build() ).show(); @@ -276,6 +278,7 @@ public void onClick(final View v) { .withArrow(true) .maxWidth((int) (metrics.widthPixels / 2.5)) .withCallback(this) + .alignment(Tooltip.Alignment.RIGHT) .floatingAnimation(AnimationBuilder.DEFAULT) .build() ).show(); @@ -289,6 +292,7 @@ public void onClick(final View v) { .text("TOP. Touch Inside exclusive.") .withArrow(true) .withOverlay(false) + .alignment(Tooltip.Alignment.RIGHT) .maxWidth(metrics.widthPixels / 3) .withCallback(this) .build() @@ -308,6 +312,7 @@ public void onClick(final View v) { .withOverlay(false) .maxWidth(metrics.widthPixels / 3) .showDelay(300) + .alignment(Tooltip.Alignment.TOP) .withCallback(this) .build() ); diff --git a/app/src/main/java/it/sephiroth/android/library/mymodule/app/MyTextView.java b/app/src/main/java/it/sephiroth/android/library/mymodule/app/MyTextView.java index 2452091e..af2eeca6 100644 --- a/app/src/main/java/it/sephiroth/android/library/mymodule/app/MyTextView.java +++ b/app/src/main/java/it/sephiroth/android/library/mymodule/app/MyTextView.java @@ -1,14 +1,14 @@ package it.sephiroth.android.library.mymodule.app; import android.content.Context; +import android.support.v7.widget.AppCompatTextView; import android.util.AttributeSet; import android.view.View; -import android.widget.TextView; /** * Created by alessandro on 04/09/14. */ -public class MyTextView extends TextView { +public class MyTextView extends AppCompatTextView { public static interface OnAttachStatusListener { void onAttachedtoWindow(View view); diff --git a/app/src/main/java/it/sephiroth/android/library/mymodule/app/TestDialogFragment.kt b/app/src/main/java/it/sephiroth/android/library/mymodule/app/TestDialogFragment.kt new file mode 100644 index 00000000..798e9bd9 --- /dev/null +++ b/app/src/main/java/it/sephiroth/android/library/mymodule/app/TestDialogFragment.kt @@ -0,0 +1,39 @@ +package it.sephiroth.android.library.mymodule.app; + +import android.os.Bundle +import android.support.v4.app.DialogFragment +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import it.sephiroth.android.library.xtooltip.ClosePolicy +import it.sephiroth.android.library.xtooltip.XTooltip +import kotlinx.android.synthetic.main.dialog_fragment.* + +class TestDialogFragment : DialogFragment() { + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + return inflater.inflate(R.layout.dialog_fragment, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + button1.setOnClickListener { button -> + XTooltip.Builder(context!!) + .anchor(button, 0, 0, false) + .closePolicy(ClosePolicy.TOUCH_ANYWHERE_CONSUME) + .fadeDuration(200) + .showDuration(0) + .text("This is a dialog") + .create() + .show(button, XTooltip.Gravity.TOP, false) + } + } + + companion object { + fun newInstance(): TestDialogFragment { + val frag = TestDialogFragment() + return frag + } + } +} \ No newline at end of file diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml new file mode 100644 index 00000000..792e7ae6 --- /dev/null +++ b/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/content_main.xml b/app/src/main/res/layout/content_main.xml new file mode 100644 index 00000000..bd773fdd --- /dev/null +++ b/app/src/main/res/layout/content_main.xml @@ -0,0 +1,201 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_fragment.xml b/app/src/main/res/layout/dialog_fragment.xml new file mode 100644 index 00000000..853747ac --- /dev/null +++ b/app/src/main/res/layout/dialog_fragment.xml @@ -0,0 +1,27 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index bfb38c43..8c1c6767 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -7,4 +7,19 @@ Target Tooltip 2 Target Tooltip 3 + + BOTTOM + TOP + LEFT + RIGHT + CENTER + + + + 0 + 1000 + 2000 + 5000 + 10000 + diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index 7802c9dd..13f92f19 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -18,7 +18,7 @@ #ffe5a000 #ffe5c700 2dip - 8dip + 5dip @style/ToolTipOverlayCustomStyle ?android:attr/textAppearanceInverse @@ -33,4 +33,21 @@ fonts/at.ttc + + + + + diff --git a/build.gradle b/build.gradle index a641feb5..048a386c 100644 --- a/build.gradle +++ b/build.gradle @@ -1,11 +1,13 @@ buildscript { + ext.kotlin_version = '1.3.11' repositories { + google() jcenter() - mavenCentral() } dependencies { - classpath 'com.android.tools.build:gradle:2.3.0-beta2' + classpath 'com.android.tools.build:gradle:3.5.0' + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" } } @@ -14,8 +16,8 @@ allprojects { group = GROUP repositories { + google() jcenter() - mavenCentral() } } diff --git a/gradle.properties b/gradle.properties index d615c99c..d4606fa6 100644 --- a/gradle.properties +++ b/gradle.properties @@ -16,8 +16,8 @@ POM_DEVELOPER_EMAIL=alessandro.crugnola@gmail.com POM_DEVELOPER_URL=http://blog.sephiroth.it POM_DEVELOPER_ROLE=author -ANDROID_BUILD_TARGET_SDK_VERSION=25 -ANDROID_BUILD_TOOLS_VERSION=25.0.2 -ANDROID_BUILD_SDK_VERSION=25 +ANDROID_BUILD_TARGET_SDK_VERSION=27 +ANDROID_BUILD_TOOLS_VERSION=27.0.2 +ANDROID_BUILD_SDK_VERSION=27 org.gradle.daemon=true diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 0633896a..33ac8ad0 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-3.3-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-5.4.1-all.zip \ No newline at end of file diff --git a/library/.gitignore b/neofecttooltip/.gitignore similarity index 100% rename from library/.gitignore rename to neofecttooltip/.gitignore diff --git a/library/build.gradle b/neofecttooltip/build.gradle similarity index 62% rename from library/build.gradle rename to neofecttooltip/build.gradle index 91532538..9bb42a57 100644 --- a/library/build.gradle +++ b/neofecttooltip/build.gradle @@ -1,14 +1,15 @@ apply plugin: 'com.android.library' +apply plugin: 'kotlin-android' +apply plugin: 'kotlin-android-extensions' group GROUP version VERSION_NAME android { compileSdkVersion ANDROID_BUILD_SDK_VERSION as int - buildToolsVersion ANDROID_BUILD_TOOLS_VERSION defaultConfig { - minSdkVersion 14 + minSdkVersion 16 targetSdkVersion ANDROID_BUILD_TARGET_SDK_VERSION as int versionCode 1 versionName VERSION_NAME @@ -24,8 +25,8 @@ android { } compileOptions { - sourceCompatibility JavaVersion.VERSION_1_7 - targetCompatibility JavaVersion.VERSION_1_7 + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 } lintOptions { @@ -42,9 +43,12 @@ android { } dependencies { - compile fileTree(dir: 'libs', include: ['*.jar']) - provided 'com.android.support:support-annotations:24.1.1' - compile 'com.android.support:appcompat-v7:24.1.1' + implementation fileTree(dir: 'libs', include: ['*.jar']) + implementation "com.android.support:support-annotations:$ANDROID_BUILD_TOOLS_VERSION" + implementation "com.android.support:appcompat-v7:$ANDROID_BUILD_TOOLS_VERSION" + + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version" + implementation 'com.jakewharton.timber:timber:4.7.1' } diff --git a/library/gradle.properties b/neofecttooltip/gradle.properties similarity index 100% rename from library/gradle.properties rename to neofecttooltip/gradle.properties diff --git a/library/proguard-rules.txt b/neofecttooltip/proguard-rules.txt similarity index 100% rename from library/proguard-rules.txt rename to neofecttooltip/proguard-rules.txt diff --git a/library/src/main/AndroidManifest.xml b/neofecttooltip/src/main/AndroidManifest.xml similarity index 100% rename from library/src/main/AndroidManifest.xml rename to neofecttooltip/src/main/AndroidManifest.xml diff --git a/library/src/main/java/it/sephiroth/android/library/tooltip/Tooltip.java b/neofecttooltip/src/main/java/it/sephiroth/android/library/tooltip/Tooltip.java similarity index 92% rename from library/src/main/java/it/sephiroth/android/library/tooltip/Tooltip.java rename to neofecttooltip/src/main/java/it/sephiroth/android/library/tooltip/Tooltip.java index e28d644b..a633689b 100644 --- a/library/src/main/java/it/sephiroth/android/library/tooltip/Tooltip.java +++ b/neofecttooltip/src/main/java/it/sephiroth/android/library/tooltip/Tooltip.java @@ -177,6 +177,10 @@ public enum Gravity { LEFT, RIGHT, TOP, BOTTOM, CENTER } + public enum Alignment { + LEFT, RIGHT, TOP, BOTTOM, CENTER + } + @SuppressWarnings ("unused") public interface TooltipView { void show(); @@ -259,9 +263,12 @@ static class TooltipViewImpl extends ViewGroup implements TooltipView { private final Point mTmpPoint = new Point(); private final Rect mHitRect = new Rect(); private final float mTextViewElevation; + private final float mMargin; private Callback mCallback; private int[] mOldLocation; private Gravity mGravity; + private Alignment mAlignment; + private int mAnchorShift; private Animator mShowAnimation; private boolean mShowing; private WeakReference mViewAnchor; @@ -311,6 +318,7 @@ public void run() { } }; private int mPadding; + private float mArrowHeight; private CharSequence mText; private Rect mViewRect; private View mView; @@ -402,11 +410,14 @@ public TooltipViewImpl(Context context, final Builder builder) { context.getTheme() .obtainStyledAttributes(null, R.styleable.TooltipLayout, builder.defStyleAttr, builder.defStyleRes); this.mPadding = theme.getDimensionPixelSize(R.styleable.TooltipLayout_ttlm_padding, 30); + this.mArrowHeight = theme.getDimension(R.styleable.TooltipLayout_ttlm_arrowHeight, 0); this.mTextAppearance = theme.getResourceId(R.styleable.TooltipLayout_android_textAppearance, 0); this.mTextGravity = theme .getInt(R.styleable.TooltipLayout_android_gravity, android.view.Gravity.TOP | android.view.Gravity.START); this.mTextViewElevation = theme.getDimension(R.styleable.TooltipLayout_ttlm_elevation, 0); int overlayStyle = theme.getResourceId(R.styleable.TooltipLayout_ttlm_overlayStyle, R.style.ToolTipOverlayDefaultStyle); + this.mMargin = theme.getDimension(R.styleable.TooltipLayout_ttlm_margin, 0); + this.mAnchorShift = theme.getDimensionPixelOffset(R.styleable.TooltipLayout_ttlm_anchorShift, -1); String font = theme.getString(R.styleable.TooltipLayout_ttlm_font); @@ -415,6 +426,7 @@ public TooltipViewImpl(Context context, final Builder builder) { this.mToolTipId = builder.id; this.mText = builder.text; this.mGravity = builder.gravity; + this.mAlignment = builder.alignment; this.mTextResId = builder.textResId; this.mMaxWidth = builder.maxWidth; this.mTopRule = builder.actionbarSize; @@ -422,6 +434,13 @@ public TooltipViewImpl(Context context, final Builder builder) { this.mShowDuration = builder.showDuration; this.mShowDelay = builder.showDelay; this.mHideArrow = builder.hideArrow; + if (mHideArrow) { + mArrowHeight = 0; + } else { + if (mArrowHeight == 0) { + mArrowHeight = mPadding; + } + } this.mActivateDelay = builder.activateDelay; this.mRestrict = builder.restrictToScreenEdges; this.mFadeDuration = builder.fadeDuration; @@ -798,6 +817,12 @@ private void initializeView() { if (0 != mTextAppearance) { mTextView.setTextAppearance(getContext(), mTextAppearance); + + final TypedArray ta = getContext().obtainStyledAttributes(mTextAppearance, R.styleable.TooltipLayout); + final int lineSpacingAdd = ta.getDimensionPixelSize(R.styleable.TooltipLayout_android_lineSpacingExtra, 0); + final float lineSpacingMultiplier = ta.getFloat(R.styleable.TooltipLayout_android_lineSpacingMultiplier, 1); + ta.recycle(); + mTextView.setLineSpacing(lineSpacingAdd, lineSpacingMultiplier); } mTextView.setGravity(mTextGravity); @@ -808,11 +833,8 @@ private void initializeView() { if (null != mDrawable) { mTextView.setBackgroundDrawable(mDrawable); - if (mHideArrow) { - mTextView.setPadding(mPadding / 2, mPadding / 2, mPadding / 2, mPadding / 2); - } else { - mTextView.setPadding(mPadding, mPadding, mPadding, mPadding); - } + mTextView.setPadding(mPadding + Math.round(mArrowHeight), mPadding + Math.round(mArrowHeight), + mPadding + Math.round(mArrowHeight), mPadding + Math.round(mArrowHeight)); } this.addView(mView); @@ -1029,6 +1051,49 @@ private void calculatePositions(List gravities, final boolean checkEdge } } + Point center = new Point(mViewRect.centerX(), mViewRect.centerY()); + + switch (mAlignment) { + case LEFT: + if (mGravity == TOP || mGravity == BOTTOM) { + int shift = mViewRect.left - mDrawRect.left - Math.round(mArrowHeight) + mAnchorShift; + mDrawRect.offset(shift, 0); + } + break; + + case RIGHT: + if (mGravity == TOP || mGravity == BOTTOM) { + int shift = mViewRect.right - mDrawRect.right + Math.round(mArrowHeight) + mAnchorShift; + mDrawRect.offset(shift, 0); + } + break; + + case TOP: + if (mGravity == LEFT || mGravity == RIGHT) { + int shift = mViewRect.top - mDrawRect.top + Math.round(mArrowHeight + mAnchorShift); + mDrawRect.offset(0, shift); + } + break; + + case BOTTOM: + if (mGravity == LEFT || mGravity == RIGHT) { + int shift = mViewRect.bottom - mDrawRect.bottom + Math.round(mArrowHeight + mAnchorShift); + mDrawRect.offset(0, shift); + } + break; + + case CENTER: + if (mGravity == TOP || mGravity == BOTTOM) { + mDrawRect.offset(-mAnchorShift, 0); + } else if (mGravity == LEFT || mGravity == RIGHT) { + mDrawRect.offset(0, mAnchorShift); + } + break; + + default: + break; + } + if (null != mViewOverlay) { mViewOverlay.setTranslationX(mViewRect.centerX() - mViewOverlay.getWidth() / 2); mViewOverlay.setTranslationY(mViewRect.centerY() - mViewOverlay.getHeight() / 2); @@ -1039,8 +1104,8 @@ private void calculatePositions(List gravities, final boolean checkEdge mView.setTranslationY(mDrawRect.top); if (null != mDrawable) { - getAnchorPoint(gravity, mTmpPoint); - mDrawable.setAnchor(gravity, mHideArrow ? 0 : mPadding / 2, mHideArrow ? null : mTmpPoint); + getAnchorPoint(gravity, mTmpPoint, center); + mDrawable.setAnchor(gravity, mHideArrow ? 0 : mPadding, mArrowHeight, mHideArrow ? null : mTmpPoint); } if (!mAlreadyCheck) { @@ -1081,6 +1146,8 @@ private boolean calculatePositionLeft( mViewRect.centerY() + height / 2 ); + mDrawRect.offset(0, Math.round(-mMargin)); + if ((mViewRect.width() / 2) < overlayWidth) { mDrawRect.offset(-(overlayWidth - (mViewRect.width() / 2)), 0); } @@ -1111,6 +1178,8 @@ private boolean calculatePositionRight( mViewRect.centerY() + height / 2 ); + mDrawRect.offset(Math.round(mMargin), 0); + if ((mViewRect.width() / 2) < overlayWidth) { mDrawRect.offset(overlayWidth - mViewRect.width() / 2, 0); } @@ -1141,6 +1210,8 @@ private boolean calculatePositionTop( mViewRect.top ); + mDrawRect.offset(0, Math.round(-mMargin)); + if ((mViewRect.height() / 2) < overlayHeight) { mDrawRect.offset(0, -(overlayHeight - (mViewRect.height() / 2))); } @@ -1171,6 +1242,8 @@ private boolean calculatePositionBottom( mViewRect.bottom + height ); + mDrawRect.offset(0, Math.round(mMargin)); + if (mViewRect.height() / 2 < overlayHeight) { mDrawRect.offset(0, overlayHeight - mViewRect.height() / 2); } @@ -1242,9 +1315,39 @@ void getAnchorPoint(final Gravity gravity, Point outPoint) { if (!mHideArrow) { if (gravity == LEFT || gravity == RIGHT) { - outPoint.y -= mPadding / 2; + outPoint.y -= mArrowHeight; } else if (gravity == TOP || gravity == BOTTOM) { - outPoint.x -= mPadding / 2; + outPoint.x -= mArrowHeight; + } + } + } + + void getAnchorPoint(final Gravity gravity, Point outPoint, Point center) { + if (gravity == BOTTOM) { + outPoint.x = center.x; + outPoint.y = mViewRect.bottom; + } else if (gravity == TOP) { + outPoint.x = center.x; + outPoint.y = mViewRect.top; + } else if (gravity == RIGHT) { + outPoint.x = mViewRect.right; + outPoint.y = center.y; + } else if (gravity == LEFT) { + outPoint.x = mViewRect.left; + outPoint.y = center.y; + } else if (this.mGravity == CENTER) { + outPoint.x = center.x; + outPoint.y = center.y; + } + + outPoint.x -= mDrawRect.left; + outPoint.y -= mDrawRect.top; + + if (!mHideArrow) { + if (gravity == LEFT || gravity == RIGHT) { + outPoint.y -= mArrowHeight; + } else if (gravity == TOP || gravity == BOTTOM) { + outPoint.x -= mArrowHeight; } } } @@ -1458,6 +1561,7 @@ public static final class Builder { CharSequence text; View view; Gravity gravity; + Alignment alignment = Alignment.CENTER; int actionbarSize = 0; int textResId = R.layout.tooltip_textview; int closePolicy = ClosePolicy.NONE; @@ -1602,6 +1706,11 @@ public Builder anchor(final Point point, final Gravity gravity) { return this; } + public Builder alignment(Alignment alignment) { + this.alignment = alignment; + return this; + } + /** * @deprecated use {#withArrow} instead */ diff --git a/library/src/main/java/it/sephiroth/android/library/tooltip/TooltipOverlay.java b/neofecttooltip/src/main/java/it/sephiroth/android/library/tooltip/TooltipOverlay.java similarity index 92% rename from library/src/main/java/it/sephiroth/android/library/tooltip/TooltipOverlay.java rename to neofecttooltip/src/main/java/it/sephiroth/android/library/tooltip/TooltipOverlay.java index 98369243..67d2f14a 100644 --- a/library/src/main/java/it/sephiroth/android/library/tooltip/TooltipOverlay.java +++ b/neofecttooltip/src/main/java/it/sephiroth/android/library/tooltip/TooltipOverlay.java @@ -2,10 +2,10 @@ import android.content.Context; import android.content.res.TypedArray; +import android.support.v7.widget.AppCompatImageView; import android.util.AttributeSet; -import android.widget.ImageView; -public class TooltipOverlay extends ImageView { +public class TooltipOverlay extends AppCompatImageView { private int mMargins; public TooltipOverlay(Context context) { diff --git a/library/src/main/java/it/sephiroth/android/library/tooltip/TooltipOverlayDrawable.java b/neofecttooltip/src/main/java/it/sephiroth/android/library/tooltip/TooltipOverlayDrawable.java similarity index 100% rename from library/src/main/java/it/sephiroth/android/library/tooltip/TooltipOverlayDrawable.java rename to neofecttooltip/src/main/java/it/sephiroth/android/library/tooltip/TooltipOverlayDrawable.java diff --git a/library/src/main/java/it/sephiroth/android/library/tooltip/TooltipTextDrawable.java b/neofecttooltip/src/main/java/it/sephiroth/android/library/tooltip/TooltipTextDrawable.java similarity index 95% rename from library/src/main/java/it/sephiroth/android/library/tooltip/TooltipTextDrawable.java rename to neofecttooltip/src/main/java/it/sephiroth/android/library/tooltip/TooltipTextDrawable.java index 5fa7d1fa..6d108289 100644 --- a/library/src/main/java/it/sephiroth/android/library/tooltip/TooltipTextDrawable.java +++ b/neofecttooltip/src/main/java/it/sephiroth/android/library/tooltip/TooltipTextDrawable.java @@ -30,6 +30,7 @@ class TooltipTextDrawable extends Drawable { private final float ellipseSize; private Point point; private int padding = 0; + private float arrowHeight; private int arrowWeight = 0; private Tooltip.Gravity gravity; @@ -77,11 +78,12 @@ public void draw(final Canvas canvas) { } } - public void setAnchor(final Tooltip.Gravity gravity, int padding, @Nullable Point point) { + public void setAnchor(final Tooltip.Gravity gravity, int padding, float arrowHeight, @Nullable Point point) { if (gravity != this.gravity || padding != this.padding || !Utils.equals(this.point, point)) { this.gravity = gravity; this.padding = padding; - this.arrowWeight = (int) ((float) padding / arrowRatio); + this.arrowHeight = arrowHeight; + this.arrowWeight = (int) (arrowHeight / arrowRatio); if (null != point) { this.point = new Point(point); @@ -98,10 +100,10 @@ public void setAnchor(final Tooltip.Gravity gravity, int padding, @Nullable Poin } void calculatePath(Rect outBounds) { - int left = outBounds.left + padding; - int top = outBounds.top + padding; - int right = outBounds.right - padding; - int bottom = outBounds.bottom - padding; + int left = outBounds.left + Math.round(arrowHeight); + int top = outBounds.top + Math.round(arrowHeight); + int right = outBounds.right - Math.round(arrowHeight); + int bottom = outBounds.bottom - Math.round(arrowHeight); final float maxY = bottom - ellipseSize; final float maxX = right - ellipseSize; diff --git a/library/src/main/java/it/sephiroth/android/library/tooltip/Typefaces.java b/neofecttooltip/src/main/java/it/sephiroth/android/library/tooltip/Typefaces.java similarity index 100% rename from library/src/main/java/it/sephiroth/android/library/tooltip/Typefaces.java rename to neofecttooltip/src/main/java/it/sephiroth/android/library/tooltip/Typefaces.java diff --git a/library/src/main/java/it/sephiroth/android/library/tooltip/Utils.java b/neofecttooltip/src/main/java/it/sephiroth/android/library/tooltip/Utils.java similarity index 100% rename from library/src/main/java/it/sephiroth/android/library/tooltip/Utils.java rename to neofecttooltip/src/main/java/it/sephiroth/android/library/tooltip/Utils.java diff --git a/neofecttooltip/src/main/java/it/sephiroth/android/library/xtooltip/TooltipOverlay.kt b/neofecttooltip/src/main/java/it/sephiroth/android/library/xtooltip/TooltipOverlay.kt new file mode 100644 index 00000000..4477769d --- /dev/null +++ b/neofecttooltip/src/main/java/it/sephiroth/android/library/xtooltip/TooltipOverlay.kt @@ -0,0 +1,51 @@ +package it.sephiroth.android.library.xtooltip + + +import android.content.Context +import android.support.v7.widget.AppCompatImageView +import android.util.AttributeSet +import it.sephiroth.android.library.tooltip.R + +/** + * Created by alessandro crugnola on 12/12/15. + * alessandro.crugnola@gmail.com + * + * + * LICENSE + * Copyright 2015 Alessandro Crugnola + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, + * modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software + * is furnished to do so, subject to the following conditions: + * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS + * BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT + * OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +class TooltipOverlay : AppCompatImageView { + private var layoutMargins: Int = 0 + + @JvmOverloads + constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = R.style.ToolTipOverlayDefaultStyle + ) : super(context, attrs, defStyleAttr) { + init(context, R.style.ToolTipLayoutDefaultStyle) + } + + private fun init(context: Context, defStyleResId: Int) { + val drawable = TooltipOverlayDrawable(context, defStyleResId) + setImageDrawable(drawable) + + val array = context.theme.obtainStyledAttributes(defStyleResId, R.styleable.TooltipOverlay) + layoutMargins = array.getDimensionPixelSize(R.styleable.TooltipOverlay_android_layout_margin, 0) + array.recycle() + + } + + constructor(context: Context, defStyleAttr: Int, defStyleResId: Int) : super(context, null, defStyleAttr) { + init(context, defStyleResId) + } +} \ No newline at end of file diff --git a/neofecttooltip/src/main/java/it/sephiroth/android/library/xtooltip/TooltipOverlayDrawable.kt b/neofecttooltip/src/main/java/it/sephiroth/android/library/xtooltip/TooltipOverlayDrawable.kt new file mode 100644 index 00000000..ae69aaf1 --- /dev/null +++ b/neofecttooltip/src/main/java/it/sephiroth/android/library/xtooltip/TooltipOverlayDrawable.kt @@ -0,0 +1,248 @@ +package it.sephiroth.android.library.xtooltip + + +import android.animation.* +import android.content.Context +import android.graphics.* +import android.graphics.drawable.Drawable +import android.view.animation.AccelerateDecelerateInterpolator +import it.sephiroth.android.library.tooltip.R +import timber.log.Timber + +/** + * Created by alessandro crugnola on 12/12/15. + * alessandro.crugnola@gmail.com + * + * + * LICENSE + * Copyright 2015 Alessandro Crugnola + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, + * modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software + * is furnished to do so, subject to the following conditions: + * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS + * BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT + * OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +@Suppress("SpellCheckingInspection") +class TooltipOverlayDrawable(context: Context, defStyleResId: Int) : Drawable() { + private val mOuterPaint = Paint(Paint.ANTI_ALIAS_FLAG) + private val mInnerPaint = Paint(Paint.ANTI_ALIAS_FLAG) + private var mOuterRadius: Float = 0.toFloat() + private var innerRadius = 0f + set(rippleRadius) { + field = rippleRadius + invalidateSelf() + } + private val mFirstAnimatorSet: AnimatorSet + private val mSecondAnimatorSet: AnimatorSet + private val mFirstAnimator: ValueAnimator + private val mSecondAnimator: ValueAnimator + private var mRepeatIndex: Int = 0 + private var mStarted: Boolean = false + private val mOuterAlpha: Int + private val mInnerAlpha: Int + private var mRepeatCount = 1 + private var mDuration: Long = 400 + + private var outerAlpha: Int + get() = mOuterPaint.alpha + set(value) { + mOuterPaint.alpha = value + invalidateSelf() + } + + private var innerAlpha: Int + get() = mInnerPaint.alpha + set(value) { + mInnerPaint.alpha = value + invalidateSelf() + } + + private var outerRadius: Float + get() = mOuterRadius + set(value) { + mOuterRadius = value + invalidateSelf() + } + + init { + mOuterPaint.style = Paint.Style.FILL + mInnerPaint.style = Paint.Style.FILL + + val array = context.theme.obtainStyledAttributes(defStyleResId, R.styleable.TooltipOverlay) + + for (i in 0 until array.indexCount) { + val index = array.getIndex(i) + + when (index) { + R.styleable.TooltipOverlay_android_color -> { + val color = array.getColor(index, 0) + mOuterPaint.color = color + mInnerPaint.color = color + + } + R.styleable.TooltipOverlay_ttlm_repeatCount -> mRepeatCount = array.getInt(index, 1) + R.styleable.TooltipOverlay_android_alpha -> { + val alpha = (array.getFloat(index, mInnerPaint.alpha / ALPHA_MAX) * 255).toInt() + mInnerPaint.alpha = alpha + mOuterPaint.alpha = alpha + + } + R.styleable.TooltipOverlay_ttlm_duration -> mDuration = array.getInt(index, 400).toLong() + } + } + + array.recycle() + + mOuterAlpha = outerAlpha + mInnerAlpha = innerAlpha + + // first + var fadeIn: Animator = ObjectAnimator.ofInt(this, "outerAlpha", 0, mOuterAlpha) + fadeIn.duration = (mDuration * FADEIN_DURATION).toLong() + + var fadeOut: Animator = ObjectAnimator.ofInt(this, "outerAlpha", mOuterAlpha, 0, 0) + fadeOut.startDelay = (mDuration * FADEOUT_START_DELAY).toLong() + fadeOut.duration = (mDuration * (1.0 - FADEOUT_START_DELAY)).toLong() + + mFirstAnimator = ObjectAnimator.ofFloat(this, "outerRadius", 0f, 1f) + mFirstAnimator.duration = mDuration + + mFirstAnimatorSet = AnimatorSet() + mFirstAnimatorSet.playTogether(fadeIn, mFirstAnimator, fadeOut) + mFirstAnimatorSet.interpolator = AccelerateDecelerateInterpolator() + mFirstAnimatorSet.duration = mDuration + + // second + fadeIn = ObjectAnimator.ofInt(this, "innerAlpha", 0, mInnerAlpha) + fadeIn.duration = (mDuration * FADEIN_DURATION).toLong() + + fadeOut = ObjectAnimator.ofInt(this, "innerAlpha", mInnerAlpha, 0, 0) + fadeOut.setStartDelay((mDuration * FADEOUT_START_DELAY).toLong()) + fadeOut.duration = (mDuration * (1.0 - FADEOUT_START_DELAY)).toLong() + + mSecondAnimator = ObjectAnimator.ofFloat(this, "innerRadius", 0f, 1f) + mSecondAnimator.duration = mDuration + + mSecondAnimatorSet = AnimatorSet() + mSecondAnimatorSet.playTogether(fadeIn, mSecondAnimator, fadeOut) + mSecondAnimatorSet.interpolator = AccelerateDecelerateInterpolator() + mSecondAnimatorSet.startDelay = (mDuration * SECOND_ANIM_START_DELAY).toLong() + mSecondAnimatorSet.duration = mDuration + + mFirstAnimatorSet.addListener(object : AnimatorListenerAdapter() { + var cancelled: Boolean = false + + override fun onAnimationCancel(animation: Animator) { + super.onAnimationCancel(animation) + cancelled = true + } + + override fun onAnimationEnd(animation: Animator) { + if (!cancelled && isVisible && ++mRepeatIndex < mRepeatCount) { + mFirstAnimatorSet.start() + } + } + }) + + mSecondAnimatorSet.addListener(object : AnimatorListenerAdapter() { + var cancelled: Boolean = false + + override fun onAnimationCancel(animation: Animator) { + super.onAnimationCancel(animation) + cancelled = true + } + + override fun onAnimationEnd(animation: Animator) { + if (!cancelled && isVisible && mRepeatIndex < mRepeatCount) { + mSecondAnimatorSet.startDelay = 0 + mSecondAnimatorSet.start() + } + } + }) + + } + + override fun draw(canvas: Canvas) { + val bounds = bounds + val centerX = bounds.width() / 2 + val centerY = bounds.height() / 2 + canvas.drawCircle(centerX.toFloat(), centerY.toFloat(), mOuterRadius, mOuterPaint) + canvas.drawCircle(centerX.toFloat(), centerY.toFloat(), innerRadius, mInnerPaint) + + } + + override fun setAlpha(i: Int) {} + + override fun setColorFilter(colorFilter: ColorFilter?) {} + + override fun setVisible(visible: Boolean, restart: Boolean): Boolean { + val changed = isVisible != visible + + if (visible) { + if (restart || !mStarted) { + replay() + } + } else { + stop() + } + + return changed + } + + override fun getOpacity(): Int { + return PixelFormat.TRANSLUCENT + } + + override fun onBoundsChange(bounds: Rect) { + Timber.i("onBoundsChange: $bounds") + super.onBoundsChange(bounds) + mOuterRadius = (Math.min(bounds.width(), bounds.height()) / 2).toFloat() + mFirstAnimator.setFloatValues(0f, mOuterRadius) + mSecondAnimator.setFloatValues(0f, mOuterRadius) + } + + override fun getIntrinsicWidth(): Int { + return 96 + } + + override fun getIntrinsicHeight(): Int { + return 96 + } + + private fun play() { + mRepeatIndex = 0 + mStarted = true + mFirstAnimatorSet.start() + mSecondAnimatorSet.startDelay = (mDuration * SECOND_ANIM_START_DELAY).toLong() + mSecondAnimatorSet.start() + } + + private fun replay() { + stop() + play() + } + + private fun stop() { + mFirstAnimatorSet.cancel() + mSecondAnimatorSet.cancel() + + mRepeatIndex = 0 + mStarted = false + + innerRadius = 0f + outerRadius = 0f + } + + @Suppress("SpellCheckingInspection") + companion object { + const val ALPHA_MAX = 255f + const val FADEOUT_START_DELAY = 0.55 + const val FADEIN_DURATION = 0.3 + const val SECOND_ANIM_START_DELAY = 0.25 + } +} diff --git a/neofecttooltip/src/main/java/it/sephiroth/android/library/xtooltip/TooltipTextDrawable.kt b/neofecttooltip/src/main/java/it/sephiroth/android/library/xtooltip/TooltipTextDrawable.kt new file mode 100644 index 00000000..f6a17cd2 --- /dev/null +++ b/neofecttooltip/src/main/java/it/sephiroth/android/library/xtooltip/TooltipTextDrawable.kt @@ -0,0 +1,274 @@ +package it.sephiroth.android.library.xtooltip + + +import android.annotation.TargetApi +import android.content.Context +import android.graphics.* +import android.graphics.drawable.Drawable +import android.os.Build +import android.support.v4.util.ObjectsCompat +import it.sephiroth.android.library.tooltip.R +import timber.log.Timber + +/** + * Created by alessandro crugnola on 12/12/15. + * alessandro.crugnola@gmail.com + * + * + * LICENSE + * Copyright 2015 Alessandro Crugnola + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, + * modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software + * is furnished to do so, subject to the following conditions: + * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS + * BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT + * OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +internal class TooltipTextDrawable(context: Context, builder: XTooltip.Builder) : Drawable() { + private val rectF: RectF + private val path: Path + private val tmpPoint = Point() + private val outlineRect = Rect() + private val bgPaint: Paint? + private val stPaint: Paint? + private val arrowRatio: Float + private val radius: Float + private var point: Point? = null + private var padding = 0 + private var arrowWeight = 0 + private var gravity: XTooltip.Gravity? = null + + init { + + val theme = context.theme.obtainStyledAttributes( + null, + R.styleable.TooltipLayout, + builder.defStyleAttr, + builder.defStyleRes + ) + this.radius = theme.getDimensionPixelSize(R.styleable.TooltipLayout_ttlm_cornerRadius, 4).toFloat() + val strokeWidth = theme.getDimensionPixelSize(R.styleable.TooltipLayout_ttlm_strokeWeight, 2) + val backgroundColor = theme.getColor(R.styleable.TooltipLayout_ttlm_backgroundColor, 0) + val strokeColor = theme.getColor(R.styleable.TooltipLayout_ttlm_strokeColor, 0) + this.arrowRatio = theme.getFloat(R.styleable.TooltipLayout_ttlm_arrowRatio, ARROW_RATIO_DEFAULT) + theme.recycle() + + this.rectF = RectF() + + if (backgroundColor != 0) { + bgPaint = Paint(Paint.ANTI_ALIAS_FLAG) + bgPaint.color = backgroundColor + bgPaint.style = Paint.Style.FILL + } else { + bgPaint = null + } + + if (strokeColor != 0) { + stPaint = Paint(Paint.ANTI_ALIAS_FLAG) + stPaint.color = strokeColor + stPaint.style = Paint.Style.STROKE + stPaint.strokeWidth = strokeWidth.toFloat() + } else { + stPaint = null + } + + path = Path() + } + + override fun draw(canvas: Canvas) { + bgPaint?.let { + canvas.drawPath(path, it) + } + + stPaint?.let { + canvas.drawPath(path, it) + } + } + + fun setAnchor(gravity: XTooltip.Gravity, padding: Int, point: Point?) { + if (gravity !== this.gravity || padding != this.padding || !ObjectsCompat.equals(this.point, point)) { + this.gravity = gravity + this.padding = padding + this.arrowWeight = (padding.toFloat() / arrowRatio).toInt() + + point?.let { + this.point = Point(it) + } ?: run { + this.point = null + } + + if (!bounds.isEmpty) { + calculatePath(bounds) + invalidateSelf() + } + } + } + + private fun calculatePath(outBounds: Rect) { + val left = outBounds.left + padding + val top = outBounds.top + padding + val right = outBounds.right - padding + val bottom = outBounds.bottom - padding + + val maxY = bottom - radius + val maxX = right - radius + val minY = top + radius + val minX = left + radius + + if (null != point && null != gravity) { + calculatePathWithGravity(outBounds, left, top, right, bottom, maxY, maxX, minY, minX) + } else { + rectF.set(left.toFloat(), top.toFloat(), right.toFloat(), bottom.toFloat()) + path.addRoundRect(rectF, radius, radius, Path.Direction.CW) + } + } + + private fun calculatePathWithGravity( + outBounds: Rect, + left: Int, + top: Int, + right: Int, + bottom: Int, + maxY: Float, + maxX: Float, + minY: Float, + minX: Float + ) { + val drawPoint = + isDrawPoint(left, top, right, bottom, maxY, maxX, minY, minX, tmpPoint, point!!, gravity, arrowWeight) + + Timber.v("drawPoint: $drawPoint") + + clampPoint(left, top, right, bottom, tmpPoint) + + path.reset() + + // top/left + path.moveTo(left + radius, top.toFloat()) + + if (drawPoint && gravity === XTooltip.Gravity.BOTTOM) { + path.lineTo((left + tmpPoint.x - arrowWeight).toFloat(), top.toFloat()) + path.lineTo((left + tmpPoint.x).toFloat(), outBounds.top.toFloat()) + path.lineTo((left + tmpPoint.x + arrowWeight).toFloat(), top.toFloat()) + } + + // top/right + path.lineTo(right - radius, top.toFloat()) + path.quadTo(right.toFloat(), top.toFloat(), right.toFloat(), top + radius) + + if (drawPoint && gravity === XTooltip.Gravity.LEFT) { + path.lineTo(right.toFloat(), (top + tmpPoint.y - arrowWeight).toFloat()) + path.lineTo(outBounds.right.toFloat(), (top + tmpPoint.y).toFloat()) + path.lineTo(right.toFloat(), (top + tmpPoint.y + arrowWeight).toFloat()) + } + + // bottom/right + path.lineTo(right.toFloat(), bottom - radius) + path.quadTo(right.toFloat(), bottom.toFloat(), right - radius, bottom.toFloat()) + + if (drawPoint && gravity === XTooltip.Gravity.TOP) { + path.lineTo((left + tmpPoint.x + arrowWeight).toFloat(), bottom.toFloat()) + path.lineTo((left + tmpPoint.x).toFloat(), outBounds.bottom.toFloat()) + path.lineTo((left + tmpPoint.x - arrowWeight).toFloat(), bottom.toFloat()) + } + + // bottom/left + path.lineTo(left + radius, bottom.toFloat()) + path.quadTo(left.toFloat(), bottom.toFloat(), left.toFloat(), bottom - radius) + + if (drawPoint && gravity === XTooltip.Gravity.RIGHT) { + path.lineTo(left.toFloat(), (top + tmpPoint.y + arrowWeight).toFloat()) + path.lineTo(outBounds.left.toFloat(), (top + tmpPoint.y).toFloat()) + path.lineTo(left.toFloat(), (top + tmpPoint.y - arrowWeight).toFloat()) + } + + // top/left + path.lineTo(left.toFloat(), top + radius) + path.quadTo(left.toFloat(), top.toFloat(), left + radius, top.toFloat()) + } + + override fun onBoundsChange(bounds: Rect) { + super.onBoundsChange(bounds) + calculatePath(bounds) + } + + override fun getAlpha(): Int { + return bgPaint?.alpha ?: run { 0 } + } + + override fun setAlpha(alpha: Int) { + bgPaint?.alpha = alpha + stPaint?.alpha = alpha + } + + override fun setColorFilter(cf: ColorFilter?) {} + + override fun getOpacity(): Int { + return PixelFormat.TRANSLUCENT + } + + @TargetApi(Build.VERSION_CODES.LOLLIPOP) + override fun getOutline(outline: Outline) { + copyBounds(outlineRect) + outlineRect.inset(padding, padding) + outline.setRoundRect(outlineRect, radius) + if (alpha < 255) { + outline.alpha = 0f + } + //outline.setAlpha(getAlpha() / ALPHA_MAX); + } + + companion object { + const val ARROW_RATIO_DEFAULT = 1.4f + + private fun isDrawPoint( + left: Int, top: Int, right: Int, bottom: Int, maxY: Float, maxX: Float, minY: Float, + minX: Float, tmpPoint: Point, point: Point, gravity: XTooltip.Gravity?, + arrowWeight: Int + ): Boolean { + Timber.i("isDrawPoint: $left, $top, $right, $bottom, $maxX, $maxY, $minX, $minY, $point, $arrowWeight") + var drawPoint = false + tmpPoint.set(point.x, point.y) + + if (gravity === XTooltip.Gravity.RIGHT || gravity === XTooltip.Gravity.LEFT) { + if (tmpPoint.y in top..bottom) { + if (top + tmpPoint.y + arrowWeight > maxY) { + tmpPoint.y = (maxY - arrowWeight.toFloat() - top.toFloat()).toInt() + } else if (top + tmpPoint.y - arrowWeight < minY) { + tmpPoint.y = (minY + arrowWeight - top).toInt() + } + drawPoint = true + } + } else { + if (tmpPoint.x in left..right) { + if (tmpPoint.x in left..right) { + if (left + tmpPoint.x + arrowWeight > maxX) { + tmpPoint.x = (maxX - arrowWeight.toFloat() - left.toFloat()).toInt() + } else if (left + tmpPoint.x - arrowWeight < minX) { + tmpPoint.x = (minX + arrowWeight - left).toInt() + } + drawPoint = true + } + } + } + return drawPoint + } + + private fun clampPoint(left: Int, top: Int, right: Int, bottom: Int, tmpPoint: Point) { + if (tmpPoint.y < top) { + tmpPoint.y = top + } else if (tmpPoint.y > bottom) { + tmpPoint.y = bottom + } + if (tmpPoint.x < left) { + tmpPoint.x = left + } + if (tmpPoint.x > right) { + tmpPoint.x = right + } + } + } +} diff --git a/neofecttooltip/src/main/java/it/sephiroth/android/library/xtooltip/Typefaces.kt b/neofecttooltip/src/main/java/it/sephiroth/android/library/xtooltip/Typefaces.kt new file mode 100644 index 00000000..c54e7705 --- /dev/null +++ b/neofecttooltip/src/main/java/it/sephiroth/android/library/xtooltip/Typefaces.kt @@ -0,0 +1,43 @@ +package it.sephiroth.android.library.xtooltip + +import android.content.Context +import android.graphics.Typeface +import timber.log.Timber +import java.util.* + +/** + * Created by alessandro crugnola on 12/12/15. + * alessandro.crugnola@gmail.com + * + * + * LICENSE + * Copyright 2015 Alessandro Crugnola + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, + * modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software + * is furnished to do so, subject to the following conditions: + * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS + * BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT + * OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +object Typefaces { + private val FONT_CACHE = Hashtable() + + operator fun get(c: Context, assetPath: String): Typeface? { + synchronized(FONT_CACHE) { + if (!FONT_CACHE.containsKey(assetPath)) { + try { + val t = Typeface.createFromAsset(c.assets, assetPath) + FONT_CACHE[assetPath] = t + } catch (e: Exception) { + Timber.e("Could not get typeface '$assetPath' because ${e.message}") + return null + } + + } + return FONT_CACHE[assetPath] + } + } +} \ No newline at end of file diff --git a/neofecttooltip/src/main/java/it/sephiroth/android/library/xtooltip/Utils.kt b/neofecttooltip/src/main/java/it/sephiroth/android/library/xtooltip/Utils.kt new file mode 100644 index 00000000..7c1549a4 --- /dev/null +++ b/neofecttooltip/src/main/java/it/sephiroth/android/library/xtooltip/Utils.kt @@ -0,0 +1,107 @@ +package it.sephiroth.android.library.xtooltip + +import android.animation.Animator +import android.graphics.Rect +import android.view.View +import android.view.ViewPropertyAnimator + +/** + * Created by alessandro crugnola on 12/12/15. + * alessandro.crugnola@gmail.com + * + * + * LICENSE + * Copyright 2015 Alessandro Crugnola + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, + * modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software + * is furnished to do so, subject to the following conditions: + * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS + * BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT + * OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +internal inline fun Rect.rectContainsWithTolerance(childRect: Rect, t: Int): Boolean { + return this.contains(childRect.left + t, childRect.top + t, childRect.right - t, childRect.bottom - t) +} + +internal inline fun View.addOnAttachStateChangeListener(func: AttachStateChangeListener.() -> Unit): View { + val listener = AttachStateChangeListener() + listener.func() + addOnAttachStateChangeListener(listener) + return this +} + +internal class AttachStateChangeListener : View.OnAttachStateChangeListener { + + private var _onViewAttachedToWindow: ((view: View?, listener: View.OnAttachStateChangeListener) -> Unit)? = null + private var _onViewDetachedFromWindow: ((view: View?, listener: View.OnAttachStateChangeListener) -> Unit)? = null + + fun onViewDetachedFromWindow(func: (view: View?, listener: View.OnAttachStateChangeListener) -> Unit) { + _onViewDetachedFromWindow = func + } + + fun onViewAttachedToWindow(func: (view: View?, listener: View.OnAttachStateChangeListener) -> Unit) { + _onViewAttachedToWindow = func + } + + override fun onViewDetachedFromWindow(v: View?) { + _onViewDetachedFromWindow?.invoke(v, this) + } + + override fun onViewAttachedToWindow(v: View?) { + _onViewAttachedToWindow?.invoke(v, this) + } +} + +internal inline fun ViewPropertyAnimator.setListener( + func: AnimationListener.() -> Unit + ): ViewPropertyAnimator { + val listener = AnimationListener() + listener.func() + setListener(listener) + return this +} + +@Suppress("unused") +internal class AnimationListener : Animator.AnimatorListener { + private var _onAnimationRepeat: ((animation: Animator) -> Unit)? = null + private var _onAnimationEnd: ((animation: Animator) -> Unit)? = null + private var _onAnimationStart: ((animation: Animator) -> Unit)? = null + private var _onAnimationCancel: ((animation: Animator) -> Unit)? = null + + override fun onAnimationRepeat(animation: Animator) { + _onAnimationRepeat?.invoke(animation) + } + + override fun onAnimationCancel(animation: Animator) { + _onAnimationCancel?.invoke(animation) + } + + override fun onAnimationEnd(animation: Animator) { + _onAnimationEnd?.invoke(animation) + } + + override fun onAnimationStart(animation: Animator) { + _onAnimationStart?.invoke(animation) + } + + fun onAnimationRepeat(func: (animation: Animator) -> Unit) { + _onAnimationRepeat = func + } + + fun onAnimationCancel(func: (animation: Animator) -> Unit) { + _onAnimationCancel = func + } + + fun onAnimationEnd(func: (animation: Animator) -> Unit) { + _onAnimationEnd = func + } + + + fun onAnimationStart(func: (animation: Animator) -> Unit) { + _onAnimationStart = func + } + +} \ No newline at end of file diff --git a/neofecttooltip/src/main/java/it/sephiroth/android/library/xtooltip/XTooltip.kt b/neofecttooltip/src/main/java/it/sephiroth/android/library/xtooltip/XTooltip.kt new file mode 100644 index 00000000..09cf7cdf --- /dev/null +++ b/neofecttooltip/src/main/java/it/sephiroth/android/library/xtooltip/XTooltip.kt @@ -0,0 +1,933 @@ +package it.sephiroth.android.library.xtooltip + +import android.animation.ObjectAnimator +import android.animation.ValueAnimator +import android.annotation.SuppressLint +import android.content.Context +import android.content.res.Resources +import android.graphics.PixelFormat +import android.graphics.Point +import android.graphics.Rect +import android.graphics.Typeface +import android.os.Build +import android.os.Handler +import android.os.IBinder +import android.support.annotation.* +import android.text.Html +import android.text.Spannable +import android.view.* +import android.view.ViewGroup +import android.view.ViewGroup.LayoutParams.WRAP_CONTENT +import android.view.animation.AccelerateDecelerateInterpolator +import android.widget.FrameLayout +import android.widget.PopupWindow.INPUT_METHOD_NOT_NEEDED +import android.widget.TextView +import it.sephiroth.android.library.tooltip.R +import timber.log.Timber +import java.lang.ref.WeakReference +import java.util.* + +/** + * Created by alessandro crugnola on 12/12/15. + * alessandro.crugnola@gmail.com + * + * + * LICENSE + * Copyright 2015 Alessandro Crugnola + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, + * modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software + * is furnished to do so, subject to the following conditions: + * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS + * BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT + * OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +class XTooltip private constructor(private val context: Context, builder: Builder) { + + private val windowManager: WindowManager = + context.getSystemService(Context.WINDOW_SERVICE) as WindowManager + var isShowing = false + private set + + private val mGravities = Gravity.values().filter { it != Gravity.CENTER } + private var isVisible = false + private val mSizeTolerance = context.resources.displayMetrics.density * 10 + + private val mLayoutInsetDecor = true + private val mWindowLayoutType = WindowManager.LayoutParams.TYPE_APPLICATION_PANEL + private val mSoftInputMode = INPUT_METHOD_NOT_NEEDED + private val mHandler = Handler() + + private var mPopupView: TooltipViewContainer? = null + private var mText: CharSequence? + private var mAnchorPoint: Point + private var mShowArrow: Boolean + private var mPadding: Int = 0 + private var mActivateDelay: Long + private var mClosePolicy: ClosePolicy + private var mFadeDuration: Long + private var mShowDuration: Long + private var mMaxWidth: Int? = null + private var mTextAppearance: Int + private var mTextGravity: Int + private var mTextViewElevation: Float + private var mTypeface: Typeface? = null + private var mIsCustomView: Boolean = false + private var mTooltipLayoutIdRes = R.layout.textview + private var mTextViewIdRes = android.R.id.text1 + private var mFloatingAnimation: Animation? + private var mAnimator: ValueAnimator? = null + private var mShowOverlay: Boolean + private var mOverlayStyle: Int + private var mActivated = false + private var mHasAnchorView = false + private var mFollowAnchor = false + + private var mViewOverlay: TooltipOverlay? = null + private var mDrawable: TooltipTextDrawable? = null + private var mAnchorView: WeakReference? = null + private lateinit var mContentView: View + private lateinit var mTextView: TextView + + private val hideRunnable = Runnable { hide() } + private val activateRunnable = Runnable { mActivated = true } + + var contentView: View? = null + get() = mContentView + private set + + private var predrawListener = ViewTreeObserver.OnPreDrawListener { + if (mHasAnchorView && null != mAnchorView?.get()) { + val view = mAnchorView?.get()!! + if (!view.viewTreeObserver.isAlive) { + removeListeners(view) + } else { + if (isShowing && null != mPopupView) { + view.getLocationOnScreen(mNewLocation) + + if (mOldLocation == null) { + mOldLocation = intArrayOf(mNewLocation[0], mNewLocation[1]) + } + + if (mOldLocation!![0] != mNewLocation[1] || mOldLocation!![1] != mNewLocation[1]) { + offsetBy( + mNewLocation[0] - mOldLocation!![0], + mNewLocation[1] - mOldLocation!![1] + ) + } + } + } + } + true + } + + init { + val theme = context.theme + .obtainStyledAttributes( + null, + R.styleable.TooltipLayout, + builder.defStyleAttr, + builder.defStyleRes + ) + this.mPadding = theme.getDimensionPixelSize(R.styleable.TooltipLayout_ttlm_padding, 30) + this.mTextAppearance = + theme.getResourceId(R.styleable.TooltipLayout_android_textAppearance, 0) + this.mTextGravity = theme + .getInt( + R.styleable.TooltipLayout_android_gravity, + android.view.Gravity.TOP or android.view.Gravity.START + ) + this.mTextViewElevation = theme.getDimension(R.styleable.TooltipLayout_ttlm_elevation, 0f) + mOverlayStyle = + theme.getResourceId( + R.styleable.TooltipLayout_ttlm_overlayStyle, + R.style.ToolTipOverlayDefaultStyle + ) + val font = theme.getString(R.styleable.TooltipLayout_ttlm_font) + theme.recycle() + + this.mText = builder.text + this.mActivateDelay = builder.activateDelay + this.mAnchorPoint = builder.point!! + this.mClosePolicy = builder.closePolicy + this.mMaxWidth = builder.maxWidth + this.mFloatingAnimation = builder.floatingAnimation + this.mShowDuration = builder.showDuration + this.mFadeDuration = builder.fadeDuration + this.mShowOverlay = builder.overlay + this.mShowArrow = builder.showArrow && builder.layoutId == null + builder.anchorView?.let { + this.mAnchorView = WeakReference(it) + this.mHasAnchorView = true + this.mFollowAnchor = builder.followAnchor + } + + builder.layoutId?.let { + mTextViewIdRes = builder.textId!! + mTooltipLayoutIdRes = builder.layoutId!! + mIsCustomView = true + } ?: run { + mDrawable = TooltipTextDrawable(context, builder) + } + + builder.typeface?.let { + mTypeface = it + } ?: run { + font?.let { mTypeface = Typefaces[context, it] } + } + } + + private var mFailureFunc: ((tooltip: XTooltip) -> Unit)? = null + private var mShownFunc: ((tooltip: XTooltip) -> Unit)? = null + private var mHiddenFunc: ((tooltip: XTooltip) -> Unit)? = null + + @Suppress("UNUSED") + fun doOnFailure(func: ((tooltip: XTooltip) -> Unit)?): XTooltip { + mFailureFunc = func + return this + } + + @Suppress("UNUSED") + fun doOnShown(func: ((tooltip: XTooltip) -> Unit)?): XTooltip { + mShownFunc = func + return this + } + + @Suppress("UNUSED") + fun doOnHidden(func: ((tooltip: XTooltip) -> Unit)?): XTooltip { + mHiddenFunc = func + return this + } + + @SuppressLint("RtlHardcoded") + private fun createPopupLayoutParams(token: IBinder): WindowManager.LayoutParams { + val p = WindowManager.LayoutParams() + p.gravity = android.view.Gravity.LEFT or android.view.Gravity.TOP + p.width = WindowManager.LayoutParams.MATCH_PARENT + p.height = WindowManager.LayoutParams.MATCH_PARENT + p.format = PixelFormat.TRANSLUCENT + p.flags = computeFlags(p.flags) + p.type = mWindowLayoutType + p.token = token + p.softInputMode = mSoftInputMode + p.title = "ToolTip:" + Integer.toHexString(hashCode()) + return p + } + + + private fun computeFlags(curFlags: Int): Int { + var curFlags1 = curFlags + curFlags1 = curFlags1 or WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL + + curFlags1 = if (mClosePolicy.inside() || mClosePolicy.outside()) { + curFlags1 and WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE.inv() + } else { + curFlags1 or WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE + } + + if (!mClosePolicy.consume()) { + curFlags1 = curFlags1 or WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE + } + curFlags1 = curFlags1 or WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM + curFlags1 = curFlags1 or WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH + curFlags1 = curFlags1 or WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS + curFlags1 = curFlags1 or WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN + // curFlags1 = curFlags1 or WindowManager.LayoutParams.FLAG_LAYOUT_INSET_DECOR + return curFlags1 + } + + @Suppress("UNUSED_PARAMETER") + private fun preparePopup(params: WindowManager.LayoutParams, gravity: Gravity) { + mPopupView?.let { + if (mViewOverlay != null && gravity == Gravity.CENTER) { + it.removeView(mViewOverlay) + mViewOverlay = null + } + } ?: run { + val viewContainer = TooltipViewContainer(context) + + if (mShowOverlay && mViewOverlay == null) { + mViewOverlay = TooltipOverlay(context, 0, mOverlayStyle) + with(mViewOverlay!!) { + adjustViewBounds = true + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ) + } + } + + val contentView = + LayoutInflater.from(context).inflate(mTooltipLayoutIdRes, viewContainer, false) + + mFloatingAnimation?.let { contentView.setPadding(it.radius, it.radius, it.radius, it.radius) } + + mTextView = contentView.findViewById(mTextViewIdRes) + + with(mTextView) { + mDrawable?.let { background = it } + + if (mShowArrow) + setPadding(mPadding, mPadding, mPadding, mPadding) + else + setPadding(mPadding / 2, mPadding / 2, mPadding / 2, mPadding / 2) + + if (mTextAppearance != 0) { + @Suppress("DEPRECATION") + setTextAppearance(context, mTextAppearance) + } + + if (!mIsCustomView && mTextViewElevation > 0 && Build.VERSION.SDK_INT >= 21) { + elevation = mTextViewElevation + translationZ = mTextViewElevation + outlineProvider = ViewOutlineProvider.BACKGROUND + } + this.gravity = mTextGravity + + text = if (mText is Spannable) { + mText + } else { + @Suppress("DEPRECATION") + Html.fromHtml(this@XTooltip.mText as String) + } + + mMaxWidth?.let { maxWidth = it } + mTypeface?.let { typeface = it } + } + + if (null != mViewOverlay) { + viewContainer.addView( + mViewOverlay, + FrameLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT) + ) + } + + viewContainer.addView(contentView, FrameLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT)) + viewContainer.measureAllChildren = true + viewContainer.measure(View.MeasureSpec.UNSPECIFIED, View.MeasureSpec.UNSPECIFIED) + + Timber.i("viewContainer size: ${viewContainer.measuredWidth}, ${viewContainer.measuredHeight}") + Timber.i("contentView size: ${contentView.measuredWidth}, ${contentView.measuredHeight}") + + mTextView.addOnAttachStateChangeListener { + onViewAttachedToWindow { _: View?, _: View.OnAttachStateChangeListener -> + mAnimator?.start() + + if (mShowDuration > 0) { + mHandler.removeCallbacks(hideRunnable) + mHandler.postDelayed(hideRunnable, mShowDuration) + } + + mHandler.removeCallbacks(activateRunnable) + mHandler.postDelayed(activateRunnable, mActivateDelay) + } + + onViewDetachedFromWindow { view: View?, listener: View.OnAttachStateChangeListener -> + view?.removeOnAttachStateChangeListener(listener) + mAnimator?.cancel() + removeCallbacks() + } + } + + mContentView = contentView + mPopupView = viewContainer + } + } + + private fun findPosition( + parent: View, + anchor: View?, + offset: Point, + gravities: ArrayList, + params: WindowManager.LayoutParams, + fitToScreen: Boolean = false + ): Positions? { + + if (null == mPopupView) return null + if (gravities.isEmpty()) return null + + val gravity = gravities.removeAt(0) + + Timber.i("findPosition. $gravity, offset: $offset") + + val displayFrame = Rect() + val anchorPosition = intArrayOf(0, 0) + val centerPosition = Point(offset) + + parent.getWindowVisibleDisplayFrame(displayFrame) + + anchor?.let { + anchor.getLocationOnScreen(anchorPosition) + centerPosition.x += anchorPosition[0] + anchor.width / 2 + centerPosition.y += anchorPosition[1] + anchor.height / 2 + + when (gravity) { + Gravity.LEFT -> { + anchorPosition[1] += anchor.height / 2 + } + Gravity.RIGHT -> { + anchorPosition[0] += anchor.width + anchorPosition[1] += anchor.height / 2 + } + Gravity.TOP -> { + anchorPosition[0] += anchor.width / 2 + } + Gravity.BOTTOM -> { + anchorPosition[0] += anchor.width / 2 + anchorPosition[1] += anchor.height + } + Gravity.CENTER -> { + anchorPosition[0] += anchor.width / 2 + anchorPosition[1] += anchor.height / 2 + } + } + } + + anchorPosition[0] += offset.x + anchorPosition[1] += offset.y + + Timber.d("anchorPosition: ${anchorPosition[0]}, ${anchorPosition[1]}") + Timber.d("centerPosition: $centerPosition") + Timber.d("displayFrame: $displayFrame") + + val w: Int = mContentView.measuredWidth + val h: Int = mContentView.measuredHeight + + Timber.v("contentView size: $w, $h") + + val contentPosition = Point() + val arrowPosition = Point() + val radius = (mFloatingAnimation?.radius ?: run { 0 }) + + when (gravity) { + Gravity.LEFT -> { + contentPosition.x = anchorPosition[0] - w + contentPosition.y = anchorPosition[1] - h / 2 + arrowPosition.y = h / 2 - mPadding / 2 - radius + } + Gravity.TOP -> { + contentPosition.x = anchorPosition[0] - w / 2 + contentPosition.y = anchorPosition[1] - h + arrowPosition.x = w / 2 - mPadding / 2 - radius + } + Gravity.RIGHT -> { + contentPosition.x = anchorPosition[0] + contentPosition.y = anchorPosition[1] - h / 2 + arrowPosition.y = h / 2 - mPadding / 2 - radius + } + Gravity.BOTTOM -> { + contentPosition.x = anchorPosition[0] - w / 2 + contentPosition.y = anchorPosition[1] + arrowPosition.x = w / 2 - mPadding / 2 - radius + } + Gravity.CENTER -> { + contentPosition.x = anchorPosition[0] - w / 2 + contentPosition.y = anchorPosition[1] - h / 2 + } + } + + anchor?.let { + // pass + } ?: run { + mViewOverlay?.let { + when (gravity) { + Gravity.LEFT -> contentPosition.x -= it.measuredWidth / 2 + Gravity.RIGHT -> contentPosition.x += it.measuredWidth / 2 + + Gravity.TOP -> contentPosition.y -= it.measuredHeight / 2 + Gravity.BOTTOM -> contentPosition.y += it.measuredHeight / 2 + Gravity.CENTER -> { + } + } + } + } + + Timber.d("arrowPosition: $arrowPosition") + Timber.d("centerPosition: $centerPosition") + Timber.d("contentPosition: $contentPosition") + + if (fitToScreen) { + val finalRect = Rect( + contentPosition.x, + contentPosition.y, + contentPosition.x + w, + contentPosition.y + h + ) + if (!displayFrame.rectContainsWithTolerance(finalRect, mSizeTolerance.toInt())) { + Timber.e("content won't fit! $displayFrame, $finalRect") + return findPosition(parent, anchor, offset, gravities, params, fitToScreen) + } + } + + return Positions(arrowPosition, centerPosition, contentPosition, gravity, params) + } + + private var mCurrentPosition: Positions? = null + private var mOldLocation: IntArray? = null + private var mNewLocation: IntArray = intArrayOf(0, 0) + + private fun invokePopup(positions: Positions?): XTooltip? { + positions?.let { + isShowing = true + mCurrentPosition = positions + + setupAnimation(positions.gravity) + + if (mHasAnchorView && mAnchorView?.get() != null) { + setupListeners(mAnchorView!!.get()!!) + } + + mDrawable?.setAnchor( + it.gravity, + if (!mShowArrow) 0 else mPadding / 2, + if (!mShowArrow) null else it.arrowPoint + ) + + offsetBy(0, 0) + + it.params.packageName = context.packageName + mPopupView?.fitsSystemWindows = mLayoutInsetDecor + windowManager.addView(mPopupView, it.params) + Timber.v("windowManager.addView: $mPopupView") + fadeIn(mFadeDuration) + return this + } ?: run { + mFailureFunc?.invoke(this) + return null + } + } + + private fun offsetBy(xoff: Int, yoff: Int) { + if (isShowing && mPopupView != null && mCurrentPosition != null) { + Timber.i("offsetBy($xoff, $yoff)") + mContentView.translationX = mCurrentPosition!!.contentPoint.x.toFloat() + xoff + mContentView.translationY = mCurrentPosition!!.contentPoint.y.toFloat() + yoff + + mViewOverlay?.let { viewOverlay -> + viewOverlay.translationX = mCurrentPosition!!.centerPoint.x.toFloat() - viewOverlay.measuredWidth / + 2 + xoff + viewOverlay.translationY = mCurrentPosition!!.centerPoint.y.toFloat() - viewOverlay.measuredHeight / + 2 + yoff + } + } + } + + private fun setupListeners(anchorView: View) { + anchorView.addOnAttachStateChangeListener { + onViewDetachedFromWindow { view: View?, listener: View.OnAttachStateChangeListener -> + Timber.i("anchorView detached from parent") + view?.removeOnAttachStateChangeListener(listener) + dismiss() + } + } + + if (mFollowAnchor) { + anchorView.viewTreeObserver.addOnPreDrawListener(predrawListener) + } + } + + private fun removeListeners(anchorView: View?) { + if (mFollowAnchor) { + anchorView?.viewTreeObserver?.removeOnPreDrawListener(predrawListener) + } + } + + private fun setupAnimation(gravity: Gravity) { + if (mTextView === mContentView || null == mFloatingAnimation) { + return + } + + val endValue = mFloatingAnimation!!.radius + val duration = mFloatingAnimation!!.duration + + val direction: Int = if (mFloatingAnimation!!.direction == 0) { + if (gravity === Gravity.TOP || gravity === Gravity.BOTTOM) 2 else 1 + } else { + mFloatingAnimation!!.direction + } + + val property = if (direction == 2) "translationY" else "translationX" + mAnimator = + ObjectAnimator.ofFloat(mTextView, property, -endValue.toFloat(), endValue.toFloat()) + mAnimator!!.run { + setDuration(duration) + interpolator = AccelerateDecelerateInterpolator() + repeatCount = ValueAnimator.INFINITE + repeatMode = ValueAnimator.REVERSE + } + } + + fun show(parent: View, gravity: Gravity, fitToScreen: Boolean = false) { + if (isShowing || (mHasAnchorView && mAnchorView?.get() == null)) return + + isVisible = false + + val params = createPopupLayoutParams(parent.windowToken) + preparePopup(params, gravity) + + val gravities = mGravities.toCollection(ArrayList()) + gravities.remove(gravity) + gravities.add(0, gravity) + + invokePopup( + findPosition( + parent, + mAnchorView?.get(), + mAnchorPoint, + gravities, + params, + fitToScreen + ) + ) + } + + fun hide() { + Timber.i("hide") + if (!isShowing) return + fadeOut(mFadeDuration) + } + + fun dismiss() { + if (isShowing && mPopupView != null) { + removeListeners(mAnchorView?.get()) + removeCallbacks() + windowManager.removeView(mPopupView) + Timber.v("dismiss: $mPopupView") + mPopupView = null + isShowing = false + isVisible = false + + mHiddenFunc?.invoke(this) + } + } + + private fun removeCallbacks() { + mHandler.removeCallbacks(hideRunnable) + mHandler.removeCallbacks(activateRunnable) + } + + private fun fadeIn(fadeDuration: Long) { + if (!isShowing || isVisible) return + + isVisible = true + + if (fadeDuration > 0 && null != mPopupView) { + mPopupView!!.alpha = 0F + mPopupView!!.animate() + .setDuration(mFadeDuration) + .alpha(1f).start() + } + mShownFunc?.invoke(this) + } + + private fun fadeOut(fadeDuration: Long) { + if (!isShowing || !isVisible) return + + isVisible = false + removeCallbacks() + + Timber.i("fadeOut($fadeDuration)") + + if (fadeDuration > 0) { + mPopupView?.let { popupView -> + popupView.clearAnimation() + popupView.animate() + .alpha(0f) + .setDuration(fadeDuration) + .setListener { + onAnimationEnd { + popupView.visibility = View.INVISIBLE + dismiss() + } + } + .start() + } + } else { + dismiss() + } + } + + inner class TooltipViewContainer(context: Context) : FrameLayout(context) { + + init { + clipChildren = false + clipToPadding = false + } + + private var sizeChange: ((w: Int, h: Int) -> Unit)? = null + + override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { + super.onSizeChanged(w, h, oldw, oldh) + sizeChange?.invoke(w, h) + } + + override fun dispatchKeyEvent(event: KeyEvent): Boolean { + if (!isShowing || !isVisible || !mActivated) return super.dispatchKeyEvent(event) + Timber.i("dispatchKeyEvent: $event") + + if (event.keyCode == KeyEvent.KEYCODE_BACK) { + if (keyDispatcherState == null) { + return super.dispatchKeyEvent(event) + } + + if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0) { + keyDispatcherState?.startTracking(event, this) + return true + } else if (event.action == KeyEvent.ACTION_UP) { + val state = keyDispatcherState + if (state != null && state.isTracking(event) && !event.isCanceled) { + Timber.v("Back pressed, close the tooltip") + hide() + return true + } + } + return super.dispatchKeyEvent(event) + } else { + return super.dispatchKeyEvent(event) + } + } + + @SuppressLint("ClickableViewAccessibility") + override fun onTouchEvent(event: MotionEvent): Boolean { + if (!isShowing || !isVisible || !mActivated) return false + + Timber.i("onTouchEvent: $event") + Timber.d("event position: ${event.x}, ${event.y}") + + val r1 = Rect() + mTextView.getGlobalVisibleRect(r1) + val containsTouch = r1.contains(event.x.toInt(), event.y.toInt()) + + if (mClosePolicy.anywhere()) { + hide() + } else if (mClosePolicy.inside() && containsTouch) { + hide() + } else if (mClosePolicy.outside() && !containsTouch) { + hide() + } + + return mClosePolicy.consume() + } + } + + private data class Positions( + val arrowPoint: Point, + val centerPoint: Point, + val contentPoint: Point, + val gravity: Gravity, + val params: WindowManager.LayoutParams + ) + + enum class Gravity { + LEFT, RIGHT, TOP, BOTTOM, CENTER + } + + data class Animation(val radius: Int, val direction: Int, val duration: Long) { + + @Suppress("unused") + companion object { + val DEFAULT = Animation(8, 0, 400) + val SLOW = Animation(4, 0, 600) + } + } + + @Suppress("unused") + class Builder(private val context: Context) { + internal var point: Point? = null + internal var closePolicy = ClosePolicy.TOUCH_INSIDE_CONSUME + internal var text: CharSequence? = null + internal var anchorView: View? = null + internal var maxWidth: Int? = null + internal var defStyleRes = R.style.ToolTipLayoutDefaultStyle + internal var defStyleAttr = R.attr.ttlm_defaultStyle + internal var typeface: Typeface? = null + internal var overlay = true + internal var floatingAnimation: Animation? = null + internal var showDuration: Long = 0 + internal var fadeDuration: Long = 100 + internal var showArrow = true + internal var activateDelay = 0L + internal var followAnchor = false + + @LayoutRes + internal var layoutId: Int? = null + + @IdRes + internal var textId: Int? = null + + fun typeface(value: Typeface?): Builder { + this.typeface = value + return this + } + + fun styleId(@StyleRes styleId: Int?): Builder { + styleId?.let { + this.defStyleAttr = 0 + this.defStyleRes = it + } ?: run { + this.defStyleRes = R.style.ToolTipLayoutDefaultStyle + this.defStyleAttr = R.attr.ttlm_defaultStyle + } + return this + } + + fun customView(@LayoutRes layoutId: Int, @IdRes textId: Int): Builder { + this.layoutId = layoutId + this.textId = textId + return this + } + + fun activateDelay(value: Long): Builder { + this.activateDelay = value + return this + } + + fun arrow(value: Boolean): Builder { + this.showArrow = value + return this + } + + fun fadeDuration(value: Long): Builder { + this.fadeDuration = value + return this + } + + fun showDuration(value: Long): Builder { + this.showDuration = value + return this + } + + fun floatingAnimation(value: Animation?): Builder { + this.floatingAnimation = value + return this + } + + fun maxWidth(w: Int): Builder { + this.maxWidth = w + return this + } + + fun maxWidth(res: Resources, @DimenRes dimension: Int): Builder { + return maxWidth(res.getDimensionPixelSize(dimension)) + } + + fun overlay(value: Boolean): Builder { + this.overlay = value + return this + } + + fun anchor(x: Int, y: Int): Builder { + this.anchorView = null + this.point = Point(x, y) + return this + } + + fun anchor(view: View, xoff: Int = 0, yoff: Int = 0, follow: Boolean = false): Builder { + this.anchorView = view + this.followAnchor = follow + this.point = Point(xoff, yoff) + return this + } + + fun text(text: CharSequence): Builder { + this.text = text + return this + } + + fun text(@StringRes text: Int): Builder { + this.text = context.getString(text) + return this + } + + fun text(@StringRes text: Int, vararg args: Any): Builder { + this.text = context.getString(text, args) + return this + } + + @JvmOverloads + fun closePolicy(policy: ClosePolicy, milliseconds:Long = 0): Builder { + this.closePolicy = policy + this.showDuration = milliseconds + Timber.v("closePolicy: $policy") + return this + } + + fun create(): XTooltip { + if (null == anchorView && null == point) { + throw IllegalArgumentException("missing anchor point or anchor view") + } + return XTooltip(context, this) + } + } +} + +class ClosePolicy internal constructor(private val policy: Int) { + + fun consume() = policy and CONSUME == CONSUME + + fun inside(): Boolean { + return policy and TOUCH_INSIDE == TOUCH_INSIDE + } + + fun outside(): Boolean { + return policy and TOUCH_OUTSIDE == TOUCH_OUTSIDE + } + + fun anywhere() = inside() and outside() + + override fun toString(): String { + return "ClosePolicy{policy: $policy, inside:${inside()}, outside: ${outside()}, anywhere: ${anywhere()}, consume: ${consume()}}" + } + + @Suppress("unused") + class Builder { + private var policy = NONE + + fun consume(value: Boolean): Builder { + policy = if (value) policy or CONSUME else policy and CONSUME.inv() + return this + } + + fun inside(value: Boolean): Builder { + policy = if (value) policy or TOUCH_INSIDE else policy and TOUCH_INSIDE.inv() + return this + } + + fun outside(value: Boolean): Builder { + policy = if (value) policy or TOUCH_OUTSIDE else policy and TOUCH_OUTSIDE.inv() + return this + } + + fun clear() { + policy = NONE + } + + fun build() = ClosePolicy(policy) + } + + @Suppress("unused") + companion object { + private const val NONE = 0 + private const val TOUCH_INSIDE = 1 shl 1 + private const val TOUCH_OUTSIDE = 1 shl 2 + private const val CONSUME = 1 shl 3 + + @JvmField + val TOUCH_NONE = ClosePolicy(NONE) + @JvmField + val TOUCH_INSIDE_CONSUME = ClosePolicy(TOUCH_INSIDE or CONSUME) + @JvmField + val TOUCH_INSIDE_NO_CONSUME = ClosePolicy(TOUCH_INSIDE) + @JvmField + val TOUCH_OUTSIDE_CONSUME = ClosePolicy(TOUCH_OUTSIDE or CONSUME) + @JvmField + val TOUCH_OUTSIDE_NO_CONSUME = ClosePolicy(TOUCH_OUTSIDE) + @JvmField + val TOUCH_ANYWHERE_NO_CONSUME = ClosePolicy(TOUCH_INSIDE or TOUCH_OUTSIDE) + @JvmField + val TOUCH_ANYWHERE_CONSUME = ClosePolicy(TOUCH_INSIDE or TOUCH_OUTSIDE or CONSUME) + } + +} \ No newline at end of file diff --git a/neofecttooltip/src/main/res/layout/textview.xml b/neofecttooltip/src/main/res/layout/textview.xml new file mode 100644 index 00000000..a682f61e --- /dev/null +++ b/neofecttooltip/src/main/res/layout/textview.xml @@ -0,0 +1,21 @@ + + + + + diff --git a/library/src/main/res/layout/tooltip_textview.xml b/neofecttooltip/src/main/res/layout/tooltip_textview.xml similarity index 100% rename from library/src/main/res/layout/tooltip_textview.xml rename to neofecttooltip/src/main/res/layout/tooltip_textview.xml diff --git a/library/src/main/res/values/attrs.xml b/neofecttooltip/src/main/res/values/attrs.xml similarity index 82% rename from library/src/main/res/values/attrs.xml rename to neofecttooltip/src/main/res/values/attrs.xml index 5b20d3fa..bf65131d 100644 --- a/library/src/main/res/values/attrs.xml +++ b/neofecttooltip/src/main/res/values/attrs.xml @@ -11,10 +11,15 @@ + + + + + diff --git a/library/src/main/res/values/dimens.xml b/neofecttooltip/src/main/res/values/dimens.xml similarity index 67% rename from library/src/main/res/values/dimens.xml rename to neofecttooltip/src/main/res/values/dimens.xml index 3b5e156e..d2407503 100644 --- a/library/src/main/res/values/dimens.xml +++ b/neofecttooltip/src/main/res/values/dimens.xml @@ -1,7 +1,8 @@ 20dip - 4dip + 20dip 0dip 2dp + 5dip \ No newline at end of file diff --git a/library/src/main/res/values/styles.xml b/neofecttooltip/src/main/res/values/styles.xml similarity index 87% rename from library/src/main/res/values/styles.xml rename to neofecttooltip/src/main/res/values/styles.xml index 9765a19c..a99ee40a 100644 --- a/library/src/main/res/values/styles.xml +++ b/neofecttooltip/src/main/res/values/styles.xml @@ -8,9 +8,12 @@ @dimen/ttlm_default_stroke_weight @dimen/ttlm_default_corner_radius 1.4 + 0dp + 0dp ?android:attr/textAppearanceSmall @style/ToolTipOverlayDefaultStyle @dimen/ttlm_default_elevation + @dimen/ttlm_default_margin