Merge "Update multiplatform Dialog and Popup APIs" into androidx-main
diff --git a/compose/desktop/desktop/samples/src/jvmMain/kotlin/androidx/compose/desktop/examples/popupexample/AppContent.jvm.kt b/compose/desktop/desktop/samples/src/jvmMain/kotlin/androidx/compose/desktop/examples/popupexample/AppContent.jvm.kt
index 16f6d92..1a7bcdb 100644
--- a/compose/desktop/desktop/samples/src/jvmMain/kotlin/androidx/compose/desktop/examples/popupexample/AppContent.jvm.kt
+++ b/compose/desktop/desktop/samples/src/jvmMain/kotlin/androidx/compose/desktop/examples/popupexample/AppContent.jvm.kt
@@ -64,6 +64,7 @@
import androidx.compose.ui.window.DialogWindow
import androidx.compose.ui.window.Notification
import androidx.compose.ui.window.Popup
+import androidx.compose.ui.window.PopupProperties
import androidx.compose.ui.window.TrayState
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.WindowPlacement
@@ -297,8 +298,10 @@
Popup(
alignment = Alignment.TopCenter,
offset = IntOffset(0, 50),
- focusable = true,
- onDismissRequest = onDismiss
+ onDismissRequest = onDismiss,
+ properties = PopupProperties(
+ focusable = true
+ )
) {
println("CompositionLocal value is ${LocalTest.current}.")
PopupContent(onDismiss)
diff --git a/compose/foundation/foundation/src/desktopMain/kotlin/androidx/compose/foundation/BasicContextMenuRepresentation.desktop.kt b/compose/foundation/foundation/src/desktopMain/kotlin/androidx/compose/foundation/BasicContextMenuRepresentation.desktop.kt
index 5b16fcc..d28c709 100644
--- a/compose/foundation/foundation/src/desktopMain/kotlin/androidx/compose/foundation/BasicContextMenuRepresentation.desktop.kt
+++ b/compose/foundation/foundation/src/desktopMain/kotlin/androidx/compose/foundation/BasicContextMenuRepresentation.desktop.kt
@@ -40,6 +40,7 @@
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Popup
+import androidx.compose.ui.window.PopupProperties
import androidx.compose.ui.window.rememberCursorPositionProvider
// Design of basic represenation is from Material specs:
@@ -68,9 +69,9 @@
val isOpen = state.status is ContextMenuState.Status.Open
if (isOpen) {
Popup(
- focusable = true,
+ popupPositionProvider = rememberCursorPositionProvider(),
onDismissRequest = { state.status = ContextMenuState.Status.Closed },
- popupPositionProvider = rememberCursorPositionProvider()
+ properties = PopupProperties(focusable = true)
) {
Column(
modifier = Modifier
diff --git a/compose/foundation/foundation/src/desktopMain/kotlin/androidx/compose/foundation/BasicTooltip.desktop.kt b/compose/foundation/foundation/src/desktopMain/kotlin/androidx/compose/foundation/BasicTooltip.desktop.kt
index b4ffef2..a0abc54 100644
--- a/compose/foundation/foundation/src/desktopMain/kotlin/androidx/compose/foundation/BasicTooltip.desktop.kt
+++ b/compose/foundation/foundation/src/desktopMain/kotlin/androidx/compose/foundation/BasicTooltip.desktop.kt
@@ -21,6 +21,7 @@
import androidx.compose.ui.Modifier
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupPositionProvider
+import androidx.compose.ui.window.PopupProperties
/**
* BasicTooltipBox that wraps a composable with a tooltip.
@@ -53,13 +54,18 @@
enableUserInput: Boolean,
content: @Composable () -> Unit
) {
+ // TODO: Reuse android implementation - there is no platform specifics here.
+ // Use expect/actual only for string resources
Box(modifier = modifier) {
content()
if (state.isVisible) {
Popup(
popupPositionProvider = positionProvider,
onDismissRequest = { state.dismiss() },
- focusable = focusable
+ properties = PopupProperties(
+ // TODO(b/326167778): focusable = true cannot work with mouse
+ focusable = false
+ )
) { tooltip() }
}
}
diff --git a/compose/material/material/src/desktopMain/kotlin/androidx/compose/material/DesktopAlertDialog.desktop.kt b/compose/material/material/src/desktopMain/kotlin/androidx/compose/material/DesktopAlertDialog.desktop.kt
index 22dfdd9..f755c0c 100644
--- a/compose/material/material/src/desktopMain/kotlin/androidx/compose/material/DesktopAlertDialog.desktop.kt
+++ b/compose/material/material/src/desktopMain/kotlin/androidx/compose/material/DesktopAlertDialog.desktop.kt
@@ -191,6 +191,7 @@
// [alignment] property of [Popup] and have to use [Box] that fills all the
// available space. Also [Box] provides a dismiss request feature when clicked
// outside of the [AlertDialog] content.
+ @Suppress("DEPRECATION") // Will be removed in aosp/3077146
Popup(
popupPositionProvider = object : PopupPositionProvider {
override fun calculatePosition(
diff --git a/compose/material/material/src/desktopMain/kotlin/androidx/compose/material/DesktopMenu.desktop.kt b/compose/material/material/src/desktopMain/kotlin/androidx/compose/material/DesktopMenu.desktop.kt
index dc5e6c2..fad8335 100644
--- a/compose/material/material/src/desktopMain/kotlin/androidx/compose/material/DesktopMenu.desktop.kt
+++ b/compose/material/material/src/desktopMain/kotlin/androidx/compose/material/DesktopMenu.desktop.kt
@@ -170,6 +170,7 @@
transformOriginState.value = calculateTransformOrigin(parentBounds, menuBounds)
}
+ @Suppress("DEPRECATION") // Will be removed in aosp/3077093
Popup(
focusable = focusable,
onDismissRequest = onDismissRequest,
@@ -294,6 +295,7 @@
if (expandedStates.currentState || expandedStates.targetState) {
val transformOriginState = remember { mutableStateOf(TransformOrigin.Center) }
+ @Suppress("DEPRECATION") // Will be removed in aosp/3077093
Popup(
focusable = focusable,
onDismissRequest = onDismissRequest,
diff --git a/compose/material3/material3/src/desktopMain/kotlin/androidx/compose/material3/internal/BasicTooltip.desktop.kt b/compose/material3/material3/src/desktopMain/kotlin/androidx/compose/material3/internal/BasicTooltip.desktop.kt
index 649eb3e..30845c6 100644
--- a/compose/material3/material3/src/desktopMain/kotlin/androidx/compose/material3/internal/BasicTooltip.desktop.kt
+++ b/compose/material3/material3/src/desktopMain/kotlin/androidx/compose/material3/internal/BasicTooltip.desktop.kt
@@ -23,6 +23,7 @@
import androidx.compose.ui.Modifier
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupPositionProvider
+import androidx.compose.ui.window.PopupProperties
/**
* NOTICE:
@@ -56,13 +57,18 @@
enableUserInput: Boolean,
content: @Composable () -> Unit
) {
+ // TODO: Reuse android implementation - there is no platform specifics here.
+ // Use expect/actual only for string resources
Box(modifier = modifier) {
content()
if (state.isVisible) {
Popup(
popupPositionProvider = positionProvider,
onDismissRequest = { state.dismiss() },
- focusable = focusable
+ properties = PopupProperties(
+ // TODO(b/326167778): focusable = true cannot work with mouse
+ focusable = false
+ )
) { tooltip() }
}
}
diff --git a/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/window/DesktopPopup.desktop.kt b/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/window/DesktopPopup.desktop.kt
index 7ad8bde..697c44e 100644
--- a/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/window/DesktopPopup.desktop.kt
+++ b/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/window/DesktopPopup.desktop.kt
@@ -16,29 +16,12 @@
package androidx.compose.ui.window
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.DisposableEffect
-import androidx.compose.runtime.Immutable
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
-import androidx.compose.runtime.rememberCompositionContext
-import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
-import androidx.compose.ui.LocalComposeScene
-import androidx.compose.ui.Modifier
import androidx.compose.ui.awt.LocalLayerContainer
import androidx.compose.ui.geometry.Offset
-import androidx.compose.ui.input.key.Key
-import androidx.compose.ui.input.key.KeyEvent
-import androidx.compose.ui.input.key.KeyEventType
-import androidx.compose.ui.input.key.key
-import androidx.compose.ui.input.key.type
-import androidx.compose.ui.layout.Layout
-import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
-import androidx.compose.ui.platform.SkiaBasedOwner
-import androidx.compose.ui.platform.setContent
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.IntOffset
@@ -49,291 +32,23 @@
import androidx.compose.ui.unit.round
import java.awt.MouseInfo
import javax.swing.SwingUtilities.convertPointFromScreen
-
-@Immutable
-actual class PopupProperties @ExperimentalComposeUiApi actual constructor(
- actual val focusable: Boolean,
- actual val dismissOnBackPress: Boolean,
- actual val dismissOnClickOutside: Boolean,
- actual val clippingEnabled: Boolean,
-) {
- override fun equals(other: Any?): Boolean {
- if (this === other) return true
- if (other !is PopupProperties) return false
-
- if (focusable != other.focusable) return false
- if (dismissOnBackPress != other.dismissOnBackPress) return false
- if (dismissOnClickOutside != other.dismissOnClickOutside) return false
- if (clippingEnabled != other.clippingEnabled) return false
-
- return true
- }
-
- override fun hashCode(): Int {
- var result = dismissOnBackPress.hashCode()
- result = 31 * result + focusable.hashCode()
- result = 31 * result + dismissOnBackPress.hashCode()
- result = 31 * result + dismissOnClickOutside.hashCode()
- result = 31 * result + clippingEnabled.hashCode()
- return result
- }
-}
+import kotlin.math.roundToInt
/**
- * Opens a popup with the given content.
- *
- * The popup is positioned relative to its parent, using the [alignment] and [offset].
- * The popup is visible as long as it is part of the composition hierarchy.
- *
- * @sample androidx.compose.ui.samples.PopupSample
- *
- * @param alignment The alignment relative to the parent.
- * @param offset An offset from the original aligned position of the popup. Offset respects the
- * Ltr/Rtl context, thus in Ltr it will be added to the original aligned position and in Rtl it
- * will be subtracted from it.
- * @param focusable Indicates if the popup can grab the focus.
- * @param onDismissRequest Executes when the user clicks outside of the popup.
- * @param onPreviewKeyEvent This callback is invoked when the user interacts with the hardware
- * keyboard. It gives ancestors of a focused component the chance to intercept a [KeyEvent].
- * Return true to stop propagation of this event. If you return false, the key event will be
- * sent to this [onPreviewKeyEvent]'s child. If none of the children consume the event,
- * it will be sent back up to the root using the onKeyEvent callback.
- * @param onKeyEvent This callback is invoked when the user interacts with the hardware
- * keyboard. While implementing this callback, return true to stop propagation of this event.
- * If you return false, the key event will be sent to this [onKeyEvent]'s parent.
- * @param content The content to be displayed inside the popup.
+ * Returns a remembered value of the mouse cursor position or null if cursor is not inside a scene.
*/
@Composable
-fun Popup(
- alignment: Alignment = Alignment.TopStart,
- offset: IntOffset = IntOffset(0, 0),
- focusable: Boolean = false,
- onDismissRequest: (() -> Unit)? = null,
- onPreviewKeyEvent: ((KeyEvent) -> Boolean) = { false },
- onKeyEvent: ((KeyEvent) -> Boolean) = { false },
- content: @Composable () -> Unit
-) {
- val popupPositioner = remember(alignment, offset) {
- AlignmentOffsetPositionProvider(
- alignment,
- offset
+private fun rememberCursorPosition(): Offset? {
+ // TODO: Apply changes from https://github.com/JetBrains/compose-multiplatform-core/pull/432
+ val component = LocalLayerContainer.current
+ return remember {
+ val awtMousePosition = MouseInfo.getPointerInfo().location
+ convertPointFromScreen(awtMousePosition, component)
+ Offset(
+ (awtMousePosition.x * component.density.density),
+ (awtMousePosition.y * component.density.density)
)
}
-
- Popup(
- popupPositionProvider = popupPositioner,
- onDismissRequest = onDismissRequest,
- onKeyEvent = onKeyEvent,
- onPreviewKeyEvent = onPreviewKeyEvent,
- focusable = focusable,
- content = content
- )
-}
-
-/**
- * Opens a popup with the given content.
- *
- * The popup is positioned using a custom [popupPositionProvider].
- *
- * @sample androidx.compose.ui.samples.PopupSample
- *
- * @param popupPositionProvider Provides the screen position of the popup.
- * @param onDismissRequest Executes when the user clicks outside of the popup.
- * @param focusable Indicates if the popup can grab the focus.
- * @param onPreviewKeyEvent This callback is invoked when the user interacts with the hardware
- * keyboard. It gives ancestors of a focused component the chance to intercept a [KeyEvent].
- * Return true to stop propagation of this event. If you return false, the key event will be
- * sent to this [onPreviewKeyEvent]'s child. If none of the children consume the event,
- * it will be sent back up to the root using the onKeyEvent callback.
- * @param onKeyEvent This callback is invoked when the user interacts with the hardware
- * keyboard. While implementing this callback, return true to stop propagation of this event.
- * If you return false, the key event will be sent to this [onKeyEvent]'s parent.
- * @param content The content to be displayed inside the popup.
- */
-@Composable
-fun Popup(
- popupPositionProvider: PopupPositionProvider,
- onDismissRequest: (() -> Unit)? = null,
- onPreviewKeyEvent: ((KeyEvent) -> Boolean) = { false },
- onKeyEvent: ((KeyEvent) -> Boolean) = { false },
- focusable: Boolean = false,
- content: @Composable () -> Unit
-) {
- PopupLayout(
- popupPositionProvider = popupPositionProvider,
- focusable = focusable,
- onDismissRequest = if (focusable) onDismissRequest else null,
- onPreviewKeyEvent = onPreviewKeyEvent,
- onKeyEvent = onKeyEvent,
- content = content
- )
-}
-
-/**
- * Opens a popup with the given content.
- *
- * A popup is a floating container that appears on top of the current activity.
- * It is especially useful for non-modal UI surfaces that remain hidden until they
- * are needed, for example floating menus like Cut/Copy/Paste.
- *
- * The popup is positioned relative to its parent, using the [alignment] and [offset].
- * The popup is visible as long as it is part of the composition hierarchy.
- *
- * @sample androidx.compose.ui.samples.PopupSample
- *
- * @param alignment The alignment relative to the parent.
- * @param offset An offset from the original aligned position of the popup. Offset respects the
- * Ltr/Rtl context, thus in Ltr it will be added to the original aligned position and in Rtl it
- * will be subtracted from it.
- * @param onDismissRequest Executes when the user clicks outside of the popup.
- * @param properties [PopupProperties] for further customization of this popup's behavior.
- * @param content The content to be displayed inside the popup.
- */
-@Composable
-actual fun Popup(
- alignment: Alignment,
- offset: IntOffset,
- onDismissRequest: (() -> Unit)?,
- properties: PopupProperties,
- content: @Composable () -> Unit
-) {
- val popupPositioner = remember(alignment, offset) {
- AlignmentOffsetPositionProvider(
- alignment,
- offset
- )
- }
-
- Popup(
- popupPositionProvider = popupPositioner,
- onDismissRequest = onDismissRequest,
- properties = properties,
- content = content
- )
-}
-
-/**
- * Opens a popup with the given content.
- *
- * The popup is positioned using a custom [popupPositionProvider].
- *
- * @sample androidx.compose.ui.samples.PopupSample
- *
- * @param popupPositionProvider Provides the screen position of the popup.
- * @param onDismissRequest Executes when the user clicks outside of the popup.
- * @param properties [PopupProperties] for further customization of this popup's behavior.
- * @param content The content to be displayed inside the popup.
- */
-@Composable
-actual fun Popup(
- popupPositionProvider: PopupPositionProvider,
- onDismissRequest: (() -> Unit)?,
- properties: PopupProperties,
- content: @Composable () -> Unit
-) {
- PopupLayout(
- popupPositionProvider,
- properties.focusable,
- if (properties.dismissOnClickOutside) onDismissRequest else null,
- onKeyEvent = {
- if (properties.dismissOnBackPress &&
- it.type == KeyEventType.KeyDown && it.key == Key.Escape &&
- onDismissRequest != null
- ) {
- onDismissRequest()
- true
- } else {
- false
- }
- },
- content = content
- )
-}
-
-@Composable
-internal fun PopupLayout(
- popupPositionProvider: PopupPositionProvider,
- focusable: Boolean,
- onDismissRequest: (() -> Unit)?,
- modifier: Modifier = Modifier,
- onPreviewKeyEvent: ((KeyEvent) -> Boolean) = { false },
- onKeyEvent: ((KeyEvent) -> Boolean) = { false },
- content: @Composable () -> Unit
-) {
- val scene = LocalComposeScene.current
- val density = LocalDensity.current
-
- var parentBounds by remember { mutableStateOf(IntRect.Zero) }
- var popupBounds by remember { mutableStateOf(IntRect.Zero) }
-
- // getting parent bounds
- Layout(
- content = {},
- modifier = Modifier.onGloballyPositioned { childCoordinates ->
- val coordinates = childCoordinates.parentCoordinates!!
- parentBounds = IntRect(
- coordinates.localToWindow(Offset.Zero).round(),
- coordinates.size
- )
- },
- measurePolicy = { _, _ ->
- layout(0, 0) {}
- }
- )
-
- val parentComposition = rememberCompositionContext()
- val (owner, composition) = remember {
- val owner = SkiaBasedOwner(
- platformInputService = scene.platformInputService,
- component = scene.component,
- density = density,
- coroutineContext = parentComposition.effectCoroutineContext,
- isPopup = true,
- isFocusable = focusable,
- onDismissRequest = onDismissRequest,
- onPreviewKeyEvent = onPreviewKeyEvent,
- onKeyEvent = onKeyEvent
- )
- scene.attach(owner)
- val composition = owner.setContent(parent = parentComposition) {
- Layout(
- content = content,
- modifier = modifier,
- measurePolicy = { measurables, constraints ->
- val width = constraints.maxWidth
- val height = constraints.maxHeight
-
- layout(constraints.maxWidth, constraints.maxHeight) {
- measurables.forEach {
- val placeable = it.measure(constraints)
- val position = popupPositionProvider.calculatePosition(
- anchorBounds = parentBounds,
- windowSize = IntSize(width, height),
- layoutDirection = layoutDirection,
- popupContentSize = IntSize(placeable.width, placeable.height)
- )
-
- popupBounds = IntRect(
- position,
- IntSize(placeable.width, placeable.height)
- )
- owner.bounds = popupBounds
- placeable.place(position.x, position.y)
- }
- }
- }
- )
- }
- owner to composition
- }
- owner.density = density
- DisposableEffect(Unit) {
- onDispose {
- scene.detach(owner)
- composition.dispose()
- owner.dispose()
- }
- }
}
/**
@@ -343,58 +58,122 @@
* @param alignment The alignment of the popup relative to the current cursor position.
* @param windowMargin Defines the area within the window that limits the placement of the popup.
*/
+@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun rememberCursorPositionProvider(
offset: DpOffset = DpOffset.Zero,
alignment: Alignment = Alignment.BottomEnd,
windowMargin: Dp = 4.dp
-): PopupPositionProvider = with(LocalDensity.current) {
- val component = LocalLayerContainer.current
- val cursorPoint = remember {
- val awtMousePosition = MouseInfo.getPointerInfo().location
- convertPointFromScreen(awtMousePosition, component)
- IntOffset(
- (awtMousePosition.x * component.density.density).toInt(),
- (awtMousePosition.y * component.density.density).toInt()
+): PopupPositionProvider {
+ val offsetPx = with(LocalDensity.current) {
+ Offset(offset.x.toPx(), offset.y.toPx())
+ }
+ val windowMarginPx = with(LocalDensity.current) {
+ windowMargin.roundToPx()
+ }
+ val cursorPosition = rememberCursorPosition()
+
+ if (cursorPosition == null) {
+ // if cursor is outside the scene, show popup under the parent component
+ return rememberComponentRectPositionProvider(
+ alignment = alignment,
+ offset = offset
)
}
- val offsetPx = IntOffset(offset.x.roundToPx(), offset.y.roundToPx())
+
+ return remember(cursorPosition, offsetPx, alignment, windowMarginPx) {
+ PopupPositionProviderAtPosition(
+ positionPx = cursorPosition,
+ isRelativeToAnchor = false,
+ offsetPx = offsetPx,
+ alignment = alignment,
+ windowMarginPx = windowMarginPx
+ )
+ }
+}
+
+/**
+ * A [PopupPositionProvider] that positions the popup at the given position relative to the anchor.
+ *
+ * @param positionPx the offset, in pixels, relative to the anchor, to position the popup at.
+ * @param offset [DpOffset] to be added to the position of the popup.
+ * @param alignment The alignment of the popup relative to desired position.
+ * @param windowMargin Defines the area within the window that limits the placement of the popup.
+ */
+@ExperimentalComposeUiApi
+@Composable
+fun rememberPopupPositionProviderAtPosition(
+ positionPx: Offset,
+ offset: DpOffset = DpOffset.Zero,
+ alignment: Alignment = Alignment.BottomEnd,
+ windowMargin: Dp = 4.dp
+): PopupPositionProvider = with(LocalDensity.current) {
+ val offsetPx = Offset(offset.x.toPx(), offset.y.toPx())
val windowMarginPx = windowMargin.roundToPx()
- object : PopupPositionProvider {
- override fun calculatePosition(
- anchorBounds: IntRect,
- windowSize: IntSize,
- layoutDirection: LayoutDirection,
- popupContentSize: IntSize
- ) = with(density) {
- val anchor = IntRect(cursorPoint, IntSize.Zero)
- val tooltipArea = IntRect(
- IntOffset(
- anchor.left - popupContentSize.width,
- anchor.top - popupContentSize.height,
- ),
- IntSize(
- popupContentSize.width * 2,
- popupContentSize.height * 2
- )
+
+ remember(positionPx, offsetPx, alignment, windowMarginPx) {
+ PopupPositionProviderAtPosition(
+ positionPx = positionPx,
+ isRelativeToAnchor = true,
+ offsetPx = offsetPx,
+ alignment = alignment,
+ windowMarginPx = windowMarginPx
+ )
+ }
+}
+
+/**
+ * A [PopupPositionProvider] that positions the popup at the given offsets and alignment.
+ *
+ * @param positionPx The offset of the popup's location, in pixels.
+ * @param isRelativeToAnchor Whether [positionPx] is relative to the anchor bounds passed to
+ * [calculatePosition]. If `false`, it is relative to the window.
+ * @param offsetPx Extra offset to be added to the position of the popup, in pixels.
+ * @param alignment The alignment of the popup relative to desired position.
+ * @param windowMarginPx Defines the area within the window that limits the placement of the popup,
+ * in pixels.
+ */
+@ExperimentalComposeUiApi
+class PopupPositionProviderAtPosition(
+ val positionPx: Offset,
+ val isRelativeToAnchor: Boolean,
+ val offsetPx: Offset,
+ val alignment: Alignment = Alignment.BottomEnd,
+ val windowMarginPx: Int,
+) : PopupPositionProvider {
+ override fun calculatePosition(
+ anchorBounds: IntRect,
+ windowSize: IntSize,
+ layoutDirection: LayoutDirection,
+ popupContentSize: IntSize
+ ): IntOffset {
+ val anchor = IntRect(
+ offset = positionPx.round() +
+ (if (isRelativeToAnchor) anchorBounds.topLeft else IntOffset.Zero),
+ size = IntSize.Zero)
+ val tooltipArea = IntRect(
+ IntOffset(
+ anchor.left - popupContentSize.width,
+ anchor.top - popupContentSize.height,
+ ),
+ IntSize(
+ popupContentSize.width * 2,
+ popupContentSize.height * 2
)
- val position = alignment.align(popupContentSize, tooltipArea.size, layoutDirection)
- var x = tooltipArea.left + position.x + offsetPx.x
- var y = tooltipArea.top + position.y + offsetPx.y
- if (x + popupContentSize.width > windowSize.width - windowMarginPx) {
- x -= popupContentSize.width
- }
- if (y + popupContentSize.height > windowSize.height - windowMarginPx) {
- y -= popupContentSize.height + anchor.height
- }
- if (x < windowMarginPx) {
- x = windowMarginPx
- }
- if (y < windowMarginPx) {
- y = windowMarginPx
- }
- IntOffset(x, y)
+ )
+ val position = alignment.align(popupContentSize, tooltipArea.size, layoutDirection)
+ var x = tooltipArea.left + position.x + offsetPx.x
+ var y = tooltipArea.top + position.y + offsetPx.y
+ if (x + popupContentSize.width > windowSize.width - windowMarginPx) {
+ x -= popupContentSize.width
}
+ if (y + popupContentSize.height > windowSize.height - windowMarginPx) {
+ y -= popupContentSize.height + anchor.height
+ }
+ x = x.coerceAtLeast(windowMarginPx.toFloat())
+ y = y.coerceAtLeast(windowMarginPx.toFloat())
+
+ return IntOffset(x.roundToInt(), y.roundToInt())
}
}
@@ -410,28 +189,32 @@
anchor: Alignment = Alignment.BottomCenter,
alignment: Alignment = Alignment.BottomCenter,
offset: DpOffset = DpOffset.Zero
-): PopupPositionProvider = with(LocalDensity.current) {
- val offsetPx = IntOffset(offset.x.roundToPx(), offset.y.roundToPx())
- return object : PopupPositionProvider {
- override fun calculatePosition(
- anchorBounds: IntRect,
- windowSize: IntSize,
- layoutDirection: LayoutDirection,
- popupContentSize: IntSize
- ): IntOffset {
- val anchorPoint = anchor.align(IntSize.Zero, anchorBounds.size, layoutDirection)
- val tooltipArea = IntRect(
- IntOffset(
- anchorBounds.left + anchorPoint.x - popupContentSize.width,
- anchorBounds.top + anchorPoint.y - popupContentSize.height,
- ),
- IntSize(
- popupContentSize.width * 2,
- popupContentSize.height * 2
+): PopupPositionProvider {
+ val offsetPx = with(LocalDensity.current) {
+ IntOffset(offset.x.roundToPx(), offset.y.roundToPx())
+ }
+ return remember(anchor, alignment, offsetPx) {
+ object : PopupPositionProvider {
+ override fun calculatePosition(
+ anchorBounds: IntRect,
+ windowSize: IntSize,
+ layoutDirection: LayoutDirection,
+ popupContentSize: IntSize
+ ): IntOffset {
+ val anchorPoint = anchor.align(IntSize.Zero, anchorBounds.size, layoutDirection)
+ val tooltipArea = IntRect(
+ IntOffset(
+ anchorBounds.left + anchorPoint.x - popupContentSize.width,
+ anchorBounds.top + anchorPoint.y - popupContentSize.height,
+ ),
+ IntSize(
+ popupContentSize.width * 2,
+ popupContentSize.height * 2
+ )
)
- )
- val position = alignment.align(popupContentSize, tooltipArea.size, layoutDirection)
- return tooltipArea.topLeft + position + offsetPx
+ val position = alignment.align(popupContentSize, tooltipArea.size, layoutDirection)
+ return tooltipArea.topLeft + position + offsetPx
+ }
}
}
}
diff --git a/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/window/Dialog.desktop.kt b/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/window/Dialog.desktop.kt
index 4d9e671..f238d7d 100644
--- a/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/window/Dialog.desktop.kt
+++ b/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/window/Dialog.desktop.kt
@@ -18,26 +18,16 @@
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
-import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.currentCompositionLocalContext
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
-import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
-import androidx.compose.ui.Modifier
import androidx.compose.ui.awt.ComposeDialog
-import androidx.compose.ui.draw.drawBehind
-import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.Painter
-import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEvent
-import androidx.compose.ui.input.key.KeyEventType
-import androidx.compose.ui.input.key.key
-import androidx.compose.ui.input.key.type
import androidx.compose.ui.unit.DpSize
-import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.ComponentUpdater
import androidx.compose.ui.util.makeDisplayable
@@ -53,75 +43,6 @@
import java.awt.event.WindowEvent
import javax.swing.JDialog
-/**
- * Properties used to customize the behavior of a [Dialog].
- *
- * @property dismissOnBackPress whether the popup can be dismissed by pressing the back button
- * * on Android or escape key on desktop.
- * If true, pressing the back button will call onDismissRequest.
- * @property dismissOnClickOutside whether the dialog can be dismissed by clicking outside the
- * dialog's bounds. If true, clicking outside the dialog will call onDismissRequest.
- * @property usePlatformDefaultWidth Whether the width of the dialog's content should be limited to
- * the platform default, which is smaller than the screen width.
- */
-@Immutable
-actual class DialogProperties actual constructor(
- actual val dismissOnBackPress: Boolean,
- actual val dismissOnClickOutside: Boolean,
- actual val usePlatformDefaultWidth: Boolean,
-) {
- override fun equals(other: Any?): Boolean {
- if (this === other) return true
- if (other !is DialogProperties) return false
-
- if (dismissOnBackPress != other.dismissOnBackPress) return false
- if (dismissOnClickOutside != other.dismissOnClickOutside) return false
- if (usePlatformDefaultWidth != other.usePlatformDefaultWidth) return false
-
- return true
- }
-
- override fun hashCode(): Int {
- var result = dismissOnBackPress.hashCode()
- result = 31 * result + dismissOnClickOutside.hashCode()
- result = 31 * result + usePlatformDefaultWidth.hashCode()
- return result
- }
-}
-
-@Composable
-actual fun Dialog(
- onDismissRequest: () -> Unit,
- properties: DialogProperties,
- content: @Composable () -> Unit
-) {
- val popupPositioner = remember {
- AlignmentOffsetPositionProvider(
- alignment = Alignment.Center,
- offset = IntOffset(0, 0)
- )
- }
- PopupLayout(
- popupPositionProvider = popupPositioner,
- focusable = true,
- if (properties.dismissOnClickOutside) onDismissRequest else null,
- modifier = Modifier.drawBehind {
- drawRect(Color.Black.copy(alpha = 0.6f))
- },
- onKeyEvent = {
- if (properties.dismissOnBackPress &&
- it.type == KeyEventType.KeyDown && it.key == Key.Escape
- ) {
- onDismissRequest()
- true
- } else {
- false
- }
- },
- content = content
- )
-}
-
@Deprecated(
message = "Replaced by DialogWindow",
replaceWith = ReplaceWith("DialogWindow(" +
@@ -155,26 +76,65 @@
resizable,
enabled,
focusable,
+ alwaysOnTop = false,
onPreviewKeyEvent,
onKeyEvent,
content
)
+@Deprecated(
+ level = DeprecationLevel.HIDDEN,
+ message = "Replaced by an overload that also takes alwaysOnTop",
+)
+@Composable
+fun DialogWindow(
+ onCloseRequest: () -> Unit,
+ state: DialogState = rememberDialogState(),
+ visible: Boolean = true,
+ title: String = "Untitled",
+ icon: Painter? = null,
+ undecorated: Boolean = false,
+ transparent: Boolean = false,
+ resizable: Boolean = true,
+ enabled: Boolean = true,
+ focusable: Boolean = true,
+ onPreviewKeyEvent: ((KeyEvent) -> Boolean) = { false },
+ onKeyEvent: ((KeyEvent) -> Boolean) = { false },
+ content: @Composable DialogWindowScope.() -> Unit
+) {
+ DialogWindow(
+ onCloseRequest,
+ state,
+ visible,
+ title,
+ icon,
+ undecorated,
+ transparent,
+ resizable,
+ enabled,
+ focusable,
+ alwaysOnTop = false,
+ onPreviewKeyEvent,
+ onKeyEvent,
+ content
+ )
+}
+
/**
* Composes platform dialog in the current composition. When Dialog enters the composition,
* a new platform dialog will be created and receives the focus. When Dialog leaves the
* composition, dialog will be disposed and closed.
*
- * Dialog is a modal window. It means it blocks the parent [Window] / [Dialog] in which composition
+ * Dialog is a modal window. It means it blocks the parent [Window] / [DialogWindow] in which composition
* context it was created.
*
* Usage:
* ```
* @Composable
* fun main() = application {
- * val isDialogOpen by remember { mutableStateOf(true) }
+ * var isDialogOpen by remember { mutableStateOf(true) }
* if (isDialogOpen) {
- * Dialog(onCloseRequest = { isDialogOpen = false })
+ * Dialog(onCloseRequest = { isDialogOpen = false }) {}
* }
* }
* ```
@@ -189,11 +149,11 @@
* the native dialog will update its corresponding properties.
* If [DialogState.position] is not [WindowPosition.isSpecified], then after the first show on the
* screen [DialogState.position] will be set to the absolute values.
- * @param visible Is [Dialog] visible to user.
+ * @param visible Is [DialogWindow] visible to user.
* If `false`:
- * - internal state of [Dialog] is preserved and will be restored next time the dialog
+ * - internal state of [DialogWindow] is preserved and will be restored next time the dialog
* will be visible;
- * - native resources will not be released. They will be released only when [Dialog]
+ * - native resources will not be released. They will be released only when [DialogWindow]
* will leave the composition.
* @param title Title in the titlebar of the dialog
* @param icon Icon in the titlebar of the window (for platforms which support this).
@@ -207,6 +167,7 @@
* changing [state])
* @param enabled Can dialog react to input events
* @param focusable Can dialog receive focus
+ * @param alwaysOnTop Should the dialog always be on top of another windows and dialogs
* @param onPreviewKeyEvent This callback is invoked when the user interacts with the hardware
* keyboard. It gives ancestors of a focused component the chance to intercept a [KeyEvent].
* Return true to stop propagation of this event. If you return false, the key event will be
@@ -229,6 +190,7 @@
resizable: Boolean = true,
enabled: Boolean = true,
focusable: Boolean = true,
+ alwaysOnTop: Boolean = false,
onPreviewKeyEvent: ((KeyEvent) -> Boolean) = { false },
onKeyEvent: ((KeyEvent) -> Boolean) = { false },
content: @Composable DialogWindowScope.() -> Unit
@@ -243,6 +205,7 @@
val currentResizable by rememberUpdatedState(resizable)
val currentEnabled by rememberUpdatedState(enabled)
val currentFocusable by rememberUpdatedState(focusable)
+ val currentAlwaysOnTop by rememberUpdatedState(alwaysOnTop)
val currentOnCloseRequest by rememberUpdatedState(onCloseRequest)
val updater = remember(::ComponentUpdater)
@@ -281,6 +244,7 @@
set(currentResizable, dialog::setResizable)
set(currentEnabled, dialog::setEnabled)
set(currentFocusable, dialog::setFocusable)
+ set(currentAlwaysOnTop, dialog::setAlwaysOnTop)
set(state.size, dialog::setSizeSafely)
set(state.position, dialog::setPositionSafely)
}
@@ -325,7 +289,7 @@
* Once Dialog leaves the composition, [dispose] will be called to free resources that
* obtained by the [ComposeDialog].
*
- * Dialog is a modal window. It means it blocks the parent [Window] / [Dialog] in which composition
+ * Dialog is a modal window. It means it blocks the parent [Window] / [DialogWindow] in which composition
* context it was created.
*
* The [update] block can be run multiple times (on the UI thread as well) due to recomposition,
@@ -334,13 +298,13 @@
* Note the block will also be ran once right after the [create] block completes.
*
* Dialog is needed for creating dialog's that still can't be created with
- * the default Compose function [androidx.compose.ui.window.Dialog]
+ * the default Compose function [androidx.compose.ui.window.DialogWindow]
*
* @param visible Is [ComposeDialog] visible to user.
* If `false`:
* - internal state of [ComposeDialog] is preserved and will be restored next time the dialog
* will be visible;
- * - native resources will not be released. They will be released only when [Dialog]
+ * - native resources will not be released. They will be released only when [DialogWindow]
* will leave the composition.
* @param onPreviewKeyEvent This callback is invoked when the user interacts with the hardware
* keyboard. It gives ancestors of a focused component the chance to intercept a [KeyEvent].
@@ -393,12 +357,12 @@
}
/**
- * Receiver scope which is used by [androidx.compose.ui.window.Dialog].
+ * Receiver scope which is used by [androidx.compose.ui.window.DialogWindow].
*/
@Stable
interface DialogWindowScope : WindowScope {
/**
- * [ComposeDialog] that was created inside [androidx.compose.ui.window.Dialog].
+ * [ComposeDialog] that was created inside [androidx.compose.ui.window.DialogWindow].
*/
override val window: ComposeDialog
}
diff --git a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/window/Dialog.skiko.kt b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/window/Dialog.skiko.kt
new file mode 100644
index 0000000..9bc95d4
--- /dev/null
+++ b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/window/Dialog.skiko.kt
@@ -0,0 +1,202 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.compose.ui.window
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.Immutable
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCompositionContext
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.ExperimentalComposeUiApi
+import androidx.compose.ui.LocalComposeScene
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.drawBehind
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.input.key.Key
+import androidx.compose.ui.input.key.KeyEvent
+import androidx.compose.ui.input.key.KeyEventType
+import androidx.compose.ui.input.key.key
+import androidx.compose.ui.input.key.type
+import androidx.compose.ui.layout.Layout
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.platform.SkiaBasedOwner
+import androidx.compose.ui.platform.setContent
+import androidx.compose.ui.unit.IntRect
+import androidx.compose.ui.unit.IntSize
+
+/**
+ * The default scrim opacity.
+ */
+private const val DefaultScrimOpacity = 0.6f
+private val DefaultScrimColor = Color.Black.copy(alpha = DefaultScrimOpacity)
+
+/**
+ * Properties used to customize the behavior of a [Dialog].
+ *
+ * @property dismissOnBackPress whether the popup can be dismissed by pressing the back button
+ * * on Android or escape key on desktop.
+ * If true, pressing the back button will call onDismissRequest.
+ * @property dismissOnClickOutside whether the dialog can be dismissed by clicking outside the
+ * dialog's bounds. If true, clicking outside the dialog will call onDismissRequest.
+ * @property usePlatformDefaultWidth Whether the width of the dialog's content should be limited to
+ * the platform default, which is smaller than the screen width.
+ * @property usePlatformInsets Whether the size of the dialog's content should be limited by
+ * platform insets.
+ * @property useSoftwareKeyboardInset Whether the size of the dialog's content should be limited by
+ * software keyboard inset.
+ * @property scrimColor Color of background fill.
+ */
+@Immutable
+actual class DialogProperties @ExperimentalComposeUiApi constructor(
+ actual val dismissOnBackPress: Boolean = true,
+ actual val dismissOnClickOutside: Boolean = true,
+ actual val usePlatformDefaultWidth: Boolean = true,
+ val usePlatformInsets: Boolean = true,
+ val useSoftwareKeyboardInset: Boolean = true,
+ val scrimColor: Color = DefaultScrimColor,
+) {
+ // Constructor with all non-experimental arguments.
+ @OptIn(ExperimentalComposeUiApi::class)
+ actual constructor(
+ dismissOnBackPress: Boolean,
+ dismissOnClickOutside: Boolean,
+ usePlatformDefaultWidth: Boolean,
+ ) : this(
+ dismissOnBackPress = dismissOnBackPress,
+ dismissOnClickOutside = dismissOnClickOutside,
+ usePlatformDefaultWidth = usePlatformDefaultWidth,
+ usePlatformInsets = true,
+ useSoftwareKeyboardInset = true,
+ scrimColor = DefaultScrimColor,
+ )
+
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (other !is DialogProperties) return false
+
+ if (dismissOnBackPress != other.dismissOnBackPress) return false
+ if (dismissOnClickOutside != other.dismissOnClickOutside) return false
+ if (usePlatformDefaultWidth != other.usePlatformDefaultWidth) return false
+ if (usePlatformInsets != other.usePlatformInsets) return false
+ if (useSoftwareKeyboardInset != other.useSoftwareKeyboardInset) return false
+ if (scrimColor != other.scrimColor) return false
+
+ return true
+ }
+
+ override fun hashCode(): Int {
+ var result = dismissOnBackPress.hashCode()
+ result = 31 * result + dismissOnClickOutside.hashCode()
+ result = 31 * result + usePlatformDefaultWidth.hashCode()
+ result = 31 * result + usePlatformInsets.hashCode()
+ result = 31 * result + useSoftwareKeyboardInset.hashCode()
+ result = 31 * result + scrimColor.hashCode()
+ return result
+ }
+}
+
+@Composable
+actual fun Dialog(
+ onDismissRequest: () -> Unit,
+ properties: DialogProperties,
+ content: @Composable () -> Unit
+) {
+ DialogLayout(
+ if (properties.dismissOnClickOutside) onDismissRequest else null,
+ modifier = Modifier.drawBehind {
+ drawRect(properties.scrimColor)
+ },
+ onPreviewKeyEvent = { false },
+ onKeyEvent = {
+ if (properties.dismissOnBackPress && it.isDismissRequest()) {
+ onDismissRequest()
+ true
+ } else {
+ false
+ }
+ },
+ content = content
+ )
+}
+
+@Composable
+internal fun DialogLayout(
+ onDismissRequest: (() -> Unit)?,
+ modifier: Modifier = Modifier,
+ onPreviewKeyEvent: ((KeyEvent) -> Boolean)?,
+ onKeyEvent: ((KeyEvent) -> Boolean)?,
+ content: @Composable () -> Unit
+) {
+ // TODO: Upstream ComposeScene refactor
+ val scene = LocalComposeScene.current
+ val density = LocalDensity.current
+
+ val parentComposition = rememberCompositionContext()
+ val (owner, composition) = remember {
+ val owner = SkiaBasedOwner(
+ platformInputService = scene.platformInputService,
+ component = scene.component,
+ density = density,
+ coroutineContext = parentComposition.effectCoroutineContext,
+ isPopup = true,
+ isFocusable = true,
+ onDismissRequest = onDismissRequest,
+ onPreviewKeyEvent = onPreviewKeyEvent ?: { false },
+ onKeyEvent = onKeyEvent ?: { false }
+ )
+ scene.attach(owner)
+ val composition = owner.setContent(parent = parentComposition) {
+ Layout(
+ content = content,
+ modifier = modifier,
+ measurePolicy = { measurables, constraints ->
+ val width = constraints.maxWidth
+ val height = constraints.maxHeight
+
+ layout(constraints.maxWidth, constraints.maxHeight) {
+ measurables.forEach {
+ val placeable = it.measure(constraints)
+ val position = Alignment.Center.align(
+ size = IntSize(placeable.width, placeable.height),
+ space = IntSize(width, height),
+ layoutDirection = layoutDirection
+ )
+ owner.bounds = IntRect(
+ position,
+ IntSize(placeable.width, placeable.height)
+ )
+ placeable.place(position.x, position.y)
+ }
+ }
+ }
+ )
+ }
+ owner to composition
+ }
+ owner.density = density
+ DisposableEffect(Unit) {
+ onDispose {
+ scene.detach(owner)
+ composition.dispose()
+ owner.dispose()
+ }
+ }
+}
+
+private fun KeyEvent.isDismissRequest() =
+ type == KeyEventType.KeyDown && key == Key.Escape
diff --git a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/window/Popup.skiko.kt b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/window/Popup.skiko.kt
new file mode 100644
index 0000000..15b2c62
--- /dev/null
+++ b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/window/Popup.skiko.kt
@@ -0,0 +1,483 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.compose.ui.window
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.Immutable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCompositionContext
+import androidx.compose.runtime.rememberUpdatedState
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.ExperimentalComposeUiApi
+import androidx.compose.ui.LocalComposeScene
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.input.key.Key
+import androidx.compose.ui.input.key.KeyEvent
+import androidx.compose.ui.input.key.KeyEventType
+import androidx.compose.ui.input.key.key
+import androidx.compose.ui.input.key.type
+import androidx.compose.ui.layout.Layout
+import androidx.compose.ui.layout.onGloballyPositioned
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.platform.SkiaBasedOwner
+import androidx.compose.ui.platform.setContent
+import androidx.compose.ui.semantics.popup
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.unit.IntOffset
+import androidx.compose.ui.unit.IntRect
+import androidx.compose.ui.unit.IntSize
+import androidx.compose.ui.unit.round
+
+/**
+ * Properties used to customize the behavior of a [Popup].
+ *
+ * @property focusable Whether the popup is focusable. When true, the popup will receive IME
+ * events and key presses, such as when the back button is pressed.
+ * @property dismissOnBackPress Whether the popup can be dismissed by pressing the back button
+ * on Android or escape key on desktop.
+ * If true, pressing the back button will call onDismissRequest. Note that [focusable] must be
+ * set to true in order to receive key events such as the back button - if the popup is not
+ * focusable then this property does nothing.
+ * @property dismissOnClickOutside Whether the popup can be dismissed by clicking outside the
+ * popup's bounds. If true, clicking outside the popup will call onDismissRequest.
+ * @property clippingEnabled Whether to allow the popup window to extend beyond the bounds of the
+ * screen. By default, the window is clipped to the screen boundaries. Setting this to false will
+ * allow windows to be accurately positioned.
+ * The default value is true.
+ * @property usePlatformDefaultWidth Whether the width of the popup's content should be limited to
+ * the platform default, which is smaller than the screen width.
+ * @property usePlatformInsets Whether the width of the popup's content should be limited by
+ * platform insets.
+ */
+@Immutable
+actual class PopupProperties @ExperimentalComposeUiApi constructor(
+ actual val focusable: Boolean = false,
+ actual val dismissOnBackPress: Boolean = true,
+ actual val dismissOnClickOutside: Boolean = true,
+ actual val clippingEnabled: Boolean = true,
+ val usePlatformDefaultWidth: Boolean = false,
+ val usePlatformInsets: Boolean = true,
+) {
+ // Constructor with all non-experimental arguments.
+ @OptIn(ExperimentalComposeUiApi::class)
+ actual constructor(
+ focusable: Boolean,
+ dismissOnBackPress: Boolean,
+ dismissOnClickOutside: Boolean,
+ clippingEnabled: Boolean,
+ ) : this(
+ focusable = focusable,
+ dismissOnBackPress = dismissOnBackPress,
+ dismissOnClickOutside = dismissOnClickOutside,
+ clippingEnabled = clippingEnabled,
+ usePlatformDefaultWidth = false,
+ usePlatformInsets = true,
+ )
+
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (other !is PopupProperties) return false
+
+ if (focusable != other.focusable) return false
+ if (dismissOnBackPress != other.dismissOnBackPress) return false
+ if (dismissOnClickOutside != other.dismissOnClickOutside) return false
+ if (clippingEnabled != other.clippingEnabled) return false
+ if (usePlatformDefaultWidth != other.usePlatformDefaultWidth) return false
+ if (usePlatformInsets != other.usePlatformInsets) return false
+
+ return true
+ }
+
+ override fun hashCode(): Int {
+ var result = focusable.hashCode()
+ result = 31 * result + dismissOnBackPress.hashCode()
+ result = 31 * result + dismissOnClickOutside.hashCode()
+ result = 31 * result + clippingEnabled.hashCode()
+ result = 31 * result + usePlatformDefaultWidth.hashCode()
+ result = 31 * result + usePlatformInsets.hashCode()
+ return result
+ }
+}
+
+/**
+ * Opens a popup with the given content.
+ *
+ * The popup is positioned relative to its parent, using the [alignment] and [offset].
+ * The popup is visible as long as it is part of the composition hierarchy.
+ *
+ * @sample androidx.compose.ui.samples.PopupSample
+ *
+ * @param alignment The alignment relative to the parent.
+ * @param offset An offset from the original aligned position of the popup. Offset respects the
+ * Ltr/Rtl context, thus in Ltr it will be added to the original aligned position and in Rtl it
+ * will be subtracted from it.
+ * @param focusable Indicates if the popup can grab the focus.
+ * @param onDismissRequest Executes when the user clicks outside of the popup.
+ * @param onPreviewKeyEvent This callback is invoked when the user interacts with the hardware
+ * keyboard. It gives ancestors of a focused component the chance to intercept a [KeyEvent].
+ * Return true to stop propagation of this event. If you return false, the key event will be
+ * sent to this [onPreviewKeyEvent]'s child. If none of the children consume the event,
+ * it will be sent back up to the root using the onKeyEvent callback.
+ * @param onKeyEvent This callback is invoked when the user interacts with the hardware
+ * keyboard. While implementing this callback, return true to stop propagation of this event.
+ * If you return false, the key event will be sent to this [onKeyEvent]'s parent.
+ * @param content The content to be displayed inside the popup.
+ */
+@Deprecated(
+ "Replaced by Popup with properties parameter",
+ ReplaceWith("Popup(alignment, offset, onDismissRequest, " +
+ "androidx.compose.ui.window.PopupProperties(focusable = focusable), " +
+ "onPreviewKeyEvent, onKeyEvent, content)")
+)
+@Composable
+fun Popup(
+ alignment: Alignment = Alignment.TopStart,
+ offset: IntOffset = IntOffset(0, 0),
+ focusable: Boolean = false,
+ onDismissRequest: (() -> Unit)? = null,
+ onPreviewKeyEvent: ((KeyEvent) -> Boolean) = { false },
+ onKeyEvent: ((KeyEvent) -> Boolean) = { false },
+ content: @Composable () -> Unit
+) = Popup(
+ alignment = alignment,
+ offset = offset,
+ onDismissRequest = onDismissRequest,
+ properties = PopupProperties(
+ focusable = focusable,
+ dismissOnBackPress = true,
+ dismissOnClickOutside = focusable
+
+ ),
+ onPreviewKeyEvent = onPreviewKeyEvent,
+ onKeyEvent = onKeyEvent,
+ content = content
+)
+
+/**
+ * Opens a popup with the given content.
+ *
+ * The popup is positioned using a custom [popupPositionProvider].
+ *
+ * @sample androidx.compose.ui.samples.PopupSample
+ *
+ * @param popupPositionProvider Provides the screen position of the popup.
+ * @param onDismissRequest Executes when the user clicks outside of the popup.
+ * @param focusable Indicates if the popup can grab the focus.
+ * @param onPreviewKeyEvent This callback is invoked when the user interacts with the hardware
+ * keyboard. It gives ancestors of a focused component the chance to intercept a [KeyEvent].
+ * Return true to stop propagation of this event. If you return false, the key event will be
+ * sent to this [onPreviewKeyEvent]'s child. If none of the children consume the event,
+ * it will be sent back up to the root using the onKeyEvent callback.
+ * @param onKeyEvent This callback is invoked when the user interacts with the hardware
+ * keyboard. While implementing this callback, return true to stop propagation of this event.
+ * If you return false, the key event will be sent to this [onKeyEvent]'s parent.
+ * @param content The content to be displayed inside the popup.
+ */
+@Deprecated(
+ "Replaced by Popup with properties parameter",
+ ReplaceWith("Popup(popupPositionProvider, onDismissRequest, " +
+ "androidx.compose.ui.window.PopupProperties(focusable = focusable), " +
+ "onPreviewKeyEvent, onKeyEvent, content)")
+)
+@Composable
+fun Popup(
+ popupPositionProvider: PopupPositionProvider,
+ onDismissRequest: (() -> Unit)? = null,
+ onPreviewKeyEvent: ((KeyEvent) -> Boolean) = { false },
+ onKeyEvent: ((KeyEvent) -> Boolean) = { false },
+ focusable: Boolean = false,
+ content: @Composable () -> Unit
+) = Popup(
+ popupPositionProvider = popupPositionProvider,
+ onDismissRequest = onDismissRequest,
+ properties = PopupProperties(
+ focusable = focusable,
+ dismissOnBackPress = true,
+ dismissOnClickOutside = focusable
+
+ ),
+ onPreviewKeyEvent = onPreviewKeyEvent,
+ onKeyEvent = onKeyEvent,
+ content = content
+)
+
+/**
+ * Opens a popup with the given content.
+ *
+ * A popup is a floating container that appears on top of the current activity.
+ * It is especially useful for non-modal UI surfaces that remain hidden until they
+ * are needed, for example floating menus like Cut/Copy/Paste.
+ *
+ * The popup is positioned relative to its parent, using the [alignment] and [offset].
+ * The popup is visible as long as it is part of the composition hierarchy.
+ *
+ * @sample androidx.compose.ui.samples.PopupSample
+ *
+ * @param alignment The alignment relative to the parent.
+ * @param offset An offset from the original aligned position of the popup. Offset respects the
+ * Ltr/Rtl context, thus in Ltr it will be added to the original aligned position and in Rtl it
+ * will be subtracted from it.
+ * @param onDismissRequest Executes when the user clicks outside of the popup.
+ * @param properties [PopupProperties] for further customization of this popup's behavior.
+ * @param content The content to be displayed inside the popup.
+ */
+@Composable
+actual fun Popup(
+ alignment: Alignment,
+ offset: IntOffset,
+ onDismissRequest: (() -> Unit)?,
+ properties: PopupProperties,
+ content: @Composable () -> Unit
+): Unit = Popup(
+ alignment = alignment,
+ offset = offset,
+ onDismissRequest = onDismissRequest,
+ properties = properties,
+ onPreviewKeyEvent = null,
+ onKeyEvent = null,
+ content = content
+)
+
+/**
+ * Opens a popup with the given content.
+ *
+ * The popup is positioned using a custom [popupPositionProvider].
+ *
+ * @sample androidx.compose.ui.samples.PopupSample
+ *
+ * @param popupPositionProvider Provides the screen position of the popup.
+ * @param onDismissRequest Executes when the user clicks outside of the popup.
+ * @param properties [PopupProperties] for further customization of this popup's behavior.
+ * @param content The content to be displayed inside the popup.
+ */
+@Composable
+actual fun Popup(
+ popupPositionProvider: PopupPositionProvider,
+ onDismissRequest: (() -> Unit)?,
+ properties: PopupProperties,
+ content: @Composable () -> Unit
+): Unit = Popup(
+ popupPositionProvider = popupPositionProvider,
+ onDismissRequest = onDismissRequest,
+ properties = properties,
+ onPreviewKeyEvent = null,
+ onKeyEvent = null,
+ content = content
+)
+
+/**
+ * Opens a popup with the given content.
+ *
+ * A popup is a floating container that appears on top of the current activity.
+ * It is especially useful for non-modal UI surfaces that remain hidden until they
+ * are needed, for example floating menus like Cut/Copy/Paste.
+ *
+ * The popup is positioned relative to its parent, using the [alignment] and [offset].
+ * The popup is visible as long as it is part of the composition hierarchy.
+ *
+ * @sample androidx.compose.ui.samples.PopupSample
+ *
+ * @param alignment The alignment relative to the parent.
+ * @param offset An offset from the original aligned position of the popup. Offset respects the
+ * Ltr/Rtl context, thus in Ltr it will be added to the original aligned position and in Rtl it
+ * will be subtracted from it.
+ * @param onDismissRequest Executes when the user clicks outside of the popup.
+ * @param properties [PopupProperties] for further customization of this popup's behavior.
+ * @param onPreviewKeyEvent This callback is invoked when the user interacts with the hardware
+ * keyboard. It gives ancestors of a focused component the chance to intercept a [KeyEvent].
+ * Return true to stop propagation of this event. If you return false, the key event will be
+ * sent to this [onPreviewKeyEvent]'s child. If none of the children consume the event,
+ * it will be sent back up to the root using the onKeyEvent callback.
+ * @param onKeyEvent This callback is invoked when the user interacts with the hardware
+ * keyboard. While implementing this callback, return true to stop propagation of this event.
+ * If you return false, the key event will be sent to this [onKeyEvent]'s parent.
+ * @param content The content to be displayed inside the popup.
+ */
+@Composable
+fun Popup(
+ alignment: Alignment = Alignment.TopStart,
+ offset: IntOffset = IntOffset(0, 0),
+ onDismissRequest: (() -> Unit)? = null,
+ properties: PopupProperties = PopupProperties(),
+ onPreviewKeyEvent: ((KeyEvent) -> Boolean)? = null,
+ onKeyEvent: ((KeyEvent) -> Boolean)? = null,
+ content: @Composable () -> Unit
+) {
+ val popupPositioner = remember(alignment, offset) {
+ AlignmentOffsetPositionProvider(alignment, offset)
+ }
+ Popup(
+ popupPositionProvider = popupPositioner,
+ onDismissRequest = onDismissRequest,
+ properties = properties,
+ onPreviewKeyEvent = onPreviewKeyEvent,
+ onKeyEvent = onKeyEvent,
+ content = content
+ )
+}
+
+/**
+ * Opens a popup with the given content.
+ *
+ * The popup is positioned using a custom [popupPositionProvider].
+ *
+ * @sample androidx.compose.ui.samples.PopupSample
+ *
+ * @param popupPositionProvider Provides the screen position of the popup.
+ * @param onDismissRequest Executes when the user clicks outside of the popup.
+ * @param properties [PopupProperties] for further customization of this popup's behavior.
+ * @param onPreviewKeyEvent This callback is invoked when the user interacts with the hardware
+ * keyboard. It gives ancestors of a focused component the chance to intercept a [KeyEvent].
+ * Return true to stop propagation of this event. If you return false, the key event will be
+ * sent to this [onPreviewKeyEvent]'s child. If none of the children consume the event,
+ * it will be sent back up to the root using the onKeyEvent callback.
+ * @param onKeyEvent This callback is invoked when the user interacts with the hardware
+ * keyboard. While implementing this callback, return true to stop propagation of this event.
+ * If you return false, the key event will be sent to this [onKeyEvent]'s parent.
+ * @param content The content to be displayed inside the popup.
+ */
+@Composable
+fun Popup(
+ popupPositionProvider: PopupPositionProvider,
+ onDismissRequest: (() -> Unit)? = null,
+ properties: PopupProperties = PopupProperties(),
+ onPreviewKeyEvent: ((KeyEvent) -> Boolean)? = null,
+ onKeyEvent: ((KeyEvent) -> Boolean)? = null,
+ content: @Composable () -> Unit
+) {
+ val currentOnDismissRequest by rememberUpdatedState(onDismissRequest)
+ val currentOnKeyEvent by rememberUpdatedState(onKeyEvent)
+
+ val overriddenOnKeyEvent = if (properties.dismissOnBackPress && onDismissRequest != null) {
+ // No need to remember this lambda, as it doesn't capture any values that can change.
+ { event: KeyEvent ->
+ val consumed = currentOnKeyEvent?.invoke(event) ?: false
+ if (!consumed && event.isDismissRequest()) {
+ currentOnDismissRequest?.invoke()
+ true
+ } else {
+ consumed
+ }
+ }
+ } else {
+ onKeyEvent
+ }
+ PopupLayout(
+ popupPositionProvider = popupPositionProvider,
+ focusable = properties.focusable,
+ onDismissRequest = onDismissRequest,
+ modifier = Modifier.semantics { popup() },
+ onPreviewKeyEvent = onPreviewKeyEvent,
+ onKeyEvent = overriddenOnKeyEvent,
+ content = content,
+ )
+}
+
+@Composable
+internal fun PopupLayout(
+ popupPositionProvider: PopupPositionProvider,
+ focusable: Boolean,
+ onDismissRequest: (() -> Unit)?,
+ modifier: Modifier = Modifier,
+ onPreviewKeyEvent: ((KeyEvent) -> Boolean)?,
+ onKeyEvent: ((KeyEvent) -> Boolean)?,
+ content: @Composable () -> Unit
+) {
+ // TODO: Upstream ComposeScene refactor
+ val scene = LocalComposeScene.current
+ val density = LocalDensity.current
+
+ var parentBounds by remember { mutableStateOf(IntRect.Zero) }
+ var popupBounds by remember { mutableStateOf(IntRect.Zero) }
+
+ // getting parent bounds
+ Layout(
+ content = {},
+ modifier = Modifier.onGloballyPositioned { childCoordinates ->
+ val coordinates = childCoordinates.parentCoordinates!!
+ parentBounds = IntRect(
+ coordinates.localToWindow(Offset.Zero).round(),
+ coordinates.size
+ )
+ },
+ measurePolicy = { _, _ ->
+ layout(0, 0) {}
+ }
+ )
+
+ val parentComposition = rememberCompositionContext()
+ val (owner, composition) = remember {
+ val owner = SkiaBasedOwner(
+ platformInputService = scene.platformInputService,
+ component = scene.component,
+ density = density,
+ coroutineContext = parentComposition.effectCoroutineContext,
+ isPopup = true,
+ isFocusable = focusable,
+ onDismissRequest = onDismissRequest,
+ onPreviewKeyEvent = onPreviewKeyEvent ?: { false },
+ onKeyEvent = onKeyEvent ?: { false }
+ )
+ scene.attach(owner)
+ val composition = owner.setContent(parent = parentComposition) {
+ Layout(
+ content = content,
+ modifier = modifier,
+ measurePolicy = { measurables, constraints ->
+ val width = constraints.maxWidth
+ val height = constraints.maxHeight
+
+ layout(constraints.maxWidth, constraints.maxHeight) {
+ measurables.forEach {
+ val placeable = it.measure(constraints)
+ val position = popupPositionProvider.calculatePosition(
+ anchorBounds = parentBounds,
+ windowSize = IntSize(width, height),
+ layoutDirection = layoutDirection,
+ popupContentSize = IntSize(placeable.width, placeable.height)
+ )
+
+ popupBounds = IntRect(
+ position,
+ IntSize(placeable.width, placeable.height)
+ )
+ owner.bounds = popupBounds
+ placeable.place(position.x, position.y)
+ }
+ }
+ }
+ )
+ }
+ owner to composition
+ }
+ owner.density = density
+ DisposableEffect(Unit) {
+ onDispose {
+ scene.detach(owner)
+ composition.dispose()
+ owner.dispose()
+ }
+ }
+}
+
+private fun KeyEvent.isDismissRequest() =
+ type == KeyEventType.KeyDown && key == Key.Escape