diff --git a/compose/mpp/demo/src/uikitMain/kotlin/androidx/compose/mpp/demo/UIKitInteropExample.kt b/compose/mpp/demo/src/uikitMain/kotlin/androidx/compose/mpp/demo/UIKitInteropExample.kt index 169959998ea7f..b6260e32a277f 100644 --- a/compose/mpp/demo/src/uikitMain/kotlin/androidx/compose/mpp/demo/UIKitInteropExample.kt +++ b/compose/mpp/demo/src/uikitMain/kotlin/androidx/compose/mpp/demo/UIKitInteropExample.kt @@ -27,10 +27,14 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.interop.UIKitView import androidx.compose.ui.interop.UIKitViewController +import androidx.compose.ui.layout.findRootCoordinates +import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.unit.dp import kotlinx.cinterop.ObjCAction +import kotlinx.cinterop.objcPtr import kotlinx.cinterop.readValue import platform.CoreGraphics.CGRectMake import platform.CoreGraphics.CGRectZero @@ -71,13 +75,18 @@ private class TouchReactingView: UIView(frame = CGRectZero.readValue()) { val UIKitInteropExample = Screen.Example("UIKitInterop") { var text by remember { mutableStateOf("Type something") } + var updatedValue by remember { mutableStateOf(null as Offset?) } + LazyColumn(Modifier.fillMaxSize()) { item { UIKitView( factory = { MKMapView() }, - modifier = Modifier.fillMaxWidth().height(200.dp) + modifier = Modifier.fillMaxWidth().height(200.dp), + update = { + println("MKMapView updated") + } ) } @@ -85,10 +94,18 @@ val UIKitInteropExample = Screen.Example("UIKitInterop") { UIKitViewController( factory = { object : UIViewController(nibName = null, bundle = null) { + val label = UILabel() + + override fun loadView() { + setView(label) + } + override fun viewDidLoad() { super.viewDidLoad() - view.backgroundColor = UIColor.blueColor + label.textAlignment = NSTextAlignmentCenter + label.textColor = UIColor.whiteColor + label.backgroundColor = UIColor.blueColor } override fun viewWillAppear(animated: Boolean) { @@ -116,7 +133,20 @@ val UIKitInteropExample = Screen.Example("UIKitInterop") { } } }, - modifier = Modifier.fillMaxWidth().height(100.dp), + modifier = Modifier + .fillMaxWidth() + .height(100.dp) + .onGloballyPositioned { coordinates -> + val rootCoordinates = coordinates.findRootCoordinates() + val box = coordinates.localBoundingBoxOf(rootCoordinates, clipBounds = false) + updatedValue = box.topLeft + }, + update = { viewController -> + updatedValue?.let { + viewController.label.text = "${it.x}, ${it.y}" + } + }, + interactive = false ) } items(100) { index -> @@ -136,7 +166,7 @@ val UIKitInteropExample = Screen.Example("UIKitInterop") { 3 -> ComposeUITextField(text, onValueChange = { text = it }, Modifier.fillMaxWidth().height(40.dp)) 4 -> UIKitView( factory = { TouchReactingView() }, - modifier = Modifier.fillMaxWidth().height(40.dp) + modifier = Modifier.fillMaxWidth().height(40.dp), ) } } @@ -171,6 +201,7 @@ private fun ComposeUITextField(value: String, onValueChange: (String) -> Unit, m }, modifier = modifier, update = { textField -> + println("Update called for UITextField(0x${textField.objcPtr().toLong().toString(16)}, value = $value") textField.text = value } ) diff --git a/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/awt/FocusSwitcher.desktop.kt b/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/awt/FocusSwitcher.desktop.kt new file mode 100644 index 0000000000000..2d31bbcbac4f4 --- /dev/null +++ b/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/awt/FocusSwitcher.desktop.kt @@ -0,0 +1,104 @@ +/* + * 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.awt + +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusDirection +import androidx.compose.ui.focus.FocusManager +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.focusTarget +import androidx.compose.ui.focus.onFocusEvent +import androidx.compose.ui.viewinterop.InteropViewGroup +import java.awt.event.FocusEvent + +internal class InteropFocusSwitcher( + private val group: InteropViewGroup, + private val focusManager: FocusManager, +) { + private val backwardTracker = Tracker { + val component = group.focusTraversalPolicy.getFirstComponent(group) + if (component != null) { + component.requestFocus(FocusEvent.Cause.TRAVERSAL_FORWARD) + } else { + moveForward() + } + } + + private val forwardTracker = Tracker { + val component = group.focusTraversalPolicy.getLastComponent(group) + if (component != null) { + component.requestFocus(FocusEvent.Cause.TRAVERSAL_BACKWARD) + } else { + moveBackward() + } + } + + val backwardTrackerModifier: Modifier + get() = backwardTracker.modifier + + val forwardTrackerModifier: Modifier + get() = forwardTracker.modifier + + fun moveBackward() { + backwardTracker.requestFocusWithoutEvent() + focusManager.moveFocus(FocusDirection.Previous) + } + + fun moveForward() { + forwardTracker.requestFocusWithoutEvent() + focusManager.moveFocus(FocusDirection.Next) + } + + /** + * A helper class that can help: + * - to prevent recursive focus events + * (a case when we focus the same element inside `onFocusEvent`) + * - to prevent triggering `onFocusEvent` while requesting focus somewhere else + */ + private class Tracker( + private val onNonRecursiveFocused: () -> Unit + ) { + private val requester = FocusRequester() + + private var isRequestingFocus = false + private var isHandlingFocus = false + + fun requestFocusWithoutEvent() { + try { + isRequestingFocus = true + requester.requestFocus() + } finally { + isRequestingFocus = false + } + } + + val modifier = Modifier + .focusRequester(requester) + .onFocusEvent { + if (!isRequestingFocus && !isHandlingFocus && it.isFocused) { + try { + isHandlingFocus = true + onNonRecursiveFocused() + } finally { + isHandlingFocus = false + } + } + } + .focusTarget() + } +} \ No newline at end of file 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 ad21b429172af..d7996cfd711eb 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 @@ -16,50 +16,21 @@ package androidx.compose.ui.awt import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.SideEffect import androidx.compose.runtime.currentCompositeKeyHash import androidx.compose.runtime.remember -import androidx.compose.runtime.snapshots.SnapshotStateObserver import androidx.compose.ui.ComposeFeatureFlags import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.drawBehind -import androidx.compose.ui.focus.FocusDirection -import androidx.compose.ui.focus.FocusManager -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.focus.focusTarget -import androidx.compose.ui.focus.onFocusEvent -import androidx.compose.ui.geometry.Rect -import androidx.compose.ui.graphics.BlendMode import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.EmptyLayout -import androidx.compose.ui.layout.OverlayLayout -import androidx.compose.ui.layout.findRootCoordinates -import androidx.compose.ui.layout.onGloballyPositioned -import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.unit.Density -import androidx.compose.ui.unit.IntRect -import androidx.compose.ui.viewinterop.InteropContainer import androidx.compose.ui.viewinterop.InteropView -import androidx.compose.ui.viewinterop.InteropViewGroup -import androidx.compose.ui.viewinterop.InteropViewHolder -import androidx.compose.ui.viewinterop.InteropViewUpdater import androidx.compose.ui.viewinterop.LocalInteropContainer import androidx.compose.ui.viewinterop.SwingInteropViewHolder -import androidx.compose.ui.viewinterop.pointerInteropFilter -import androidx.compose.ui.viewinterop.trackInteropPlacement import java.awt.Component import java.awt.Container import java.awt.event.FocusEvent -import java.awt.event.FocusListener import javax.swing.JPanel import javax.swing.LayoutFocusTraversalPolicy -import javax.swing.SwingUtilities -import kotlin.math.ceil -import kotlin.math.floor -import kotlinx.atomicfu.atomic val NoOpUpdate: Component.() -> Unit = {} @@ -87,71 +58,46 @@ public fun SwingPanel( update: (T) -> Unit = NoOpUpdate, ) { val interopContainer = LocalInteropContainer.current - val compositeKey = currentCompositeKeyHash - val interopViewHolder = remember { - SwingInteropViewHolder2( - container = interopContainer, - group = SwingInteropViewGroup( - key = compositeKey, - focusComponent = interopContainer.root, - ), - update = update, - ) - } - - val density = LocalDensity.current + val compositeKeyHash = currentCompositeKeyHash val focusManager = LocalFocusManager.current - val focusSwitcher = remember { FocusSwitcher(interopViewHolder, focusManager) } - OverlayLayout( - modifier = modifier.onGloballyPositioned { coordinates -> - val rootCoordinates = coordinates.findRootCoordinates() - val clippedBounds = rootCoordinates - .localBoundingBoxOf(coordinates, clipBounds = true).round(density) - val bounds = rootCoordinates - .localBoundingBoxOf(coordinates, clipBounds = false).round(density) + // TODO: entire interop context must be inside SwingInteropViewHolder in order to + // expose a version of this API with `onReset` callback and integrated with ReusableComposeNode + // https://youtrack.jetbrains.com/issue/CMP-5897/Desktop-self-contained-InteropViewHolder - interopViewHolder.setBounds(bounds, clippedBounds) - }.drawBehind { - // Clear interop area to make visible the component under our canvas. - drawRect(Color.Transparent, blendMode = BlendMode.Clear) - }.trackInteropPlacement(interopViewHolder) - .pointerInteropFilter(interopViewHolder) - ) { - focusSwitcher.Content() + val group = remember { + SwingInteropViewGroup( + key = compositeKeyHash, + focusComponent = interopContainer.root + ) } - DisposableEffect(Unit) { - val focusListener = object : FocusListener { - override fun focusGained(e: FocusEvent) { - if (e.isFocusGainedHandledBySwingPanel(interopViewHolder.group)) { - when (e.cause) { - FocusEvent.Cause.TRAVERSAL_FORWARD -> focusSwitcher.moveForward() - FocusEvent.Cause.TRAVERSAL_BACKWARD -> focusSwitcher.moveBackward() - else -> Unit - } - } - } + val focusSwitcher = remember { InteropFocusSwitcher(group, focusManager) } - override fun focusLost(e: FocusEvent) = Unit - } - interopContainer.root.addFocusListener(focusListener) - onDispose { - interopContainer.root.removeFocusListener(focusListener) - } + val interopViewHolder = remember { + SwingInteropViewHolder( + factory = factory, + container = interopContainer, + group = group, + focusSwitcher = focusSwitcher, + compositeKeyHash = compositeKeyHash + ) } - DisposableEffect(factory) { - interopViewHolder.setupUserComponent(factory()) - onDispose { - interopViewHolder.cleanUserComponent() + EmptyLayout(focusSwitcher.backwardTrackerModifier) + + InteropView( + factory = { + interopViewHolder + }, + modifier = modifier, + update = { + it.background = background.toAwtColor() + update(it) } - } + ) - SideEffect { - interopViewHolder.group.background = background.toAwtColor() - interopViewHolder.update = update - } + EmptyLayout(focusSwitcher.forwardTrackerModifier) } /** @@ -176,12 +122,15 @@ internal fun FocusEvent.isFocusGainedHandledBySwingPanel(container: Container) = private class SwingInteropViewGroup( key: Int, private val focusComponent: Component -): JPanel() { +) : JPanel() { init { name = "SwingPanel #${key.toString(MaxSupportedRadix)}" layout = null focusTraversalPolicy = object : LayoutFocusTraversalPolicy() { - override fun getComponentAfter(aContainer: Container?, aComponent: Component?): Component? { + override fun getComponentAfter( + aContainer: Container?, + aComponent: Component? + ): Component? { return if (aComponent == getLastComponent(aContainer)) { focusComponent } else { @@ -189,7 +138,10 @@ private class SwingInteropViewGroup( } } - override fun getComponentBefore(aContainer: Container?, aComponent: Component?): Component? { + override fun getComponentBefore( + aContainer: Container?, + aComponent: Component? + ): Component? { return if (aComponent == getFirstComponent(aContainer)) { focusComponent } else { @@ -201,143 +153,6 @@ private class SwingInteropViewGroup( } } -private class FocusSwitcher( - private val interopViewHolder: InteropViewHolder, - private val focusManager: FocusManager, -) { - private val backwardTracker = FocusTracker { - val group = interopViewHolder.group - val component = group.focusTraversalPolicy.getFirstComponent(group) - if (component != null) { - component.requestFocus(FocusEvent.Cause.TRAVERSAL_FORWARD) - } else { - moveForward() - } - } - - private val forwardTracker = FocusTracker { - val group = interopViewHolder.group - val component = group.focusTraversalPolicy.getLastComponent(group) - if (component != null) { - component.requestFocus(FocusEvent.Cause.TRAVERSAL_BACKWARD) - } else { - moveBackward() - } - } - - fun moveBackward() { - backwardTracker.requestFocusWithoutEvent() - focusManager.moveFocus(FocusDirection.Previous) - } - - fun moveForward() { - forwardTracker.requestFocusWithoutEvent() - focusManager.moveFocus(FocusDirection.Next) - } - - @Composable - fun Content() { - EmptyLayout(backwardTracker.modifier) - EmptyLayout(forwardTracker.modifier) - } - - /** - * A helper class that can help: - * - to prevent recursive focus events - * (a case when we focus the same element inside `onFocusEvent`) - * - to prevent triggering `onFocusEvent` while requesting focus somewhere else - */ - private class FocusTracker( - private val onNonRecursiveFocused: () -> Unit - ) { - private val requester = FocusRequester() - - private var isRequestingFocus = false - private var isHandlingFocus = false - - fun requestFocusWithoutEvent() { - try { - isRequestingFocus = true - requester.requestFocus() - } finally { - isRequestingFocus = false - } - } - - val modifier = Modifier - .focusRequester(requester) - .onFocusEvent { - if (!isRequestingFocus && !isHandlingFocus && it.isFocused) { - try { - isHandlingFocus = true - onNonRecursiveFocused() - } finally { - isHandlingFocus = false - } - } - } - .focusTarget() - } -} - -// TODO: Align naming. It's typed version of view holder, On Android it's called "ViewFactoryHolder" -private class SwingInteropViewHolder2( - container: InteropContainer, - group: InteropViewGroup, - var update: (T) -> Unit -): SwingInteropViewHolder(container, group) { - private var userComponent: T? = null - private var updater: InteropViewUpdater? = null - - override fun getInteropView(): InteropView? = - userComponent - - fun setupUserComponent(component: T) { - check(userComponent == null) - userComponent = component - group.add(component) - updater = InteropViewUpdater(component, update) { SwingUtilities.invokeLater(it) } - } - - fun cleanUserComponent() { - group.remove(userComponent) - updater?.dispose() - userComponent = null - updater = null - } - - fun setBounds( - bounds: IntRect, - clippedBounds: IntRect = bounds - ) = container.changeInteropViewLayout { - clipBounds = clippedBounds // Clipping area for skia canvas - group.isVisible = !clippedBounds.isEmpty // Hide if it's fully clipped - // Swing clips children based on parent's bounds, so use our container for clipping - group.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 - userComponent?.setBounds( - /* x = */ bounds.left - clippedBounds.left, // Local position relative to container - /* y = */ bounds.top - clippedBounds.top, - /* width = */ bounds.width, - /* height = */ bounds.height - ) - } -} - -private fun Rect.round(density: Density): IntRect { - val left = floor(left / density.density).toInt() - val top = floor(top / density.density).toInt() - val right = ceil(right / density.density).toInt() - val bottom = ceil(bottom / density.density).toInt() - return IntRect(left, top, right, bottom) -} - /** * The maximum radix available for conversion to and from strings. */ diff --git a/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.desktop.kt b/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.desktop.kt index 49aa22aa8008f..19a8bec88405c 100644 --- a/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.desktop.kt +++ b/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.desktop.kt @@ -134,7 +134,7 @@ internal class ComposeSceneMediator( private val _platformContext = DesktopPlatformContext() val platformContext: PlatformContext get() = _platformContext - private val skiaLayerComponent by lazy { skiaLayerComponentFactory(this) } + private val skiaLayerComponent: SkiaLayerComponent by lazy { skiaLayerComponentFactory(this) } val contentComponent by skiaLayerComponent::contentComponent var fullscreen by skiaLayerComponent::fullscreen val windowHandle by skiaLayerComponent::windowHandle @@ -161,7 +161,8 @@ internal class ComposeSceneMediator( */ private val interopContainer = SwingInteropContainer( root = container, - placeInteropAbove = !useInteropBlending || metalOrderHack + placeInteropAbove = !useInteropBlending || metalOrderHack, + requestRedraw = ::onComposeInvalidation ) private val containerListener = object : ContainerListener { @@ -459,6 +460,8 @@ internal class ComposeSceneMediator( unsubscribe(contentComponent) + // Since rendering will not happen after, we needs to execute all scheduled updates + interopContainer.dispose() container.removeContainerListener(containerListener) container.remove(contentComponent) container.remove(invisibleComponent) @@ -550,8 +553,10 @@ internal class ComposeSceneMediator( } override fun onRender(canvas: Canvas, width: Int, height: Int, nanoTime: Long) = catchExceptions { - canvas.withSceneOffset { - scene.render(asComposeCanvas(), nanoTime) + interopContainer.postponingExecutingScheduledUpdates { + canvas.withSceneOffset { + scene.render(asComposeCanvas(), nanoTime) + } } } diff --git a/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/viewinterop/InteropView.desktop.kt b/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/viewinterop/InteropView.desktop.kt index 588f71bda2859..2a06314fe3c8b 100644 --- a/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/viewinterop/InteropView.desktop.kt +++ b/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/viewinterop/InteropView.desktop.kt @@ -24,7 +24,4 @@ package androidx.compose.ui.viewinterop actual typealias InteropView = Any // java.awt.Component @Suppress("ACTUAL_WITHOUT_EXPECT") // https://youtrack.jetbrains.com/issue/KT-37316 -internal actual typealias InteropViewGroup = java.awt.Container - -internal val InteropView.asAwtComponent - get() = this as java.awt.Component +internal actual typealias InteropViewGroup = java.awt.Container \ No newline at end of file diff --git a/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/viewinterop/SwingInteropContainer.desktop.kt b/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/viewinterop/SwingInteropContainer.desktop.kt index 9e70fda400493..76d9d14fae07a 100644 --- a/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/viewinterop/SwingInteropContainer.desktop.kt +++ b/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/viewinterop/SwingInteropContainer.desktop.kt @@ -18,10 +18,89 @@ package androidx.compose.ui.viewinterop import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.snapshots.SnapshotStateObserver import androidx.compose.ui.scene.ComposeSceneMediator +import androidx.compose.ui.util.fastForEach import java.awt.Component +import javax.swing.SwingUtilities.isEventDispatchThread import org.jetbrains.skiko.ClipRectangle +/** + * A helper class to back-buffer scheduled updates for Swing Interop without allocating + * an array on each frame. + */ +private class ScheduledUpdatesSwapchain( + private val requestRedraw: () -> Unit +) { + private var executed = mutableListOf<() -> Unit>() + private var scheduled = mutableListOf<() -> Unit>() + private val lock = Any() + + /** + * Indicates whether a redraw is requested when update is scheduled. + */ + private var needsRequestRedrawOnUpdateScheduled = true + + /** + * Schedule an update to be executed later. + */ + fun scheduleUpdate(action: () -> Unit) = synchronized(lock) { + scheduled.add(action) + + if (needsRequestRedrawOnUpdateScheduled) { + requestRedraw() + } + } + + /** + * Performs a [body], if [scheduleUpdate] is called-back from within it, no redraw requests + * will be made. + */ + inline fun preventingRedrawRequests(body: () -> Unit) { + try { + synchronized(lock) { + check(needsRequestRedrawOnUpdateScheduled) { + "Reentry into ignoringRedrawRequests is not allowed" + } + + needsRequestRedrawOnUpdateScheduled = false + } + + body() + } finally { + synchronized(lock) { + needsRequestRedrawOnUpdateScheduled = true + } + } + } + + /** + * Execute all scheduled updates. + * + * @return True if there were any updates to execute. False otherwise. + */ + fun execute(): Boolean { + // Race condition on [executed] is prevented by the fact that this method is called only + // on the AWT EDT. We only need to synchronize [scheduled] across threads using [lock]. + + synchronized(lock) { + // Swap lists and return the one to be executed + val t = executed + executed = scheduled + scheduled = t + } + + val hasAnyUpdates = executed.isNotEmpty() + + executed.fastForEach { + it.invoke() + } + executed.clear() + + return hasAnyUpdates + } +} + /** * A container that controls interop views/components. * @@ -30,16 +109,27 @@ import org.jetbrains.skiko.ClipRectangle * * @property root The Swing container to add the interop views to. * @property placeInteropAbove Whether to place interop components above non-interop components. + * @param requestRedraw Function to request a redraw. It's needed because executing scheduled + * updates is tied to the draw loop and update doesn't necessary trigger an invalidation causing + * a redraw, so we need to request it explicitly. */ internal class SwingInteropContainer( override val root: InteropViewGroup, - private val placeInteropAbove: Boolean -): InteropContainer { - private var interopComponents = mutableMapOf() + private val placeInteropAbove: Boolean, + requestRedraw: () -> Unit +) : InteropContainer { + /** + * Map to reverse-lookup of [InteropViewHolder] having an [InteropViewGroup]. + */ + private var interopComponents = mutableMapOf() override var rootModifier: TrackInteropPlacementModifierNode? = null - override val interopViews: Set - get() = interopComponents.values.toSet() + + override val snapshotObserver: SnapshotStateObserver = SnapshotStateObserver { command -> + command() + } + + private val scheduledUpdatesSwapchain = ScheduledUpdatesSwapchain(requestRedraw) /** * Index of last interop component in [root]. @@ -60,53 +150,93 @@ internal class SwingInteropContainer( return lastInteropIndex } - override fun placeInteropView(interopView: InteropViewHolder) { - val component = interopView.group + override fun contains(holder: InteropViewHolder): Boolean = + interopComponents.contains(holder.group) + + override fun place(holder: InteropViewHolder) { + val group = holder.group + + if (interopComponents.isEmpty()) { + snapshotObserver.start() + } // Add this component to [interopComponents] to track count and clip rects - val alreadyAdded = component in interopComponents + val alreadyAdded = group in interopComponents if (!alreadyAdded) { - interopComponents[component] = interopView + interopComponents[group] = holder } // Iterate through a Compose layout tree in draw order and count interop view below this one - val countBelow = countInteropComponentsBelow(interopView) + val countBelow = countInteropComponentsBelow(holder) // AWT/Swing uses the **REVERSE ORDER** for drawing and events val awtIndex = lastInteropIndex - countBelow // Update AWT/Swing hierarchy - if (alreadyAdded) { - root.setComponentZOrder(component, awtIndex) - } else { - root.add(component, awtIndex) + scheduleUpdate { + if (alreadyAdded) { + holder.changeInteropViewIndex(root = root, index = awtIndex) + } else { + holder.insertInteropView(root = root, index = awtIndex) + } + } + } + + override fun unplace(holder: InteropViewHolder) { + scheduleUpdate { + holder.removeInteropView(root = root) } - // Sometimes Swing displays the rest of interop views in incorrect order after adding, - // so we need to force re-validate it. - root.validate() - root.repaint() + interopComponents.remove(holder.group) + + if (interopComponents.isEmpty()) { + snapshotObserver.stop() + } + } + + private fun executeScheduledUpdates() { + check(isEventDispatchThread()) + + val hasAnyUpdates = scheduledUpdatesSwapchain.execute() + + if (hasAnyUpdates) { + // Sometimes Swing displays the rest of interop views in incorrect order after an update + // so we need to re-validate and repaint the root component. + + root.validate() + root.repaint() + } } - override fun unplaceInteropView(interopView: InteropViewHolder) { - val component = interopView.group - root.remove(component) - interopComponents.remove(component) + fun dispose() { + executeScheduledUpdates() + } - // Sometimes Swing displays the rest of interop views in incorrect order after removing, - // so we need to force re-validate it. - root.validate() - root.repaint() + /** + * Performs a [body] and then executes all scheduled updates, including those that can happen + * inside [body]. + */ + fun postponingExecutingScheduledUpdates(body: () -> Unit) { + scheduledUpdatesSwapchain.preventingRedrawRequests { + body() + } + + executeScheduledUpdates() } - override fun changeInteropViewLayout(action: () -> Unit) { - action() - root.validate() - root.repaint() + override fun scheduleUpdate(action: () -> Unit) { + scheduledUpdatesSwapchain.scheduleUpdate(action) } + // TODO: Should be the same as [Owner.onInteropViewLayoutChange]? +// override fun onInteropViewLayoutChange(holder: InteropViewHolder) { +// // No-op. +// // On Swing it's called after relayout for specific interop view was requested. +// // It means that the validate and repaint will be executed after it. +// } + fun getClipRectForComponent(component: Component): ClipRectangle = - requireNotNull(interopComponents[component] as? ClipRectangle) + requireNotNull(interopComponents[component]) as ClipRectangle @Composable operator fun invoke(content: @Composable () -> Unit) { diff --git a/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/viewinterop/SwingInteropViewHolder.desktop.kt b/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/viewinterop/SwingInteropViewHolder.desktop.kt index 2faf1011abaa8..9aa5530d3cf7d 100644 --- a/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/viewinterop/SwingInteropViewHolder.desktop.kt +++ b/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/viewinterop/SwingInteropViewHolder.desktop.kt @@ -16,20 +16,126 @@ package androidx.compose.ui.viewinterop +import androidx.compose.ui.Modifier +import androidx.compose.ui.awt.InteropFocusSwitcher import androidx.compose.ui.awt.awtEventOrNull +import androidx.compose.ui.awt.isFocusGainedHandledBySwingPanel +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.PointerEvent +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.layout.MeasurePolicy +import androidx.compose.ui.layout.findRootCoordinates +import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.IntRect import androidx.compose.ui.util.fastForEach import java.awt.Component +import java.awt.event.FocusEvent +import java.awt.event.FocusListener import java.awt.event.MouseEvent import javax.swing.SwingUtilities +import kotlin.math.ceil +import kotlin.math.floor import org.jetbrains.skiko.ClipRectangle -internal open class SwingInteropViewHolder( +internal class SwingInteropViewHolder( + factory: () -> T, container: InteropContainer, group: InteropViewGroup, -) : InteropViewHolder(container, group), ClipRectangle { - protected var clipBounds: IntRect? = null + focusSwitcher: InteropFocusSwitcher, + compositeKeyHash: Int, +) : TypedInteropViewHolder( + factory, + container, + group, + compositeKeyHash, + MeasurePolicy { _, constraints -> + layout(constraints.minWidth, constraints.minHeight) {} + }, + isInteractive = true, + platformModifier = Modifier + .drawBehind { + // Clear interop area to make visible the component under our canvas. + drawRect( + color = Color.Transparent, + blendMode = BlendMode.Clear + ) + } +), ClipRectangle { + private var clipBounds: IntRect? = null + + val focusListener = object : FocusListener { + override fun focusGained(e: FocusEvent) { + if (e.isFocusGainedHandledBySwingPanel(group)) { + when (e.cause) { + FocusEvent.Cause.TRAVERSAL_FORWARD -> focusSwitcher.moveForward() + FocusEvent.Cause.TRAVERSAL_BACKWARD -> focusSwitcher.moveBackward() + else -> Unit + } + } + } + + override fun focusLost(e: FocusEvent) = Unit + } + + override fun getInteropView(): InteropView = + typedInteropView + + init { + group.add(typedInteropView) + } + + override fun layoutAccordingTo(layoutCoordinates: LayoutCoordinates) { + val rootCoordinates = layoutCoordinates.findRootCoordinates() + + val clippedBounds = rootCoordinates + .localBoundingBoxOf(layoutCoordinates, clipBounds = true) + .round(density) + + val bounds = rootCoordinates + .localBoundingBoxOf(layoutCoordinates, clipBounds = false) + .round(density) + + clipBounds = clippedBounds // Clipping area for skia canvas + + // Swing clips children based on parent's bounds, so use our container for clipping + container.scheduleUpdate { + group.isVisible = !clippedBounds.isEmpty // Hide if it's fully clipped + + group.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 + typedInteropView.setBounds( + /* x = */ bounds.left - clippedBounds.left, // Local position relative to container + /* y = */ bounds.top - clippedBounds.top, + /* width = */ bounds.width, + /* height = */ bounds.height + ) + } + } + + override fun insertInteropView(root: InteropViewGroup, index: Int) { + root.add(group, index) + super.insertInteropView(root, index) + container.root.addFocusListener(focusListener) + } + + override fun changeInteropViewIndex(root: InteropViewGroup, index: Int) { + root.setComponentZOrder(group, index) + } + + override fun removeInteropView(root: InteropViewGroup) { + root.remove(group) + super.removeInteropView(root) + container.root.removeFocusListener(focusListener) + } override val x: Float get() = (clipBounds?.left ?: group.x).toFloat() @@ -61,8 +167,19 @@ internal open class SwingInteropViewHolder( } private fun getDeepestComponentForEvent(event: MouseEvent): Component? { - val userComponent = getInteropView()?.asAwtComponent ?: return null - val point = SwingUtilities.convertPoint(event.component, event.point, userComponent) - return SwingUtilities.getDeepestComponentAt(userComponent, point.x, point.y) + val point = SwingUtilities.convertPoint( + /* source = */event.component, + /* aPoint = */event.point, + /* destination = */typedInteropView + ) + return SwingUtilities.getDeepestComponentAt(typedInteropView, point.x, point.y) } } + +private fun Rect.round(density: Density): IntRect { + val left = floor(left / density.density).toInt() + val top = floor(top / density.density).toInt() + val right = ceil(right / density.density).toInt() + val bottom = ceil(bottom / density.density).toInt() + return IntRect(left, top, right, bottom) +} \ No newline at end of file diff --git a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/scene/PlatformLayersComposeScene.skiko.kt b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/scene/PlatformLayersComposeScene.skiko.kt index 6db9cad9b5e88..3981892206ee8 100644 --- a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/scene/PlatformLayersComposeScene.skiko.kt +++ b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/scene/PlatformLayersComposeScene.skiko.kt @@ -21,7 +21,6 @@ import androidx.compose.runtime.Composition import androidx.compose.runtime.CompositionContext import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.InternalComposeUiApi import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Canvas diff --git a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/viewinterop/InteropContainer.skiko.kt b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/viewinterop/InteropContainer.skiko.kt index 74b0f0b31437b..25afb0c869ef1 100644 --- a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/viewinterop/InteropContainer.skiko.kt +++ b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/viewinterop/InteropContainer.skiko.kt @@ -17,6 +17,7 @@ package androidx.compose.ui.viewinterop import androidx.compose.runtime.Composable +import androidx.compose.runtime.snapshots.SnapshotStateObserver import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.Modifier import androidx.compose.ui.layout.LayoutCoordinates @@ -42,30 +43,54 @@ internal val LocalInteropContainer = staticCompositionLocalOf internal interface InteropContainer { val root: InteropViewGroup var rootModifier: TrackInteropPlacementModifierNode? - val interopViews: Set - - fun placeInteropView(interopView: InteropViewHolder) - fun unplaceInteropView(interopView: InteropViewHolder) - - // TODO: Should be the same as [Owner.onInteropViewLayoutChange] - fun changeInteropViewLayout(action: () -> Unit) { - action() - } + val snapshotObserver: SnapshotStateObserver + + fun contains(holder: InteropViewHolder): Boolean + + /** + * Calculates the proper index for the interop view in the container and issues a request to + * update the view hierarchy. + */ + fun place(holder: InteropViewHolder) + + /** + * Issues a request to remove the interop view from the hierarchy. + */ + fun unplace(holder: InteropViewHolder) + + /** + * Schedule an update to be performed on interop view. Platforms have their different strategy + * to align the updates with the rendering and threading requirements when modifying + * interop views. + * + * @param action The action to be performed. Could be layout change, or other visual updates + * to the view state, such as background, corner radius, etc. + */ + fun scheduleUpdate(action: () -> Unit) + + // TODO: Should be the same as [Owner.onInteropViewLayoutChange]? +// /** +// * Callback to be invoked when the layout of the interop view changes to notify the system +// * that something has changed. +// */ +// fun onInteropViewLayoutChange(holder: InteropViewHolder) } /** * Counts the number of interop components before the given native view in the container. * - * @param interopView The native view to count interop components before. + * @param holder The holder for native view to count interop components before. * @return The number of interop components before the given native view. */ -internal fun InteropContainer.countInteropComponentsBelow(interopView: InteropViewHolder): Int { +internal fun InteropContainer.countInteropComponentsBelow(holder: InteropViewHolder): Int { var componentsBefore = 0 rootModifier?.traverseDescendantsInDrawOrder { - if (it.interopView != interopView) { + val currentHolder = it.interopViewHolder + if (currentHolder != null && currentHolder != holder) { // It might be inside a Compose tree before adding in InteropContainer in case // if it was initiated out of scroll visible bounds for example. - if (it.interopView in interopViews) { + + if (contains(currentHolder)) { componentsBefore++ } true @@ -79,6 +104,9 @@ internal fun InteropContainer.countInteropComponentsBelow(interopView: InteropVi /** * Wrapper of Compose content that might contain interop views. It adds a helper modifier to root * that allows traversing interop views in the tree with the right order. + * + * TODO: refactor to use a root node modifier instead of emitting an extra node + * https://youtrack.jetbrains.com/issue/CMP-5896 */ @Composable internal fun InteropContainer.TrackInteropPlacementContainer(content: @Composable () -> Unit) { @@ -98,7 +126,7 @@ private data class RootTrackInteropPlacementModifierElement( val onModifierNodeCreated: (TrackInteropPlacementModifierNode) -> Unit ) : ModifierNodeElement() { override fun create() = TrackInteropPlacementModifierNode( - interopView = null + interopViewHolder = null ).also { onModifierNodeCreated.invoke(it) } @@ -110,28 +138,28 @@ private data class RootTrackInteropPlacementModifierElement( /** * Modifier to track interop view inside [LayoutNode] hierarchy. * - * @param interopView The interop view that matches the current node. + * @param interopViewHolder The interop view holder that matches the current node. */ -internal fun Modifier.trackInteropPlacement(interopView: InteropViewHolder): Modifier = - this then TrackInteropPlacementModifierElement(interopView) +internal fun Modifier.trackInteropPlacement(interopViewHolder: InteropViewHolder): Modifier = + this then TrackInteropPlacementModifierElement(interopViewHolder) /** * A helper modifier element that tracks an interop view inside a [LayoutNode] hierarchy. * - * @property interopView The native view associated with this modifier element. + * @property interopViewHolder The native view associated with this modifier element. * * @see TrackInteropPlacementModifierNode * @see ModifierNodeElement */ private data class TrackInteropPlacementModifierElement( - val interopView: InteropViewHolder, + val interopViewHolder: InteropViewHolder, ) : ModifierNodeElement() { override fun create() = TrackInteropPlacementModifierNode( - interopView = interopView + interopViewHolder = interopViewHolder ) override fun update(node: TrackInteropPlacementModifierNode) { - node.interopView = interopView + node.interopViewHolder = interopViewHolder } } @@ -141,23 +169,21 @@ private const val TRAVERSAL_NODE_KEY = /** * A modifier node for tracking and traversing interop purposes. * - * @property interopView the native view that matches the current node. + * @property interopViewHolder the native view that matches the current node. * * @see TraversableNode */ internal class TrackInteropPlacementModifierNode( - var interopView: InteropViewHolder?, + var interopViewHolder: InteropViewHolder?, ) : Modifier.Node(), TraversableNode, LayoutAwareModifierNode, OnUnplacedModifierNode { override val traverseKey = TRAVERSAL_NODE_KEY override fun onPlaced(coordinates: LayoutCoordinates) { - val interopView = interopView ?: return - interopView.container.placeInteropView(interopView) + interopViewHolder?.place() } override fun onUnplaced() { - val interopView = interopView ?: return - interopView.container.unplaceInteropView(interopView) + interopViewHolder?.unplace() } override fun onDetach() { diff --git a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/viewinterop/InteropView.skiko.kt b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/viewinterop/InteropView.skiko.kt new file mode 100644 index 0000000000000..af01e85c872b1 --- /dev/null +++ b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/viewinterop/InteropView.skiko.kt @@ -0,0 +1,199 @@ +/* + * 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.viewinterop + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ComposeNode +import androidx.compose.runtime.CompositionLocalMap +import androidx.compose.runtime.ReusableComposeNode +import androidx.compose.runtime.Updater +import androidx.compose.runtime.currentComposer +import androidx.compose.runtime.currentCompositeKeyHash +import androidx.compose.ui.Modifier +import androidx.compose.ui.UiComposable +import androidx.compose.ui.layout.MeasurePolicy +import androidx.compose.ui.materialize +import androidx.compose.ui.node.ComposeUiNode.Companion.SetCompositeKeyHash +import androidx.compose.ui.node.ComposeUiNode.Companion.SetResolvedCompositionLocals +import androidx.compose.ui.node.LayoutNode +import androidx.compose.ui.platform.DefaultUiApplier +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Density + +private val NoOp: Any.() -> Unit = {} + +/** + * Base class for any concrete implementation of [InteropViewHolder] that holds a specific type + * of InteropView to be implemented by the platform-specific [TypedInteropViewHolder] subclass + */ +internal abstract class TypedInteropViewHolder( + factory: () -> T, + interopContainer: InteropContainer, + group: InteropViewGroup, + compositeKeyHash: Int, + measurePolicy: MeasurePolicy, + isInteractive: Boolean, + platformModifier: Modifier +) : InteropViewHolder( + interopContainer, + group, + compositeKeyHash, + measurePolicy, + isInteractive, + platformModifier +) { + protected val typedInteropView = factory() + + override fun getInteropView(): InteropView? { + return typedInteropView + } + + /** + * A block containing the update logic for [T], to be forwarded to user. + * Setting it will schedule an update immediately. + * See [InteropViewHolder.update] + */ + var updateBlock: (T) -> Unit = NoOp + set(value) { + field = value + update = { typedInteropView.apply(updateBlock) } + } + + /** + * A block containing the reset logic for [T], to be forwarded to user. + * It will be called if [LayoutNode] associated with this [InteropViewHolder] is reused to + * avoid interop view reallocation. + */ + var resetBlock: (T) -> Unit = NoOp + set(value) { + field = value + reset = { typedInteropView.apply(resetBlock) } + } + + /** + * A block containing the release logic for [T], to be forwarded to user. + * It will be called if [LayoutNode] associated with this [InteropViewHolder] is released. + */ + var releaseBlock: (T) -> Unit = NoOp + set(value) { + field = value + release = { + typedInteropView.apply(releaseBlock) + } + } +} + +/** + * Create a [LayoutNode] factory that can be constructed from [TypedInteropViewHolder] built with + * the [currentCompositeKeyHash] + * + * @see [AndroidView.android.kt:createAndroidViewNodeFactory] + */ +@Composable +private fun createInteropViewLayoutNodeFactory( + factory: (compositeKeyHash: Int) -> TypedInteropViewHolder +): () -> LayoutNode { + val compositeKeyHash = currentCompositeKeyHash + + return { + factory(compositeKeyHash).layoutNode + } +} + +/** + * Entry point for creating a composable that wraps a platform specific interop view. + * Platform implementations should call it and provide the appropriate factory, returning + * a subclass of [TypedInteropViewHolder]. + * + * @see [AndroidView.android.kt:AndroidView] + */ +@Composable +@UiComposable +internal fun InteropView( + factory: (compositeKeyHash: Int) -> TypedInteropViewHolder, + modifier: Modifier, + onReset: ((T) -> Unit)? = null, + onRelease: (T) -> Unit = NoOp, + update: (T) -> Unit = NoOp +) { + val compositeKeyHash = currentCompositeKeyHash + val materializedModifier = currentComposer.materialize(modifier) + val density = LocalDensity.current + val compositionLocalMap = currentComposer.currentCompositionLocalMap + + // TODO: there are other parameters on Android that we don't yet use: + // lifecycleOwner, savedStateRegistryOwner, layoutDirection + if (onReset == null) { + ComposeNode( + factory = createInteropViewLayoutNodeFactory(factory), + update = { + updateParameters( + compositionLocalMap, + materializedModifier, + density, + compositeKeyHash + ) + set(update) { requireViewFactoryHolder().updateBlock = it } + set(onRelease) { requireViewFactoryHolder().releaseBlock = it } + } + ) + } else { + ReusableComposeNode( + factory = createInteropViewLayoutNodeFactory(factory), + update = { + updateParameters( + compositionLocalMap, + materializedModifier, + density, + compositeKeyHash + ) + set(onReset) { requireViewFactoryHolder().resetBlock = it } + set(update) { requireViewFactoryHolder().updateBlock = it } + set(onRelease) { requireViewFactoryHolder().releaseBlock = it } + } + ) + } +} + +/** + * Updates the parameters of the [LayoutNode] in the current [Updater] with the given values. + * @see [AndroidView.android.kt:updateViewHolderParams] + */ +private fun Updater.updateParameters( + compositionLocalMap: CompositionLocalMap, + modifier: Modifier, + density: Density, + compositeKeyHash: Int +) { + set(compositionLocalMap, SetResolvedCompositionLocals) + set(modifier) { requireViewFactoryHolder().modifier = it } + set(density) { requireViewFactoryHolder().density = it } + set(compositeKeyHash, SetCompositeKeyHash) +} + +/** + * Returns the [TypedInteropViewHolder] associated with the current [LayoutNode]. + * Since the [TypedInteropViewHolder] is responsible for constructing the [LayoutNode], it + * associates itself with the [LayoutNode] by setting the [LayoutNode.interopViewFactoryHolder] + * property and it's safe to cast from [InteropViewHolder] + */ +@Suppress("UNCHECKED_CAST") +private fun LayoutNode.requireViewFactoryHolder(): TypedInteropViewHolder { + // This LayoutNode is created and managed internally here, so it's safe to cast + return checkNotNull(interopViewFactoryHolder) as TypedInteropViewHolder +} + diff --git a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/viewinterop/InteropViewHolder.skiko.kt b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/viewinterop/InteropViewHolder.skiko.kt index f1abe41f9b0b5..0021b4eb19820 100644 --- a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/viewinterop/InteropViewHolder.skiko.kt +++ b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/viewinterop/InteropViewHolder.skiko.kt @@ -18,31 +18,202 @@ package androidx.compose.ui.viewinterop import androidx.compose.runtime.ComposeNodeLifecycleCallback import androidx.compose.runtime.snapshots.SnapshotStateObserver +import androidx.compose.ui.Modifier import androidx.compose.ui.input.pointer.PointerEvent -import kotlinx.atomicfu.atomic +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.layout.MeasurePolicy +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.node.LayoutNode +import androidx.compose.ui.unit.Density + +private fun abstractInvocationError(name: String): Nothing { + throw NotImplementedError("Abstract `$name` must be implemented by platform-specific subclass of `InteropViewHolder`") +} /** * A holder that keeps references to user interop view and its group (container). - * It's actual implementation of [InteropViewFactoryHolder] + * It's an actual implementation of `expect class [InteropViewFactoryHolder]` * * @see InteropViewFactoryHolder + * + * @param platformModifier The modifier that is specific to the platform. */ internal open class InteropViewHolder( val container: InteropContainer, - val group: InteropViewGroup + val group: InteropViewGroup, + private val compositeKeyHash: Int, + measurePolicy: MeasurePolicy, + isInteractive: Boolean, + platformModifier: Modifier ) : ComposeNodeLifecycleCallback { + private var onModifierChanged: ((Modifier) -> Unit)? = null + + /** + * User-provided modifier that will be reapplied if changed. + */ + var modifier: Modifier = Modifier + set(value) { + if (value !== field) { + field = value + onModifierChanged?.invoke(value) + } + } + + private var hasUpdateBlock = false + + var update: () -> Unit = {} + protected set(value) { + field = value + hasUpdateBlock = true + runUpdate() + } + + protected var reset: () -> Unit = {} + + protected var release: () -> Unit = {} + + private var onDensityChanged: ((Density) -> Unit)? = null + var density: Density = Density(1f) + set(value) { + if (value !== field) { + field = value + onDensityChanged?.invoke(value) + } + } + + /** + * If the view is not attached, update on closure change (or on setting initial one) will + * be postponed until it's attached and triggered when this flag is set to `true`. + * + * If the view is detached, the observer is stopped to avoid redundant callbacks. + */ + private var isAttachedToWindow: Boolean = false + set(value) { + if (value != field) { + field = value + + if (value) { + runUpdate() + } else { + snapshotObserver.clear(this) + } + } + } + + private val snapshotObserver: SnapshotStateObserver + get() { + return container.snapshotObserver + } + + /** + * If we're not attached, the observer won't be started in scope of this object. It will be run + * after [insertInteropView] is called. + * + * Dispatch scheduling strategy is defined by platform implementation of + * [InteropContainer.scheduleUpdate]. + */ + private val runUpdate: () -> Unit = { + if (hasUpdateBlock && isAttachedToWindow) { + snapshotObserver.observeReads(this, DispatchUpdateUsingContainerStrategy, update) + } + } + + override fun onReuse() { + reset() + } + + override fun onDeactivate() { + // TODO: Android calls [reset] here, but it's not clear why it's needed, because + // [onReuse] will be called after [onDeactivate] if the holder is indeed reused. + // discuss it with Google when this code is commonized + } + + override fun onRelease() { + release() + } + + /** + * Construct a [LayoutNode] that is linked to this [InteropViewHolder]. + */ + val layoutNode: LayoutNode by lazy { + val layoutNode = LayoutNode() + + layoutNode.interopViewFactoryHolder = this - // Keep nullable to match the `expect` declaration of InteropViewFactoryHolder - open fun getInteropView(): InteropView? = throw NotImplementedError() + val coreModifier = platformModifier + .pointerInteropFilter(isInteractive = isInteractive, interopViewHolder = this) + .trackInteropPlacement(this) + .onGloballyPositioned { layoutCoordinates -> + layoutAccordingTo(layoutCoordinates) + // TODO: Should be the same as [Owner.onInteropViewLayoutChange]? +// container.onInteropViewLayoutChange(this) + } - // TODO: implement interop view recycling - override fun onReuse() {} - override fun onDeactivate() {} - override fun onRelease() {} + layoutNode.compositeKeyHash = compositeKeyHash + layoutNode.modifier = modifier then coreModifier + onModifierChanged = { layoutNode.modifier = it then coreModifier } - // TODO: Try to share more with [AndroidViewHolder] + layoutNode.density = density + onDensityChanged = { layoutNode.density = it } + layoutNode.measurePolicy = measurePolicy + + layoutNode + } + + fun place() { + container.place(this) + } + + fun unplace() { + container.unplace(this) + } + + /** + * Must be called by implementations when the interop view is attached to the window. + */ + open fun insertInteropView(root: InteropViewGroup, index: Int) { + isAttachedToWindow = true + } + + /** + * Must be called by implementations when the interop view is detached from the window. + */ + open fun removeInteropView(root: InteropViewGroup) { + isAttachedToWindow = false + } + + // ===== Abstract methods to be implemented by platform-specific subclasses ===== + + open fun changeInteropViewIndex(root: InteropViewGroup, index: Int) { + abstractInvocationError("fun moveInteropViewTo(index: Int)") + } + + /** + * Dispatches the pointer event to the interop view. + */ open fun dispatchToView(pointerEvent: PointerEvent) { - throw NotImplementedError() + abstractInvocationError("fun dispatchToView(pointerEvent: PointerEvent)") } -} + + /** + * Layout the interop view according to the given layout coordinates. + */ + open fun layoutAccordingTo(layoutCoordinates: LayoutCoordinates) { + abstractInvocationError("fun layoutAccordingTo(layoutCoordinates: LayoutCoordinates)") + } + + /** + * `expect fun` of expect class [InteropViewFactoryHolder] (aka this) + * Returns the actual interop view instance. + */ + open fun getInteropView(): InteropView? { + abstractInvocationError("fun getInteropView(): InteropView?") + } + + companion object { + private val DispatchUpdateUsingContainerStrategy: (InteropViewHolder) -> Unit = { + it.container.scheduleUpdate { it.update() } + } + } +} \ No newline at end of file diff --git a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/viewinterop/InteropViewUpdater.skiko.kt b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/viewinterop/InteropViewUpdater.skiko.kt deleted file mode 100644 index 5d0b1ab0d1272..0000000000000 --- a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/viewinterop/InteropViewUpdater.skiko.kt +++ /dev/null @@ -1,81 +0,0 @@ -/* - * 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.viewinterop - -import androidx.compose.runtime.State -import androidx.compose.runtime.snapshots.SnapshotStateObserver -import kotlinx.atomicfu.atomic - -/** - * A helper class to schedule an update for the interop component whenever the [State] used by - * the [update] lambda is changed. - * - * @param component The interop component to be updated. - * @param update The lambda to be called whenever the state used by this lambda is changed. - * @param invokeLater The lambda to register [update] execution to defer it in order to sync it with - * Compose rendering. The aim of this is to make visual changes to UIKit and Compose - * simultaneously. - */ -internal class InteropViewUpdater( - private val component: T, - update: (T) -> Unit, - private val invokeLater: (() -> Unit) -> Unit, -) { - private var isDisposed = false - private val isUpdateScheduled = atomic(false) - private val snapshotObserver = SnapshotStateObserver { command -> - command() - } - - private val scheduleUpdate = { _: T -> - if (!isUpdateScheduled.getAndSet(true)) { - invokeLater { - isUpdateScheduled.value = false - if (!isDisposed) { - performUpdate() - } - } - } - } - - var update: (T) -> Unit = update - set(value) { - if (field != value) { - field = value - performUpdate() - } - } - - private fun performUpdate() { - // don't replace scheduleUpdate by lambda reference, - // scheduleUpdate should always be the same instance - snapshotObserver.observeReads(component, scheduleUpdate) { - update(component) - } - } - - init { - snapshotObserver.start() - performUpdate() - } - - fun dispose() { - snapshotObserver.stop() - snapshotObserver.clear() - isDisposed = true - } -} 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 cd282ce66c509..26bba3178f1bc 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 @@ -17,124 +17,30 @@ package androidx.compose.ui.interop import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.currentCompositeKeyHash +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import androidx.compose.runtime.State -import androidx.compose.ui.ExperimentalComposeUiApi -import androidx.compose.ui.draw.drawBehind -import androidx.compose.ui.graphics.BlendMode import androidx.compose.ui.graphics.Color -import androidx.compose.ui.input.pointer.PointerEvent -import androidx.compose.ui.layout.EmptyLayout -import androidx.compose.ui.layout.findRootCoordinates -import androidx.compose.ui.layout.onGloballyPositioned -import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.graphics.isUnspecified import androidx.compose.ui.semantics.semantics import androidx.compose.ui.uikit.toUIColor -import androidx.compose.ui.unit.Density -import androidx.compose.ui.unit.IntRect -import androidx.compose.ui.unit.asCGRect -import androidx.compose.ui.unit.height -import androidx.compose.ui.unit.roundToIntRect -import androidx.compose.ui.unit.toDpOffset -import androidx.compose.ui.unit.toDpRect -import androidx.compose.ui.unit.toOffset -import androidx.compose.ui.unit.toRect -import androidx.compose.ui.unit.width -import androidx.compose.ui.viewinterop.InteropContainer import androidx.compose.ui.viewinterop.InteropView -import androidx.compose.ui.viewinterop.InteropViewHolder -import androidx.compose.ui.viewinterop.InteropViewUpdater +import androidx.compose.ui.viewinterop.InteropWrappingView import androidx.compose.ui.viewinterop.LocalInteropContainer -import androidx.compose.ui.viewinterop.UIKitInteropViewGroup -import androidx.compose.ui.viewinterop.interopViewSemantics -import androidx.compose.ui.viewinterop.pointerInteropFilter -import androidx.compose.ui.viewinterop.trackInteropPlacement +import androidx.compose.ui.viewinterop.UIKitInteropViewControllerHolder +import androidx.compose.ui.viewinterop.UIKitInteropViewHolder import kotlinx.cinterop.CValue import platform.CoreGraphics.CGRect -import platform.CoreGraphics.CGRectMake import platform.UIKit.UIView import platform.UIKit.UIViewController -import platform.UIKit.addChildViewController -import platform.UIKit.didMoveToParentViewController -import platform.UIKit.removeFromParentViewController -import platform.UIKit.willMoveToParentViewController private val STUB_CALLBACK_WITH_RECEIVER: Any.() -> Unit = {} private val DefaultViewResize: UIView.(CValue) -> Unit = { rect -> this.setFrame(rect) } private val DefaultViewControllerResize: UIViewController.(CValue) -> Unit = { rect -> this.view.setFrame(rect) } -/** - * Internal common part of custom layout emitting a node associated with UIKit interop for [UIView] and [UIViewController]. - */ -@Composable -private fun UIKitInteropLayout( - modifier: Modifier, - update: (T) -> Unit, - background: Color, - interopViewHolder: UIKitInteropViewHolder, - interactive: Boolean, - accessibilityEnabled: Boolean, -) { - val density = LocalDensity.current - val finalModifier = modifier - .onGloballyPositioned { coordinates -> - val rootCoordinates = coordinates.findRootCoordinates() - - val unclippedBounds = rootCoordinates - .localBoundingBoxOf( - sourceCoordinates = coordinates, - clipBounds = false - ) - - val clippedBounds = rootCoordinates - .localBoundingBoxOf( - sourceCoordinates = coordinates, - clipBounds = true - ) - - interopViewHolder.updateRect( - unclippedRect = unclippedBounds.roundToIntRect(), - clippedRect = clippedBounds.roundToIntRect(), - density = density - ) - } - .drawBehind { - // Paint the rectangle behind with transparent color to let our interop shine through - drawRect( - color = Color.Transparent, - blendMode = BlendMode.Clear - ) - } - .trackInteropPlacement(interopViewHolder) - .pointerInteropFilter(interactive, interopViewHolder) - .interopViewSemantics(accessibilityEnabled, interopViewHolder) - - EmptyLayout( - finalModifier - ) - - DisposableEffect(Unit) { - interopViewHolder.onStart(initialUpdateBlock = update) - - onDispose { - interopViewHolder.onStop() - } - } - - LaunchedEffect(background) { - interopViewHolder.onBackgroundColorChange(background) - } - - SideEffect { - interopViewHolder.setUpdate(update) - } -} - /** * @param factory The block creating the [UIView] to be composed. * @param modifier The modifier to be applied to the layout. Size should be specified in modifier. @@ -174,24 +80,32 @@ fun UIKitView( interactive: Boolean = true, accessibilityEnabled: Boolean = true ) { + val compositeKeyHash = currentCompositeKeyHash val interopContainer = LocalInteropContainer.current - val interopViewHolder = remember { - UIKitViewHolder( - container = interopContainer, - createView = factory, - onResize = onResize, - onRelease = onRelease, - areTouchesDelayed = true - ) - } - UIKitInteropLayout( + val backgroundColor by remember(background) { mutableStateOf(background.toUIColor()) } + + InteropView( + factory = { + UIKitInteropViewHolder( + factory = factory, + interopContainer = interopContainer, + group = InteropWrappingView(areTouchesDelayed = true), + isInteractive = interactive, + isNativeAccessibilityEnabled = accessibilityEnabled, + compositeKeyHash = compositeKeyHash, + resize = onResize + ) + }, modifier = modifier, - update = update, - background = background, - interopViewHolder = interopViewHolder, - interactive = interactive, - accessibilityEnabled = accessibilityEnabled + onReset = null, + onRelease = onRelease, + update = { + backgroundColor?.let { color -> + it.backgroundColor = color + } + update(it) + } ) } @@ -236,173 +150,33 @@ fun UIKitViewController( interactive: Boolean = true, accessibilityEnabled: Boolean = true ) { + val compositeKeyHash = currentCompositeKeyHash val interopContainer = LocalInteropContainer.current - val rootViewController = LocalUIViewController.current - val interopViewHolder = remember { - UIKitViewControllerHolder( - container = interopContainer, - createViewController = factory, - rootViewController = rootViewController, - onResize = onResize, - onRelease = onRelease, - areTouchesDelayed = true - ) - } - - UIKitInteropLayout( + val parentViewController = LocalUIViewController.current + + val backgroundColor by remember(background) { mutableStateOf(background.toUIColor()) } + + InteropView( + factory = { + UIKitInteropViewControllerHolder( + factory = factory, + parentViewController = parentViewController, + interopContainer = interopContainer, + group = InteropWrappingView(areTouchesDelayed = true), + isInteractive = interactive, + isNativeAccessibilityEnabled = accessibilityEnabled, + compositeKeyHash = compositeKeyHash, + resize = onResize + ) + }, modifier = modifier, - update = update, - background = background, - interopViewHolder = interopViewHolder, - interactive = interactive, - accessibilityEnabled = accessibilityEnabled - ) -} - -/** - * An abstract class responsible for hierarchy updates and state management of interop components - * like [UIView] and [UIViewController]. - */ -private abstract class UIKitInteropViewHolder( - container: InteropContainer, - - // TODO: reuse an object created makeComponent inside LazyColumn like in AndroidView: - // https://developer.android.com/reference/kotlin/androidx/compose/ui/viewinterop/package-summary#AndroidView(kotlin.Function1,kotlin.Function1,androidx.compose.ui.Modifier,kotlin.Function1,kotlin.Function1) - val createUserComponent: () -> T, - val onResize: (T, rect: CValue) -> Unit, - val onRelease: (T) -> Unit, - areTouchesDelayed: Boolean -) : InteropViewHolder(container, group = UIKitInteropViewGroup(areTouchesDelayed)) { - private var currentUnclippedRect: IntRect? = null - private var currentClippedRect: IntRect? = null - lateinit var userComponent: T - private lateinit var updater: InteropViewUpdater - - /** - * Set the [InteropViewUpdater.update] lambda. - * Lambda is immediately executed after setting. - * @see InteropViewUpdater.performUpdate - */ - fun setUpdate(block: (T) -> Unit) { - updater.update = block - } - - /** - * Set the frame of the wrapping view. - */ - fun updateRect(unclippedRect: IntRect, clippedRect: IntRect, density: Density) { - if (currentUnclippedRect == unclippedRect && currentClippedRect == clippedRect) { - return - } - - val clippedDpRect = clippedRect.toRect().toDpRect(density) - val unclippedDpRect = unclippedRect.toRect().toDpRect(density) - - // wrapping view itself is always using the clipped rect - if (clippedRect != currentClippedRect) { - container.changeInteropViewLayout { - group.setFrame(clippedDpRect.asCGRect()) - } - } - - // Only call onResize if the actual size changes. - if (currentUnclippedRect != unclippedRect || currentClippedRect != clippedRect) { - // offset to move the component to the correct position inside the wrapping view, so - // its global unclipped frame stays the same - val offset = unclippedRect.topLeft - clippedRect.topLeft - val dpOffset = offset.toOffset().toDpOffset(density) - - container.changeInteropViewLayout { - // The actual component created by the user is resized here using the provided callback. - onResize( - userComponent, - CGRectMake( - x = dpOffset.x.value.toDouble(), - y = dpOffset.y.value.toDouble(), - width = unclippedDpRect.width.value.toDouble(), - height = unclippedDpRect.height.value.toDouble() - ), - ) + onReset = null, + onRelease = onRelease, + update = { + backgroundColor?.let { color -> + it.view.backgroundColor = color } + update(it) } - - currentUnclippedRect = unclippedRect - currentClippedRect = clippedRect - } - - fun onStart(initialUpdateBlock: (T) -> Unit) { - userComponent = createUserComponent() - updater = InteropViewUpdater(userComponent, initialUpdateBlock) { - container.changeInteropViewLayout(action = it) - } - - container.changeInteropViewLayout { - setupViewHierarchy() - } - } - - fun onStop() { - container.changeInteropViewLayout { - destroyViewHierarchy() - } - - onRelease(userComponent) - updater.dispose() - } - - fun onBackgroundColorChange(color: Color) = container.changeInteropViewLayout { - if (color == Color.Unspecified) { - group.backgroundColor = container.root.backgroundColor - } else { - group.backgroundColor = color.toUIColor() - } - } - - override fun dispatchToView(pointerEvent: PointerEvent) { - // Do nothing - iOS uses hit-testing instead of redispatching - } - - abstract fun setupViewHierarchy() - abstract fun destroyViewHierarchy() -} - -private class UIKitViewHolder( - container: InteropContainer, - createView: () -> T, - onResize: (T, rect: CValue) -> Unit, - onRelease: (T) -> Unit, - areTouchesDelayed: Boolean -) : UIKitInteropViewHolder(container, createView, onResize, onRelease, areTouchesDelayed) { - override fun getInteropView(): InteropView = - userComponent - - override fun setupViewHierarchy() { - group.addSubview(userComponent) - } - - override fun destroyViewHierarchy() { - } -} - -private class UIKitViewControllerHolder( - container: InteropContainer, - createViewController: () -> T, - private val rootViewController: UIViewController, - onResize: (T, rect: CValue) -> Unit, - onRelease: (T) -> Unit, - areTouchesDelayed: Boolean -) : UIKitInteropViewHolder(container, createViewController, onResize, onRelease, areTouchesDelayed) { - override fun getInteropView(): InteropView = - userComponent.view - - override fun setupViewHierarchy() { - rootViewController.addChildViewController(userComponent) - group.addSubview(userComponent.view) - userComponent.didMoveToParentViewController(rootViewController) - } - - override fun destroyViewHierarchy() { - userComponent.willMoveToParentViewController(null) - userComponent.removeFromParentViewController() - } -} + ) +} \ No newline at end of file diff --git a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/platform/Accessibility.uikit.kt b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/platform/Accessibility.uikit.kt index fd119a393eb6e..5918d4f84ad36 100644 --- a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/platform/Accessibility.uikit.kt +++ b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/platform/Accessibility.uikit.kt @@ -31,8 +31,8 @@ import androidx.compose.ui.state.ToggleableState import androidx.compose.ui.uikit.utils.CMPAccessibilityContainer import androidx.compose.ui.uikit.utils.CMPAccessibilityElement import androidx.compose.ui.unit.toSize -import androidx.compose.ui.viewinterop.InteropViewSemanticsKey -import androidx.compose.ui.viewinterop.UIKitInteropViewGroup +import androidx.compose.ui.viewinterop.NativeAccessibilityViewSemanticsKey +import androidx.compose.ui.viewinterop.InteropWrappingView import kotlin.coroutines.CoroutineContext import kotlin.time.measureTime import kotlinx.cinterop.CValue @@ -119,7 +119,7 @@ private object CachedAccessibilityPropertyKeys { val accessibilityTraits = CachedAccessibilityPropertyKey() val accessibilityValue = CachedAccessibilityPropertyKey() val accessibilityFrame = CachedAccessibilityPropertyKey>() - val interopWrappingView = CachedAccessibilityPropertyKey() + val nativeAccessibilityView = CachedAccessibilityPropertyKey() } /** @@ -191,12 +191,18 @@ private class AccessibilityElement( private var children = mutableListOf() /** - * Cached InteropWrappingView for the element if it's present. AX services will be redirected - * to this view if it's not null, semantics data for this element will be ignored. + * Cached [InteropWrappingView] for the element if it's present. AX services will be redirected + * to this view if it's not null, other Compose semantics data for this element will be ignored. + * + * The specific type of [InteropWrappingView] is needed to allow to change the + * [InteropWrappingView.actualAccessibilityContainer], which overrides defaults accessibility + * containers of view (its superview) to be whatever container is resolved within Compose + * hierarchy. This is required to allow the synthesized accessibility tree to be properly + * traversed by AX services. */ - private val interopView: UIKitInteropViewGroup? - get() = getOrElse(CachedAccessibilityPropertyKeys.interopWrappingView) { - cachedConfig.getOrNull(InteropViewSemanticsKey)?.also { + private val nativeAccessibilityView: InteropWrappingView? + get() = getOrElse(CachedAccessibilityPropertyKeys.nativeAccessibilityView) { + cachedConfig.getOrNull(NativeAccessibilityViewSemanticsKey)?.also { it.actualAccessibilityContainer = parent?.accessibilityContainer } } @@ -217,7 +223,7 @@ private class AccessibilityElement( /** * Returns accessibility element communicated to iOS Accessibility services for the given [index]. * Takes a child at [index]. - * If the child is constructed from a [SemanticsNode] with [InteropViewSemanticsKey], + * If the child is constructed from a [SemanticsNode] with [NativeAccessibilityViewSemanticsKey], * then the element at the given index is a native view. * If the child has its own children, then the element at the given index is the synthesized container * for the child. Otherwise, the element at the given index is the child itself. @@ -228,7 +234,7 @@ private class AccessibilityElement( return if (i in children.indices) { val child = children[i] - val nativeView = child.interopView + val nativeView = child.nativeAccessibilityView if (nativeView != null) { return nativeView @@ -252,7 +258,7 @@ private class AccessibilityElement( for (index in 0 until children.size) { val child = children[index] - if (element == child.interopView) { + if (element == child.nativeAccessibilityView) { return index.toLong() } else if (child.hasChildren && element == child.accessibilityContainer) { return index.toLong() 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 2718d4fd77f17..612f26b893ef7 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 @@ -102,6 +102,7 @@ import platform.UIKit.UIPress import platform.UIKit.UITouch import platform.UIKit.UITouchPhase import platform.UIKit.UIView +import platform.UIKit.UIViewController import platform.UIKit.UIViewControllerTransitionCoordinatorProtocol import platform.UIKit.UIWindow @@ -362,10 +363,15 @@ internal class ComposeSceneMediator( renderingView.redrawer.needsProactiveDisplayLink = needHighFrequencyPolling } - private fun hitTestInteropView(point: CValue, event: UIEvent?): InteropView? = + private fun hitTestInteropView(point: CValue, event: UIEvent?): UIView? = point.useContents { val position = asDpOffset().toOffset(density) - scene.hitTestInteropView(position) + val interopView = scene.hitTestInteropView(position) + + // Find a group of a holder assocaited with a given interop view or view controller + interopView?.let { + interopContainer.groupForInteropView(it) + } } /** diff --git a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/uikit/Extensions.uikit.kt b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/uikit/Extensions.uikit.kt index e65c58954f64d..dfcab12a6a341 100644 --- a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/uikit/Extensions.uikit.kt +++ b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/uikit/Extensions.uikit.kt @@ -35,9 +35,14 @@ internal val UITraitEnvironmentProtocol.systemDensity: Density ) } -internal fun Color.toUIColor() = UIColor( - red = red.toDouble(), - green = green.toDouble(), - blue = blue.toDouble(), - alpha = alpha.toDouble(), -) +internal fun Color.toUIColor(): UIColor? = + if (this == Color.Unspecified) { + null + } else { + UIColor( + red = red.toDouble(), + green = green.toDouble(), + blue = blue.toDouble(), + alpha = alpha.toDouble(), + ) + } diff --git a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/viewinterop/InteropView.uikit.kt b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/viewinterop/InteropView.uikit.kt index fb7f5572032a3..8d1fd714a6d2c 100644 --- a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/viewinterop/InteropView.uikit.kt +++ b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/viewinterop/InteropView.uikit.kt @@ -21,16 +21,19 @@ import androidx.compose.ui.semantics.AccessibilityKey import androidx.compose.ui.semantics.SemanticsPropertyReceiver import androidx.compose.ui.semantics.semantics import androidx.compose.ui.uikit.utils.CMPInteropWrappingView +import androidx.compose.ui.interop.UIKitView +import androidx.compose.ui.interop.UIKitViewController import kotlinx.cinterop.readValue import platform.CoreGraphics.CGRectZero +import platform.UIKit.UIResponder import platform.UIKit.UIView import platform.UIKit.UIViewController /** - * On iOS, [InteropView] is a typealias for [UIView]. Interop entity can in fact be - * a [UIViewController], in this case it will be wrapped in a [UIView] anyway. + * On iOS [InteropView] is a [UIResponder], which is a base class for [UIView] and [UIViewController] + * that can be both created in interop API exposed on iOS. */ -actual typealias InteropView = UIView +actual typealias InteropView = UIResponder @Suppress("ACTUAL_WITHOUT_EXPECT") // https://youtrack.jetbrains.com/issue/KT-37316 internal actual typealias InteropViewGroup = UIView @@ -39,11 +42,15 @@ internal actual typealias InteropViewGroup = UIView * A [UIView] that contains underlying interop element, such as an independent [UIView] * or [UIViewController]'s root [UIView]. * + * Also contains [actualAccessibilityContainer] property that overrides default (superview) + * accessibility container to allow proper traversal of a Compose semantics tree containing + * interop views. + * * @param areTouchesDelayed indicates whether the touches are allowed to be delayed by Compose * in attempt to intercept touches, or should get delivered to the interop view immediately without * Compose being aware of them. */ -internal class UIKitInteropViewGroup( +internal class InteropWrappingView( val areTouchesDelayed: Boolean ) : CMPInteropWrappingView(frame = CGRectZero.readValue()) { var actualAccessibilityContainer: Any? = null @@ -59,8 +66,8 @@ internal class UIKitInteropViewGroup( } } -internal val InteropViewSemanticsKey = AccessibilityKey( - name = "InteropView", +internal val NativeAccessibilityViewSemanticsKey = AccessibilityKey( + name = "NativeAccessibilityView", mergePolicy = { parentValue, childValue -> if (parentValue == null) { childValue @@ -77,15 +84,19 @@ internal val InteropViewSemanticsKey = AccessibilityKey( } ) -private var SemanticsPropertyReceiver.interopView by InteropViewSemanticsKey +private var SemanticsPropertyReceiver.nativeAccessibilityView by NativeAccessibilityViewSemanticsKey +// TODO: align "platform" vs "native" naming /** - * Chain [this] with [Modifier.semantics] that sets the [trackInteropPlacement] of the node - * if [enabled] is true. If [enabled] is false, [this] is returned as is. + * Chain [this] with [Modifier.semantics] that sets the [nativeAccessibilityView] of the node to + * the [interopWrappingView] if [isEnabled] is true. + * If [isEnabled] is false, [this] is returned as is. + * + * See [UIKitView] and [UIKitViewController] accessibility argument for description of effects introduced by this semantics. */ -internal fun Modifier.interopViewSemantics(enabled: Boolean, interopViewHolder: InteropViewHolder) = - if (enabled) { - this.semantics { interopView = interopViewHolder.group as UIKitInteropViewGroup } +internal fun Modifier.nativeAccessibility(isEnabled: Boolean, interopWrappingView: InteropWrappingView) = + if (isEnabled) { + this.semantics { nativeAccessibilityView = interopWrappingView } } else { this - } + } \ No newline at end of file diff --git a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/viewinterop/UIKitInteropContainer.uikit.kt b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/viewinterop/UIKitInteropContainer.uikit.kt index 5c0901f221479..233bd8759db2e 100644 --- a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/viewinterop/UIKitInteropContainer.uikit.kt +++ b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/viewinterop/UIKitInteropContainer.uikit.kt @@ -16,6 +16,8 @@ package androidx.compose.ui.viewinterop +import androidx.compose.runtime.snapshots.SnapshotStateObserver + /** * A container that controls interop views/components. * It's using a modifier of [TrackInteropPlacementModifierNode] to properly sort native interop @@ -27,11 +29,28 @@ internal class UIKitInteropContainer( val requestRedraw: () -> Unit ) : InteropContainer { override var rootModifier: TrackInteropPlacementModifierNode? = null - override var interopViews = mutableSetOf() - private set - + private var interopViews = mutableMapOf() private var transaction = UIKitInteropMutableTransaction() + // TODO: Android reuses `owner.snapshotObserver`. We should probably do the same with RootNodeOwner. + /** + * Snapshot observer that is used by underlying [InteropViewHolder] to observe changes in + * Compose state and trigger changes in UIKit objects. + * It starts observing when the first interop view is added and stops when the last one is + * removed. + */ + override val snapshotObserver = SnapshotStateObserver { command -> + command() + } + + override fun contains(holder: InteropViewHolder): Boolean = + interopViews.contains(holder.getInteropView()) + + fun groupForInteropView(interopView: InteropView): InteropViewGroup? { + val holder = interopViews[interopView] ?: return null + return holder.group + } + /** * Dispose by immediately executing all UIKit interop actions that can't be deferred to be * synchronized with rendering because scene will never be rendered past that moment. @@ -42,6 +61,9 @@ internal class UIKitInteropContainer( for (action in lastTransaction.actions) { action.invoke() } + + // snapshotObserver.stop() is not needed, because unplaceInteropView will be called + // for all interop views and it will stop observing when the last one is removed. } /** @@ -53,34 +75,53 @@ internal class UIKitInteropContainer( return result } - override fun placeInteropView(interopView: InteropViewHolder) { + override fun place(holder: InteropViewHolder) { + val interopView = checkNotNull(holder.getInteropView()) + if (interopViews.isEmpty()) { transaction.state = UIKitInteropState.BEGAN + snapshotObserver.start() } - interopViews.add(interopView) - val countBelow = countInteropComponentsBelow(interopView) - changeInteropViewLayout { - root.insertSubview(interopView.group, countBelow.toLong()) + val isAdded = interopViews.put(interopView, holder) == null + + val countBelow = countInteropComponentsBelow(holder) + + if (isAdded) { + scheduleUpdate { + holder.insertInteropView(root = root, index = countBelow) + } + } else { + scheduleUpdate { + holder.changeInteropViewIndex(root = root, index = countBelow) + } } } - override fun unplaceInteropView(interopView: InteropViewHolder) { + override fun unplace(holder: InteropViewHolder) { + val interopView = requireNotNull(holder.getInteropView()) + interopViews.remove(interopView) if (interopViews.isEmpty()) { transaction.state = UIKitInteropState.ENDED + snapshotObserver.stop() } - changeInteropViewLayout { - interopView.group.removeFromSuperview() + scheduleUpdate { + holder.removeInteropView(root = root) } } - override fun changeInteropViewLayout(action: () -> Unit) { + override fun scheduleUpdate(action: () -> Unit) { requestRedraw() // Add lambda to a list of commands which will be executed later // in the same [CATransaction], when the next rendered Compose frame is presented. transaction.add(action) } + + // TODO: Should be the same as [Owner.onInteropViewLayoutChange]? +// override fun onInteropViewLayoutChange(holder: InteropViewHolder) { +// // No-op +// } } diff --git a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/viewinterop/UIKitInteropElementHolder.uikit.kt b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/viewinterop/UIKitInteropElementHolder.uikit.kt new file mode 100644 index 0000000000000..0034cdad53177 --- /dev/null +++ b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/viewinterop/UIKitInteropElementHolder.uikit.kt @@ -0,0 +1,150 @@ +/* + * 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.viewinterop + +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.PointerEvent +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.layout.MeasurePolicy +import androidx.compose.ui.layout.findRootCoordinates +import androidx.compose.ui.unit.IntRect +import androidx.compose.ui.unit.asCGRect +import androidx.compose.ui.unit.roundToIntRect +import androidx.compose.ui.unit.toDpRect +import androidx.compose.ui.unit.toRect +import kotlinx.cinterop.CValue +import platform.CoreGraphics.CGRect + +internal abstract class UIKitInteropElementHolder( + factory: () -> T, + interopContainer: InteropContainer, + group: InteropWrappingView, + isInteractive: Boolean, + isNativeAccessibilityEnabled: Boolean, + compositeKeyHash: Int, +) : TypedInteropViewHolder( + factory = factory, + interopContainer = interopContainer, + group = group, + compositeKeyHash = compositeKeyHash, + measurePolicy = MeasurePolicy { _, constraints -> + layout(constraints.minWidth, constraints.minHeight) { + // No-op, no children are expected + // TODO: attempt to calculate the size of the wrapped view using constraints + // and autolayout system if possible + // https://youtrack.jetbrains.com/issue/CMP-5873/iOS-investigate-intrinsic-sizing-of-interop-elements + } + }, + isInteractive = isInteractive, + platformModifier = Modifier + // Make the canvas transparent in that area to make the interop view behind visible + .drawBehind { + drawRect( + color = Color.Transparent, + blendMode = BlendMode.Clear + ) + } + .nativeAccessibility(isNativeAccessibilityEnabled, group) +) { + + private var currentUnclippedRect: IntRect? = null + private var currentClippedRect: IntRect? = null + private var currentUserComponentRect: IntRect? = null + + override fun layoutAccordingTo(layoutCoordinates: LayoutCoordinates) { + val rootCoordinates = layoutCoordinates.findRootCoordinates() + + val unclippedRect = rootCoordinates + .localBoundingBoxOf( + sourceCoordinates = layoutCoordinates, + clipBounds = false + ).roundToIntRect() + + val clippedRect = rootCoordinates + .localBoundingBoxOf( + sourceCoordinates = layoutCoordinates, + clipBounds = true + ).roundToIntRect() + + if (currentUnclippedRect == unclippedRect && currentClippedRect == clippedRect) { + return + } + + // wrapping view itself is always using the clipped rect + // don't issue a redundant update, if the clipped rect is the same + if (clippedRect != currentClippedRect) { + val groupFrame = clippedRect + .toRect() + .toDpRect(density) + .asCGRect() + + container.scheduleUpdate { + group.setFrame(groupFrame) + } + } + + // user component is always updated if the unclipped or clipped rect changes, + // because it needs to be moved inside the clipping view to keep the frame + // in window coordinates the same + if (currentUnclippedRect != unclippedRect || currentClippedRect != clippedRect) { + // offset to move the component to the correct position inside the wrapping view, so + // its root space frame stays the same if the wrapping view is clipped + + val userComponentRect = IntRect( + offset = unclippedRect.topLeft - clippedRect.topLeft, + size = unclippedRect.size + ) + + // update the user component frame only if it changes + if (userComponentRect != currentUserComponentRect) { + currentUserComponentRect = userComponentRect + + val userComponentFrame = + userComponentRect + .toRect() + .toDpRect(density) + .asCGRect() + + container.scheduleUpdate { + setUserComponentFrame(userComponentFrame) + } + } + } + + currentUnclippedRect = unclippedRect + currentClippedRect = clippedRect + + } + + abstract fun setUserComponentFrame(rect: CValue) + + + override fun dispatchToView(pointerEvent: PointerEvent) { + // No-op, we can't dispatch events to UIView or UIViewController directly, see + // [InteractionUIView] logic + } + + /** + * This logic is similar for both interop view and view controller holders. + */ + override fun changeInteropViewIndex(root: InteropViewGroup, index: Int) { + root.insertSubview(view = group, atIndex = index.toLong()) + } +} \ No newline at end of file diff --git a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/viewinterop/UIKitInteropTransaction.uikit.kt b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/viewinterop/UIKitInteropTransaction.uikit.kt index 07209faa2376e..93abc6a33f6ba 100644 --- a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/viewinterop/UIKitInteropTransaction.uikit.kt +++ b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/viewinterop/UIKitInteropTransaction.uikit.kt @@ -20,7 +20,7 @@ import platform.QuartzCore.CATransaction /** * Enum which is used to define if rendering strategy should be changed along with this transaction. - * If [BEGAN], it will wait until a next CATransaction on every frame and make the metal layer opaque. + * If [BEGAN], it will wait until a next CATransaction on every frame and make the metal layer transparent. * If [ENDED] it will fallback to the most efficient rendering strategy * (opaque layer, no transaction waiting, asynchronous encoding and GPU-driven presentation). * If [UNCHANGED] it will keep the current rendering strategy. @@ -54,7 +54,7 @@ internal fun UIKitInteropTransaction.isNotEmpty() = !isEmpty() * A mutable transaction managed by [UIKitInteropContainer] to collect changes * to UIKit objects to be executed later. * - * @see UIKitInteropContainer.changeInteropViewLayout + * @see UIKitInteropContainer.scheduleUpdate */ internal class UIKitInteropMutableTransaction : UIKitInteropTransaction { private val _actions = mutableListOf() diff --git a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/viewinterop/UIKitInteropViewControllerHolder.uikit.kt b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/viewinterop/UIKitInteropViewControllerHolder.uikit.kt new file mode 100644 index 0000000000000..60350d400eecb --- /dev/null +++ b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/viewinterop/UIKitInteropViewControllerHolder.uikit.kt @@ -0,0 +1,70 @@ +/* + * 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.viewinterop + +import kotlinx.cinterop.CValue +import platform.CoreGraphics.CGRect +import platform.UIKit.UIViewController +import platform.UIKit.addChildViewController +import platform.UIKit.didMoveToParentViewController +import platform.UIKit.removeFromParentViewController +import platform.UIKit.willMoveToParentViewController + +internal class UIKitInteropViewControllerHolder( + factory: () -> T, + interopContainer: InteropContainer, + private val parentViewController: UIViewController, + group: InteropWrappingView, + isInteractive: Boolean, + isNativeAccessibilityEnabled: Boolean, + compositeKeyHash: Int, + // TODO: deprecate after new API arrives https://youtrack.jetbrains.com/issue/CMP-5719/iOS-revisit-UIKit-interop-API + val resize: (T, rect: CValue) -> Unit +) : UIKitInteropElementHolder( + factory = factory, + interopContainer = interopContainer, + group = group, + isInteractive = isInteractive, + isNativeAccessibilityEnabled = isNativeAccessibilityEnabled, + compositeKeyHash = compositeKeyHash, +) { + init { + // Group will be placed to hierarchy in [InteropContainer.placeInteropView] + group.addSubview(typedInteropView.view) + } + + override fun setUserComponentFrame(rect: CValue) { + // TODO: deprecate after new API arrives https://youtrack.jetbrains.com/issue/CMP-5719/iOS-revisit-UIKit-interop-API + resize(typedInteropView, rect) + } + + override fun insertInteropView(root: InteropViewGroup, index: Int) { + parentViewController.addChildViewController(typedInteropView) + root.insertSubview(group, index.toLong()) + typedInteropView.didMoveToParentViewController(parentViewController) + + super.insertInteropView(root, index) + } + + override fun removeInteropView(root: InteropViewGroup) { + typedInteropView.willMoveToParentViewController(null) + group.removeFromSuperview() + typedInteropView.removeFromParentViewController() + + super.removeInteropView(root) + } +} \ No newline at end of file diff --git a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/viewinterop/UIKitInteropViewHolder.uikit.kt b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/viewinterop/UIKitInteropViewHolder.uikit.kt new file mode 100644 index 0000000000000..7d081d71c15d1 --- /dev/null +++ b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/viewinterop/UIKitInteropViewHolder.uikit.kt @@ -0,0 +1,62 @@ +/* + * 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.viewinterop + +import kotlinx.cinterop.CValue +import platform.CoreGraphics.CGRect +import platform.UIKit.UIView + +internal class UIKitInteropViewHolder( + factory: () -> T, + interopContainer: InteropContainer, + group: InteropWrappingView, + isInteractive: Boolean, + isNativeAccessibilityEnabled: Boolean, + compositeKeyHash: Int, + // TODO: deprecate after new API arrives https://youtrack.jetbrains.com/issue/CMP-5719/iOS-revisit-UIKit-interop-API + val resize: (T, rect: CValue) -> Unit +) : UIKitInteropElementHolder( + factory = factory, + interopContainer = interopContainer, + group = group, + isInteractive = isInteractive, + isNativeAccessibilityEnabled = isNativeAccessibilityEnabled, + compositeKeyHash = compositeKeyHash +) { + init { + // Group will be placed to hierarchy in [InteropContainer.placeInteropView] + group.addSubview(typedInteropView) + } + + override fun setUserComponentFrame(rect: CValue) { + // typedInteropView.setFrame(rect) + // TODO: deprecate after new API arrives https://youtrack.jetbrains.com/issue/CMP-5719/iOS-revisit-UIKit-interop-API + resize(typedInteropView, rect) + } + + override fun insertInteropView(root: InteropViewGroup, index: Int) { + root.insertSubview(group, index.toLong()) + + super.insertInteropView(root, index) + } + + override fun removeInteropView(root: InteropViewGroup) { + group.removeFromSuperview() + + super.removeInteropView(root) + } +} \ No newline at end of file diff --git a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/InteractionUIView.uikit.kt b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/InteractionUIView.uikit.kt index e80d3d5e8052d..d4f74c8eca27c 100644 --- a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/InteractionUIView.uikit.kt +++ b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/InteractionUIView.uikit.kt @@ -20,7 +20,7 @@ import androidx.compose.ui.platform.CUPERTINO_TOUCH_SLOP import androidx.compose.ui.uikit.utils.CMPGestureRecognizer import androidx.compose.ui.uikit.utils.CMPGestureRecognizerHandlerProtocol import androidx.compose.ui.viewinterop.InteropView -import androidx.compose.ui.viewinterop.UIKitInteropViewGroup +import androidx.compose.ui.viewinterop.InteropWrappingView import kotlinx.cinterop.CValue import kotlinx.cinterop.readValue import kotlinx.cinterop.useContents @@ -442,7 +442,7 @@ private class GestureRecognizerHandlerImpl( * lightweight generics. */ internal class InteractionUIView( - private var hitTestInteropView: (point: CValue, event: UIEvent?) -> InteropView?, + private var hitTestInteropView: (point: CValue, event: UIEvent?) -> UIView?, onTouchesEvent: (view: UIView, touches: Set<*>, event: UIEvent?, phase: CupertinoTouchesPhase) -> Unit, private var onTouchesCountChange: (count: Int) -> Unit, private var inInteractionBounds: (CValue) -> Boolean, @@ -500,7 +500,7 @@ internal class InteractionUIView( if (!inInteractionBounds(point)) { null } else { - // Find if a scene contains a [InteropViewAnchorModifierNode] at the given point. + // Find if a scene contains an [InteropView] val interopView = hitTestInteropView(point, withEvent) if (interopView == null) { @@ -552,7 +552,7 @@ internal class InteractionUIView( // If the hit-tested view is not a descendant of [InteropWrappingView], then it // should be considered as a view that doesn't want to cooperate with Compose. - val areTouchesDelayed = result.interopViewGroup?.areTouchesDelayed ?: false + val areTouchesDelayed = result.findAncestorInteropWrappingView()?.areTouchesDelayed ?: false if (areTouchesDelayed) { InteractionUIViewHitTestResult.COOPERATIVE_CHILD_VIEW @@ -566,18 +566,17 @@ internal class InteractionUIView( } /** - * There is no way to associate [UIKitInteropViewGroup.areTouchesDelayed] with a given hitTest query. - * This extension property allows to find the nearest [UIKitInteropViewGroup] up the view hierarchy + * There is no way to associate [InteropWrappingView.areTouchesDelayed] with a given hitTest query. + * This extension property allows to find the nearest [InteropWrappingView] up the view hierarchy * and request the value retroactively. */ -private val UIView.interopViewGroup: UIKitInteropViewGroup? - get() { - var view: UIView? = this - while (view != null) { - if (view is UIKitInteropViewGroup) { - return view - } - view = view.superview +private fun UIView.findAncestorInteropWrappingView(): InteropWrappingView? { + var view: UIView? = this + while (view != null) { + if (view is InteropWrappingView) { + return view } - return null + view = view.superview } + return null +}