PM-25028: Migrate coachmarks and tooltips to UI module (#5757)

This commit is contained in:
David Perez
2025-08-20 11:04:19 -05:00
committed by GitHub
parent d8e319948c
commit e5a1546291
19 changed files with 49 additions and 51 deletions

View File

@@ -0,0 +1,24 @@
package com.bitwarden.ui.platform.components.coachmark
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bitwarden.ui.platform.components.text.BitwardenClickableText
import com.bitwarden.ui.platform.theme.BitwardenTheme
/**
* Clickable text used for the standard action UI for a Coach Mark which applies
* correct text style by default.
*/
@Composable
fun CoachMarkActionText(
actionLabel: String,
onActionClick: () -> Unit,
modifier: Modifier = Modifier,
) {
BitwardenClickableText(
label = actionLabel,
onClick = onActionClick,
style = BitwardenTheme.typography.labelLarge,
modifier = modifier,
)
}

View File

@@ -0,0 +1,260 @@
package com.bitwarden.ui.platform.components.coachmark
import androidx.activity.compose.BackHandler
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.bitwarden.ui.platform.components.button.BitwardenStandardIconButton
import com.bitwarden.ui.platform.components.coachmark.model.CoachMarkHighlightShape
import com.bitwarden.ui.platform.components.coachmark.model.CoachMarkState
import com.bitwarden.ui.platform.components.coachmark.model.rememberCoachMarkState
import com.bitwarden.ui.platform.components.coachmark.scope.CoachMarkScope
import com.bitwarden.ui.platform.components.coachmark.scope.CoachMarkScopeInstance
import com.bitwarden.ui.platform.components.text.BitwardenClickableText
import com.bitwarden.ui.platform.components.util.rememberVectorPainter
import com.bitwarden.ui.platform.resource.BitwardenDrawable
import com.bitwarden.ui.platform.resource.BitwardenString
import com.bitwarden.ui.platform.theme.BitwardenTheme
import kotlinx.coroutines.launch
/**
* 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,
) {
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 (val shape = currentHighlightShape) {
is CoachMarkHighlightShape.RoundedRectangle -> addRoundRect(
RoundRect(
rect = highlightArea,
cornerRadius = CornerRadius(
x = shape.radius,
),
),
)
CoachMarkHighlightShape.Oval -> addOval(highlightArea)
}
}
}
if (boundedRectangle != Rect.Zero && isVisible) {
val backgroundColor = BitwardenTheme.colorScheme.background.scrim
Box(
modifier = Modifier
.pointerInput(Unit) {
detectTapGestures(
onTap = {
// NO-OP, this consumes any touch events
// while the scrim is showing.
},
)
}
.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)
}
}
}
// Consume system back event when the scrim is visible.
BackHandler(
enabled = state.isVisible.value,
onBack = {
// No-op
},
)
}
}
@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.",
onDismiss = null,
leftAction = null,
rightAction = {
BitwardenClickableText(
label = "Next",
onClick = {
scope.launch {
state.showNextCoachMark()
}
},
style = BitwardenTheme.typography.labelLarge,
)
},
shape = CoachMarkHighlightShape.Oval,
) {
BitwardenStandardIconButton(
painter = rememberVectorPainter(BitwardenDrawable.ic_puzzle),
contentDescription = stringResource(BitwardenString.close),
onClick = {},
)
}
}
Spacer(Modifier.height(24.dp))
CoachMarkHighlight(
key = Foo.Baz,
title = "Foo",
description = "Baz",
shape = CoachMarkHighlightShape.RoundedRectangle(radius = 50f),
onDismiss = null,
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,23 @@
package com.bitwarden.ui.platform.components.coachmark.model
private const val ROUNDED_RECT_DEFAULT_RADIUS = 8f
/**
* Defines the available shapes for a coach mark highlight.
*/
sealed class CoachMarkHighlightShape {
/**
* A rounded rectangle shape which has a radius to round the corners by.
*
* @property radius the radius to use to round the corners of the rectangle shape.
* Defaults to [ROUNDED_RECT_DEFAULT_RADIUS]
*/
data class RoundedRectangle(
val radius: Float = ROUNDED_RECT_DEFAULT_RADIUS,
) : CoachMarkHighlightShape()
/**
* An oval-shaped highlight.
*/
data object Oval : CoachMarkHighlightShape()
}

View File

@@ -0,0 +1,20 @@
package com.bitwarden.ui.platform.components.coachmark.model
import androidx.compose.ui.geometry.Rect
import com.bitwarden.ui.platform.components.tooltip.model.BitwardenToolTipState
/**
* 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).
*/
data class CoachMarkHighlightState<T : Enum<T>>(
val key: T,
val highlightBounds: Rect?,
val toolTipState: BitwardenToolTipState,
val shape: CoachMarkHighlightShape,
)

View File

@@ -0,0 +1,266 @@
package com.bitwarden.ui.platform.components.coachmark.model
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
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 com.bitwarden.core.data.util.concurrentMapOf
import com.bitwarden.ui.platform.components.tooltip.model.BitwardenToolTipState
import kotlin.math.max
import kotlin.math.min
/**
* 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.
*/
@Stable
open class CoachMarkState<T : Enum<T>>(
val orderedList: List<T>,
initialCoachMarkHighlight: T? = null,
isCoachMarkVisible: Boolean = false,
) {
private val highlights: MutableMap<T, CoachMarkHighlightState<T>?> = concurrentMapOf()
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>(
CoachMarkHighlightShape.RoundedRectangle(),
)
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.RoundedRectangle].
*/
fun updateHighlight(
key: T,
bounds: Rect?,
toolTipState: BitwardenToolTipState,
shape: CoachMarkHighlightShape = CoachMarkHighlightShape.RoundedRectangle(),
) {
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) {
highlights[key]?.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()
updateCoachMarkStateInternal(highlightToShow)
}
/**
* 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)
// We return early here if the the previous highlight does exist but is somehow not
// present in the list. If the previous highlight is null we resolve that the next
// coach mark to show is the first item in the orderedList.
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() ?: return
currentHighlight.toolTipState.cleanUp()
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.RoundedRectangle()
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.RoundedRectangle()
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 BitwardenToolTipState.cleanUp() {
if (isVisible) {
dismissBitwardenToolTip()
}
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,
)
},
)
}
}
/**
* 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)
}
}
/**
* 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),
)
}

View File

@@ -0,0 +1,181 @@
package com.bitwarden.ui.platform.components.coachmark.model
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.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.listSaver
import androidx.compose.runtime.saveable.rememberSaveable
/**
* A [CoachMarkState] that depends on a [LazyListState] to automatically scroll to the current
* Coach Mark if not on currently on the screen.
*/
@Stable
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) {
lazyListState.searchForKey(coachMarkToShow)
super.showCoachMark(coachMarkToShow)
}
private suspend fun LazyListState.searchForKey(keyToFind: T) {
val keyFound = 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)
if (!keyFound) {
// if key not found scroll back to the top.
scrollToItem(index = 0)
}
}
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)
}
}
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)
}
}
return found
}
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 [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)
}
}
private const val END_VIEW_PORT_PIXEL_THRESHOLD = 150
private const val START_VIEW_PORT_PIXEL_THRESHOLD = 40

