Skip to content

Commit

Permalink
Re-show the tooltip on mouse-move following a click in TooltipArea. (#…
Browse files Browse the repository at this point in the history
  • Loading branch information
m-sasha committed Mar 21, 2024
1 parent 5741ad4 commit 832d708
Show file tree
Hide file tree
Showing 2 changed files with 178 additions and 42 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.pointer.changedToDown
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.PointerInputScope
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInWindow
Expand All @@ -37,14 +35,14 @@ import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.input.pointer.AwaitPointerEventScope
import androidx.compose.ui.input.pointer.PointerEvent
import androidx.compose.ui.input.pointer.PointerEventType
import androidx.compose.ui.input.pointer.areAnyPressed
import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupPositionProvider
import androidx.compose.ui.window.rememberPopupPositionProviderAtPosition
import androidx.compose.ui.window.rememberCursorPositionProvider
import androidx.compose.ui.window.rememberComponentRectPositionProvider
import kotlinx.coroutines.delay
import kotlinx.coroutines.Job
Expand Down Expand Up @@ -105,14 +103,15 @@ fun TooltipArea(
content: @Composable () -> Unit
) {
var parentBounds by remember { mutableStateOf(Rect.Zero) }
var popupPosition by remember { mutableStateOf(Offset.Zero) }
var cursorPosition by remember { mutableStateOf(Offset.Zero) }
var isVisible by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
var job: Job? by remember { mutableStateOf(null) }

fun startShowing() {
job?.cancel()
if (job?.isActive == true) { // Don't restart the job if it's already active
return
}
job = scope.launch {
delay(delayMillis.toLong())
isVisible = true
Expand All @@ -121,6 +120,7 @@ fun TooltipArea(

fun hide() {
job?.cancel()
job = null
isVisible = false
}

Expand All @@ -135,19 +135,21 @@ fun TooltipArea(
.onGloballyPositioned { parentBounds = it.boundsInWindow() }
.onPointerEvent(PointerEventType.Enter) {
cursorPosition = it.position
startShowing()
if (!isVisible && !it.buttons.areAnyPressed) {
startShowing()
}
}
.onPointerEvent(PointerEventType.Move) {
cursorPosition = it.position
hideIfNotHovered(parentBounds.topLeft + it.position)
if (!isVisible && !it.buttons.areAnyPressed) {
startShowing()
}
}
.onPointerEvent(PointerEventType.Exit) {
hideIfNotHovered(parentBounds.topLeft + it.position)
}
.pointerInput(Unit) {
detectDown {
hide()
}
.onPointerEvent(PointerEventType.Press, pass = PointerEventPass.Initial) {
hide()
}
) {
content()
Expand All @@ -157,6 +159,7 @@ fun TooltipArea(
popupPositionProvider = tooltipPlacement.positionProvider(cursorPosition),
onDismissRequest = { isVisible = false }
) {
var popupPosition by remember { mutableStateOf(Offset.Zero) }
Box(
Modifier
.onGloballyPositioned { popupPosition = it.positionInWindow() }
Expand Down Expand Up @@ -191,18 +194,6 @@ private fun Modifier.onPointerEvent(
}
}

private suspend fun PointerInputScope.detectDown(onDown: (Offset) -> Unit) {
while (true) {
awaitPointerEventScope {
val event = awaitPointerEvent(PointerEventPass.Initial)
val down = event.changes.find { it.changedToDown() }
if (down != null) {
onDown(down.position)
}
}
}
}

/**
* An interface for providing a [PopupPositionProvider] for the tooltip.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,48 +18,193 @@ package androidx.compose.foundation

import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performMouseInput
import androidx.compose.ui.test.runComposeUiTest
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import org.junit.Rule
import kotlin.test.assertFalse
import org.junit.Test

@OptIn(ExperimentalFoundationApi::class, ExperimentalTestApi::class)
internal class TooltipAreaTest {
@get:Rule
val rule = createComposeRule()

// https://github.com/JetBrains/compose-jb/issues/2821
@Test
fun `simple tooltip is shown`(): Unit = runBlocking(Dispatchers.Main) {
rule.setContent {
fun `simple tooltip is shown`() = runComposeUiTest {
setContent {
SimpleTooltipArea()
}

onNodeWithTag("tooltip").assertDoesNotExist()

onNodeWithTag("elementWithTooltip").performMouseInput {
moveTo(Offset(30f, 40f))
}

onNodeWithTag("tooltip").assertExists()
}

/**
* Verify that the tooltip is hidden when the tooltip area is pressed.
*/
@Test
fun tooltipHiddenOnPress() = runComposeUiTest {
setContent {
SimpleTooltipArea()
}

onNodeWithTag("elementWithTooltip").performMouseInput {
moveTo(Offset(30f, 40f))
}
onNodeWithTag("tooltip").assertExists()

onNodeWithTag("elementWithTooltip").performMouseInput {
press()
}
onNodeWithTag("tooltip").assertDoesNotExist()
}

/**
* Verify that the tooltip is hidden when the mouse leaves the tooltip area.
*/
@Test
fun tooltipHiddenOnExit() = runComposeUiTest {
setContent {
SimpleTooltipArea()
}

onNodeWithTag("elementWithTooltip").performMouseInput {
moveTo(Offset(30f, 40f))
}
onNodeWithTag("tooltip").assertExists()

onNodeWithTag("elementWithTooltip").performMouseInput {
moveTo(Offset(150f, 150f))
}
onNodeWithTag("tooltip").assertDoesNotExist()
}

/**
* Verify that the tooltip is hidden when the mouse moves into the tooltip, as long as it's
* still also inside the tooltip.
*/
@Test
fun tooltipNotHiddenOnMoveIntoTooltip() = runComposeUiTest {
var tooltipHidden = false
setContent {
TooltipArea(
tooltip = {
Box {
BasicText(
text = "Tooltip",
modifier = Modifier.testTag("tooltipText")
)
Box(Modifier.size(100.dp).testTag("tooltip"))
DisposableEffect(Unit) {
onDispose {
println("Tooltip disposed")
tooltipHidden = true
}
}
}
},
tooltipPlacement = TooltipPlacement.CursorPoint(
offset = DpOffset(x = 0.dp, y = 10.dp)
),
) {
BasicText("Text", modifier = Modifier.size(50.dp).testTag("elementWithTooltip"))
Box(Modifier.size(100.dp).testTag("elementWithTooltip"))
}
}

rule.onNodeWithTag("elementWithTooltip").performMouseInput {
// Move into the tooltip area
onNodeWithTag("elementWithTooltip").performMouseInput {
moveTo(Offset(30f, 40f))
}
rule.waitForIdle()

rule.onNodeWithTag("tooltipText").assertExists()
// Move into the tooltip, but still inside the area
onNodeWithTag("tooltip").let {
it.assertExists()
it.performMouseInput {
moveTo(Offset(10f, 10f)) // Still inside the tooltip area
}
}
waitForIdle()

// Can't test with `assertExists` because if the tooltip was hidden, it could still be
// re-shown after a delay. So the test would pass even on the wrong behavior.
assertFalse(tooltipHidden, "Tooltip was hidden on move into tooltip")

// Move within the tooltip to a position outside the tooltip area
onNodeWithTag("tooltip").let {
it.assertExists()
it.performMouseInput {
moveTo(Offset(99f, 99f)) // Outside the tooltip area
}
}
onNodeWithTag("tooltip").assertDoesNotExist()
}

/**
* Verify that the tooltip is shown after the given delay and not beforehand.
*/
@Test
fun tooltipShownAfterDelay() = runComposeUiTest {
mainClock.autoAdvance = false

setContent {
SimpleTooltipArea(delayMillis = 200)
}

onNodeWithTag("elementWithTooltip").performMouseInput {
moveTo(Offset(30f, 40f))
}
mainClock.advanceTimeBy(100)
onNodeWithTag("tooltip").assertDoesNotExist()
mainClock.advanceTimeBy(101)
onNodeWithTag("tooltip").assertExists()
}

/**
* Verify that the tooltip is re-shown after press -> release -> move
*/
@Test
fun tooltipReshownOnMove() = runComposeUiTest {
setContent {
SimpleTooltipArea()
}

onNodeWithTag("elementWithTooltip").performMouseInput {
moveTo(Offset(30f, 40f))
}
onNodeWithTag("tooltip").assertExists()

onNodeWithTag("elementWithTooltip").performMouseInput {
press()
}
onNodeWithTag("tooltip").assertDoesNotExist()

onNodeWithTag("elementWithTooltip").performMouseInput {
release()
moveBy(Offset(10f, 10f))
}
onNodeWithTag("tooltip").assertExists()
}

@Composable
private fun SimpleTooltipArea(
areaSize: Dp = 100.dp,
tooltipSize: Dp = 20.dp,
delayMillis: Int = 500
) {
TooltipArea(
tooltip = {
Box(Modifier.size(tooltipSize).testTag("tooltip"))
},
delayMillis = delayMillis
) {
Box(Modifier.size(areaSize).testTag("elementWithTooltip"))
}
}
}

0 comments on commit 832d708

Please sign in to comment.