mirror of
https://github.com/bitwarden/android.git
synced 2025-02-22 16:49:13 +03:00
PM-16631 Adding coach mark container and state to allow for guided screen tours
This commit is contained in:
parent
412649ed9e
commit
4b5f16d407
4 changed files with 1198 additions and 0 deletions
app/src/main/java/com/x8bit/bitwarden/ui/platform/components/coachmark
|
@ -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,
|
||||||
|
}
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
|
@ -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
|
|
@ -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
|
Loading…
Add table
Reference in a new issue