mirror of
https://github.com/bitwarden/android.git
synced 2025-01-30 19:53:47 +03:00
PM-16631 Adding coach mark container and state to allow for guided screen tours
This commit is contained in:
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