Skip to content

Support sizing of Compose hosting views on iOS#2984

Draft
Vendula Švastalová (svastven) wants to merge 30 commits into
jb-mainfrom
svastven/sizing-compose-view
Draft

Support sizing of Compose hosting views on iOS#2984
Vendula Švastalová (svastven) wants to merge 30 commits into
jb-mainfrom
svastven/sizing-compose-view

Conversation

@svastven

@svastven Vendula Švastalová (svastven) commented Apr 19, 2026

Copy link
Copy Markdown

This PR adds a sizing integration that lets a Compose-hosting UIView participate in UIKit/SwiftUI sizing loops by reporting Compose’s preferred size for a given sizing proposal.

Fixes CMP-5788 iOS investigate intrinsic sizing of ComposeUIViewController
Fixes CMP-5873 [Epic] iOS intrinsic sizing of interop elements

How it works

  1. UIKit/SwiftUI calls sizeThatFits(...) with a proposal (including UIViewNoIntrinsicMetric for unbounded axes).
  2. The container converts the proposal into Compose Constraints.
  3. After each Compose layout completes, we probe-measure the scene under the latest proposal constraints and compute the preferred size.
  4. If the preferred size changes, the hosting view invalidates its intrinsic content size, allowing UIKit/SwiftUI to re-run layout and apply the new size.

Fallback handling uses super.sizeThatFits for non-intrinsic axes to avoid 0 sizes before Compose has produced a measurement.

Example usage

(SwiftUI, iOS 16+): sizeThatFits

On the Kotlin side:

val view = ComposeUIView() { FooComposable() }

On the SwiftUI side, wrap the returned UIView/UIViewController and forward SwiftUI’s proposal to UIKit sizeThatFits:

import SwiftUI
import UIKit

struct ComposeView: UIViewRepresentable {
    // Provided by the Kotlin/Swift bridge
    let makeComposeUIView: () -> UIView

    func makeUIView(context: Context) -> UIView {
        makeComposeUIView()
    }

    func updateUIView(_ uiView: UIView, context: Context) {}

    @available(iOS 16.0, *)
    func sizeThatFits(_ proposal: ProposedViewSize, uiView: UIView, context: Context) -> CGSize? {
        let proposed = CGSize(
            width: proposal.width ?? UIView.noIntrinsicMetric,
            height: proposal.height ?? UIView.noIntrinsicMetric
        )
        // Calls into ComposeHostingView/ComposeContainerView.sizeThatFits, which uses the latest
        // proposal constraints to produce Compose’s preferred size.
        return uiView.sizeThatFits(proposed)
    }
}

(SwiftUI, iOS <16): sizeThatFits

The same mechanism works via intrinsic sizing alone: SwiftUI reacts to invalidateIntrinsicContentSize() and re-queries layout.

Testing

Adds instrumented tests that simulate the SwiftUI sizing loop (proposal → sizeThatFits → apply frame → wait for intrinsic invalidation → repeat) and validate that Compose scene size and hosting view frame converge as Compose content or size proposed by SwiftUI changes.

This should be tested by QA

Release Notes

Features - iOS

  • Support automatic sizing of Compose hosting views (ComposeHostingView / ComposeHostingViewController) used on the UIKit / SwiftUI side. Enabled by default; opt out via ComposeContainerConfiguration.useSelfSizing.

Breaking Changes - iOS

  • Compose hosting views now automatically adjust their size to match their content by default. This may result in different layout behavior than before.

@svastven Vendula Švastalová (svastven) force-pushed the svastven/sizing-compose-view branch 2 times, most recently from c3fcdd8 to 9d38020 Compare April 20, 2026 00:12
@svastven Vendula Švastalová (svastven) changed the title Auto-sizing of Compose Hosting on iOS Sizing of Compose Hosting Views on iOS Apr 20, 2026
@svastven Vendula Švastalová (svastven) changed the title Sizing of Compose Hosting Views on iOS Support sizing of Compose Hosting Views on iOS Apr 20, 2026
@svastven Vendula Švastalová (svastven) changed the title Support sizing of Compose Hosting Views on iOS Support sizing of Compose hosting views on iOS Apr 20, 2026
@svastven

