Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -88,4 +88,14 @@ internal object ComposeFeatureFlags {
val useInteropBlending: Boolean by lazy {
System.getProperty("compose.interop.blending").toBoolean()
}

/**
* Whether to redispatch unconsumed mouse wheel events to the parent heavyweight component.
* This allows any scrollable components beneath Compose to be scrolled.
*
* This flag will be removed in the future, and the default behavior will correspond to a value
* of `true`.
*/
val redispatchUnconsumedMouseWheelEvents: Boolean
get() = System.getProperty("compose.swing.redispatchMouseWheelEvents", "false").toBoolean()
}
Original file line number Diff line number Diff line change
Expand Up @@ -376,7 +376,7 @@ internal class ComposeSceneMediator(

contentComponent.focusTraversalKeysEnabled = false

subscribe(contentComponent)
subscribeToInputEvents()
}

private inline fun catchExceptions(block: () -> Unit) {
Expand All @@ -394,18 +394,22 @@ internal class ComposeSceneMediator(
}
}

private fun subscribe(component: Component) {
component.addInputMethodListener(inputMethodListener)
component.addFocusListener(focusListener)
component.addKeyListener(keyListener)
component.subscribeToMouseEvents(mouseListener)
private fun subscribeToInputEvents() {
with(contentComponent) {
addInputMethodListener(inputMethodListener)
addFocusListener(focusListener)
addKeyListener(keyListener)
subscribeToMouseEvents(mouseListener)
}
}

private fun unsubscribe(component: Component) {
component.removeInputMethodListener(inputMethodListener)
component.removeFocusListener(focusListener)
component.removeKeyListener(keyListener)
component.unsubscribeFromMouseEvents(mouseListener)
private fun unsubscribeFromInputEvents() {
with(contentComponent) {
removeInputMethodListener(inputMethodListener)
removeFocusListener(focusListener)
removeKeyListener(keyListener)
unsubscribeFromMouseEvents(mouseListener)
}
}

private var isMouseEventProcessing = false
Expand Down Expand Up @@ -459,8 +463,57 @@ internal class ComposeSceneMediator(
if (eventListener.onMouseEvent(event)) {
return
}

processMouseEvent {
scene.onMouseWheelEvent(event.position, event)
val processingResult = scene.onMouseWheelEvent(event.position, event)
if (!processingResult.anyChangeConsumed) {
if (ComposeFeatureFlags.redispatchUnconsumedMouseWheelEvents) {
redispatchUnconsumedMouseEvent(event)
}
}
}
}

/**
* Returns the first heavyweight ancestor of the given component.
*/
private fun Component.heavyWeightAncestorOrNull() : Component? {
var parent = parent
while (parent != null) {
if (!parent.isLightweight) return parent
parent = parent.parent
}
return null
}

/**
* (Re)Dispatches the given mouse event to the component that would have received it had
* this [ComposeSceneMediator] not been listening to the corresponding type of mouse events.
*
* The problem this attempts to solve is that [ComposeSceneMediator] has to register listeners
* for all types of mouse events, even if there is nothing in the scene that listens to them.
* AWT/Swing, however, interprets listening to mouse events as "interest" in them and sends them
* only to the "interested" component "under" the mouse pointer.
*/
private fun redispatchUnconsumedMouseEvent(event: MouseEvent) {
// Redispatch the event to the heavyweight ancestor, which in turn will try to find the
// correct target component and send the event to it. Unregistering from mouse events
// during this call allows the event to be sent to the component it would've been sent to
// if ComposeSceneMediator wasn't listening to the corresponding type of mouse events.
//
// This is possibly a dangerous hack. If it breaks something, the alternative is to dispatch
// only to the parent of the source. This isn't ideal because the "right" component may not
// be the parent/ancestor, but a sibling of the source component.
// With that approach, there would probably also be a need to add a flag (or multiple flags)
// to ComposePanel that would control which types of events should be listened to.
val source = event.component ?: return // Should be contentComponent
val target = source.heavyWeightAncestorOrNull() ?: return
try {
unsubscribeFromInputEvents()
val retargetedEvent = SwingUtilities.convertMouseEvent(source, event, target)
target.dispatchEvent(retargetedEvent)
} finally {
subscribeToInputEvents()
}
}

Expand Down Expand Up @@ -488,7 +541,7 @@ internal class ComposeSceneMediator(
check(!isDisposed) { "ComposeSceneMediator is already disposed" }
isDisposed = true

unsubscribe(contentComponent)
unsubscribeFromInputEvents()

container.remove(contentComponent)
container.remove(invisibleComponent)
Expand Down Expand Up @@ -814,8 +867,8 @@ internal val MouseEvent.composePointerButton: PointerButton? get() {
private fun ComposeScene.onMouseWheelEvent(
position: Offset,
event: MouseWheelEvent
) {
sendPointerEvent(
) : PointerEventResult {
return sendPointerEvent(
eventType = PointerEventType.Scroll,
position = position,
scrollDelta = if (event.isShiftDown) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -186,8 +186,8 @@ fun Container.sendMouseEvent(
}

fun Container.sendMouseWheelEvent(
x: Int,
y: Int,
x: Int = width / 2,
y: Int = height / 2,
scrollType: Int = MouseWheelEvent.WHEEL_UNIT_SCROLL,
wheelRotation: Double = 0.0,
modifiers: Int = 0,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,17 @@
*/
package androidx.compose.ui.awt

import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
Expand All @@ -31,12 +36,15 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.background
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.PointerEventType
import androidx.compose.ui.input.pointer.onPointerEvent
import androidx.compose.ui.layout.layout
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.sendMouseEvent
import androidx.compose.ui.sendMouseWheelEvent
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.toSize
Expand All @@ -49,8 +57,11 @@ import java.awt.BorderLayout
import java.awt.Dimension
import java.awt.GraphicsEnvironment
import java.awt.event.MouseEvent
import javax.swing.BoxLayout
import javax.swing.JFrame
import javax.swing.JPanel
import javax.swing.JScrollPane
import javax.swing.ScrollPaneConstants
import junit.framework.TestCase.assertTrue
import kotlin.test.assertEquals
import kotlin.test.assertFalse
Expand Down Expand Up @@ -545,4 +556,128 @@ class ComposePanelTest {
window.dispose()
}
}

@Test
fun `ComposePanel propagates unconsumed mouse wheel scroll events to parent`() {
val originalFlagValue = System.setProperty("compose.swing.redispatchMouseWheelEvents", "true")
try {
runApplicationTest {
val composePanel = ComposePanel()
composePanel.preferredSize = Dimension(200, 200)
val scrollState = ScrollState(0)
composePanel.setContent {
Box(Modifier.size(200.dp).verticalScroll(scrollState).background(Color.Yellow)) {
Column(Modifier.fillMaxWidth().height(400.dp)) {
Text("Hello World")
Text("Hello World")
Text("Hello World")
Text("Hello World")
Text("Hello World")
}
}
}

val window = JFrame()
try {
window.size = Dimension(200, 200)
val scrollPane = JScrollPane(
JPanel().apply {
layout = BoxLayout(this, BoxLayout.Y_AXIS)
add(composePanel)
add(javax.swing.Box.createVerticalStrut(1000), BorderLayout.CENTER)
}
)
scrollPane.horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER
window.contentPane.add(scrollPane, BorderLayout.CENTER)
window.isVisible = true

awaitIdle()

// Scroll a little and check that compose content was scrolled
composePanel.sendMouseWheelEvent(wheelRotation = 1.0)
awaitIdle()
assertThat(scrollState.value).isGreaterThan(0)

// Scroll a lot and check that the Swing JScrollPane was scrolled
// Note that we need two scroll events for now because Compose can't partially consume
// scroll events. So one event is needed to scroll Compose content to the end, and
// another one to scroll JScrollPane.
window.sendMouseWheelEvent(wheelRotation = 1000.0)
awaitIdle()
window.sendMouseWheelEvent(wheelRotation = 1000.0)
assertThat(scrollPane.viewport.viewPosition.y).isGreaterThan(0)
} finally {
window.dispose()
}
}
} finally {
System.setProperty("compose.swing.redispatchMouseWheelEvents", originalFlagValue ?: "false")
}
}

@Test
fun `ComposePanel propagates unconsumed mouse wheel scroll events to sibling`() {
val originalFlagValue = System.setProperty("compose.swing.redispatchMouseWheelEvents", "true")
try {
runApplicationTest {
val composePanel = ComposePanel()
val scrollState = ScrollState(0)
composePanel.setContent {
Box(Modifier.size(200.dp).verticalScroll(scrollState).background(Color.Green)) {
Column(Modifier.fillMaxWidth().height(400.dp)) {
Text("Hello World")
Text("Hello World")
Text("Hello World")
Text("Hello World")
Text("Hello World")
}
}
}

val container = JPanel(null)
container.size = Dimension(200, 200)

val scrollPane = JScrollPane(
JPanel().apply {
layout = BoxLayout(this, BoxLayout.Y_AXIS)
add(javax.swing.Box.createVerticalStrut(1000), BorderLayout.CENTER)
}
)
scrollPane.horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER

composePanel.size = Dimension(200, 200)
scrollPane.size = Dimension(200, 400)

val window = JFrame()
try {
window.size = Dimension(200, 400)
container.add(composePanel)
container.add(scrollPane)

window.contentPane.add(container, BorderLayout.CENTER)
window.isVisible = true

awaitIdle()

// Scroll a little and check that compose content was scrolled
composePanel.sendMouseWheelEvent(wheelRotation = 1.0)
awaitIdle()
assertThat(scrollState.value).isGreaterThan(0)

// Scroll a lot and check that the Swing JScrollPane was scrolled
// Note that we need two scroll events for now because Compose can't partially consume
// scroll events. So one event is needed to scroll Compose content to the end, and
// another one to scroll JScrollPane.
composePanel.sendMouseWheelEvent(wheelRotation = 1000.0)
awaitIdle()
window.sendMouseWheelEvent(wheelRotation = 1000.0)
assertThat(scrollPane.viewport.viewPosition.y).isGreaterThan(0)
} finally {
window.dispose()
}
}
} finally {
System.setProperty("compose.swing.redispatchMouseWheelEvents", originalFlagValue ?: "false")
}
}
}
Loading