View File

@@ -0,0 +1,124 @@
package com.bitwarden.ui.platform.components.coachmark.scope
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bitwarden.ui.platform.components.coachmark.model.CoachMarkHighlightShape
import com.bitwarden.ui.platform.components.model.CardStyle
import com.bitwarden.ui.util.Text
/**
* 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.
* @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,
onDismiss: (() -> Unit)?,
leftAction: (@Composable RowScope.() -> Unit)?,
rightAction: (@Composable RowScope.() -> Unit)?,
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.coachMarkHighlightItem(
key: T,
title: Text,
description: Text,
modifier: Modifier = Modifier,
shape: CoachMarkHighlightShape = CoachMarkHighlightShape.RoundedRectangle(),
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.RoundedRectangle(),
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,307 @@
package com.bitwarden.ui.platform.components.coachmark.scope
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.TooltipBox
import androidx.compose.material3.TooltipDefaults
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.geometry.Rect
import androidx.compose.ui.layout.boundsInRoot
import androidx.compose.ui.layout.onGloballyPositioned
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.dp
import com.bitwarden.ui.platform.base.util.toListItemCardStyle
import com.bitwarden.ui.platform.components.coachmark.model.CoachMarkHighlightShape
import com.bitwarden.ui.platform.components.coachmark.model.CoachMarkState
import com.bitwarden.ui.platform.components.model.CardStyle
import com.bitwarden.ui.platform.components.tooltip.BitwardenToolTip
import com.bitwarden.ui.platform.components.tooltip.model.BitwardenToolTipState
import com.bitwarden.ui.platform.components.tooltip.model.rememberBitwardenToolTipState
import com.bitwarden.ui.util.Text
import kotlinx.collections.immutable.toImmutableList
import org.jetbrains.annotations.VisibleForTesting
/**
* Creates an instance of [CoachMarkScope] for a given [CoachMarkState].
*/
@OptIn(ExperimentalMaterial3Api::class)
internal 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 = rememberBitwardenToolTipState(
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.coachMarkHighlightItem(
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)
var topCardAlreadyExists = hasLeadingContent && leadingContentIsTopCard
val bottomCardAlreadyExists = (trailingStaticContent != null) && trailingContentIsBottomCard
val itemsAdjusted = items
.drop(if (hasLeadingContent) 0 else 1)
.toImmutableList()
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?.invoke(this) ?: run {
if (items.isNotEmpty()) {
itemContent(
items.first(),
items.toCoachMarkListItemCardStyle(
index = 0,
topCardAlreadyExists = false,
bottomCardAlreadyExists = bottomCardAlreadyExists,
),
)
topCardAlreadyExists = true
}
}
}
}
}
itemsIndexed(
itemsAdjusted,
) { index, item ->
Box(
modifier = modifier.calculateBoundsAndAddForKey(key),
) {
val cardStyle = itemsAdjusted.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: BitwardenToolTipState = rememberBitwardenToolTipState(
initialIsVisible = false,
isPersistent = true,
),
anchorContent: @Composable () -> Unit,
) {
TooltipBox(
positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(
spacingBetweenTooltipAndAnchor = 10.dp,
),
tooltip = {
BitwardenToolTip(
title = title,
description = description,
onDismiss = {
coachMarkState.coachingComplete()
onDismiss?.invoke()
},
leftAction = leftAction,
rightAction = rightAction,
modifier = Modifier
.padding(horizontal = 6.dp)
.semantics { isCoachMarkToolTip = true },
)
},
enableUserInput = false,
focusable = false,
state = toolTipState,
modifier = modifier,
content = anchorContent,
)
LaunchedEffect(Unit) {
coachMarkState.updateHighlight(
key = key,
bounds = null,
toolTipState = toolTipState,
shape = shape,
)
}
}
@Composable
private fun Modifier.calculateBoundsAndAddForKey(
key: T,
isFirstItem: Boolean = false,
): Modifier {
var bounds: Rect? by remember {
mutableStateOf(null)
}
LaunchedEffect(bounds) {
bounds?.let {
coachMarkState.addToExistingBounds(
key = key,
isFirstItem = isFirstItem,
additionalBounds = it,
)
}
}
return this.onGloballyPositioned {
bounds = it.boundsInRoot()
}
}
}
/**
* 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,92 @@
package com.bitwarden.ui.platform.components.tooltip
import androidx.compose.foundation.layout.Column
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.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredSizeIn
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.PlainTooltip
import androidx.compose.material3.Text
import androidx.compose.material3.TooltipScope
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import com.bitwarden.ui.platform.components.button.BitwardenStandardIconButton
import com.bitwarden.ui.platform.components.util.rememberVectorPainter
import com.bitwarden.ui.platform.resource.BitwardenDrawable
import com.bitwarden.ui.platform.resource.BitwardenString
import com.bitwarden.ui.platform.theme.BitwardenTheme
private val MIN_TOOLTIP_WIDTH = 312.dp
/**
* Bitwarden themed rich tool-tip to show within a [TooltipScope].
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TooltipScope.BitwardenToolTip(
title: String,
description: String,
modifier: Modifier = Modifier,
onDismiss: (() -> Unit)? = null,
leftAction: (@Composable RowScope.() -> Unit)? = null,
rightAction: (@Composable RowScope.() -> Unit)? = null,
) {
PlainTooltip(
modifier = modifier.requiredSizeIn(minWidth = MIN_TOOLTIP_WIDTH),
caretSize = DpSize(width = 24.dp, height = 12.dp),
shape = BitwardenTheme.shapes.coachmark,
contentColor = BitwardenTheme.colorScheme.text.primary,
containerColor = BitwardenTheme.colorScheme.background.secondary,
) {
// PlainTooltip already applies 8.dp of horizontal padding and 4.dp of vertical to the
// content.
Column(
modifier = Modifier.padding(
bottom = 4.dp,
start = 8.dp,
end = 8.dp,
),
) {
Row(
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = title,
style = BitwardenTheme.typography.eyebrowMedium,
color = BitwardenTheme.colorScheme.text.secondary,
modifier = Modifier.align(Alignment.CenterVertically),
)
Spacer(modifier = Modifier.weight(1f))
onDismiss?.let {
BitwardenStandardIconButton(
painter = rememberVectorPainter(BitwardenDrawable.ic_close_small),
contentDescription = stringResource(BitwardenString.close),
onClick = it,
modifier = Modifier.offset(x = 16.dp),
)
}
}
Text(
text = description,
style = BitwardenTheme.typography.bodyMedium,
)
Spacer(modifier = Modifier.height(4.dp))
Row(
modifier = Modifier.fillMaxWidth(),
) {
leftAction?.invoke(this)
Spacer(modifier = Modifier.weight(1f))
rightAction?.invoke(this)
}
}
}
}

View File

@@ -0,0 +1,17 @@
package com.bitwarden.ui.platform.components.tooltip.model
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.TooltipState
/**
* A custom [TooltipState] to be used for the tool tips which should not be
* dismissed automatically by clicking outside of the pop-up area.
*/
@OptIn(ExperimentalMaterial3Api::class)
interface BitwardenToolTipState : TooltipState {
/**
* Call to dismiss the tool tip from the screen, should be used in
* place of [TooltipState.dismiss]
*/
fun dismissBitwardenToolTip()
}

