Skip to content

Commit f571d1e

Browse files
authored
Make Image/AsyncImage stop using subcompose by default (#423)
* Make Image/AsyncImage stop using subcompose by default 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. * Fix Complex Layout ImagePlayground
1 parent 46d9e5a commit f571d1e

5 files changed

Lines changed: 356 additions & 71 deletions

File tree

README.md

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2912,6 +2912,132 @@ Image(systemName: "Icons.Filled.Settings")
29122912
#endif
29132913
```
29142914

2915+
#### First-frame rendering and `AsyncImagePhase` quirks
2916+
2917+
SkipUI's `AsyncImage` is built on top of [Coil for Android](https://coil-kt.github.io/coil/), which was inspired by SwiftUI. Just like SwiftUI, Coil allows its users to specify an URL and a placeholder, or to react to phase changes as the image loads.
2918+
2919+
But there is one major difference between Coil's `AsyncImage` and SwiftUI's `AsyncImage`. On the very first frame, Coil cannot know whether the image will be successfully rendered from the memory cache, because it can't yet know the size of the layout constraints.
2920+
2921+
This may impact you if you use [`AsyncImage(url:scale:transaction:content:)`](https://developer.apple.com/documentation/swiftui/asyncimage/init(url:scale:transaction:content:)), where the `content` callback accepts an [`AsyncImagePhase`](https://developer.apple.com/documentation/swiftui/asyncimagephase) enum, with three phases:
2922+
2923+
* `empty`: No image is loaded
2924+
* `failure(any Error)`: An image failed to load with an error
2925+
* `success(Image)`: An image successfully loaded
2926+
2927+
Instead of three phases, Coil has _four_ phases: `Success`, `Failure`, `Loading`, and `Empty`. `Empty` is the state where Coil doesn't yet know whether the image is ready or not.
2928+
2929+
To model this, SkipUI's `empty` case is actually `empty(Image?)`. You can use `let image: Image? = phase.image` to read it. If the phase is `empty` and the image is `nil`, then Coil is `Loading`, and you should show your placeholder.
2930+
2931+
If the image is not `nil` in the `empty` case, you can decide what to do with that image.
2932+
2933+
There are a few options available to you:
2934+
2935+
1. Option 1 (Optimistic): You can optimistically render the image, hoping to get a cache hit, delaying rendering the placeholder.
2936+
2937+
```swift
2938+
AsyncImage(url: url) { phase in
2939+
switch phase {
2940+
case .empty:
2941+
if let image = phase.image {
2942+
image.resizable()
2943+
} else {
2944+
ProgressView()
2945+
}
2946+
case .failure:
2947+
Color.red
2948+
case .success(let image):
2949+
image.resizable()
2950+
}
2951+
}
2952+
```
2953+
2954+
**Beware, this can cause a layout shift.** If the image isn't ready yet, it will always render at 0x0 size. To workaround this, consider using a transparent `Color.clear` placeholder in Option 2, below.
2955+
2956+
2. Option 2 (ZStack): You can render the placeholder underneath the image in a `ZStack` when the phase is `empty`. If the image renders (and if the image doesn't include any transparency), it will completely obscure the placeholder.
2957+
2958+
```swift
2959+
AsyncImage(url: url) { phase in
2960+
switch phase {
2961+
case .empty:
2962+
if let image = phase.image {
2963+
ZStack {
2964+
ProgressView() // or Color.clear
2965+
if let image = phase.image {
2966+
image.resizable()
2967+
}
2968+
}
2969+
} else {
2970+
ProgressView()
2971+
}
2972+
case .failure:
2973+
Color.red
2974+
case .success(let image):
2975+
image.resizable()
2976+
}
2977+
}
2978+
```
2979+
2980+
If you use this Option 2 (ZStack), and your image contains transparency, the placeholder might be visible underneath your image on the first frame of rendering. In that case, consider using a transparent placeholder for the `empty` case where an `image` is available, like `Color.clear`.
2981+
2982+
2. Option 3 (Pessimistic): You can pessimistically render the placeholder, waiting to render the image until we can be certain it's ready.
2983+
2984+
```swift
2985+
AsyncImage(url: url) { phase in
2986+
switch phase {
2987+
case .empty:
2988+
ProgressView()
2989+
case .failure:
2990+
Color.red
2991+
case .success(let image):
2992+
image.resizable()
2993+
}
2994+
}
2995+
```
2996+
2997+
This is what you'll get if you write idiomatic SwiftUI code with [`AsyncImage(url:scale:transaction:content:)`](https://developer.apple.com/documentation/swiftui/asyncimage/init(url:scale:transaction:content:)).
2998+
2999+
3000+
If you use [`AsyncImage(url:scale:)`](https://developer.apple.com/documentation/swiftui/asyncimage/init(url:scale:)) or [`AsyncImage(url:scale:content:placeholder:)`](https://developer.apple.com/documentation/swiftui/asyncimage/init(url:scale:content:placeholder:)), SkipUI will prefer Option 2 (ZStack), so your image can render as soon as possible. We use a `Color.clear` placeholder during the first frame, ensuring that the layout doesn't shift, but if the image doesn't load instantly, this will delay showing a visible placeholder for one frame.
3001+
3002+
##### Use `.subcomposeAsyncImage()` to opt out of first-frame rendering quirks (at a performance cost)
3003+
3004+
Lastly, there is a mode of Coil that _doesn't_ use Coil's `Empty` phase, called `SubcomposeAsyncImage`. Under the hood, `SubcomposeAsyncImage` uses Compose `BoxWithConstraints` (which relies on `SubcomposeLayout`) to measure the size of the constraints before rendering.
3005+
3006+
Coil's documentation warns that `SubcomposeAsyncImage` is "slow."
3007+
3008+
> Subcomposition is slower than regular composition so this composable may not be suitable for performance-critical parts of your UI (e.g. `LazyList`).
3009+
3010+
> Specifically, [SubcomposeAsyncImage] is only useful if you need to observe `AsyncImagePainter.state` [SkipUI AsyncImagePhase] and you can't have it be `Empty` for the first composition and first frame.
3011+
3012+
To opt-in to using `SubcomposeAsyncImage`, you can use the Android-only `.subcomposeAsyncImage()` modifier.
3013+
3014+
```swift
3015+
AsyncImage(url: url) { image in
3016+
image.resizable()
3017+
} placeholder: {
3018+
Color.gray
3019+
}
3020+
#if os(Android)
3021+
.subcomposeAsyncImage()
3022+
#endif
3023+
```
3024+
3025+
`.subcomposeAsyncImage()` sets an environment value, so you can set it at a high level; it will affect all images in its tree. You can use `.subcomposeAsyncImage(false)` to turn it back off for an entire subtree.
3026+
3027+
To decide whether you "need" this, decide what's most important to you:
3028+
3029+
1. Is rendering the image as soon as possible your top priority?
3030+
3031+
In that case, don't use `.subcomposeAsyncImage()`. SkipUI's default already optimizes for this.
3032+
3033+
2. Is it more important to render the placeholder as soon as possible, even if that delays rendering the image?
3034+
3035+
In that case, use [`AsyncImage(url:scale:transaction:content:)`](https://developer.apple.com/documentation/swiftui/asyncimage/init(url:scale:transaction:content:)) pessimistically rendering your placeholder in `case empty:`. (See "Option 3" above.)
3036+
3037+
3. Is it more important that the first frame be "correct" (showing the rendered image or a visible placeholder), even if this requires doing more work on the main UI thread?
3038+
3039+
That's when you should use `.subcomposeAsyncImage()`. `.subcomposeAsyncImage()` will show the correct UI slower than the other two options, but the UI will be correct on the very first frame.
3040+
29153041
### Layout
29163042

29173043
SkipUI fully supports SwiftUI's various layout mechanisms, including `HStack`, `VStack`, `ZStack`, and the `.frame` modifier. If you discover layout edge cases where the result on Android does not match the result on iOS, please file an Issue. The following is a list of known cases where results may not match:

0 commit comments

Comments
 (0)