1
0
Fork 0
mirror of https://github.com/bitwarden/android.git synced 2025-01-30 19:53:47 +03:00

PM-16631 Adding coach mark container and state to allow for guided screen tours

This commit is contained in:
Dave Severns 2025-01-03 14:51:42 -05:00
parent 412649ed9e
commit 4b5f16d407
4 changed files with 1198 additions and 0 deletions
app/src/main/java/com/x8bit/bitwarden/ui/platform/components/coachmark

View file

@ -0,0 +1,246 @@
package com.x8bit.bitwarden.ui.platform.components.coachmark
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.RoundRect
import androidx.compose.ui.graphics.ClipOp
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.clipPath
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.components.button.BitwardenStandardIconButton
import com.x8bit.bitwarden.ui.platform.components.text.BitwardenClickableText
import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter
import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
import kotlinx.coroutines.launch
private const val ROUNDED_RECT_RADIUS = 8f
/**
* A composable container that manages and displays coach mark highlights.
*
* This composable provides a full-screen overlay that can highlight specific
* areas of the UI and display tooltips to guide the user through a sequence
* of steps or features.
*
* @param T The type of the enum used to represent the unique keys for each coach mark highlight.
* @param state The [CoachMarkState] that manages the sequence and state of the coach marks.
* @param modifier The modifier to be applied to the container.
* @param content The composable content that defines the coach mark highlights within the
* [CoachMarkScope].
*/
@Composable
@Suppress("LongMethod")
fun <T : Enum<T>> CoachMarkContainer(
state: CoachMarkState<T>,
modifier: Modifier = Modifier,
content: @Composable CoachMarkScope<T>.() -> Unit,
) {
val scope = rememberCoroutineScope()
Box(
modifier = Modifier
.fillMaxSize()
.then(modifier),
) {
CoachMarkScopeInstance(coachMarkState = state).content()
val boundedRectangle by state.currentHighlightBounds
val isVisible by state.isVisible
val currentHighlightShape by state.currentHighlightShape
val highlightPath = remember(boundedRectangle, currentHighlightShape) {
if (boundedRectangle == Rect.Zero) {
return@remember Path()
}
val highlightArea = Rect(
topLeft = boundedRectangle.topLeft,
bottomRight = boundedRectangle.bottomRight,
)
Path().apply {
when (currentHighlightShape) {
CoachMarkHighlightShape.SQUARE -> addRoundRect(
RoundRect(
rect = highlightArea,
cornerRadius = CornerRadius(
x = ROUNDED_RECT_RADIUS,
),
),
)
CoachMarkHighlightShape.OVAL -> addOval(highlightArea)
}
}
}
if (boundedRectangle != Rect.Zero && isVisible) {
val backgroundColor = BitwardenTheme.colorScheme.background.scrim
Box(
modifier = Modifier
.pointerInput(Unit) {
detectTapGestures(
onTap = {
scope.launch {
state.showToolTipForCurrentCoachMark()
}
},
)
}
.fillMaxSize()
.drawBehind {
clipPath(
path = highlightPath,
clipOp = ClipOp.Difference,
block = {
drawRect(
color = backgroundColor,
)
},
)
},
)
}
// Once the bounds and shape update show the tooltip for the active coach mark.
LaunchedEffect(state.currentHighlightBounds.value, state.currentHighlightShape.value) {
if (state.currentHighlightBounds.value != Rect.Zero) {
state.showToolTipForCurrentCoachMark()
}
}
// On the initial composition of the screen check to see if the coach mark was visible and
// then show the associated coach mark.
LaunchedEffect(Unit) {
if (state.isVisible.value) {
state.currentHighlight.value?.let {
state.showCoachMark(it)
}
}
}
}
}
@Preview
@Composable
@Suppress("LongMethod")
private fun BitwardenCoachMarkContainer_preview() {
BitwardenTheme {
val state = rememberCoachMarkState(Foo.entries)
val scope = rememberCoroutineScope()
CoachMarkContainer(
state = state,
) {
Column(
modifier = Modifier
.background(BitwardenTheme.colorScheme.background.primary)
.padding(top = 100.dp)
.padding(horizontal = 16.dp)
.fillMaxSize(),
) {
BitwardenClickableText(
label = "Start Coach Mark Flow",
onClick = {
scope.launch {
state.showCoachMark(Foo.Bar)
}
},
style = BitwardenTheme.typography.labelLarge,
modifier = Modifier
.padding(bottom = 16.dp)
.align(Alignment.CenterHorizontally),
)
Spacer(Modifier.height(24.dp))
Row(
modifier = Modifier
.align(Alignment.CenterHorizontally)
.fillMaxWidth(),
) {
Spacer(modifier = Modifier.weight(1f))
CoachMarkHighlight(
key = Foo.Bar,
title = "1 of 3",
description = "Use this button to generate a new unique password.",
rightAction = {
BitwardenClickableText(
label = "Next",
onClick = {
scope.launch {
state.showNextCoachMark()
}
},
style = BitwardenTheme.typography.labelLarge,
)
},
shape = CoachMarkHighlightShape.OVAL,
) {
BitwardenStandardIconButton(
painter = rememberVectorPainter(R.drawable.ic_puzzle),
contentDescription = stringResource(R.string.close),
onClick = {},
)
}
}
Spacer(Modifier.height(24.dp))
CoachMarkHighlight(
key = Foo.Baz,
title = "Foo",
description = "Baz",
leftAction = {
BitwardenClickableText(
label = "Back",
onClick = {
scope.launch {
state.showPreviousCoachMark()
}
},
style = BitwardenTheme.typography.labelLarge,
)
},
rightAction = {
BitwardenClickableText(
label = "Done",
onClick = {
scope.launch {
state.coachingComplete()
}
},
style = BitwardenTheme.typography.labelLarge,
)
},
) {
Text(text = "Foo Baz")
}
Spacer(Modifier.size(100.dp))
}
}
}
}
/**
* Example enum for demonstration purposes.
*/
private enum class Foo {
Bar,
Baz,
}

