Skip to content

Make Image/AsyncImage stop using subcompose by default#423

Open
dfabulich wants to merge 2 commits into
skiptools:mainfrom
dfabulich:nonsubcompose-async-image
Open

Make Image/AsyncImage stop using subcompose by default#423
dfabulich wants to merge 2 commits into
skiptools:mainfrom
dfabulich:nonsubcompose-async-image

Conversation

@dfabulich
Copy link
Copy Markdown
Contributor

@dfabulich dfabulich commented May 11, 2026

Coil's documentation strongly recommends against using SubcomposeAsyncImage for performance reasons. https://coil-kt.github.io/coil/compose/

Here, we've replaced it with the most flexible version of Coil's non-subcompose API, rememberAsyncImagePainter.

But there is an important quirk in this API. SwiftUI AsyncImage has three AsyncImagePhase cases: .success(Image), and .failure(Error), and .empty, but there are four states of AsyncImagePainter.State: Success, Failure, Loading and Empty.

Empty is quite different from Loading. In the Empty state, which occurs on the first frame of rendering, Coil doesn't know yet whether the image will render immediately from the memory cache. In the subsequent frame, the state will change to either Success or Loading; it's up to the user to decide what to do in the Empty state.

Coil users have a few options for handling Empty.

  1. We can optimistically render the image, hoping to get a cache hit.
  2. We can pessimistically render the placeholder, waiting to render the image until we can be certain it's ready.
  3. We can render the placeholder underneath the image, in a ZStack/Box. If the image renders (and if the image doesn't have transparency), the image will completely obscure the placeholder.

For AsyncImage(url:scale:) and AsyncImage(url:scale:content:placeholder:), we've chosen option 3 for Coil's Empty case.

Users can choose their own option with AsyncImage(url:scale:content:), which accepts an AsyncImagePhase. To permit users to distinguish Coil's Loading from Empty, we've added an argument to SwiftUI's AsyncImagePhase.empty enum case; it's now .empty(Image?). This allows users to access the image in the .empty case with let image: Image? = phase.image. If the image is nil, then the image is Loading; users can render their placeholder. (In the .empty case, phase.image will always be nil in SwiftUI.) If the image is not nil, users can choose what to do with it, probably selecting one of the three options above.

Existing users of AsyncImage(url:scale:content:) can continue to handle case .empty without changing their code. In practice, that will function like the pessimistic option 2.

We've also introduced a new modifier, .subcomposeAsyncImage(), which sets an environment value, causing SkipUI to use SubcomposeAsyncImage, the old way.

Skip Pull Request Checklist:


  • AI was used to generate or assist with generating this PR. Please specify below how you used AI to help you, and what steps you have taken to manually verify the changes.

Cursor generated a first draft; I significantly refactored it. I tested it in Showcase Lite and Fuse. I tested by ensuring that the empty branch was hit, then I uncommented .subcomposeAsyncImage() (and added temporary logging in skip-ui to prove that it actually used subcompose), and verified that the new empty branch was not reached in subcompose mode. (The new empty branch is never reached in iOS SwiftUI.)

Coil's documentation strongly recommends against using `SubcomposeAsyncImage` for performance reasons. https://coil-kt.github.io/coil/compose/

Here, we've replaced it with the most flexible version of Coil's non-subcompose API, `rememberAsyncImagePainter`.

But there is an important quirk in this API. SwiftUI `AsyncImage` has three `AsyncImagePhase` cases: `.success(Image)`, and `.failure(Error)`, and `.empty`, but there are four states of `AsyncImagePainter.State`: `Success`, `Failure`, `Loading` and `Empty`.

`Empty` is quite different from `Loading`. In the `Empty` state, which occurs on the first frame of rendering, Coil doesn't know yet whether the image will render immediately from the memory cache. In the subsequent frame, the state will change to either `Success` or `Loading`; it's up to the user to decide what to do in the `Empty` state.

Coil users have a few options for handling `Empty`.

1. We can optimistically render the image, hoping to get a cache hit.
2. We can pessimistically render the placeholder, waiting to render the image until we can be certain it's ready.
3. We can render the placeholder underneath the image, in a ZStack/Box. If the image renders (and if the image doesn't have transparency), the image will completely obscure the placeholder.

For `AsyncImage(url:scale:)` and `AsyncImage(url:scale:content:placeholder:)`, we've chosen option 3 for Coil's `Empty` case.

Users can choose their own option with `AsyncImage(url:scale:content:)`, which accepts an `AsyncImagePhase`. To permit users to distinguish Coil's `Loading` from `Empty`, we've added an argument to SwiftUI's `AsyncImagePhase.empty` enum case; it's now `.empty(Image?)`. This allows users to access the image in the `.empty` case with `let image: Image? = phase.image`. If the image is `nil`, then the image is `Loading`; users can render their placeholder. (In the `.empty` case, `phase.image` will always be `nil` in SwiftUI.) If the image is not `nil`, users can choose what to do with it, probably selecting one of the three options above.

Existing users of `AsyncImage(url:scale:content:)` can continue to handle `case .empty` without changing their code. In practice, that will function like the pessimistic option 2.

We've also introduced a new modifier, `.subcomposeAsyncImage()`, which sets an environment value, causing SkipUI to use `SubcomposeAsyncImage`, the old way.
@dfabulich dfabulich force-pushed the nonsubcompose-async-image branch from 22a88a7 to 8be7a7d Compare May 11, 2026 22:10
Copy link
Copy Markdown
Member

@marcprux marcprux left a comment

Choose a reason for hiding this comment

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

This is currently breaking the ImagePlayground's "Complex Layout", which on iOS looks like:

Image

and prior to this change, it matched on Android:

Image

But with this change applied, the image expands to fill the area:

Image

@dfabulich dfabulich requested a review from marcprux May 15, 2026 00:46
@dfabulich
Copy link
Copy Markdown
Contributor Author

I've fixed the Complex Layout Playground in a secondary commit, worth independently reviewing, because this code is kinda gnarly.

The bug was that when a painter has no intrinsic size, RenderPainter slaps fillSize() on it. I didn't touch RenderPainter in this PR, so I don't understand why it uses fillSize() on painters with no intrinsic size. Even when the painter recomposes with an intrinsic size, the painter's fillSize() had already "claimed" maximum vertical space, and so it never fixes itself on recompose.

I thought about getting rid of fillSize() in RenderPainter, but I don't want to change something like that without knowing what I'd break.

More broadly, it's not at all clear to me what job RenderPainter and ImageLayout are supposed to be accomplishing (other than tinting). I experimented with switching Complex Layout over to AsyncImage and it just worked, by not trying to apply fillSize to the placeholder, not trying to apply a custom layout. It's just… rendering the image.

And the fact that asset images are using asynchronous loading is not great. #154 Asset images shouldn't require a loading placeholder! (I've already long ago stopped using SkipUI Image, for performance reasons.)

@dfabulich
Copy link
Copy Markdown
Contributor Author

I believe this PR is the optimal fix for #426.

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants