Make Image/AsyncImage stop using subcompose by default#423
Conversation
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
marcprux
left a comment
There was a problem hiding this comment.
This is currently breaking the ImagePlayground's "Complex Layout", which on iOS looks like:
and prior to this change, it matched on Android:
But with this change applied, the image expands to fill the area:
|
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, I thought about getting rid of 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 |
|
I believe this PR is the optimal fix for #426. |
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.)