Skip to content

Commit 06376d4

Browse files
committed
Redispatch unconsumed mouse wheel events
1 parent 2f5787a commit 06376d4

File tree

3 files changed

+186
-17
lines changed

3 files changed

+186
-17
lines changed

compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.desktop.kt

Lines changed: 66 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -381,7 +381,7 @@ internal class ComposeSceneMediator(
381381

382382
contentComponent.focusTraversalKeysEnabled = false
383383

384-
subscribe(contentComponent)
384+
subscribeToInputEvents()
385385
}
386386

387387
private inline fun catchExceptions(block: () -> Unit) {
@@ -399,18 +399,22 @@ internal class ComposeSceneMediator(
399399
}
400400
}
401401

402-
private fun subscribe(component: Component) {
403-
component.addInputMethodListener(inputMethodListener)
404-
component.addFocusListener(focusListener)
405-
component.addKeyListener(keyListener)
406-
component.subscribeToMouseEvents(mouseListener)
402+
private fun subscribeToInputEvents() {
403+
with(contentComponent) {
404+
addInputMethodListener(inputMethodListener)
405+
addFocusListener(focusListener)
406+
addKeyListener(keyListener)
407+
subscribeToMouseEvents(mouseListener)
408+
}
407409
}
408410

409-
private fun unsubscribe(component: Component) {
410-
component.removeInputMethodListener(inputMethodListener)
411-
component.removeFocusListener(focusListener)
412-
component.removeKeyListener(keyListener)
413-
component.unsubscribeFromMouseEvents(mouseListener)
411+
private fun unsubscribeFromInputEvents() {
412+
with(contentComponent) {
413+
removeInputMethodListener(inputMethodListener)
414+
removeFocusListener(focusListener)
415+
removeKeyListener(keyListener)
416+
unsubscribeFromMouseEvents(mouseListener)
417+
}
414418
}
415419

416420
private var isMouseEventProcessing = false
@@ -464,8 +468,55 @@ internal class ComposeSceneMediator(
464468
if (eventListener.onMouseEvent(event)) {
465469
return
466470
}
471+
467472
processMouseEvent {
468-
scene.onMouseWheelEvent(event.position, event)
473+
val processingResult = scene.onMouseWheelEvent(event.position, event)
474+
if (!processingResult.anyChangeConsumed) {
475+
redispatchUnconsumedMouseEvent(event)
476+
}
477+
}
478+
}
479+
480+
/**
481+
* Returns the first heavyweight ancestor of the given component.
482+
*/
483+
private fun Component.heavyWeightAncestorOrNull() : Component? {
484+
var parent = parent
485+
while (parent != null) {
486+
if (!parent.isLightweight) return parent
487+
parent = parent.parent
488+
}
489+
return null
490+
}
491+
492+
/**
493+
* (Re)Dispatches the given mouse event to the component that would have received it had
494+
* this [ComposeSceneMediator] not been listening to the corresponding type of mouse events.
495+
*
496+
* The problem this attempts to solve is that [ComposeSceneMediator] has to register listeners
497+
* for all types of mouse events, even if there is nothing in the scene that listens to them.
498+
* AWT/Swing, however, interprets listening to mouse events as "interest" in them and sends them
499+
* only to the "interested" component "under" the mouse pointer.
500+
*/
501+
private fun redispatchUnconsumedMouseEvent(event: MouseEvent) {
502+
// Redispatch the event to the heavyweight ancestor, which in turn will try to find the
503+
// correct target component and send the event to it. Unregistering from mouse events
504+
// during this call allows the event to be sent to the component it would've been sent to
505+
// if ComposeSceneMediator wasn't listening to the corresponding type of mouse events.
506+
//
507+
// This is possibly a dangerous hack. If it breaks something, the alternative is to dispatch
508+
// only to the parent of the source. This isn't ideal because the "right" component may not
509+
// be the parent/ancestor, but a sibling of the source component.
510+
// With that approach, there would probably also be a need to add a flag (or multiple flags)
511+
// to ComposePanel that would control which types of events should be listened to.
512+
val source = event.component ?: return // Should be contentComponent
513+
val target = source.heavyWeightAncestorOrNull() ?: return
514+
try {
515+
unsubscribeFromInputEvents()
516+
val retargetedEvent = SwingUtilities.convertMouseEvent(source, event, target)
517+
target.dispatchEvent(retargetedEvent)
518+
} finally {
519+
subscribeToInputEvents()
469520
}
470521
}
471522

@@ -493,7 +544,7 @@ internal class ComposeSceneMediator(
493544
check(!isDisposed) { "ComposeSceneMediator is already disposed" }
494545
isDisposed = true
495546

496-
unsubscribe(contentComponent)
547+
unsubscribeFromInputEvents()
497548

498549
container.remove(contentComponent)
499550
container.remove(invisibleComponent)
@@ -829,8 +880,8 @@ internal val MouseEvent.composePointerButton: PointerButton? get() {
829880
private fun ComposeScene.onMouseWheelEvent(
830881
position: Offset,
831882
event: MouseWheelEvent
832-
) {
833-
sendPointerEvent(
883+
) : PointerEventResult {
884+
return sendPointerEvent(
834885
eventType = PointerEventType.Scroll,
835886
position = position,
836887
scrollDelta = if (event.isShiftDown) {

compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/TestUtils.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -186,8 +186,8 @@ fun Container.sendMouseEvent(
186186
}
187187

188188
fun Container.sendMouseWheelEvent(
189-
x: Int,
190-
y: Int,
189+
x: Int = width / 2,
190+
y: Int = height / 2,
191191
scrollType: Int = MouseWheelEvent.WHEEL_UNIT_SCROLL,
192192
wheelRotation: Double = 0.0,
193193
modifiers: Int = 0,

compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/awt/ComposePanelTest.kt

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,19 @@
1515
*/
1616
package androidx.compose.ui.awt
1717

18+
import androidx.compose.foundation.ScrollState
1819
import androidx.compose.foundation.focusable
1920
import androidx.compose.foundation.layout.Box
21+
import androidx.compose.foundation.layout.Column
2022
import androidx.compose.foundation.layout.fillMaxSize
23+
import androidx.compose.foundation.layout.fillMaxWidth
24+
import androidx.compose.foundation.layout.height
2125
import androidx.compose.foundation.layout.requiredSize
2226
import androidx.compose.foundation.layout.size
2327
import androidx.compose.foundation.layout.sizeIn
2428
import androidx.compose.foundation.lazy.LazyColumn
2529
import androidx.compose.foundation.text.input.rememberTextFieldState
30+
import androidx.compose.foundation.verticalScroll
2631
import androidx.compose.material.Text
2732
import androidx.compose.material3.TextField
2833
import androidx.compose.runtime.Composable
@@ -51,6 +56,7 @@ import androidx.compose.ui.layout.onGloballyPositioned
5156
import androidx.compose.ui.sendCharTypedEvents
5257
import androidx.compose.ui.sendKeyEvent
5358
import androidx.compose.ui.sendMouseEvent
59+
import androidx.compose.ui.sendMouseWheelEvent
5460
import androidx.compose.ui.unit.Constraints
5561
import androidx.compose.ui.unit.dp
5662
import androidx.compose.ui.unit.toSize
@@ -65,8 +71,11 @@ import java.awt.BorderLayout
6571
import java.awt.Dimension
6672
import java.awt.GraphicsEnvironment
6773
import java.awt.event.MouseEvent
74+
import javax.swing.BoxLayout
6875
import javax.swing.JFrame
6976
import javax.swing.JPanel
77+
import javax.swing.JScrollPane
78+
import javax.swing.ScrollPaneConstants
7079
import junit.framework.TestCase.assertTrue
7180
import kotlin.test.assertEquals
7281
import kotlin.test.assertFalse
@@ -713,4 +722,113 @@ class ComposePanelTest {
713722
}
714723
}
715724

725+
@Test
726+
fun `ComposePanel propagates unconsumed mouse wheel scroll events to parent`() = runApplicationTest {
727+
val composePanel = ComposePanel()
728+
composePanel.preferredSize = Dimension(200, 200)
729+
val scrollState = ScrollState(0)
730+
composePanel.setContent {
731+
Box(Modifier.size(200.dp).verticalScroll(scrollState).background(Color.Yellow)) {
732+
Column(Modifier.fillMaxWidth().height(400.dp)) {
733+
Text("Hello World")
734+
Text("Hello World")
735+
Text("Hello World")
736+
Text("Hello World")
737+
Text("Hello World")
738+
}
739+
}
740+
}
741+
742+
val window = JFrame()
743+
try {
744+
window.size = Dimension(200, 200)
745+
val scrollPane = JScrollPane(
746+
JPanel().apply {
747+
layout = BoxLayout(this, BoxLayout.Y_AXIS)
748+
add(composePanel)
749+
add(javax.swing.Box.createVerticalStrut(1000), BorderLayout.CENTER)
750+
}
751+
)
752+
scrollPane.horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER
753+
window.contentPane.add(scrollPane, BorderLayout.CENTER)
754+
window.isVisible = true
755+
756+
awaitIdle()
757+
758+
// Scroll a little and check that compose content was scrolled
759+
composePanel.sendMouseWheelEvent(wheelRotation = 1.0)
760+
awaitIdle()
761+
assertThat(scrollState.value).isGreaterThan(0)
762+
763+
// Scroll a lot and check that the Swing JScrollPane was scrolled
764+
// Note that we need two scroll events for now because Compose can't partially consume
765+
// scroll events. So one event is needed to scroll Compose content to the end, and
766+
// another one to scroll JScrollPane.
767+
window.sendMouseWheelEvent(wheelRotation = 1000.0)
768+
awaitIdle()
769+
window.sendMouseWheelEvent(wheelRotation = 1000.0)
770+
assertThat(scrollPane.viewport.viewPosition.y).isGreaterThan(0)
771+
} finally {
772+
window.dispose()
773+
}
774+
}
775+
776+
@Test
777+
fun `ComposePanel propagates unconsumed mouse wheel scroll events to sibling`() = runApplicationTest {
778+
val composePanel = ComposePanel()
779+
val scrollState = ScrollState(0)
780+
composePanel.setContent {
781+
Box(Modifier.size(200.dp).verticalScroll(scrollState).background(Color.Green)) {
782+
Column(Modifier.fillMaxWidth().height(400.dp)) {
783+
Text("Hello World")
784+
Text("Hello World")
785+
Text("Hello World")
786+
Text("Hello World")
787+
Text("Hello World")
788+
}
789+
}
790+
}
791+
792+
val container = JPanel(null)
793+
container.size = Dimension(200, 200)
794+
795+
val scrollPane = JScrollPane(
796+
JPanel().apply {
797+
layout = BoxLayout(this, BoxLayout.Y_AXIS)
798+
add(javax.swing.Box.createVerticalStrut(1000), BorderLayout.CENTER)
799+
}
800+
)
801+
scrollPane.horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER
802+
803+
composePanel.size = Dimension(200, 200)
804+
scrollPane.size = Dimension(200, 400)
805+
806+
val window = JFrame()
807+
try {
808+
window.size = Dimension(200, 400)
809+
container.add(composePanel)
810+
container.add(scrollPane)
811+
812+
window.contentPane.add(container, BorderLayout.CENTER)
813+
window.isVisible = true
814+
815+
awaitIdle()
816+
817+
// Scroll a little and check that compose content was scrolled
818+
composePanel.sendMouseWheelEvent(wheelRotation = 1.0)
819+
awaitIdle()
820+
assertThat(scrollState.value).isGreaterThan(0)
821+
822+
// Scroll a lot and check that the Swing JScrollPane was scrolled
823+
// Note that we need two scroll events for now because Compose can't partially consume
824+
// scroll events. So one event is needed to scroll Compose content to the end, and
825+
// another one to scroll JScrollPane.
826+
composePanel.sendMouseWheelEvent(wheelRotation = 1000.0)
827+
awaitIdle()
828+
window.sendMouseWheelEvent(wheelRotation = 1000.0)
829+
assertThat(scrollPane.viewport.viewPosition.y).isGreaterThan(0)
830+
} finally {
831+
window.dispose()
832+
}
833+
}
716834
}

0 commit comments

Comments
 (0)