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
Refactor synthetic move event sending #456
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -65,6 +65,7 @@ class ComposeSceneInputTest { | |
overlappedPopup.Content() | ||
independentPopup.Content() | ||
} | ||
scene.render() // Popup has 2-frame layout passes. Call it to avoid synthetic events | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. My solution (below in There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Your solution is better, rewrote to it. |
||
|
||
scene.sendPointerEvent(PointerEventType.Enter, Offset(-10f, -10f)) | ||
background.events.assertReceivedNoEvents() | ||
|
@@ -258,7 +259,6 @@ class ComposeSceneInputTest { | |
independentPopup.events.assertReceivedNoEvents() | ||
} | ||
|
||
|
||
@Test | ||
fun scroll() = ImageComposeScene(100, 100).use { scene -> | ||
val background = FillBox() | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -21,8 +21,13 @@ import androidx.compose.foundation.layout.fillMaxSize | |
import androidx.compose.foundation.layout.requiredSize | ||
import androidx.compose.runtime.Composable | ||
import androidx.compose.ui.geometry.Offset | ||
import androidx.compose.ui.input.pointer.PointerButtons | ||
import androidx.compose.ui.input.pointer.PointerEvent | ||
import androidx.compose.ui.input.pointer.PointerEventType | ||
import androidx.compose.ui.input.pointer.PointerId | ||
import androidx.compose.ui.input.pointer.PointerInputEvent | ||
import androidx.compose.ui.input.pointer.PointerInputEventData | ||
import androidx.compose.ui.input.pointer.PointerType | ||
import androidx.compose.ui.input.pointer.pointerInput | ||
import androidx.compose.ui.platform.LocalDensity | ||
import androidx.compose.ui.unit.IntRect | ||
|
@@ -32,6 +37,7 @@ import androidx.compose.ui.unit.toOffset | |
import androidx.compose.ui.window.Popup | ||
import androidx.compose.ui.window.PopupPositionProvider | ||
import com.google.common.truth.Truth.assertThat | ||
import kotlin.test.assertContentEquals | ||
|
||
fun Events.assertReceivedNoEvents() = assertThat(list).isEmpty() | ||
|
||
|
@@ -120,4 +126,52 @@ fun Modifier.collectEvents(events: Events) = pointerInput(Unit) { | |
events.add(awaitPointerEvent()) | ||
} | ||
} | ||
} | ||
|
||
@OptIn(ExperimentalComposeUiApi::class) | ||
internal fun mouseEvent( | ||
type: PointerEventType, | ||
x: Float, | ||
y: Float, | ||
pressed: Boolean | ||
) = PointerInputEvent( | ||
type, | ||
0, | ||
listOf( | ||
PointerInputEventData( | ||
id = PointerId(0), | ||
uptime = 0, | ||
Offset(x, y), | ||
Offset(x, y), | ||
down = pressed, | ||
pressure = 1f, | ||
type = PointerType.Mouse, | ||
scrollDelta = Offset.Zero | ||
) | ||
), | ||
buttons = PointerButtons(isPrimaryPressed = pressed) | ||
) | ||
|
||
internal infix fun List<PointerInputEvent>.shouldEqual(expected: List<PointerInputEvent>) { | ||
assertContentEquals(expected.toList().map { it.formatEssential() }, toList().map { it.formatEssential() }) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why is the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Removed it. It is added by mistake. |
||
} | ||
|
||
internal fun PointerInputEvent.formatEssential(): String { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Some of the fields are not used here (buttons, keyboardModifiers etc.). If that's intentional, it should be documented in There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Done |
||
val pointers = if (pointers.size == 1) { | ||
pointers.first().formatEssential() | ||
} else { | ||
pointers.joinToString(" ") { | ||
val id = it.id.value | ||
val data = it.formatEssential() | ||
"$id-$data" | ||
} | ||
} | ||
return "$eventType $pointers" | ||
} | ||
|
||
internal fun PointerInputEventData.formatEssential(): String { | ||
val x = position.x.toInt() | ||
val y = position.y.toInt() | ||
val down = if (down) "down" else "up" | ||
return "$x:$y:$down" | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,88 @@ | ||
/* | ||
* Copyright 2023 The Android Open Source Project | ||
* | ||
* Licensed under the Apache License, Version 2.0 (the "License"); | ||
* you may not use this file except in compliance with the License. | ||
* You may obtain a copy of the License at | ||
* | ||
* http://www.apache.org/licenses/LICENSE-2.0 | ||
* | ||
* Unless required by applicable law or agreed to in writing, software | ||
* distributed under the License is distributed on an "AS IS" BASIS, | ||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
* See the License for the specific language governing permissions and | ||
* limitations under the License. | ||
*/ | ||
|
||
package androidx.compose.ui | ||
|
||
import androidx.compose.ui.input.pointer.PointerEventType.Companion.Enter | ||
import androidx.compose.ui.input.pointer.PointerEventType.Companion.Exit | ||
import androidx.compose.ui.input.pointer.PointerEventType.Companion.Move | ||
import androidx.compose.ui.input.pointer.PointerEventType.Companion.Press | ||
import androidx.compose.ui.input.pointer.PointerEventType.Companion.Release | ||
import androidx.compose.ui.input.pointer.PointerInputEvent | ||
import kotlin.test.Test | ||
|
||
class SyntheticEventSenderTest { | ||
@Test | ||
fun `mouse, shouldn't generate new events if order is correct`() { | ||
syntheticEvents( | ||
mouseEvent(Enter, 10f, 20f, pressed = false), | ||
mouseEvent(Press, 10f, 20f, pressed = true), | ||
mouseEvent(Move, 10f, 30f, pressed = true), | ||
mouseEvent(Release, 10f, 30f, pressed = false), | ||
mouseEvent(Move, 10f, 40f, pressed = false), | ||
mouseEvent(Press, 10f, 40f, pressed = true), | ||
mouseEvent(Release, 10f, 40f, pressed = false), | ||
mouseEvent(Exit, -1f, -1f, pressed = false), | ||
) shouldEqual listOf( | ||
mouseEvent(Enter, 10f, 20f, pressed = false), | ||
mouseEvent(Press, 10f, 20f, pressed = true), | ||
mouseEvent(Move, 10f, 30f, pressed = true), | ||
mouseEvent(Release, 10f, 30f, pressed = false), | ||
mouseEvent(Move, 10f, 40f, pressed = false), | ||
mouseEvent(Press, 10f, 40f, pressed = true), | ||
mouseEvent(Release, 10f, 40f, pressed = false), | ||
mouseEvent(Exit, -1f, -1f, pressed = false), | ||
) | ||
} | ||
|
||
@Test | ||
fun `mouse, should generate new move before non-move if position isn't the same`() { | ||
syntheticEvents( | ||
mouseEvent(Enter, 10f, 20f, pressed = false), | ||
mouseEvent(Press, 10f, 25f, pressed = true), | ||
mouseEvent(Move, 10f, 30f, pressed = true), | ||
mouseEvent(Release, 10f, 35f, pressed = false), | ||
mouseEvent(Move, 10f, 40f, pressed = false), | ||
mouseEvent(Press, 10f, 45f, pressed = true), | ||
mouseEvent(Release, 10f, 50f, pressed = false), | ||
mouseEvent(Exit, -1f, -1f, pressed = false), | ||
) shouldEqual listOf( | ||
mouseEvent(Enter, 10f, 20f, pressed = false), | ||
mouseEvent(Move, 10f, 25f, pressed = false), | ||
mouseEvent(Press, 10f, 25f, pressed = true), | ||
mouseEvent(Move, 10f, 30f, pressed = true), | ||
mouseEvent(Move, 10f, 35f, pressed = true), | ||
mouseEvent(Release, 10f, 35f, pressed = false), | ||
mouseEvent(Move, 10f, 40f, pressed = false), | ||
mouseEvent(Move, 10f, 45f, pressed = false), | ||
mouseEvent(Press, 10f, 45f, pressed = true), | ||
mouseEvent(Move, 10f, 50f, pressed = true), | ||
mouseEvent(Release, 10f, 50f, pressed = false), | ||
mouseEvent(Exit, -1f, -1f, pressed = false), | ||
) | ||
} | ||
|
||
private fun syntheticEvents( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Maybe call this There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes, it is better. Renamed it. |
||
vararg inputEvents: PointerInputEvent | ||
): List<PointerInputEvent> { | ||
val received = mutableListOf<PointerInputEvent>() | ||
val sender = SyntheticEventSender(received::add) | ||
for (inputEvent in inputEvents) { | ||
sender.send(inputEvent) | ||
} | ||
return received | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -93,10 +93,7 @@ class ComposeScene internal constructor( | |
coroutineContext: CoroutineContext = Dispatchers.Unconfined, | ||
internal val platform: Platform, | ||
density: Density = Density(1f), | ||
private val invalidate: () -> Unit = {}, | ||
@Deprecated("Will be removed in Compose 1.3") | ||
internal val createSyntheticNativeMoveEvent: | ||
(sourceEvent: Any?, positionSourceEvent: Any?) -> Any? = { _, _ -> null } | ||
private val invalidate: () -> Unit = {} | ||
) { | ||
/** | ||
* Constructs [ComposeScene] | ||
|
@@ -215,7 +212,10 @@ class ComposeScene internal constructor( | |
|
||
private val recomposer = Recomposer(coroutineContext + job + effectDispatcher) | ||
|
||
internal val pointerPositionUpdater = PointerPositionUpdater(::invalidateIfNeeded, ::sendAsMove) | ||
private val syntheticEventSender = SyntheticEventSender(::processPointerInput) | ||
internal val pointerPositionUpdater = PointerPositionUpdater( | ||
::invalidateIfNeeded, syntheticEventSender | ||
) | ||
|
||
internal var mainOwner: SkiaBasedOwner? = null | ||
private var composition: Composition? = null | ||
|
@@ -354,6 +354,7 @@ class ComposeScene internal constructor( | |
content: @Composable () -> Unit | ||
) { | ||
check(!isClosed) { "ComposeScene is closed" } | ||
syntheticEventSender.reset() | ||
pointerPositionUpdater.reset() | ||
composition?.dispose() | ||
mainOwner?.dispose() | ||
|
@@ -442,7 +443,7 @@ class ComposeScene internal constructor( | |
* is platform-dependent. | ||
* @param type The device type that produced the event, such as [mouse][PointerType.Mouse], | ||
* or [touch][PointerType.Touch]. | ||
* @param buttons Contains the state of pointer buttons (e.g. mouse and stylus buttons). | ||
* @param buttons Contains the state of pointer buttons (e.g. mouse and stylus buttons) after the event. | ||
* @param keyboardModifiers Contains the state of modifier keys, such as Shift, Control, | ||
* and Alt, as well as the state of the lock keys, such as Caps Lock and Num Lock. | ||
* @param nativeEvent The original native event. | ||
|
@@ -479,8 +480,8 @@ class ComposeScene internal constructor( | |
) | ||
needLayout = false | ||
forEachOwner { it.measureAndLayout() } | ||
pointerPositionUpdater.beforeEvent(event) | ||
processPointerInput(event) | ||
pointerPositionUpdater.update() | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Here the difference with the previous code. In case of relayout, we send always one additional move that is based on the last Move event, not on the current. Even if after that we send one more Move (that was passed to this function). It was a missing event without which we can have some inconsistent behavior. Imagine a game that has a square that jumps to random position after we hover it:
The example is very synthtic thought, and in reality we don't need this. But besides that, I remember that Compose nodes don't work well if we send a different Move after the node changes its position (it is probably a bug of Compose node), so I would keep it just as a precaution. |
||
syntheticEventSender.send(event) | ||
updatePointerPositions(event) | ||
} | ||
|
||
|
@@ -507,16 +508,6 @@ class ComposeScene internal constructor( | |
} | ||
} | ||
|
||
@Suppress("DEPRECATION") | ||
private fun sendAsMove(sourceEvent: PointerInputEvent, positionSourceEvent: PointerInputEvent) { | ||
val nativeEvent = createSyntheticNativeMoveEvent( | ||
sourceEvent.nativeEvent, | ||
positionSourceEvent.nativeEvent | ||
) | ||
processPointerInput(createMoveEvent(nativeEvent, sourceEvent, positionSourceEvent)) | ||
} | ||
|
||
@OptIn(ExperimentalComposeUiApi::class) | ||
private fun processPointerInput(event: PointerInputEvent) { | ||
when (event.eventType) { | ||
PointerEventType.Press -> processPress(event) | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
"re-layout"
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done