Skip to content

Commit 773fa7b

Browse files
authored
Redispatch unconsumed mouse wheel events (#2425)
1 parent 0255eff commit 773fa7b

File tree

4 files changed

+205
-17
lines changed

4 files changed

+205
-17
lines changed

compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/ComposeFeatureFlags.desktop.kt

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,17 @@ internal object ComposeFeatureFlags {
8888
val useInteropBlending = FeatureFlag {
8989
System.getProperty("compose.interop.blending").toBoolean()
9090
}
91+
92+
/**
93+
* Whether to redispatch unconsumed mouse wheel events to the parent heavyweight component.
94+
* This allows any scrollable components beneath Compose to be scrolled.
95+
*
96+
* This flag will be removed in the future, and the default behavior will correspond to a value
97+
* of `true`.
98+
*/
99+
val redispatchUnconsumedMouseWheelEvents = FeatureFlag {
100+
System.getProperty("compose.swing.redispatchMouseWheelEvents", "true").toBoolean()
101+
}
91102
}
92103

93104

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

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

@@ -493,7 +546,7 @@ internal class ComposeSceneMediator(
493546
check(!isDisposed) { "ComposeSceneMediator is already disposed" }
494547
isDisposed = true
495548

496-
unsubscribe(contentComponent)
549+
unsubscribeFromInputEvents()
497550

498551
container.remove(contentComponent)
499552
container.remove(invisibleComponent)
@@ -829,8 +882,8 @@ internal val MouseEvent.composePointerButton: PointerButton? get() {
829882
private fun ComposeScene.onMouseWheelEvent(
830883
position: Offset,
831884
event: MouseWheelEvent
832-
) {
833-
sendPointerEvent(
885+
) : PointerEventResult {
886+
return sendPointerEvent(
834887
eventType = PointerEventType.Scroll,
835888
position = position,
836889
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: 124 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,119 @@ class ComposePanelTest {
713722
}
714723
}
715724

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

0 commit comments

Comments
 (0)