Skip to content

Commit dd678b5

Browse files
committed
Redispatch unconsumed mouse wheel events (#2425)
1 parent 5f13d9e commit dd678b5

File tree

4 files changed

+208
-17
lines changed

4 files changed

+208
-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,4 +88,15 @@ internal object ComposeFeatureFlags {
8888
val useInteropBlending: Boolean by lazy {
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 by lazy {
100+
System.getProperty("compose.swing.redispatchMouseWheelEvents", "false").toBoolean()
101+
}
91102
}

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
@@ -376,7 +376,7 @@ internal class ComposeSceneMediator(
376376

377377
contentComponent.focusTraversalKeysEnabled = false
378378

379-
subscribe(contentComponent)
379+
subscribeToInputEvents()
380380
}
381381

382382
private inline fun catchExceptions(block: () -> Unit) {
@@ -394,18 +394,22 @@ internal class ComposeSceneMediator(
394394
}
395395
}
396396

397-
private fun subscribe(component: Component) {
398-
component.addInputMethodListener(inputMethodListener)
399-
component.addFocusListener(focusListener)
400-
component.addKeyListener(keyListener)
401-
component.subscribeToMouseEvents(mouseListener)
397+
private fun subscribeToInputEvents() {
398+
with(contentComponent) {
399+
addInputMethodListener(inputMethodListener)
400+
addFocusListener(focusListener)
401+
addKeyListener(keyListener)
402+
subscribeToMouseEvents(mouseListener)
403+
}
402404
}
403405

404-
private fun unsubscribe(component: Component) {
405-
component.removeInputMethodListener(inputMethodListener)
406-
component.removeFocusListener(focusListener)
407-
component.removeKeyListener(keyListener)
408-
component.unsubscribeFromMouseEvents(mouseListener)
406+
private fun unsubscribeFromInputEvents() {
407+
with(contentComponent) {
408+
removeInputMethodListener(inputMethodListener)
409+
removeFocusListener(focusListener)
410+
removeKeyListener(keyListener)
411+
unsubscribeFromMouseEvents(mouseListener)
412+
}
409413
}
410414

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

@@ -488,7 +541,7 @@ internal class ComposeSceneMediator(
488541
check(!isDisposed) { "ComposeSceneMediator is already disposed" }
489542
isDisposed = true
490543

491-
unsubscribe(contentComponent)
544+
unsubscribeFromInputEvents()
492545

493546
container.remove(contentComponent)
494547
container.remove(invisibleComponent)
@@ -814,8 +867,8 @@ internal val MouseEvent.composePointerButton: PointerButton? get() {
814867
private fun ComposeScene.onMouseWheelEvent(
815868
position: Offset,
816869
event: MouseWheelEvent
817-
) {
818-
sendPointerEvent(
870+
) : PointerEventResult {
871+
return sendPointerEvent(
819872
eventType = PointerEventType.Scroll,
820873
position = position,
821874
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: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,17 @@
1515
*/
1616
package androidx.compose.ui.awt
1717

18+
import androidx.compose.foundation.ScrollState
1819
import androidx.compose.foundation.layout.Box
20+
import androidx.compose.foundation.layout.Column
1921
import androidx.compose.foundation.layout.fillMaxSize
22+
import androidx.compose.foundation.layout.fillMaxWidth
23+
import androidx.compose.foundation.layout.height
2024
import androidx.compose.foundation.layout.requiredSize
2125
import androidx.compose.foundation.layout.size
2226
import androidx.compose.foundation.layout.sizeIn
2327
import androidx.compose.foundation.lazy.LazyColumn
28+
import androidx.compose.foundation.verticalScroll
2429
import androidx.compose.material.Text
2530
import androidx.compose.runtime.Composable
2631
import androidx.compose.runtime.LaunchedEffect
@@ -31,12 +36,15 @@ import androidx.compose.runtime.saveable.rememberSaveable
3136
import androidx.compose.runtime.setValue
3237
import androidx.compose.ui.ExperimentalComposeUiApi
3338
import androidx.compose.ui.Modifier
39+
import androidx.compose.ui.background
3440
import androidx.compose.ui.geometry.Size
41+
import androidx.compose.ui.graphics.Color
3542
import androidx.compose.ui.input.pointer.PointerEventType
3643
import androidx.compose.ui.input.pointer.onPointerEvent
3744
import androidx.compose.ui.layout.layout
3845
import androidx.compose.ui.layout.onGloballyPositioned
3946
import androidx.compose.ui.sendMouseEvent
47+
import androidx.compose.ui.sendMouseWheelEvent
4048
import androidx.compose.ui.unit.Constraints
4149
import androidx.compose.ui.unit.dp
4250
import androidx.compose.ui.unit.toSize
@@ -49,8 +57,11 @@ import java.awt.BorderLayout
4957
import java.awt.Dimension
5058
import java.awt.GraphicsEnvironment
5159
import java.awt.event.MouseEvent
60+
import javax.swing.BoxLayout
5261
import javax.swing.JFrame
5362
import javax.swing.JPanel
63+
import javax.swing.JScrollPane
64+
import javax.swing.ScrollPaneConstants
5465
import junit.framework.TestCase.assertTrue
5566
import kotlin.test.assertEquals
5667
import kotlin.test.assertFalse
@@ -545,4 +556,120 @@ class ComposePanelTest {
545556
window.dispose()
546557
}
547558
}
559+
560+
@Test
561+
fun `ComposePanel propagates unconsumed mouse wheel scroll events to parent`() {
562+
System.setProperty("compose.swing.redispatchMouseWheelEvents", "true")
563+
runApplicationTest {
564+
val composePanel = ComposePanel()
565+
composePanel.preferredSize = Dimension(200, 200)
566+
val scrollState = ScrollState(0)
567+
composePanel.setContent {
568+
Box(Modifier.size(200.dp).verticalScroll(scrollState).background(Color.Yellow)) {
569+
Column(Modifier.fillMaxWidth().height(400.dp)) {
570+
Text("Hello World")
571+
Text("Hello World")
572+
Text("Hello World")
573+
Text("Hello World")
574+
Text("Hello World")
575+
}
576+
}
577+
}
578+
579+
val window = JFrame()
580+
try {
581+
window.size = Dimension(200, 200)
582+
val scrollPane = JScrollPane(
583+
JPanel().apply {
584+
layout = BoxLayout(this, BoxLayout.Y_AXIS)
585+
add(composePanel)
586+
add(javax.swing.Box.createVerticalStrut(1000), BorderLayout.CENTER)
587+
}
588+
)
589+
scrollPane.horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER
590+
window.contentPane.add(scrollPane, BorderLayout.CENTER)
591+
window.isVisible = true
592+
593+
awaitIdle()
594+
595+
// Scroll a little and check that compose content was scrolled
596+
composePanel.sendMouseWheelEvent(wheelRotation = 1.0)
597+
awaitIdle()
598+
assertThat(scrollState.value).isGreaterThan(0)
599+
600+
// Scroll a lot and check that the Swing JScrollPane was scrolled
601+
// Note that we need two scroll events for now because Compose can't partially consume
602+
// scroll events. So one event is needed to scroll Compose content to the end, and
603+
// another one to scroll JScrollPane.
604+
window.sendMouseWheelEvent(wheelRotation = 1000.0)
605+
awaitIdle()
606+
window.sendMouseWheelEvent(wheelRotation = 1000.0)
607+
assertThat(scrollPane.viewport.viewPosition.y).isGreaterThan(0)
608+
} finally {
609+
window.dispose()
610+
}
611+
}
612+
}
613+
614+
@Test
615+
fun `ComposePanel propagates unconsumed mouse wheel scroll events to sibling`() {
616+
System.setProperty("compose.swing.redispatchMouseWheelEvents", "true")
617+
runApplicationTest {
618+
val composePanel = ComposePanel()
619+
val scrollState = ScrollState(0)
620+
composePanel.setContent {
621+
Box(Modifier.size(200.dp).verticalScroll(scrollState).background(Color.Green)) {
622+
Column(Modifier.fillMaxWidth().height(400.dp)) {
623+
Text("Hello World")
624+
Text("Hello World")
625+
Text("Hello World")
626+
Text("Hello World")
627+
Text("Hello World")
628+
}
629+
}
630+
}
631+
632+
val container = JPanel(null)
633+
container.size = Dimension(200, 200)
634+
635+
val scrollPane = JScrollPane(
636+
JPanel().apply {
637+
layout = BoxLayout(this, BoxLayout.Y_AXIS)
638+
add(javax.swing.Box.createVerticalStrut(1000), BorderLayout.CENTER)
639+
}
640+
)
641+
scrollPane.horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER
642+
643+
composePanel.size = Dimension(200, 200)
644+
scrollPane.size = Dimension(200, 400)
645+
646+
val window = JFrame()
647+
try {
648+
window.size = Dimension(200, 400)
649+
container.add(composePanel)
650+
container.add(scrollPane)
651+
652+
window.contentPane.add(container, BorderLayout.CENTER)
653+
window.isVisible = true
654+
655+
awaitIdle()
656+
657+
// Scroll a little and check that compose content was scrolled
658+
composePanel.sendMouseWheelEvent(wheelRotation = 1.0)
659+
awaitIdle()
660+
assertThat(scrollState.value).isGreaterThan(0)
661+
662+
// Scroll a lot and check that the Swing JScrollPane was scrolled
663+
// Note that we need two scroll events for now because Compose can't partially consume
664+
// scroll events. So one event is needed to scroll Compose content to the end, and
665+
// another one to scroll JScrollPane.
666+
composePanel.sendMouseWheelEvent(wheelRotation = 1000.0)
667+
awaitIdle()
668+
window.sendMouseWheelEvent(wheelRotation = 1000.0)
669+
assertThat(scrollPane.viewport.viewPosition.y).isGreaterThan(0)
670+
} finally {
671+
window.dispose()
672+
}
673+
}
674+
}
548675
}

0 commit comments

Comments
 (0)