diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d09b0b2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ + +.gradle/ +.ides/ +.idea/ diff --git a/OnBoardingBubbles/.gitignore b/OnBoardingBubbles/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/OnBoardingBubbles/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/OnBoardingBubbles/build.gradle b/OnBoardingBubbles/build.gradle new file mode 100644 index 0000000..155c040 --- /dev/null +++ b/OnBoardingBubbles/build.gradle @@ -0,0 +1,38 @@ +plugins { + id 'com.android.library' + id 'org.jetbrains.kotlin.android' +} + +android { + compileSdk 30 + + defaultConfig { + minSdk 21 + targetSdk 30 + + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles "consumer-rules.pro" + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = '1.8' + } +} + +dependencies { + + implementation 'androidx.core:core-ktx:1.5.0' + implementation 'androidx.appcompat:appcompat:1.0.2' + implementation 'com.google.android.material:material:1.1.0-alpha09' + implementation 'androidx.constraintlayout:constraintlayout:1.1.3' +} \ No newline at end of file diff --git a/OnBoardingBubbles/consumer-rules.pro b/OnBoardingBubbles/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/OnBoardingBubbles/proguard-rules.pro b/OnBoardingBubbles/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/OnBoardingBubbles/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/OnBoardingBubbles/src/main/AndroidManifest.xml b/OnBoardingBubbles/src/main/AndroidManifest.xml new file mode 100644 index 0000000..8f9f11e --- /dev/null +++ b/OnBoardingBubbles/src/main/AndroidManifest.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/OnBoardingBubbles/src/main/java/com/mohsin/onboardingbubbles/AnimationUtils.kt b/OnBoardingBubbles/src/main/java/com/mohsin/onboardingbubbles/AnimationUtils.kt new file mode 100644 index 0000000..33a8583 --- /dev/null +++ b/OnBoardingBubbles/src/main/java/com/mohsin/onboardingbubbles/AnimationUtils.kt @@ -0,0 +1,53 @@ +package com.mohsin.onboardingbubbles + +import android.animation.ObjectAnimator +import android.animation.PropertyValuesHolder +import android.view.View +import android.view.animation.AlphaAnimation +import android.view.animation.Animation +import android.view.animation.DecelerateInterpolator +import android.view.animation.ScaleAnimation + +object AnimationUtils { + + fun getScaleAnimation(offset: Int, duration: Int): Animation { + val anim = ScaleAnimation( + 0f, 1f, // Start and end values for the X axis scaling + 0f, 1f, // Start and end values for the Y axis scaling + Animation.RELATIVE_TO_SELF, 0.5f, // Pivot point of X scaling + Animation.RELATIVE_TO_SELF, 0.5f + ) // Pivot point of Y scaling + anim.fillAfter = true + anim.startOffset = offset.toLong() + anim.duration = duration.toLong() + return anim + } + + fun getFadeInAnimation(offset: Int, duration: Int): Animation { + val fadeIn = AlphaAnimation(0f, 1f) + fadeIn.startOffset = offset.toLong() + fadeIn.interpolator = DecelerateInterpolator() + fadeIn.duration = duration.toLong() + return fadeIn + } + + fun setBouncingAnimation(view: View, offset: Int, duration: Int): View { + + val objAnim = ObjectAnimator.ofPropertyValuesHolder( + view, + PropertyValuesHolder.ofFloat("scaleX", 1.05f), + PropertyValuesHolder.ofFloat("scaleY", 1.05f) + ) + objAnim.duration = duration.toLong() + objAnim.startDelay = offset.toLong() + objAnim.repeatCount = ObjectAnimator.INFINITE + objAnim.repeatMode = ObjectAnimator.REVERSE + objAnim.start() + return view + } + + fun setAnimationToView(view: View, animation: Animation): View { + view.startAnimation(animation) + return view + } +} \ No newline at end of file diff --git a/OnBoardingBubbles/src/main/java/com/mohsin/onboardingbubbles/BubbleMessageView.kt b/OnBoardingBubbles/src/main/java/com/mohsin/onboardingbubbles/BubbleMessageView.kt new file mode 100644 index 0000000..6db0fb7 --- /dev/null +++ b/OnBoardingBubbles/src/main/java/com/mohsin/onboardingbubbles/BubbleMessageView.kt @@ -0,0 +1,367 @@ +package com.mohsin.onboardingbubbles + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.Path +import android.graphics.RectF +import android.graphics.drawable.Drawable +import android.util.AttributeSet +import android.util.TypedValue +import android.view.View +import android.widget.Button +import android.widget.ImageView +import android.widget.TextView +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.content.ContextCompat +import java.lang.ref.WeakReference +import java.util.ArrayList +import kotlin.math.roundToInt + +class BubbleMessageView : ConstraintLayout { + + companion object { + private const val WIDTH_ARROW = 20 + } + + private var itemView: View? = null + + private var imageViewIcon: ImageView? = null + private var textViewTitle: TextView? = null + private var textViewSubtitle: TextView? = null + private var imageViewClose: ImageView? = null + private var showCaseMessageViewLayout: ConstraintLayout? = null + private var nextButton: Button? = null + + private var targetViewScreenLocation: RectF? = null + private var mBackgroundColor: Int = ContextCompat.getColor(context, R.color.blue_default) + private var arrowPositionList = ArrayList() + + private var paint: Paint? = null + + constructor(context: Context) : super(context) { + initView() + } + + constructor(context: Context, builder: Builder) : super(context) { + initView() + setAttributes(builder) + setBubbleListener(builder) + } + + constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) { + initView() + } + + private fun initView() { + setWillNotDraw(false) + + inflateXML() + bindViews() + } + + private fun inflateXML() { + itemView = inflate(context, R.layout.view_bubble_message, this) + } + + private fun bindViews() { + imageViewIcon = findViewById(R.id.imageViewShowCase) + imageViewClose = findViewById(R.id.imageViewShowCaseClose) + textViewTitle = findViewById(R.id.textViewShowCaseTitle) + textViewSubtitle = findViewById(R.id.textViewShowCaseText) + showCaseMessageViewLayout = findViewById(R.id.showCaseMessageViewLayout) + nextButton = findViewById(R.id.nextButton) + } + + private fun setAttributes(builder: Builder) { + if (builder.mImage != null) { + imageViewIcon?.visibility = View.VISIBLE + imageViewIcon?.setImageDrawable(builder.mImage!!) + } + if (builder.mCloseAction != null) { + imageViewClose?.visibility = View.VISIBLE + imageViewClose?.setImageDrawable(builder.mCloseAction!!) + } + + if (builder.mDisableCloseAction != null && builder.mDisableCloseAction!!) { + imageViewClose?.visibility = View.INVISIBLE + } + + builder.mTitle?.let { + textViewTitle?.visibility = View.VISIBLE + textViewTitle?.text = builder.mTitle + } + builder.mSubtitle?.let { + textViewSubtitle?.visibility = View.VISIBLE + textViewSubtitle?.text = builder.mSubtitle + } + builder.mTextColor?.let { + textViewTitle?.setTextColor(builder.mTextColor!!) + textViewSubtitle?.setTextColor(builder.mTextColor!!) + } + builder.mTitleTextSize?.let { + textViewTitle?.setTextSize( + TypedValue.COMPLEX_UNIT_SP, + builder.mTitleTextSize!!.toFloat() + ) + } + builder.mSubtitleTextSize?.let { + textViewSubtitle?.setTextSize( + TypedValue.COMPLEX_UNIT_SP, + builder.mSubtitleTextSize!!.toFloat() + ) + } + builder.mBackgroundColor?.let { mBackgroundColor = builder.mBackgroundColor!! } + arrowPositionList = builder.mArrowPosition + targetViewScreenLocation = builder.mTargetViewScreenLocation + + if (builder.mShowNextButton) { + nextButton?.visibility = View.VISIBLE + } + } + + private fun setBubbleListener(builder: Builder) { + imageViewClose?.setOnClickListener { builder.mListener?.onCloseActionImageClick() } + itemView?.setOnClickListener { builder.mListener?.onBubbleClick() } + nextButton?.setOnClickListener { builder.mListener?.onNextButtonClick() } + } + + //REGION AUX FUNCTIONS + + private fun getViewWidth(): Int = width + + private fun getMargin(): Int = ScreenUtils.dpToPx(20) + + private fun getSecurityArrowMargin(): Int = + getMargin() + ScreenUtils.dpToPx(2 * WIDTH_ARROW / 3) + + //REGION SHOW ITEM + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + + prepareToDraw() + drawRectangle(canvas) + + for (arrowPosition in arrowPositionList) { + drawArrow(canvas, arrowPosition, targetViewScreenLocation) + } + } + + private fun prepareToDraw() { + paint = Paint(Paint.ANTI_ALIAS_FLAG) + paint!!.color = mBackgroundColor + paint!!.style = Paint.Style.FILL + paint!!.strokeWidth = 4.0f + } + + private fun drawRectangle(canvas: Canvas) { + val rect = RectF( + getMargin().toFloat(), + getMargin().toFloat(), + getViewWidth() - getMargin().toFloat(), + height - getMargin().toFloat() + ) + canvas.drawRoundRect(rect, 10f, 10f, paint!!) + } + + private fun drawArrow( + canvas: Canvas, + arrowPosition: BubbleShowCase.ArrowPosition, + targetViewLocationOnScreen: RectF? + ) { + val xPosition: Int + val yPosition: Int + + when (arrowPosition) { + BubbleShowCase.ArrowPosition.LEFT -> { + xPosition = getMargin() + yPosition = + if (targetViewLocationOnScreen != null) getArrowVerticalPositionDependingOnTarget( + targetViewLocationOnScreen + ) else height / 2 + } + BubbleShowCase.ArrowPosition.RIGHT -> { + xPosition = getViewWidth() - getMargin() + yPosition = + if (targetViewLocationOnScreen != null) getArrowVerticalPositionDependingOnTarget( + targetViewLocationOnScreen + ) else height / 2 + } + BubbleShowCase.ArrowPosition.TOP -> { + xPosition = + if (targetViewLocationOnScreen != null) getArrowHorizontalPositionDependingOnTarget( + targetViewLocationOnScreen + ) else width / 2 + yPosition = getMargin() + } + BubbleShowCase.ArrowPosition.BOTTOM -> { + xPosition = + if (targetViewLocationOnScreen != null) getArrowHorizontalPositionDependingOnTarget( + targetViewLocationOnScreen + ) else width / 2 + yPosition = height - getMargin() + } + } + + drawRhombus(canvas, paint, xPosition, yPosition, ScreenUtils.dpToPx(WIDTH_ARROW)) + } + + private fun getArrowHorizontalPositionDependingOnTarget(targetViewLocationOnScreen: RectF?): Int { + val xPosition: Int = when { + isOutOfRightBound(targetViewLocationOnScreen) -> width - getSecurityArrowMargin() + isOutOfLeftBound(targetViewLocationOnScreen) -> getSecurityArrowMargin() + else -> (targetViewLocationOnScreen!!.centerX() - ScreenUtils.getAxisXPositionOfViewOnScreen( + this + )).roundToInt() + } + return xPosition + } + + private fun getArrowVerticalPositionDependingOnTarget(targetViewLocationOnScreen: RectF?): Int { + val yPosition: Int = when { + isOutOfBottomBound(targetViewLocationOnScreen) -> height - getSecurityArrowMargin() + isOutOfTopBound(targetViewLocationOnScreen) -> getSecurityArrowMargin() + else -> (targetViewLocationOnScreen!!.centerY() + ScreenUtils.getStatusBarHeight(context) - ScreenUtils.getAxisYPositionOfViewOnScreen( + this + )).roundToInt() + } + return yPosition + } + + private fun isOutOfRightBound(targetViewLocationOnScreen: RectF?): Boolean { + return targetViewLocationOnScreen!!.centerX() > ScreenUtils.getAxisXPositionOfViewOnScreen( + this + ) + width - getSecurityArrowMargin() + } + + private fun isOutOfLeftBound(targetViewLocationOnScreen: RectF?): Boolean { + return targetViewLocationOnScreen!!.centerX() < ScreenUtils.getAxisXPositionOfViewOnScreen( + this + ) + getSecurityArrowMargin() + } + + private fun isOutOfBottomBound(targetViewLocationOnScreen: RectF?): Boolean { + return targetViewLocationOnScreen!!.centerY() > ScreenUtils.getAxisYPositionOfViewOnScreen( + this + ) + height - getSecurityArrowMargin() - ScreenUtils.getStatusBarHeight(context) + } + + private fun isOutOfTopBound(targetViewLocationOnScreen: RectF?): Boolean { + return targetViewLocationOnScreen!!.centerY() < ScreenUtils.getAxisYPositionOfViewOnScreen( + this + ) + getSecurityArrowMargin() - ScreenUtils.getStatusBarHeight(context) + } + + private fun drawRhombus(canvas: Canvas, paint: Paint?, x: Int, y: Int, width: Int) { + val halfRhombusWidth = width / 2 + + val path = Path() + path.moveTo(x.toFloat(), (y + halfRhombusWidth).toFloat()) // Top + path.lineTo((x - halfRhombusWidth).toFloat(), y.toFloat()) // Left + path.lineTo(x.toFloat(), (y - halfRhombusWidth).toFloat()) // Bottom + path.lineTo((x + halfRhombusWidth).toFloat(), y.toFloat()) // Right + path.lineTo(x.toFloat(), (y + halfRhombusWidth).toFloat()) // Back to Top + path.close() + + canvas.drawPath(path, paint!!) + } + + + //END REGION + + /** + * Builder for BubbleMessageView class + */ + class Builder { + private lateinit var mContext: WeakReference + var mTargetViewScreenLocation: RectF? = null + var mImage: Drawable? = null + var mDisableCloseAction: Boolean? = null + var mTitle: String? = null + var mSubtitle: String? = null + var mCloseAction: Drawable? = null + var mBackgroundColor: Int? = null + var mTextColor: Int? = null + var mTitleTextSize: Int? = null + var mSubtitleTextSize: Int? = null + var mArrowPosition = ArrayList() + var mListener: OnBubbleMessageViewListener? = null + var mShowNextButton: Boolean = false + + fun from(context: Context): Builder { + mContext = WeakReference(context) + return this + } + + fun title(title: String?): Builder { + mTitle = title + return this + } + + fun subtitle(subtitle: String?): Builder { + mSubtitle = subtitle + return this + } + + fun image(image: Drawable?): Builder { + mImage = image + return this + } + + fun closeActionImage(image: Drawable?): Builder { + mCloseAction = image + return this + } + + fun disableCloseAction(isDisabled: Boolean): Builder { + mDisableCloseAction = isDisabled + return this + } + + fun targetViewScreenLocation(targetViewLocationOnScreen: RectF): Builder { + mTargetViewScreenLocation = targetViewLocationOnScreen + return this + } + + fun backgroundColor(backgroundColor: Int?): Builder { + mBackgroundColor = backgroundColor + return this + } + + fun textColor(textColor: Int?): Builder { + mTextColor = textColor + return this + } + + fun titleTextSize(textSize: Int?): Builder { + mTitleTextSize = textSize + return this + } + + fun subtitleTextSize(textSize: Int?): Builder { + mSubtitleTextSize = textSize + return this + } + + fun arrowPosition(arrowPosition: List): Builder { + mArrowPosition.clear() + mArrowPosition.addAll(arrowPosition) + return this + } + + fun showNextButton(showNextButton: Boolean): Builder { + mShowNextButton = showNextButton + return this + } + + fun listener(listener: OnBubbleMessageViewListener?): Builder { + mListener = listener + return this + } + + fun build(): BubbleMessageView { + return BubbleMessageView(mContext.get()!!, this) + } + } +} \ No newline at end of file diff --git a/OnBoardingBubbles/src/main/java/com/mohsin/onboardingbubbles/BubbleShowCase.kt b/OnBoardingBubbles/src/main/java/com/mohsin/onboardingbubbles/BubbleShowCase.kt new file mode 100644 index 0000000..3828ecc --- /dev/null +++ b/OnBoardingBubbles/src/main/java/com/mohsin/onboardingbubbles/BubbleShowCase.kt @@ -0,0 +1,524 @@ +package com.mohsin.onboardingbubbles + +import android.app.Activity +import android.content.Context +import android.content.Context.MODE_PRIVATE +import android.content.SharedPreferences +import android.graphics.Bitmap +import android.graphics.RectF +import android.graphics.drawable.Drawable +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.RelativeLayout +import androidx.core.content.ContextCompat +import com.mohsin.onboardingbubbles.ScreenUtils.dpToPx +import java.lang.ref.WeakReference + +class BubbleShowCase(builder: BubbleShowCaseBuilder) { + + companion object { + private const val SHARED_PREFS_NAME = "BubbleShowCasePrefs" + private const val FOREGROUND_LAYOUT_ID = 731 + private const val DURATION_SHOW_CASE_ANIMATION = 200 //ms + private const val DURATION_BACKGROUND_ANIMATION = 700 //ms + private const val DURATION_BEATING_ANIMATION = 700 //ms + private const val MAX_WIDTH_MESSAGE_VIEW_TABLET = 420 //dp + } + + /** + * Enum class which corresponds to each valid position for the BubbleMessageView arrow + */ + enum class ArrowPosition { + TOP, BOTTOM, LEFT, RIGHT + } + + /** + * Highlight mode. It represents the way that the target view will be highlighted + * - VIEW_LAYOUT: Default value. All the view box is highlighted (the rectangle where the view is contained). Example: For a TextView, all the element is highlighted (characters and background) + * - VIEW_SURFACE: Only the view surface is highlighted, but not the background. Example: For a TextView, only the characters will be highlighted + */ + enum class HighlightMode { + VIEW_LAYOUT, VIEW_SURFACE + } + + + private val mActivity: WeakReference = builder.mActivity!! + + //BubbleMessageView params + private val mImage: Drawable? = builder.mImage + private val mTitle: String? = builder.mTitle + private val mSubtitle: String? = builder.mSubtitle + private val mCloseAction: Drawable? = builder.mCloseAction + private val mBackgroundColor: Int? = builder.mBackgroundColor + private val mTextColor: Int? = builder.mTextColor + private val mTitleTextSize: Int? = builder.mTitleTextSize + private val mSubtitleTextSize: Int? = builder.mSubtitleTextSize + private val mShowOnce: String? = builder.mShowOnce + private val mShowNextButton: Boolean = builder.mShowNextButton + private val mDisableTargetClick: Boolean = builder.mDisableTargetClick + private val mDisableCloseAction: Boolean = builder.mDisableCloseAction + private val mHighlightMode: HighlightMode? = builder.mHighlightMode + private val mArrowPositionList: MutableList = builder.mArrowPositionList + private val mTargetView: WeakReference? = builder.mTargetView + private val mBubbleShowCaseListener: BubbleShowCaseListener? = builder.mBubbleShowCaseListener + + //Sequence params + private val mSequenceListener: SequenceShowCaseListener? = builder.mSequenceShowCaseListener + private val isFirstOfSequence: Boolean = builder.mIsFirstOfSequence!! + private val isLastOfSequence: Boolean = builder.mIsLastOfSequence!! + + //References + private var backgroundDimLayout: RelativeLayout? = null + private var bubbleMessageViewBuilder: BubbleMessageView.Builder? = null + + fun show() { + if (mShowOnce != null) { + if (isBubbleShowCaseHasBeenShowedPreviously(mShowOnce)) { + notifyDismissToSequenceListener() + return + } else { + registerBubbleShowCaseInPreferences(mShowOnce) + } + } + + val rootView = getViewRoot(mActivity.get()!!) + backgroundDimLayout = getBackgroundDimLayout() + setBackgroundDimListener(backgroundDimLayout) + bubbleMessageViewBuilder = getBubbleMessageViewBuilder() + + if (mTargetView != null && mArrowPositionList.size <= 1) { + //Wait until the end of the layout animation, to avoid problems with pending scrolls or view movements + val handler = Handler(Looper.getMainLooper()) + handler.postDelayed({ + val target = mTargetView.get()!! + //If the arrow list is empty, the arrow position is set by default depending on the targetView position on the screen + if (mArrowPositionList.isEmpty()) { + if (ScreenUtils.isViewLocatedAtHalfTopOfTheScreen( + mActivity.get()!!, + target + ) + ) mArrowPositionList.add( + ArrowPosition.TOP + ) else mArrowPositionList.add(ArrowPosition.BOTTOM) + bubbleMessageViewBuilder = getBubbleMessageViewBuilder() + } + + if (isVisibleOnScreen(target)) { + addTargetViewAtBackgroundDimLayout(target, backgroundDimLayout) + addBubbleMessageViewDependingOnTargetView( + target, + bubbleMessageViewBuilder!!, + backgroundDimLayout + ) + } else { + dismiss() + } + }, DURATION_BACKGROUND_ANIMATION.toLong()) + } else { + addBubbleMessageViewOnScreenCenter(bubbleMessageViewBuilder!!, backgroundDimLayout) + } + if (isFirstOfSequence) { + //Add the background dim layout above the root view + val animation = AnimationUtils.getFadeInAnimation(0, DURATION_BACKGROUND_ANIMATION) + backgroundDimLayout?.let { + val childView = AnimationUtils.setAnimationToView(backgroundDimLayout!!, animation) + if (childView.parent == null) { //if the parent of childView is null then will add in rootView. + rootView.addView(childView) + } + } + } + } + + fun dismiss() { + if (backgroundDimLayout != null && isLastOfSequence) { + //Remove background dim layout if the BubbleShowCase is the last of the sequence + finishSequence() + } else { + //Remove all the views created over the background dim layout waiting for the next BubbleShowCase in the sequence + backgroundDimLayout?.removeAllViews() + } + notifyDismissToSequenceListener() + } + + private fun finishSequence() { + val rootView = getViewRoot(mActivity.get()!!) + rootView.removeView(backgroundDimLayout) + backgroundDimLayout = null + } + + private fun notifyDismissToSequenceListener() { + mSequenceListener?.let { mSequenceListener.onDismiss() } + } + + private fun getViewRoot(activity: Activity): ViewGroup { + val androidContent = activity.findViewById(android.R.id.content) + return androidContent.parent.parent as ViewGroup + } + + private fun getBackgroundDimLayout(): RelativeLayout { + if (mActivity.get()!!.findViewById(FOREGROUND_LAYOUT_ID) != null) + return mActivity.get()!!.findViewById(FOREGROUND_LAYOUT_ID) + val backgroundLayout = RelativeLayout(mActivity.get()!!) + backgroundLayout.id = FOREGROUND_LAYOUT_ID + backgroundLayout.layoutParams = RelativeLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + backgroundLayout.setBackgroundColor( + ContextCompat.getColor( + mActivity.get()!!, + R.color.transparent_grey + ) + ) + backgroundLayout.isClickable = true + backgroundLayout.isFocusable = true + return backgroundLayout + } + + private fun setBackgroundDimListener(backgroundDimLayout: RelativeLayout?) { + backgroundDimLayout?.setOnClickListener { mBubbleShowCaseListener?.onBackgroundDimClick(this) } + } + + private fun getBubbleMessageViewBuilder(): BubbleMessageView.Builder { + return BubbleMessageView.Builder() + .from(mActivity.get()!!) + .arrowPosition(mArrowPositionList) + .backgroundColor(mBackgroundColor) + .textColor(mTextColor) + .titleTextSize(mTitleTextSize) + .subtitleTextSize(mSubtitleTextSize) + .title(mTitle) + .subtitle(mSubtitle) + .image(mImage) + .showNextButton(mShowNextButton) + .closeActionImage(mCloseAction) + .disableCloseAction(mDisableCloseAction) + .listener(object : OnBubbleMessageViewListener { + override fun onBubbleClick() { + mBubbleShowCaseListener?.onBubbleClick(this@BubbleShowCase) + } + + override fun onCloseActionImageClick() { + dismiss() + mBubbleShowCaseListener?.onCloseActionImageClick(this@BubbleShowCase) + } + + override fun onNextButtonClick() { + dismiss() + mBubbleShowCaseListener?.onNextButtonClick() + } + + }) + } + + private fun isBubbleShowCaseHasBeenShowedPreviously(id: String): Boolean { + val mPrefs = mActivity.get()!!.getSharedPreferences(SHARED_PREFS_NAME, MODE_PRIVATE) + return getString(mPrefs, id) != null + } + + private fun registerBubbleShowCaseInPreferences(id: String) { + val mPrefs = mActivity.get()!!.getSharedPreferences(SHARED_PREFS_NAME, MODE_PRIVATE) + setString(mPrefs, id, id) + } + + private fun getString(mPrefs: SharedPreferences, key: String): String? { + return mPrefs.getString(key, null) + } + + private fun setString(mPrefs: SharedPreferences, key: String, value: String) { + val editor = mPrefs.edit() + editor.putString(key, value) + editor.apply() + } + + + /** + * This function takes a screenshot of the targetView, creating an ImageView from it. This new ImageView is also set on the layout passed by param + */ + private fun addTargetViewAtBackgroundDimLayout( + targetView: View?, + backgroundDimLayout: RelativeLayout? + ) { + if (targetView == null) return + + val targetScreenshot = takeScreenshot(targetView, mHighlightMode) + val targetScreenshotView = ImageView(mActivity.get()!!) + targetScreenshotView.setImageBitmap(targetScreenshot) + targetScreenshotView.setOnClickListener { + if (!mDisableTargetClick) + dismiss() + mBubbleShowCaseListener?.onTargetClick(this) + } + + val targetViewParams = RelativeLayout.LayoutParams( + RelativeLayout.LayoutParams.WRAP_CONTENT, + RelativeLayout.LayoutParams.WRAP_CONTENT + ) + targetViewParams.setMargins( + getXPosition(targetView), + getYPosition(targetView), + getScreenWidth(mActivity.get()!!) - (getXPosition(targetView) + targetView.width), + 0 + ) + backgroundDimLayout?.addView( + AnimationUtils.setBouncingAnimation( + targetScreenshotView, + 0, + DURATION_BEATING_ANIMATION + ), targetViewParams + ) + } + + /** + * This function creates the BubbleMessageView depending the position of the target and the desired arrow position. This new view is also set on the layout passed by param + */ + private fun addBubbleMessageViewDependingOnTargetView( + targetView: View?, + bubbleMessageViewBuilder: BubbleMessageView.Builder, + backgroundDimLayout: RelativeLayout? + ) { + if (targetView == null) return + val showCaseParams = RelativeLayout.LayoutParams( + RelativeLayout.LayoutParams.MATCH_PARENT, + RelativeLayout.LayoutParams.WRAP_CONTENT + ) + + when (bubbleMessageViewBuilder.mArrowPosition[0]) { + ArrowPosition.LEFT -> { + showCaseParams.addRule(RelativeLayout.ALIGN_PARENT_LEFT) + if (ScreenUtils.isViewLocatedAtHalfTopOfTheScreen(mActivity.get()!!, targetView)) { + showCaseParams.setMargins( + getXPosition(targetView) + targetView.width, + getYPosition(targetView), + if (isTablet()) getScreenWidth(mActivity.get()!!) - (getXPosition(targetView) + targetView.width) - getMessageViewWidthOnTablet( + getScreenWidth(mActivity.get()!!) - (getXPosition(targetView) + targetView.width) + ) else 0, + 0 + ) + showCaseParams.addRule(RelativeLayout.ALIGN_PARENT_TOP) + } else { + showCaseParams.setMargins( + getXPosition(targetView) + targetView.width, + 0, + if (isTablet()) getScreenWidth(mActivity.get()!!) - (getXPosition(targetView) + targetView.width) - getMessageViewWidthOnTablet( + getScreenWidth(mActivity.get()!!) - (getXPosition(targetView) + targetView.width) + ) else 0, + getScreenHeight(mActivity.get()!!) - getYPosition(targetView) - targetView.height + ) + showCaseParams.addRule(RelativeLayout.ALIGN_PARENT_BOTTOM) + } + } + ArrowPosition.RIGHT -> { + showCaseParams.addRule(RelativeLayout.ALIGN_PARENT_RIGHT) + if (ScreenUtils.isViewLocatedAtHalfTopOfTheScreen(mActivity.get()!!, targetView)) { + showCaseParams.setMargins( + if (isTablet()) getXPosition(targetView) - getMessageViewWidthOnTablet( + getXPosition(targetView) + ) else 0, + getYPosition(targetView), + getScreenWidth(mActivity.get()!!) - getXPosition(targetView), + 0 + ) + showCaseParams.addRule(RelativeLayout.ALIGN_PARENT_TOP) + } else { + showCaseParams.setMargins( + if (isTablet()) getXPosition(targetView) - getMessageViewWidthOnTablet( + getXPosition(targetView) + ) else 0, + 0, + getScreenWidth(mActivity.get()!!) - getXPosition(targetView), + getScreenHeight(mActivity.get()!!) - getYPosition(targetView) - targetView.height + ) + showCaseParams.addRule(RelativeLayout.ALIGN_PARENT_BOTTOM) + } + } + ArrowPosition.TOP -> { + showCaseParams.addRule(RelativeLayout.ALIGN_PARENT_TOP) + if (ScreenUtils.isViewLocatedAtHalfLeftOfTheScreen(mActivity.get()!!, targetView)) { + showCaseParams.setMargins( + if (isTablet()) getXPosition(targetView) else 0, + getYPosition(targetView) + targetView.height, + if (isTablet()) getScreenWidth(mActivity.get()!!) - getXPosition(targetView) - getMessageViewWidthOnTablet( + getScreenWidth(mActivity.get()!!) - getXPosition(targetView) + ) else 0, + 0 + ) + } else { + showCaseParams.setMargins( + if (isTablet()) getXPosition(targetView) + targetView.width - getMessageViewWidthOnTablet( + getXPosition(targetView) + ) else 0, + getYPosition(targetView) + targetView.height, + if (isTablet()) getScreenWidth(mActivity.get()!!) - getXPosition(targetView) - targetView.width else 0, + 0 + ) + } + } + ArrowPosition.BOTTOM -> { + showCaseParams.addRule(RelativeLayout.ALIGN_PARENT_BOTTOM) + if (ScreenUtils.isViewLocatedAtHalfLeftOfTheScreen(mActivity.get()!!, targetView)) { + showCaseParams.setMargins( + if (isTablet()) getXPosition(targetView) else 0, + 0, + if (isTablet()) getScreenWidth(mActivity.get()!!) - getXPosition(targetView) - getMessageViewWidthOnTablet( + getScreenWidth(mActivity.get()!!) - getXPosition(targetView) + ) else 0, + getScreenHeight(mActivity.get()!!) - getYPosition(targetView) + ) + } else { + showCaseParams.setMargins( + if (isTablet()) getXPosition(targetView) + targetView.width - getMessageViewWidthOnTablet( + getXPosition(targetView) + ) else 0, + 0, + if (isTablet()) getScreenWidth(mActivity.get()!!) - getXPosition(targetView) - targetView.width else 0, + getScreenHeight(mActivity.get()!!) - getYPosition(targetView) + ) + } + } + } + + val bubbleMessageView = bubbleMessageViewBuilder.targetViewScreenLocation( + RectF( + getXPosition(targetView).toFloat(), + getYPosition(targetView).toFloat(), + getXPosition(targetView).toFloat() + targetView.width, + getYPosition(targetView).toFloat() + targetView.height + ) + ) + .build() + + bubbleMessageView.id = createViewId() + val animation = AnimationUtils.getScaleAnimation(0, DURATION_SHOW_CASE_ANIMATION) + backgroundDimLayout?.addView( + AnimationUtils.setAnimationToView( + bubbleMessageView, + animation + ), showCaseParams + ) + } + + /** + * This function creates a BubbleMessageView and it is set on the center of the layout passed by param + */ + private fun addBubbleMessageViewOnScreenCenter( + bubbleMessageViewBuilder: BubbleMessageView.Builder, + backgroundDimLayout: RelativeLayout? + ) { + val showCaseParams = RelativeLayout.LayoutParams( + RelativeLayout.LayoutParams.MATCH_PARENT, + RelativeLayout.LayoutParams.WRAP_CONTENT + ) + showCaseParams.addRule(RelativeLayout.CENTER_VERTICAL) + val bubbleMessageView: BubbleMessageView = bubbleMessageViewBuilder.build() + bubbleMessageView.id = createViewId() + if (isTablet()) showCaseParams.setMargins( + if (isTablet()) getScreenWidth(mActivity.get()!!) / 2 - dpToPx( + MAX_WIDTH_MESSAGE_VIEW_TABLET + ) / 2 else 0, + 0, + if (isTablet()) getScreenWidth(mActivity.get()!!) / 2 - dpToPx( + MAX_WIDTH_MESSAGE_VIEW_TABLET + ) / 2 else 0, + 0 + ) + val animation = AnimationUtils.getScaleAnimation(0, DURATION_SHOW_CASE_ANIMATION) + backgroundDimLayout?.addView( + AnimationUtils.setAnimationToView( + bubbleMessageView, + animation + ), showCaseParams + ) + } + + private fun createViewId(): Int { + return View.generateViewId() + } + + private fun takeScreenshot(targetView: View, highlightMode: HighlightMode?): Bitmap? { + if (highlightMode == null || highlightMode == HighlightMode.VIEW_LAYOUT) + return takeScreenshotOfLayoutView(targetView) + return takeScreenshotOfSurfaceView(targetView) + } + + private fun takeScreenshotOfLayoutView(targetView: View): Bitmap? { + if (targetView.width == 0 || targetView.height == 0) { + return null + } + + val rootView = getViewRoot(mActivity.get()!!) + val currentScreenView = rootView.getChildAt(0) + currentScreenView.buildDrawingCache() + val bitmap: Bitmap = Bitmap.createBitmap( + currentScreenView.drawingCache, + getXPosition(targetView), + getYPosition(targetView), + targetView.width, + targetView.height + ) + currentScreenView.isDrawingCacheEnabled = false + currentScreenView.destroyDrawingCache() + return bitmap + } + + private fun takeScreenshotOfSurfaceView(targetView: View): Bitmap? { + if (targetView.width == 0 || targetView.height == 0) { + return null + } + + targetView.isDrawingCacheEnabled = true + val bitmap: Bitmap = Bitmap.createBitmap(targetView.drawingCache) + targetView.isDrawingCacheEnabled = false + return bitmap + } + + private fun isVisibleOnScreen(targetView: View?): Boolean { + if (targetView != null) { + if (getXPosition(targetView) >= 0 && getYPosition(targetView) >= 0) { + return getXPosition(targetView) != 0 || getYPosition(targetView) != 0 + } + } + return false + } + + private fun getXPosition(targetView: View): Int { + return ScreenUtils.getAxisXPositionOfViewOnScreen(targetView) - getScreenHorizontalOffset() + } + + private fun getYPosition(targetView: View): Int { + return ScreenUtils.getAxisYPositionOfViewOnScreen(targetView) - getScreenVerticalOffset() + } + + private fun getScreenHeight(context: Context): Int { + return ScreenUtils.getScreenHeight(context) - getScreenVerticalOffset() + } + + private fun getScreenWidth(context: Context): Int { + return ScreenUtils.getScreenWidth(context) - getScreenHorizontalOffset() + } + + private fun getScreenVerticalOffset(): Int { + return if (backgroundDimLayout != null) ScreenUtils.getAxisYPositionOfViewOnScreen( + backgroundDimLayout!! + ) else 0 + } + + private fun getScreenHorizontalOffset(): Int { + return if (backgroundDimLayout != null) ScreenUtils.getAxisXPositionOfViewOnScreen( + backgroundDimLayout!! + ) else 0 + } + + private fun getMessageViewWidthOnTablet(availableSpace: Int): Int { + return if (availableSpace > dpToPx(MAX_WIDTH_MESSAGE_VIEW_TABLET)) dpToPx( + MAX_WIDTH_MESSAGE_VIEW_TABLET + ) else availableSpace + } + + private fun isTablet(): Boolean = mActivity.get()!!.resources.getBoolean(R.bool.isTablet) + +} \ No newline at end of file diff --git a/OnBoardingBubbles/src/main/java/com/mohsin/onboardingbubbles/BubbleShowCaseBuilder.kt b/OnBoardingBubbles/src/main/java/com/mohsin/onboardingbubbles/BubbleShowCaseBuilder.kt new file mode 100644 index 0000000..6064a23 --- /dev/null +++ b/OnBoardingBubbles/src/main/java/com/mohsin/onboardingbubbles/BubbleShowCaseBuilder.kt @@ -0,0 +1,294 @@ +package com.mohsin.onboardingbubbles + +import android.app.Activity +import android.graphics.drawable.Drawable +import android.view.View +import android.view.ViewTreeObserver +import androidx.core.content.ContextCompat +import java.lang.ref.WeakReference +import java.util.ArrayList + +/** + * It needs an instance of the current activity to convert it to a weak reference in order to avoid memory leaks + */ +class BubbleShowCaseBuilder(activity: Activity) { + + internal var mActivity: WeakReference? = null + internal var mImage: Drawable? = null + internal var mTitle: String? = null + internal var mSubtitle: String? = null + internal var mCloseAction: Drawable? = null + internal var mBackgroundColor: Int? = null + internal var mTextColor: Int? = null + internal var mTitleTextSize: Int? = null + internal var mSubtitleTextSize: Int? = null + internal var mHighlightMode: BubbleShowCase.HighlightMode? = null + internal var mDisableTargetClick: Boolean = false + internal var mDisableCloseAction: Boolean = false + internal var mShowOnce: String? = null + internal var mIsFirstOfSequence: Boolean? = null + internal var mIsLastOfSequence: Boolean? = null + internal val mArrowPositionList = ArrayList() + internal var mTargetView: WeakReference? = null + internal var mBubbleShowCaseListener: BubbleShowCaseListener? = null + internal var mSequenceShowCaseListener: SequenceShowCaseListener? = null + internal var mShowNextButton: Boolean = false + + private var onGlobalLayoutListenerTargetView: ViewTreeObserver.OnGlobalLayoutListener? = null + + init { + mActivity = WeakReference(activity) + } + + /** + * Title of the BubbleShowCase. This text is bolded in the view. + */ + fun title(title: String): BubbleShowCaseBuilder { + mTitle = title + return this + } + + /** + * Additional description of the BubbleShowCase. This text has a regular format + */ + fun description(subtitle: String): BubbleShowCaseBuilder { + mSubtitle = subtitle + return this + } + + /** + * Image drawable to inserted as main image in the BubbleShowCase + * - If this param is not passed, the BubbleShowCase will not have main image + */ + fun image(image: Drawable): BubbleShowCaseBuilder { + mImage = image + return this + } + + /** + * Image resource id to insert the corresponding drawable as main image in the BubbleShowCase + * - If this param is not passed, the BubbleShowCase will not have main image + */ + fun imageResourceId(resId: Int): BubbleShowCaseBuilder { + mImage = mActivity!!.get()?.let { ContextCompat.getDrawable(it, resId) } + return this + } + + /** + * Image drawable to be inserted as close icon in the BubbleShowCase. + * - If this param is not defined, a default close icon is displayed + */ + fun closeActionImage(image: Drawable?): BubbleShowCaseBuilder { + mCloseAction = image + return this + } + + /** + * Image resource id to insert the corresponding drawable as close icon in the BubbleShowCase. + * - If this param is not defined, a default close icon is displayed + */ + fun closeActionImageResourceId(resId: Int): BubbleShowCaseBuilder { + mCloseAction = mActivity!!.get()?.let { ContextCompat.getDrawable(it, resId) } + return this + } + + + /** + * Background color of the BubbleShowCase. + * - #3F51B5 color will be set if this param is not defined + */ + fun backgroundColor(color: Int): BubbleShowCaseBuilder { + mBackgroundColor = color + return this + } + + /** + * Background color of the BubbleShowCase. + * - #3F51B5 color will be set if this param is not defined + */ + fun backgroundColorResourceId(colorResId: Int): BubbleShowCaseBuilder { + mBackgroundColor = mActivity!!.get()?.let { ContextCompat.getColor(it, colorResId) } + return this + } + + /** + * Text color of the BubbleShowCase. + * - White color will be set if this param is not defined + */ + fun textColor(color: Int): BubbleShowCaseBuilder { + mTextColor = color + return this + } + + /** + * Text color of the BubbleShowCase. + * - White color will be set if this param is not defined + */ + fun textColorResourceId(colorResId: Int): BubbleShowCaseBuilder { + mTextColor = mActivity!!.get()?.let { ContextCompat.getColor(it, colorResId) } + return this + } + + /** + * Title text size in SP. + * - Default value -> 16 sp + */ + fun titleTextSize(textSize: Int): BubbleShowCaseBuilder { + mTitleTextSize = textSize + return this + } + + /** + * Description text size in SP. + * - Default value -> 14 sp + */ + fun descriptionTextSize(textSize: Int): BubbleShowCaseBuilder { + mSubtitleTextSize = textSize + return this + } + + /** + * If an unique id is passed in this function, this BubbleShowCase will only be showed once + * - ID to identify the BubbleShowCase + */ + fun showOnce(id: String): BubbleShowCaseBuilder { + mShowOnce = id + return this + } + + /** + * Target view to be highlighted. Set a TargetView is essential to figure out BubbleShowCase position + * - If a target view is not defined, the BubbleShowCase final position will be the center of the screen + */ + fun targetView(targetView: View): BubbleShowCaseBuilder { + mTargetView = WeakReference(targetView) + return this + } + + /** + * If this variable is true, when user clicks on the target, the showcase will not be dismissed + * Default value -> false + */ + fun disableTargetClick(isDisabled: Boolean): BubbleShowCaseBuilder { + mDisableTargetClick = isDisabled + return this + } + + /** + * If this variable is true, close action button will be gone + * Default value -> false + */ + fun disableCloseAction(isDisabled: Boolean): BubbleShowCaseBuilder { + mDisableCloseAction = isDisabled + return this + } + + /** + * Insert an arrowPosition to force the position of the BubbleShowCase. + * - ArrowPosition enum values: LEFT, RIGHT, TOP and DOWN + * - If an arrow position is not defined, the BubbleShowCase will be set in a default position depending on the targetView + */ + fun arrowPosition(arrowPosition: BubbleShowCase.ArrowPosition): BubbleShowCaseBuilder { + mArrowPositionList.clear() + mArrowPositionList.add(arrowPosition) + return this + } + + /** + * Insert a set of arrowPosition to force the position of the BubbleShowCase. + * - ArrowPosition enum values: LEFT, RIGHT, TOP and DOWN + * - If the number of arrow positions is 0 or more than 1, BubbleShowCase will be set on the center of the screen + */ + fun arrowPosition(arrowPosition: List): BubbleShowCaseBuilder { + mArrowPositionList.clear() + mArrowPositionList.addAll(arrowPosition) + return this + } + + /** + * Highlight mode. It represents the way that the target view will be highlighted + * - VIEW_LAYOUT: Default value. All the view box is highlighted (the rectangle where the view is contained). Example: For a TextView, all the element is highlighted (characters and background) + * - VIEW_SURFACE: Only the view surface is highlighted, but not the background. Example: For a TextView, only the characters will be highlighted + */ + fun highlightMode(highlightMode: BubbleShowCase.HighlightMode): BubbleShowCaseBuilder { + mHighlightMode = highlightMode + return this + } + + /** + * If this variable is true, Next button will be Visible + * Default value -> false + */ + fun showNextButton(show: Boolean): BubbleShowCaseBuilder { + mShowNextButton = show + return this + } + + /** + * Add a BubbleShowCaseListener in order to listen the user actions: + * - onTargetClick -> It is triggered when the user clicks on the target view + * - onCloseClick -> It is triggered when the user clicks on the close icon + */ + fun listener(bubbleShowCaseListener: BubbleShowCaseListener): BubbleShowCaseBuilder { + mBubbleShowCaseListener = bubbleShowCaseListener + return this + } + + /** + * Add a sequence listener in order to know when a BubbleShowCase has been dismissed to show another one + */ + internal fun sequenceListener(sequenceShowCaseListener: SequenceShowCaseListener): BubbleShowCaseBuilder { + mSequenceShowCaseListener = sequenceShowCaseListener + return this + } + + internal fun isFirstOfSequence(isFirst: Boolean): BubbleShowCaseBuilder { + mIsFirstOfSequence = isFirst + return this + } + + internal fun isLastOfSequence(isLast: Boolean): BubbleShowCaseBuilder { + mIsLastOfSequence = isLast + return this + } + + /** + * Build the BubbleShowCase object from the builder one + */ + private fun build(): BubbleShowCase { + if (mIsFirstOfSequence == null) + mIsFirstOfSequence = true + if (mIsLastOfSequence == null) + mIsLastOfSequence = true + + return BubbleShowCase(this) + } + + /** + * Show the BubbleShowCase using the params added previously + */ + fun show(): BubbleShowCase { + val bubbleShowCase = build() + if (mTargetView != null) { + val targetView = mTargetView!!.get() + if (targetView!!.height == 0 || targetView.width == 0) { + //If the view is not already painted, we wait for it waiting for view changes using OnGlobalLayoutListener + onGlobalLayoutListenerTargetView = ViewTreeObserver.OnGlobalLayoutListener { + bubbleShowCase.show() + targetView.viewTreeObserver.removeOnGlobalLayoutListener( + onGlobalLayoutListenerTargetView + ) + } + targetView.viewTreeObserver.addOnGlobalLayoutListener( + onGlobalLayoutListenerTargetView + ) + } else { + bubbleShowCase.show() + } + } else { + bubbleShowCase.show() + } + return bubbleShowCase + } + +} \ No newline at end of file diff --git a/OnBoardingBubbles/src/main/java/com/mohsin/onboardingbubbles/BubbleShowCaseListener.kt b/OnBoardingBubbles/src/main/java/com/mohsin/onboardingbubbles/BubbleShowCaseListener.kt new file mode 100644 index 0000000..5f52c1d --- /dev/null +++ b/OnBoardingBubbles/src/main/java/com/mohsin/onboardingbubbles/BubbleShowCaseListener.kt @@ -0,0 +1,33 @@ +package com.mohsin.onboardingbubbles + +/** + * + * Listener of user actions in a BubbleShowCase + */ +interface BubbleShowCaseListener { + /** + * It is called when the user clicks on the targetView + */ + fun onTargetClick(bubbleShowCase: BubbleShowCase) {} + + /** + * It is called when the user clicks on the close icon + */ + fun onCloseActionImageClick(bubbleShowCase: BubbleShowCase) {} + + /** + * It is called when the user clicks on the background dim + */ + fun onBackgroundDimClick(bubbleShowCase: BubbleShowCase) {} + + /** + * It is called when the user clicks on the bubble + */ + fun onBubbleClick(bubbleShowCase: BubbleShowCase) {} + + /** + * It is called when the user clicks on the Next Button + * if you set next button true, then you should implement this method + */ + fun onNextButtonClick() {} +} \ No newline at end of file diff --git a/OnBoardingBubbles/src/main/java/com/mohsin/onboardingbubbles/BubbleShowCaseSequence.kt b/OnBoardingBubbles/src/main/java/com/mohsin/onboardingbubbles/BubbleShowCaseSequence.kt new file mode 100644 index 0000000..d8f10d9 --- /dev/null +++ b/OnBoardingBubbles/src/main/java/com/mohsin/onboardingbubbles/BubbleShowCaseSequence.kt @@ -0,0 +1,46 @@ +package com.mohsin.onboardingbubbles + +class BubbleShowCaseSequence { + private val mBubbleShowCaseBuilderList = ArrayList() + + init { + mBubbleShowCaseBuilderList.clear() + } + + fun addShowCase(bubbleShowCaseBuilder: BubbleShowCaseBuilder): BubbleShowCaseSequence { + mBubbleShowCaseBuilderList.add(bubbleShowCaseBuilder) + return this + } + + fun addShowCases(bubbleShowCaseBuilderList: List): BubbleShowCaseSequence { + mBubbleShowCaseBuilderList.addAll(bubbleShowCaseBuilderList) + return this + } + + fun show() = show(0) + + private fun show(position: Int) { + if (position >= mBubbleShowCaseBuilderList.size) + return + + when (position) { + 0 -> { + mBubbleShowCaseBuilderList[position].isFirstOfSequence(true) + mBubbleShowCaseBuilderList[position].isLastOfSequence(false) + } + mBubbleShowCaseBuilderList.size - 1 -> { + mBubbleShowCaseBuilderList[position].isFirstOfSequence(false) + mBubbleShowCaseBuilderList[position].isLastOfSequence(true) + } + else -> { + mBubbleShowCaseBuilderList[position].isFirstOfSequence(false) + mBubbleShowCaseBuilderList[position].isLastOfSequence(false) + } + } + mBubbleShowCaseBuilderList[position].sequenceListener(object : SequenceShowCaseListener { + override fun onDismiss() { + show(position + 1) + } + }).show() + } +} \ No newline at end of file diff --git a/OnBoardingBubbles/src/main/java/com/mohsin/onboardingbubbles/OnBubbleMessageViewListener.kt b/OnBoardingBubbles/src/main/java/com/mohsin/onboardingbubbles/OnBubbleMessageViewListener.kt new file mode 100644 index 0000000..4bf09e2 --- /dev/null +++ b/OnBoardingBubbles/src/main/java/com/mohsin/onboardingbubbles/OnBubbleMessageViewListener.kt @@ -0,0 +1,19 @@ +package com.mohsin.onboardingbubbles + +interface OnBubbleMessageViewListener { + /** + * It is called when a user clicks the close action image in the BubbleMessageView + */ + fun onCloseActionImageClick() + + + /** + * It is called when a user clicks the BubbleMessageView + */ + fun onBubbleClick() + + /** + * It is called when a user clicks the Next Button + */ + fun onNextButtonClick() +} \ No newline at end of file diff --git a/OnBoardingBubbles/src/main/java/com/mohsin/onboardingbubbles/ScreenUtils.kt b/OnBoardingBubbles/src/main/java/com/mohsin/onboardingbubbles/ScreenUtils.kt new file mode 100644 index 0000000..5427a36 --- /dev/null +++ b/OnBoardingBubbles/src/main/java/com/mohsin/onboardingbubbles/ScreenUtils.kt @@ -0,0 +1,88 @@ +package com.mohsin.onboardingbubbles + +import android.app.Activity +import android.content.Context +import android.content.res.Resources +import android.graphics.Point +import android.util.DisplayMetrics +import android.view.View +import android.view.ViewGroup +import android.view.WindowManager +import kotlin.math.roundToInt + +object ScreenUtils { + + fun getScreenHeight(context: Context): Int { + val wm = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager + val display = wm.defaultDisplay + val size = Point() + display.getSize(size) + return size.y + } + + fun getScreenWidth(context: Context): Int { + val wm = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager + val display = wm.defaultDisplay + val size = Point() + display.getSize(size) + return size.x + } + + fun getAxisXPositionOfViewOnScreen(targetView: View): Int { + val locationTarget = IntArray(2) + targetView.getLocationOnScreen(locationTarget) + return locationTarget[0] + } + + fun getAxisYPositionOfViewOnScreen(targetView: View): Int { + val locationTarget = IntArray(2) + targetView.getLocationOnScreen(locationTarget) + return locationTarget[1] + } + + fun getVerticalScreenOffset(activity: Activity): Int { + val viewRoot = getViewRoot(activity) + return getScreenHeight(activity) - viewRoot.height + } + + fun getHorizontalScreenOffset(activity: Activity): Int { + val viewRoot = getViewRoot(activity) + return getScreenWidth(activity) - viewRoot.width + } + + private fun getViewRoot(activity: Activity): ViewGroup { + val androidContent = activity.findViewById(android.R.id.content) + return androidContent.parent.parent as ViewGroup + } + + fun getStatusBarHeight(context: Context): Int { + var result = 0 + val resourceId = context.resources.getIdentifier("status_bar_height", "dimen", "android") + if (resourceId > 0) { + result = context.resources.getDimensionPixelSize(resourceId) + } + return result + } + + fun pxToDp(px: Int): Int { + val metrics = Resources.getSystem().displayMetrics + return (px / (metrics.densityDpi.toFloat() / DisplayMetrics.DENSITY_DEFAULT)).roundToInt() + } + + fun dpToPx(dp: Int): Int { + val metrics = Resources.getSystem().displayMetrics + return (dp * (metrics.densityDpi.toFloat() / DisplayMetrics.DENSITY_DEFAULT)).roundToInt() + } + + fun isViewLocatedAtHalfTopOfTheScreen(activity: Activity, targetView: View): Boolean { + val screenHeight = getScreenHeight(activity) + val positionTargetAxisY = getAxisYPositionOfViewOnScreen(targetView) + return screenHeight / 2 > positionTargetAxisY + } + + fun isViewLocatedAtHalfLeftOfTheScreen(activity: Activity, targetView: View): Boolean { + val screenWidth = getScreenWidth(activity) + val positionTargetAxisX = getAxisXPositionOfViewOnScreen(targetView) + return screenWidth / 2 > positionTargetAxisX + } +} \ No newline at end of file diff --git a/OnBoardingBubbles/src/main/java/com/mohsin/onboardingbubbles/SequenceShowCaseListener.kt b/OnBoardingBubbles/src/main/java/com/mohsin/onboardingbubbles/SequenceShowCaseListener.kt new file mode 100644 index 0000000..8f98efa --- /dev/null +++ b/OnBoardingBubbles/src/main/java/com/mohsin/onboardingbubbles/SequenceShowCaseListener.kt @@ -0,0 +1,5 @@ +package com.mohsin.onboardingbubbles + +interface SequenceShowCaseListener { + fun onDismiss() +} \ No newline at end of file diff --git a/OnBoardingBubbles/src/main/res/drawable/button_bg.xml b/OnBoardingBubbles/src/main/res/drawable/button_bg.xml new file mode 100644 index 0000000..ba1f0b3 --- /dev/null +++ b/OnBoardingBubbles/src/main/res/drawable/button_bg.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/OnBoardingBubbles/src/main/res/drawable/ic_close_grey_16dp.xml b/OnBoardingBubbles/src/main/res/drawable/ic_close_grey_16dp.xml new file mode 100644 index 0000000..81ed437 --- /dev/null +++ b/OnBoardingBubbles/src/main/res/drawable/ic_close_grey_16dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/OnBoardingBubbles/src/main/res/drawable/rounded_rectangle.xml b/OnBoardingBubbles/src/main/res/drawable/rounded_rectangle.xml new file mode 100644 index 0000000..b452d87 --- /dev/null +++ b/OnBoardingBubbles/src/main/res/drawable/rounded_rectangle.xml @@ -0,0 +1,11 @@ + + + + + + \ No newline at end of file diff --git a/OnBoardingBubbles/src/main/res/font/nunito.xml b/OnBoardingBubbles/src/main/res/font/nunito.xml new file mode 100644 index 0000000..28b18c4 --- /dev/null +++ b/OnBoardingBubbles/src/main/res/font/nunito.xml @@ -0,0 +1,7 @@ + + + diff --git a/OnBoardingBubbles/src/main/res/font/nunito_black.xml b/OnBoardingBubbles/src/main/res/font/nunito_black.xml new file mode 100644 index 0000000..440dfc4 --- /dev/null +++ b/OnBoardingBubbles/src/main/res/font/nunito_black.xml @@ -0,0 +1,7 @@ + + + diff --git a/OnBoardingBubbles/src/main/res/font/nunito_black_italic.xml b/OnBoardingBubbles/src/main/res/font/nunito_black_italic.xml new file mode 100644 index 0000000..fc04f28 --- /dev/null +++ b/OnBoardingBubbles/src/main/res/font/nunito_black_italic.xml @@ -0,0 +1,7 @@ + + + diff --git a/OnBoardingBubbles/src/main/res/font/nunito_bold.xml b/OnBoardingBubbles/src/main/res/font/nunito_bold.xml new file mode 100644 index 0000000..4484884 --- /dev/null +++ b/OnBoardingBubbles/src/main/res/font/nunito_bold.xml @@ -0,0 +1,7 @@ + + + diff --git a/OnBoardingBubbles/src/main/res/font/nunito_bold_italic.xml b/OnBoardingBubbles/src/main/res/font/nunito_bold_italic.xml new file mode 100644 index 0000000..47e54b4 --- /dev/null +++ b/OnBoardingBubbles/src/main/res/font/nunito_bold_italic.xml @@ -0,0 +1,7 @@ + + + diff --git a/OnBoardingBubbles/src/main/res/font/nunito_extrabold.xml b/OnBoardingBubbles/src/main/res/font/nunito_extrabold.xml new file mode 100644 index 0000000..e35770f --- /dev/null +++ b/OnBoardingBubbles/src/main/res/font/nunito_extrabold.xml @@ -0,0 +1,7 @@ + + + diff --git a/OnBoardingBubbles/src/main/res/font/nunito_extrabold_italic.xml b/OnBoardingBubbles/src/main/res/font/nunito_extrabold_italic.xml new file mode 100644 index 0000000..1d30f2d --- /dev/null +++ b/OnBoardingBubbles/src/main/res/font/nunito_extrabold_italic.xml @@ -0,0 +1,7 @@ + + + diff --git a/OnBoardingBubbles/src/main/res/font/nunito_extralight.xml b/OnBoardingBubbles/src/main/res/font/nunito_extralight.xml new file mode 100644 index 0000000..bd64b75 --- /dev/null +++ b/OnBoardingBubbles/src/main/res/font/nunito_extralight.xml @@ -0,0 +1,7 @@ + + + diff --git a/OnBoardingBubbles/src/main/res/font/nunito_extralight_italic.xml b/OnBoardingBubbles/src/main/res/font/nunito_extralight_italic.xml new file mode 100644 index 0000000..b836944 --- /dev/null +++ b/OnBoardingBubbles/src/main/res/font/nunito_extralight_italic.xml @@ -0,0 +1,7 @@ + + + diff --git a/OnBoardingBubbles/src/main/res/font/nunito_font_family.xml b/OnBoardingBubbles/src/main/res/font/nunito_font_family.xml new file mode 100644 index 0000000..5b55a5a --- /dev/null +++ b/OnBoardingBubbles/src/main/res/font/nunito_font_family.xml @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/OnBoardingBubbles/src/main/res/font/nunito_italic.xml b/OnBoardingBubbles/src/main/res/font/nunito_italic.xml new file mode 100644 index 0000000..d7a5204 --- /dev/null +++ b/OnBoardingBubbles/src/main/res/font/nunito_italic.xml @@ -0,0 +1,7 @@ + + + diff --git a/OnBoardingBubbles/src/main/res/font/nunito_light.xml b/OnBoardingBubbles/src/main/res/font/nunito_light.xml new file mode 100644 index 0000000..4cd468f --- /dev/null +++ b/OnBoardingBubbles/src/main/res/font/nunito_light.xml @@ -0,0 +1,7 @@ + + + diff --git a/OnBoardingBubbles/src/main/res/font/nunito_light_italic.xml b/OnBoardingBubbles/src/main/res/font/nunito_light_italic.xml new file mode 100644 index 0000000..42b58f2 --- /dev/null +++ b/OnBoardingBubbles/src/main/res/font/nunito_light_italic.xml @@ -0,0 +1,7 @@ + + + diff --git a/OnBoardingBubbles/src/main/res/font/nunito_semibold.xml b/OnBoardingBubbles/src/main/res/font/nunito_semibold.xml new file mode 100644 index 0000000..52aa54c --- /dev/null +++ b/OnBoardingBubbles/src/main/res/font/nunito_semibold.xml @@ -0,0 +1,7 @@ + + + diff --git a/OnBoardingBubbles/src/main/res/font/nunito_semibold_italic.xml b/OnBoardingBubbles/src/main/res/font/nunito_semibold_italic.xml new file mode 100644 index 0000000..02c25da --- /dev/null +++ b/OnBoardingBubbles/src/main/res/font/nunito_semibold_italic.xml @@ -0,0 +1,7 @@ + + + diff --git a/OnBoardingBubbles/src/main/res/layout/view_bubble_message.xml b/OnBoardingBubbles/src/main/res/layout/view_bubble_message.xml new file mode 100644 index 0000000..9e53324 --- /dev/null +++ b/OnBoardingBubbles/src/main/res/layout/view_bubble_message.xml @@ -0,0 +1,88 @@ + + + + + + + + + + + + + + + + +