Make Image/AsyncImage stop using subcompose by default#423
Open
dfabulich wants to merge 1 commit into
Open
Conversation
dfabulich
added a commit
to dfabulich/skipapp-showcase
that referenced
this pull request
May 11, 2026
This was referenced May 11, 2026
f84aa54 to
22a88a7
Compare
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.
22a88a7 to
8be7a7d
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Coil's documentation strongly recommends against using
SubcomposeAsyncImagefor 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
AsyncImagehas threeAsyncImagePhasecases:.success(Image), and.failure(Error), and.empty, but there are four states ofAsyncImagePainter.State:Success,Failure,LoadingandEmpty.Emptyis quite different fromLoading. In theEmptystate, 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 eitherSuccessorLoading; it's up to the user to decide what to do in theEmptystate.Coil users have a few options for handling
Empty.For
AsyncImage(url:scale:)andAsyncImage(url:scale:content:placeholder:), we've chosen option 3 for Coil'sEmptycase.Users can choose their own option with
AsyncImage(url:scale:content:), which accepts anAsyncImagePhase. To permit users to distinguish Coil'sLoadingfromEmpty, we've added an argument to SwiftUI'sAsyncImagePhase.emptyenum case; it's now.empty(Image?). This allows users to access the image in the.emptycase withlet image: Image? = phase.image. If the image isnil, then the image isLoading; users can render their placeholder. (In the.emptycase,phase.imagewill always benilin SwiftUI.) If the image is notnil, 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 handlecase .emptywithout 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 useSubcomposeAsyncImage, the old way.Skip Pull Request Checklist:
swift testAdd
.subcomposeAsyncImage()and AsyncImagePhase.empty(Image?) skip-fuse-ui#105Add a custom
emptyclosure variation skipapp-showcase#99Add a custom
emptyclosure variation skipapp-showcase-fuse#67Cursor generated a first draft; I significantly refactored it. I tested it in Showcase Lite and Fuse. I tested by ensuring that the
emptybranch was hit, then I uncommented.subcomposeAsyncImage()(and added temporary logging inskip-uito prove that it actually used subcompose), and verified that the newemptybranch was not reached in subcompose mode. (The newemptybranch is never reached in iOS SwiftUI.)