Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature: finish spotlight on touch outside of current target #129

Open
wants to merge 8 commits into
base: master
Choose a base branch
from
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,16 @@ If you want to show Spotlight immediately, you have to wait until views are laid
view.doOnPreDraw { Spotlight.Builder(this)...start() }
```

If you want to enable (disabled by default) finishing Spotlight on touch outside of current target:

```kt
val spotlight = Spotlight.Builder(this)
...
.setFinishOnTouchOutsideOfCurrentTarget(true)
...
.build()
```

<br/>
<br/>

Expand Down Expand Up @@ -108,6 +118,10 @@ class CustomShape(
override fun draw(canvas: Canvas, point: PointF, value: Float, paint: Paint) {
// draw your shape here.
}

override fun contains(anchor: PointF, point: PointF): Boolean {
// check if point is inside of shape here.
}
}
```

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.takusemba.spotlight

/**
* Listener to notify when user touches spotlight outside of current Target.
*/
interface OnTouchOutsideOfCurrentTargetListener {

fun onEvent()
}
47 changes: 33 additions & 14 deletions spotlight/src/main/java/com/takusemba/spotlight/Spotlight.kt
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import java.util.concurrent.TimeUnit
* unless you create a new [Spotlight] to start again.
*/
class Spotlight private constructor(
private val spotlight: SpotlightView,
private val spotlightView: SpotlightView,
private val targets: Array<Target>,
private val duration: Long,
private val interpolator: TimeInterpolator,
Expand All @@ -32,7 +32,7 @@ class Spotlight private constructor(
private var currentIndex = NO_POSITION

init {
container.addView(spotlight, MATCH_PARENT, MATCH_PARENT)
container.addView(spotlightView, MATCH_PARENT, MATCH_PARENT)
}

/**
Expand Down Expand Up @@ -77,7 +77,7 @@ class Spotlight private constructor(
* Starts Spotlight.
*/
private fun startSpotlight() {
spotlight.startSpotlight(duration, interpolator, object : AnimatorListenerAdapter() {
spotlightView.startSpotlight(duration, interpolator, object : AnimatorListenerAdapter() {
override fun onAnimationStart(animation: Animator) {
spotlightListener?.onStarted()
}
Expand All @@ -95,18 +95,18 @@ class Spotlight private constructor(
if (currentIndex == NO_POSITION) {
val target = targets[index]
currentIndex = index
spotlight.startTarget(target)
spotlightView.startTarget(target)
target.listener?.onStarted()
} else {
spotlight.finishTarget(object : AnimatorListenerAdapter() {
spotlightView.finishTarget(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator) {
val previousIndex = currentIndex
val previousTarget = targets[previousIndex]
previousTarget.listener?.onEnded()
if (index < targets.size) {
val target = targets[index]
currentIndex = index
spotlight.startTarget(target)
spotlightView.startTarget(target)
target.listener?.onStarted()
} else {
finishSpotlight()
Expand All @@ -120,10 +120,10 @@ class Spotlight private constructor(
* Closes Spotlight.
*/
private fun finishSpotlight() {
spotlight.finishSpotlight(duration, interpolator, object : AnimatorListenerAdapter() {
spotlightView.finishSpotlight(duration, interpolator, object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator) {
spotlight.cleanup()
container.removeView(spotlight)
spotlightView.cleanup()
container.removeView(spotlightView)
spotlightListener?.onEnded()
}
})
Expand All @@ -147,6 +147,9 @@ class Spotlight private constructor(
private var container: ViewGroup? = null
private var listener: OnSpotlightListener? = null

// Finish on touch outside of current target feature is disabled by default
private var finishOnTouchOutsideOfCurrentTarget: Boolean = false

/**
* Sets [Target]s to show on [Spotlight].
*/
Expand Down Expand Up @@ -205,20 +208,36 @@ class Spotlight private constructor(
this.listener = listener
}

fun build(): Spotlight {
/**
* Sets [finishOnTouchOutsideOfCurrentTarget] flag
* to enable/disable (true/false) finishing on touch outside feature.
*/
fun setFinishOnTouchOutsideOfCurrentTarget(
finishOnTouchOutsideOfCurrentTarget: Boolean
): Builder = apply {
this.finishOnTouchOutsideOfCurrentTarget = finishOnTouchOutsideOfCurrentTarget
}

val spotlight = SpotlightView(activity, null, 0, backgroundColor)
fun build(): Spotlight {
val spotlightView = SpotlightView(activity, null, 0, backgroundColor)
val targets = requireNotNull(targets) { "targets should not be null. " }
val container = container ?: activity.window.decorView as ViewGroup

return Spotlight(
spotlight = spotlight,
spotlightView = spotlightView,
targets = targets,
duration = duration,
interpolator = interpolator,
container = container,
spotlightListener = listener
)
).apply {
if ([email protected]) {
spotlightView.setOnTouchOutsideOfCurrentTargetListener(
object : OnTouchOutsideOfCurrentTargetListener {
override fun onEvent() = finishSpotlight()
}
)
}
}
}

companion object {
Expand Down
39 changes: 39 additions & 0 deletions spotlight/src/main/java/com/takusemba/spotlight/SpotlightView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import android.graphics.PointF
import android.graphics.PorterDuff
import android.graphics.PorterDuffXfermode
import android.util.AttributeSet
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import android.widget.FrameLayout
Expand Down Expand Up @@ -46,6 +47,8 @@ internal class SpotlightView @JvmOverloads constructor(
private var effectAnimator: ValueAnimator? = null
private var target: Target? = null

private var onTouchOutsideOfCurrentTargetListener: OnTouchOutsideOfCurrentTargetListener? = null

init {
setWillNotDraw(false)
setLayerType(View.LAYER_TYPE_HARDWARE, null)
Expand Down Expand Up @@ -75,6 +78,38 @@ internal class SpotlightView @JvmOverloads constructor(
}
}

/**
* Based on guide:
* https://developer.android.com/guide/topics/ui/accessibility/custom-views#custom-click-events
*/
override fun onTouchEvent(event: MotionEvent): Boolean {
super.onTouchEvent(event)
return when (event.action) {
MotionEvent.ACTION_UP -> {
performClick() // Call this method to handle the response, and
// thereby enable accessibility services to
// perform this action for a user who cannot
// click the touchscreen.
true
}
MotionEvent.ACTION_DOWN -> {
val currentTarget = this.target ?: return false
val touchPoint = PointF(event.x, event.y)
if (!currentTarget.contains(touchPoint)) onTouchOutsideOfCurrentTargetListener?.onEvent()
true
}
else -> false
}
}

override fun performClick(): Boolean {
// Calls the super implementation, which generates an AccessibilityEvent
// and calls the onClick() listener on the view, if any
super.performClick()
// Handle the action for the custom click here
return true
}

/**
* Starts [Spotlight].
*/
Expand Down Expand Up @@ -209,4 +244,8 @@ internal class SpotlightView @JvmOverloads constructor(
shapeAnimator = null
removeAllViews()
}

fun setOnTouchOutsideOfCurrentTargetListener(listener: OnTouchOutsideOfCurrentTargetListener) {
onTouchOutsideOfCurrentTargetListener = listener
}
}
8 changes: 8 additions & 0 deletions spotlight/src/main/java/com/takusemba/spotlight/Target.kt
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,14 @@ class Target(
val listener: OnTargetListener?
) {

/**
* Checks if point on edge or inside of the Shape.
*
* @param point point to check against contains.
* @return true if contains, false - otherwise.
*/
fun contains(point: PointF): Boolean = shape.contains(anchor, point)

/**
* [Builder] to build a [Target].
* All parameters should be set in this [Builder].
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@ class Circle @JvmOverloads constructor(
canvas.drawCircle(point.x, point.y, value * radius, paint)
}

override fun contains(anchor: PointF, point: PointF): Boolean {
val xNorm = point.x - anchor.x
val yNorm = point.y - anchor.y
return (xNorm * xNorm + yNorm * yNorm) <= radius * radius
}

companion object {

val DEFAULT_DURATION = TimeUnit.MILLISECONDS.toMillis(500)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import android.graphics.PointF
import android.graphics.RectF
import android.view.animation.DecelerateInterpolator
import java.util.concurrent.TimeUnit
import kotlin.math.abs
import kotlin.math.pow

/**
* [Shape] of RoundedRectangle with customizable height, width, and radius.
Expand All @@ -30,6 +32,25 @@ class RoundedRectangle @JvmOverloads constructor(
canvas.drawRoundRect(rect, radius, radius, paint)
}

/**
* Ellipsis function is used to check if point is in rounded rectangle.
* Ellipsis doesn't guarantee ideal precision.
* Check https://en.wikipedia.org/wiki/Squircle
*
* Calculated values:
* - r = [0; widthHalf], where 0 - rectangle, widthHalf - "smooth" ellipse
* - n = [2; inf], where 2 - "smooth" ellipse, inf - rectangle
*/
override fun contains(anchor: PointF, point: PointF): Boolean {
val xNorm = point.x - anchor.x
val yNorm = point.y - anchor.y
val widthHalf = width / 2
val heightHalf = height / 2
val r = radius.coerceIn(minimumValue = 0f, maximumValue = widthHalf)
val n = maxOf(width, height) / r
return abs((xNorm / widthHalf)).pow(n) + abs((yNorm / heightHalf)).pow(n) <= 1
}

companion object {

val DEFAULT_DURATION = TimeUnit.MILLISECONDS.toMillis(500)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,13 @@ interface Shape {
* @param value the animated value from 0 to 1.
*/
fun draw(canvas: Canvas, point: PointF, value: Float, paint: Paint)

/**
* Checks if point on edge or inside of the Shape.
*
* @param anchor center of Shape.
* @param point point to check against contains.
* @return true if contains, false - otherwise.
*/
fun contains(anchor: PointF, point: PointF): Boolean
}