From a534d87493674ad9b85c06b246ec3e08ffa26075 Mon Sep 17 00:00:00 2001 From: svastven Date: Sun, 19 Apr 2026 23:15:30 +0200 Subject: [PATCH 01/30] skiko: add constrained sizing + persistent layout-complete listener --- .../compose/ui/node/RootNodeOwner.skiko.kt | 136 +++++++++++++++++- .../scene/CanvasLayersComposeScene.skiko.kt | 5 + .../compose/ui/scene/ComposeScene.skiko.kt | 13 +- .../scene/PlatformLayersComposeScene.skiko.kt | 5 + 4 files changed, 151 insertions(+), 8 deletions(-) diff --git a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/node/RootNodeOwner.skiko.kt b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/node/RootNodeOwner.skiko.kt index 031973900f69e..c2618d93de382 100644 --- a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/node/RootNodeOwner.skiko.kt +++ b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/node/RootNodeOwner.skiko.kt @@ -161,6 +161,7 @@ internal class RootNodeOwner( private val ownedLayerManager = OwnedLayerManagerImpl() private val pointerInputEventProcessor = PointerInputEventProcessor(owner.root) private val measureAndLayoutDelegate = MeasureAndLayoutDelegate(owner.root) + private var layoutCompletedManager: LayoutCompletedManager? = null private var isDisposed = false private var positionInWindow: Offset? = null @@ -212,20 +213,59 @@ internal class RootNodeOwner( } /** - * Provides a way to measure Owner's content in given [constraints] - * Draw/pointer and other callbacks won't be called here like in [measureAndLayout] functions + * Runs a temporary *measure-only* pass for the root under the provided [constraints], then + * executes [block] while those constraints are in effect. + * + * This is a probe measurement: it does not place nodes and does not dispatch draw/pointer + * callbacks. */ private fun measuringRootWithConstraints( constraints: Constraints, block: (LayoutNode) -> T ): T { - return try { + try { // TODO: is it possible to measure without reassigning root constraints? measureAndLayoutDelegate.updateRootConstraints(constraints) measureAndLayoutDelegate.measureOnly() - block(owner.root) + + return block(owner.root) } finally { - measureAndLayoutDelegate.updateRootConstraints(size.toMaxConstraints()) + val hadPendingBeforeRestore = measureAndLayoutDelegate.hasPendingMeasureOrLayout + val restoreConstraints = size.toMaxConstraints() + val constraintsChanged = restoreConstraints != constraints + + measureAndLayoutDelegate.updateRootConstraints(restoreConstraints) + + val hasPendingAfterRestore = measureAndLayoutDelegate.hasPendingMeasureOrLayout + if (constraintsChanged && !hadPendingBeforeRestore && hasPendingAfterRestore) { + // Probe measurement temporarily swaps root constraints. Restoring them may enqueue + // a rebound layout pass; suppress its layout-complete dispatch to avoid re-entrancy + // for observers that initiated this probe measurement. + layoutCompletedManager?.suppressNextDispatch() + } + } + } + + /** + * Registers an additional persistent layout-complete listener. + * + * Unlike [MeasureAndLayoutDelegate.registerOnLayoutCompletedListener], this listener is not + * one-shot: it remains active for later layout passes until the returned handle is + * closed. + */ + fun registerOnLayoutCompletedListener(listener: () -> Unit): AutoCloseable { + if (layoutCompletedManager == null) { + layoutCompletedManager = LayoutCompletedManager() + } + layoutCompletedManager?.registerListener(listener) + + return object : AutoCloseable { + override fun close() { + layoutCompletedManager?.deregisterListener(listener) + if (layoutCompletedManager?.isEmpty == true) { + layoutCompletedManager = null + } + } } } @@ -560,6 +600,7 @@ internal class RootNodeOwner( } measureAndLayoutDelegate.dispatchOnPositionedCallbacks() rectManager.dispatchCallbacks() + layoutCompletedManager?.dispatch() } } } @@ -575,6 +616,7 @@ internal class RootNodeOwner( measureAndLayoutDelegate.dispatchOnPositionedCallbacks() } rectManager.dispatchCallbacks() + layoutCompletedManager?.dispatch() } } @@ -992,6 +1034,50 @@ internal class RootNodeOwner( private fun IntSize?.toMaxConstraints() = if (this == null) Constraints() else Constraints(maxWidth = width, maxHeight = height) +// TODO a proper way is to provide API in Constraints to get this value +/** + * Equals [Constraints.MinNonFocusMask] + */ +private const val ConstraintsMinNonFocusMask = 0x7FFF // 32767 + +/** + * The max value that can be passed as Constraints(0, LargeDimension, 0, LargeDimension) + * + * Greater values cause "Can't represent a width of". + * See [Constraints.createConstraints] and [Constraints.bitsNeedForSize]: + * - it fails if `widthBits + heightBits > 31` + * - widthBits/heightBits are greater than 15 if we pass size >= [Constraints.MinNonFocusMask] + */ +internal const val LargeDimension = ConstraintsMinNonFocusMask - 1 + +/** + * After https://android-review.googlesource.com/c/platform/frameworks/support/+/2901556 + * Compose core doesn't allow measuring in infinity constraints, + * but RootNodeOwner and ComposeScene allow passing Infinity constraints by contract + * (Android on the other hand doesn't have public API for that and don't have such an issue). + * + * This method adds additional check on Infinity constraints, + * and pass constraint large enough instead + */ +private fun MeasureAndLayoutDelegate.updateRootConstraintsWithInfinityCheck( + constraints: Constraints? +) { + updateRootConstraints(constraints = constraints.withInfinityCheck()) +} + +private fun Constraints?.withInfinityCheck(): Constraints = + if (this == null) + Constraints(0, LargeDimension, 0, LargeDimension) + else + Constraints( + minWidth = minWidth, + maxWidth = if (hasBoundedWidth) maxWidth else LargeDimension, + minHeight = minHeight, + maxHeight = if (hasBoundedHeight) maxHeight else LargeDimension + ) + +private fun IntSize.toConstraints() = Constraints(maxWidth = width, maxHeight = height) + private object IdentityPositionCalculator : PositionCalculator { override fun screenToLocal(positionOnScreen: Offset): Offset = positionOnScreen override fun localToScreen(localPosition: Offset): Offset = localPosition @@ -1022,3 +1108,43 @@ private class RootPlatformWindowInsetsProviderNode( } } } + +private class LayoutCompletedManager { + private var suppressed: Int = 0 + private var listeners = mutableVectorOf<(() -> Unit)?>() + private var activeListenersCount: Int = 0 + val isEmpty: Boolean get() = activeListenersCount == 0 + + fun suppressNextDispatch() { + suppressed++ + } + + fun registerListener(listener: () -> Unit) { + // Use referential equality: multiple distinct function instances may be "equal" but + // must still be treated as separate registrations. + for (i in 0 until listeners.size) { + if (listeners[i] === listener) return + } + + listeners += listener + activeListenersCount++ + } + + fun deregisterListener(listener: () -> Unit) { + for (i in 0 until listeners.size) { + if (listeners[i] === listener) { + listeners[i] = null + activeListenersCount-- + break + } + } + } + + fun dispatch() { + if (suppressed > 0) { + suppressed-- + return + } + listeners.forEach { it?.invoke() } + } +} diff --git a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/scene/CanvasLayersComposeScene.skiko.kt b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/scene/CanvasLayersComposeScene.skiko.kt index aa4abceae4817..e21dc0f6e7c31 100644 --- a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/scene/CanvasLayersComposeScene.skiko.kt +++ b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/scene/CanvasLayersComposeScene.skiko.kt @@ -216,6 +216,11 @@ private class CanvasLayersComposeSceneImpl( return IntSize(width, height) } + override fun registerOnLayoutCompletedListener(listener: () -> Unit): AutoCloseable { + check(!isClosed) { "registerOnLayoutCompletedListener called after ComposeScene is closed" } + return mainOwner.registerOnLayoutCompletedListener(listener) + } + override fun invalidatePositionInWindow() { check(!isClosed) { "invalidatePositionInWindow called after ComposeScene is closed" } mainOwner.invalidatePositionInWindow() diff --git a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/scene/ComposeScene.skiko.kt b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/scene/ComposeScene.skiko.kt index e20ff20d34c5c..10543e4f10fa1 100644 --- a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/scene/ComposeScene.skiko.kt +++ b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/scene/ComposeScene.skiko.kt @@ -138,6 +138,15 @@ sealed interface ComposeScene : AutoCloseable { */ fun measureContent(constraints: Constraints): IntSize + /** + * Registers [listener] to be invoked after each completed measure/layout pass of the scene. + * + * Returns a handle that **must** be closed to deregister the listener. This is important for + * platform integrations to avoid retain cycles between a hosting container and + * the scene/root. + */ + fun registerOnLayoutCompletedListener(listener: () -> Unit): AutoCloseable + /** * Invalidates position of [ComposeScene] in the window. It will trigger callbacks like * [Modifier.onGloballyPositioned] so they can recalculate actual position in the window. @@ -326,6 +335,4 @@ fun ComposeScene.hasInvalidations(): Boolean = * size bounds (e.g., LazyColumn without maximum height). */ @InternalComposeUiApi -fun ComposeScene.unconstrainedSize(): IntSize { - return measureContent(Constraints()) -} +fun ComposeScene.unconstrainedSize(): IntSize = measureContent(Constraints()) 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 bb4c71ba4645a..2b7e7e404ead0 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 @@ -150,6 +150,11 @@ private class PlatformLayersComposeSceneImpl( return mainOwner.measureContentWithConstraints(constraints) } + override fun registerOnLayoutCompletedListener(listener: () -> Unit): AutoCloseable { + check(!isClosed) { "registerOnLayoutCompletedListener called after ComposeScene is closed" } + return mainOwner.registerOnLayoutCompletedListener(listener) + } + override fun invalidatePositionInWindow() { check(!isClosed) { "invalidatePositionInWindow called after ComposeScene is closed" } mainOwner.invalidatePositionInWindow() From 2a31776dcc0d769a57ac307c29fd17a53bfea8a5 Mon Sep 17 00:00:00 2001 From: svastven Date: Sun, 19 Apr 2026 23:16:13 +0200 Subject: [PATCH 02/30] ios: reconcile sizeThatFits proposals with Compose preferred size --- .../compose/ui/scene/ComposeContainer.ios.kt | 155 +++++++++++++++++- .../ui/scene/ComposeHostingView.ios.kt | 11 +- .../ui/scene/ComposeSceneMediator.ios.kt | 8 +- .../ComposeContainerConfiguration.ios.kt | 17 ++ .../ui/window/ComposeContainerView.ios.kt | 18 ++ 5 files changed, 206 insertions(+), 3 deletions(-) diff --git a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeContainer.ios.kt b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeContainer.ios.kt index 067f7f3cc64b5..d78aa559f2754 100644 --- a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeContainer.ios.kt +++ b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeContainer.ios.kt @@ -37,9 +37,14 @@ import androidx.compose.ui.uikit.density import androidx.compose.ui.uikit.embedSubview import androidx.compose.ui.uikit.utils.CMPKeyValueObserver import androidx.compose.ui.uikit.utils.CMPUIWindowSceneUtils +import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.toIntSize import androidx.compose.ui.util.fastForEachReversed import androidx.compose.ui.viewinterop.UIKitInteropAction import androidx.compose.ui.viewinterop.UIKitInteropTransaction @@ -54,8 +59,13 @@ import kotlin.coroutines.CoroutineContext import kotlin.coroutines.EmptyCoroutineContext import kotlinx.cinterop.CPointed import kotlinx.cinterop.CPointer +import kotlinx.cinterop.CValue +import kotlinx.cinterop.useContents import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job +import platform.CoreGraphics.CGFloat +import platform.CoreGraphics.CGSize +import platform.CoreGraphics.CGSizeMake import platform.Foundation.NSKeyValueObservingOptionNew import platform.Foundation.addObserver import platform.Foundation.removeObserver @@ -65,8 +75,10 @@ import platform.UIKit.UIResponder import platform.UIKit.UIUserInterfaceLayoutDirection import platform.UIKit.UIUserInterfaceStyle import platform.UIKit.UIViewController +import platform.UIKit.UIViewNoIntrinsicMetric import platform.UIKit.UIWindow import platform.UIKit.UIWindowScene +import platform.UIKit.UIView /** * The class represents a common part of Compose integration for all iOS containers. @@ -81,7 +93,10 @@ internal class ComposeContainer( val view = ComposeContainerView( transparentForTouches = false, useOpaqueConfiguration = configuration.opaque, - ) + ).apply { + onSizeThatFits = ::onSizeThatFits + onIntrinsicContentSize = ::onIntrinsicContentSize + } private var mediator: ComposeSceneMediator? = null private val windowContext = PlatformWindowContext() @@ -108,6 +123,16 @@ internal class ComposeContainer( getTopLeftOffsetInWindow = { IntOffset.Zero }, //full screen endEdgePanGestureBehavior = configuration.endEdgePanGestureBehavior ) + private val composeSceneSizeSynchronizer = ComposeSceneSizeSynchronizer( + view = view, + composeSceneSize = { constraints -> + mediator?.constrainedSceneSize(constraints) ?: IntSize.Zero + }, + invalidateComposeSceneContainerSize = { + view.superview?.invalidateIntrinsicContentSize() + } + ) + val hasInteropViews: Boolean get() = mediator?.hasInteropViews ?: false /* @@ -121,6 +146,8 @@ internal class ComposeContainer( private val focusedViewsList = FocusedViewsList() + private var onLayoutCompletedListenerHandle: AutoCloseable? = null + init { if (configuration.enforceStrictPlistSanityCheck) { PlistSanityCheck.performIfNeeded() @@ -169,6 +196,14 @@ internal class ComposeContainer( lifecycleDelegate.windowScene = window.windowScene } + private fun onSizeThatFits(size: CValue): CValue? { + return composeSceneSizeSynchronizer.onSizeThatFitsRequest(size) + } + + private fun onIntrinsicContentSize(): CValue? { + return composeSceneSizeSynchronizer.preferredCGSize + } + fun updateInterfaceOrientationState() { currentInterfaceOrientation?.let { interfaceOrientationState.value = it @@ -248,6 +283,11 @@ internal class ComposeContainer( } } + onLayoutCompletedListenerHandle?.close() + onLayoutCompletedListenerHandle = mediator?.registerOnLayoutCompletedListener( + composeSceneSizeSynchronizer::onComposeLayoutCompleted + ) + activeStateListener = SceneActiveStateListener( getScene = ::windowScene ) { isSceneActive -> @@ -276,6 +316,9 @@ internal class ComposeContainer( navigationEventInput.onDidMoveToWindow(null, view) architectureComponentsOwner.navigationEventDispatcher.removeInput(navigationEventInput) + onLayoutCompletedListenerHandle?.close() + onLayoutCompletedListenerHandle = null + mediator = null activeStateListener?.dispose() @@ -479,3 +522,113 @@ private class SceneGeometryObserver( onGeometryChanged() } } + +/** + * Synchronizes UIKit/SwiftUI sizing proposals (`sizeThatFits`) with the preferred size produced by + * the Compose scene under the corresponding constraints. + * + * - UIKit/SwiftUI proposes constraints via `sizeThatFits` + * - Compose produces a preferred size under those constraints after its layout completes + * - if the preferred size changes, we invalidate the *hosting* view intrinsic size so + * UIKit/SwiftUI can re-run layout with the updated information + */ +internal class ComposeSceneSizeSynchronizer( + private val view: ComposeContainerView, + private val composeSceneSize: (Constraints) -> IntSize, + private var invalidateComposeSceneContainerSize: () -> Unit = {}, +) { + + /** + * Latest constraints requested by UIKit/SwiftUI through [UIView.sizeThatFits]. + * We intentionally keep the latest value because [UIView.sizeThatFits] is the source of truth. + */ + private var latestSizeThatFitsConstraints: Constraints? = null + + /** + * Constraints for which [lastMeasuredPreferredSize] was produced. + * This allows us to reuse measured results only when constraints exactly match. + */ + private var lastMeasuredConstraints: Constraints? = null + + /** + * Preferred size measured by Compose for [lastMeasuredConstraints]. + */ + private var lastMeasuredPreferredSize: IntSize? = null + + /** + * Final size exposed to UIKit sizing APIs. The size resolved for constraints given by UIKit + * and the preferred size measured by Compose respecting these constraints. + * + * This value may temporarily contain a fallback (before Compose measurement is available). + */ + private var preferredSize: IntSize? = null + + val preferredCGSize: CValue? + get() = preferredSize?.toCGSize(view.density) + + fun onSizeThatFitsRequest(size: CValue): CValue? { + val constraints = size.useContents { + Constraints( + maxWidth = width.toConstraintValue(view.density), + maxHeight = height.toConstraintValue(view.density) + ) + } + + // `sizeThatFits` requests come from UIKit/SwiftUI and should win over stale internal state. + if (latestSizeThatFitsConstraints != constraints) { + latestSizeThatFitsConstraints = constraints + } + + // Fast path: if Compose already measured preferred size for the exact same constraints, + // return it directly. + if (lastMeasuredConstraints == constraints && lastMeasuredPreferredSize != null) { + preferredSize = lastMeasuredPreferredSize + return preferredCGSize + } + + // Fallback path used before Compose measurement is ready for these constraints. + // For `UIViewNoIntrinsicMetric`, ask UIKit's super implementation for a concrete axis size. + val viewSizeThatFits by lazy { view.superSizeThatFits(size) } + + val width = if (size.useContents { width } == UIViewNoIntrinsicMetric) { + viewSizeThatFits.useContents { width } + } else { + size.useContents { width } + } + val height = if (size.useContents { height } == UIViewNoIntrinsicMetric) { + viewSizeThatFits.useContents { height } + } else { + size.useContents { height } + } + + val fallbackSize = with(view.density) { + DpSize(width.dp, height.dp).toSize().toIntSize() + } + preferredSize = fallbackSize + return preferredCGSize + } + + fun onComposeLayoutCompleted() { + val constraints = latestSizeThatFitsConstraints ?: return + val preferredSize = composeSceneSize(constraints) + + lastMeasuredConstraints = constraints + lastMeasuredPreferredSize = preferredSize + + if (preferredSize != this.preferredSize) { + this.preferredSize = preferredSize + invalidateComposeSceneContainerSize() + } + } + + private fun CGFloat.toConstraintValue(density: Density): Int { + if (this == UIViewNoIntrinsicMetric) return Constraints.Infinity + val px = with(density) { dp.roundToPx() } + if (px >= 0 || px == Constraints.Infinity) return px + throw IllegalArgumentException("Invalid constraint size: $this") + } +} + +private fun IntSize.toCGSize(density: Density) = with(density) { + CGSizeMake(width.toDp().value.toDouble(), height.toDp().value.toDouble()) +} diff --git a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeHostingView.ios.kt b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeHostingView.ios.kt index 21404aa0f6dd0..a6dcd78a63c37 100644 --- a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeHostingView.ios.kt +++ b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeHostingView.ios.kt @@ -55,6 +55,9 @@ internal class ComposeHostingView( // Used for testing val rootRedrawer: MetalRedrawer? get() = container.view.redrawer fun hasInvalidations(): Boolean = container.hasInvalidations() + // Invoked when this hosting view invalidates its intrinsic size. + // Kept as an internal test hook to assert SwiftUI/UIKit relayout signaling. + var onIntrinsicContentSizeInvalidated: (() -> Unit)? = null init { addSubview(container.view) @@ -70,6 +73,11 @@ internal class ComposeHostingView( return container.view.intrinsicContentSize } + override fun invalidateIntrinsicContentSize() { + super.invalidateIntrinsicContentSize() + onIntrinsicContentSizeInvalidated?.invoke() + } + override fun layoutSubviews() { super.layoutSubviews() @@ -128,6 +136,7 @@ internal class ComposeHostingView( } private var isAnimating = false + private fun animateSizeTransition(initialSize: DpSize) { if (isAnimating) { container.view.setFrame(bounds) @@ -177,4 +186,4 @@ internal fun UIView.transitionProgress(initialSize: DpSize): Float { else -> 1f } return progress.coerceIn(0.0f, 1.0f) -} \ No newline at end of file +} diff --git a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.ios.kt b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.ios.kt index deb76601732ee..4804ed3d76ccd 100644 --- a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.ios.kt +++ b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.ios.kt @@ -74,6 +74,7 @@ import androidx.compose.ui.input.pointer.isAltPressed import androidx.compose.ui.input.pointer.isCtrlPressed import androidx.compose.ui.input.pointer.isMetaPressed import androidx.compose.ui.input.pointer.isShiftPressed +import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.IntRect @@ -276,7 +277,7 @@ internal class ComposeSceneMediator( * Density of the hosting UIKit screen. * * This value is intentionally separate from [composeSceneDensity] so we can support setting - * composeSceneDensity without regressions. + * [composeSceneDensity] without regressions. */ val screenDensity: Density get() = _overlayView.density @@ -759,6 +760,11 @@ internal class ComposeSceneMediator( this.onKeyEvent = onKeyEvent ?: { false } } + fun registerOnLayoutCompletedListener(listener: () -> Unit): AutoCloseable = + scene.registerOnLayoutCompletedListener(listener) + + fun constrainedSceneSize(constraints: Constraints): IntSize = scene.constrainedSize(constraints) + /** * Converts [UIPress] objects to [KeyEvent] and dispatches them to the appropriate handlers. * @param presses a [Set] of [UIPress] objects. Erasure happens due to K/N not supporting Obj-C lightweight generics. diff --git a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/uikit/ComposeContainerConfiguration.ios.kt b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/uikit/ComposeContainerConfiguration.ios.kt index 8983cc0734d92..fed8a6ad47ad7 100644 --- a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/uikit/ComposeContainerConfiguration.ios.kt +++ b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/uikit/ComposeContainerConfiguration.ios.kt @@ -72,6 +72,23 @@ sealed class ComposeContainerConfiguration { */ @ExperimentalComposeUiApi var isClearFocusOnMouseDownEnabled: Boolean = ComposeUiFlags.isClearFocusOnMouseDownEnabled + + /** + * Enables preferred-size sizing interop for UIKit/SwiftUI hosting containers. + * + * When enabled, the internal container view participates in UIKit sizing APIs: + * - `sizeThatFits(...)` proposals from UIKit/SwiftUI are translated to Compose constraints + * - after each Compose layout completes, the scene is probed for its preferred size under the + * latest proposal constraints + * - if the preferred size changes, the hosting view invalidates its `intrinsicContentSize` to + * trigger another UIKit/SwiftUI layout pass + * + * This mode is intended for `ComposeHostingView` / `ComposeHostingViewController` style + * integrations. Other container types should keep it disabled to avoid unnecessary measuring + * work and potential sizing feedback loops. + */ + @ExperimentalComposeUiApi + var usePreferredSizeSizing: Boolean = false } /** diff --git a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/window/ComposeContainerView.ios.kt b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/window/ComposeContainerView.ios.kt index 33b646c066033..0cc474749f847 100644 --- a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/window/ComposeContainerView.ios.kt +++ b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/window/ComposeContainerView.ios.kt @@ -27,6 +27,7 @@ import platform.CoreGraphics.CGPoint import platform.CoreGraphics.CGRect import platform.CoreGraphics.CGRectEqualToRect import platform.CoreGraphics.CGRectMake +import platform.CoreGraphics.CGSize import platform.UIKit.UIColor import platform.UIKit.UIEvent import platform.UIKit.UIGraphicsImageRenderer @@ -55,12 +56,29 @@ internal class ComposeContainerView( private var onLayoutSubviews: () -> Unit = {} private var foregroundStateListener: SceneForegroundStateListener? = null + var onSizeThatFits: (CValue) -> CValue? = { null } + var onIntrinsicContentSize: () -> CValue? = { null } + val redrawer: MetalRedrawer? get() = metalView?.redrawer override fun canBecomeFirstResponder(): Boolean { return true } + override fun sizeThatFits(size: CValue): CValue = + onSizeThatFits(size) ?: super.sizeThatFits(size) + + /** + * Exposes `super.sizeThatFits` so sizing interop logic can obtain UIKit's default fallback + * without re-entering [onSizeThatFits]. + */ + fun superSizeThatFits(size: CValue): CValue { + return super.sizeThatFits(size) + } + + override fun intrinsicContentSize(): CValue = + onIntrinsicContentSize() ?: super.intrinsicContentSize() + override fun traitCollectionDidChange(previousTraitCollection: UITraitCollection?) { super.traitCollectionDidChange(previousTraitCollection) From bea6ae90a67b297a3839151b278043e62531e7bd Mon Sep 17 00:00:00 2001 From: svastven Date: Sun, 19 Apr 2026 23:17:24 +0200 Subject: [PATCH 03/30] uikitInstrumentedTest: add ComposeUIView sizing reconciliation tests --- .../ui/interop/ComposeUIViewSizingTest.kt | 336 ++++++++++++++++++ .../compose/ui/test/UIKitInstrumentedTest.kt | 59 ++- 2 files changed, 379 insertions(+), 16 deletions(-) create mode 100644 compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interop/ComposeUIViewSizingTest.kt diff --git a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interop/ComposeUIViewSizingTest.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interop/ComposeUIViewSizingTest.kt new file mode 100644 index 0000000000000..dff7b1eda8654 --- /dev/null +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interop/ComposeUIViewSizingTest.kt @@ -0,0 +1,336 @@ +/* + * Copyright 2026 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.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.scene.ComposeHostingView +import androidx.compose.ui.test.UIKitInstrumentedTest +import androidx.compose.ui.test.runUIKitInstrumentedTestInHostingView +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.asDpSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.size +import androidx.compose.ui.unit.toDpRect +import kotlin.test.Test +import kotlinx.cinterop.CValue +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.useContents +import platform.CoreGraphics.CGRectMake +import platform.CoreGraphics.CGSize +import platform.CoreGraphics.CGSizeMake +import platform.UIKit.UIViewNoIntrinsicMetric + +@OptIn(ExperimentalForeignApi::class, ExperimentalComposeUiApi::class) +class ComposeUIViewSizingTest { + private val contentSize = DpSize(200.dp, 100.dp) + + @Test + fun testBothAxesBounded() = testComposeUIViewSizing( + content = { Box(Modifier.size(contentSize)) }, + proposal = CGSizeMake(150.0, 60.0), + expected = DpSize(150.dp, 60.dp) + ) + + @Test + fun testBothAxesBoundedProposedHeightLargerThanContentSize() = testComposeUIViewSizing( + content = { Box(Modifier.size(contentSize)) }, + proposal = CGSizeMake(150.0, (contentSize.height + 10.dp).value.toDouble()), + expected = DpSize(150.dp, contentSize.height) + ) + + @Test + fun testBothAxesBoundedProposedWidthLargerThanContentSize() = testComposeUIViewSizing( + content = { Box(Modifier.size(contentSize)) }, + proposal = CGSizeMake((contentSize.width + 10.dp).value.toDouble(), 60.0), + expected = DpSize(contentSize.width, 60.dp) + ) + + @Test + fun testBothAxesBoundedLargerThanContentSize() = testComposeUIViewSizing( + content = { Box(Modifier.size(contentSize)) }, + proposal = CGSizeMake( + (contentSize.width + 10.dp).value.toDouble(), + (contentSize.height + 10.dp).value.toDouble() + ), + expected = DpSize(contentSize.width, contentSize.height) + ) + + @Test + fun testBoundedWidthAndUnboundedHeight() = testComposeUIViewSizing( + content = { Box(Modifier.size(contentSize)) }, + proposal = CGSizeMake(150.0, UIViewNoIntrinsicMetric), + expected = DpSize(150.dp, 100.dp) + ) + + @Test + fun testUnboundedWidthAndBoundedHeight() = testComposeUIViewSizing( + content = { Box(Modifier.size(contentSize)) }, + proposal = CGSizeMake(UIViewNoIntrinsicMetric, 60.0), + expected = DpSize(200.dp, 60.dp) + ) + + @Test + fun testBothAxesUnbounded() = testComposeUIViewSizing( + content = { Box(Modifier.size(contentSize)) }, + proposal = CGSizeMake(UIViewNoIntrinsicMetric, UIViewNoIntrinsicMetric), + expected = DpSize(200.dp, 100.dp) + ) + + @Test + fun testFixedSizeProposalAndComposeContentChanges() { + val expanded = mutableStateOf(false) + val sizeProposal = CGSizeMake(150.0, UIViewNoIntrinsicMetric) + val collapsedExpected = DpSize(150.dp, 60.dp) + val expandedExpected = DpSize(150.dp, 120.dp) + + runComposeUIViewSizingTest( + content = { + val height = if (expanded.value) 120.dp else 60.dp + Box(Modifier.size(width = 200.dp, height = height)) + } + ) { context -> + context.proposeSwiftUIConstraints(sizeProposal) + + waitForExpectedSize(context, collapsedExpected, "initial fixed-width proposal state") + + expanded.value = true + waitForIdle() + + waitForExpectedSize(context, expandedExpected, "expanded fixed-width proposal state") + } + } + + @Test + fun testFixedHeightProposalAndComposeContentWidthChanges() { + val expanded = mutableStateOf(false) + val sizeProposal = CGSizeMake(UIViewNoIntrinsicMetric, 80.0) + val collapsedExpected = DpSize(100.dp, 80.dp) + val expandedExpected = DpSize(180.dp, 80.dp) + + runComposeUIViewSizingTest( + content = { + val width = if (expanded.value) 180.dp else 100.dp + Box(Modifier.size(width = width, height = 140.dp)) + } + ) { context -> + context.proposeSwiftUIConstraints(sizeProposal) + waitForExpectedSize(context, collapsedExpected, "initial fixed-height proposal state") + + expanded.value = true + waitForIdle() + + waitForExpectedSize(context, expandedExpected, "expanded fixed-height proposal state") + } + } + + @Test + fun testUnboundedProposalAndComposeContentBothAxesChange() { + val expanded = mutableStateOf(false) + val sizeProposal = CGSizeMake(UIViewNoIntrinsicMetric, UIViewNoIntrinsicMetric) + val collapsedExpected = DpSize(90.dp, 40.dp) + val expandedExpected = DpSize(170.dp, 130.dp) + + runComposeUIViewSizingTest( + content = { + val width = if (expanded.value) 170.dp else 90.dp + val height = if (expanded.value) 130.dp else 40.dp + Box(Modifier.size(width = width, height = height)) + } + ) { context -> + context.proposeSwiftUIConstraints(sizeProposal) + waitForExpectedSize(context, collapsedExpected, "initial fully-unbounded proposal state") + + expanded.value = true + waitForIdle() + + waitForExpectedSize(context, expandedExpected, "expanded fully-unbounded proposal state") + } + } + + @Test + fun testBoundedProposalAndComposeContentChangesFromSmallerToLargerThanProposal() { + val expanded = mutableStateOf(false) + val sizeProposal = CGSizeMake(140.0, 75.0) + val collapsedExpected = DpSize(80.dp, 40.dp) + val expandedExpected = DpSize(140.dp, 75.dp) + + runComposeUIViewSizingTest( + content = { + val width = if (expanded.value) 220.dp else 80.dp + val height = if (expanded.value) 180.dp else 40.dp + Box(Modifier.size(width = width, height = height)) + } + ) { context -> + context.proposeSwiftUIConstraints(sizeProposal) + + waitForExpectedSize(context, collapsedExpected, "initial bounded proposal state") + + expanded.value = true + waitForIdle() + + waitForExpectedSize(context, expandedExpected, "expanded bounded proposal state") + } + } + + @Test + fun testBoundedProposalAndComposeContentChangesFromLargerToSmallerThanProposal() { + val expanded = mutableStateOf(true) + val sizeProposal = CGSizeMake(140.0, 75.0) + val collapsedExpected = DpSize(80.dp, 40.dp) + val expandedExpected = DpSize(140.dp, 75.dp) + + runComposeUIViewSizingTest( + content = { + val width = if (expanded.value) 220.dp else 80.dp + val height = if (expanded.value) 180.dp else 40.dp + Box(Modifier.size(width = width, height = height)) + } + ) { context -> + context.proposeSwiftUIConstraints(sizeProposal) + + waitForExpectedSize(context, expandedExpected, "initial bounded proposal state") + + expanded.value = false + println("Change expanded to false") + waitForIdle() + + waitForExpectedSize(context, collapsedExpected, "expanded bounded proposal state") + } + } + + @Test + fun testComposeContentAndProposalChangeSequence() { + val expanded = mutableStateOf(false) + val firstProposal = CGSizeMake(150.0, UIViewNoIntrinsicMetric) + val secondProposal = CGSizeMake(UIViewNoIntrinsicMetric, 90.0) + val collapsedWithFirstProposal = DpSize(150.dp, 60.dp) + val expandedWithFirstProposal = DpSize(150.dp, 120.dp) + val expandedWithSecondProposal = DpSize(200.dp, 90.dp) + + runComposeUIViewSizingTest( + content = { + val height = if (expanded.value) 120.dp else 60.dp + Box(Modifier.size(width = 200.dp, height = height)) + } + ) { context -> + context.proposeSwiftUIConstraints(firstProposal) + waitForExpectedSize(context, collapsedWithFirstProposal, "collapsed with first proposal") + + expanded.value = true + waitForIdle() + waitForExpectedSize(context, expandedWithFirstProposal, "expanded with first proposal") + + context.proposeSwiftUIConstraints(secondProposal) + waitForExpectedSize(context, expandedWithSecondProposal, "expanded with second proposal") + } + } + + private fun runComposeUIViewSizingTest( + content: @Composable () -> Unit, + runTest: UIKitInstrumentedTest.(SwiftUISimulationContext) -> Unit + ) = runUIKitInstrumentedTestInHostingView { + var composeSceneSize: DpSize? = null + + setContent( + configure = { useSelfSizing = true }, + waitForIdle = false + ) { + Column( + modifier = Modifier.onGloballyPositioned { coordinates -> + composeSceneSize = coordinates.boundsInWindow().toDpRect(density).size + }, + content = { content() } + ) + } + + this.runTest( + SwiftUISimulationContext( + hostingView!!, + { composeSceneSize } + ) + ) + } + + private class SwiftUISimulationContext( + val composeView: ComposeHostingView, + private val getComposeContentSize: () -> DpSize? + ) { + val composeContentSize: DpSize? get() = getComposeContentSize() + val composeUIViewSize: DpSize get() = composeView.frame.useContents { size.asDpSize() } + + private var lastSwiftUIConstraints: CValue? = null + + init { + composeView.onIntrinsicContentSizeInvalidated = { + if (lastSwiftUIConstraints != null) { + proposeSwiftUIConstraints(lastSwiftUIConstraints!!) + } + } + } + + fun proposeSwiftUIConstraints(size: CValue) { + lastSwiftUIConstraints = size + val sizeThatFits = composeView.sizeThatFits(size) + composeView.applyFrame(sizeThatFits) + } + + private fun ComposeHostingView.applyFrame(size: CValue) { + size.useContents { + setFrame(CGRectMake(0.0, 0.0, width, height)) + } + layoutIfNeeded() + } + } + + private fun testComposeUIViewSizing( + proposal: CValue, + expected: DpSize, + content: @Composable () -> Unit + ) = runComposeUIViewSizingTest(content) { context -> + context.proposeSwiftUIConstraints(proposal) + + waitForIdle() + + waitForExpectedSize(context, expected, "proposal") + } + + private fun UIKitInstrumentedTest.waitForExpectedSize( + context: SwiftUISimulationContext, + expected: DpSize, + phase: String + ) { + try { + waitUntil( + conditionDescription = "Waiting for expected size ($phase): $expected" + ) { + context.composeContentSize == expected && + context.composeUIViewSize == expected + } + } catch (e: Throwable) { + println("composeContentSize ${context.composeContentSize}, composeUIViewSize ${context.composeUIViewSize}, expected $expected") + throw e + } + } +} diff --git a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt index 0646421cf2e17..88932929fa53d 100644 --- a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt @@ -116,6 +116,11 @@ import platform.darwin.dispatch_get_main_queue * @param [testBlock] The test function. */ internal fun runUIKitInstrumentedTest(testBlock: UIKitInstrumentedTest.() -> Unit) { + runUIKitInstrumentedTestInHostingView(testBlock) + runUIKitInstrumentedTestInHostingViewController(testBlock) +} + +internal fun runUIKitInstrumentedTestInHostingView(testBlock: UIKitInstrumentedTest.() -> Unit) { println("Debug: Running test with ComposeHostingView") with(UIKitInstrumentedTest(useHostingView = true)) { try { @@ -124,7 +129,9 @@ internal fun runUIKitInstrumentedTest(testBlock: UIKitInstrumentedTest.() -> Uni tearDown() } } +} +internal fun runUIKitInstrumentedTestInHostingViewController(testBlock: UIKitInstrumentedTest.() -> Unit) { println("Debug: Running test with ComposeHostingViewController") with(UIKitInstrumentedTest(useHostingView = false)) { try { @@ -257,8 +264,10 @@ internal class UIKitInstrumentedTest( val accessibilityNotifications = mutableListOf() val lastAccessibilityNotification: AccessibilityNotification? get() = accessibilityNotifications.lastOrNull() - private var hostingViewController: ComposeHostingViewController? = null - private var hostingView: ComposeHostingView? = null + var hostingViewController: ComposeHostingViewController? = null + private set + var hostingView: ComposeHostingView? = null + private set val viewController: UIViewController get() = appDelegate.window?.rootViewController ?: error("Cannot find active UIViewController") @@ -277,29 +286,23 @@ internal class UIKitInstrumentedTest( fun setContent( configure: ComposeContainerConfiguration.() -> Unit = {}, interfaceOrientation: UIInterfaceOrientation = UIInterfaceOrientationPortrait, + waitForIdle: Boolean = true, content: @Composable () -> Unit ) { accessibilityNotifications.clear() AccessibilityNotification.onNotificationPostedForTests = { accessibilityNotifications.add(it) } - val innerConfigure: ComposeContainerConfiguration.() -> Unit = { - enforceStrictPlistSanityCheck = false - configure() - } val rootViewController: UIViewController = if (useHostingView) { - hostingView = ComposeHostingView( - configuration = ComposeUIViewConfiguration().apply(innerConfigure), - content = content, - coroutineContext = coroutineContext - ) - UIViewController().also { - it.view.embedSubview(hostingView!!) - } + UIViewController() } else { + val configuration = ComposeUIViewControllerConfiguration() + .apply({ enforceStrictPlistSanityCheck = false }) + .apply(configure) + ComposeHostingViewController( - configuration = ComposeUIViewControllerConfiguration().apply(innerConfigure), + configuration = configuration, content = content, coroutineContext = coroutineContext ).also { @@ -308,7 +311,31 @@ internal class UIKitInstrumentedTest( } appDelegate.setUpWindow(rootViewController) - waitForIdle() + + if (useHostingView) { + val configuration = ComposeUIViewConfiguration() + .apply({ enforceStrictPlistSanityCheck = false }) + .apply(configure) + + val hostingView = ComposeHostingView( + configuration = configuration, + content = content, + coroutineContext = coroutineContext + ) + this.hostingView = hostingView + + rootViewController.view.let { + if (configuration.usePreferredSizeSizing) { + it.addSubview(hostingView) + } else { + it.embedSubview(hostingView) + } + } + } + + if (waitForIdle) { + waitForIdle() + } if (appDelegate.requestInterfaceOrientationChangeIfNeeded(interfaceOrientation)) { delay(700) From c0b76156f4c4affab7b1608c6eaefb05d40d0bdf Mon Sep 17 00:00:00 2001 From: svastven Date: Mon, 20 Apr 2026 00:30:17 +0200 Subject: [PATCH 04/30] ios: honor usePreferredSizeSizing in ComposeContainer --- .../compose/ui/scene/ComposeContainer.ios.kt | 42 +++++++++---------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeContainer.ios.kt b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeContainer.ios.kt index d78aa559f2754..b0d3b9b4f5979 100644 --- a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeContainer.ios.kt +++ b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeContainer.ios.kt @@ -94,8 +94,8 @@ internal class ComposeContainer( transparentForTouches = false, useOpaqueConfiguration = configuration.opaque, ).apply { - onSizeThatFits = ::onSizeThatFits - onIntrinsicContentSize = ::onIntrinsicContentSize + onSizeThatFits = { composeSceneSizeSynchronizer?.onSizeThatFitsRequest(it) } + onIntrinsicContentSize = { composeSceneSizeSynchronizer?.preferredCGSize } } private var mediator: ComposeSceneMediator? = null @@ -123,15 +123,23 @@ internal class ComposeContainer( getTopLeftOffsetInWindow = { IntOffset.Zero }, //full screen endEdgePanGestureBehavior = configuration.endEdgePanGestureBehavior ) - private val composeSceneSizeSynchronizer = ComposeSceneSizeSynchronizer( - view = view, - composeSceneSize = { constraints -> - mediator?.constrainedSceneSize(constraints) ?: IntSize.Zero - }, - invalidateComposeSceneContainerSize = { - view.superview?.invalidateIntrinsicContentSize() + + private val composeSceneSizeSynchronizer: ComposeSceneSizeSynchronizer? = + if (configuration.useSelfSizing) { + ComposeSceneSizeSynchronizer( + view = view, + composeSceneSize = { constraints -> + mediator?.constrainedSceneSize(constraints) ?: IntSize.Zero + }, + invalidateComposeSceneContainerSize = { + // SwiftUI observes the hosting view’s intrinsic size, not the internal Compose + // container view. + view.superview?.invalidateIntrinsicContentSize() + } + ) + } else { + null } - ) val hasInteropViews: Boolean get() = mediator?.hasInteropViews ?: false @@ -196,14 +204,6 @@ internal class ComposeContainer( lifecycleDelegate.windowScene = window.windowScene } - private fun onSizeThatFits(size: CValue): CValue? { - return composeSceneSizeSynchronizer.onSizeThatFitsRequest(size) - } - - private fun onIntrinsicContentSize(): CValue? { - return composeSceneSizeSynchronizer.preferredCGSize - } - fun updateInterfaceOrientationState() { currentInterfaceOrientation?.let { interfaceOrientationState.value = it @@ -284,9 +284,9 @@ internal class ComposeContainer( } onLayoutCompletedListenerHandle?.close() - onLayoutCompletedListenerHandle = mediator?.registerOnLayoutCompletedListener( - composeSceneSizeSynchronizer::onComposeLayoutCompleted - ) + onLayoutCompletedListenerHandle = composeSceneSizeSynchronizer?.let { + mediator?.registerOnLayoutCompletedListener(it::onComposeLayoutCompleted) + } activeStateListener = SceneActiveStateListener( getScene = ::windowScene From 15dc1559cb2159928358ea2cb1ce5db75e66351d Mon Sep 17 00:00:00 2001 From: svastven Date: Mon, 20 Apr 2026 00:31:08 +0200 Subject: [PATCH 05/30] ios: simplify usePreferredSizeSizing documentation --- .../uikit/ComposeContainerConfiguration.ios.kt | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/uikit/ComposeContainerConfiguration.ios.kt b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/uikit/ComposeContainerConfiguration.ios.kt index fed8a6ad47ad7..b3e5cee62b804 100644 --- a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/uikit/ComposeContainerConfiguration.ios.kt +++ b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/uikit/ComposeContainerConfiguration.ios.kt @@ -74,21 +74,12 @@ sealed class ComposeContainerConfiguration { var isClearFocusOnMouseDownEnabled: Boolean = ComposeUiFlags.isClearFocusOnMouseDownEnabled /** - * Enables preferred-size sizing interop for UIKit/SwiftUI hosting containers. - * - * When enabled, the internal container view participates in UIKit sizing APIs: - * - `sizeThatFits(...)` proposals from UIKit/SwiftUI are translated to Compose constraints - * - after each Compose layout completes, the scene is probed for its preferred size under the - * latest proposal constraints - * - if the preferred size changes, the hosting view invalidates its `intrinsicContentSize` to - * trigger another UIKit/SwiftUI layout pass - * - * This mode is intended for `ComposeHostingView` / `ComposeHostingViewController` style - * integrations. Other container types should keep it disabled to avoid unnecessary measuring - * work and potential sizing feedback loops. + * Enables sizing of the container to fit the preferred size of the Compose content, evaluated + * under the size constraints proposed by the containing UIKit/SwiftUI layout flow (e.g. + * `sizeThatFits` / intrinsic sizing). */ @ExperimentalComposeUiApi - var usePreferredSizeSizing: Boolean = false + var useSelfSizing: Boolean = false } /** From 2ad93c9f54b41ccad415e16793e05eb3b8d8e5cb Mon Sep 17 00:00:00 2001 From: svastven Date: Wed, 22 Apr 2026 00:20:22 +0200 Subject: [PATCH 06/30] skiko: return unregister handle from registerOnLayoutCompletedListener --- .../compose/ui/scene/ComposeContainer.ios.kt | 7 ++++--- .../ui/scene/ComposeSceneMediator.ios.kt | 3 ++- .../compose/ui/node/RootNodeOwner.skiko.kt | 18 ++++++++++++++---- .../ui/scene/CanvasLayersComposeScene.skiko.kt | 3 ++- .../compose/ui/scene/ComposeScene.skiko.kt | 9 +++++---- .../scene/PlatformLayersComposeScene.skiko.kt | 3 ++- 6 files changed, 29 insertions(+), 14 deletions(-) diff --git a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeContainer.ios.kt b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeContainer.ios.kt index b0d3b9b4f5979..b790b538d9afb 100644 --- a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeContainer.ios.kt +++ b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeContainer.ios.kt @@ -24,6 +24,7 @@ import androidx.compose.ui.LocalSystemTheme import androidx.compose.ui.SystemTheme import androidx.compose.ui.graphics.asComposeCanvas import androidx.compose.ui.navigationevent.UIKitNavigationEventInput +import androidx.compose.ui.node.OnLayoutCompletedListenerHandle import androidx.compose.ui.platform.DefaultArchitectureComponentsOwner import androidx.compose.ui.platform.FrameRecomposer import androidx.compose.ui.platform.MotionDurationScaleImpl @@ -154,7 +155,7 @@ internal class ComposeContainer( private val focusedViewsList = FocusedViewsList() - private var onLayoutCompletedListenerHandle: AutoCloseable? = null + private var onLayoutCompletedListenerHandle: OnLayoutCompletedListenerHandle? = null init { if (configuration.enforceStrictPlistSanityCheck) { @@ -283,7 +284,7 @@ internal class ComposeContainer( } } - onLayoutCompletedListenerHandle?.close() + onLayoutCompletedListenerHandle?.unregister() onLayoutCompletedListenerHandle = composeSceneSizeSynchronizer?.let { mediator?.registerOnLayoutCompletedListener(it::onComposeLayoutCompleted) } @@ -316,7 +317,7 @@ internal class ComposeContainer( navigationEventInput.onDidMoveToWindow(null, view) architectureComponentsOwner.navigationEventDispatcher.removeInput(navigationEventInput) - onLayoutCompletedListenerHandle?.close() + onLayoutCompletedListenerHandle?.unregister() onLayoutCompletedListenerHandle = null mediator = null diff --git a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.ios.kt b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.ios.kt index 4804ed3d76ccd..2daa685d446a0 100644 --- a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.ios.kt +++ b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.ios.kt @@ -46,6 +46,7 @@ import androidx.compose.ui.input.pointer.PointerId import androidx.compose.ui.input.pointer.PointerType import androidx.compose.ui.layout.OffsetToFocusedRect import androidx.compose.ui.navigationevent.UIKitNavigationEventInput +import androidx.compose.ui.node.OnLayoutCompletedListenerHandle import androidx.compose.ui.platform.AccessibilityMediator import androidx.compose.ui.platform.CUPERTINO_TOUCH_SLOP import androidx.compose.ui.platform.DefaultInputModeManager @@ -760,7 +761,7 @@ internal class ComposeSceneMediator( this.onKeyEvent = onKeyEvent ?: { false } } - fun registerOnLayoutCompletedListener(listener: () -> Unit): AutoCloseable = + fun registerOnLayoutCompletedListener(listener: () -> Unit): OnLayoutCompletedListenerHandle = scene.registerOnLayoutCompletedListener(listener) fun constrainedSceneSize(constraints: Constraints): IntSize = scene.constrainedSize(constraints) diff --git a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/node/RootNodeOwner.skiko.kt b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/node/RootNodeOwner.skiko.kt index c2618d93de382..f2e01d7a969bd 100644 --- a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/node/RootNodeOwner.skiko.kt +++ b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/node/RootNodeOwner.skiko.kt @@ -251,16 +251,20 @@ internal class RootNodeOwner( * * Unlike [MeasureAndLayoutDelegate.registerOnLayoutCompletedListener], this listener is not * one-shot: it remains active for later layout passes until the returned handle is - * closed. + * unregistered. */ - fun registerOnLayoutCompletedListener(listener: () -> Unit): AutoCloseable { + fun registerOnLayoutCompletedListener(listener: () -> Unit): OnLayoutCompletedListenerHandle { if (layoutCompletedManager == null) { layoutCompletedManager = LayoutCompletedManager() } layoutCompletedManager?.registerListener(listener) - return object : AutoCloseable { - override fun close() { + return object : OnLayoutCompletedListenerHandle { + private var isUnregistered = false + + override fun unregister() { + if (isUnregistered) return + isUnregistered = true layoutCompletedManager?.deregisterListener(listener) if (layoutCompletedManager?.isEmpty == true) { layoutCompletedManager = null @@ -1109,6 +1113,12 @@ private class RootPlatformWindowInsetsProviderNode( } } +@InternalComposeUiApi +fun interface OnLayoutCompletedListenerHandle { + /** Unregisters the associated listener. Calling this multiple times is a no-op. */ + fun unregister() +} + private class LayoutCompletedManager { private var suppressed: Int = 0 private var listeners = mutableVectorOf<(() -> Unit)?>() diff --git a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/scene/CanvasLayersComposeScene.skiko.kt b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/scene/CanvasLayersComposeScene.skiko.kt index e21dc0f6e7c31..29cb11ecf109d 100644 --- a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/scene/CanvasLayersComposeScene.skiko.kt +++ b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/scene/CanvasLayersComposeScene.skiko.kt @@ -36,6 +36,7 @@ import androidx.compose.ui.input.pointer.PointerEventType import androidx.compose.ui.input.pointer.PointerInputEvent import androidx.compose.ui.input.pointer.PointerType import androidx.compose.ui.input.rotary.RotaryScrollEvent +import androidx.compose.ui.node.OnLayoutCompletedListenerHandle import androidx.compose.ui.node.RootNodeOwner import androidx.compose.ui.platform.FrameRecomposer import androidx.compose.ui.platform.PlatformContext @@ -216,7 +217,7 @@ private class CanvasLayersComposeSceneImpl( return IntSize(width, height) } - override fun registerOnLayoutCompletedListener(listener: () -> Unit): AutoCloseable { + override fun registerOnLayoutCompletedListener(listener: () -> Unit): OnLayoutCompletedListenerHandle { check(!isClosed) { "registerOnLayoutCompletedListener called after ComposeScene is closed" } return mainOwner.registerOnLayoutCompletedListener(listener) } diff --git a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/scene/ComposeScene.skiko.kt b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/scene/ComposeScene.skiko.kt index 10543e4f10fa1..fcf23c478c3d2 100644 --- a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/scene/ComposeScene.skiko.kt +++ b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/scene/ComposeScene.skiko.kt @@ -39,6 +39,7 @@ import androidx.compose.ui.input.pointer.PointerType import androidx.compose.ui.input.rotary.RotaryScrollEvent import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.node.LayoutNode +import androidx.compose.ui.node.OnLayoutCompletedListenerHandle import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.platform.PlatformContext import androidx.compose.ui.platform.PlatformDragAndDropManager @@ -141,11 +142,11 @@ sealed interface ComposeScene : AutoCloseable { /** * Registers [listener] to be invoked after each completed measure/layout pass of the scene. * - * Returns a handle that **must** be closed to deregister the listener. This is important for - * platform integrations to avoid retain cycles between a hosting container and - * the scene/root. + * Returns a [OnLayoutCompletedListenerHandle] that **must** be unregistered to unregister + * the listener. This is important for platform integrations to avoid retain cycles between + * a hosting container and the scene/root. */ - fun registerOnLayoutCompletedListener(listener: () -> Unit): AutoCloseable + fun registerOnLayoutCompletedListener(listener: () -> Unit): OnLayoutCompletedListenerHandle /** * Invalidates position of [ComposeScene] in the window. It will trigger callbacks like 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 2b7e7e404ead0..61147d5f2f21a 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 @@ -26,6 +26,7 @@ import androidx.compose.ui.input.key.KeyEvent import androidx.compose.ui.input.pointer.PointerInputEvent import androidx.compose.ui.input.rotary.RotaryScrollEvent import androidx.compose.ui.node.LayoutNode +import androidx.compose.ui.node.OnLayoutCompletedListenerHandle import androidx.compose.ui.node.RootNodeOwner import androidx.compose.ui.platform.FrameRecomposer import androidx.compose.ui.platform.setContent @@ -150,7 +151,7 @@ private class PlatformLayersComposeSceneImpl( return mainOwner.measureContentWithConstraints(constraints) } - override fun registerOnLayoutCompletedListener(listener: () -> Unit): AutoCloseable { + override fun registerOnLayoutCompletedListener(listener: () -> Unit): OnLayoutCompletedListenerHandle { check(!isClosed) { "registerOnLayoutCompletedListener called after ComposeScene is closed" } return mainOwner.registerOnLayoutCompletedListener(listener) } From ea1c62171782184c43e07acbef5d6d10287f9f2b Mon Sep 17 00:00:00 2001 From: svastven Date: Mon, 27 Apr 2026 12:09:25 +0200 Subject: [PATCH 07/30] Change useSelfSizing default value to true --- .../compose/ui/uikit/ComposeContainerConfiguration.ios.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/uikit/ComposeContainerConfiguration.ios.kt b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/uikit/ComposeContainerConfiguration.ios.kt index b3e5cee62b804..1d15d7bc3a5e0 100644 --- a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/uikit/ComposeContainerConfiguration.ios.kt +++ b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/uikit/ComposeContainerConfiguration.ios.kt @@ -79,7 +79,7 @@ sealed class ComposeContainerConfiguration { * `sizeThatFits` / intrinsic sizing). */ @ExperimentalComposeUiApi - var useSelfSizing: Boolean = false + var useSelfSizing: Boolean = true } /** From 55a2fedaefda56a4a093ca81a9d6147cd8ef0a22 Mon Sep 17 00:00:00 2001 From: svastven Date: Tue, 5 May 2026 16:56:56 +0200 Subject: [PATCH 08/30] ios: refine Compose scene size synchronizer --- .../compose/ui/scene/ComposeContainer.ios.kt | 73 +++++++++---------- 1 file changed, 33 insertions(+), 40 deletions(-) diff --git a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeContainer.ios.kt b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeContainer.ios.kt index b790b538d9afb..f037b76c42d9b 100644 --- a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeContainer.ios.kt +++ b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeContainer.ios.kt @@ -79,7 +79,6 @@ import platform.UIKit.UIViewController import platform.UIKit.UIViewNoIntrinsicMetric import platform.UIKit.UIWindow import platform.UIKit.UIWindowScene -import platform.UIKit.UIView /** * The class represents a common part of Compose integration for all iOS containers. @@ -130,7 +129,7 @@ internal class ComposeContainer( ComposeSceneSizeSynchronizer( view = view, composeSceneSize = { constraints -> - mediator?.constrainedSceneSize(constraints) ?: IntSize.Zero + mediator?.constrainedSceneSize(constraints) }, invalidateComposeSceneContainerSize = { // SwiftUI observes the hosting view’s intrinsic size, not the internal Compose @@ -535,33 +534,18 @@ private class SceneGeometryObserver( */ internal class ComposeSceneSizeSynchronizer( private val view: ComposeContainerView, - private val composeSceneSize: (Constraints) -> IntSize, + private val composeSceneSize: (Constraints) -> IntSize?, private var invalidateComposeSceneContainerSize: () -> Unit = {}, ) { - /** - * Latest constraints requested by UIKit/SwiftUI through [UIView.sizeThatFits]. - * We intentionally keep the latest value because [UIView.sizeThatFits] is the source of truth. - */ private var latestSizeThatFitsConstraints: Constraints? = null - /** - * Constraints for which [lastMeasuredPreferredSize] was produced. - * This allows us to reuse measured results only when constraints exactly match. + * Latest constraints proposed by UIKit/SwiftUI through `sizeThatFits`. */ private var lastMeasuredConstraints: Constraints? = null - /** * Preferred size measured by Compose for [lastMeasuredConstraints]. */ - private var lastMeasuredPreferredSize: IntSize? = null - - /** - * Final size exposed to UIKit sizing APIs. The size resolved for constraints given by UIKit - * and the preferred size measured by Compose respecting these constraints. - * - * This value may temporarily contain a fallback (before Compose measurement is available). - */ private var preferredSize: IntSize? = null val preferredCGSize: CValue? @@ -582,13 +566,37 @@ internal class ComposeSceneSizeSynchronizer( // Fast path: if Compose already measured preferred size for the exact same constraints, // return it directly. - if (lastMeasuredConstraints == constraints && lastMeasuredPreferredSize != null) { - preferredSize = lastMeasuredPreferredSize + if (lastMeasuredConstraints == constraints && preferredSize != null) { return preferredCGSize } - // Fallback path used before Compose measurement is ready for these constraints. - // For `UIViewNoIntrinsicMetric`, ask UIKit's super implementation for a concrete axis size. + return if (measureAndCachePreferredSize(constraints) != null) { + preferredCGSize + } else { + fallbackSizeThatFits(size) + } + } + + fun onComposeLayoutCompleted() { + val constraints = latestSizeThatFitsConstraints ?: return + val didUpdatePreferredSize = measureAndCachePreferredSize(constraints) ?: return + + if (didUpdatePreferredSize) { + invalidateComposeSceneContainerSize() + } + } + + private fun measureAndCachePreferredSize(constraints: Constraints): Boolean? { + val preferredSize = composeSceneSize(constraints) ?: return null + lastMeasuredConstraints = constraints + val preferredSizeUpdated = preferredSize != this.preferredSize + if (preferredSizeUpdated) { + this.preferredSize = preferredSize + } + return preferredSizeUpdated + } + + private fun fallbackSizeThatFits(size: CValue): CValue { val viewSizeThatFits by lazy { view.superSizeThatFits(size) } val width = if (size.useContents { width } == UIViewNoIntrinsicMetric) { @@ -602,23 +610,8 @@ internal class ComposeSceneSizeSynchronizer( size.useContents { height } } - val fallbackSize = with(view.density) { - DpSize(width.dp, height.dp).toSize().toIntSize() - } - preferredSize = fallbackSize - return preferredCGSize - } - - fun onComposeLayoutCompleted() { - val constraints = latestSizeThatFitsConstraints ?: return - val preferredSize = composeSceneSize(constraints) - - lastMeasuredConstraints = constraints - lastMeasuredPreferredSize = preferredSize - - if (preferredSize != this.preferredSize) { - this.preferredSize = preferredSize - invalidateComposeSceneContainerSize() + return with(view.density) { + DpSize(width.dp, height.dp).toSize().toIntSize().toCGSize(this) } } From c1f660dd02ef4b4d2aaebf2e8dcb7b344cf298b8 Mon Sep 17 00:00:00 2001 From: svastven Date: Wed, 6 May 2026 14:41:02 +0200 Subject: [PATCH 09/30] ios: defer preferred size caching until scene is ready --- .../androidx/compose/ui/scene/ComposeContainer.ios.kt | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeContainer.ios.kt b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeContainer.ios.kt index f037b76c42d9b..e5c483277b900 100644 --- a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeContainer.ios.kt +++ b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeContainer.ios.kt @@ -548,6 +548,8 @@ internal class ComposeSceneSizeSynchronizer( */ private var preferredSize: IntSize? = null + private val hasPreferredSize: Boolean get() = preferredSize != null + val preferredCGSize: CValue? get() = preferredSize?.toCGSize(view.density) @@ -566,11 +568,11 @@ internal class ComposeSceneSizeSynchronizer( // Fast path: if Compose already measured preferred size for the exact same constraints, // return it directly. - if (lastMeasuredConstraints == constraints && preferredSize != null) { + if (lastMeasuredConstraints == constraints && hasPreferredSize) { return preferredCGSize } - return if (measureAndCachePreferredSize(constraints) != null) { + return if (hasPreferredSize && measureAndCachePreferredSize(constraints) != null) { preferredCGSize } else { fallbackSizeThatFits(size) @@ -588,6 +590,7 @@ internal class ComposeSceneSizeSynchronizer( private fun measureAndCachePreferredSize(constraints: Constraints): Boolean? { val preferredSize = composeSceneSize(constraints) ?: return null + lastMeasuredConstraints = constraints val preferredSizeUpdated = preferredSize != this.preferredSize if (preferredSizeUpdated) { From e6fe7eefc09a1da919ae6b5c39ff28b8a9241a60 Mon Sep 17 00:00:00 2001 From: svastven Date: Wed, 6 May 2026 15:22:37 +0200 Subject: [PATCH 10/30] uikit tests: avoid internal asDpSize helper --- .../androidx/compose/ui/interop/ComposeUIViewSizingTest.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interop/ComposeUIViewSizingTest.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interop/ComposeUIViewSizingTest.kt index dff7b1eda8654..c75e11959d33b 100644 --- a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interop/ComposeUIViewSizingTest.kt +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interop/ComposeUIViewSizingTest.kt @@ -29,8 +29,8 @@ import androidx.compose.ui.scene.ComposeHostingView import androidx.compose.ui.test.UIKitInstrumentedTest import androidx.compose.ui.test.runUIKitInstrumentedTestInHostingView import androidx.compose.ui.unit.DpSize -import androidx.compose.ui.unit.asDpSize import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.dpSize import androidx.compose.ui.unit.size import androidx.compose.ui.unit.toDpRect import kotlin.test.Test @@ -278,7 +278,7 @@ class ComposeUIViewSizingTest { private val getComposeContentSize: () -> DpSize? ) { val composeContentSize: DpSize? get() = getComposeContentSize() - val composeUIViewSize: DpSize get() = composeView.frame.useContents { size.asDpSize() } + val composeUIViewSize: DpSize get() = composeView.frame.dpSize() private var lastSwiftUIConstraints: CValue? = null From 9090eb2622a5725f76e35eee946d8c473981071c Mon Sep 17 00:00:00 2001 From: svastven Date: Sun, 10 May 2026 03:13:23 +0200 Subject: [PATCH 11/30] Invalidate container view's intrinsic content size --- .../kotlin/androidx/compose/ui/scene/ComposeContainer.ios.kt | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeContainer.ios.kt b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeContainer.ios.kt index e5c483277b900..ae9e75a21b450 100644 --- a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeContainer.ios.kt +++ b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeContainer.ios.kt @@ -132,9 +132,7 @@ internal class ComposeContainer( mediator?.constrainedSceneSize(constraints) }, invalidateComposeSceneContainerSize = { - // SwiftUI observes the hosting view’s intrinsic size, not the internal Compose - // container view. - view.superview?.invalidateIntrinsicContentSize() + view.invalidateIntrinsicContentSize() } ) } else { From 06316146db091fc7d245c03a822d687e493e509c Mon Sep 17 00:00:00 2001 From: svastven Date: Tue, 12 May 2026 09:43:31 +0200 Subject: [PATCH 12/30] Create synchronizeComposeViewFrame function --- .../compose/ui/scene/ComposeHostingView.ios.kt | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeHostingView.ios.kt b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeHostingView.ios.kt index a6dcd78a63c37..91f60d96e72e0 100644 --- a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeHostingView.ios.kt +++ b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeHostingView.ios.kt @@ -87,13 +87,13 @@ internal class ComposeHostingView( if (initialSize == null || initialSize == bounds.dpSize() || container.hasInteropViews) { - container.view.setFrame(bounds) + synchronizeComposeViewFrame() return } val scope = container.nestedCoroutineScope() if (!scope.isActive) { - container.view.setFrame(bounds) + synchronizeComposeViewFrame() return } @@ -108,7 +108,7 @@ internal class ComposeHostingView( if (actualSize != null && actualSize != bounds.dpSize() && !container.hasInteropViews) { animateSizeTransition(initialSize = initialSize) } else { - container.view.setFrame(bounds) + synchronizeComposeViewFrame() } } } @@ -135,11 +135,15 @@ internal class ComposeHostingView( container.disposeComposeScene() } + private fun synchronizeComposeViewFrame() { + container.view.setFrame(bounds) + } + private var isAnimating = false private fun animateSizeTransition(initialSize: DpSize) { if (isAnimating) { - container.view.setFrame(bounds) + synchronizeComposeViewFrame() return } isAnimating = true @@ -166,7 +170,7 @@ internal class ComposeHostingView( animations() } container.view.clipsToBounds = false - container.view.setFrame(bounds) + synchronizeComposeViewFrame() } } From 6ffda8d745e3a77afce351b392a1c8a602e7c65d Mon Sep 17 00:00:00 2001 From: svastven Date: Tue, 12 May 2026 14:58:12 +0200 Subject: [PATCH 13/30] ios: refine compose hosting sizeThatFits state --- .../compose/ui/scene/ComposeContainer.ios.kt | 32 +++++++++++++++---- .../ui/scene/ComposeHostingView.ios.kt | 3 ++ .../ui/window/ComposeContainerView.ios.kt | 6 ++++ 3 files changed, 34 insertions(+), 7 deletions(-) diff --git a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeContainer.ios.kt b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeContainer.ios.kt index ae9e75a21b450..c592d63a8fdb7 100644 --- a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeContainer.ios.kt +++ b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeContainer.ios.kt @@ -545,13 +545,20 @@ internal class ComposeSceneSizeSynchronizer( * Preferred size measured by Compose for [lastMeasuredConstraints]. */ private var preferredSize: IntSize? = null + /** + * Last size returned from `sizeThatFits`. + * + * Used as a temporary intrinsic size fallback until Compose measures and caches a real + * preferred size for the latest external proposal. + */ + private var lastSizeThatFitsResult: CValue? = null private val hasPreferredSize: Boolean get() = preferredSize != null val preferredCGSize: CValue? - get() = preferredSize?.toCGSize(view.density) + get() = preferredSize?.toCGSize(view.density) ?: lastSizeThatFitsResult - fun onSizeThatFitsRequest(size: CValue): CValue? { + fun onSizeThatFitsRequest(size: CValue): CValue { val constraints = size.useContents { Constraints( maxWidth = width.toConstraintValue(view.density), @@ -564,17 +571,28 @@ internal class ComposeSceneSizeSynchronizer( latestSizeThatFitsConstraints = constraints } + val result = preferredSizeForConstraints(constraints) ?: fallbackSizeThatFits(size) + lastSizeThatFitsResult = result + + return result + } + + private fun preferredSizeForConstraints(constraints: Constraints): CValue? { + if (!hasPreferredSize) return null + // Fast path: if Compose already measured preferred size for the exact same constraints, // return it directly. - if (lastMeasuredConstraints == constraints && hasPreferredSize) { + if (lastMeasuredConstraints == constraints) { return preferredCGSize } - return if (hasPreferredSize && measureAndCachePreferredSize(constraints) != null) { - preferredCGSize - } else { - fallbackSizeThatFits(size) + val didUpdatePreferredSize = measureAndCachePreferredSize(constraints) ?: return null + + if (didUpdatePreferredSize) { + invalidateComposeSceneContainerSize() } + + return preferredCGSize } fun onComposeLayoutCompleted() { diff --git a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeHostingView.ios.kt b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeHostingView.ios.kt index 91f60d96e72e0..ae81003a1de85 100644 --- a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeHostingView.ios.kt +++ b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeHostingView.ios.kt @@ -63,6 +63,9 @@ internal class ComposeHostingView( addSubview(container.view) clipsToBounds = true opaque = configuration.opaque + container.view.onIntrinsicContentSizeInvalidated = { + invalidateIntrinsicContentSize() + } } override fun sizeThatFits(size: CValue): CValue { diff --git a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/window/ComposeContainerView.ios.kt b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/window/ComposeContainerView.ios.kt index 0cc474749f847..2e2a58f7b320a 100644 --- a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/window/ComposeContainerView.ios.kt +++ b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/window/ComposeContainerView.ios.kt @@ -58,6 +58,7 @@ internal class ComposeContainerView( var onSizeThatFits: (CValue) -> CValue? = { null } var onIntrinsicContentSize: () -> CValue? = { null } + var onIntrinsicContentSizeInvalidated: (() -> Unit)? = null val redrawer: MetalRedrawer? get() = metalView?.redrawer @@ -79,6 +80,11 @@ internal class ComposeContainerView( override fun intrinsicContentSize(): CValue = onIntrinsicContentSize() ?: super.intrinsicContentSize() + override fun invalidateIntrinsicContentSize() { + super.invalidateIntrinsicContentSize() + onIntrinsicContentSizeInvalidated?.invoke() + } + override fun traitCollectionDidChange(previousTraitCollection: UITraitCollection?) { super.traitCollectionDidChange(previousTraitCollection) From 6c0841bf8009f6388eb84a0a427e3f99552a87fb Mon Sep 17 00:00:00 2001 From: svastven Date: Wed, 3 Jun 2026 20:04:54 +0200 Subject: [PATCH 14/30] Remove useSelfSizing from ComposeContainerConfiguration --- .../compose/ui/scene/ComposeContainer.ios.kt | 24 +++++++------------ .../ComposeContainerConfiguration.ios.kt | 8 ------- .../ui/interop/ComposeUIViewSizingTest.kt | 9 ++++--- .../compose/ui/test/UIKitInstrumentedTest.kt | 8 +------ 4 files changed, 13 insertions(+), 36 deletions(-) diff --git a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeContainer.ios.kt b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeContainer.ios.kt index c592d63a8fdb7..cca00b8e57e07 100644 --- a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeContainer.ios.kt +++ b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeContainer.ios.kt @@ -94,8 +94,8 @@ internal class ComposeContainer( transparentForTouches = false, useOpaqueConfiguration = configuration.opaque, ).apply { - onSizeThatFits = { composeSceneSizeSynchronizer?.onSizeThatFitsRequest(it) } - onIntrinsicContentSize = { composeSceneSizeSynchronizer?.preferredCGSize } + onSizeThatFits = { composeSceneSizeSynchronizer.onSizeThatFitsRequest(it) } + onIntrinsicContentSize = { composeSceneSizeSynchronizer.preferredCGSize } } private var mediator: ComposeSceneMediator? = null @@ -124,20 +124,12 @@ internal class ComposeContainer( endEdgePanGestureBehavior = configuration.endEdgePanGestureBehavior ) - private val composeSceneSizeSynchronizer: ComposeSceneSizeSynchronizer? = - if (configuration.useSelfSizing) { - ComposeSceneSizeSynchronizer( - view = view, - composeSceneSize = { constraints -> - mediator?.constrainedSceneSize(constraints) - }, - invalidateComposeSceneContainerSize = { - view.invalidateIntrinsicContentSize() - } - ) - } else { - null - } + private val composeSceneSizeSynchronizer: ComposeSceneSizeSynchronizer = + ComposeSceneSizeSynchronizer( + view = view, + composeSceneSize = { mediator?.constrainedSceneSize(it) }, + invalidateComposeSceneContainerSize = view::invalidateIntrinsicContentSize + ) val hasInteropViews: Boolean get() = mediator?.hasInteropViews ?: false diff --git a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/uikit/ComposeContainerConfiguration.ios.kt b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/uikit/ComposeContainerConfiguration.ios.kt index 1d15d7bc3a5e0..8983cc0734d92 100644 --- a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/uikit/ComposeContainerConfiguration.ios.kt +++ b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/uikit/ComposeContainerConfiguration.ios.kt @@ -72,14 +72,6 @@ sealed class ComposeContainerConfiguration { */ @ExperimentalComposeUiApi var isClearFocusOnMouseDownEnabled: Boolean = ComposeUiFlags.isClearFocusOnMouseDownEnabled - - /** - * Enables sizing of the container to fit the preferred size of the Compose content, evaluated - * under the size constraints proposed by the containing UIKit/SwiftUI layout flow (e.g. - * `sizeThatFits` / intrinsic sizing). - */ - @ExperimentalComposeUiApi - var useSelfSizing: Boolean = true } /** diff --git a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interop/ComposeUIViewSizingTest.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interop/ComposeUIViewSizingTest.kt index c75e11959d33b..2e791cb6d7876 100644 --- a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interop/ComposeUIViewSizingTest.kt +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interop/ComposeUIViewSizingTest.kt @@ -48,10 +48,10 @@ class ComposeUIViewSizingTest { @Test fun testBothAxesBounded() = testComposeUIViewSizing( - content = { Box(Modifier.size(contentSize)) }, - proposal = CGSizeMake(150.0, 60.0), - expected = DpSize(150.dp, 60.dp) - ) + content = { Box(Modifier.size(contentSize)) }, + proposal = CGSizeMake(150.0, 60.0), + expected = DpSize(150.dp, 60.dp) + ) @Test fun testBothAxesBoundedProposedHeightLargerThanContentSize() = testComposeUIViewSizing( @@ -254,7 +254,6 @@ class ComposeUIViewSizingTest { var composeSceneSize: DpSize? = null setContent( - configure = { useSelfSizing = true }, waitForIdle = false ) { Column( diff --git a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt index 88932929fa53d..a3fe7482210c9 100644 --- a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt @@ -324,13 +324,7 @@ internal class UIKitInstrumentedTest( ) this.hostingView = hostingView - rootViewController.view.let { - if (configuration.usePreferredSizeSizing) { - it.addSubview(hostingView) - } else { - it.embedSubview(hostingView) - } - } + rootViewController.view.embedSubview(hostingView) } if (waitForIdle) { From 0073e77cabed5523c585fa8426e6f79960dfd8da Mon Sep 17 00:00:00 2001 From: svastven Date: Thu, 4 Jun 2026 00:22:19 +0200 Subject: [PATCH 15/30] Parameterize ComposeUIView sizing host tests --- .../ui/interop/ComposeUIViewSizingTest.kt | 105 ++++++++++++++---- 1 file changed, 83 insertions(+), 22 deletions(-) diff --git a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interop/ComposeUIViewSizingTest.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interop/ComposeUIViewSizingTest.kt index 2e791cb6d7876..1e0936069578d 100644 --- a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interop/ComposeUIViewSizingTest.kt +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interop/ComposeUIViewSizingTest.kt @@ -25,9 +25,9 @@ import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.layout.boundsInWindow import androidx.compose.ui.layout.onGloballyPositioned -import androidx.compose.ui.scene.ComposeHostingView import androidx.compose.ui.test.UIKitInstrumentedTest import androidx.compose.ui.test.runUIKitInstrumentedTestInHostingView +import androidx.compose.ui.test.runUIKitInstrumentedTestInHostingViewController import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dpSize @@ -40,10 +40,21 @@ import kotlinx.cinterop.useContents import platform.CoreGraphics.CGRectMake import platform.CoreGraphics.CGSize import platform.CoreGraphics.CGSizeMake +import platform.UIKit.UIView +import platform.UIKit.UIViewController import platform.UIKit.UIViewNoIntrinsicMetric +import platform.UIKit.addChildViewController +import platform.UIKit.didMoveToParentViewController + +internal enum class ComposeUIViewHost { + HostingView, + HostingViewController +} @OptIn(ExperimentalForeignApi::class, ExperimentalComposeUiApi::class) -class ComposeUIViewSizingTest { +internal abstract class BaseComposeUIViewSizingTest( + private val host: ComposeUIViewHost +) { private val contentSize = DpSize(200.dp, 100.dp) @Test @@ -79,17 +90,17 @@ class ComposeUIViewSizingTest { @Test fun testBoundedWidthAndUnboundedHeight() = testComposeUIViewSizing( - content = { Box(Modifier.size(contentSize)) }, - proposal = CGSizeMake(150.0, UIViewNoIntrinsicMetric), - expected = DpSize(150.dp, 100.dp) - ) + content = { Box(Modifier.size(contentSize)) }, + proposal = CGSizeMake(150.0, UIViewNoIntrinsicMetric), + expected = DpSize(150.dp, 100.dp) + ) @Test fun testUnboundedWidthAndBoundedHeight() = testComposeUIViewSizing( - content = { Box(Modifier.size(contentSize)) }, - proposal = CGSizeMake(UIViewNoIntrinsicMetric, 60.0), - expected = DpSize(200.dp, 60.dp) - ) + content = { Box(Modifier.size(contentSize)) }, + proposal = CGSizeMake(UIViewNoIntrinsicMetric, 60.0), + expected = DpSize(200.dp, 60.dp) + ) @Test fun testBothAxesUnbounded() = testComposeUIViewSizing( @@ -213,10 +224,9 @@ class ComposeUIViewSizingTest { waitForExpectedSize(context, expandedExpected, "initial bounded proposal state") expanded.value = false - println("Change expanded to false") waitForIdle() - waitForExpectedSize(context, collapsedExpected, "expanded bounded proposal state") + waitForExpectedSize(context, collapsedExpected, "collapsed bounded proposal state") } } @@ -247,15 +257,23 @@ class ComposeUIViewSizingTest { } } + private fun runComposeUIViewSizingTest( + testBlock: UIKitInstrumentedTest.() -> Unit + ) { + when (host) { + ComposeUIViewHost.HostingView -> runUIKitInstrumentedTestInHostingView(testBlock) + ComposeUIViewHost.HostingViewController -> + runUIKitInstrumentedTestInHostingViewController(testBlock) + } + } + private fun runComposeUIViewSizingTest( content: @Composable () -> Unit, runTest: UIKitInstrumentedTest.(SwiftUISimulationContext) -> Unit - ) = runUIKitInstrumentedTestInHostingView { + ) = runComposeUIViewSizingTest { var composeSceneSize: DpSize? = null - setContent( - waitForIdle = false - ) { + val columnContent = @Composable { Column( modifier = Modifier.onGloballyPositioned { coordinates -> composeSceneSize = coordinates.boundsInWindow().toDpRect(density).size @@ -264,25 +282,59 @@ class ComposeUIViewSizingTest { ) } + val rootViewController = UIViewController() + + val composeHostView = if (useHostingView) { + val hostingView = createHostingView(content = columnContent).also { + rootViewController.view.addSubview(it) + } + ComposeHostView( + view = hostingView, + setIntrinsicContentSizeInvalidationHandler = { + hostingView.onIntrinsicContentSizeInvalidated = it + } + ) + } else { + val hostingViewController = createHostingViewController(content = columnContent).also { + rootViewController.addChildViewController(it) + rootViewController.view.addSubview(it.view) + it.didMoveToParentViewController(rootViewController) + } + ComposeHostView( + view = hostingViewController.view, + setIntrinsicContentSizeInvalidationHandler = { + hostingViewController.onIntrinsicContentSizeInvalidated = it + } + ) + } + + appDelegate.setUpWindow(rootViewController) + this.runTest( SwiftUISimulationContext( - hostingView!!, - { composeSceneSize } + composeHostView = composeHostView, + getComposeContentSize = { composeSceneSize } ) ) } + private class ComposeHostView( + val view: UIView, + val setIntrinsicContentSizeInvalidationHandler: (() -> Unit) -> Unit + ) + private class SwiftUISimulationContext( - val composeView: ComposeHostingView, + val composeHostView: ComposeHostView, private val getComposeContentSize: () -> DpSize? ) { + val composeView: UIView get() = composeHostView.view val composeContentSize: DpSize? get() = getComposeContentSize() val composeUIViewSize: DpSize get() = composeView.frame.dpSize() private var lastSwiftUIConstraints: CValue? = null init { - composeView.onIntrinsicContentSizeInvalidated = { + composeHostView.setIntrinsicContentSizeInvalidationHandler { if (lastSwiftUIConstraints != null) { proposeSwiftUIConstraints(lastSwiftUIConstraints!!) } @@ -295,7 +347,7 @@ class ComposeUIViewSizingTest { composeView.applyFrame(sizeThatFits) } - private fun ComposeHostingView.applyFrame(size: CValue) { + private fun UIView.applyFrame(size: CValue) { size.useContents { setFrame(CGRectMake(0.0, 0.0, width, height)) } @@ -328,8 +380,17 @@ class ComposeUIViewSizingTest { context.composeUIViewSize == expected } } catch (e: Throwable) { - println("composeContentSize ${context.composeContentSize}, composeUIViewSize ${context.composeUIViewSize}, expected $expected") + println( + "composeContentSize ${context.composeContentSize}, " + + "composeUIViewSize ${context.composeUIViewSize}, expected $expected" + ) throw e } } } + +internal class ComposeUIViewSizingInHostingViewTest : + BaseComposeUIViewSizingTest(ComposeUIViewHost.HostingView) + +internal class ComposeUIViewSizingInHostingViewControllerTest : + BaseComposeUIViewSizingTest(ComposeUIViewHost.HostingViewController) From f523ff7d89ced810e03fdad16e8dd01109a6ceee Mon Sep 17 00:00:00 2001 From: svastven Date: Thu, 4 Jun 2026 00:25:03 +0200 Subject: [PATCH 16/30] Add UIKit host sizing test hooks --- .../ui/scene/ComposeHostingView.ios.kt | 9 +-- .../scene/ComposeHostingViewController.ios.kt | 9 ++- .../compose/ui/test/UIKitInstrumentedTest.kt | 70 +++++++++++-------- 3 files changed, 52 insertions(+), 36 deletions(-) diff --git a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeHostingView.ios.kt b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeHostingView.ios.kt index ae81003a1de85..afc4fc882e58d 100644 --- a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeHostingView.ios.kt +++ b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeHostingView.ios.kt @@ -50,7 +50,11 @@ internal class ComposeHostingView( content = content, coroutineContext = coroutineContext, lifecycleDelegate = lifecycleDelegate - ) + ).also { + it.view.onIntrinsicContentSizeInvalidated = { + invalidateIntrinsicContentSize() + } + } // Used for testing val rootRedrawer: MetalRedrawer? get() = container.view.redrawer @@ -63,9 +67,6 @@ internal class ComposeHostingView( addSubview(container.view) clipsToBounds = true opaque = configuration.opaque - container.view.onIntrinsicContentSizeInvalidated = { - invalidateIntrinsicContentSize() - } } override fun sizeThatFits(size: CValue): CValue { diff --git a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeHostingViewController.ios.kt b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeHostingViewController.ios.kt index 14dfa143e5290..6903643529f32 100644 --- a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeHostingViewController.ios.kt +++ b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeHostingViewController.ios.kt @@ -53,11 +53,18 @@ internal class ComposeHostingViewController( content = content, coroutineContext = coroutineContext, lifecycleDelegate = lifecycleDelegate - ) + ).also { + it.view.onIntrinsicContentSizeInvalidated = { + onIntrinsicContentSizeInvalidated?.invoke() + } + } // Used for testing val rootRedrawer: MetalRedrawer? get() = container.view.redrawer fun hasInvalidations(): Boolean = container.hasInvalidations() + // Invoked when this hosting view invalidates its intrinsic size. + // Kept as an internal test hook to assert SwiftUI/UIKit relayout signaling. + var onIntrinsicContentSizeInvalidated: (() -> Unit)? = null @Suppress("DEPRECATION") override fun preferredStatusBarStyle(): UIStatusBarStyle = diff --git a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt index a3fe7482210c9..81d2af3e913d2 100644 --- a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt @@ -215,7 +215,7 @@ internal fun runUIKitInstrumentedTest( */ @OptIn(ExperimentalForeignApi::class) internal class UIKitInstrumentedTest( - private val useHostingView: Boolean + val useHostingView: Boolean ) { companion object { fun delay(timeoutMillis: Long) { @@ -286,7 +286,6 @@ internal class UIKitInstrumentedTest( fun setContent( configure: ComposeContainerConfiguration.() -> Unit = {}, interfaceOrientation: UIInterfaceOrientation = UIInterfaceOrientationPortrait, - waitForIdle: Boolean = true, content: @Composable () -> Unit ) { accessibilityNotifications.clear() @@ -295,44 +294,53 @@ internal class UIKitInstrumentedTest( } val rootViewController: UIViewController = if (useHostingView) { - UIViewController() - } else { - val configuration = ComposeUIViewControllerConfiguration() - .apply({ enforceStrictPlistSanityCheck = false }) - .apply(configure) - - ComposeHostingViewController( - configuration = configuration, - content = content, - coroutineContext = coroutineContext - ).also { - hostingViewController = it + UIViewController().also { + it.view.embedSubview(createHostingView(configure, content)) } + } else { + createHostingViewController(configure, content) } appDelegate.setUpWindow(rootViewController) - if (useHostingView) { - val configuration = ComposeUIViewConfiguration() - .apply({ enforceStrictPlistSanityCheck = false }) - .apply(configure) - - val hostingView = ComposeHostingView( - configuration = configuration, - content = content, - coroutineContext = coroutineContext - ) - this.hostingView = hostingView + waitForIdle() - rootViewController.view.embedSubview(hostingView) + if (appDelegate.requestInterfaceOrientationChangeIfNeeded(interfaceOrientation)) { + delay(700) } + } - if (waitForIdle) { - waitForIdle() + fun createHostingView( + configure: ComposeUIViewConfiguration.() -> Unit = {}, + content: @Composable () -> Unit + ): ComposeHostingView { + val configuration = ComposeUIViewConfiguration() + .apply({ enforceStrictPlistSanityCheck = false }) + .apply(configure) + + return ComposeHostingView( + configuration = configuration, + content = content, + coroutineContext = coroutineContext + ).also { + hostingView = it } + } - if (appDelegate.requestInterfaceOrientationChangeIfNeeded(interfaceOrientation)) { - delay(700) + fun createHostingViewController( + configure: ComposeUIViewControllerConfiguration.() -> Unit = {}, + content: @Composable () -> Unit + ): ComposeHostingViewController { + val configuration = ComposeUIViewControllerConfiguration() + .apply({ enforceStrictPlistSanityCheck = false }) + .apply(configure) + + return ComposeHostingViewController( + configuration = configuration, + content = content, + coroutineContext = coroutineContext + ).also { + this.hostingViewController = it } } @@ -777,4 +785,4 @@ internal fun UIKitInstrumentedTest.waitForContextMenu() { } != null } delay(500) // wait for toolbar animation -} \ No newline at end of file +} From 69a4248e6b3b72f7bbcae8b41f8234956ef737ec Mon Sep 17 00:00:00 2001 From: svastven Date: Thu, 4 Jun 2026 01:44:04 +0200 Subject: [PATCH 17/30] Use WeakReference in lambda to prevent retain cycle --- .../compose/ui/scene/ComposeHostingView.ios.kt | 2 +- .../ui/scene/ComposeHostingViewController.ios.kt | 2 +- .../compose/ui/window/ComposeContainerView.ios.kt | 14 ++++++++++++-- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeHostingView.ios.kt b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeHostingView.ios.kt index afc4fc882e58d..9829e46b3bc28 100644 --- a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeHostingView.ios.kt +++ b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeHostingView.ios.kt @@ -51,7 +51,7 @@ internal class ComposeHostingView( coroutineContext = coroutineContext, lifecycleDelegate = lifecycleDelegate ).also { - it.view.onIntrinsicContentSizeInvalidated = { + it.view.setIntrinsicContentSizeInvalidationHandler(this) { invalidateIntrinsicContentSize() } } diff --git a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeHostingViewController.ios.kt b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeHostingViewController.ios.kt index 6903643529f32..c617c00e33fe0 100644 --- a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeHostingViewController.ios.kt +++ b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeHostingViewController.ios.kt @@ -54,7 +54,7 @@ internal class ComposeHostingViewController( coroutineContext = coroutineContext, lifecycleDelegate = lifecycleDelegate ).also { - it.view.onIntrinsicContentSizeInvalidated = { + it.view.setIntrinsicContentSizeInvalidationHandler(this) { onIntrinsicContentSizeInvalidated?.invoke() } } diff --git a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/window/ComposeContainerView.ios.kt b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/window/ComposeContainerView.ios.kt index 2e2a58f7b320a..63faa18e7ba41 100644 --- a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/window/ComposeContainerView.ios.kt +++ b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/window/ComposeContainerView.ios.kt @@ -16,6 +16,7 @@ package androidx.compose.ui.window +import androidx.compose.ui.node.WeakReference import androidx.compose.ui.unit.toDpSize import kotlin.math.max import kotlinx.cinterop.CValue @@ -55,10 +56,9 @@ internal class ComposeContainerView( private var onWillMoveToWindow: (UIWindow?) -> Unit = {} private var onLayoutSubviews: () -> Unit = {} private var foregroundStateListener: SceneForegroundStateListener? = null - + private var onIntrinsicContentSizeInvalidated: (() -> Unit)? = null var onSizeThatFits: (CValue) -> CValue? = { null } var onIntrinsicContentSize: () -> CValue? = { null } - var onIntrinsicContentSizeInvalidated: (() -> Unit)? = null val redrawer: MetalRedrawer? get() = metalView?.redrawer @@ -291,4 +291,14 @@ internal class ComposeContainerView( this.drawViewHierarchyInRect(bounds, false) } } + + fun setIntrinsicContentSizeInvalidationHandler( + owner: T, + handler: T.() -> Unit + ) { + val ownerRef = WeakReference(owner) + onIntrinsicContentSizeInvalidated = { + ownerRef.get()?.handler() + } + } } From 1ef163af57636de468ae67394d4912967d6bbef1 Mon Sep 17 00:00:00 2001 From: svastven Date: Thu, 4 Jun 2026 22:39:43 +0200 Subject: [PATCH 18/30] Rename constrainedSceneSize to measureSceneSize --- .../kotlin/androidx/compose/ui/scene/ComposeContainer.ios.kt | 2 +- .../androidx/compose/ui/scene/ComposeSceneMediator.ios.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeContainer.ios.kt b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeContainer.ios.kt index cca00b8e57e07..0dfc064de5007 100644 --- a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeContainer.ios.kt +++ b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeContainer.ios.kt @@ -127,7 +127,7 @@ internal class ComposeContainer( private val composeSceneSizeSynchronizer: ComposeSceneSizeSynchronizer = ComposeSceneSizeSynchronizer( view = view, - composeSceneSize = { mediator?.constrainedSceneSize(it) }, + composeSceneSize = { mediator?.measureSceneSize(it) }, invalidateComposeSceneContainerSize = view::invalidateIntrinsicContentSize ) diff --git a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.ios.kt b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.ios.kt index 2daa685d446a0..8a8d50bd10d09 100644 --- a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.ios.kt +++ b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.ios.kt @@ -764,7 +764,7 @@ internal class ComposeSceneMediator( fun registerOnLayoutCompletedListener(listener: () -> Unit): OnLayoutCompletedListenerHandle = scene.registerOnLayoutCompletedListener(listener) - fun constrainedSceneSize(constraints: Constraints): IntSize = scene.constrainedSize(constraints) + fun measureSceneSize(constraints: Constraints): IntSize = scene.measureContent(constraints) /** * Converts [UIPress] objects to [KeyEvent] and dispatches them to the appropriate handlers. From 0ce3a5af0ad996f7b88f44683dcdd60e6c45265a Mon Sep 17 00:00:00 2001 From: svastven Date: Mon, 8 Jun 2026 13:37:34 +0200 Subject: [PATCH 19/30] Move onLayoutCompleted handling from RootNodeOwner to platform ComposeSceneMediator --- .../compose/ui/scene/ComposeContainer.ios.kt | 12 +-- .../ui/scene/ComposeSceneMediator.ios.kt | 17 ++-- .../compose/ui/node/RootNodeOwner.skiko.kt | 90 +------------------ .../scene/CanvasLayersComposeScene.skiko.kt | 6 -- .../compose/ui/scene/ComposeScene.skiko.kt | 10 --- .../scene/PlatformLayersComposeScene.skiko.kt | 6 -- .../scene/SingleComposeSceneRenderingScope.kt | 2 + 7 files changed, 18 insertions(+), 125 deletions(-) diff --git a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeContainer.ios.kt b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeContainer.ios.kt index 0dfc064de5007..0e04fd12bf91e 100644 --- a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeContainer.ios.kt +++ b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeContainer.ios.kt @@ -24,7 +24,6 @@ import androidx.compose.ui.LocalSystemTheme import androidx.compose.ui.SystemTheme import androidx.compose.ui.graphics.asComposeCanvas import androidx.compose.ui.navigationevent.UIKitNavigationEventInput -import androidx.compose.ui.node.OnLayoutCompletedListenerHandle import androidx.compose.ui.platform.DefaultArchitectureComponentsOwner import androidx.compose.ui.platform.FrameRecomposer import androidx.compose.ui.platform.MotionDurationScaleImpl @@ -144,8 +143,6 @@ internal class ComposeContainer( private val focusedViewsList = FocusedViewsList() - private var onLayoutCompletedListenerHandle: OnLayoutCompletedListenerHandle? = null - init { if (configuration.enforceStrictPlistSanityCheck) { PlistSanityCheck.performIfNeeded() @@ -273,9 +270,8 @@ internal class ComposeContainer( } } - onLayoutCompletedListenerHandle?.unregister() - onLayoutCompletedListenerHandle = composeSceneSizeSynchronizer?.let { - mediator?.registerOnLayoutCompletedListener(it::onComposeLayoutCompleted) + mediator?.setOnLayoutCompletedListener { + composeSceneSizeSynchronizer.onComposeLayoutCompleted() } activeStateListener = SceneActiveStateListener( @@ -306,9 +302,7 @@ internal class ComposeContainer( navigationEventInput.onDidMoveToWindow(null, view) architectureComponentsOwner.navigationEventDispatcher.removeInput(navigationEventInput) - onLayoutCompletedListenerHandle?.unregister() - onLayoutCompletedListenerHandle = null - + mediator?.setOnLayoutCompletedListener(null) mediator = null activeStateListener?.dispose() diff --git a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.ios.kt b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.ios.kt index 8a8d50bd10d09..1994b36158bd1 100644 --- a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.ios.kt +++ b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.ios.kt @@ -46,7 +46,6 @@ import androidx.compose.ui.input.pointer.PointerId import androidx.compose.ui.input.pointer.PointerType import androidx.compose.ui.layout.OffsetToFocusedRect import androidx.compose.ui.navigationevent.UIKitNavigationEventInput -import androidx.compose.ui.node.OnLayoutCompletedListenerHandle import androidx.compose.ui.platform.AccessibilityMediator import androidx.compose.ui.platform.CUPERTINO_TOUCH_SLOP import androidx.compose.ui.platform.DefaultInputModeManager @@ -208,8 +207,8 @@ internal class ComposeSceneMediator( ) -> ComposeScene, ) { private var onPreviewKeyEvent: (KeyEvent) -> Boolean = { false } - private var onKeyEvent: (KeyEvent) -> Boolean = { false } + private var onLayoutCompleted: (() -> Unit) = {} private var animateKeyboardOffsetChanges by mutableStateOf(false) private var platformScreenReader = object : PlatformScreenReader { override var isActive by mutableStateOf(false) @@ -238,7 +237,10 @@ internal class ComposeSceneMediator( // by platform view invalidation (which is triggered by [scene.invalidateLayout] OR by regular platform invalidation) // - [scene.draw] during drawing phase of platform views (which is triggered by [scene.invalidateDraw]). // Note that in case of custom GPU surface/V-Sync handling, it needs to be handled differently. - private val sceneRenderingScope = SingleComposeSceneRenderingScope(redrawer::setNeedsRedraw) + private val sceneRenderingScope = SingleComposeSceneRenderingScope( + redrawer::setNeedsRedraw, + onDidLayout = { onLayoutCompleted() } + ) private val scene: ComposeScene by lazy { composeSceneFactory( @@ -761,8 +763,13 @@ internal class ComposeSceneMediator( this.onKeyEvent = onKeyEvent ?: { false } } - fun registerOnLayoutCompletedListener(listener: () -> Unit): OnLayoutCompletedListenerHandle = - scene.registerOnLayoutCompletedListener(listener) + /** + * Sets the single callback invoked after the scene measure/layout phase during rendering. + * Passing `null` clears the current callback. + */ + fun setOnLayoutCompletedListener(listener: (() -> Unit)?) { + onLayoutCompleted = listener ?: {} + } fun measureSceneSize(constraints: Constraints): IntSize = scene.measureContent(constraints) diff --git a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/node/RootNodeOwner.skiko.kt b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/node/RootNodeOwner.skiko.kt index f2e01d7a969bd..5dd37c1b05354 100644 --- a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/node/RootNodeOwner.skiko.kt +++ b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/node/RootNodeOwner.skiko.kt @@ -161,7 +161,6 @@ internal class RootNodeOwner( private val ownedLayerManager = OwnedLayerManagerImpl() private val pointerInputEventProcessor = PointerInputEventProcessor(owner.root) private val measureAndLayoutDelegate = MeasureAndLayoutDelegate(owner.root) - private var layoutCompletedManager: LayoutCompletedManager? = null private var isDisposed = false private var positionInWindow: Offset? = null @@ -230,46 +229,7 @@ internal class RootNodeOwner( return block(owner.root) } finally { - val hadPendingBeforeRestore = measureAndLayoutDelegate.hasPendingMeasureOrLayout - val restoreConstraints = size.toMaxConstraints() - val constraintsChanged = restoreConstraints != constraints - - measureAndLayoutDelegate.updateRootConstraints(restoreConstraints) - - val hasPendingAfterRestore = measureAndLayoutDelegate.hasPendingMeasureOrLayout - if (constraintsChanged && !hadPendingBeforeRestore && hasPendingAfterRestore) { - // Probe measurement temporarily swaps root constraints. Restoring them may enqueue - // a rebound layout pass; suppress its layout-complete dispatch to avoid re-entrancy - // for observers that initiated this probe measurement. - layoutCompletedManager?.suppressNextDispatch() - } - } - } - - /** - * Registers an additional persistent layout-complete listener. - * - * Unlike [MeasureAndLayoutDelegate.registerOnLayoutCompletedListener], this listener is not - * one-shot: it remains active for later layout passes until the returned handle is - * unregistered. - */ - fun registerOnLayoutCompletedListener(listener: () -> Unit): OnLayoutCompletedListenerHandle { - if (layoutCompletedManager == null) { - layoutCompletedManager = LayoutCompletedManager() - } - layoutCompletedManager?.registerListener(listener) - - return object : OnLayoutCompletedListenerHandle { - private var isUnregistered = false - - override fun unregister() { - if (isUnregistered) return - isUnregistered = true - layoutCompletedManager?.deregisterListener(listener) - if (layoutCompletedManager?.isEmpty == true) { - layoutCompletedManager = null - } - } + measureAndLayoutDelegate.updateRootConstraints(size.toMaxConstraints()) } } @@ -604,7 +564,6 @@ internal class RootNodeOwner( } measureAndLayoutDelegate.dispatchOnPositionedCallbacks() rectManager.dispatchCallbacks() - layoutCompletedManager?.dispatch() } } } @@ -620,7 +579,6 @@ internal class RootNodeOwner( measureAndLayoutDelegate.dispatchOnPositionedCallbacks() } rectManager.dispatchCallbacks() - layoutCompletedManager?.dispatch() } } @@ -1112,49 +1070,3 @@ private class RootPlatformWindowInsetsProviderNode( } } } - -@InternalComposeUiApi -fun interface OnLayoutCompletedListenerHandle { - /** Unregisters the associated listener. Calling this multiple times is a no-op. */ - fun unregister() -} - -private class LayoutCompletedManager { - private var suppressed: Int = 0 - private var listeners = mutableVectorOf<(() -> Unit)?>() - private var activeListenersCount: Int = 0 - val isEmpty: Boolean get() = activeListenersCount == 0 - - fun suppressNextDispatch() { - suppressed++ - } - - fun registerListener(listener: () -> Unit) { - // Use referential equality: multiple distinct function instances may be "equal" but - // must still be treated as separate registrations. - for (i in 0 until listeners.size) { - if (listeners[i] === listener) return - } - - listeners += listener - activeListenersCount++ - } - - fun deregisterListener(listener: () -> Unit) { - for (i in 0 until listeners.size) { - if (listeners[i] === listener) { - listeners[i] = null - activeListenersCount-- - break - } - } - } - - fun dispatch() { - if (suppressed > 0) { - suppressed-- - return - } - listeners.forEach { it?.invoke() } - } -} diff --git a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/scene/CanvasLayersComposeScene.skiko.kt b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/scene/CanvasLayersComposeScene.skiko.kt index 29cb11ecf109d..aa4abceae4817 100644 --- a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/scene/CanvasLayersComposeScene.skiko.kt +++ b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/scene/CanvasLayersComposeScene.skiko.kt @@ -36,7 +36,6 @@ import androidx.compose.ui.input.pointer.PointerEventType import androidx.compose.ui.input.pointer.PointerInputEvent import androidx.compose.ui.input.pointer.PointerType import androidx.compose.ui.input.rotary.RotaryScrollEvent -import androidx.compose.ui.node.OnLayoutCompletedListenerHandle import androidx.compose.ui.node.RootNodeOwner import androidx.compose.ui.platform.FrameRecomposer import androidx.compose.ui.platform.PlatformContext @@ -217,11 +216,6 @@ private class CanvasLayersComposeSceneImpl( return IntSize(width, height) } - override fun registerOnLayoutCompletedListener(listener: () -> Unit): OnLayoutCompletedListenerHandle { - check(!isClosed) { "registerOnLayoutCompletedListener called after ComposeScene is closed" } - return mainOwner.registerOnLayoutCompletedListener(listener) - } - override fun invalidatePositionInWindow() { check(!isClosed) { "invalidatePositionInWindow called after ComposeScene is closed" } mainOwner.invalidatePositionInWindow() diff --git a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/scene/ComposeScene.skiko.kt b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/scene/ComposeScene.skiko.kt index fcf23c478c3d2..7485c7b0db743 100644 --- a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/scene/ComposeScene.skiko.kt +++ b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/scene/ComposeScene.skiko.kt @@ -39,7 +39,6 @@ import androidx.compose.ui.input.pointer.PointerType import androidx.compose.ui.input.rotary.RotaryScrollEvent import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.node.LayoutNode -import androidx.compose.ui.node.OnLayoutCompletedListenerHandle import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.platform.PlatformContext import androidx.compose.ui.platform.PlatformDragAndDropManager @@ -139,15 +138,6 @@ sealed interface ComposeScene : AutoCloseable { */ fun measureContent(constraints: Constraints): IntSize - /** - * Registers [listener] to be invoked after each completed measure/layout pass of the scene. - * - * Returns a [OnLayoutCompletedListenerHandle] that **must** be unregistered to unregister - * the listener. This is important for platform integrations to avoid retain cycles between - * a hosting container and the scene/root. - */ - fun registerOnLayoutCompletedListener(listener: () -> Unit): OnLayoutCompletedListenerHandle - /** * Invalidates position of [ComposeScene] in the window. It will trigger callbacks like * [Modifier.onGloballyPositioned] so they can recalculate actual position in the window. 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 61147d5f2f21a..bb4c71ba4645a 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 @@ -26,7 +26,6 @@ import androidx.compose.ui.input.key.KeyEvent import androidx.compose.ui.input.pointer.PointerInputEvent import androidx.compose.ui.input.rotary.RotaryScrollEvent import androidx.compose.ui.node.LayoutNode -import androidx.compose.ui.node.OnLayoutCompletedListenerHandle import androidx.compose.ui.node.RootNodeOwner import androidx.compose.ui.platform.FrameRecomposer import androidx.compose.ui.platform.setContent @@ -151,11 +150,6 @@ private class PlatformLayersComposeSceneImpl( return mainOwner.measureContentWithConstraints(constraints) } - override fun registerOnLayoutCompletedListener(listener: () -> Unit): OnLayoutCompletedListenerHandle { - check(!isClosed) { "registerOnLayoutCompletedListener called after ComposeScene is closed" } - return mainOwner.registerOnLayoutCompletedListener(listener) - } - override fun invalidatePositionInWindow() { check(!isClosed) { "invalidatePositionInWindow called after ComposeScene is closed" } mainOwner.invalidatePositionInWindow() diff --git a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/scene/SingleComposeSceneRenderingScope.kt b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/scene/SingleComposeSceneRenderingScope.kt index 0ae363cefd3aa..e5cb33ce38fba 100644 --- a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/scene/SingleComposeSceneRenderingScope.kt +++ b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/scene/SingleComposeSceneRenderingScope.kt @@ -42,6 +42,7 @@ import androidx.compose.ui.platform.FrameRecomposer @InternalComposeUiApi class SingleComposeSceneRenderingScope( private val scheduleFrame: () -> Unit, + private val onDidLayout: () -> Unit = {}, ) { private var isRendering = false @@ -74,6 +75,7 @@ class SingleComposeSceneRenderingScope( postponingSceneInvalidations { frameRecomposer.performFrame(nanoTime) measureAndLayout() + onDidLayout() draw(canvas) } if (hasInvalidations()) { From 6eda5ea2262e859671873fbe0fa6897c0e52d14f Mon Sep 17 00:00:00 2001 From: svastven Date: Mon, 8 Jun 2026 13:47:55 +0200 Subject: [PATCH 20/30] Add named scheduleFrame parameter --- .../androidx/compose/ui/scene/ComposeSceneMediator.desktop.kt | 2 +- .../kotlin/androidx/compose/ui/platform/RenderingTestScope.kt | 2 +- .../androidx/compose/ui/scene/ComposeSceneMediator.ios.kt | 2 +- .../kotlin/androidx/compose/ui/window/ComposeWindow.macos.kt | 2 +- .../androidx/compose/ui/window/ComposeWindowInternal.web.kt | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) 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 496c9a1a48970..bb4aeff6b8455 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 @@ -164,7 +164,7 @@ internal class ComposeSceneMediator( // by platform view invalidation (which is triggered by [scene.invalidateLayout] OR by regular platform invalidation) // - [scene.draw] during drawing phase of platform views (which is triggered by [scene.invalidateDraw]). // Note that in case of custom GPU surface/V-Sync handling, it needs to be handled differently. - private val sceneRenderingScope = SingleComposeSceneRenderingScope(::needRender) + private val sceneRenderingScope = SingleComposeSceneRenderingScope(scheduleFrame = ::needRender) private val _platformContext = DesktopPlatformContext() val platformContext: PlatformContext get() = _platformContext diff --git a/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/platform/RenderingTestScope.kt b/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/platform/RenderingTestScope.kt index d501353734333..0f6436efc9596 100644 --- a/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/platform/RenderingTestScope.kt +++ b/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/platform/RenderingTestScope.kt @@ -58,7 +58,7 @@ internal class RenderingTestScope( onRender(currentTimeMillis * 1_000_000) } private val frameRecomposer = FrameRecomposer(coroutineContext, frameDispatcher::scheduleFrame) - private val sceneRenderingScope = SingleComposeSceneRenderingScope(frameDispatcher::scheduleFrame) + private val sceneRenderingScope = SingleComposeSceneRenderingScope(scheduleFrame = frameDispatcher::scheduleFrame) val surface: Surface = Surface.makeRasterN32Premul(width, height) private val canvas = surface.canvas.asComposeCanvas() diff --git a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.ios.kt b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.ios.kt index 1994b36158bd1..f03bb2b95b0ab 100644 --- a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.ios.kt +++ b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.ios.kt @@ -238,7 +238,7 @@ internal class ComposeSceneMediator( // - [scene.draw] during drawing phase of platform views (which is triggered by [scene.invalidateDraw]). // Note that in case of custom GPU surface/V-Sync handling, it needs to be handled differently. private val sceneRenderingScope = SingleComposeSceneRenderingScope( - redrawer::setNeedsRedraw, + scheduleFrame = redrawer::setNeedsRedraw, onDidLayout = { onLayoutCompleted() } ) diff --git a/compose/ui/ui/src/macosMain/kotlin/androidx/compose/ui/window/ComposeWindow.macos.kt b/compose/ui/ui/src/macosMain/kotlin/androidx/compose/ui/window/ComposeWindow.macos.kt index aa6ebc2aa3349..a0a66adc76e6e 100644 --- a/compose/ui/ui/src/macosMain/kotlin/androidx/compose/ui/window/ComposeWindow.macos.kt +++ b/compose/ui/ui/src/macosMain/kotlin/androidx/compose/ui/window/ComposeWindow.macos.kt @@ -109,7 +109,7 @@ private class ComposeWindow( // by platform view invalidation (which is triggered by [scene.invalidateLayout] OR by regular platform invalidation) // - [scene.draw] during drawing phase of platform views (which is triggered by [scene.invalidateDraw]). // Note that in case of custom GPU surface/V-Sync handling, it needs to be handled differently. - private val sceneRenderingScope = SingleComposeSceneRenderingScope { skiaLayer.needRender() } + private val sceneRenderingScope = SingleComposeSceneRenderingScope(scheduleFrame = { skiaLayer.needRender() }) private val platformContext: PlatformContext = object : PlatformContext by PlatformContext.Empty() { diff --git a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/window/ComposeWindowInternal.web.kt b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/window/ComposeWindowInternal.web.kt index 88e2b0139c056..618907726bdbc 100644 --- a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/window/ComposeWindowInternal.web.kt +++ b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/window/ComposeWindowInternal.web.kt @@ -230,7 +230,7 @@ internal class ComposeWindow( // by platform view invalidation (which is triggered by [scene.invalidateLayout] OR by regular platform invalidation) // - [scene.draw] during drawing phase of platform views (which is triggered by [scene.invalidateDraw]). // Note that in case of custom GPU surface/V-Sync handling, it needs to be handled differently. - private val sceneRenderingScope = SingleComposeSceneRenderingScope { skiaLayer.needRender() } + private val sceneRenderingScope = SingleComposeSceneRenderingScope(scheduleFrame = { skiaLayer.needRender() }) private val platformContext: PlatformContext = object : PlatformContext by PlatformContext.Empty() { From 779c988737fa18eedbe7bab04ddacaa7f5641617 Mon Sep 17 00:00:00 2001 From: svastven Date: Mon, 8 Jun 2026 13:54:29 +0200 Subject: [PATCH 21/30] Remove unused constraints infinity check --- .../compose/ui/node/RootNodeOwner.skiko.kt | 44 ------------------- 1 file changed, 44 deletions(-) diff --git a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/node/RootNodeOwner.skiko.kt b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/node/RootNodeOwner.skiko.kt index 5dd37c1b05354..b4179fb69373f 100644 --- a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/node/RootNodeOwner.skiko.kt +++ b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/node/RootNodeOwner.skiko.kt @@ -996,50 +996,6 @@ internal class RootNodeOwner( private fun IntSize?.toMaxConstraints() = if (this == null) Constraints() else Constraints(maxWidth = width, maxHeight = height) -// TODO a proper way is to provide API in Constraints to get this value -/** - * Equals [Constraints.MinNonFocusMask] - */ -private const val ConstraintsMinNonFocusMask = 0x7FFF // 32767 - -/** - * The max value that can be passed as Constraints(0, LargeDimension, 0, LargeDimension) - * - * Greater values cause "Can't represent a width of". - * See [Constraints.createConstraints] and [Constraints.bitsNeedForSize]: - * - it fails if `widthBits + heightBits > 31` - * - widthBits/heightBits are greater than 15 if we pass size >= [Constraints.MinNonFocusMask] - */ -internal const val LargeDimension = ConstraintsMinNonFocusMask - 1 - -/** - * After https://android-review.googlesource.com/c/platform/frameworks/support/+/2901556 - * Compose core doesn't allow measuring in infinity constraints, - * but RootNodeOwner and ComposeScene allow passing Infinity constraints by contract - * (Android on the other hand doesn't have public API for that and don't have such an issue). - * - * This method adds additional check on Infinity constraints, - * and pass constraint large enough instead - */ -private fun MeasureAndLayoutDelegate.updateRootConstraintsWithInfinityCheck( - constraints: Constraints? -) { - updateRootConstraints(constraints = constraints.withInfinityCheck()) -} - -private fun Constraints?.withInfinityCheck(): Constraints = - if (this == null) - Constraints(0, LargeDimension, 0, LargeDimension) - else - Constraints( - minWidth = minWidth, - maxWidth = if (hasBoundedWidth) maxWidth else LargeDimension, - minHeight = minHeight, - maxHeight = if (hasBoundedHeight) maxHeight else LargeDimension - ) - -private fun IntSize.toConstraints() = Constraints(maxWidth = width, maxHeight = height) - private object IdentityPositionCalculator : PositionCalculator { override fun screenToLocal(positionOnScreen: Offset): Offset = positionOnScreen override fun localToScreen(localPosition: Offset): Offset = localPosition From 79e016d8c2e22f018aa48f509ed639dbc1538c6b Mon Sep 17 00:00:00 2001 From: svastven Date: Mon, 8 Jun 2026 13:57:13 +0200 Subject: [PATCH 22/30] Move return outside try block --- .../kotlin/androidx/compose/ui/node/RootNodeOwner.skiko.kt | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/node/RootNodeOwner.skiko.kt b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/node/RootNodeOwner.skiko.kt index b4179fb69373f..94987166ead7e 100644 --- a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/node/RootNodeOwner.skiko.kt +++ b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/node/RootNodeOwner.skiko.kt @@ -222,12 +222,11 @@ internal class RootNodeOwner( constraints: Constraints, block: (LayoutNode) -> T ): T { - try { + return try { // TODO: is it possible to measure without reassigning root constraints? measureAndLayoutDelegate.updateRootConstraints(constraints) measureAndLayoutDelegate.measureOnly() - - return block(owner.root) + block(owner.root) } finally { measureAndLayoutDelegate.updateRootConstraints(size.toMaxConstraints()) } From e7df1a105431bede611a4a2a1196a35262e91619 Mon Sep 17 00:00:00 2001 From: svastven Date: Mon, 8 Jun 2026 14:47:38 +0200 Subject: [PATCH 23/30] Revert lint changes --- .../kotlin/androidx/compose/ui/scene/ComposeScene.skiko.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/scene/ComposeScene.skiko.kt b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/scene/ComposeScene.skiko.kt index 7485c7b0db743..e20ff20d34c5c 100644 --- a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/scene/ComposeScene.skiko.kt +++ b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/scene/ComposeScene.skiko.kt @@ -326,4 +326,6 @@ fun ComposeScene.hasInvalidations(): Boolean = * size bounds (e.g., LazyColumn without maximum height). */ @InternalComposeUiApi -fun ComposeScene.unconstrainedSize(): IntSize = measureContent(Constraints()) +fun ComposeScene.unconstrainedSize(): IntSize { + return measureContent(Constraints()) +} From bb9ac23d6411835fde3d16be6c04340224351d9a Mon Sep 17 00:00:00 2001 From: svastven Date: Mon, 8 Jun 2026 14:57:53 +0200 Subject: [PATCH 24/30] Revert doc comment for measuringRootWithConstraints --- .../kotlin/androidx/compose/ui/node/RootNodeOwner.skiko.kt | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/node/RootNodeOwner.skiko.kt b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/node/RootNodeOwner.skiko.kt index 94987166ead7e..031973900f69e 100644 --- a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/node/RootNodeOwner.skiko.kt +++ b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/node/RootNodeOwner.skiko.kt @@ -212,11 +212,8 @@ internal class RootNodeOwner( } /** - * Runs a temporary *measure-only* pass for the root under the provided [constraints], then - * executes [block] while those constraints are in effect. - * - * This is a probe measurement: it does not place nodes and does not dispatch draw/pointer - * callbacks. + * Provides a way to measure Owner's content in given [constraints] + * Draw/pointer and other callbacks won't be called here like in [measureAndLayout] functions */ private fun measuringRootWithConstraints( constraints: Constraints, From 3fded9380f7feaff828a8b7062fcf71deb887a10 Mon Sep 17 00:00:00 2001 From: svastven Date: Mon, 8 Jun 2026 15:22:03 +0200 Subject: [PATCH 25/30] Revert "Add named scheduleFrame parameter" This reverts commit 6eda5ea2262e859671873fbe0fa6897c0e52d14f. --- .../androidx/compose/ui/scene/ComposeSceneMediator.desktop.kt | 2 +- .../kotlin/androidx/compose/ui/platform/RenderingTestScope.kt | 2 +- .../androidx/compose/ui/scene/ComposeSceneMediator.ios.kt | 2 +- .../kotlin/androidx/compose/ui/window/ComposeWindow.macos.kt | 2 +- .../androidx/compose/ui/window/ComposeWindowInternal.web.kt | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) 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 bb4aeff6b8455..496c9a1a48970 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 @@ -164,7 +164,7 @@ internal class ComposeSceneMediator( // by platform view invalidation (which is triggered by [scene.invalidateLayout] OR by regular platform invalidation) // - [scene.draw] during drawing phase of platform views (which is triggered by [scene.invalidateDraw]). // Note that in case of custom GPU surface/V-Sync handling, it needs to be handled differently. - private val sceneRenderingScope = SingleComposeSceneRenderingScope(scheduleFrame = ::needRender) + private val sceneRenderingScope = SingleComposeSceneRenderingScope(::needRender) private val _platformContext = DesktopPlatformContext() val platformContext: PlatformContext get() = _platformContext diff --git a/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/platform/RenderingTestScope.kt b/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/platform/RenderingTestScope.kt index 0f6436efc9596..d501353734333 100644 --- a/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/platform/RenderingTestScope.kt +++ b/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/platform/RenderingTestScope.kt @@ -58,7 +58,7 @@ internal class RenderingTestScope( onRender(currentTimeMillis * 1_000_000) } private val frameRecomposer = FrameRecomposer(coroutineContext, frameDispatcher::scheduleFrame) - private val sceneRenderingScope = SingleComposeSceneRenderingScope(scheduleFrame = frameDispatcher::scheduleFrame) + private val sceneRenderingScope = SingleComposeSceneRenderingScope(frameDispatcher::scheduleFrame) val surface: Surface = Surface.makeRasterN32Premul(width, height) private val canvas = surface.canvas.asComposeCanvas() diff --git a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.ios.kt b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.ios.kt index f03bb2b95b0ab..1994b36158bd1 100644 --- a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.ios.kt +++ b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.ios.kt @@ -238,7 +238,7 @@ internal class ComposeSceneMediator( // - [scene.draw] during drawing phase of platform views (which is triggered by [scene.invalidateDraw]). // Note that in case of custom GPU surface/V-Sync handling, it needs to be handled differently. private val sceneRenderingScope = SingleComposeSceneRenderingScope( - scheduleFrame = redrawer::setNeedsRedraw, + redrawer::setNeedsRedraw, onDidLayout = { onLayoutCompleted() } ) diff --git a/compose/ui/ui/src/macosMain/kotlin/androidx/compose/ui/window/ComposeWindow.macos.kt b/compose/ui/ui/src/macosMain/kotlin/androidx/compose/ui/window/ComposeWindow.macos.kt index a0a66adc76e6e..aa6ebc2aa3349 100644 --- a/compose/ui/ui/src/macosMain/kotlin/androidx/compose/ui/window/ComposeWindow.macos.kt +++ b/compose/ui/ui/src/macosMain/kotlin/androidx/compose/ui/window/ComposeWindow.macos.kt @@ -109,7 +109,7 @@ private class ComposeWindow( // by platform view invalidation (which is triggered by [scene.invalidateLayout] OR by regular platform invalidation) // - [scene.draw] during drawing phase of platform views (which is triggered by [scene.invalidateDraw]). // Note that in case of custom GPU surface/V-Sync handling, it needs to be handled differently. - private val sceneRenderingScope = SingleComposeSceneRenderingScope(scheduleFrame = { skiaLayer.needRender() }) + private val sceneRenderingScope = SingleComposeSceneRenderingScope { skiaLayer.needRender() } private val platformContext: PlatformContext = object : PlatformContext by PlatformContext.Empty() { diff --git a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/window/ComposeWindowInternal.web.kt b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/window/ComposeWindowInternal.web.kt index 618907726bdbc..88e2b0139c056 100644 --- a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/window/ComposeWindowInternal.web.kt +++ b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/window/ComposeWindowInternal.web.kt @@ -230,7 +230,7 @@ internal class ComposeWindow( // by platform view invalidation (which is triggered by [scene.invalidateLayout] OR by regular platform invalidation) // - [scene.draw] during drawing phase of platform views (which is triggered by [scene.invalidateDraw]). // Note that in case of custom GPU surface/V-Sync handling, it needs to be handled differently. - private val sceneRenderingScope = SingleComposeSceneRenderingScope(scheduleFrame = { skiaLayer.needRender() }) + private val sceneRenderingScope = SingleComposeSceneRenderingScope { skiaLayer.needRender() } private val platformContext: PlatformContext = object : PlatformContext by PlatformContext.Empty() { From d94f2f5365561841884c12062a2dc468162e7a68 Mon Sep 17 00:00:00 2001 From: svastven Date: Mon, 8 Jun 2026 15:52:06 +0200 Subject: [PATCH 26/30] Create platform specific implementation of the render pipeline --- .../ui/scene/ComposeSceneMediator.ios.kt | 25 ++++++++++++++----- .../scene/SingleComposeSceneRenderingScope.kt | 4 +-- 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.ios.kt b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.ios.kt index 1994b36158bd1..b86941e4f5715 100644 --- a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.ios.kt +++ b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.ios.kt @@ -23,6 +23,7 @@ import androidx.compose.runtime.State import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameNanos import androidx.compose.ui.InternalComposeUiApi import androidx.compose.ui.draganddrop.UIKitDragAndDropManager import androidx.compose.ui.geometry.Offset @@ -237,10 +238,7 @@ internal class ComposeSceneMediator( // by platform view invalidation (which is triggered by [scene.invalidateLayout] OR by regular platform invalidation) // - [scene.draw] during drawing phase of platform views (which is triggered by [scene.invalidateDraw]). // Note that in case of custom GPU surface/V-Sync handling, it needs to be handled differently. - private val sceneRenderingScope = SingleComposeSceneRenderingScope( - redrawer::setNeedsRedraw, - onDidLayout = { onLayoutCompleted() } - ) + private val sceneRenderingScope = SingleComposeSceneRenderingScope(redrawer::setNeedsRedraw) private val scene: ComposeScene by lazy { composeSceneFactory( @@ -643,8 +641,23 @@ internal class ComposeSceneMediator( private var lastRenderTime = CACurrentMediaTime().toNanoSeconds() fun render(canvas: Canvas, nanoTime: Long) { lastRenderTime = nanoTime - with(sceneRenderingScope) { - scene.render(frameRecomposer, canvas, nanoTime) + scene.render(canvas, nanoTime) + } + + /** + * Renders the current content on [canvas]: advances the host [frameRecomposer] by one frame, + * then runs the scene's measure/layout and draw phases. [nanoTime] is the frame time used to + * drive all animations in the content (and any other code using [withFrameNanos]). + */ + private fun ComposeScene.render(canvas: Canvas, nanoTime: Long) { + sceneRenderingScope.postponingSceneInvalidations { + frameRecomposer.performFrame(nanoTime) + measureAndLayout() + onLayoutCompleted() + draw(canvas) + } + if (hasInvalidations()) { + redrawer.setNeedsRedraw() } } diff --git a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/scene/SingleComposeSceneRenderingScope.kt b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/scene/SingleComposeSceneRenderingScope.kt index e5cb33ce38fba..041d7e794268b 100644 --- a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/scene/SingleComposeSceneRenderingScope.kt +++ b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/scene/SingleComposeSceneRenderingScope.kt @@ -42,11 +42,10 @@ import androidx.compose.ui.platform.FrameRecomposer @InternalComposeUiApi class SingleComposeSceneRenderingScope( private val scheduleFrame: () -> Unit, - private val onDidLayout: () -> Unit = {}, ) { private var isRendering = false - private inline fun postponingSceneInvalidations(crossinline block: () -> Unit) { + fun postponingSceneInvalidations(block: () -> Unit) { check(!isRendering) isRendering = true try { @@ -75,7 +74,6 @@ class SingleComposeSceneRenderingScope( postponingSceneInvalidations { frameRecomposer.performFrame(nanoTime) measureAndLayout() - onDidLayout() draw(canvas) } if (hasInvalidations()) { From d0a0393ce57b6861505a718f25dc5822f5284b7a Mon Sep 17 00:00:00 2001 From: svastven Date: Mon, 8 Jun 2026 16:33:34 +0200 Subject: [PATCH 27/30] Keep postponingSceneInvalidations inline --- .../compose/ui/scene/SingleComposeSceneRenderingScope.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/scene/SingleComposeSceneRenderingScope.kt b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/scene/SingleComposeSceneRenderingScope.kt index 041d7e794268b..10f9cbd45fcd9 100644 --- a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/scene/SingleComposeSceneRenderingScope.kt +++ b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/scene/SingleComposeSceneRenderingScope.kt @@ -43,9 +43,10 @@ import androidx.compose.ui.platform.FrameRecomposer class SingleComposeSceneRenderingScope( private val scheduleFrame: () -> Unit, ) { - private var isRendering = false + @PublishedApi + internal var isRendering = false - fun postponingSceneInvalidations(block: () -> Unit) { + inline fun postponingSceneInvalidations(block: () -> Unit) { check(!isRendering) isRendering = true try { From db615667fe921b03f3ecc2f626677d9e74dbfd15 Mon Sep 17 00:00:00 2001 From: svastven Date: Mon, 8 Jun 2026 16:46:32 +0200 Subject: [PATCH 28/30] Add UIKitInstrumentedTestBlock and comment testing method --- .../ui/interop/ComposeUIViewSizingTest.kt | 29 +++++++------------ .../compose/ui/test/UIKitInstrumentedTest.kt | 10 ++++--- 2 files changed, 16 insertions(+), 23 deletions(-) diff --git a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interop/ComposeUIViewSizingTest.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interop/ComposeUIViewSizingTest.kt index 1e0936069578d..88b0a8e958312 100644 --- a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interop/ComposeUIViewSizingTest.kt +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/interop/ComposeUIViewSizingTest.kt @@ -26,6 +26,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.layout.boundsInWindow import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.test.UIKitInstrumentedTest +import androidx.compose.ui.test.UIKitInstrumentedTestBlock import androidx.compose.ui.test.runUIKitInstrumentedTestInHostingView import androidx.compose.ui.test.runUIKitInstrumentedTestInHostingViewController import androidx.compose.ui.unit.DpSize @@ -46,14 +47,9 @@ import platform.UIKit.UIViewNoIntrinsicMetric import platform.UIKit.addChildViewController import platform.UIKit.didMoveToParentViewController -internal enum class ComposeUIViewHost { - HostingView, - HostingViewController -} - @OptIn(ExperimentalForeignApi::class, ExperimentalComposeUiApi::class) internal abstract class BaseComposeUIViewSizingTest( - private val host: ComposeUIViewHost + private val runTest: (UIKitInstrumentedTestBlock) -> Unit ) { private val contentSize = DpSize(200.dp, 100.dp) @@ -257,20 +253,10 @@ internal abstract class BaseComposeUIViewSizingTest( } } - private fun runComposeUIViewSizingTest( - testBlock: UIKitInstrumentedTest.() -> Unit - ) { - when (host) { - ComposeUIViewHost.HostingView -> runUIKitInstrumentedTestInHostingView(testBlock) - ComposeUIViewHost.HostingViewController -> - runUIKitInstrumentedTestInHostingViewController(testBlock) - } - } - private fun runComposeUIViewSizingTest( content: @Composable () -> Unit, runTest: UIKitInstrumentedTest.(SwiftUISimulationContext) -> Unit - ) = runComposeUIViewSizingTest { + ) = runTest { var composeSceneSize: DpSize? = null val columnContent = @Composable { @@ -389,8 +375,13 @@ internal abstract class BaseComposeUIViewSizingTest( } } +// All tests in BaseComposeUIViewSizingTest should be run for both hosts ComposeHostingView and +// ComposeHostingViewController. We need to run each test as a separate XCTest and to achieve this +// we create two implementations of BaseComposeUIViewSizingTest where each implementation uses a +// different way to run the test. This method avoids code duplication but mainly it avoids flaky +// tests because setContent and therefore appDelegate.setUpWindow is only called once for each XCTest. internal class ComposeUIViewSizingInHostingViewTest : - BaseComposeUIViewSizingTest(ComposeUIViewHost.HostingView) + BaseComposeUIViewSizingTest(::runUIKitInstrumentedTestInHostingView) internal class ComposeUIViewSizingInHostingViewControllerTest : - BaseComposeUIViewSizingTest(ComposeUIViewHost.HostingViewController) + BaseComposeUIViewSizingTest(::runUIKitInstrumentedTestInHostingViewController) diff --git a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt index 81d2af3e913d2..2e1c1c1345ecd 100644 --- a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt @@ -107,6 +107,8 @@ import platform.darwin.NSObject import platform.darwin.dispatch_async import platform.darwin.dispatch_get_main_queue +internal typealias UIKitInstrumentedTestBlock = UIKitInstrumentedTest.() -> Unit + /** * Sets up the test environment for iOS instrumented tests, runs the given [test][testBlock] against * UIView- and UIViewController-based Compose Container. @@ -115,12 +117,12 @@ import platform.darwin.dispatch_get_main_queue * assertions on it. * @param [testBlock] The test function. */ -internal fun runUIKitInstrumentedTest(testBlock: UIKitInstrumentedTest.() -> Unit) { +internal fun runUIKitInstrumentedTest(testBlock: UIKitInstrumentedTestBlock) { runUIKitInstrumentedTestInHostingView(testBlock) runUIKitInstrumentedTestInHostingViewController(testBlock) } -internal fun runUIKitInstrumentedTestInHostingView(testBlock: UIKitInstrumentedTest.() -> Unit) { +internal fun runUIKitInstrumentedTestInHostingView(testBlock: UIKitInstrumentedTestBlock) { println("Debug: Running test with ComposeHostingView") with(UIKitInstrumentedTest(useHostingView = true)) { try { @@ -131,7 +133,7 @@ internal fun runUIKitInstrumentedTestInHostingView(testBlock: UIKitInstrumentedT } } -internal fun runUIKitInstrumentedTestInHostingViewController(testBlock: UIKitInstrumentedTest.() -> Unit) { +internal fun runUIKitInstrumentedTestInHostingViewController(testBlock: UIKitInstrumentedTestBlock) { println("Debug: Running test with ComposeHostingViewController") with(UIKitInstrumentedTest(useHostingView = false)) { try { @@ -194,7 +196,7 @@ internal fun runUIKitInstrumentedTest( internal fun runUIKitInstrumentedTest( ignoreIf: Boolean, ignoreNotes: String, - testBlock: UIKitInstrumentedTest.() -> Unit + testBlock: UIKitInstrumentedTestBlock ) { if (ignoreIf) { println("Debug: Ignored test: $ignoreNotes") From b121073a9f4e81266b28fb50f98d40cec8195f90 Mon Sep 17 00:00:00 2001 From: svastven Date: Mon, 8 Jun 2026 16:51:01 +0200 Subject: [PATCH 29/30] Revert extra UIKitInstrumentedTest changes --- .../androidx/compose/ui/test/UIKitInstrumentedTest.kt | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt index 2e1c1c1345ecd..5b656ab5bf3b5 100644 --- a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt @@ -266,10 +266,8 @@ internal class UIKitInstrumentedTest( val accessibilityNotifications = mutableListOf() val lastAccessibilityNotification: AccessibilityNotification? get() = accessibilityNotifications.lastOrNull() - var hostingViewController: ComposeHostingViewController? = null - private set - var hostingView: ComposeHostingView? = null - private set + private var hostingViewController: ComposeHostingViewController? = null + private var hostingView: ComposeHostingView? = null val viewController: UIViewController get() = appDelegate.window?.rootViewController ?: error("Cannot find active UIViewController") @@ -787,4 +785,4 @@ internal fun UIKitInstrumentedTest.waitForContextMenu() { } != null } delay(500) // wait for toolbar animation -} +} \ No newline at end of file From 9da605cca2ff615ce47410b0350c82a53f86416f Mon Sep 17 00:00:00 2001 From: svastven Date: Mon, 8 Jun 2026 17:21:39 +0200 Subject: [PATCH 30/30] Add missing crossinline keyword --- .../compose/ui/scene/SingleComposeSceneRenderingScope.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/scene/SingleComposeSceneRenderingScope.kt b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/scene/SingleComposeSceneRenderingScope.kt index 10f9cbd45fcd9..0e2e51a306b22 100644 --- a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/scene/SingleComposeSceneRenderingScope.kt +++ b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/scene/SingleComposeSceneRenderingScope.kt @@ -46,7 +46,7 @@ class SingleComposeSceneRenderingScope( @PublishedApi internal var isRendering = false - inline fun postponingSceneInvalidations(block: () -> Unit) { + inline fun postponingSceneInvalidations(crossinline block: () -> Unit) { check(!isRendering) isRendering = true try {