Skip to content

Commit

Permalink
Merge pull request #13 from glasheen99/master
Browse files Browse the repository at this point in the history
issue 12: fixed tooltip flicker; prevent tooltip run off screen
  • Loading branch information
pseudoankit authored Dec 1, 2023
2 parents 9b34f41 + 3b8c79d commit 58e0f29
Show file tree
Hide file tree
Showing 6 changed files with 238 additions and 85 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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")
}
}
}
Expand 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)
}
}
}
}
Original file line number Diff line number Diff line change
@@ -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<Float> = CoachMarkDefaults.Overlay.animationSpec
override val overlayAnimationSpec: AnimationSpec<Float> = CoachMarkDefaults.Overlay.animationSpec,
private val paddingForTooltip: Dp = CoachMarkDefaults.ToolTip.paddingForTooltip,
) : UnifyOverlayEffect {

@Composable
Expand All @@ -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 {
Expand All @@ -39,11 +44,10 @@ public class DimOverlayEffect(
previousTooltip?.item?.let { tooltip ->
highlightActualView(tooltip, density, previousTooltip.alpha)
}
}
) {
content()
}
},
content = content,
paddingForTooltip = paddingForTooltip,
)
}


}
Original file line number Diff line number Diff line change
@@ -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<Measurable>,
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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
}
Expand Down
Loading

0 comments on commit 58e0f29

Please sign in to comment.