diff --git a/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/awt/SwingInteropContainer.desktop.kt b/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/awt/SwingInteropContainer.desktop.kt index c4041a2ae9456..cfa8af50e9ace 100644 --- a/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/awt/SwingInteropContainer.desktop.kt +++ b/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/awt/SwingInteropContainer.desktop.kt @@ -110,7 +110,7 @@ internal class SwingInteropContainer( container.repaint() } - override fun removeInteropView(nativeView: InteropComponent) { + fun removeInteropView(nativeView: InteropComponent) { val component = nativeView.container container.remove(component) interopComponents.remove(component) diff --git a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/node/InteropContainer.skiko.kt b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/node/InteropContainer.skiko.kt index dd54b1dbf121e..dc46bd7d816b2 100644 --- a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/node/InteropContainer.skiko.kt +++ b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/node/InteropContainer.skiko.kt @@ -31,7 +31,6 @@ internal interface InteropContainer { val interopViews: Set fun placeInteropView(nativeView: T) - fun removeInteropView(nativeView: T) } /** diff --git a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/interop/LocalUIKitInteropContext.kt b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/interop/LocalUIKitInteropContext.kt deleted file mode 100644 index 91ca720df8317..0000000000000 --- a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/interop/LocalUIKitInteropContext.kt +++ /dev/null @@ -1,146 +0,0 @@ -/* - * Copyright 2023 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.interop - -import androidx.compose.runtime.staticCompositionLocalOf -import platform.Foundation.NSLock - -internal enum class UIKitInteropState { - BEGAN, UNCHANGED, ENDED -} - -internal enum class UIKitInteropViewHierarchyChange { - VIEW_ADDED, - VIEW_REMOVED -} - -/** - * Lambda containing changes to UIKit objects, which can be synchronized within [CATransaction] - */ -internal typealias UIKitInteropAction = () -> Unit - -internal interface UIKitInteropTransaction { - val actions: List - val state: UIKitInteropState - - companion object { - val empty = object : UIKitInteropTransaction { - override val actions: List - get() = emptyList() - - override val state: UIKitInteropState - get() = UIKitInteropState.UNCHANGED - } - } -} - -internal fun UIKitInteropTransaction.isEmpty() = actions.isEmpty() && state == UIKitInteropState.UNCHANGED -internal fun UIKitInteropTransaction.isNotEmpty() = !isEmpty() - -private class UIKitInteropMutableTransaction: UIKitInteropTransaction { - override val actions = mutableListOf() - override var state = UIKitInteropState.UNCHANGED - set(value) { - field = when (value) { - UIKitInteropState.UNCHANGED -> error("Can't assign UNCHANGED value explicitly") - UIKitInteropState.BEGAN -> { - when (field) { - UIKitInteropState.BEGAN -> error("Can't assign BEGAN twice in the same transaction") - UIKitInteropState.UNCHANGED -> value - UIKitInteropState.ENDED -> UIKitInteropState.UNCHANGED - } - } - UIKitInteropState.ENDED -> { - when (field) { - UIKitInteropState.BEGAN -> UIKitInteropState.UNCHANGED - UIKitInteropState.UNCHANGED -> value - UIKitInteropState.ENDED -> error("Can't assign ENDED twice in the same transaction") - } - } - } - } -} - -/** - * Class which can be used to add actions related to UIKit objects to be executed in sync with compose rendering, - * Addding deferred actions is threadsafe, but they will be executed in the order of their submission, and on the main thread. - */ -internal class UIKitInteropContext( - val requestRedraw: () -> Unit -) { - private val lock: NSLock = NSLock() - private var transaction = UIKitInteropMutableTransaction() - - /** - * Number of views, created by interop API and present in current view hierarchy - */ - private var viewsCount = 0 - set(value) { - require(value >= 0) - - field = value - } - - /** - * Add lambda to a list of commands which will be executed later in the same CATransaction, when the next rendered Compose frame is presented - */ - fun deferAction(hierarchyChange: UIKitInteropViewHierarchyChange? = null, action: () -> Unit) { - requestRedraw() - - lock.doLocked { - if (hierarchyChange == UIKitInteropViewHierarchyChange.VIEW_ADDED) { - if (viewsCount == 0) { - transaction.state = UIKitInteropState.BEGAN - } - viewsCount += 1 - } - - transaction.actions.add(action) - - if (hierarchyChange == UIKitInteropViewHierarchyChange.VIEW_REMOVED) { - viewsCount -= 1 - if (viewsCount == 0) { - transaction.state = UIKitInteropState.ENDED - } - } - } - } - - /** - * Return an object containing pending changes and reset internal storage - */ - internal fun retrieve(): UIKitInteropTransaction = - lock.doLocked { - val result = transaction - transaction = UIKitInteropMutableTransaction() - result - } -} - -internal inline fun NSLock.doLocked(block: () -> T): T { - lock() - - try { - return block() - } finally { - unlock() - } -} - -internal val LocalUIKitInteropContext = staticCompositionLocalOf { - error("CompositionLocal UIKitInteropContext not provided") -} diff --git a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/interop/UIKitInteropContainer.uikit.kt b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/interop/UIKitInteropContainer.uikit.kt index 985d818fabc86..f56078527fd4d 100644 --- a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/interop/UIKitInteropContainer.uikit.kt +++ b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/interop/UIKitInteropContainer.uikit.kt @@ -27,6 +27,7 @@ import kotlinx.cinterop.CValue import kotlinx.cinterop.readValue import platform.CoreGraphics.CGPoint import platform.CoreGraphics.CGRectZero +import platform.QuartzCore.CATransaction import platform.UIKit.UIEvent import platform.UIKit.UIView @@ -38,35 +39,152 @@ internal val LocalUIKitInteropContainer = staticCompositionLocalOf Unit + +/** + * A transaction containing changes to UIKit objects to be synchronized within [CATransaction] inside a + * renderer to make sure that changes in UIKit and Compose are visually simultaneous. + * [actions] contains a list of lambdas that will be executed in the same CATransaction. + * [state] defines if rendering strategy should be changed along with this transaction. + */ +internal interface UIKitInteropTransaction { + val actions: List + val state: UIKitInteropState +} + +internal fun UIKitInteropTransaction.isEmpty() = + actions.isEmpty() && state == UIKitInteropState.UNCHANGED + +internal fun UIKitInteropTransaction.isNotEmpty() = !isEmpty() + +/** + * A mutable transaction managed by [UIKitInteropContainer] to collect changes to UIKit objects to be executed later. + * @see UIKitView + * @see UIKitViewController + * @see UIKitInteropContainer.deferAction + */ +private class UIKitInteropMutableTransaction : UIKitInteropTransaction { + private val _actions = mutableListOf() + + override val actions + get() = _actions + + override var state = UIKitInteropState.UNCHANGED + set(value) { + field = when (value) { + UIKitInteropState.UNCHANGED -> error("Can't assign UNCHANGED value explicitly") + UIKitInteropState.BEGAN -> { + when (field) { + UIKitInteropState.BEGAN -> error("Can't assign BEGAN twice in the same transaction") + UIKitInteropState.UNCHANGED -> value + UIKitInteropState.ENDED -> UIKitInteropState.UNCHANGED + } + } + UIKitInteropState.ENDED -> { + when (field) { + UIKitInteropState.BEGAN -> UIKitInteropState.UNCHANGED + UIKitInteropState.UNCHANGED -> value + UIKitInteropState.ENDED -> error("Can't assign ENDED twice in the same transaction") + } + } + } + } + + fun add(action: UIKitInteropAction) { + _actions.add(action) + } +} + +/** + * A container that controls interop views/components. It's using a modifier of [TrackInteropModifierNode] + * to properly sort native interop elements and contains a logic for syncing changes to UIKit objects + * driven by Compose state changes with Compose rendering. */ internal class UIKitInteropContainer( - private val interopContext: UIKitInteropContext -): InteropContainer { + val requestRedraw: () -> Unit +) : InteropContainer { val containerView: UIView = UIKitInteropContainerView() override var rootModifier: TrackInteropModifierNode? = null override var interopViews = mutableSetOf() private set - override fun placeInteropView(nativeView: UIView) = interopContext.deferAction { + private var transaction = UIKitInteropMutableTransaction() + + /** + * 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. + */ + fun dispose() { + val lastTransaction = retrieveTransaction() + + for (action in lastTransaction.actions) { + action.invoke() + } + } + + /** + * Add lambda to a list of commands which will be executed later in the same CATransaction, when the next rendered Compose frame is presented + */ + fun deferAction(action: () -> Unit) { + requestRedraw() + + transaction.add(action) + } + + /** + * Return an object containing pending changes and reset internal storage + */ + fun retrieveTransaction(): UIKitInteropTransaction { + val result = transaction + transaction = UIKitInteropMutableTransaction() + return result + } + + /** + * Counts the number of interop components before the given native view in the container. + * And updates the index of the native view in the container for proper Z-ordering. + * @see TrackInteropModifierNode.onPlaced + */ + override fun placeInteropView(nativeView: UIView) { val index = countInteropComponentsBelow(nativeView) - if (nativeView in interopViews) { - // Place might be called multiple times - nativeView.removeFromSuperview() - } else { - interopViews.add(nativeView) + + deferAction { + containerView.insertSubview(nativeView, index.toLong()) + } + } + + fun startTrackingInteropView(nativeView: UIView) { + if (interopViews.isEmpty()) { + transaction.state = UIKitInteropState.BEGAN } - containerView.insertSubview(nativeView, index.toLong()) + + interopViews.add(nativeView) } - override fun removeInteropView(nativeView: UIView) { - nativeView.removeFromSuperview() + fun stopTrackingInteropView(nativeView: UIView) { interopViews.remove(nativeView) + + if (interopViews.isEmpty()) { + transaction.state = UIKitInteropState.ENDED + } } } -private class UIKitInteropContainerView: UIView(CGRectZero.readValue()) { +private class UIKitInteropContainerView : UIView(CGRectZero.readValue()) { /** * We used a simple solution to make only this view not touchable. * Another view added to this container will be touchable. @@ -78,7 +196,8 @@ private class UIKitInteropContainerView: UIView(CGRectZero.readValue()) { } /** - * Modifier to track interop view inside [LayoutNode] hierarchy. + * Modifier to track interop view inside [LayoutNode] hierarchy. Used to properly + * sort interop views in the tree. * * @param view The [UIView] that matches the current node. */ @@ -89,3 +208,4 @@ internal fun Modifier.trackUIKitInterop( container = container, nativeView = view ) + diff --git a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/interop/UIKitView.uikit.kt b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/interop/UIKitView.uikit.kt index a178a8d30233c..05e1200b14ba4 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 @@ -20,30 +20,25 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.SideEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshots.SnapshotStateObserver import androidx.compose.ui.Modifier -import androidx.compose.ui.* +import androidx.compose.runtime.State import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.graphics.BlendMode import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.InteropViewCatchPointerModifier import androidx.compose.ui.layout.EmptyLayout +import androidx.compose.ui.layout.findRootCoordinates import androidx.compose.ui.layout.onGloballyPositioned -import androidx.compose.ui.layout.positionInRoot import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.semantics.AccessibilityKey import androidx.compose.ui.semantics.SemanticsPropertyReceiver import androidx.compose.ui.semantics.semantics import androidx.compose.ui.uikit.toUIColor -import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntRect import androidx.compose.ui.unit.asCGRect import androidx.compose.ui.unit.height -import androidx.compose.ui.unit.round import androidx.compose.ui.unit.toDpRect import androidx.compose.ui.unit.toRect import androidx.compose.ui.unit.width @@ -59,14 +54,17 @@ import platform.UIKit.didMoveToParentViewController import platform.UIKit.removeFromParentViewController import platform.UIKit.willMoveToParentViewController import androidx.compose.ui.uikit.utils.CMPInteropWrappingView +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.roundToIntRect import kotlinx.cinterop.readValue import platform.CoreGraphics.CGRectZero 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) } +private val DefaultViewControllerResize: UIViewController.(CValue) -> Unit = + { rect -> this.view.setFrame(rect) } -internal class InteropWrappingView: CMPInteropWrappingView(frame = CGRectZero.readValue()) { +internal class InteropWrappingView : CMPInteropWrappingView(frame = CGRectZero.readValue()) { var actualAccessibilityContainer: Any? = null override fun accessibilityContainer(): Any? { @@ -80,10 +78,12 @@ internal val InteropViewSemanticsKey = AccessibilityKey( if (parentValue == null) { childValue } else { - println("Warning: Merging accessibility for multiple interop views is not supported. " + - "Multiple [UIKitView] are grouped under one node that should be represented as a single accessibility element." + - "It isn't recommended because the accessibility system can only recognize the first one. " + - "If you need multiple native views for accessibility, make sure to place them inside a single [UIKitView].") + println( + "Warning: Merging accessibility for multiple interop views is not supported. " + + "Multiple [UIKitView] are grouped under one node that should be represented as a single accessibility element." + + "It isn't recommended because the accessibility system can only recognize the first one. " + + "If you need multiple native views for accessibility, make sure to place them inside a single [UIKitView]." + ) parentValue } @@ -96,7 +96,10 @@ private var SemanticsPropertyReceiver.interopView by InteropViewSemanticsKey * Chain [this] with [Modifier.semantics] that sets the [interopView] of the node if [enabled] is true. * If [enabled] is false, [this] is returned as is. */ -private fun Modifier.interopSemantics(enabled: Boolean, wrappingView: InteropWrappingView): Modifier = +private fun Modifier.interopSemantics( + enabled: Boolean, + wrappingView: InteropWrappingView +): Modifier = if (enabled) { this.semantics { interopView = wrappingView @@ -105,6 +108,76 @@ private fun Modifier.interopSemantics(enabled: Boolean, wrappingView: InteropWra this } +private fun Modifier.catchInteropPointer(isInteractive: Boolean): Modifier = + if (isInteractive) { + this then InteropViewCatchPointerModifier() + } else { + this + } + +/** + * 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, + componentHandler: InteropComponentHandler, + interactive: Boolean, + accessibilityEnabled: Boolean, +) { + val density = LocalDensity.current + val interopContainer = LocalUIKitInteropContainer.current + + val finalModifier = modifier + .onGloballyPositioned { coordinates -> + val rootCoordinates = coordinates.findRootCoordinates() + + // TODO: perform proper clipping of underlying view with `clipBounds` set to true + val bounds = rootCoordinates + .localBoundingBoxOf( + sourceCoordinates = coordinates, + clipBounds = false + ) + + componentHandler.updateRect( + to = bounds.roundToIntRect(), + density = density + ) + } + .drawBehind { + // Paint the rectangle behind with transparent color to let our interop shine through + drawRect( + color = Color.Transparent, + blendMode = BlendMode.Clear + ) + } + .trackUIKitInterop(interopContainer, componentHandler.wrappingView) + .catchInteropPointer(interactive) + .interopSemantics(accessibilityEnabled, componentHandler.wrappingView) + + EmptyLayout( + finalModifier + ) + + DisposableEffect(Unit) { + componentHandler.onStart(initialUpdateBlock = update) + + onDispose { + componentHandler.onStop() + } + } + + LaunchedEffect(background) { + componentHandler.onBackgroundColorChange(background) + } + + SideEffect { + componentHandler.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. @@ -144,79 +217,24 @@ fun UIKitView( interactive: Boolean = true, accessibilityEnabled: Boolean = true, ) { - // TODO: adapt UIKitView to reuse 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 interopContainer = LocalUIKitInteropContainer.current - val embeddedInteropComponent = remember { - EmbeddedInteropView( + val handler = remember { + InteropViewHandler( + createView = factory, interopContainer = interopContainer, - onRelease + onResize = onResize, + onRelease = onRelease ) } - val density = LocalDensity.current - var rectInPixels by remember { mutableStateOf(IntRect(0, 0, 0, 0)) } - var localToWindowOffset: IntOffset by remember { mutableStateOf(IntOffset.Zero) } - val interopContext = LocalUIKitInteropContext.current - EmptyLayout( - modifier.onGloballyPositioned { coordinates -> - localToWindowOffset = coordinates.positionInRoot().round() - val newRectInPixels = IntRect(localToWindowOffset, coordinates.size) - if (rectInPixels != newRectInPixels) { - val rect = newRectInPixels.toRect().toDpRect(density) - - interopContext.deferAction { - embeddedInteropComponent.wrappingView.setFrame(rect.asCGRect()) - } - - if (rectInPixels.width != newRectInPixels.width || rectInPixels.height != newRectInPixels.height) { - interopContext.deferAction { - onResize( - embeddedInteropComponent.component, - CGRectMake(0.0, 0.0, rect.width.value.toDouble(), rect.height.value.toDouble()), - ) - } - } - rectInPixels = newRectInPixels - } - }.drawBehind { - // Clear interop area to make visible the component under our canvas. - drawRect(Color.Transparent, blendMode = BlendMode.Clear) - }.trackUIKitInterop(interopContainer, embeddedInteropComponent.wrappingView).let { - if (interactive) { - it.then(InteropViewCatchPointerModifier()) - } else { - it - } - }.interopSemantics(accessibilityEnabled, embeddedInteropComponent.wrappingView) + UIKitInteropLayout( + modifier = modifier, + update = update, + background = background, + componentHandler = handler, + interactive = interactive, + accessibilityEnabled = accessibilityEnabled ) - - DisposableEffect(Unit) { - embeddedInteropComponent.component = factory() - embeddedInteropComponent.updater = Updater(embeddedInteropComponent.component, update) { - interopContext.deferAction(action = it) - } - - interopContext.deferAction(UIKitInteropViewHierarchyChange.VIEW_ADDED) { - embeddedInteropComponent.addToHierarchy() - } - - onDispose { - interopContext.deferAction(UIKitInteropViewHierarchyChange.VIEW_REMOVED) { - embeddedInteropComponent.removeFromHierarchy() - } - } - } - - LaunchedEffect(background) { - interopContext.deferAction { - embeddedInteropComponent.setBackgroundColor(background) - } - } - - SideEffect { - embeddedInteropComponent.updater.update = update - } } /** @@ -260,93 +278,113 @@ fun UIKitViewController( interactive: Boolean = true, accessibilityEnabled: Boolean = true, ) { - // TODO: adapt UIKitViewController to reuse 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 interopContainer = LocalUIKitInteropContainer.current val rootViewController = LocalUIViewController.current - val embeddedInteropComponent = remember { - EmbeddedInteropViewController( + val handler = remember { + InteropViewControllerHandler( + createViewController = factory, interopContainer = interopContainer, rootViewController = rootViewController, + onResize = onResize, onRelease = onRelease ) } - val density = LocalDensity.current - var rectInPixels by remember { mutableStateOf(IntRect(0, 0, 0, 0)) } - var localToWindowOffset: IntOffset by remember { mutableStateOf(IntOffset.Zero) } - val interopContext = LocalUIKitInteropContext.current + UIKitInteropLayout( + modifier = modifier, + update = update, + background = background, + componentHandler = handler, + interactive = interactive, + accessibilityEnabled = accessibilityEnabled + ) +} - EmptyLayout( - modifier.onGloballyPositioned { coordinates -> - localToWindowOffset = coordinates.positionInRoot().round() - val newRectInPixels = IntRect(localToWindowOffset, coordinates.size) - if (rectInPixels != newRectInPixels) { - val rect = newRectInPixels.toRect().toDpRect(density) - - interopContext.deferAction { - embeddedInteropComponent.wrappingView.setFrame(rect.asCGRect()) - } +/** + * An abstract class responsible for hiearchy updates and state management of interop components like [UIView] and [UIViewController] + */ +private abstract class InteropComponentHandler( + // 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 createComponent: () -> T, + val interopContainer: UIKitInteropContainer, + val onResize: (T, rect: CValue) -> Unit, + val onRelease: (T) -> Unit, +) { + /** + * The coordinates + */ + private var currentRect: IntRect? = null + val wrappingView = InteropWrappingView() + lateinit var component: T + private lateinit var updater: Updater - if (rectInPixels.width != newRectInPixels.width || rectInPixels.height != newRectInPixels.height) { - interopContext.deferAction { - onResize( - embeddedInteropComponent.component, - CGRectMake(0.0, 0.0, rect.width.value.toDouble(), rect.height.value.toDouble()), - ) - } - } - rectInPixels = newRectInPixels - } - }.drawBehind { - // Clear interop area to make visible the component under our canvas. - drawRect(Color.Transparent, blendMode = BlendMode.Clear) - }.trackUIKitInterop(interopContainer, embeddedInteropComponent.wrappingView).let { - if (interactive) { - it.then(InteropViewCatchPointerModifier()) - } else { - it - } - }.interopSemantics(accessibilityEnabled, embeddedInteropComponent.wrappingView) - ) + /** + * Set the [Updater.update] lambda. + * Lambda is immediately executed after setting. + * @see Updater.performUpdate + */ + fun setUpdate(block: (T) -> Unit) { + updater.update = block + } - DisposableEffect(Unit) { - embeddedInteropComponent.component = factory() - embeddedInteropComponent.updater = Updater(embeddedInteropComponent.component, update) { - interopContext.deferAction(action = it) + /** + * Set the frame of the wrapping view. + */ + fun updateRect(to: IntRect, density: Density) { + if (currentRect == to) { + return } - interopContext.deferAction(UIKitInteropViewHierarchyChange.VIEW_ADDED) { - embeddedInteropComponent.addToHierarchy() + val dpRect = to.toRect().toDpRect(density) + + interopContainer.deferAction { + wrappingView.setFrame(dpRect.asCGRect()) } - onDispose { - interopContext.deferAction(UIKitInteropViewHierarchyChange.VIEW_REMOVED) { - embeddedInteropComponent.removeFromHierarchy() + + // Only call onResize if the actual size changes. + if (currentRect?.size != to.size) { + interopContainer.deferAction { + // The actual component created by the user is resized here using the provided callback. + onResize( + component, + CGRectMake( + x = 0.0, + y = 0.0, + width = dpRect.width.value.toDouble(), + height = dpRect.height.value.toDouble() + ), + ) } } + + currentRect = to } - LaunchedEffect(background) { - interopContext.deferAction { - embeddedInteropComponent.setBackgroundColor(background) + fun onStart(initialUpdateBlock: (T) -> Unit) { + component = createComponent() + updater = Updater(component, initialUpdateBlock) { + interopContainer.deferAction(action = it) } - } - SideEffect { - embeddedInteropComponent.updater.update = update + interopContainer.startTrackingInteropView(wrappingView) + interopContainer.deferAction { + setupViewHierarchy() + } } -} -private abstract class EmbeddedInteropComponent( - val interopContainer: UIKitInteropContainer, - val onRelease: (T) -> Unit -) { - val wrappingView = InteropWrappingView() - lateinit var component: T - lateinit var updater: Updater + fun onStop() { + interopContainer.stopTrackingInteropView(wrappingView) + interopContainer.deferAction { + destroyViewHierarchy() + } - fun setBackgroundColor(color: Color) { + onRelease(component) + updater.dispose() + } + + fun onBackgroundColorChange(color: Color) = interopContainer.deferAction { if (color == Color.Unspecified) { wrappingView.backgroundColor = interopContainer.containerView.backgroundColor } else { @@ -354,53 +392,58 @@ private abstract class EmbeddedInteropComponent( } } - abstract fun addToHierarchy() - abstract fun removeFromHierarchy() - - protected fun addViewToHierarchy(view: UIView) { - wrappingView.addSubview(view) - // wrappingView will be added to the container from [onPlaced] - } - - protected fun removeViewFromHierarchy(view: UIView) { - view.removeFromSuperview() - interopContainer.removeInteropView(wrappingView) - updater.dispose() - onRelease(component) - } + abstract fun setupViewHierarchy() + abstract fun destroyViewHierarchy() } -private class EmbeddedInteropView( +private class InteropViewHandler( + createView: () -> T, interopContainer: UIKitInteropContainer, + onResize: (T, rect: CValue) -> Unit, onRelease: (T) -> Unit -) : EmbeddedInteropComponent(interopContainer, onRelease) { - override fun addToHierarchy() { - addViewToHierarchy(component) +) : InteropComponentHandler(createView, interopContainer, onResize, onRelease) { + override fun setupViewHierarchy() { + interopContainer.containerView.addSubview(wrappingView) + wrappingView.addSubview(component) } - override fun removeFromHierarchy() { - removeViewFromHierarchy(component) + override fun destroyViewHierarchy() { + wrappingView.removeFromSuperview() } } -private class EmbeddedInteropViewController( +private class InteropViewControllerHandler( + createViewController: () -> T, interopContainer: UIKitInteropContainer, private val rootViewController: UIViewController, + onResize: (T, rect: CValue) -> Unit, onRelease: (T) -> Unit -) : EmbeddedInteropComponent(interopContainer, onRelease) { - override fun addToHierarchy() { +) : InteropComponentHandler(createViewController, interopContainer, onResize, onRelease) { + override fun setupViewHierarchy() { rootViewController.addChildViewController(component) - addViewToHierarchy(component.view) + interopContainer.containerView.addSubview(wrappingView) + wrappingView.addSubview(component.view) component.didMoveToParentViewController(rootViewController) } - override fun removeFromHierarchy() { + override fun destroyViewHierarchy() { component.willMoveToParentViewController(null) - removeViewFromHierarchy(component.view) + wrappingView.removeFromSuperview() component.removeFromParentViewController() } } +/** + * 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 deferAction 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. + * @see [UIKitInteropContainer] and [UIKitInteropTransaction] for more details. + */ private class Updater( private val component: T, update: (T) -> Unit, 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 3eb0db582c516..52d1596f4c907 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 @@ -33,9 +33,7 @@ import androidx.compose.ui.input.pointer.PointerEventType import androidx.compose.ui.input.pointer.PointerId import androidx.compose.ui.input.pointer.PointerType import androidx.compose.ui.interop.LocalUIKitInteropContainer -import androidx.compose.ui.interop.LocalUIKitInteropContext import androidx.compose.ui.interop.UIKitInteropContainer -import androidx.compose.ui.interop.UIKitInteropContext import androidx.compose.ui.node.TrackInteropContainer import androidx.compose.ui.platform.AccessibilityMediator import androidx.compose.ui.platform.AccessibilitySyncOptions @@ -206,7 +204,7 @@ internal class ComposeSceneMediator( */ private val measureDrawLayerBounds: Boolean = false, val coroutineContext: CoroutineContext, - private val renderingUIViewFactory: (UIKitInteropContext, SkikoRenderDelegate) -> RenderingUIView, + private val renderingUIViewFactory: (UIKitInteropContainer, SkikoRenderDelegate) -> RenderingUIView, composeSceneFactory: ( invalidate: () -> Unit, platformContext: PlatformContext, @@ -249,7 +247,7 @@ internal class ComposeSceneMediator( val focusManager get() = scene.focusManager private val renderingView by lazy { - renderingUIViewFactory(interopContext, renderDelegate) + renderingUIViewFactory(interopContainer, renderDelegate) } private val applicationForegroundStateListener = ApplicationForegroundStateListener { isForeground -> @@ -262,19 +260,16 @@ internal class ComposeSceneMediator( } /** - * view, that contains [interopViewContainer] and [interactionView] and is added to [container] + * view, that contains [interopContainer] and [interactionView] and is added to [container] */ private val rootView = ComposeSceneMediatorRootUIView() - - private val interopContext = UIKitInteropContext( - requestRedraw = ::onComposeSceneInvalidate - ) - /** - * Container for UIKitView and UIKitViewController + * Container for managing UIKitView and UIKitViewController */ - private val interopViewContainer = UIKitInteropContainer(interopContext) + private val interopContainer = UIKitInteropContainer( + requestRedraw = ::onComposeSceneInvalidate + ) private val interactionBounds: IntRect get() { val boundsLayout = _layout as? SceneLayout.Bounds @@ -431,10 +426,10 @@ internal class ComposeSceneMediator( getConstraintsToFillParent(rootView, container) ) - interopViewContainer.containerView.translatesAutoresizingMaskIntoConstraints = false - rootView.addSubview(interopViewContainer.containerView) + interopContainer.containerView.translatesAutoresizingMaskIntoConstraints = false + rootView.addSubview(interopContainer.containerView) NSLayoutConstraint.activateConstraints( - getConstraintsToFillParent(interopViewContainer.containerView, rootView) + getConstraintsToFillParent(interopContainer.containerView, rootView) ) interactionView.translatesAutoresizingMaskIntoConstraints = false @@ -460,7 +455,7 @@ internal class ComposeSceneMediator( */ if (renderingView.isReadyToShowContent.value) { ProvideComposeSceneMediatorCompositionLocals { - interopViewContainer.TrackInteropContainer( + interopContainer.TrackInteropContainer( content = content ) } @@ -486,8 +481,7 @@ internal class ComposeSceneMediator( @Composable private fun ProvideComposeSceneMediatorCompositionLocals(content: @Composable () -> Unit) = CompositionLocalProvider( - LocalUIKitInteropContext provides interopContext, - LocalUIKitInteropContainer provides interopViewContainer, + LocalUIKitInteropContainer provides interopContainer, LocalKeyboardOverlapHeight provides keyboardOverlapHeightState.value, LocalSafeArea provides safeAreaState.value, LocalLayoutMargins provides layoutMarginsState.value, @@ -505,9 +499,7 @@ internal class ComposeSceneMediator( interactionView.removeFromSuperview() renderingView.removeFromSuperview() scene.close() - // After scene is disposed all UIKit interop actions can't be deferred to be synchronized with rendering - // Thus they need to be executed now. - interopContext.retrieve().actions.forEach { it.invoke() } + interopContainer.dispose() } private fun onComposeSceneInvalidate() = renderingView.needRedraw() diff --git a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/scene/UIViewComposeSceneLayer.uikit.kt b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/scene/UIViewComposeSceneLayer.uikit.kt index d1f9b8aba2e0c..0e280be76aad0 100644 --- a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/scene/UIViewComposeSceneLayer.uikit.kt +++ b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/scene/UIViewComposeSceneLayer.uikit.kt @@ -23,7 +23,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.key.KeyEvent import androidx.compose.ui.input.pointer.PointerButton import androidx.compose.ui.input.pointer.PointerEventType -import androidx.compose.ui.interop.UIKitInteropContext +import androidx.compose.ui.interop.UIKitInteropContainer import androidx.compose.ui.platform.PlatformContext import androidx.compose.ui.platform.PlatformWindowContext import androidx.compose.ui.skiko.RecordDrawRectRenderDecorator @@ -149,10 +149,12 @@ internal class UIViewComposeSceneLayer( composeContainer.attachLayer(this) } - private fun createSkikoUIView(interopContext: UIKitInteropContext, renderDelegate: SkikoRenderDelegate): RenderingUIView = + private fun createSkikoUIView(interopContainer: UIKitInteropContainer, renderDelegate: SkikoRenderDelegate): RenderingUIView = RenderingUIView( - interopContext = interopContext, - renderDelegate = recordDrawBounds(renderDelegate) + renderDelegate = recordDrawBounds(renderDelegate), + retrieveInteropTransaction = { + interopContainer.retrieveTransaction() + } ).apply { opaque = false } diff --git a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/ComposeContainer.uikit.kt b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/ComposeContainer.uikit.kt index 768f3c6e704d2..c5baaa54b83e3 100644 --- a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/ComposeContainer.uikit.kt +++ b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/ComposeContainer.uikit.kt @@ -26,7 +26,7 @@ import androidx.compose.ui.LocalSystemTheme import androidx.compose.ui.SystemTheme import androidx.compose.ui.hapticfeedback.CupertinoHapticFeedback import androidx.compose.ui.interop.LocalUIViewController -import androidx.compose.ui.interop.UIKitInteropContext +import androidx.compose.ui.interop.UIKitInteropContainer import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.platform.LocalInternalViewModelStoreOwner @@ -315,10 +315,15 @@ internal class ComposeContainer( @OptIn(ExperimentalComposeApi::class) private fun createSkikoUIView( - interopContext: UIKitInteropContext, + interopContainer: UIKitInteropContainer, renderRelegate: SkikoRenderDelegate ): RenderingUIView = - RenderingUIView(interopContext, renderRelegate).apply { + RenderingUIView( + renderDelegate = renderRelegate, + retrieveInteropTransaction = { + interopContainer.retrieveTransaction() + } + ).apply { opaque = configuration.opaque } diff --git a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/MetalRedrawer.uikit.kt b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/MetalRedrawer.uikit.kt index 94e06b695e9c6..638b0984f2e4f 100644 --- a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/MetalRedrawer.uikit.kt +++ b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/MetalRedrawer.uikit.kt @@ -18,7 +18,6 @@ package androidx.compose.ui.window import androidx.compose.ui.interop.UIKitInteropState import androidx.compose.ui.interop.UIKitInteropTransaction -import androidx.compose.ui.interop.doLocked import androidx.compose.ui.interop.isNotEmpty import androidx.compose.ui.uikit.utils.CMPMetalDrawablesHandler import androidx.compose.ui.util.fastForEach @@ -115,7 +114,7 @@ internal interface MetalRedrawerCallbacks { fun render(canvas: Canvas, targetTimestamp: NSTimeInterval) /** - * Retrieve a transaction object, containing a list of pending actions + * Create an interop transaction by flushing the list of all pending actions * that need to be synchronized with Metal rendering using CATransaction mechanism. */ fun retrieveInteropTransaction(): UIKitInteropTransaction @@ -492,3 +491,13 @@ private class DisplayLinkProxy( callback() } } + +private inline fun NSLock.doLocked(block: () -> T): T { + lock() + + try { + return block() + } finally { + unlock() + } +} diff --git a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/RenderingUIView.uikit.kt b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/RenderingUIView.uikit.kt index 6d336de983ba3..58582367aced2 100644 --- a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/RenderingUIView.uikit.kt +++ b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/window/RenderingUIView.uikit.kt @@ -19,7 +19,6 @@ package androidx.compose.ui.window import androidx.compose.runtime.MutableState import androidx.compose.runtime.State import androidx.compose.runtime.mutableStateOf -import androidx.compose.ui.interop.UIKitInteropContext import androidx.compose.ui.interop.UIKitInteropTransaction import kotlin.math.floor import kotlin.math.roundToLong @@ -35,8 +34,8 @@ import platform.QuartzCore.CAMetalLayer import platform.UIKit.* internal class RenderingUIView( - private val interopContext: UIKitInteropContext, private val renderDelegate: SkikoRenderDelegate, + private val retrieveInteropTransaction: () -> UIKitInteropTransaction, ) : UIView( frame = CGRectMake( x = 0.0, @@ -67,7 +66,7 @@ internal class RenderingUIView( } override fun retrieveInteropTransaction(): UIKitInteropTransaction = - interopContext.retrieve() + this@RenderingUIView.retrieveInteropTransaction() } )