Gianfranco
10/30/2024, 11:35 PMTutorialTarget
, how to properly calculate the positioning and dimensions of the content
with the following rules:
• If there is more space above, then position the content above the composable marked with markForTutorial
• If there is more space below, then position the content below the composable marked with markForTutorial
• It should support content
with Lazy/Scrollable containers and Modifiers like fillMaxSize
/ FillMaxHeight
Code in thread 🧵Gianfranco
10/30/2024, 11:37 PMStylianos Gakis
10/30/2024, 11:39 PMGianfranco
10/30/2024, 11:42 PM@Stable
class TutorialBoxState internal constructor(
internal val initialIndex: Int,
) {
internal var tutorialTargets = mutableStateMapOf<Int, TutorialBoxTarget>()
var currentTargetIndex by mutableStateOf(initialIndex)
internal set
val currentTarget: TutorialBoxTarget?
get() = tutorialTargets[currentTargetIndex]
}
@Immutable
data class TutorialBoxTarget(
val index: Int,
val coordinates: LayoutCoordinates,
val content: (@Composable BoxScope.() -> Unit)? = null
)
@Composable
fun rememberTutorialBoxState(
initialIndex: Int = 0,
): TutorialBoxState {
return remember {
TutorialBoxState(
initialIndex = initialIndex,
)
}
}
class TutorialBoxScope(
private val state: TutorialBoxState,
) {
/**
* markForTutorial will adds a tag in the content to TutorialBox draws the [content]
* using the order of the [index].
*
* But if [content] is not defined or is null, the TutorialBox will use the
* [TutoriaBox(tutorialTarget: @Composable (index: Int) -> Unit]
*/
fun Modifier.markForTutorial(
index: Int,
content: (@Composable BoxScope.() -> Unit)? = null,
): Modifier = tutorialTarget(
state = state,
index = index,
content = content,
)
@Composable
internal fun TutorialCompose(
state: TutorialBoxState,
constraints: Constraints,
onTutorialCompleted: () -> Unit,
onTutorialIndexChanged: (Int) -> Unit,
customTutorialTarget: @Composable (index: Int) -> Unit
) {
TutorialFocusBox(currentContent = state.currentTarget)
TutorialTarget(
currentContent = state.currentTarget?.copy(
content = { customTutorialTarget(state.currentTargetIndex) }
),
constraints = constraints
)
TutorialClickHandler {
state.currentTargetIndex++
onTutorialIndexChanged(state.currentTargetIndex)
if (state.currentTargetIndex >= state.tutorialTargets.size) {
onTutorialCompleted()
}
}
}
@Composable
internal fun TutorialCompose(
state: TutorialBoxState,
constraints: Constraints,
onTutorialCompleted: () -> Unit,
onTutorialIndexChanged: (Int) -> Unit,
) {
TutorialFocusBox(currentContent = state.currentTarget)
TutorialTarget(
currentContent = state.currentTarget,
constraints = constraints
)
TutorialClickHandler {
state.currentTargetIndex++
onTutorialIndexChanged(state.currentTargetIndex)
if (state.currentTargetIndex >= state.tutorialTargets.size) {
onTutorialCompleted()
}
}
}
@OptIn(ExperimentalAnimationApi::class)
@Composable
private fun TutorialFocusBox(currentContent: TutorialBoxTarget?) {
AnimatedContent(
modifier = Modifier.fillMaxSize(),
targetState = currentContent,
transitionSpec = {
fadeIn(tween(500)) with fadeOut(tween(500))
}) { state ->
state?.let { content ->
Canvas(modifier = Modifier.fillMaxSize(), onDraw = {
val offSetInRoot = content.coordinates.positionInRoot()
val contentSize = content.coordinates.size
val cornerRadius = 12f
val focusPadding = 8f
val pathToClip = Path().apply {
addRoundRect(
RoundRect(
left = offSetInRoot.x - focusPadding,
top = offSetInRoot.y - focusPadding,
right = offSetInRoot.x + contentSize.width.toFloat() + focusPadding,
bottom = offSetInRoot.y + contentSize.height.toFloat() + focusPadding,
radiusX = cornerRadius,
radiusY = cornerRadius
)
)
}
clipPath(pathToClip, clipOp = ClipOp.Difference) {
drawRect(SolidColor(value = Color.Black.copy(alpha = 0.75f)))
}
})
}
}
}
@Composable
private fun TutorialTarget(currentContent: TutorialBoxTarget?, constraints: Constraints) {
currentContent?.let { tutorialContent ->
val composeWidth = remember(tutorialContent) {
tutorialContent.coordinates.size.width
}
val composeHeight = remember(tutorialContent) {
tutorialContent.coordinates.size.height
}
val x = remember(tutorialContent) {
(tutorialContent.coordinates.positionInRoot().x.toInt())
}
val y = remember(tutorialContent) {
(tutorialContent.coordinates.positionInRoot().y.toInt())
}
var tutorialSize by remember {
mutableStateOf(IntSize.Zero)
}
val xWithDisplacement by remember(x, composeWidth, tutorialSize) {
derivedStateOf {
val displacement = calculateDisplacementToMid(
startX = x,
composeWidth = composeWidth,
tutorialComposeWidth = tutorialSize.width
)
val xWithDisplacement = x + displacement
if (xWithDisplacement < 0) 0
else if (xWithDisplacement + tutorialSize.width > constraints.maxWidth) xWithDisplacement
else xWithDisplacement
}
}
val outOfBoundsStart by remember(xWithDisplacement) {
mutableStateOf(xWithDisplacement < 0)
}
val outOfBoundsEnd by remember(xWithDisplacement) {
mutableStateOf(xWithDisplacement + tutorialSize.width > constraints.maxWidth)
}
val outOfBoundsTop by remember(xWithDisplacement) {
mutableStateOf(y < 0)
}
val outOfBoundsBottom by remember(xWithDisplacement) {
mutableStateOf(y + tutorialSize.height > constraints.maxHeight)
}
val xToDraw by remember(xWithDisplacement, tutorialSize, constraints) {
derivedStateOf {
val xSafeRight =
xWithDisplacement - ((xWithDisplacement + tutorialSize.width) - constraints.maxWidth)
val safeX = if (outOfBoundsStart) 0
else if (outOfBoundsEnd) xSafeRight
else xWithDisplacement
safeX
}
}
val yToDraw by remember(y, tutorialSize, constraints) {
derivedStateOf {
val ySafeBottom = y - ((y + tutorialSize.height) - constraints.maxHeight)
var safeY = if (outOfBoundsTop) 0
else if (outOfBoundsBottom) ySafeBottom
else y
val isTutorialInFrontOfContent =
(safeY >= y && safeY <= (y + composeHeight)) ||
!(safeY <= y && safeY >= (y + composeHeight))
if (isTutorialInFrontOfContent) {
val tutorialHeight = tutorialSize.height
if (safeY + composeHeight + tutorialHeight < constraints.maxHeight) {
// is safe to draw bottom to content
safeY += composeHeight + (18)
} else if (safeY - composeHeight - tutorialHeight > 0) {
// is safe to draw top to content
safeY -= (tutorialHeight + 18)
}
}
safeY
}
}
val xAnimated = remember { Animatable(0f) }
val yAnimated = remember { Animatable(0f) }
var visible by remember(tutorialContent.index) { mutableStateOf(false) }
LaunchedEffect(key1 = xToDraw, key2 = yToDraw) {
xAnimated.animateTo(xToDraw.toFloat(), tween(50, delayMillis = 0))
yAnimated.animateTo(yToDraw.toFloat(), tween(50, delayMillis = 0))
visible = true
}
AnimatedVisibility(
modifier = Modifier
.onSizeChanged { tutorialSize = it }
.offset {
IntOffset(
x = xAnimated.value.roundToInt(),
y = yAnimated.value.roundToInt()
)
},
enter = fadeIn(tween(200, delayMillis = 100, easing = FastOutLinearInEasing)),
exit = fadeOut(tween(50, delayMillis = 0)),
visible = visible
) {
if (visible) {
Box {
tutorialContent.content?.invoke(this)
}
}
}
}
}
@Composable
private fun TutorialClickHandler(onTutorialClick: () -> Unit) {
Box(
modifier = Modifier
.fillMaxSize()
.clickable(
interactionSource = remember {
MutableInteractionSource()
},
indication = null,
onClick = onTutorialClick
)
)
}
}
internal fun calculateDisplacementToMid(
startX: Int, composeWidth: Int, tutorialComposeWidth: Int
): Int {
val xMidComponent = startX + (composeWidth / 2)
val xMidTutorial = startX + (tutorialComposeWidth / 2)
var displacementToMid = kotlin.math.abs(xMidComponent - xMidTutorial)
if (xMidComponent < xMidTutorial) displacementToMid *= -1
return displacementToMid
}
internal fun Modifier.tutorialTarget(
state: TutorialBoxState,
index: Int,
content: (@Composable BoxScope.() -> Unit)? = null,
): Modifier = onGloballyPositioned { coordinates ->
state.tutorialTargets[index] = TutorialBoxTarget(
index = index,
coordinates = coordinates,
content = content
)
}
/**
* markForTutorial will adds a tag in the content to TutorialBox draws the [content]
* using the order of the [index].
*
* In some complex layouts you may need to pass [state] between layers
*
* But if [content] is not defined or is null, the TutorialBox will use the
* [TutoriaBox(tutorialTarget: @Composable (index: Int) -> Unit]
*/
fun Modifier.markForTutorial(
state: TutorialBoxState,
index: Int,
content: (@Composable BoxScope.() -> Unit)? = null,
): Modifier = tutorialTarget(
state = state,
index = index,
content = content,
)
Gianfranco
10/30/2024, 11:43 PM