View file

@ -0,0 +1,125 @@
package com.x8bit.bitwarden.ui.platform.components.coachmark
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.items
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.x8bit.bitwarden.ui.platform.base.util.Text
import com.x8bit.bitwarden.ui.platform.components.model.CardStyle
/**
* Defines the scope for creating coach mark highlights within a user interface.
*
* This interface provides a way to define and display a highlight that guides the user's
* attention to a specific part of the UI, often accompanied by a tooltip with
* explanatory text and actions.
*
* @param T The type of the enum used to represent the unique keys for each coach mark highlight.
*/
interface CoachMarkScope<T : Enum<T>> {
/**
* Creates a highlight for a specific coach mark.
*
* This function defines a region of the UI to be highlighted, along with an
* associated tooltip that can display a title, description, and actions.
*
* @param key The unique key identifying this highlight. This key is used to
* manage the state and order of the coach mark sequence.
* @param title The title of the coach mark, displayed in the tooltip.
* @param description The description of the coach mark, providing more context
* to the user. Displayed in the tooltip.
* @param shape The shape of the highlight. Defaults to [CoachMarkHighlightShape.SQUARE].
* Use [CoachMarkHighlightShape.OVAL] for a circular highlight.
* @param onDismiss An optional callback that is invoked when the coach mark is dismissed
* (e.g., by clicking the close button). If provided, this function
* will be executed after the coach mark is dismissed. If not provided,
* no action is taken on dismissal.
* @param leftAction An optional composable to be displayed on the left side of the
* action row in the tooltip. This can be used to provide
* additional actions or controls.
* @param rightAction An optional composable to be displayed on the right side of the
* action row in the tooltip. This can be used to provide
* primary actions or navigation.
* @param anchorContent The composable content to be highlighted. This is the UI element
* that will be visually emphasized by the coach mark.
*/
@Composable
fun CoachMarkHighlight(
key: T,
title: String,
description: String,
modifier: Modifier = Modifier,
shape: CoachMarkHighlightShape = CoachMarkHighlightShape.SQUARE,
onDismiss: (() -> Unit)? = null,
leftAction: (@Composable RowScope.() -> Unit)? = null,
rightAction: (@Composable RowScope.() -> Unit)? = null,
anchorContent: @Composable () -> Unit,
)
/**
* Creates a [CoachMarkScope.CoachMarkHighlight] in the context of a [LazyListScope],
* automatically assigns the value of [key] as the [LazyListScope.item]'s `key` value.
* This is used to be able to find the item to apply the coach mark to in the LazyList.
* Analogous with [LazyListScope.item] in the context of adding a coach mark around an entire
* item.
*
* @param key The key used for the CoachMark data as well as the `item.key` to find within
* the `LazyList`.
*
* @see [CoachMarkScope.CoachMarkHighlight]
*
* Note: If you are only intending "highlight" part of an `item` you will want to give that
* item the same `key` as the [key] for the coach mark.
*/
@Suppress("LongParameterList")
fun LazyListScope.coachMarkHighlight(
key: T,
title: Text,
description: Text,
modifier: Modifier = Modifier,
shape: CoachMarkHighlightShape = CoachMarkHighlightShape.SQUARE,
onDismiss: (() -> Unit)? = null,
leftAction: (@Composable RowScope.() -> Unit)? = null,
rightAction: (@Composable RowScope.() -> Unit)? = null,
anchorContent: @Composable () -> Unit,
)
/**
* Allows for wrapping an entire list of [items] in a single Coach Mark Highlight. The
* anchor for the tooltip and the scrolling target will be the start/top of the content.
*
* @param items Typed list of items to display in the [LazyListScope.items] block.
* @param leadingStaticContent Optional static content to slot in a [LazyListScope.item]
* ahead of the list of items.
* @param leadingContentIsTopCard To denote that the leading content is the "top" part of a
* card creating using [CardStyle].
* @param trailingStaticContent Optional static content to slot in a [LazyListScope.item]
* after the list of items.
* @param trailingContentIsBottomCard To denote that the trailing content is the "top" part of
* a card creating using [CardStyle].
* @param itemContent The content to draw for each [R] in [items] and the necessary
* [CardStyle] based on its position and other factors.
*
* @see [CoachMarkScope.CoachMarkHighlight]
*/
@Suppress("LongParameterList")
fun <R> LazyListScope.coachMarkHighlightItems(
key: T,
title: Text,
description: Text,
modifier: Modifier = Modifier,
shape: CoachMarkHighlightShape = CoachMarkHighlightShape.SQUARE,
items: List<R>,
onDismiss: (() -> Unit)? = null,
leftAction: (@Composable RowScope.() -> Unit)? = null,
rightAction: (@Composable RowScope.() -> Unit)? = null,
leadingStaticContent: (@Composable BoxScope.() -> Unit)? = null,
leadingContentIsTopCard: Boolean = false,
trailingStaticContent: (@Composable BoxScope.() -> Unit)? = null,
trailingContentIsBottomCard: Boolean = false,
itemContent: @Composable (R, CardStyle) -> Unit,
)
}

