Skip to content

Make Image/AsyncImage stop using subcompose by default#423

Open
dfabulich wants to merge 1 commit into
skiptools:mainfrom
dfabulich:nonsubcompose-async-image
Open

Make Image/AsyncImage stop using subcompose by default#423
dfabulich wants to merge 1 commit 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
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.

1 participant