Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Re-show the tooltip on mouse-move following a click in TooltipArea. #1209

Merged
merged 1 commit into from
Mar 21, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
}
Comment on lines +112 to +114
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is needed because this function is now potentially called on every mouse-move, and we don't want to restart the timer.

job = scope.launch {
delay(delayMillis.toLong())
isVisible = true
Expand All @@ -121,6 +120,7 @@ fun TooltipArea(

fun hide() {
job?.cancel()
job = null
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because why not.

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()
}
Comment on lines +138 to +140
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This fixes the case of press -> exit -> enter
Previously we would re-show the tooltip.

}
.onPointerEvent(PointerEventType.Move) {
cursorPosition = it.position
hideIfNotHovered(parentBounds.topLeft + it.position)
igordmn marked this conversation as resolved.
Show resolved Hide resolved
if (!isVisible && !it.buttons.areAnyPressed) {
startShowing()
}
Comment on lines +144 to +146
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-show the tooltip on mouse-move (but no buttons pressed).

}
.onPointerEvent(PointerEventType.Exit) {
hideIfNotHovered(parentBounds.topLeft + it.position)
}
.pointerInput(Unit) {
detectDown {
hide()
}
.onPointerEvent(PointerEventType.Press, pass = PointerEventPass.Initial) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need Initial instead of the main pass here?

Copy link
Author

@m-sasha m-sasha Mar 20, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't know, but it was in the code already, so I decided not to change it just in case.

hide()
Comment on lines -147 to +152
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure why there was a need for a custom implementation here.

}
) {
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)
}
}
}
}

Comment on lines -194 to -205
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure why this was needed, and in any case it's buggy - you're supposed to put the while inside the awaitPointerEventScope.

/**
* 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"))
}
}
}
Loading