-
Notifications
You must be signed in to change notification settings - Fork 1.2k
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
Hover doesn't always disappear after exit #1324
Comments
Managed to reproduce on Mac, but it is really-really difficult |
I have noticed that it happens much more in my Manjaro KDE installation than on Ubuntu, not sure why tho. Not sure if it's the desktop environment or there is something different in the build process (like different JDK). In any case, still happens in Ubuntu even if it's less. |
I have the same problem on Windows. Reproducibility is easy: fun main() = singleWindowApplication {
val interactionSource = MutableInteractionSource()
val isHovered by interactionSource.collectIsHoveredAsState()
Box(Modifier.size(100.dp).background(Color.Gray).hoverable(interactionSource)) {
if (isHovered) {
Text("Hovered")
}
}
} 2021-11-13-21-29-30.mp4edit: |
In 1.0.0-rc4 reproduced with a scrollbar and a heavy lazy list: 2021-11-26-69.mp4With a light lazy list it is hard to reproduce. |
With rc5 the issue seems to be solved when simply hovering with the cursor, however, when hovering an item in a lazy list and then scrolling, it still sometimes ends up with a hovered line that shouldn't be. |
Fixes JetBrains/compose-multiplatform#1324 (comment) Compose doesn't work well if we send an event with different coordinates without sending Move event before it: ``` Column { Box(size=10) Box(size=10) } ``` If we send these events: Move(5,5) -> Scroll(5,15) -> Move(5,15) Then during the scroll event, HitPathTracker forgets about the first Box, and never send Exit to it. Instead it sends the Scroll event to it. We should send events in this order: Move(5,5) -> Move(5,15) -> Scroll(5,15) -> Move(5,15) With synthetic events things more complicated, as we always send events with the same position. I suppose the proper fix should be in Compose core itself, but it would a very huge fix. Reproducer: ``` import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.size import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.ImageComposeScene import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.input.pointer.PointerEventType import androidx.compose.ui.input.pointer.onPointerEvent import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.dp import androidx.compose.ui.use import com.google.common.truth.Truth.assertThat import org.junit.Test import org.junit.runner.RunWith import org.junit.runners.JUnit4 @OptIn(ExperimentalComposeUiApi::class) @RunWith(JUnit4::class) class MouseHoverFilterTest { // bug: JetBrains/compose-multiplatform#1324 (comment) @test fun `move from one component to another, between another non-move event`() = ImageComposeScene( width = 100, height = 100, density = Density(1f) ).use { scene -> var enterCount1 = 0 var exitCount1 = 0 var enterCount2 = 0 var exitCount2 = 0 scene.setContent { Column { Box( modifier = Modifier .pointerMove( onEnter = { enterCount1++ }, onExit = { exitCount1++ } ) .size(10.dp, 10.dp) ) Box( modifier = Modifier .pointerMove( onEnter = { enterCount2++ }, onExit = { exitCount2++ } ) .size(10.dp, 10.dp) ) } } scene.sendPointerEvent(PointerEventType.Enter, Offset(5f, 5f)) assertThat(enterCount1).isEqualTo(1) assertThat(exitCount1).isEqualTo(0) assertThat(enterCount2).isEqualTo(0) assertThat(exitCount2).isEqualTo(0) // Compose doesn't work well if we send an event with different type and different coordinates, without sending move event before it scene.sendPointerEvent(PointerEventType.Scroll, Offset(5f, 15f)) scene.sendPointerEvent(PointerEventType.Move, Offset(5f, 15f)) assertThat(enterCount1).isEqualTo(1) assertThat(exitCount1).isEqualTo(1) assertThat(enterCount2).isEqualTo(1) assertThat(exitCount2).isEqualTo(0) } } ``` Another issue: JetBrains/compose-multiplatform#1480
My usecase #1324 (comment) behave exactly the same in latest RC5. Windows 11. |
Can you check on 0.0.0-feature-test28112021-dev491? It will be rc6 tomorrow |
Actually, I reproduced it. The issue is in your snippet. It should be:
|
I really sorry, lame mistake. My non-simiplified usecase works correctly with RC5. THX. |
Does this version include JetBrains/compose-multiplatform-core#135? |
Yes, it includes this fix |
Can you check if this works in 1.0.0-rc12? There was one more change regarding this issue. On my PC it works in all tested cases. |
In 1.0.0-rc12 seems to work nicely 🥳 Good job and thanks @igordmn ! |
Call enter/exit events if we hover another popup Fixes JetBrains/compose-multiplatform#841 Consume key events Don't send event to focusable popup if there is no hovered popup Don't scroll outside of focusable popup Fixes JetBrains/compose-multiplatform#1346 Fix crash when we press right mouse button during dragging with left button Fixes JetBrains/compose-multiplatform#1426 Fixes JetBrains/compose-multiplatform#1176 The weird behaviour doesn't reproduce anymore after cherry-picks (this fix was reverted a few days ago here: #89) Fix lazy scrollbar Fixes JetBrains/compose-multiplatform#1430 Fix ScrollbarTest Make real synthetic move events I encountered code, where users doesn't use Compose events at all. They just listen to awtEvent, and check its type. The feature "send synthetic move event on relayout", which we recently implemented, will break such use case, as synthetic event type would not reflect reality. For example, we would resend the latest native event, which was MousePressed. The application checks it and determines, that that cursor is over some button and presses it. To fix that, the platform should provide a factory-method to create synthetic move events. Fix build Desktop. Fix problems with Enter/Exit events Resend events similar to Android: https://android-review.googlesource.com/c/platform/frameworks/support/+/1856856 On desktop we don't change the native event, as we don't know the nature of it, it can be not only MouseEvent in the future. We change only PointerEvent. This can lead to some confusion, if user reads the native event type instead of Compose event type. Fixes JetBrains/compose-multiplatform#523 Fixes JetBrains/compose-multiplatform#594 Manual test: ``` import androidx.compose.foundation.background import androidx.compose.foundation.gestures.scrollBy import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.PointerEventType import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.dp import androidx.compose.ui.window.singleWindowApplication import kotlinx.coroutines.delay @composable private fun App() { val state = rememberScrollState() Column(Modifier.size(300.dp).verticalScroll(state)) { repeat(20) { Item(it) } } } @composable private fun Item(num: Int) { var isHovered by remember { mutableStateOf(false) } Box( Modifier .fillMaxWidth() .height(50.dp) .padding(8.dp) .background(if (isHovered) Color.Red else Color.Green) .pointerInput(Unit) { while (true) { awaitPointerEventScope<Unit> { val event = awaitPointerEvent() when (event.type) { PointerEventType.Enter -> { isHovered = true } PointerEventType.Exit -> { isHovered = false } } } } } ) { Text(num.toString()) } } fun main() { singleWindowApplication { App() } } ``` Change-Id: I667c206bd08568fa0cb78208037c797bb8298702 Test: manual and ./gradlew jvmTest desktopTest -Pandroidx.compose.multiplatformEnabled=true # Conflicts: # compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/input/pointer/PointerEvent.desktop.kt # compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/ComposeScene.skiko.kt Get rid of mousePressed from ComposeScene mousePressed is unreliable on Windows (we can miss the release event), and doesn't work well with multiple buttons. After this fix, mouseClickable only reacts to the first pressed button. Right button click doesn't trigger callback, if there is already left mouse button is pressed. `Clickable` shouldn't be able to handle these cases. If users would want simultaneously handle multiple buttons, they have to use low-level api: ``` Modifier.pointerInput(Unit) { while (true) { val event = awaitPointerEventScope { awaitPointerEvent() } if (event.type == PointerEventType.Press && event.buttons.isPrimaryPressed) { // do something } else if (event.type == PointerEventType.Press && event.buttons.isSecondaryPressed) { // do something } } } ``` (it is verbose, there is a field for improvement) Also pass PointerButtons and PointerKeyboardModifiers to ComposeScene, instead of reading them from AWT event. Test: ./gradlew jvmTest desktopTest -Pandroidx.compose.multiplatformEnabled=true Test: the snippet from JetBrains/compose-multiplatform#1149, because changed the code for that fix Revert "Remove pointerId from ComposeScene" Remove pointerId from ComposeScene pointerId was indroduced in https://android-review.googlesource.com/c/platform/frameworks/support/+/1402607, because double click didn't work (see https://jetbrains.slack.com/archives/GT449QBCK/p1597328095373000) But it messes with hover and clicking multiple mouse buttons at the same time. Double clicking still works after removing it: ``` import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.size import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import androidx.compose.ui.window.singleWindowApplication @OptIn(ExperimentalFoundationApi::class) fun main() = singleWindowApplication { Box( Modifier .size(300.dp) .background(Color.Red) .combinedClickable(onDoubleClick = { println("onDoubleClick") }, onClick = { println("onClick") }) ) { } } ``` Fixes JetBrains/compose-multiplatform#1176 Test: ./gradlew jvmTest desktopTest -Pandroidx.compose.multiplatformEnabled=true Test: manual (see the snippet) Events Fix sending events from AWT Fix disposing window in event callback Fixes JetBrains/compose-multiplatform#1448 The sequence of calls: mouseReleased window.dispose scene.dispose cancelRippleEffect scene.invalidate layer.needRedraw Fix losing Exit event on scroll Fixes JetBrains/compose-multiplatform#1324 (comment) Compose doesn't work well if we send an event with different coordinates without sending Move event before it: ``` Column { Box(size=10) Box(size=10) } ``` If we send these events: Move(5,5) -> Scroll(5,15) -> Move(5,15) Then during the scroll event, HitPathTracker forgets about the first Box, and never send Exit to it. Instead it sends the Scroll event to it. We should send events in this order: Move(5,5) -> Move(5,15) -> Scroll(5,15) -> Move(5,15) With synthetic events things more complicated, as we always send events with the same position. I suppose the proper fix should be in Compose core itself, but it would a very huge fix. Reproducer: ``` import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.size import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.ImageComposeScene import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.input.pointer.PointerEventType import androidx.compose.ui.input.pointer.onPointerEvent import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.dp import androidx.compose.ui.use import com.google.common.truth.Truth.assertThat import org.junit.Test import org.junit.runner.RunWith import org.junit.runners.JUnit4 @OptIn(ExperimentalComposeUiApi::class) @RunWith(JUnit4::class) class MouseHoverFilterTest { // bug: JetBrains/compose-multiplatform#1324 (comment) @test fun `move from one component to another, between another non-move event`() = ImageComposeScene( width = 100, height = 100, density = Density(1f) ).use { scene -> var enterCount1 = 0 var exitCount1 = 0 var enterCount2 = 0 var exitCount2 = 0 scene.setContent { Column { Box( modifier = Modifier .pointerMove( onEnter = { enterCount1++ }, onExit = { exitCount1++ } ) .size(10.dp, 10.dp) ) Box( modifier = Modifier .pointerMove( onEnter = { enterCount2++ }, onExit = { exitCount2++ } ) .size(10.dp, 10.dp) ) } } scene.sendPointerEvent(PointerEventType.Enter, Offset(5f, 5f)) assertThat(enterCount1).isEqualTo(1) assertThat(exitCount1).isEqualTo(0) assertThat(enterCount2).isEqualTo(0) assertThat(exitCount2).isEqualTo(0) // Compose doesn't work well if we send an event with different type and different coordinates, without sending move event before it scene.sendPointerEvent(PointerEventType.Scroll, Offset(5f, 15f)) scene.sendPointerEvent(PointerEventType.Move, Offset(5f, 15f)) assertThat(enterCount1).isEqualTo(1) assertThat(exitCount1).isEqualTo(1) assertThat(enterCount2).isEqualTo(1) assertThat(exitCount2).isEqualTo(0) } } ``` Another issue: JetBrains/compose-multiplatform#1480 Fix losing hover events, speed up scroll Fixes JetBrains/compose-multiplatform#1324 Fix crash when AWT event is sent after the window is disposed Fixes JetBrains/compose-multiplatform#1448 (comment) AWT can send event even after calling `window.dipose` I checked, and it nothing to do with scheduleSyntheticMoveEvent - the crash still reproducible without it. Remove async events Async events is cause why sometimes interaction is so clunky: - when we resize a heavy undecorated window from #124, it continue resizing, even if we release the mouse - when we scroll CodeViewer, it continues to scroll for 1-2 seconds Async events removed freezes for us for scrolling heavy lazy lists, but instead it adds unresponsive UI that lives its own live. Also, i checked heavy lazy list (codeviewer), and it doesn't freeze anymore during scrolling Partially fixes JetBrains/compose-multiplatform#1345 Without that we can't merge #124 1
Make real synthetic move events I encountered code, where users doesn't use Compose events at all. They just listen to awtEvent, and check its type. The feature "send synthetic move event on relayout", which we recently implemented, will break such use case, as synthetic event type would not reflect reality. For example, we would resend the latest native event, which was MousePressed. The application checks it and determines, that that cursor is over some button and presses it. To fix that, the platform should provide a factory-method to create synthetic move events. Desktop. Fix problems with Enter/Exit events Resend events similar to Android: https://android-review.googlesource.com/c/platform/frameworks/support/+/1856856 On desktop we don't change the native event, as we don't know the nature of it, it can be not only MouseEvent in the future. We change only PointerEvent. This can lead to some confusion, if user reads the native event type instead of Compose event type. Fixes JetBrains/compose-multiplatform#523 Fixes JetBrains/compose-multiplatform#594 Manual test: ``` import androidx.compose.foundation.background import androidx.compose.foundation.gestures.scrollBy import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.PointerEventType import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.dp import androidx.compose.ui.window.singleWindowApplication import kotlinx.coroutines.delay @composable private fun App() { val state = rememberScrollState() Column(Modifier.size(300.dp).verticalScroll(state)) { repeat(20) { Item(it) } } } @composable private fun Item(num: Int) { var isHovered by remember { mutableStateOf(false) } Box( Modifier .fillMaxWidth() .height(50.dp) .padding(8.dp) .background(if (isHovered) Color.Red else Color.Green) .pointerInput(Unit) { while (true) { awaitPointerEventScope<Unit> { val event = awaitPointerEvent() when (event.type) { PointerEventType.Enter -> { isHovered = true } PointerEventType.Exit -> { isHovered = false } } } } } ) { Text(num.toString()) } } fun main() { singleWindowApplication { App() } } ``` Change-Id: I667c206bd08568fa0cb78208037c797bb8298702 Test: manual and ./gradlew jvmTest desktopTest -Pandroidx.compose.multiplatformEnabled=true # Conflicts: # compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/input/pointer/PointerEvent.desktop.kt # compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/ComposeScene.skiko.kt Fix losing Exit event on scroll Fixes JetBrains/compose-multiplatform#1324 (comment) Compose doesn't work well if we send an event with different coordinates without sending Move event before it: ``` Column { Box(size=10) Box(size=10) } ``` If we send these events: Move(5,5) -> Scroll(5,15) -> Move(5,15) Then during the scroll event, HitPathTracker forgets about the first Box, and never send Exit to it. Instead it sends the Scroll event to it. We should send events in this order: Move(5,5) -> Move(5,15) -> Scroll(5,15) -> Move(5,15) With synthetic events things more complicated, as we always send events with the same position. I suppose the proper fix should be in Compose core itself, but it would a very huge fix. Reproducer: ``` import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.size import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.ImageComposeScene import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.input.pointer.PointerEventType import androidx.compose.ui.input.pointer.onPointerEvent import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.dp import androidx.compose.ui.use import com.google.common.truth.Truth.assertThat import org.junit.Test import org.junit.runner.RunWith import org.junit.runners.JUnit4 @OptIn(ExperimentalComposeUiApi::class) @RunWith(JUnit4::class) class MouseHoverFilterTest { // bug: JetBrains/compose-multiplatform#1324 (comment) @test fun `move from one component to another, between another non-move event`() = ImageComposeScene( width = 100, height = 100, density = Density(1f) ).use { scene -> var enterCount1 = 0 var exitCount1 = 0 var enterCount2 = 0 var exitCount2 = 0 scene.setContent { Column { Box( modifier = Modifier .pointerMove( onEnter = { enterCount1++ }, onExit = { exitCount1++ } ) .size(10.dp, 10.dp) ) Box( modifier = Modifier .pointerMove( onEnter = { enterCount2++ }, onExit = { exitCount2++ } ) .size(10.dp, 10.dp) ) } } scene.sendPointerEvent(PointerEventType.Enter, Offset(5f, 5f)) assertThat(enterCount1).isEqualTo(1) assertThat(exitCount1).isEqualTo(0) assertThat(enterCount2).isEqualTo(0) assertThat(exitCount2).isEqualTo(0) // Compose doesn't work well if we send an event with different type and different coordinates, without sending move event before it scene.sendPointerEvent(PointerEventType.Scroll, Offset(5f, 15f)) scene.sendPointerEvent(PointerEventType.Move, Offset(5f, 15f)) assertThat(enterCount1).isEqualTo(1) assertThat(exitCount1).isEqualTo(1) assertThat(enterCount2).isEqualTo(1) assertThat(exitCount2).isEqualTo(0) } } ``` Another issue: JetBrains/compose-multiplatform#1480 JetBrains/compose-multiplatform#1324
Make real synthetic move events I encountered code, where users doesn't use Compose events at all. They just listen to awtEvent, and check its type. The feature "send synthetic move event on relayout", which we recently implemented, will break such use case, as synthetic event type would not reflect reality. For example, we would resend the latest native event, which was MousePressed. The application checks it and determines, that that cursor is over some button and presses it. To fix that, the platform should provide a factory-method to create synthetic move events. Desktop. Fix problems with Enter/Exit events Resend events similar to Android: https://android-review.googlesource.com/c/platform/frameworks/support/+/1856856 On desktop we don't change the native event, as we don't know the nature of it, it can be not only MouseEvent in the future. We change only PointerEvent. This can lead to some confusion, if user reads the native event type instead of Compose event type. Fixes JetBrains/compose-multiplatform#523 Fixes JetBrains/compose-multiplatform#594 Manual test: ``` import androidx.compose.foundation.background import androidx.compose.foundation.gestures.scrollBy import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.PointerEventType import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.dp import androidx.compose.ui.window.singleWindowApplication import kotlinx.coroutines.delay @composable private fun App() { val state = rememberScrollState() Column(Modifier.size(300.dp).verticalScroll(state)) { repeat(20) { Item(it) } } } @composable private fun Item(num: Int) { var isHovered by remember { mutableStateOf(false) } Box( Modifier .fillMaxWidth() .height(50.dp) .padding(8.dp) .background(if (isHovered) Color.Red else Color.Green) .pointerInput(Unit) { while (true) { awaitPointerEventScope<Unit> { val event = awaitPointerEvent() when (event.type) { PointerEventType.Enter -> { isHovered = true } PointerEventType.Exit -> { isHovered = false } } } } } ) { Text(num.toString()) } } fun main() { singleWindowApplication { App() } } ``` Change-Id: I667c206bd08568fa0cb78208037c797bb8298702 Test: manual and ./gradlew jvmTest desktopTest -Pandroidx.compose.multiplatformEnabled=true # Conflicts: # compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/input/pointer/PointerEvent.desktop.kt # compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/ComposeScene.skiko.kt Fix losing Exit event on scroll Fixes JetBrains/compose-multiplatform#1324 (comment) Compose doesn't work well if we send an event with different coordinates without sending Move event before it: ``` Column { Box(size=10) Box(size=10) } ``` If we send these events: Move(5,5) -> Scroll(5,15) -> Move(5,15) Then during the scroll event, HitPathTracker forgets about the first Box, and never send Exit to it. Instead it sends the Scroll event to it. We should send events in this order: Move(5,5) -> Move(5,15) -> Scroll(5,15) -> Move(5,15) With synthetic events things more complicated, as we always send events with the same position. I suppose the proper fix should be in Compose core itself, but it would a very huge fix. Reproducer: ``` import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.size import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.ImageComposeScene import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.input.pointer.PointerEventType import androidx.compose.ui.input.pointer.onPointerEvent import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.dp import androidx.compose.ui.use import com.google.common.truth.Truth.assertThat import org.junit.Test import org.junit.runner.RunWith import org.junit.runners.JUnit4 @OptIn(ExperimentalComposeUiApi::class) @RunWith(JUnit4::class) class MouseHoverFilterTest { // bug: JetBrains/compose-multiplatform#1324 (comment) @test fun `move from one component to another, between another non-move event`() = ImageComposeScene( width = 100, height = 100, density = Density(1f) ).use { scene -> var enterCount1 = 0 var exitCount1 = 0 var enterCount2 = 0 var exitCount2 = 0 scene.setContent { Column { Box( modifier = Modifier .pointerMove( onEnter = { enterCount1++ }, onExit = { exitCount1++ } ) .size(10.dp, 10.dp) ) Box( modifier = Modifier .pointerMove( onEnter = { enterCount2++ }, onExit = { exitCount2++ } ) .size(10.dp, 10.dp) ) } } scene.sendPointerEvent(PointerEventType.Enter, Offset(5f, 5f)) assertThat(enterCount1).isEqualTo(1) assertThat(exitCount1).isEqualTo(0) assertThat(enterCount2).isEqualTo(0) assertThat(exitCount2).isEqualTo(0) // Compose doesn't work well if we send an event with different type and different coordinates, without sending move event before it scene.sendPointerEvent(PointerEventType.Scroll, Offset(5f, 15f)) scene.sendPointerEvent(PointerEventType.Move, Offset(5f, 15f)) assertThat(enterCount1).isEqualTo(1) assertThat(exitCount1).isEqualTo(1) assertThat(enterCount2).isEqualTo(1) assertThat(exitCount2).isEqualTo(0) } } ``` Another issue: JetBrains/compose-multiplatform#1480 JetBrains/compose-multiplatform#1324
Make real synthetic move events I encountered code, where users doesn't use Compose events at all. They just listen to awtEvent, and check its type. The feature "send synthetic move event on relayout", which we recently implemented, will break such use case, as synthetic event type would not reflect reality. For example, we would resend the latest native event, which was MousePressed. The application checks it and determines, that that cursor is over some button and presses it. To fix that, the platform should provide a factory-method to create synthetic move events. Desktop. Fix problems with Enter/Exit events Resend events similar to Android: https://android-review.googlesource.com/c/platform/frameworks/support/+/1856856 On desktop we don't change the native event, as we don't know the nature of it, it can be not only MouseEvent in the future. We change only PointerEvent. This can lead to some confusion, if user reads the native event type instead of Compose event type. Fixes JetBrains/compose-multiplatform#523 Fixes JetBrains/compose-multiplatform#594 Manual test: ``` import androidx.compose.foundation.background import androidx.compose.foundation.gestures.scrollBy import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.PointerEventType import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.dp import androidx.compose.ui.window.singleWindowApplication import kotlinx.coroutines.delay @composable private fun App() { val state = rememberScrollState() Column(Modifier.size(300.dp).verticalScroll(state)) { repeat(20) { Item(it) } } } @composable private fun Item(num: Int) { var isHovered by remember { mutableStateOf(false) } Box( Modifier .fillMaxWidth() .height(50.dp) .padding(8.dp) .background(if (isHovered) Color.Red else Color.Green) .pointerInput(Unit) { while (true) { awaitPointerEventScope<Unit> { val event = awaitPointerEvent() when (event.type) { PointerEventType.Enter -> { isHovered = true } PointerEventType.Exit -> { isHovered = false } } } } } ) { Text(num.toString()) } } fun main() { singleWindowApplication { App() } } ``` Change-Id: I667c206bd08568fa0cb78208037c797bb8298702 Test: manual and ./gradlew jvmTest desktopTest -Pandroidx.compose.multiplatformEnabled=true # Conflicts: # compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/input/pointer/PointerEvent.desktop.kt # compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/ComposeScene.skiko.kt Fix losing Exit event on scroll Fixes JetBrains/compose-multiplatform#1324 (comment) Compose doesn't work well if we send an event with different coordinates without sending Move event before it: ``` Column { Box(size=10) Box(size=10) } ``` If we send these events: Move(5,5) -> Scroll(5,15) -> Move(5,15) Then during the scroll event, HitPathTracker forgets about the first Box, and never send Exit to it. Instead it sends the Scroll event to it. We should send events in this order: Move(5,5) -> Move(5,15) -> Scroll(5,15) -> Move(5,15) With synthetic events things more complicated, as we always send events with the same position. I suppose the proper fix should be in Compose core itself, but it would a very huge fix. Reproducer: ``` import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.size import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.ImageComposeScene import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.input.pointer.PointerEventType import androidx.compose.ui.input.pointer.onPointerEvent import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.dp import androidx.compose.ui.use import com.google.common.truth.Truth.assertThat import org.junit.Test import org.junit.runner.RunWith import org.junit.runners.JUnit4 @OptIn(ExperimentalComposeUiApi::class) @RunWith(JUnit4::class) class MouseHoverFilterTest { // bug: JetBrains/compose-multiplatform#1324 (comment) @test fun `move from one component to another, between another non-move event`() = ImageComposeScene( width = 100, height = 100, density = Density(1f) ).use { scene -> var enterCount1 = 0 var exitCount1 = 0 var enterCount2 = 0 var exitCount2 = 0 scene.setContent { Column { Box( modifier = Modifier .pointerMove( onEnter = { enterCount1++ }, onExit = { exitCount1++ } ) .size(10.dp, 10.dp) ) Box( modifier = Modifier .pointerMove( onEnter = { enterCount2++ }, onExit = { exitCount2++ } ) .size(10.dp, 10.dp) ) } } scene.sendPointerEvent(PointerEventType.Enter, Offset(5f, 5f)) assertThat(enterCount1).isEqualTo(1) assertThat(exitCount1).isEqualTo(0) assertThat(enterCount2).isEqualTo(0) assertThat(exitCount2).isEqualTo(0) // Compose doesn't work well if we send an event with different type and different coordinates, without sending move event before it scene.sendPointerEvent(PointerEventType.Scroll, Offset(5f, 15f)) scene.sendPointerEvent(PointerEventType.Move, Offset(5f, 15f)) assertThat(enterCount1).isEqualTo(1) assertThat(exitCount1).isEqualTo(1) assertThat(enterCount2).isEqualTo(1) assertThat(exitCount2).isEqualTo(0) } } ``` Another issue: JetBrains/compose-multiplatform#1480 JetBrains/compose-multiplatform#1324
Make real synthetic move events I encountered code, where users doesn't use Compose events at all. They just listen to awtEvent, and check its type. The feature "send synthetic move event on relayout", which we recently implemented, will break such use case, as synthetic event type would not reflect reality. For example, we would resend the latest native event, which was MousePressed. The application checks it and determines, that that cursor is over some button and presses it. To fix that, the platform should provide a factory-method to create synthetic move events. Desktop. Fix problems with Enter/Exit events Resend events similar to Android: https://android-review.googlesource.com/c/platform/frameworks/support/+/1856856 On desktop we don't change the native event, as we don't know the nature of it, it can be not only MouseEvent in the future. We change only PointerEvent. This can lead to some confusion, if user reads the native event type instead of Compose event type. Fixes JetBrains/compose-multiplatform#523 Fixes JetBrains/compose-multiplatform#594 Manual test: ``` import androidx.compose.foundation.background import androidx.compose.foundation.gestures.scrollBy import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.PointerEventType import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.dp import androidx.compose.ui.window.singleWindowApplication import kotlinx.coroutines.delay @composable private fun App() { val state = rememberScrollState() Column(Modifier.size(300.dp).verticalScroll(state)) { repeat(20) { Item(it) } } } @composable private fun Item(num: Int) { var isHovered by remember { mutableStateOf(false) } Box( Modifier .fillMaxWidth() .height(50.dp) .padding(8.dp) .background(if (isHovered) Color.Red else Color.Green) .pointerInput(Unit) { while (true) { awaitPointerEventScope<Unit> { val event = awaitPointerEvent() when (event.type) { PointerEventType.Enter -> { isHovered = true } PointerEventType.Exit -> { isHovered = false } } } } } ) { Text(num.toString()) } } fun main() { singleWindowApplication { App() } } ``` Change-Id: I667c206bd08568fa0cb78208037c797bb8298702 Test: manual and ./gradlew jvmTest desktopTest -Pandroidx.compose.multiplatformEnabled=true # Conflicts: # compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/input/pointer/PointerEvent.desktop.kt # compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/ComposeScene.skiko.kt Fix losing Exit event on scroll Fixes JetBrains/compose-multiplatform#1324 (comment) Compose doesn't work well if we send an event with different coordinates without sending Move event before it: ``` Column { Box(size=10) Box(size=10) } ``` If we send these events: Move(5,5) -> Scroll(5,15) -> Move(5,15) Then during the scroll event, HitPathTracker forgets about the first Box, and never send Exit to it. Instead it sends the Scroll event to it. We should send events in this order: Move(5,5) -> Move(5,15) -> Scroll(5,15) -> Move(5,15) With synthetic events things more complicated, as we always send events with the same position. I suppose the proper fix should be in Compose core itself, but it would a very huge fix. Reproducer: ``` import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.size import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.ImageComposeScene import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.input.pointer.PointerEventType import androidx.compose.ui.input.pointer.onPointerEvent import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.dp import androidx.compose.ui.use import com.google.common.truth.Truth.assertThat import org.junit.Test import org.junit.runner.RunWith import org.junit.runners.JUnit4 @OptIn(ExperimentalComposeUiApi::class) @RunWith(JUnit4::class) class MouseHoverFilterTest { // bug: JetBrains/compose-multiplatform#1324 (comment) @test fun `move from one component to another, between another non-move event`() = ImageComposeScene( width = 100, height = 100, density = Density(1f) ).use { scene -> var enterCount1 = 0 var exitCount1 = 0 var enterCount2 = 0 var exitCount2 = 0 scene.setContent { Column { Box( modifier = Modifier .pointerMove( onEnter = { enterCount1++ }, onExit = { exitCount1++ } ) .size(10.dp, 10.dp) ) Box( modifier = Modifier .pointerMove( onEnter = { enterCount2++ }, onExit = { exitCount2++ } ) .size(10.dp, 10.dp) ) } } scene.sendPointerEvent(PointerEventType.Enter, Offset(5f, 5f)) assertThat(enterCount1).isEqualTo(1) assertThat(exitCount1).isEqualTo(0) assertThat(enterCount2).isEqualTo(0) assertThat(exitCount2).isEqualTo(0) // Compose doesn't work well if we send an event with different type and different coordinates, without sending move event before it scene.sendPointerEvent(PointerEventType.Scroll, Offset(5f, 15f)) scene.sendPointerEvent(PointerEventType.Move, Offset(5f, 15f)) assertThat(enterCount1).isEqualTo(1) assertThat(exitCount1).isEqualTo(1) assertThat(enterCount2).isEqualTo(1) assertThat(exitCount2).isEqualTo(0) } } ``` Another issue: JetBrains/compose-multiplatform#1480 https: //github.com/JetBrains/compose-multiplatform/issues/1324 Change-Id: I3bba1a9878626a3a8bd52d86671e3260229425fa
It appears, it isn't completely fixed in 1.0.0. The new found cases should be fixed in 1.1.0 |
1. Don't send native event when we send synthetic events 2. Fix losing Exit event on scroll (JetBrains/compose-multiplatform#1324) The current HitPathTracker algorithm doesn't work well if we miss Move event before non-Move event with a different position (see example in PointerPositionUpdater) Change-Id: I3bba1a9878626a3a8bd52d86671e3260229425fa Test: ./gradlew jvmTest desktopTest -Pandroidx.compose.multiplatformEnabled=true
1. Don't send native event when we send synthetic events 2. Fix losing Exit event on scroll (JetBrains/compose-multiplatform#1324) The current HitPathTracker algorithm doesn't work well if we miss Move event before non-Move event with a different position (see example in PointerPositionUpdater) Change-Id: I3bba1a9878626a3a8bd52d86671e3260229425fa Test: ./gradlew jvmTest desktopTest -Pandroidx.compose.multiplatformEnabled=true
1. Don't send native event when we send synthetic events 2. Fix losing Exit event on scroll (JetBrains/compose-multiplatform#1324) The current HitPathTracker algorithm doesn't work well if we miss Move event before non-Move event with a different position (see example in PointerPositionUpdater) Change-Id: I3bba1a9878626a3a8bd52d86671e3260229425fa Test: ./gradlew jvmTest desktopTest -Pandroidx.compose.multiplatformEnabled=true
1. Don't send native event when we send synthetic events 2. Fix losing Exit event on scroll (JetBrains/compose-multiplatform#1324) The current HitPathTracker algorithm doesn't work well if we miss Move event before non-Move event with a different position (see example in PointerPositionUpdater) Change-Id: I3bba1a9878626a3a8bd52d86671e3260229425fa Test: ./gradlew jvmTest desktopTest -Pandroidx.compose.multiplatformEnabled=true
1. Don't send native event when we send synthetic events 2. Fix losing Exit event on scroll (JetBrains/compose-multiplatform#1324) The current HitPathTracker algorithm doesn't work well if we miss Move event before non-Move event with a different position (see example in PointerPositionUpdater) Change-Id: I3bba1a9878626a3a8bd52d86671e3260229425fa Test: ./gradlew jvmTest desktopTest -Pandroidx.compose.multiplatformEnabled=true
1. Don't send native event when we send synthetic events 2. Fix losing Exit event on scroll (JetBrains/compose-multiplatform#1324) The current HitPathTracker algorithm doesn't work well if we miss Move event before non-Move event with a different position (see example in PointerPositionUpdater) Change-Id: I3bba1a9878626a3a8bd52d86671e3260229425fa Test: ./gradlew jvmTest desktopTest -Pandroidx.compose.multiplatformEnabled=true
1. Don't send native event when we send synthetic events 2. Fix losing Exit event on scroll (JetBrains/compose-multiplatform#1324) The current HitPathTracker algorithm doesn't work well if we miss Move event before non-Move event with a different position (see example in PointerPositionUpdater) Change-Id: I3bba1a9878626a3a8bd52d86671e3260229425fa Test: ./gradlew jvmTest desktopTest -Pandroidx.compose.multiplatformEnabled=true
1. Don't send native event when we send synthetic events 2. Fix losing Exit event on scroll (JetBrains/compose-multiplatform#1324) The current HitPathTracker algorithm doesn't work well if we miss Move event before non-Move event with a different position (see example in PointerPositionUpdater) Change-Id: I3bba1a9878626a3a8bd52d86671e3260229425fa Test: ./gradlew jvmTest desktopTest -Pandroidx.compose.multiplatformEnabled=true
1. Don't send native event when we send synthetic events 2. Fix losing Exit event on scroll (JetBrains/compose-multiplatform#1324) The current HitPathTracker algorithm doesn't work well if we miss Move event before non-Move event with a different position (see example in PointerPositionUpdater) Change-Id: I3bba1a9878626a3a8bd52d86671e3260229425fa Test: ./gradlew jvmTest desktopTest -Pandroidx.compose.multiplatformEnabled=true
1. Don't send native event when we send synthetic events 2. Fix losing Exit event on scroll (JetBrains/compose-multiplatform#1324) The current HitPathTracker algorithm doesn't work well if we miss Move event before non-Move event with a different position (see example in PointerPositionUpdater) Change-Id: I3bba1a9878626a3a8bd52d86671e3260229425fa Test: ./gradlew jvmTest desktopTest -Pandroidx.compose.multiplatformEnabled=true
1. Don't send native event when we send synthetic events 2. Fix losing Exit event on scroll (JetBrains/compose-multiplatform#1324) The current HitPathTracker algorithm doesn't work well if we miss Move event before non-Move event with a different position (see example in PointerPositionUpdater) Change-Id: I3bba1a9878626a3a8bd52d86671e3260229425fa Test: ./gradlew jvmTest desktopTest -Pandroidx.compose.multiplatformEnabled=true
1. Don't send native event when we send synthetic events 2. Fix losing Exit event on scroll (JetBrains#1324) The current HitPathTracker algorithm doesn't work well if we miss Move event before non-Move event with a different position (see example in PointerPositionUpdater) Change-Id: I3bba1a9878626a3a8bd52d86671e3260229425fa Test: ./gradlew jvmTest desktopTest -Pandroidx.compose.multiplatformEnabled=true
Please check the following ticket on YouTrack for follow-ups to this issue. GitHub issues will be closed in the coming weeks. |
Seems like sometimes the hover exit is not working as expected, as the item is still marked as if it was still being hovered.
Code to reproduce it:
GIF showing the problem where I hover the list's items and sometimes some of them keep being hovered when the mouse is not on top them:
OS: Ubuntu 20.04 & Manjaro with KDE
Compose Desktop Version: 1.0.0-beta5 (started happening since the first version that included the hovering)
The text was updated successfully, but these errors were encountered: