Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
a534d87
skiko: add constrained sizing + persistent layout-complete listener
svastven Apr 19, 2026
2a31776
ios: reconcile sizeThatFits proposals with Compose preferred size
svastven Apr 19, 2026
bea6ae9
uikitInstrumentedTest: add ComposeUIView sizing reconciliation tests
svastven Apr 19, 2026
c0b7615
ios: honor usePreferredSizeSizing in ComposeContainer
svastven Apr 19, 2026
15dc155
ios: simplify usePreferredSizeSizing documentation
svastven Apr 19, 2026
2ad93c9
skiko: return unregister handle from registerOnLayoutCompletedListener
svastven Apr 21, 2026
ea1c621
Change useSelfSizing default value to true
svastven Apr 27, 2026
55a2fed
ios: refine Compose scene size synchronizer
svastven May 5, 2026
c1f660d
ios: defer preferred size caching until scene is ready
svastven May 6, 2026
e6fe7ee
uikit tests: avoid internal asDpSize helper
svastven May 6, 2026
9090eb2
Invalidate container view's intrinsic content size
svastven May 10, 2026
0631614
Create synchronizeComposeViewFrame function
svastven May 12, 2026
6ffda8d
ios: refine compose hosting sizeThatFits state
svastven May 12, 2026
6c0841b
Remove useSelfSizing from ComposeContainerConfiguration
svastven Jun 3, 2026
0073e77
Parameterize ComposeUIView sizing host tests
svastven Jun 3, 2026
f523ff7
Add UIKit host sizing test hooks
svastven Jun 3, 2026
69a4248
Use WeakReference in lambda to prevent retain cycle
svastven Jun 3, 2026
1ef163a
Rename constrainedSceneSize to measureSceneSize
svastven Jun 4, 2026
0ce3a5a
Move onLayoutCompleted handling from RootNodeOwner to platform Compos…
svastven Jun 8, 2026
6eda5ea
Add named scheduleFrame parameter
svastven Jun 8, 2026
779c988
Remove unused constraints infinity check
svastven Jun 8, 2026
79e016d
Move return outside try block
svastven Jun 8, 2026
e7df1a1
Revert lint changes
svastven Jun 8, 2026
bb9ac23
Revert doc comment for measuringRootWithConstraints
svastven Jun 8, 2026
3fded93
Revert "Add named scheduleFrame parameter"
svastven Jun 8, 2026
d94f2f5
Create platform specific implementation of the render pipeline
svastven Jun 8, 2026
d0a0393
Keep postponingSceneInvalidations inline
svastven Jun 8, 2026
db61566
Add UIKitInstrumentedTestBlock and comment testing method
svastven Jun 8, 2026
b121073
Revert extra UIKitInstrumentedTest changes
svastven Jun 8, 2026
9da605c
Add missing crossinline keyword
svastven Jun 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -65,6 +75,7 @@ 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

Expand All @@ -81,7 +92,10 @@ internal class ComposeContainer(
val view = ComposeContainerView(
transparentForTouches = false,
useOpaqueConfiguration = configuration.opaque,
)
).apply {
onSizeThatFits = { composeSceneSizeSynchronizer.onSizeThatFitsRequest(it) }
onIntrinsicContentSize = { composeSceneSizeSynchronizer.preferredCGSize }
}

private var mediator: ComposeSceneMediator? = null
private val windowContext = PlatformWindowContext()
Expand All @@ -108,6 +122,14 @@ internal class ComposeContainer(
getTopLeftOffsetInWindow = { IntOffset.Zero }, //full screen
endEdgePanGestureBehavior = configuration.endEdgePanGestureBehavior
)

private val composeSceneSizeSynchronizer: ComposeSceneSizeSynchronizer =
ComposeSceneSizeSynchronizer(
view = view,
composeSceneSize = { mediator?.measureSceneSize(it) },
invalidateComposeSceneContainerSize = view::invalidateIntrinsicContentSize
)

val hasInteropViews: Boolean get() = mediator?.hasInteropViews ?: false

/*
Expand Down Expand Up @@ -248,6 +270,10 @@ internal class ComposeContainer(
}
}

mediator?.setOnLayoutCompletedListener {
composeSceneSizeSynchronizer.onComposeLayoutCompleted()
}

activeStateListener = SceneActiveStateListener(
getScene = ::windowScene
) { isSceneActive ->
Expand Down Expand Up @@ -276,6 +302,7 @@ internal class ComposeContainer(
navigationEventInput.onDidMoveToWindow(null, view)
architectureComponentsOwner.navigationEventDispatcher.removeInput(navigationEventInput)

mediator?.setOnLayoutCompletedListener(null)
mediator = null

activeStateListener?.dispose()
Expand Down Expand Up @@ -479,3 +506,128 @@ 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 = {},
) {

private var latestSizeThatFitsConstraints: Constraints? = null
/**
* Latest constraints proposed by UIKit/SwiftUI through `sizeThatFits`.
*/
private var lastMeasuredConstraints: Constraints? = null
/**
* 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<CGSize>? = null

private val hasPreferredSize: Boolean get() = preferredSize != null

val preferredCGSize: CValue<CGSize>?
get() = preferredSize?.toCGSize(view.density) ?: lastSizeThatFitsResult

fun onSizeThatFitsRequest(size: CValue<CGSize>): CValue<CGSize> {
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
}

val result = preferredSizeForConstraints(constraints) ?: fallbackSizeThatFits(size)
lastSizeThatFitsResult = result

return result
}

private fun preferredSizeForConstraints(constraints: Constraints): CValue<CGSize>? {
if (!hasPreferredSize) return null

// Fast path: if Compose already measured preferred size for the exact same constraints,
// return it directly.
if (lastMeasuredConstraints == constraints) {
return preferredCGSize
}

val didUpdatePreferredSize = measureAndCachePreferredSize(constraints) ?: return null

if (didUpdatePreferredSize) {
invalidateComposeSceneContainerSize()
}

return preferredCGSize
}

fun onComposeLayoutCompleted() {
val constraints = latestSizeThatFitsConstraints ?: return
val didUpdatePreferredSize = measureAndCachePreferredSize(constraints) ?: return

if (didUpdatePreferredSize) {
invalidateComposeSceneContainerSize()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems it will request another frame and apply changes only there.
It might work for PoC, but for the long term I believe compose should participate in the native layout phase synchronously

}
}

private fun measureAndCachePreferredSize(constraints: Constraints): Boolean? {
val preferredSize = composeSceneSize(constraints) ?: return null

@MatkovIvan Ivan Matkov (MatkovIvan) Jun 8, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems that one more unconditional re-measure on each frame might be costly


lastMeasuredConstraints = constraints
val preferredSizeUpdated = preferredSize != this.preferredSize
if (preferredSizeUpdated) {
this.preferredSize = preferredSize
}
return preferredSizeUpdated
}

private fun fallbackSizeThatFits(size: CValue<CGSize>): CValue<CGSize> {
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 }
}

return with(view.density) {
DpSize(width.dp, height.dp).toSize().toIntSize().toCGSize(this)
}
}

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())
}
Original file line number Diff line number Diff line change
Expand Up @@ -50,11 +50,18 @@ internal class ComposeHostingView(
content = content,
coroutineContext = coroutineContext,
lifecycleDelegate = lifecycleDelegate
)
).also {
it.view.setIntrinsicContentSizeInvalidationHandler(this) {
invalidateIntrinsicContentSize()
}
}

// 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)
Expand All @@ -70,6 +77,11 @@ internal class ComposeHostingView(
return container.view.intrinsicContentSize
}

override fun invalidateIntrinsicContentSize() {
super.invalidateIntrinsicContentSize()
onIntrinsicContentSizeInvalidated?.invoke()
}

override fun layoutSubviews() {
super.layoutSubviews()

Expand All @@ -79,13 +91,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
}

Expand All @@ -100,7 +112,7 @@ internal class ComposeHostingView(
if (actualSize != null && actualSize != bounds.dpSize() && !container.hasInteropViews) {
animateSizeTransition(initialSize = initialSize)
} else {
container.view.setFrame(bounds)
synchronizeComposeViewFrame()
}
}
}
Expand All @@ -127,10 +139,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
Expand All @@ -157,7 +174,7 @@ internal class ComposeHostingView(
animations()
}
container.view.clipsToBounds = false
container.view.setFrame(bounds)
synchronizeComposeViewFrame()
}
}

Expand All @@ -177,4 +194,4 @@ internal fun UIView.transitionProgress(initialSize: DpSize): Float {
else -> 1f
}
return progress.coerceIn(0.0f, 1.0f)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -53,11 +53,18 @@ internal class ComposeHostingViewController(
content = content,
coroutineContext = coroutineContext,
lifecycleDelegate = lifecycleDelegate
)
).also {
it.view.setIntrinsicContentSizeInvalidationHandler(this) {
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 =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -74,6 +75,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
Expand Down Expand Up @@ -206,8 +208,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)
Expand Down Expand Up @@ -276,7 +278,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

Expand Down Expand Up @@ -639,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()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is it important to do it before drawing?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we wrap call it only if there was hasPendingMeasureOrLayout == true before measureAndLayout call?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought about this and if we only did this, then here we are assuming that measureAndLayout calculates with hasPendingMeasureOrLayout internally. It would be great to have an indicator that layout actually took place during the measureAndLayout call. But maybe comparing the values of hasPendingMeasureOrLayout is the right way to do it.

@MatkovIvan Ivan Matkov (MatkovIvan) Jun 8, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually it seems that invalidation request to a system should be initiated much earlier, in invalidateLayout callback from a scene.
It's supposed to be called when the layout requires recalculation on the next frame and needs to be scheduled. But now the chain is like this (afaiu):

invalidateLayout -> scheduleFrame -> render -> invalidateIntrinsicContentSize

which seems that actual resize will happen with one frame delay (see another comment/thread).
Of course, it will work better once measureAndLayout is actually part of the platform layout, not render.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be great to have an indicator that layout actually took place during the measureAndLayout call

Well, the issue is opposite. The platform does not need to call measureAndLayout without receiving invalidateLayout callback at all.

draw(canvas)
}
if (hasInvalidations()) {
redrawer.setNeedsRedraw()
}
}

Expand Down Expand Up @@ -759,6 +776,16 @@ internal class ComposeSceneMediator(
this.onKeyEvent = onKeyEvent ?: { false }
}

/**
* 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)

/**
* 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.
Expand Down
Loading
Loading