View File

@@ -0,0 +1,115 @@
package com.bitwarden.ui.platform.components.tooltip.model
import androidx.compose.animation.core.MutableTransitionState
import androidx.compose.foundation.MutatePriority
import androidx.compose.foundation.MutatorMutex
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.TooltipState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import kotlinx.coroutines.CancellableContinuation
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withTimeout
/**
* Default implementation of [BitwardenToolTipState]
*
* This is making use of the implementation of [TooltipState] provided via
* [androidx.compose.material3.rememberTooltipState] but overriding [dismiss] to be no-op.
*/
internal class BitwardenToolTipStateImpl(
initialIsVisible: Boolean,
override val isPersistent: Boolean,
private val mutatorMutex: MutatorMutex,
) : BitwardenToolTipState {
override fun dismissBitwardenToolTip() {
transition.targetState = false
}
override val transition: MutableTransitionState<Boolean> =
MutableTransitionState(initialIsVisible)
override val isVisible: Boolean
get() = transition.currentState || transition.targetState
/** continuation used to clean up */
private var job: (CancellableContinuation<Unit>)? = null
/**
* Show the tooltip associated with the current [TooltipState]. When this method is called, all
* of the other tooltips associated with [mutatorMutex] will be dismissed.
*
* @param mutatePriority [MutatePriority] to be used with [mutatorMutex].
*/
override suspend fun show(mutatePriority: MutatePriority) {
val cancellableShow: suspend () -> Unit = {
suspendCancellableCoroutine { continuation ->
transition.targetState = true
job = continuation
}
}
// Show associated tooltip for [TooltipDuration] amount of time
// or until tooltip is explicitly dismissed depending on [isPersistent].
mutatorMutex.mutate(mutatePriority) {
try {
if (isPersistent) {
cancellableShow()
} else {
withTimeout(BITWARDEN_TOOL_TIP_TIMEOUT) { cancellableShow() }
}
} finally {
if (mutatePriority != MutatePriority.PreventUserInput) {
// timeout or cancellation has occurred and we close out the current tooltip.
dismissBitwardenToolTip()
}
}
}
}
/**
* We are overriding this specifically to make it so it is a no-op this prevents the
* tooltip from being dismissed if the user taps anywhere out of it.
*/
override fun dismiss() {
/**No-Op**/
}
/** Cleans up [mutatorMutex] when the tooltip associated with this state leaves Composition. */
override fun onDispose() {
job?.cancel()
}
}
/**
* Provides a [BitwardenToolTipState] in a composable scope remembered across compositions.
*
* @param mutatorMutex if providing your own, ensure that any tool tips that should be
* shown/hidden in the context of the one you are using this state for, make use of the same
* instance of the passed in value.
*/
@Composable
@ExperimentalMaterial3Api
fun rememberBitwardenToolTipState(
initialIsVisible: Boolean = false,
isPersistent: Boolean = false,
mutatorMutex: MutatorMutex = BitwardenToolTipStateDefaults.GlobalMutatorMutex,
): BitwardenToolTipState =
remember(isPersistent, mutatorMutex) {
BitwardenToolTipStateImpl(
initialIsVisible = initialIsVisible,
isPersistent = isPersistent,
mutatorMutex = mutatorMutex,
)
}
/**
* Provides a global [MutatorMutex] as a singleton to be used by default for each
* created [BitwardenToolTipState]
*/
private object BitwardenToolTipStateDefaults {
val GlobalMutatorMutex = MutatorMutex()
}
private const val BITWARDEN_TOOL_TIP_TIMEOUT = 1500L

View File

@@ -25,6 +25,7 @@ import androidx.compose.ui.test.performScrollToNode
import androidx.compose.ui.test.printToString
import androidx.compose.ui.text.LinkAnnotation
import com.bitwarden.ui.platform.components.bottomsheet.IsBottomSheetKey
import com.bitwarden.ui.platform.components.coachmark.scope.IsCoachMarkToolTipKey
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.jupiter.api.assertThrows
@@ -37,6 +38,14 @@ val isBottomSheet: SemanticsMatcher
it.config.getOrNull(IsBottomSheetKey) == true
}
/**
* A [SemanticsMatcher] user to find Popup nodes used specifically for CoachMarkToolTips
*/
val isCoachMarkToolTip: SemanticsMatcher
get() = SemanticsMatcher("Node is used to show tool tip for active coach mark.") {
it.config.getOrNull(IsCoachMarkToolTipKey) == true
}
/**
* A [SemanticsMatcher] used to find editable text nodes.
*/