diff --git a/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/awt/SwingInteropContainer.desktop.kt b/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/awt/SwingInteropContainer.desktop.kt index cb6ab5922c437..62f17aef1a15c 100644 --- a/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/awt/SwingInteropContainer.desktop.kt +++ b/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/awt/SwingInteropContainer.desktop.kt @@ -30,6 +30,10 @@ import androidx.compose.ui.scene.ComposeSceneMediator import androidx.compose.ui.unit.IntRect import java.awt.Component import java.awt.Container +import java.awt.event.ContainerAdapter +import java.awt.event.ContainerEvent +import java.awt.event.ContainerListener +import javax.swing.SwingUtilities import org.jetbrains.skiko.ClipRectangle /** @@ -54,7 +58,7 @@ internal class SwingInteropContainer( private val placeInteropAbove: Boolean ): InteropContainer { /** - * @see SwingInteropContainer.addInteropView + * @see SwingInteropContainer.placeInteropView * @see SwingInteropContainer.removeInteropView */ private var interopComponents = mutableMapOf() @@ -63,12 +67,13 @@ internal class SwingInteropContainer( override val interopViews: Set get() = interopComponents.values.toSet() - override fun addInteropView(nativeView: InteropComponent) { + override fun placeInteropView(nativeView: InteropComponent) = SwingUtilities.invokeLater { val component = nativeView.container val nonInteropComponents = container.componentCount - interopComponents.size // AWT uses the reverse order for drawing and events, so index = size - count val index = interopComponents.size - countInteropComponentsBefore(nativeView) interopComponents[component] = nativeView + container.remove(component) container.add(component, if (placeInteropAbove) { index } else { @@ -81,7 +86,7 @@ internal class SwingInteropContainer( container.repaint() } - override fun removeInteropView(nativeView: InteropComponent) { + override fun removeInteropView(nativeView: InteropComponent) = SwingUtilities.invokeLater { val component = nativeView.container container.remove(component) interopComponents.remove(component) @@ -92,6 +97,11 @@ internal class SwingInteropContainer( container.repaint() } + fun validateComponentsOrder() = SwingUtilities.invokeLater { + container.validate() + container.repaint() + } + fun getClipRectForComponent(component: Component): ClipRectangle = requireNotNull(interopComponents[component]) @@ -113,8 +123,10 @@ internal class SwingInteropContainer( * @param component The Swing component that matches the current node. */ internal fun Modifier.trackSwingInterop( + container: SwingInteropContainer, component: InteropComponent ): Modifier = this then TrackInteropModifierElement( + container = container, nativeView = component ) @@ -126,7 +138,7 @@ internal fun Modifier.trackSwingInterop( */ internal open class InteropComponent( val container: Container, - var clipBounds: IntRect? = null + protected var clipBounds: IntRect? = null ) : ClipRectangle { override val x: Float get() = (clipBounds?.left ?: container.x).toFloat() diff --git a/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/awt/SwingPanel.desktop.kt b/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/awt/SwingPanel.desktop.kt index de44524ea243a..1ea734d7ac2dc 100644 --- a/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/awt/SwingPanel.desktop.kt +++ b/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/awt/SwingPanel.desktop.kt @@ -89,52 +89,35 @@ public fun SwingPanel( ) { val interopContainer = LocalSwingInteropContainer.current val compositeKey = currentCompositeKeyHash - val componentInfo = remember { - ComponentInfo( + val interopComponent = remember { + SwingInteropComponent( container = SwingPanelContainer( key = compositeKey, focusComponent = interopContainer.container, - ) + ), + update = update, ) } val density = LocalDensity.current val focusManager = LocalFocusManager.current - val focusSwitcher = remember { FocusSwitcher(componentInfo, focusManager) } + val focusSwitcher = remember { FocusSwitcher(interopComponent, focusManager) } OverlayLayout( modifier = modifier.onGloballyPositioned { coordinates -> val rootCoordinates = coordinates.findRootCoordinates() - val clipedBounds = rootCoordinates + val clippedBounds = rootCoordinates .localBoundingBoxOf(coordinates, clipBounds = true).round(density) val bounds = rootCoordinates .localBoundingBoxOf(coordinates, clipBounds = false).round(density) - // Take care about clipped bounds - componentInfo.clipBounds = clipedBounds // Clipping area for skia canvas - componentInfo.container.isVisible = !clipedBounds.isEmpty // Hide if it's fully clipped - // Swing clips children based on parent's bounds, so use our container for clipping - componentInfo.container.setBounds( - /* x = */ clipedBounds.left, - /* y = */ clipedBounds.top, - /* width = */ clipedBounds.width, - /* height = */ clipedBounds.height - ) - - // The real size and position should be based on not-clipped bounds - componentInfo.component.setBounds( - /* x = */ bounds.left - clipedBounds.left, // Local position relative to container - /* y = */ bounds.top - clipedBounds.top, - /* width = */ bounds.width, - /* height = */ bounds.height - ) - componentInfo.container.validate() - componentInfo.container.repaint() + interopComponent.setBounds(bounds, clippedBounds) + interopContainer.validateComponentsOrder() }.drawBehind { // Clear interop area to make visible the component under our canvas. drawRect(Color.Transparent, blendMode = BlendMode.Clear) - }.trackSwingInterop(componentInfo) - .then(InteropPointerInputModifier(componentInfo)) + }.trackSwingInterop(interopContainer, interopComponent) + .then(InteropPointerInputModifier(interopComponent)) ) { focusSwitcher.Content() } @@ -142,7 +125,7 @@ public fun SwingPanel( DisposableEffect(Unit) { val focusListener = object : FocusListener { override fun focusGained(e: FocusEvent) { - if (componentInfo.container.isParentOf(e.oppositeComponent)) { + if (interopComponent.container.isParentOf(e.oppositeComponent)) { when (e.cause) { FocusEvent.Cause.TRAVERSAL_FORWARD -> focusSwitcher.moveForward() FocusEvent.Cause.TRAVERSAL_BACKWARD -> focusSwitcher.moveBackward() @@ -154,26 +137,22 @@ public fun SwingPanel( override fun focusLost(e: FocusEvent) = Unit } interopContainer.container.addFocusListener(focusListener) - interopContainer.addInteropView(componentInfo) onDispose { - interopContainer.removeInteropView(componentInfo) + interopContainer.removeInteropView(interopComponent) interopContainer.container.removeFocusListener(focusListener) } } DisposableEffect(factory) { - componentInfo.component = factory() - componentInfo.container.add(componentInfo.component) - componentInfo.updater = Updater(componentInfo.component, update) + interopComponent.setComponent(factory()) onDispose { - componentInfo.container.remove(componentInfo.component) - componentInfo.updater.dispose() + interopComponent.dispose() } } SideEffect { - componentInfo.container.background = background.toAwtColor() - componentInfo.updater.update = update + interopComponent.container.background = background.toAwtColor() + interopComponent.update = update } } @@ -212,7 +191,7 @@ private class SwingPanelContainer( } private class FocusSwitcher( - private val info: ComponentInfo, + private val interopComponent: SwingInteropComponent, private val focusManager: FocusManager, ) { private val backwardRequester = FocusRequester() @@ -244,11 +223,13 @@ private class FocusSwitcher( EmptyLayout( Modifier .focusRequester(backwardRequester) - .onFocusEvent { + .onFocusEvent { it -> if (it.isFocused && !isRequesting) { focusManager.clearFocus(force = true) - val component = info.container.focusTraversalPolicy.getFirstComponent(info.container) + val component = interopComponent.container.let { container -> + container.focusTraversalPolicy.getFirstComponent(container) + } if (component != null) { component.requestFocus(FocusEvent.Cause.TRAVERSAL_FORWARD) } else { @@ -265,7 +246,7 @@ private class FocusSwitcher( if (it.isFocused && !isRequesting) { focusManager.clearFocus(force = true) - val component = info.container.focusTraversalPolicy.getLastComponent(info.container) + val component = interopComponent.container.focusTraversalPolicy.getLastComponent(interopComponent.container) if (component != null) { component.requestFocus(FocusEvent.Cause.TRAVERSAL_BACKWARD) } else { @@ -278,11 +259,54 @@ private class FocusSwitcher( } } -private class ComponentInfo( - container: SwingPanelContainer +private class SwingInteropComponent( + container: SwingPanelContainer, + var update: (T) -> Unit ): InteropComponent(container) { - lateinit var component: T - lateinit var updater: Updater + private var component: T? = null + private var updater: Updater? = null + + fun dispose() { + container.remove(component) + updater?.dispose() + component = null + updater = null + } + + fun setComponent(component: T) { + this.component = component + container.add(component) + updater = Updater(component, update) + } + + fun setBounds( + bounds: IntRect, + clippedBounds: IntRect = bounds + ) { + clipBounds = clippedBounds // Clipping area for skia canvas + container.isVisible = !clippedBounds.isEmpty // Hide if it's fully clipped + // Swing clips children based on parent's bounds, so use our container for clipping + container.setBounds( + /* x = */ clippedBounds.left, + /* y = */ clippedBounds.top, + /* width = */ clippedBounds.width, + /* height = */ clippedBounds.height + ) + + // The real size and position should be based on not-clipped bounds + component?.setBounds( + /* x = */ bounds.left - clippedBounds.left, // Local position relative to container + /* y = */ bounds.top - clippedBounds.top, + /* width = */ bounds.width, + /* height = */ bounds.height + ) + } + + fun getDeepestComponentForEvent(event: MouseEvent): Component? { + if (component == null) return null + val point = SwingUtilities.convertPoint(event.component, event.point, component) + return SwingUtilities.getDeepestComponentAt(component, point.x, point.y) + } } private class Updater( @@ -343,7 +367,7 @@ private fun Rect.round(density: Density): IntRect { } private class InteropPointerInputModifier( - private val componentInfo: ComponentInfo, + private val interopComponent: SwingInteropComponent, ) : PointerInputFilter(), PointerInputModifier { override val pointerInputFilter: PointerInputFilter = this @@ -380,11 +404,11 @@ private class InteropPointerInputModifier( // to original component. MouseEvent.MOUSE_ENTERED, MouseEvent.MOUSE_EXITED -> return } - if (SwingUtilities.isDescendingFrom(e.component, componentInfo.container)) { + if (SwingUtilities.isDescendingFrom(e.component, interopComponent.container)) { // Do not redispatch the event if it originally from this interop view. return } - val component = getDeepestComponentForEvent(componentInfo.component, e) + val component = interopComponent.getDeepestComponentForEvent(e) if (component != null) { component.dispatchEvent(SwingUtilities.convertMouseEvent(e.component, e, component)) pointerEvent.changes.fastForEach { @@ -392,11 +416,6 @@ private class InteropPointerInputModifier( } } } - - private fun getDeepestComponentForEvent(parent: Component, event: MouseEvent): Component? { - val point = SwingUtilities.convertPoint(event.component, event.point, parent) - return SwingUtilities.getDeepestComponentAt(parent, point.x, point.y) - } } /** diff --git a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/node/InteropContainer.kt b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/node/InteropContainer.kt index 1f8f17e9f0283..48e3b47c68198 100644 --- a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/node/InteropContainer.kt +++ b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/node/InteropContainer.kt @@ -19,6 +19,7 @@ package androidx.compose.ui.node import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.areObjectsOfSameType +import androidx.compose.ui.layout.LayoutCoordinates import androidx.compose.ui.layout.OverlayLayout /** @@ -30,7 +31,7 @@ internal interface InteropContainer { var rootModifier: TrackInteropModifierNode? val interopViews: Set - fun addInteropView(nativeView: T) + fun placeInteropView(nativeView: T) fun removeInteropView(nativeView: T) } @@ -42,57 +43,83 @@ internal interface InteropContainer { */ internal fun InteropContainer.countInteropComponentsBefore(nativeView: T): Int { var componentsBefore = 0 - rootModifier?.visitSubtreeIf(Nodes.Traversable, zOrder = true) { - if (TRAVERSAL_NODE_KEY == it.traverseKey && areObjectsOfSameType(this, it)) { - @Suppress("UNCHECKED_CAST") - val interopModifierNode = it as TrackInteropModifierNode - if (interopModifierNode.nativeView == nativeView) return componentsBefore - + rootModifier?.traverseDescendantsInDrawOrder { + if (it.nativeView != nativeView) { // It might be inside Compose tree before adding in InteropContainer in case // if it was initiated out of scroll visible bounds for example. if (it.nativeView in interopViews) { componentsBefore++ } + true + } else { + false } - true } return componentsBefore } +private fun T.traverseDescendantsInDrawOrder(block: (T) -> Boolean) where T : TraversableNode { + visitSubtreeIf(Nodes.Traversable, zOrder = true) { + if (this.traverseKey == it.traverseKey && areObjectsOfSameType(this, it)) { + @Suppress("UNCHECKED_CAST") + if (!block(it as T)) return + } + true + } +} + /** * Wrapper of Compose content that might contain interop views. It adds a helper modifier to root - * that allows to traverse interop views in the tree with the right order. + * that allows traversing interop views in the tree with the right order. */ @Composable internal fun InteropContainer.TrackInteropContainer(content: @Composable () -> Unit) { OverlayLayout( - modifier = TrackInteropModifierElement { rootModifier = it }, + modifier = RootTrackInteropModifierElement { rootModifier = it }, content = content ) } /** - * A helper modifier element that tracks an interop view inside a [LayoutNode] hierarchy. + * A helper modifier element to track interop views inside a [LayoutNode] hierarchy. * - * @property nativeView The native view associated with this modifier element. * @property onModifierNodeCreated An optional block of code that to receive the reference to * [TrackInteropModifierNode]. + */ +private data class RootTrackInteropModifierElement( + val onModifierNodeCreated: (TrackInteropModifierNode) -> Unit +) : ModifierNodeElement>() { + override fun create() = TrackInteropModifierNode( + container = null, + nativeView = null + ).also { + onModifierNodeCreated.invoke(it) + } + + override fun update(node: TrackInteropModifierNode) { + } +} + +/** + * A helper modifier element that tracks an interop view inside a [LayoutNode] hierarchy. + * + * @property nativeView The native view associated with this modifier element. * @param T The type of the native view. * * @see TrackInteropModifierNode * @see ModifierNodeElement */ internal data class TrackInteropModifierElement( - var nativeView: T? = null, - val onModifierNodeCreated: ((TrackInteropModifierNode) -> Unit)? = null + var container: InteropContainer, + var nativeView: T, ) : ModifierNodeElement>() { override fun create() = TrackInteropModifierNode( + container = container, nativeView = nativeView - ).also { - onModifierNodeCreated?.invoke(it) - } + ) override fun update(node: TrackInteropModifierNode) { + node.container = container node.nativeView = nativeView } } @@ -109,7 +136,13 @@ private const val TRAVERSAL_NODE_KEY = * @see TraversableNode */ internal class TrackInteropModifierNode( - var nativeView: T? -) : Modifier.Node(), TraversableNode { + var container: InteropContainer?, + var nativeView: T?, +) : Modifier.Node(), TraversableNode, LayoutAwareModifierNode { override val traverseKey = TRAVERSAL_NODE_KEY + + override fun onPlaced(coordinates: LayoutCoordinates) { + val nativeView = nativeView ?: return + container?.placeInteropView(nativeView) + } } diff --git a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/interop/UIKitInteropContainer.uikit.kt b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/interop/UIKitInteropContainer.uikit.kt index 94dad704884a3..a4e8b847720ac 100644 --- a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/interop/UIKitInteropContainer.uikit.kt +++ b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/interop/UIKitInteropContainer.uikit.kt @@ -41,15 +41,22 @@ internal val LocalUIKitInteropContainer = staticCompositionLocalOf { +internal class UIKitInteropContainer( + private val interopContext: UIKitInteropContext +): InteropContainer { val containerView: UIView = UIKitInteropContainerView() override var rootModifier: TrackInteropModifierNode? = null override var interopViews = mutableSetOf() private set - override fun addInteropView(nativeView: UIView) { + override fun placeInteropView(nativeView: UIView) = interopContext.deferAction { val index = countInteropComponentsBefore(nativeView) - interopViews.add(nativeView) + if (nativeView in interopViews) { + // Place might be called multiple times + nativeView.removeFromSuperview() + } else { + interopViews.add(nativeView) + } containerView.insertSubview(nativeView, index.toLong()) } @@ -61,8 +68,8 @@ internal class UIKitInteropContainer: InteropContainer { private class UIKitInteropContainerView: UIView(CGRectZero.readValue()) { /** - * We used simple solution to make only this view not touchable. - * Other view added to this container will be touchable. + * We used a simple solution to make only this view not touchable. + * Another view added to this container will be touchable. */ override fun hitTest(point: CValue, withEvent: UIEvent?): UIView? = super.hitTest(point, withEvent).takeIf { @@ -76,7 +83,9 @@ private class UIKitInteropContainerView: UIView(CGRectZero.readValue()) { * @param view The [UIView] that matches the current node. */ internal fun Modifier.trackUIKitInterop( + container: UIKitInteropContainer, view: UIView ): Modifier = this then TrackInteropModifierElement( + container = container, nativeView = view ) diff --git a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/interop/UIKitView.uikit.kt b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/interop/UIKitView.uikit.kt index 9f20adc8a6907..a178a8d30233c 100644 --- a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/interop/UIKitView.uikit.kt +++ b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/interop/UIKitView.uikit.kt @@ -182,7 +182,7 @@ fun UIKitView( }.drawBehind { // Clear interop area to make visible the component under our canvas. drawRect(Color.Transparent, blendMode = BlendMode.Clear) - }.trackUIKitInterop(embeddedInteropComponent.wrappingView).let { + }.trackUIKitInterop(interopContainer, embeddedInteropComponent.wrappingView).let { if (interactive) { it.then(InteropViewCatchPointerModifier()) } else { @@ -301,7 +301,7 @@ fun UIKitViewController( }.drawBehind { // Clear interop area to make visible the component under our canvas. drawRect(Color.Transparent, blendMode = BlendMode.Clear) - }.trackUIKitInterop(embeddedInteropComponent.wrappingView).let { + }.trackUIKitInterop(interopContainer, embeddedInteropComponent.wrappingView).let { if (interactive) { it.then(InteropViewCatchPointerModifier()) } else { @@ -359,7 +359,7 @@ private abstract class EmbeddedInteropComponent( protected fun addViewToHierarchy(view: UIView) { wrappingView.addSubview(view) - interopContainer.addInteropView(wrappingView) + // wrappingView will be added to the container from [onPlaced] } protected fun removeViewFromHierarchy(view: UIView) { diff --git a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.uikit.kt b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.uikit.kt index bbefb225ec976..0e467f3e40075 100644 --- a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.uikit.kt +++ b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.uikit.kt @@ -266,10 +266,15 @@ internal class ComposeSceneMediator( */ private val rootView = ComposeSceneMediatorRootUIView() + + private val interopContext = UIKitInteropContext( + requestRedraw = ::onComposeSceneInvalidate + ) + /** * Container for UIKitView and UIKitViewController */ - private val interopViewContainer = UIKitInteropContainer() + private val interopViewContainer = UIKitInteropContainer(interopContext) private val interactionBounds: IntRect get() { val boundsLayout = _layout as? SceneLayout.Bounds @@ -293,12 +298,6 @@ internal class ComposeSceneMediator( ) } - private val interopContext: UIKitInteropContext by lazy { - UIKitInteropContext( - requestRedraw = ::onComposeSceneInvalidate - ) - } - @OptIn(ExperimentalComposeApi::class) private val semanticsOwnerListener by lazy { SemanticsOwnerListenerImpl(