diff --git a/coachmark/src/main/java/com/pseudoankit/coachmark/demo/UnifyCoachmarkDemo.kt b/coachmark/src/main/java/com/pseudoankit/coachmark/demo/UnifyCoachmarkDemo.kt index be09d87..a1e2d45 100644 --- a/coachmark/src/main/java/com/pseudoankit/coachmark/demo/UnifyCoachmarkDemo.kt +++ b/coachmark/src/main/java/com/pseudoankit/coachmark/demo/UnifyCoachmarkDemo.kt @@ -2,6 +2,7 @@ package com.pseudoankit.coachmark.demo import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding @@ -23,33 +24,40 @@ import com.pseudoankit.coachmark.shape.Arrow import com.pseudoankit.coachmark.shape.Balloon import com.pseudoankit.coachmark.util.CoachMarkKey -public enum class Keys { Text1, Text2 } +public enum class Keys { Text1, Text2, TextStart, TextBottom, TextTop } @Composable -private fun PlotTextsAndUseLocalCoachMarkScope() { +private fun ColumnScope.PlotTextsAndUseLocalCoachMarkScope() { + + CoachMarkTargetText("Will show tooltip 1", Alignment.Start, Keys.Text1, ToolTipPlacement.End) + + CoachMarkTargetText("Will show tooltip 2", Alignment.Start, Keys.Text2, ToolTipPlacement.End) + + CoachMarkTargetText("Will show tooltip to left", Alignment.End, Keys.TextStart, ToolTipPlacement.Start) + + CoachMarkTargetText("Will show tooltip below", Alignment.CenterHorizontally, Keys.TextBottom, ToolTipPlacement.Bottom) + + CoachMarkTargetText("Will show tooltip above", Alignment.CenterHorizontally, Keys.TextTop, ToolTipPlacement.Top) + +} + +@Composable +private fun ColumnScope.CoachMarkTargetText( + text: String, + alignment: Alignment.Horizontal, + key: Keys, + placement: ToolTipPlacement, +) { val coachMarkScope = LocalCoachMarkScope.current - coachMarkScope?.apply { - Text( - text = "Will show tooltip 1", - modifier = Modifier - .enableCoachMark( - key = Keys.Text1, - toolTipPlacement = ToolTipPlacement.End, - highlightedViewConfig = HighlightedViewConfig( - shape = HighlightedViewConfig.Shape.Rect(12.dp), - padding = PaddingValues(8.dp) - ) - ) - .padding(16.dp), - color = Color.Black - ) + coachMarkScope?.apply { Text( - text = "Will show tooltip 2", + text = text, modifier = Modifier + .align(alignment) .enableCoachMark( - key = Keys.Text2, - toolTipPlacement = ToolTipPlacement.End, + key = key, + toolTipPlacement = placement, highlightedViewConfig = HighlightedViewConfig( shape = HighlightedViewConfig.Shape.Rect(12.dp), padding = PaddingValues(8.dp) @@ -82,15 +90,15 @@ public fun UnifyCoachmarkDemo() { }, modifier = Modifier.align(Alignment.CenterHorizontally) ) { - Text(text = "Highlight1") + Text(text = "Highlight 1") } Button( onClick = { - show(Keys.Text1, Keys.Text2) + show(*Keys.values()) }, modifier = Modifier.align(Alignment.CenterHorizontally) ) { - Text(text = "Highlight 1 & 2") + Text(text = "Highlight All") } } } @@ -110,5 +118,23 @@ private fun Tooltip(key: CoachMarkKey) { Text(text = "Highlighting Text2", color = Color.White) } } + + Keys.TextStart -> { + Balloon(arrow = Arrow.End()) { + Text(text = "A tooltip to the left", color = Color.White) + } + } + + Keys.TextBottom -> { + Balloon(arrow = Arrow.Top()) { + Text(text = "A tooltip below", color = Color.White) + } + } + + Keys.TextTop -> { + Balloon(arrow = Arrow.Bottom()) { + Text(text = "A tooltip above", color = Color.White) + } + } } } \ No newline at end of file diff --git a/coachmark/src/main/java/com/pseudoankit/coachmark/overlay/DimOverlayEffect.kt b/coachmark/src/main/java/com/pseudoankit/coachmark/overlay/DimOverlayEffect.kt index e02132d..bbc2ef9 100644 --- a/coachmark/src/main/java/com/pseudoankit/coachmark/overlay/DimOverlayEffect.kt +++ b/coachmark/src/main/java/com/pseudoankit/coachmark/overlay/DimOverlayEffect.kt @@ -1,21 +1,25 @@ package com.pseudoankit.coachmark.overlay import androidx.compose.animation.core.AnimationSpec -import androidx.compose.foundation.layout.Box import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp import com.pseudoankit.coachmark.model.TooltipHolder import com.pseudoankit.coachmark.scope.CoachMarkScope import com.pseudoankit.coachmark.util.CoachMarkDefaults import com.pseudoankit.coachmark.util.highlightActualView +/** + * @param paddingForTooltip min distance between tooltip and left/right side of screen/overlay + */ public class DimOverlayEffect( private val color: Color = Color.Black.copy(alpha = .75f), - override val overlayAnimationSpec: AnimationSpec = CoachMarkDefaults.Overlay.animationSpec + override val overlayAnimationSpec: AnimationSpec = CoachMarkDefaults.Overlay.animationSpec, + private val paddingForTooltip: Dp = CoachMarkDefaults.ToolTip.paddingForTooltip, ) : UnifyOverlayEffect { @Composable @@ -25,10 +29,11 @@ public class DimOverlayEffect( previousTooltip: TooltipHolder?, content: @Composable () -> Unit ) { - val density = LocalDensity.current - Box( + OverlayLayout( + configCurrent = currentTooltip?.item, + configPrevious = previousTooltip?.item, modifier = modifier .graphicsLayer(alpha = .99f) .drawBehind { @@ -39,11 +44,10 @@ public class DimOverlayEffect( previousTooltip?.item?.let { tooltip -> highlightActualView(tooltip, density, previousTooltip.alpha) } - } - ) { - content() - } + }, + content = content, + paddingForTooltip = paddingForTooltip, + ) } - } \ No newline at end of file diff --git a/coachmark/src/main/java/com/pseudoankit/coachmark/overlay/OverlayLayout.kt b/coachmark/src/main/java/com/pseudoankit/coachmark/overlay/OverlayLayout.kt new file mode 100644 index 0000000..21dd356 --- /dev/null +++ b/coachmark/src/main/java/com/pseudoankit/coachmark/overlay/OverlayLayout.kt @@ -0,0 +1,161 @@ +package com.pseudoankit.coachmark.overlay + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.UiComposable +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.layout.Measurable +import androidx.compose.ui.layout.Placeable +import androidx.compose.ui.layout.layoutId +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Dp +import com.pseudoankit.coachmark.model.ToolTipPlacement +import com.pseudoankit.coachmark.model.TooltipConfig +import com.pseudoankit.coachmark.util.CoachMarkDefaults + +/** Containing composable must use these values for layoutId on current and previous tooltip. */ +public object TooltipId { + public const val current: Int = 1 + public const val previous: Int = 2 +} + +/** Proposed minimum useful tooltip width; this can be adjusted if needed */ +private const val TOOLTIP_MAX_WIDTH_OVERRIDE_PX = 30 + +/** + * Extracted from DimOverlayEffect, in case another overlay effect needs it in the future. + * + * @param configCurrent of coach mark highlight; null suits case where there's no "current" tooltip in content + * @param configPrevious of coach mark highlight; null suits case where there's no "previous" tooltip in content + * @param paddingForTooltip min distance between tooltip and left/right side of screen/overlay + */ +@Composable +public fun OverlayLayout( + configCurrent: TooltipConfig?, + configPrevious: TooltipConfig?, + modifier: Modifier = Modifier, + paddingForTooltip: Dp = CoachMarkDefaults.ToolTip.paddingForTooltip, + content: @Composable @UiComposable () -> Unit, +) { + Layout(content, modifier) { measurables, constraints -> + + // child count < 2 occurs on first and last coach mark + require(measurables.size <= 2) { "OverlayLayout cannot have more than two children" } + + val gapTooltipScreenPx = paddingForTooltip.roundToPx() + + // measure children + val placeableCurrent = measure( + tooltipConfig = configCurrent, + layoutId = TooltipId.current, + measurables = measurables, + constraintsParent = constraints, + gapTooltipScreenPx = gapTooltipScreenPx + ) + val placeablePrevious = measure( + tooltipConfig = configPrevious, + layoutId = TooltipId.previous, + measurables = measurables, + constraintsParent = constraints, + gapTooltipScreenPx = gapTooltipScreenPx + ) + + // place children + layout(constraints.maxWidth, constraints.maxHeight) { + place(placeableCurrent, configCurrent) + place(placeablePrevious, configPrevious) + } + } +} + + +/** + * @param layoutId use consts from TooltipId + * @return null if tooltipConfig is null + */ +private fun measure( + tooltipConfig: TooltipConfig?, + layoutId: Int, + measurables: List, + constraintsParent: Constraints, + gapTooltipScreenPx: Int, +): Placeable? { + if (tooltipConfig == null) return null + + // constrain max width to prevent tooltip running off screen + var maxWidth = when (tooltipConfig.toolTipPlacement) { + ToolTipPlacement.Start -> { + tooltipConfig.layout.startX.toInt() - gapTooltipScreenPx // left edge of highlight, minus overlay padding + } + ToolTipPlacement.End -> { + constraintsParent.maxWidth - gapTooltipScreenPx - tooltipConfig.layout.endX.toInt() + } + + // Top and Bottom: allow full screen width, minus edge padding + else -> constraintsParent.maxWidth - (gapTooltipScreenPx shl 1) + } + + /* + We can't currently constraint max height to prevent tooltip running off screen, because text + already uses as much width as it can by default, so there's no horizontal space to trade. + */ + + /* + This is mainly intended to avoid a crash when calculated max width is less than zero, such + as when tooltip is positioned to "End" while highlight is very close to right side of screen. + This fail-soft approach, where we display the tooltip running off the screen, allows the + developer to see the problem at runtime without having to check the log. + */ + if (maxWidth < TOOLTIP_MAX_WIDTH_OVERRIDE_PX) { + maxWidth = Constraints.Infinity + } + + val constraintsChild = Constraints( + minWidth = 0, + minHeight = 0, + maxWidth = maxWidth, + maxHeight = constraintsParent.maxHeight, + ) + + return measurables.find { it.layoutId == layoutId }?.measure(constraintsChild) +} + +/** + * Centralizes null checks and switching on toolTipPlacement value. + * @param placeable no-op if null + * @param config no-op if null + */ +private fun Placeable.PlacementScope.place(placeable: Placeable?, config: TooltipConfig?) { + if (placeable != null && config != null) { + val layout = config.layout + var x = 0 + var y = 0 + + // result positive when highlight is larger, negative when tooltip is larger + fun calculateCenteringOffset(independentHeight: Int, dependentHeight: Int): Int = (independentHeight - dependentHeight) shr 1 + + fun centerVertically() = (layout.startY + calculateCenteringOffset(layout.height, placeable.height)).toInt() + fun centerHorizontally() = (layout.startX + calculateCenteringOffset(layout.width, placeable.width)).toInt() + + when (config.toolTipPlacement) { + ToolTipPlacement.Start -> { + x = layout.startX.toInt() - placeable.width + y = centerVertically() + } + ToolTipPlacement.End -> { + x = layout.endX.toInt() + y = centerVertically() + } + ToolTipPlacement.Top -> { + x = centerHorizontally() + y = layout.startY.toInt() - placeable.height + } + ToolTipPlacement.Bottom -> { + x = centerHorizontally() + y = layout.endY.toInt() + } + } + + placeable.placeRelative(x, y) + } +} diff --git a/coachmark/src/main/java/com/pseudoankit/coachmark/ui/CoachMarkImpl.kt b/coachmark/src/main/java/com/pseudoankit/coachmark/ui/CoachMarkImpl.kt index d50a3d1..5943005 100644 --- a/coachmark/src/main/java/com/pseudoankit/coachmark/ui/CoachMarkImpl.kt +++ b/coachmark/src/main/java/com/pseudoankit/coachmark/ui/CoachMarkImpl.kt @@ -6,6 +6,8 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha +import androidx.compose.ui.layout.layoutId +import com.pseudoankit.coachmark.overlay.TooltipId import com.pseudoankit.coachmark.overlay.UnifyOverlayEffect import com.pseudoankit.coachmark.scope.CoachMarkScope import com.pseudoankit.coachmark.scope.CoachMarkScopeImpl @@ -48,16 +50,23 @@ internal fun CoachMarkImpl( .alpha( animateFloatAsState( targetValue = if (currentTooltip?.isVisible == true) 1f else 0f, - animationSpec = overlayEffect.overlayAnimationSpec + animationSpec = overlayEffect.overlayAnimationSpec, + label = "OverlayAlphaAnimation", // just to avoid warning ).value ), currentTooltip = currentTooltip, previousTooltip = previousTooltip ) { - Tooltip(currentTooltip) { + Tooltip( + tooltipHolder = currentTooltip, + modifier = Modifier.layoutId(TooltipId.current), + ) { coachMarkScope.tooltip(it) } - Tooltip(previousTooltip) { + Tooltip( + tooltipHolder = previousTooltip, + modifier = Modifier.layoutId(TooltipId.previous), + ) { coachMarkScope.tooltip(it) } } diff --git a/coachmark/src/main/java/com/pseudoankit/coachmark/ui/Tooltip.kt b/coachmark/src/main/java/com/pseudoankit/coachmark/ui/Tooltip.kt index 4b38236..ca05d35 100644 --- a/coachmark/src/main/java/com/pseudoankit/coachmark/ui/Tooltip.kt +++ b/coachmark/src/main/java/com/pseudoankit/coachmark/ui/Tooltip.kt @@ -1,22 +1,11 @@ package com.pseudoankit.coachmark.ui import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.offset import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha -import androidx.compose.ui.layout.onGloballyPositioned -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.unit.Density -import androidx.compose.ui.unit.IntSize -import com.pseudoankit.coachmark.model.ToolTipPlacement -import com.pseudoankit.coachmark.model.TooltipConfig import com.pseudoankit.coachmark.model.TooltipHolder import com.pseudoankit.coachmark.util.CoachMarkKey -import com.pseudoankit.coachmark.util.rememberMutableStateOf -import com.pseudoankit.coachmark.util.toDp /** * composable to render the tooltip @@ -28,49 +17,12 @@ import com.pseudoankit.coachmark.util.toDp @Composable internal fun Tooltip( tooltipHolder: TooltipHolder?, + modifier: Modifier = Modifier, content: @Composable (CoachMarkKey) -> Unit ) { - val density = LocalDensity.current - var toolTipSize by rememberMutableStateOf(value = IntSize(0, 0)) - tooltipHolder?.item?.let { activeItem -> - Box( - modifier = Modifier - .onGloballyPositioned { - toolTipSize = it.size - } - .offset( - x = activeItem.offsetX(density, toolTipSize), - y = activeItem.offsetY(density, toolTipSize), - ) - .alpha(tooltipHolder.alpha) - ) { + Box(modifier = modifier.alpha(tooltipHolder.alpha)) { content(activeItem.key) } } } - -private fun TooltipConfig.offsetX( - density: Density, toolTipSize: IntSize -) = when (toolTipPlacement) { - ToolTipPlacement.Start -> layout.startX - toolTipSize.width - ToolTipPlacement.End -> layout.endX - ToolTipPlacement.Top, ToolTipPlacement.Bottom -> { - val viewCenter = (layout.startX + layout.endX).div(2) - val toolTipCenter = (toolTipSize.width).div(2) - viewCenter - toolTipCenter - } -}.toDp(density) - -private fun TooltipConfig.offsetY( - density: Density, toolTipSize: IntSize -) = when (toolTipPlacement) { - ToolTipPlacement.Start, ToolTipPlacement.End -> { - val viewCenter = (layout.startY + layout.endY).div(2) - val toolTipCenter = toolTipSize.height.div(2) - viewCenter - toolTipCenter - } - - ToolTipPlacement.Top -> layout.startY - toolTipSize.height - ToolTipPlacement.Bottom -> layout.endY -}.toDp(density) \ No newline at end of file diff --git a/coachmark/src/main/java/com/pseudoankit/coachmark/util/CoachMarkDefaults.kt b/coachmark/src/main/java/com/pseudoankit/coachmark/util/CoachMarkDefaults.kt index a2874ef..98a9153 100644 --- a/coachmark/src/main/java/com/pseudoankit/coachmark/util/CoachMarkDefaults.kt +++ b/coachmark/src/main/java/com/pseudoankit/coachmark/util/CoachMarkDefaults.kt @@ -31,6 +31,7 @@ public object CoachMarkDefaults { public object ToolTip { public val animationSpec: AnimationSpec = tween(ANIMATION_DURATION) + public val paddingForTooltip: Dp = 8.dp } public object Overlay {