Vendula Švastalová (svastven) commented Apr 20, 2026

Copy link
Copy Markdown
Author

Marked as draft as I still need to add tests for the UIKit/SwiftUI intrinsic sizing path.

@svastven

Copy link
Copy Markdown
Author

Alexander Maryanovsky (@m-sasha) Adding you as reviewer, too because desktop sources are involved and there is overlap with #2938

Comment on lines +331 to +336
/**
* Returns the current content size (in pixels) measured with the provided [constraints].
*
* @throws IllegalStateException when [ComposeScene] content has lazy layouts without maximum
* size bounds (e.g. LazyColumn without maximum height) and the constraints are unbounded.
*/

Choose a reason for hiding this comment

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

This comment is only correct if the relevant dimension is unconstrained (e.g. height=Infinity).

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.

so "the constraints are unbounded" doesn't reflect the behavior correctly? It seems to me that the doc is the same as for unconstrainedSize() only specifies the unbounded constraints in addition which is exactly what happens in unconstrainedSize()

Choose a reason for hiding this comment

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

Sorry, you're right. I didn't read the comment to the end, though it was just copy/pasted from unconstrainedSize.

I would just clarify in the comment that this applies on each axis separately.

* platform integrations to avoid retain cycles between a hosting container and
* the scene/root.
*/
fun registerOnLayoutCompletedListener(listener: () -> Unit): AutoCloseable

Choose a reason for hiding this comment

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

Not a blocker, but I'd slightly prefer a dedicated class instead of AutoCloseable.
Existing examples:

  • PinnableContainer.PinnedHandle
  • DelegatableNode.RegistrationHandle (maybe even just use this)

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.

added a custom OnLayoutCompletedListenerHandle

@MatkovIvan Ivan Matkov (MatkovIvan) left a comment

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.

I don't think it's the right way to support it.
The root problem now is that compose stages are not aligned with the platform framework.
We already have an item to do it properly, let's combine the efforts to do it right instead of redoing things again later

* `sizeThatFits` / intrinsic sizing).
*/
@ExperimentalComposeUiApi
var useSelfSizing: Boolean = false

@ASalavei Andrei Salavei (ASalavei) Apr 22, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Do we need this option? I assumed that self-sizing (like, providing intrinsicContentSize) should be always ON.

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 added this flag so we don't change behavior for existing users. If we turn it on by default, it will affect what they already have. Do we want that, or keep it opt-in for now?

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Hm.. Previously we didn't have any constraints there - so users must configure them explicitly. I expect that transition should go smooth to the new state in most cases.
It looks like a safe option here is to make useSelfSizing = true by default, with "Breaking Change" release note and also with intent to remove the flag in the next version (1.13).

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 have removed the flag, it is correct that embedding view's using AutoLayout already worked correctly so there should be no significant changes in the current way user's embed Compose into UIKit using AutoLayout.


rootViewController.view.let {
if (configuration.useSelfSizing) {
it.addSubview(hostingView)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Not sure I get the point. If we're adding view with the translatesAutoresizingMaskIntoConstraints == true, it means the frame of this view must be manually adjusted somewhere (btw, I didn't find the place where it happened). The idea behind the "Self Sizing" Compose views was to make it work correctly inside Autolayout and in the SwiftUI system. No sure we should manually update the frame.

@svastven Vendula Švastalová (svastven) Jun 8, 2026

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.

The setContent flow remains the same as before. I have only created a few convenience methods to be used in setContent.

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?

}

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

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.

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.

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

@MatkovIvan

Copy link
Copy Markdown
Collaborator

I've removed "changes in API" label since I don't see any changes in public API here anymore

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants