Skip to content

Commit 2f39345

Browse files
committed
Add advanceOnTargetTapOnly for restricting showcase advancement to taps inside the highlighted target
1 parent 5a99c82 commit 2f39345

7 files changed

Lines changed: 132 additions & 12 deletions

File tree

README.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,22 @@ ShowcaseLayout(
311311
)
312312
```
313313

314+
#### advanceOnTargetTapOnly
315+
316+
By default a tap anywhere on the screen advances to the next target (or dismisses the showcase). Set
317+
`advanceOnTargetTapOnly = true` to only advance when the user taps **inside the highlighted target
318+
shape** — taps elsewhere are ignored. Useful when you want the user to actually interact with (or
319+
notice) the highlighted element before moving on. The greeting/initial screen still advances on any
320+
tap. Defaults to `false`. Available on both `ShowcaseLayout` and `TargetShowcaseLayout`.
321+
322+
```kotlin
323+
ShowcaseLayout(
324+
isShowcasing = isShowcasing,
325+
onFinish = { isShowcasing = false },
326+
advanceOnTargetTapOnly = true
327+
)
328+
```
329+
314330
#### ShowcaseMsg
315331

316332
Use `ShowcaseMsg()` to add a message and customize it with arrow, background and more.

composeApp/src/commonMain/kotlin/App.kt

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -31,12 +31,10 @@ import ly.com.tahaben.showcase_layout_compose.domain.ShowcaseEventListener
3131
import ly.com.tahaben.showcase_layout_compose.model.*
3232
import org.jetbrains.compose.resources.ExperimentalResourceApi
3333
import org.jetbrains.compose.resources.painterResource
34-
import org.jetbrains.compose.ui.tooling.preview.Preview
3534
import showcase_layout_compose_kmp.composeapp.generated.resources.*
3635
import kotlin.math.roundToInt
3736

3837
@OptIn(ExperimentalResourceApi::class, ExperimentalMaterial3Api::class)
39-
@Preview
4038
@Composable
4139
fun App(openUrl: (String) -> Boolean, onWebLoadFinish: () -> Unit = {}) {
4240
LaunchedEffect(key1 = Unit, block = { onWebLoadFinish() })
@@ -92,6 +90,7 @@ fun App(openUrl: (String) -> Boolean, onWebLoadFinish: () -> Unit = {}) {
9290
var durationSliderValue by remember { mutableStateOf(500) }
9391
var animationDuration by remember { mutableStateOf(500) }
9492
var animateToNextTarget by remember { mutableStateOf(true) }
93+
var advanceOnTargetTapOnly by remember { mutableStateOf(true) }
9594
var targetShape by remember { mutableStateOf(TargetShape.CIRCLE) }
9695
val targetShapeName by derivedStateOf {
9796
when(targetShape){
@@ -120,7 +119,8 @@ fun App(openUrl: (String) -> Boolean, onWebLoadFinish: () -> Unit = {}) {
120119
lineThickness = lineThinckness.dp,
121120
animationDuration = animationDuration,
122121
targetShape = targetShape,
123-
animateToNextTarget = animateToNextTarget
122+
animateToNextTarget = animateToNextTarget,
123+
advanceOnTargetTapOnly = advanceOnTargetTapOnly
124124
) {
125125
Column(modifier = Modifier.fillMaxSize().verticalScroll(scrollState)) {
126126
TopAppBar(title = {
@@ -198,7 +198,7 @@ fun App(openUrl: (String) -> Boolean, onWebLoadFinish: () -> Unit = {}) {
198198
expanded = selectTargetMenuExpaned,
199199
onExpandedChange = { selectTargetMenuExpaned = it }) {
200200
OutlinedTextField(
201-
modifier = Modifier.menuAnchor(type = MenuAnchorType.PrimaryNotEditable).showcase(
201+
modifier = Modifier.menuAnchor(type = ExposedDropdownMenuAnchorType.PrimaryNotEditable).showcase(
202202
4, message = ShowcaseMsg(
203203
text = buildAnnotatedString {
204204
append("You can choose what target to showcase form here then click the ")
@@ -259,7 +259,7 @@ fun App(openUrl: (String) -> Boolean, onWebLoadFinish: () -> Unit = {}) {
259259
expanded = selectHeadMenuExpaned,
260260
onExpandedChange = { selectHeadMenuExpaned = it }) {
261261
OutlinedTextField(
262-
modifier = Modifier.menuAnchor(type = MenuAnchorType.PrimaryNotEditable),
262+
modifier = Modifier.menuAnchor(type = ExposedDropdownMenuAnchorType.PrimaryNotEditable),
263263
value = selectedHead,
264264
onValueChange = {},
265265
readOnly = true,
@@ -370,7 +370,7 @@ fun App(openUrl: (String) -> Boolean, onWebLoadFinish: () -> Unit = {}) {
370370
expanded = selectTargetShapeMenuExpaned,
371371
onExpandedChange = { selectTargetShapeMenuExpaned = it }) {
372372
OutlinedTextField(
373-
modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable, true),
373+
modifier = Modifier.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable, true),
374374
value = targetShapeName,
375375
onValueChange = {},
376376
readOnly = true,
@@ -605,7 +605,7 @@ fun App(openUrl: (String) -> Boolean, onWebLoadFinish: () -> Unit = {}) {
605605
Modifier.selectable(
606606
selected = animateHead,
607607
onClick = { if (!useTargetShowcaseLayout) animateHead = !animateHead },
608-
role = Role.RadioButton,
608+
role = Role.Checkbox,
609609
enabled = !useTargetShowcaseLayout
610610
), verticalAlignment = Alignment.CenterVertically
611611
) {
@@ -621,6 +621,20 @@ fun App(openUrl: (String) -> Boolean, onWebLoadFinish: () -> Unit = {}) {
621621
}
622622
}
623623
}
624+
Row(
625+
modifier = Modifier.selectable(
626+
selected = advanceOnTargetTapOnly,
627+
onClick = { advanceOnTargetTapOnly = !advanceOnTargetTapOnly },
628+
role = Role.Checkbox
629+
),
630+
verticalAlignment = Alignment.CenterVertically,
631+
) {
632+
Text("Advance on target tap only")
633+
Checkbox(
634+
checked = advanceOnTargetTapOnly,
635+
onCheckedChange = { advanceOnTargetTapOnly = it }
636+
)
637+
}
624638
}
625639
}
626640
Row {
@@ -735,7 +749,6 @@ fun App(openUrl: (String) -> Boolean, onWebLoadFinish: () -> Unit = {}) {
735749
}
736750
}
737751

738-
@Preview
739752
@Composable
740753
fun AppPreview() {
741754
MyTheme {

composeApp/src/commonMain/kotlin/ShowcaseLayoutWrapper.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import ly.com.tahaben.showcase_layout_compose.ui.TargetShowcaseLayout
2121
* @param targetShape the shape of the target highlight (RECTANGLE, CIRCLE, or ROUNDED_RECTANGLE).
2222
* @param cornerRadius the corner radius for the ROUNDED_RECTANGLE shape in dp.
2323
* @param animateToNextTarget if true, the target shape will animate smoothly from one target to the next when the index changes (only used in TargetShowcaseLayout).
24+
* @param advanceOnTargetTapOnly when true, only taps inside the highlighted target advance/dismiss the showcase; the greeting always advances on any tap.
2425
* @param colors the colors used to draw the overlay (and pulse for TargetShowcaseLayout), created with [ShowcaseLayoutDefaults.colors].
2526
*/
2627
@Composable
@@ -35,6 +36,7 @@ fun ShowcaseLayoutWrapper(
3536
targetShape: TargetShape = TargetShape.RECTANGLE,
3637
cornerRadius: Dp = 16.dp,
3738
animateToNextTarget: Boolean = true,
39+
advanceOnTargetTapOnly: Boolean = true,
3840
colors: ShowcaseLayoutDefaults.Colors = ShowcaseLayoutDefaults.colors(),
3941
content: @Composable ShowcaseScope.() -> Unit
4042
) {
@@ -49,6 +51,7 @@ fun ShowcaseLayoutWrapper(
4951
targetShape = targetShape,
5052
cornerRadius = cornerRadius,
5153
animateToNextTarget = animateToNextTarget,
54+
advanceOnTargetTapOnly = advanceOnTargetTapOnly,
5255
colors = colors,
5356
content = content
5457
)
@@ -62,6 +65,7 @@ fun ShowcaseLayoutWrapper(
6265
lineThickness = lineThickness,
6366
targetShape = targetShape,
6467
cornerRadius = cornerRadius,
68+
advanceOnTargetTapOnly = advanceOnTargetTapOnly,
6569
colors = colors,
6670
content = content
6771
)

gradle/libs.versions.toml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
[versions]
22
agp = "8.13.2"
3-
android-compileSdk = "35"
3+
android-compileSdk = "36"
44
android-minSdk = "24"
5-
android-targetSdk = "35"
5+
#noinspection EditedTargetSdkVersion
6+
android-targetSdk = "36"
67
androidx-activityCompose = "1.13.0"
78
androidx-appcompat = "1.7.1"
89
androidx-constraintlayout = "2.2.1"
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
package ly.com.tahaben.showcase_layout_compose.domain.usecase
2+
3+
import androidx.compose.ui.geometry.Offset
4+
import androidx.compose.ui.geometry.Size
5+
import ly.com.tahaben.showcase_layout_compose.model.TargetShape
6+
import kotlin.math.hypot
7+
import kotlin.math.max
8+
9+
/**
10+
* Returns true if [tap] falls inside the target highlight described by [topLeft] and [size]
11+
* for the given [targetShape].
12+
*
13+
* Used to restrict advancing/dismissing the showcase to taps on the highlighted target.
14+
*
15+
* @param tap the tap position, in the same coordinate space as the drawn cutout.
16+
* @param topLeft the top-left corner of the target bounds.
17+
* @param size the size of the target bounds.
18+
* @param targetShape the shape of the target highlight.
19+
* @param tolerancePx expands the hit area on every side so tiny targets and finger slop still register.
20+
*/
21+
internal fun isTapInsideTarget(
22+
tap: Offset,
23+
topLeft: Offset,
24+
size: Size,
25+
targetShape: TargetShape,
26+
tolerancePx: Float = 0f,
27+
): Boolean {
28+
val left = topLeft.x - tolerancePx
29+
val top = topLeft.y - tolerancePx
30+
val right = topLeft.x + size.width + tolerancePx
31+
val bottom = topLeft.y + size.height + tolerancePx
32+
return when (targetShape) {
33+
// The rounded-rectangle corners are approximated by the bounding box: the difference is a
34+
// few pixels at each corner and is irrelevant for tap intent.
35+
TargetShape.RECTANGLE, TargetShape.ROUNDED_RECTANGLE ->
36+
tap.x in left..right && tap.y in top..bottom
37+
38+
TargetShape.CIRCLE -> {
39+
val centerX = topLeft.x + size.width / 2f
40+
val centerY = topLeft.y + size.height / 2f
41+
val radius = max(size.width, size.height) / 2f + tolerancePx
42+
hypot(tap.x - centerX, tap.y - centerY) <= radius
43+
}
44+
}
45+
}

showcase-layout-compose/src/commonMain/kotlin/ly/com/tahaben/showcase_layout_compose/ui/ShowcaseLayout.kt

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import kotlinx.coroutines.flow.asStateFlow
3434
import kotlinx.coroutines.launch
3535
import ly.com.tahaben.showcase_layout_compose.domain.Level
3636
import ly.com.tahaben.showcase_layout_compose.domain.ShowcaseEventListener
37+
import ly.com.tahaben.showcase_layout_compose.domain.usecase.isTapInsideTarget
3738
import ly.com.tahaben.showcase_layout_compose.domain.usecase.validateInitIndex
3839
import ly.com.tahaben.showcase_layout_compose.model.*
3940
import kotlin.math.PI
@@ -59,6 +60,7 @@ import kotlin.math.max
5960

6061
private const val TAG = "ShowcaseLayout "
6162
private const val INDEX_RESET_DELAY = 250L
63+
private const val TARGET_TAP_TOLERANCE_PX = 16f
6264

6365
/**
6466
* ShowcaseLayout
@@ -71,6 +73,7 @@ private const val INDEX_RESET_DELAY = 250L
7173
* @param lineThickness thickness of the arrow line in dp.
7274
* @param targetShape the shape of the target highlight (RECTANGLE, CIRCLE, or ROUNDED_RECTANGLE).
7375
* @param cornerRadius the corner radius for the ROUNDED_RECTANGLE shape in dp.
76+
* @param advanceOnTargetTapOnly when true, only taps inside the highlighted target shape advance/dismiss the showcase; taps elsewhere are ignored. The greeting/initial screen always advances on any tap. Defaults to false (tap anywhere).
7477
* @param colors the colors used to draw the overlay, created with [ShowcaseLayoutDefaults.colors]. Pass a light [ShowcaseLayoutDefaults.Colors.overlayColor] for a dark UI.
7578
**/
7679

@@ -84,6 +87,7 @@ fun ShowcaseLayout(
8487
lineThickness: Dp = 5.dp,
8588
targetShape: TargetShape = TargetShape.RECTANGLE,
8689
cornerRadius: Dp = 16.dp,
90+
advanceOnTargetTapOnly: Boolean = false,
8791
colors: ShowcaseLayoutDefaults.Colors = ShowcaseLayoutDefaults.colors(),
8892
content: @Composable ShowcaseScope.() -> Unit
8993
) {
@@ -258,6 +262,17 @@ fun ShowcaseLayout(
258262
.semantics { testTag = "canvas" }
259263
.pointerInput(Unit) {
260264
detectTapGestures {
265+
/** when enabled, only taps inside the target shape advance the showcase
266+
* (the greeting/initial screen always advances on any tap) */
267+
if (advanceOnTargetTapOnly && currentIndex != 0 && !isSingleGreeting &&
268+
!isTapInsideTarget(it, offset, itemSize, targetShape, TARGET_TAP_TOLERANCE_PX)
269+
) {
270+
scope.showcaseEventListener?.onEvent(
271+
Level.VERBOSE,
272+
TAG + "tap outside target ignored at $it"
273+
)
274+
return@detectTapGestures
275+
}
261276
/** detect taps on the screen */
262277
coroutineScope.launch {
263278

@@ -964,7 +979,7 @@ fun ShowcaseLayout(
964979
message = "isDarkLayout has been replaced by the colors parameter; pass a light overlay color instead.",
965980
replaceWith = ReplaceWith(
966981
"ShowcaseLayout(isShowcasing, initIndex, animationDuration, onFinish, greeting, " +
967-
"lineThickness, targetShape, cornerRadius, " +
982+
"lineThickness, targetShape, cornerRadius, false, " +
968983
"ShowcaseLayoutDefaults.colors(overlayColor = if (isDarkLayout) Color.White.copy(alpha = 0.9f) else Color.Black.copy(alpha = 0.9f)), content)"
969984
)
970985
)

showcase-layout-compose/src/commonMain/kotlin/ly/com/tahaben/showcase_layout_compose/ui/TargetShowcaseLayout.kt

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import androidx.compose.ui.unit.dp
2727
import kotlinx.coroutines.delay
2828
import kotlinx.coroutines.launch
2929
import ly.com.tahaben.showcase_layout_compose.domain.Level
30+
import ly.com.tahaben.showcase_layout_compose.domain.usecase.isTapInsideTarget
3031
import ly.com.tahaben.showcase_layout_compose.domain.usecase.validateInitIndex
3132
import ly.com.tahaben.showcase_layout_compose.model.MsgAnimation
3233
import ly.com.tahaben.showcase_layout_compose.model.ShowcaseMsg
@@ -54,6 +55,7 @@ import kotlin.math.min
5455

5556
private const val TAG = "TargetShowcaseLayout "
5657
private const val INDEX_RESET_DELAY = 250L
58+
private const val TARGET_TAP_TOLERANCE_PX = 16f
5759

5860
/**
5961
* TargetShowcaseLayout
@@ -68,6 +70,7 @@ private const val INDEX_RESET_DELAY = 250L
6870
* @param cornerRadius the radius of the corners when targetShape is ROUNDED_RECTANGLE.
6971
* @param animateToNextTarget if true, the target shape will animate smoothly from one target to the next when the index changes.
7072
* If false, the shape will shrink at the current location, then expand at the new location.
73+
* @param advanceOnTargetTapOnly when true, only taps inside the highlighted target shape advance/dismiss the showcase; taps elsewhere are ignored. The greeting/initial screen always advances on any tap. Defaults to false (tap anywhere).
7174
* @param colors the colors used to draw the overlay and pulse, created with [ShowcaseLayoutDefaults.colors]. Pass a light [ShowcaseLayoutDefaults.Colors.overlayColor] for a dark UI. A per-step [ShowcaseMsg.msgBackground] still takes precedence over [ShowcaseLayoutDefaults.Colors.overlayColor].
7275
**/
7376
@Composable
@@ -81,6 +84,7 @@ fun TargetShowcaseLayout(
8184
targetShape: TargetShape = TargetShape.ROUNDED_RECTANGLE,
8285
cornerRadius: Dp = 8.dp,
8386
animateToNextTarget: Boolean = true,
87+
advanceOnTargetTapOnly: Boolean = false,
8488
colors: ShowcaseLayoutDefaults.Colors = ShowcaseLayoutDefaults.colors(),
8589
content: @Composable ShowcaseScope.() -> Unit
8690
) {
@@ -134,6 +138,11 @@ fun TargetShowcaseLayout(
134138

135139
val animatedX = remember { Animatable(offset.x) }
136140
val animatedY = remember { Animatable(offset.y) }
141+
// The tap handler lives in a pointerInput(Unit) that never restarts, so it would
142+
// otherwise capture the first composition's (greeting) bounds. Track the current
143+
// target's resting bounds in State so the hit-test always reads the live values.
144+
val currentTargetTopLeft by rememberUpdatedState(offset)
145+
val currentTargetSize by rememberUpdatedState(itemSize)
137146
val maxDimension =
138147
max(maxHeight.value, maxWidth.value)
139148

@@ -316,6 +325,23 @@ fun TargetShowcaseLayout(
316325
Level.VERBOSE,
317326
TAG + "tapped here $it"
318327
)
328+
/** when enabled, only taps inside the target shape advance the showcase
329+
* (the greeting/initial screen always advances on any tap) */
330+
if (advanceOnTargetTapOnly && currentIndex != 0 && !isSingleGreeting &&
331+
!isTapInsideTarget(
332+
it,
333+
currentTargetTopLeft,
334+
currentTargetSize,
335+
targetShape,
336+
TARGET_TAP_TOLERANCE_PX
337+
)
338+
) {
339+
scope.showcaseEventListener?.onEvent(
340+
Level.VERBOSE,
341+
TAG + "tap outside target ignored at $it"
342+
)
343+
return@detectTapGestures
344+
}
319345
if (showCasingItem) {
320346
val shrinkDuration = animationDuration
321347

@@ -1127,7 +1153,7 @@ fun TargetShowcaseLayout(
11271153
message = "isDarkLayout has been replaced by the colors parameter; pass a light overlay color instead.",
11281154
replaceWith = ReplaceWith(
11291155
"TargetShowcaseLayout(isShowcasing, initIndex, animationDuration, onFinish, greeting, " +
1130-
"lineThickness, targetShape, cornerRadius, animateToNextTarget, " +
1156+
"lineThickness, targetShape, cornerRadius, animateToNextTarget, false, " +
11311157
"ShowcaseLayoutDefaults.colors(overlayColor = if (isDarkLayout) Color.White.copy(alpha = 0.9f) else Color.Black.copy(alpha = 0.9f)), content)"
11321158
)
11331159
)

0 commit comments

Comments
 (0)