Skip to content

Commit 910d0b2

Browse files
authored
Support not-blocking pointer inputs outside of focusable Popups (#2992)
[CMP-8852](https://youtrack.jetbrains.com/issue/CMP-8852) Support not-blocking pointer inputs outside of focusable Popups ## Release Notes ### Features - Multiple Platforms - Add `blockPointerInputOutside` flag to `PopupProperties` to support not-blocking pointer inputs outside of focusable `Popup`s
1 parent 5d6ff93 commit 910d0b2

17 files changed

Lines changed: 316 additions & 53 deletions

File tree

compose/mpp/demo/src/commonMain/kotlin/androidx/compose/mpp/demo/components/popup/ConfigurablePopup.kt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -148,13 +148,15 @@ private fun EditPopupProperties(): PopupProperties {
148148
val clippingEnabled = EditBooleanSetting("clippingEnabled", true)
149149
val usePlatformDefaultWidth = EditBooleanSetting("usePlatformDefaultWidth", false)
150150
val usePlatformInsets = EditBooleanSetting("usePlatformInsets", true)
151+
val consumePointerInputOutside = EditBooleanSetting("consumePointerInputOutside", false)
151152
popupProperties = PopupProperties(
152153
focusable = focusable,
153154
dismissOnBackPress = dismissOnBackPress,
154155
dismissOnClickOutside = dismissOnClickOutside,
155156
clippingEnabled = clippingEnabled,
156157
usePlatformDefaultWidth = usePlatformDefaultWidth,
157-
usePlatformInsets = usePlatformInsets
158+
usePlatformInsets = usePlatformInsets,
159+
consumePointerInputOutside = consumePointerInputOutside,
158160
)
159161
}
160162
return popupProperties

compose/ui/ui/api/desktop/ui.api

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4642,7 +4642,6 @@ public abstract interface class androidx/compose/ui/window/PopupPositionProvider
46424642

46434643
public final class androidx/compose/ui/window/PopupProperties {
46444644
public static final field $stable I
4645-
public fun <init> ()V
46464645
public synthetic fun <init> (ZZZZ)V
46474646
public synthetic fun <init> (ZZZZILkotlin/jvm/internal/DefaultConstructorMarker;)V
46484647
public fun <init> (ZZZZZ)V

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

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,6 @@ import androidx.compose.ui.scene.skia.SkiaLayerComponent
3333
import androidx.compose.ui.skiko.OverlayRenderDecorator
3434
import androidx.compose.ui.unit.Density
3535
import androidx.compose.ui.unit.LayoutDirection
36-
import androidx.compose.ui.util.fastAll
3736
import androidx.compose.ui.util.fastForEach
3837
import androidx.compose.ui.util.fastForEachReversed
3938
import androidx.compose.ui.window.Dialog
@@ -139,7 +138,7 @@ internal class ComposeContainer(
139138
},
140139
eventListener = AwtEventListeners(
141140
DetectEventOutsideLayer(),
142-
FocusableLayerEventFilter()
141+
BlockingInputLayerEventFilter()
143142
),
144143
architectureComponentsOwner = architectureComponentsOwner,
145144
coroutineContext = coroutineContext + MainUIDispatcher + DesktopCoroutineExceptionHandler(),
@@ -406,29 +405,31 @@ internal class ComposeContainer(
406405
}
407406

408407
private fun createPlatformLayer(
408+
compositionContext: CompositionContext,
409409
density: Density,
410410
layoutDirection: LayoutDirection,
411411
focusable: Boolean,
412-
compositionContext: CompositionContext
412+
consumePointerInputOutside: Boolean,
413413
): ComposeSceneLayer {
414414
return when (layerType) {
415415
LayerType.OnWindow -> WindowComposeSceneLayer(
416416
composeContainer = this,
417417
skiaLayerAnalytics = skiaLayerAnalytics,
418+
renderSettings = renderSettings,
418419
transparent = true, // TODO: Consider allowing opaque window layers
420+
compositionContext = compositionContext,
419421
density = density,
420422
layoutDirection = layoutDirection,
421423
focusable = focusable,
422-
compositionContext = compositionContext,
423-
renderSettings = renderSettings
424424
)
425425
LayerType.OnComponent -> SwingComposeSceneLayer(
426426
composeContainer = this,
427427
skiaLayerAnalytics = skiaLayerAnalytics,
428+
compositionContext = compositionContext,
428429
density = density,
429430
layoutDirection = layoutDirection,
430431
focusable = focusable,
431-
compositionContext = compositionContext
432+
consumePointerInputOutside = consumePointerInputOutside,
432433
)
433434
else -> error("Unexpected LayerType")
434435
}
@@ -503,15 +504,17 @@ internal class ComposeContainer(
503504
override val platformContext: PlatformContext,
504505
) : ComposeSceneContext {
505506
override fun createLayer(
507+
compositionContext: CompositionContext,
506508
density: Density,
507509
layoutDirection: LayoutDirection,
508510
focusable: Boolean,
509-
compositionContext: CompositionContext
511+
consumePointerInputOutside: Boolean,
510512
): ComposeSceneLayer = createPlatformLayer(
513+
compositionContext = compositionContext,
511514
density = density,
512515
layoutDirection = layoutDirection,
513516
focusable = focusable,
514-
compositionContext = compositionContext
517+
consumePointerInputOutside = consumePointerInputOutside,
515518
)
516519
}
517520

@@ -524,24 +527,24 @@ internal class ComposeContainer(
524527

525528
/**
526529
* Detect and trigger [DesktopComposeSceneLayer.onMouseEventOutside] if event happened below
527-
* focused layer.
530+
* a layer that blocks pointer input outside of its bounds.
528531
*/
529532
private inner class DetectEventOutsideLayer : AwtEventListener {
530533
override fun onMouseEvent(event: AwtMouseEvent): Boolean {
531534
layers.fastForEachReversed {
532535
it.onMouseEventOutside(event)
533-
if (it.focusable) {
536+
if (it.consumePointerInputOutside) {
534537
return false
535538
}
536539
}
537540
return false
538541
}
539542
}
540543

541-
private inner class FocusableLayerEventFilter : AwtEventFilter() {
542-
private val noFocusableLayers get() = layers.fastAll { !it.focusable }
544+
private inner class BlockingInputLayerEventFilter : AwtEventFilter() {
545+
private val noBlockingInputLayers get() = layers.all { !it.consumePointerInputOutside }
543546

544-
override fun shouldSendMouseEvent(event: AwtMouseEvent): Boolean = noFocusableLayers
545-
override fun shouldSendKeyEvent(event: AwtKeyEvent): Boolean = noFocusableLayers
547+
override fun shouldSendMouseEvent(event: AwtMouseEvent): Boolean = noBlockingInputLayers
548+
override fun shouldSendKeyEvent(event: AwtKeyEvent): Boolean = noBlockingInputLayers
546549
}
547550
}

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

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ internal abstract class DesktopComposeSceneLayer(
5656
protected val eventListener get() = AwtEventListeners(
5757
DetectEventOutsideLayer(),
5858
boundsEventFilter,
59-
FocusableLayerEventFilter()
59+
BlockingInputLayerEventFilter()
6060
)
6161
private val boundsEventFilter = BoundsEventFilter(
6262
bounds = Rectangle(windowContainer.size)
@@ -228,28 +228,28 @@ internal abstract class DesktopComposeSceneLayer(
228228

229229
/**
230230
* Detect and trigger [DesktopComposeSceneLayer.onMouseEventOutside] if event happened below
231-
* focused layer.
231+
* a layer that blocks pointer input outside of its bounds.
232232
*/
233233
private inner class DetectEventOutsideLayer : AwtEventListener {
234234
override fun onMouseEvent(event: MouseEvent): Boolean {
235235
layersAbove.toList().fastForEachReversed {
236236
if (!inBounds(event)) {
237237
it.onMouseEventOutside(event)
238238
}
239-
if (it.focusable) {
239+
if (it.consumePointerInputOutside) {
240240
return false
241241
}
242242
}
243243
return false
244244
}
245245
}
246246

247-
private inner class FocusableLayerEventFilter : AwtEventFilter() {
248-
private val noFocusableLayersAbove: Boolean
249-
get() = layersAbove.all { !it.focusable }
247+
private inner class BlockingInputLayerEventFilter : AwtEventFilter() {
248+
private val noBlockingInputLayers: Boolean
249+
get() = layersAbove.none { it.consumePointerInputOutside }
250250

251-
override fun shouldSendMouseEvent(event: MouseEvent): Boolean = noFocusableLayersAbove
252-
override fun shouldSendKeyEvent(event: KeyEvent): Boolean = focusable && noFocusableLayersAbove
251+
override fun shouldSendMouseEvent(event: MouseEvent): Boolean = noBlockingInputLayers
252+
override fun shouldSendKeyEvent(event: KeyEvent): Boolean = focusable && noBlockingInputLayers
253253
}
254254

255255
private inner class BoundsEventFilter(

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,10 +40,11 @@ import org.jetbrains.skiko.SkiaLayerAnalytics
4040
internal class SwingComposeSceneLayer(
4141
composeContainer: ComposeContainer,
4242
private val skiaLayerAnalytics: SkiaLayerAnalytics,
43+
compositionContext: CompositionContext,
4344
density: Density,
4445
layoutDirection: LayoutDirection,
4546
focusable: Boolean,
46-
compositionContext: CompositionContext
47+
override var consumePointerInputOutside: Boolean = focusable,
4748
) : DesktopComposeSceneLayer(composeContainer, density, layoutDirection) {
4849
private val backgroundMouseListener = object : MouseAdapter() {
4950
override fun mousePressed(event: MouseEvent) = onMouseEventOutside(event)
@@ -156,7 +157,7 @@ internal class SwingComposeSceneLayer(
156157
val contentComponent = mediator?.contentComponent ?: return
157158
val localDrawBounds = drawBounds.toAwtRectangle(density)
158159

159-
if (focusable) {
160+
if (consumePointerInputOutside) {
160161
container.setBounds(0, 0, windowContainer.width, windowContainer.height)
161162
contentComponent.bounds = localDrawBounds
162163
mediator?.sceneBoundsInPx = null

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

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,12 +49,12 @@ import org.jetbrains.skiko.transparentWindowBackgroundHack
4949
internal class WindowComposeSceneLayer(
5050
composeContainer: ComposeContainer,
5151
private val skiaLayerAnalytics: SkiaLayerAnalytics,
52+
private val renderSettings: RenderSettings,
5253
private val transparent: Boolean,
54+
compositionContext: CompositionContext,
5355
density: Density,
5456
layoutDirection: LayoutDirection,
5557
focusable: Boolean,
56-
compositionContext: CompositionContext,
57-
private val renderSettings: RenderSettings
5858
) : DesktopComposeSceneLayer(composeContainer, density, layoutDirection) {
5959
// WindowComposeSceneLayer is tied to the window it was created with
6060
private val parentWindow = requireNotNull(composeContainer.window)
@@ -113,6 +113,11 @@ internal class WindowComposeSceneLayer(
113113

114114
override var scrimColor: Color? = null
115115

116+
// Blocking pointer input outside is not supported for window-based popup layers.
117+
override var consumePointerInputOutside: Boolean
118+
get() = false
119+
set(_) {}
120+
116121
init {
117122
val boundsInPx = windowContainer.sizeInPx.toRect()
118123
drawBounds = boundsInPx.roundToIntRect()

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
package androidx.compose.ui.awt
1717

1818
import androidx.compose.foundation.ScrollState
19+
import androidx.compose.foundation.clickable
1920
import androidx.compose.foundation.focusable
2021
import androidx.compose.foundation.layout.Box
2122
import androidx.compose.foundation.layout.Column

compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/window/DesktopPopupTest.kt

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
package androidx.compose.ui.window
1818

1919
import androidx.compose.foundation.Canvas
20+
import androidx.compose.foundation.background
21+
import androidx.compose.foundation.clickable
2022
import androidx.compose.foundation.layout.Box
2123
import androidx.compose.foundation.layout.size
2224
import androidx.compose.foundation.lazy.LazyColumn
@@ -31,18 +33,25 @@ import androidx.compose.runtime.withFrameNanos
3133
import androidx.compose.ui.ComposeFeatureFlags
3234
import androidx.compose.ui.LayerType
3335
import androidx.compose.ui.Modifier
36+
import androidx.compose.ui.awt.ComposePanel
3437
import androidx.compose.ui.input.key.Key
3538
import androidx.compose.ui.input.key.KeyEvent
3639
import androidx.compose.ui.input.key.KeyEventType
3740
import androidx.compose.ui.layout.Layout
41+
import androidx.compose.ui.sendMousePress
42+
import androidx.compose.ui.sendMouseRelease
3843
import androidx.compose.ui.test.isPopup
3944
import androidx.compose.ui.test.junit4.createComposeRule
4045
import androidx.compose.ui.test.performKeyPress
46+
import androidx.compose.ui.graphics.Color
4147
import androidx.compose.ui.unit.dp
4248
import androidx.navigationevent.DirectNavigationEventInput
4349
import androidx.navigationevent.compose.LocalNavigationEventDispatcherOwner
4450
import com.google.common.truth.Truth.assertThat
51+
import java.awt.BorderLayout
4552
import java.awt.Window
53+
import javax.swing.JFrame
54+
import kotlin.test.assertEquals
4655
import kotlin.test.assertTrue
4756
import org.junit.Rule
4857
import org.junit.Test
@@ -264,6 +273,66 @@ class DesktopPopupTest {
264273
assertThat(onKeyEventCallCount).isEqualTo(2)
265274
}
266275

276+
@Test
277+
fun focusableNonBlockingPopup_withComponentLayerType_passesClicksThrough() {
278+
ComposeFeatureFlags.layerType.withOverride(LayerType.OnComponent) {
279+
ComposeFeatureFlags.useSwingGraphicsInComposePanel.withOverride(true) {
280+
val window = JFrame()
281+
try {
282+
runApplicationTest {
283+
var showPopup by mutableStateOf(false)
284+
var backgroundClickCount = 0
285+
286+
val composePanel = ComposePanel()
287+
composePanel.setContent {
288+
Box(
289+
Modifier
290+
.size(200.dp)
291+
.background(Color.Yellow)
292+
.clickable { backgroundClickCount++ }
293+
)
294+
295+
if (showPopup) {
296+
Popup(
297+
properties = PopupProperties(
298+
focusable = true,
299+
dismissOnClickOutside = false,
300+
consumePointerInputOutside = false,
301+
),
302+
) {
303+
Box(
304+
Modifier
305+
.size(50.dp)
306+
.background(Color.Blue)
307+
)
308+
}
309+
}
310+
}
311+
312+
composePanel.windowContainer = window.layeredPane
313+
314+
window.contentPane.add(composePanel, BorderLayout.CENTER)
315+
window.pack()
316+
window.isVisible = true
317+
318+
awaitIdle()
319+
320+
showPopup = true
321+
awaitIdle()
322+
323+
window.layeredPane.sendMousePress(x = 100, y = 100)
324+
window.layeredPane.sendMouseRelease(x = 100, y = 100)
325+
awaitIdle()
326+
327+
assertEquals(1, backgroundClickCount)
328+
}
329+
} finally {
330+
window.dispose()
331+
}
332+
}
333+
}
334+
}
335+
267336
@Test
268337
fun nonFocusablePopup_withWindowLayerType_doesNotGrabFocus() {
269338
ComposeFeatureFlags.layerType.withOverride(LayerType.OnWindow) {

compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeContainer.ios.kt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -297,10 +297,11 @@ internal class ComposeContainer(
297297
override val platformContext: PlatformContext = platformContext
298298

299299
override fun createLayer(
300+
compositionContext: CompositionContext,
300301
density: Density,
301302
layoutDirection: LayoutDirection,
302303
focusable: Boolean,
303-
compositionContext: CompositionContext
304+
consumePointerInputOutside: Boolean,
304305
): ComposeSceneLayer {
305306
val layer = UIKitComposeSceneLayer(
306307
onClosed = {
@@ -314,6 +315,7 @@ internal class ComposeContainer(
314315
configuration = configuration,
315316
onAccessibilityChanged = ::onAccessibilityChanged,
316317
focusedViewsList = if (focusable) focusedViewsList.childFocusedViewsList() else null,
318+
consumePointerInputOutside = consumePointerInputOutside,
317319
parentCoroutineContext = compositionContext.effectCoroutineContext,
318320
ownerProvider = architectureComponentsOwner,
319321
interfaceOrientationState = interfaceOrientationState,

compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/UIKitComposeSceneLayer.ios.kt

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ internal class UIKitComposeSceneLayer(
5858
private val onAccessibilityChanged: () -> Unit,
5959
configuration: ComposeContainerConfiguration,
6060
private var focusedViewsList: FocusedViewsList?,
61+
consumePointerInputOutside: Boolean = focusedViewsList != null,
6162
parentCoroutineContext: CoroutineContext,
6263
private val ownerProvider: PlatformArchitectureComponentsOwner,
6364
private val interfaceOrientationState: State<InterfaceOrientation>,
@@ -66,6 +67,14 @@ internal class UIKitComposeSceneLayer(
6667
private val layerCoroutineContext = parentCoroutineContext + layerJob
6768

6869
override var focusable: Boolean = focusedViewsList != null
70+
set(value) {
71+
if (field != value) {
72+
field = value
73+
onAccessibilityChanged()
74+
}
75+
}
76+
77+
override var consumePointerInputOutside: Boolean = consumePointerInputOutside
6978
set(value) {
7079
if (field != value) {
7180
field = value
@@ -102,7 +111,7 @@ internal class UIKitComposeSceneLayer(
102111
interfaceOrientationState = interfaceOrientationState
103112
).also {
104113
interactionView.embedSubview(it.backgroundView)
105-
it.isInterceptingOutsideEvents = focusable
114+
it.isInterceptingOutsideEvents = consumePointerInputOutside
106115
}
107116

108117
private fun createComposeScene(
@@ -228,4 +237,4 @@ internal class UIKitComposeSceneLayer(
228237
fun sceneWillDisappear() {
229238
mediator.sceneWillDisappear()
230239
}
231-
}
240+
}

0 commit comments

Comments
 (0)