View file

@ -0,0 +1,362 @@
package com.x8bit.bitwarden.ui.platform.components.coachmark
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.RichTooltip
import androidx.compose.material3.Text
import androidx.compose.material3.TooltipBox
import androidx.compose.material3.TooltipDefaults
import androidx.compose.material3.TooltipScope
import androidx.compose.material3.TooltipState
import androidx.compose.material3.rememberTooltipState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.layout.boundsInRoot
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.SemanticsPropertyKey
import androidx.compose.ui.semantics.SemanticsPropertyReceiver
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.base.util.Text
import com.x8bit.bitwarden.ui.platform.base.util.toListItemCardStyle
import com.x8bit.bitwarden.ui.platform.components.button.BitwardenStandardIconButton
import com.x8bit.bitwarden.ui.platform.components.model.CardStyle
import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter
import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
import org.jetbrains.annotations.VisibleForTesting
/**
* Creates an instance of [CoachMarkScope] for a given [CoachMarkState].
*/
@OptIn(ExperimentalMaterial3Api::class)
class CoachMarkScopeInstance<T : Enum<T>>(
private val coachMarkState: CoachMarkState<T>,
) : CoachMarkScope<T> {
@Composable
override fun CoachMarkHighlight(
key: T,
title: String,
description: String,
modifier: Modifier,
shape: CoachMarkHighlightShape,
onDismiss: (() -> Unit)?,
leftAction: @Composable() (RowScope.() -> Unit)?,
rightAction: @Composable() (RowScope.() -> Unit)?,
anchorContent: @Composable () -> Unit,
) {
val toolTipState = rememberTooltipState(
initialIsVisible = false,
isPersistent = true,
)
CoachMarkHighlightInternal(
key = key,
title = title,
description = description,
shape = shape,
onDismiss = onDismiss,
leftAction = leftAction,
rightAction = rightAction,
toolTipState = toolTipState,
modifier = modifier.onGloballyPositioned {
coachMarkState.updateHighlight(
key = key,
bounds = it.boundsInRoot(),
toolTipState = toolTipState,
shape = shape,
)
},
anchorContent = anchorContent,
)
}
override fun LazyListScope.coachMarkHighlight(
key: T,
title: Text,
description: Text,
modifier: Modifier,
shape: CoachMarkHighlightShape,
onDismiss: (() -> Unit)?,
leftAction: @Composable() (RowScope.() -> Unit)?,
rightAction: @Composable() (RowScope.() -> Unit)?,
anchorContent: @Composable () -> Unit,
) {
item(key = key) {
this@CoachMarkScopeInstance.CoachMarkHighlight(
key = key,
title = title(),
description = description(),
modifier = modifier,
shape = shape,
onDismiss = onDismiss,
leftAction = leftAction,
rightAction = rightAction,
anchorContent = anchorContent,
)
}
}
override fun <R> LazyListScope.coachMarkHighlightItems(
key: T,
title: Text,
description: Text,
modifier: Modifier,
shape: CoachMarkHighlightShape,
items: List<R>,
onDismiss: (() -> Unit)?,
leftAction: @Composable (RowScope.() -> Unit)?,
rightAction: @Composable (RowScope.() -> Unit)?,
leadingStaticContent: @Composable (BoxScope.() -> Unit)?,
leadingContentIsTopCard: Boolean,
trailingStaticContent: @Composable (BoxScope.() -> Unit)?,
trailingContentIsBottomCard: Boolean,
itemContent: @Composable (item: R, cardStyle: CardStyle) -> Unit,
) {
val hasLeadingContent = (leadingStaticContent != null)
val topCardAlreadyExists = hasLeadingContent && leadingContentIsTopCard
val bottomCardAlreadyExists = (trailingStaticContent != null) && trailingContentIsBottomCard
item(key = key) {
this@CoachMarkScopeInstance.CoachMarkHighlightInternal(
key = key,
title = title(),
description = description(),
shape = shape,
onDismiss = onDismiss,
leftAction = leftAction,
rightAction = rightAction,
) {
Box(
modifier = modifier.calculateBoundsAndAddForKey(key = key, isFirstItem = true),
) {
leadingStaticContent?.let { it() } ?: run {
if (items.isNotEmpty()) {
itemContent(
items[0],
items.toCoachMarkListItemCardStyle(
index = 0,
topCardAlreadyExists = false,
bottomCardAlreadyExists = bottomCardAlreadyExists,
),
)
}
}
}
}
}
itemsIndexed(items) { index, item ->
// if there is no leading content we already added the first item.
if (!hasLeadingContent && index == 0) return@itemsIndexed
Box(
modifier = modifier.calculateBoundsAndAddForKey(key),
) {
val cardStyle = items.toCoachMarkListItemCardStyle(
index = index,
topCardAlreadyExists = topCardAlreadyExists,
bottomCardAlreadyExists = bottomCardAlreadyExists,
)
itemContent(item, cardStyle)
}
}
trailingStaticContent?.let {
item {
Box(
modifier = modifier.calculateBoundsAndAddForKey(key),
content = it,
)
}
}
}
@Composable
private fun CoachMarkHighlightInternal(
key: T,
title: String,
description: String,
shape: CoachMarkHighlightShape,
onDismiss: (() -> Unit)?,
leftAction: @Composable() (RowScope.() -> Unit)?,
rightAction: @Composable() (RowScope.() -> Unit)?,
modifier: Modifier = Modifier,
toolTipState: TooltipState = rememberTooltipState(
initialIsVisible = false,
isPersistent = true,
),
anchorContent: @Composable () -> Unit,
) {
TooltipBox(
positionProvider = TooltipDefaults.rememberRichTooltipPositionProvider(
spacingBetweenTooltipAndAnchor = 12.dp,
),
tooltip = {
CoachMarkToolTip(
title = title,
description = description,
onDismiss = {
coachMarkState.coachingComplete()
onDismiss?.invoke()
},
leftAction = leftAction,
rightAction = rightAction,
)
},
enableUserInput = false,
focusable = false,
state = toolTipState,
modifier = modifier,
content = anchorContent,
)
LaunchedEffect(Unit) {
coachMarkState.updateHighlight(
key = key,
bounds = null,
toolTipState = toolTipState,
shape = shape,
)
}
}
private fun Modifier.calculateBoundsAndAddForKey(
key: T,
isFirstItem: Boolean = false,
): Modifier = composed {
var bounds by remember {
mutableStateOf(Rect.Zero)
}
LaunchedEffect(bounds) {
if (bounds != Rect.Zero) {
coachMarkState.addToExistingBounds(
key = key,
isFirstItem = isFirstItem,
additionalBounds = bounds,
)
}
}
this.onGloballyPositioned {
bounds = it.boundsInRoot()
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun TooltipScope.CoachMarkToolTip(
title: String,
description: String,
onDismiss: (() -> Unit),
leftAction: (@Composable RowScope.() -> Unit)?,
rightAction: (@Composable RowScope.() -> Unit)?,
) {
RichTooltip(
modifier = Modifier
.padding(horizontal = 4.dp)
.semantics { isCoachMarkToolTip = true },
caretSize = DpSize(width = 24.dp, height = 16.dp),
title = {
Row(
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = title,
style = BitwardenTheme.typography.eyebrowMedium,
color = BitwardenTheme.colorScheme.text.secondary,
)
Spacer(modifier = Modifier.weight(1f))
BitwardenStandardIconButton(
painter = rememberVectorPainter(R.drawable.ic_close),
contentDescription = stringResource(R.string.close),
onClick = onDismiss,
modifier = Modifier.size(16.dp),
)
}
},
action = {
Row(
Modifier.fillMaxWidth(),
) {
leftAction?.invoke(this)
Spacer(modifier = Modifier.weight(1f))
rightAction?.invoke(this)
}
},
colors = TooltipDefaults.richTooltipColors(
containerColor = BitwardenTheme.colorScheme.background.secondary,
),
) {
Text(
text = description,
style = BitwardenTheme.typography.bodyMedium,
color = BitwardenTheme.colorScheme.text.primary,
)
}
}
/**
* Returns the appropriate [CardStyle] based on the current [index] in the list being used
* for a coachMarkHighlightItems list.
*/
private fun <T> Collection<T>.toCoachMarkListItemCardStyle(
index: Int,
topCardAlreadyExists: Boolean,
bottomCardAlreadyExists: Boolean,
hasDivider: Boolean = true,
dividerPadding: Dp = 16.dp,
): CardStyle = when {
topCardAlreadyExists && bottomCardAlreadyExists -> {
CardStyle.Middle(hasDivider = hasDivider, dividerPadding = dividerPadding)
}
topCardAlreadyExists && !bottomCardAlreadyExists -> {
if (this.size == 1) {
CardStyle.Bottom
} else if (index == this.size - 1) {
CardStyle.Bottom
} else {
CardStyle.Middle(hasDivider = hasDivider, dividerPadding = dividerPadding)
}
}
!topCardAlreadyExists && bottomCardAlreadyExists -> {
if (this.size == 1) {
CardStyle.Top(hasDivider = hasDivider, dividerPadding = dividerPadding)
} else if (index == 0) {
CardStyle.Top(hasDivider = hasDivider, dividerPadding = dividerPadding)
} else {
CardStyle.Middle(hasDivider = hasDivider, dividerPadding = dividerPadding)
}
}
else -> this.toListItemCardStyle(
index = index,
hasDivider = hasDivider,
dividerPadding = dividerPadding,
)
}
/**
* SemanticPropertyKey used for Unit tests where checking if any displayed CoachMarkToolTips
*/
@VisibleForTesting
val IsCoachMarkToolTipKey = SemanticsPropertyKey<Boolean>("IsCoachMarkToolTip")
private var SemanticsPropertyReceiver.isCoachMarkToolTip by IsCoachMarkToolTipKey

View file

@ -0,0 +1,465 @@
package com.x8bit.bitwarden.ui.platform.components.coachmark
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.lazy.LazyListLayoutInfo
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.TooltipState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.listSaver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.geometry.Rect
import timber.log.Timber
import java.util.concurrent.ConcurrentHashMap
import kotlin.math.max
import kotlin.math.min
/**
* Represents a highlight within a coach mark sequence.
*
* @param T The type of the enum key used to identify the highlight.
* @property key The unique key identifying this highlight.
* @property highlightBounds The rectangular bounds of the area to highlight.
* @property toolTipState The state of the tooltip associated with this highlight.
* @property shape The shape of the highlight (e.g., square, oval).
*/
@OptIn(ExperimentalMaterial3Api::class)
data class CoachMarkHighlightState<T : Enum<T>>(
val key: T,
val highlightBounds: Rect?,
val toolTipState: TooltipState,
val shape: CoachMarkHighlightShape,
)
/**
* Defines the available shapes for a coach mark highlight.
*/
enum class CoachMarkHighlightShape {
/**
* A square-shaped highlight.
*/
SQUARE,
/**
* An oval-shaped highlight.
*/
OVAL,
}
/**
* Manages the state of a coach mark sequence, guiding users through a series of highlights.
*
* This class handles the ordered list of highlights, the currently active highlight,
* and the overall visibility of the coach mark overlay.
*
* @param T The type of the enum used to represent the coach mark keys.
* @property orderedList The ordered list of coach mark keys that define the sequence.
* @param initialCoachMarkHighlight The initial coach mark to be highlighted, or null if
* none should be highlighted at start.
* @param isCoachMarkVisible is any coach mark currently visible.
*/
@OptIn(ExperimentalMaterial3Api::class)
open class CoachMarkState<T : Enum<T>>(
val orderedList: List<T>,
initialCoachMarkHighlight: T? = null,
isCoachMarkVisible: Boolean = false,
) {
private val highlights: MutableMap<T, CoachMarkHighlightState<T>?> = ConcurrentHashMap()
private val mutableCurrentHighlight = mutableStateOf(initialCoachMarkHighlight)
val currentHighlight: State<T?> = mutableCurrentHighlight
private val mutableCurrentHighlightBounds = mutableStateOf(Rect.Zero)
val currentHighlightBounds: State<Rect> = mutableCurrentHighlightBounds
private val mutableCurrentHighlightShape = mutableStateOf(CoachMarkHighlightShape.SQUARE)
val currentHighlightShape: State<CoachMarkHighlightShape> = mutableCurrentHighlightShape
private val mutableIsVisible = mutableStateOf(isCoachMarkVisible)
val isVisible: State<Boolean> = mutableIsVisible
/**
* Updates the highlight information for a given key. If the key matches the current shown
* [key] then also update the public state for the highlight bounds and shape.
*
* @param key The key of the highlight to update.
* @param bounds The rectangular bounds of the area to highlight. If null, defaults to
* Rect.Zero.
* @param toolTipState The state of the tooltip associated with this highlight.
* @param shape The shape of the highlight (e.g., square, oval). Defaults to
* [CoachMarkHighlightShape.SQUARE].
*/
fun updateHighlight(
key: T,
bounds: Rect?,
toolTipState: TooltipState,
shape: CoachMarkHighlightShape = CoachMarkHighlightShape.SQUARE,
) {
highlights[key] = CoachMarkHighlightState(
key = key,
highlightBounds = bounds,
toolTipState = toolTipState,
shape = shape,
).also {
if (key == currentHighlight.value) {
updateCoachMarkStateInternal(it)
}
}
}
/**
* For the provided [key] add a new rectangle to any existing bounds unless it is
* the first item then it is used as the "starting" rectangle.
*
* @param key the [CoachMarkHighlightState] to modify.
* @param isFirstItem if this new calculation is coming from the "first" or base item.
* @param additionalBounds the rectangle to add to the existing bounds.
*/
fun addToExistingBounds(key: T, isFirstItem: Boolean, additionalBounds: Rect) {
val highlight = highlights[key]
highlight?.let {
val newRect = it.highlightBounds?.union(additionalBounds)
.takeIf { !isFirstItem } ?: additionalBounds
highlights[key] = it.copy(highlightBounds = newRect)
if (key == currentHighlight.value) {
updateCoachMarkStateInternal(getCurrentHighlight())
}
}
}
/**
* Show the the tooltip for the currently shown tooltip.
*/
suspend fun showToolTipForCurrentCoachMark() {
val currentCoachMark = getCurrentHighlight()
currentCoachMark?.toolTipState?.show()
}
/**
* Indicates that the coach mark associated with the provided key should be shown and
* starts that process of updating the state.
*
* @param coachMarkToShow The key of the coach mark to show.
*/
open suspend fun showCoachMark(coachMarkToShow: T) {
// Clean up the previous tooltip if one is showing.
if (currentHighlight.value != coachMarkToShow && isVisible.value) {
getCurrentHighlight()?.toolTipState?.cleanUp()
}
mutableCurrentHighlight.value = coachMarkToShow
val highlightToShow = getCurrentHighlight()
highlightToShow?.let {
updateCoachMarkStateInternal(it)
}
}
/**
* Shows the next highlight in the sequence.
* If there is no previous highlight, it will show the first highlight.
* If the previous highlight is the last in the list, nothing will happen.
*/
suspend fun showNextCoachMark() {
val previousHighlight = getCurrentHighlight()
previousHighlight?.toolTipState?.cleanUp()
val index = orderedList.indexOf(previousHighlight?.key)
if (index < 0 && previousHighlight != null) return
mutableCurrentHighlight.value = orderedList.getOrNull(index + 1)
mutableCurrentHighlight.value?.let {
showCoachMark(it)
}
}
/**
* Shows the previous coach mark in the sequence.
* If the current highlighted coach mark is the first in the list, the coach mark will
* be hidden.
*/
suspend fun showPreviousCoachMark() {
val currentHighlight = getCurrentHighlight()
currentHighlight?.toolTipState?.cleanUp() ?: return
val index = orderedList.indexOf(currentHighlight.key)
if (index == 0) {
mutableCurrentHighlight.value = null
mutableIsVisible.value = false
return
}
mutableCurrentHighlight.value = orderedList.getOrNull(index - 1)
mutableCurrentHighlight.value?.let {
showCoachMark(it)
}
}
/**
* Completes the coaching sequence, clearing all highlights and resetting the state.
*
* @param onComplete An optional callback to invoke once all the other clean up logic has
* taken place.
*/
fun coachingComplete(onComplete: (() -> Unit)? = null) {
getCurrentHighlight()?.toolTipState?.cleanUp()
mutableCurrentHighlight.value = null
mutableCurrentHighlightBounds.value = Rect.Zero
mutableCurrentHighlightShape.value = CoachMarkHighlightShape.SQUARE
mutableIsVisible.value = false
onComplete?.invoke()
}
/**
* Gets the current highlight information.
*
* @return The current [CoachMarkHighlightState] or null if no highlight is active.
*/
private fun getCurrentHighlight(): CoachMarkHighlightState<T>? {
return currentHighlight.value?.let { highlights[it] }
}
private fun updateCoachMarkStateInternal(highlight: CoachMarkHighlightState<T>?) {
mutableIsVisible.value = highlight != null
mutableCurrentHighlightShape.value = highlight?.shape ?: CoachMarkHighlightShape.SQUARE
if (currentHighlightBounds.value != highlight?.highlightBounds) {
mutableCurrentHighlightBounds.value = highlight?.highlightBounds ?: Rect.Zero
}
}
/**
* Cleans up the tooltip state by dismissing it if visible and calling onDispose.
*/
private fun TooltipState.cleanUp() {
if (isVisible) {
dismiss()
}
onDispose()
}
@Suppress("UndocumentedPublicClass")
companion object {
/**
* Creates a [Saver] for [CoachMarkState] to enable saving and restoring its state.
*
* @return A [Saver] that can save and restore [CoachMarkState].
*/
inline fun <reified T : Enum<T>> saver(): Saver<CoachMarkState<T>, Any> =
listSaver(
save = { coachMarkState ->
listOf(
coachMarkState.orderedList.map { it.name },
coachMarkState.currentHighlight.value?.name,
coachMarkState.isVisible.value,
)
},
restore = { restoredList ->
val enumList = restoredList[0] as List<*>
val currentHighlightName = restoredList[1] as String?
val enumValues = enumValues<T>()
val list = enumList.mapNotNull { name ->
enumValues.find { it.name == name }
}
val currentHighlight = currentHighlightName?.let { name ->
enumValues.find { it.name == name }
}
val isVisible = restoredList[2] as Boolean
CoachMarkState(
orderedList = list,
initialCoachMarkHighlight = currentHighlight,
isCoachMarkVisible = isVisible,
)
},
)
}
}
/**
* A [CoachMarkState] that depends on a [LazyListState] to automatically scroll to the current
* Coach Mark if not on currently on the screen.
*/
class LazyListCoachMarkState<T : Enum<T>>(
private val lazyListState: LazyListState,
orderedList: List<T>,
initialCoachMarkHighlight: T? = null,
isCoachMarkVisible: Boolean = false,
) : CoachMarkState<T>(orderedList, initialCoachMarkHighlight, isCoachMarkVisible) {
override suspend fun showCoachMark(coachMarkToShow: T) {
super.showCoachMark(coachMarkToShow)
lazyListState.searchForKey(coachMarkToShow)
}
private suspend fun LazyListState.searchForKey(keyToFind: T): Boolean =
layoutInfo.visibleItemsInfo.any { it.key == keyToFind }
.takeIf { itemAlreadyVisible ->
if (itemAlreadyVisible) {
val offset =
layoutInfo.visibleItemsInfo.find { visItem ->
visItem.key == keyToFind
}
?.offset
when {
offset == null -> Unit
((layoutInfo.viewportEndOffset - offset) <
END_VIEW_PORT_PIXEL_THRESHOLD) -> {
scrollBy(layoutInfo.quarterViewPortScrollAmount())
}
((offset - layoutInfo.viewportStartOffset) <
START_VIEW_PORT_PIXEL_THRESHOLD) -> {
scrollBy(-(layoutInfo.quarterViewPortScrollAmount()))
}
else -> Unit
}
}
itemAlreadyVisible
}
?: scrollUpToKey(keyToFind).takeIf { it }
?: scrollDownToKey(keyToFind)
private suspend fun LazyListState.scrollUpToKey(
targetKey: T,
): Boolean {
val scrollAmount = (-1).toFloat()
var found = false
var keepSearching = true
while (keepSearching && !found) {
val layoutInfo = this.layoutInfo
val visibleItems = layoutInfo.visibleItemsInfo
if (visibleItems.any { it.key == targetKey }) {
scrollBy(-(layoutInfo.halfViewPortScrollAmount()))
found = true
} else {
if (!canScrollBackward) {
keepSearching = false
} else {
this.scrollBy(scrollAmount)
}
}
}
Timber.i("$targetKey has been found: $found by scrolling up.")
return found
}
private suspend fun LazyListState.scrollDownToKey(
targetKey: T,
): Boolean {
val scrollAmount = 1.toFloat()
var found = false
var keepSearching = true
while (keepSearching && !found) {
val layoutInfo = this.layoutInfo
val visibleItems = layoutInfo.visibleItemsInfo
if (visibleItems.any { it.key == targetKey }) {
scrollBy(layoutInfo.halfViewPortScrollAmount())
found = true
} else {
if (!this.canScrollForward) {
// Reached the end of the list without finding the key
keepSearching = false
} else {
this.scrollBy(scrollAmount)
}
}
}
Timber.i("$targetKey has been found: $found by scrolling down.")
return found
}
@Suppress("MagicNumber")
private fun LazyListLayoutInfo.halfViewPortScrollAmount(): Float = when (this.orientation) {
Orientation.Vertical -> (viewportSize.height / 2f)
Orientation.Horizontal -> (viewportSize.width / 2f)
}
@Suppress("MagicNumber")
private fun LazyListLayoutInfo.quarterViewPortScrollAmount(): Float = when (this.orientation) {
Orientation.Vertical -> (viewportSize.height / 4f)
Orientation.Horizontal -> (viewportSize.width / 4f)
}
@Suppress("UndocumentedPublicClass")
companion object {
/**
* Creates a [Saver] for [CoachMarkState] to enable saving and restoring its state.
*
* @return A [Saver] that can save and restore [CoachMarkState].
*/
inline fun <reified T : Enum<T>> saver(
lazyListState: LazyListState,
): Saver<CoachMarkState<T>, Any> =
listSaver(
save = { coachMarkState ->
listOf(
coachMarkState.orderedList.map { it.name },
coachMarkState.currentHighlight.value?.name,
coachMarkState.isVisible.value,
)
},
restore = { restoredList ->
val enumList = restoredList[0] as List<*>
val currentHighlightName = restoredList[1] as String?
val enumValues = enumValues<T>()
val list = enumList.mapNotNull { name ->
enumValues.find { it.name == name }
}
val currentHighlight = currentHighlightName?.let { name ->
enumValues.find { it.name == name }
}
val isVisible = restoredList[2] as Boolean
LazyListCoachMarkState(
lazyListState = lazyListState,
orderedList = list,
initialCoachMarkHighlight = currentHighlight,
isCoachMarkVisible = isVisible,
)
},
)
}
}
/**
* Remembers and saves the state of a [CoachMarkState].
*
* @param T The type of the enum used to represent the coach mark keys.
* @param orderedList The ordered list of coach mark keys.
* @return A [CoachMarkState] instance.
*/
@Composable
inline fun <reified T : Enum<T>> rememberCoachMarkState(orderedList: List<T>): CoachMarkState<T> {
return rememberSaveable(saver = CoachMarkState.saver<T>()) {
CoachMarkState(orderedList)
}
}
/**
* Remembers and saves the state of a [LazyListCoachMarkState].
*
* @param T The type of the enum used to represent the coach mark keys.
* @param orderedList The ordered list of coach mark keys.
* @param lazyListState The lazy list state to be used by the created instance.
* @return A [LazyListCoachMarkState] instance.
*/
@Composable
inline fun <reified T : Enum<T>> rememberLazyListCoachMarkState(
orderedList: List<T>,
lazyListState: LazyListState,
): CoachMarkState<T> {
return rememberSaveable(saver = LazyListCoachMarkState.saver<T>(lazyListState)) {
LazyListCoachMarkState(lazyListState = lazyListState, orderedList = orderedList)
}
}
/**
* Combine two [Rect] to create the largest result rectangle between them.
* This will include any space between the [Rect] as well.
*/
private fun Rect.union(other: Rect): Rect {
return Rect(
left = min(left, other.left),
top = min(top, other.top),
right = max(right, other.right),
bottom = max(bottom, other.bottom),
)
}
private const val END_VIEW_PORT_PIXEL_THRESHOLD = 150
private const val START_VIEW_PORT_PIXEL_THRESHOLD = 40