From 8be7a7dac5f800515ffc990f89d2fb984808300e Mon Sep 17 00:00:00 2001 From: Dan Fabulich Date: Sat, 9 May 2026 21:41:34 -0700 Subject: [PATCH 1/2] 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. --- README.md | 126 +++++++++++ .../SkipUI/SkipUI/Components/AsyncImage.swift | 196 +++++++++++++----- Sources/SkipUI/SkipUI/Components/Image.swift | 69 ++++-- .../Environment/EnvironmentValues.swift | 5 + .../SkipUI/View/AdditionalViewModifiers.swift | 25 +++ 5 files changed, 350 insertions(+), 71 deletions(-) diff --git a/README.md b/README.md index c7f7c1dd..4a3c2717 100644 --- a/README.md +++ b/README.md @@ -2904,6 +2904,132 @@ Image(systemName: "Icons.Filled.Settings") #endif ``` +#### First-frame rendering and `AsyncImagePhase` quirks + +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. + +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. + +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: + +* `empty`: No image is loaded +* `failure(any Error)`: An image failed to load with an error +* `success(Image)`: An image successfully loaded + +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. + +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. + +If the image is not `nil` in the `empty` case, you can decide what to do with that image. + +There are a few options available to you: + +1. Option 1 (Optimistic): You can optimistically render the image, hoping to get a cache hit, delaying rendering the placeholder. + + ```swift + AsyncImage(url: url) { phase in + switch phase { + case .empty: + if let image = phase.image { + image.resizable() + } else { + ProgressView() + } + case .failure: + Color.red + case .success(let image): + image.resizable() + } + } + ``` + + **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. + +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. + + ```swift + AsyncImage(url: url) { phase in + switch phase { + case .empty: + if let image = phase.image { + ZStack { + ProgressView() // or Color.clear + if let image = phase.image { + image.resizable() + } + } + } else { + ProgressView() + } + case .failure: + Color.red + case .success(let image): + image.resizable() + } + } + ``` + + 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`. + +2. Option 3 (Pessimistic): You can pessimistically render the placeholder, waiting to render the image until we can be certain it's ready. + + ```swift + AsyncImage(url: url) { phase in + switch phase { + case .empty: + ProgressView() + case .failure: + Color.red + case .success(let image): + image.resizable() + } + } + ``` + + 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:)). + + +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. + +##### Use `.subcomposeAsyncImage()` to opt out of first-frame rendering quirks (at a performance cost) + +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. + +Coil's documentation warns that `SubcomposeAsyncImage` is "slow." + +> Subcomposition is slower than regular composition so this composable may not be suitable for performance-critical parts of your UI (e.g. `LazyList`). + +> 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. + +To opt-in to using `SubcomposeAsyncImage`, you can use the Android-only `.subcomposeAsyncImage()` modifier. + +```swift +AsyncImage(url: url) { image in + image.resizable() +} placeholder: { + Color.gray +} +#if os(Android) +.subcomposeAsyncImage() +#endif +``` + +`.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. + +To decide whether you "need" this, decide what's most important to you: + +1. Is rendering the image as soon as possible your top priority? + + In that case, don't use `.subcomposeAsyncImage()`. SkipUI's default already optimizes for this. + +2. Is it more important to render the placeholder as soon as possible, even if that delays rendering the image? + + 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.) + +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? + + 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. + ### Layout 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: diff --git a/Sources/SkipUI/SkipUI/Components/AsyncImage.swift b/Sources/SkipUI/SkipUI/Components/AsyncImage.swift index 9aeb05c0..aa1ae61d 100644 --- a/Sources/SkipUI/SkipUI/Components/AsyncImage.swift +++ b/Sources/SkipUI/SkipUI/Components/AsyncImage.swift @@ -3,11 +3,17 @@ #if !SKIP_BRIDGE import Foundation #if SKIP +import androidx.compose.foundation.layout.Box import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.remember +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import android.webkit.MimeTypeMap +import coil3.compose.AsyncImagePainter import coil3.compose.SubcomposeAsyncImage +import coil3.compose.rememberAsyncImagePainter +import coil3.compose.rememberConstraintsSizeResolver import coil3.request.ImageRequest import coil3.fetch.Fetcher import coil3.fetch.FetchResult @@ -19,8 +25,25 @@ import coil3.asImage import kotlin.math.roundToInt import okio.buffer import okio.source + +/// Composes AsyncImage placeholder views (empty / error / nil URL) with optional ``EnvironmentValues/_aspectRatio`` from the environment. +@Composable +private func composePlaceholder(_ view: any View, context: ComposeContext) { + if let (aspectRatio, contentMode) = EnvironmentValues.shared._aspectRatio { + view.aspectRatio(aspectRatio, contentMode: contentMode).Compose(context: context) + } else { + view.Compose(context: context) + } +} #endif +// SKIP @bridgeMembers +public struct AsyncImageBridgedContentArguments { + public var image: Image? + public var error: (any Error)? + public var isEmpty: Bool +} + // SKIP @bridge public struct AsyncImage : View, Renderable { let url: URL? @@ -33,7 +56,18 @@ public struct AsyncImage : View, Renderable { self.content = { phase in switch phase { case .empty: + #if SKIP + if let image = phase.image { + return ZStack { + Color.clear // Showing a placeholder here prevents layout shift if the image is 0x0 on the first frame + image + } + } else { + return Self.defaultPlaceholder() + } + #else return Self.defaultPlaceholder() + #endif case .failure: return Self.defaultPlaceholder() case .success(let image): @@ -48,7 +82,18 @@ public struct AsyncImage : View, Renderable { self.content = { phase in switch phase { case .empty: + #if SKIP + if let image = phase.image { + return ZStack { + Color.clear // Showing a placeholder here prevents layout shift if the image is 0x0 on the first frame + content(image) + } + } else { + return placeholder() + } + #else return placeholder() + #endif case .failure: return placeholder() case .success(let image): @@ -65,27 +110,37 @@ public struct AsyncImage : View, Renderable { // Note that we reverse the `url` and `scale` parameter order just to create a unique JVM signature // SKIP @bridge - public init(scale: CGFloat, url: URL?, bridgedContent: ((Image?, (any Error)?) -> any View)?) { + public init(scale: CGFloat, url: URL?, bridgedContent: ((AsyncImageBridgedContentArguments) -> any View)?) { self.url = url self.scale = scale self.content = { phase in - switch phase { - case .empty: - if let bridgedContent { - return bridgedContent(nil, nil) - } else { - return Self.defaultPlaceholder() + if let bridgedContent { + switch phase { + case .empty: + return bridgedContent(.init(image: phase.image, error: nil, isEmpty: true)) + case .failure(let error): + return bridgedContent(.init(image: nil, error: error, isEmpty: false)) + case .success(let image): + return bridgedContent(.init(image: image, error: nil, isEmpty: false)) } - case .failure(let error): - if let bridgedContent { - return bridgedContent(nil, error) - } else { + } else { + switch phase { + case .empty: + #if SKIP + if let image = phase.image { + return ZStack { + Color.clear // Showing a placeholder here prevents layout shift if the image is 0x0 on the first frame + image + } + } else { + return Self.defaultPlaceholder() + } + #else return Self.defaultPlaceholder() - } - case .success(let image): - if let bridgedContent { - return bridgedContent(image, nil) - } else { + #endif + case .failure(let error): + return Self.defaultPlaceholder() + case .success(let image): return image } } @@ -98,12 +153,8 @@ public struct AsyncImage : View, Renderable { // Instead, we set it in the environment, so the Image can consume it // If we're showing a placeholder, and the aspectRatio is in the environment, we need to apply it to the placeholder guard let url else { - let placeholderView = self.content(AsyncImagePhase.empty) - if let (aspectRatio, contentMode) = EnvironmentValues.shared._aspectRatio { - let _ = placeholderView.aspectRatio(aspectRatio, contentMode: contentMode).Compose(context) - } else { - let _ = placeholderView.Compose(context) - } + let placeholderView = self.content(AsyncImagePhase.empty(nil)) + composePlaceholder(placeholderView, context: context) return } @@ -114,47 +165,84 @@ public struct AsyncImage : View, Renderable { // we add a custom fetchers that will handle loading the URL. // Otherwise use Coil's default URL string handling let requestSource: Any = AssetURLFetcher.handlesURL(url) ? url : urlString - let androidContext = LocalContext.current - let dm = androidContext.resources.displayMetrics - let maxPx = max(Int(dm.widthPixels), Int(dm.heightPixels)) - let cacheKey = "\(urlString)#\(maxPx)x\(maxPx)" - let model = remember(urlString, maxPx) { - // Coil refuses to use its memory cache for .size(Size.ORIGINAL) requests! - // We're using maxPx as an arbitrary bound to force it to cache properly - // Coil memory-cache size validation is in MemoryCacheService.isCacheValueValidForSize: - // See compose-source/io-coil-kt-coil3/coil-core-android/commonMain/coil3/memory/MemoryCacheService.kt:127. + + if EnvironmentValues.shared._subcomposeAsyncImage { + let dm = androidContext.resources.displayMetrics + let maxPx = max(Int(dm.widthPixels), Int(dm.heightPixels)) + let cacheKey = "\(urlString)#\(maxPx)x\(maxPx)" + + let model = remember(urlString, maxPx) { + // Coil refuses to use its memory cache for .size(Size.ORIGINAL) requests! + // We're using maxPx as an arbitrary bound to force it to cache properly + // Coil memory-cache size validation is in MemoryCacheService.isCacheValueValidForSize: + // See compose-source/io-coil-kt-coil3/coil-core-android/commonMain/coil3/memory/MemoryCacheService.kt:127. + return ImageRequest.Builder(androidContext) + .fetcherFactory(AssetURLFetcher.Factory()) // handler for asset:/ and jar:file:/ URLs + .decoderFactory(coil3.svg.SvgDecoder.Factory()) + //.decoderFactory(coil3.gif.GifDecoder.Factory()) + .decoderFactory(PdfDecoder.Factory()) + .data(requestSource) + .size(coil3.size.Size(width: maxPx, height: maxPx)) + .memoryCacheKey(cacheKey) + .diskCacheKey(cacheKey) + .build() + } + + SubcomposeAsyncImage(model: model, contentDescription: nil, loading: { _ in + let placeholderView = content(AsyncImagePhase.empty(nil)) + composePlaceholder(placeholderView, context: context) + }, success: { state in + let image = Image(painter: self.painter, scale: scale) + let content = content(AsyncImagePhase.success(image)) + content.Compose(context: context) + }, error: { state in + let placeholderView = content(AsyncImagePhase.failure(ErrorException(cause: state.result.throwable))) + composePlaceholder(placeholderView, context: context) + }) + return + } + + let sizeResolver = rememberConstraintsSizeResolver() + let cacheKey = "\(urlString)#layout" + let model = remember(urlString, sizeResolver) { return ImageRequest.Builder(androidContext) .fetcherFactory(AssetURLFetcher.Factory()) // handler for asset:/ and jar:file:/ URLs .decoderFactory(coil3.svg.SvgDecoder.Factory()) //.decoderFactory(coil3.gif.GifDecoder.Factory()) .decoderFactory(PdfDecoder.Factory()) .data(requestSource) - .size(coil3.size.Size(width: maxPx, height: maxPx)) + .size(sizeResolver) .memoryCacheKey(cacheKey) .diskCacheKey(cacheKey) .build() } - - SubcomposeAsyncImage(model: model, contentDescription: nil, loading: { _ in - let placeholderView = content(AsyncImagePhase.empty) - if let (aspectRatio, contentMode) = EnvironmentValues.shared._aspectRatio { - placeholderView.aspectRatio(aspectRatio, contentMode: contentMode).Compose(context: context) - } else { - placeholderView.Compose(context: context) + let painter = rememberAsyncImagePainter(model: model, contentScale: ContentScale.Fit) + let asyncImageState = painter.state.collectAsState() + + let innerContext = context.content() + Box(modifier: context.modifier.then(sizeResolver), contentAlignment: androidx.compose.ui.Alignment.Center) { + let state = asyncImageState.value + if state == AsyncImagePainter.State.Empty { + // In this case, Coil doesn't yet know the true state + // If the image is cached in memory, we can render it right away + // If not, we'd want to show a placeholder + let coilImage = Image(painter: painter, scale: scale) + let placeholderView = content(AsyncImagePhase.empty(coilImage)) + composePlaceholder(placeholderView, context: innerContext) + } else if state is AsyncImagePainter.State.Loading { + let placeholderView = content(AsyncImagePhase.empty(nil)) + composePlaceholder(placeholderView, context: innerContext) + } else if state is AsyncImagePainter.State.Success { + let image = Image(painter: painter, scale: scale) + let successContent = content(AsyncImagePhase.success(image)) + successContent.Compose(context: innerContext) + } else if state is AsyncImagePainter.State.Error { + let errorState = state as! AsyncImagePainter.State.Error + let placeholderView = content(AsyncImagePhase.failure(ErrorException(cause: errorState.result.throwable))) + composePlaceholder(placeholderView, context: innerContext) } - }, success: { state in - let image = Image(painter: self.painter, scale: scale) - let content = content(AsyncImagePhase.success(image)) - content.Compose(context: context) - }, error: { state in - let placeholderView = content(AsyncImagePhase.failure(ErrorException(cause: state.result.throwable))) - if let (aspectRatio, contentMode) = EnvironmentValues.shared._aspectRatio { - placeholderView.aspectRatio(aspectRatio, contentMode: contentMode).Compose(context: context) - } else { - placeholderView.Compose(context: context) - } - }) + } } #else public var body: some View { @@ -172,7 +260,9 @@ public struct AsyncImage : View, Renderable { } public enum AsyncImagePhase { - case empty + // This is either Coil's `Loading` state with a nil image, or `Empty` state with a painter-backed image + // In Coil's `Empty` state, Coil doesn't yet know whether the image is in memory cache or not + case empty(Image?) case success(Image) case failure(Error) @@ -180,6 +270,8 @@ public enum AsyncImagePhase { switch self { case .success(let image): return image + case .empty(let image): + return image default: return nil } diff --git a/Sources/SkipUI/SkipUI/Components/Image.swift b/Sources/SkipUI/SkipUI/Components/Image.swift index d31c7960..eef65109 100644 --- a/Sources/SkipUI/SkipUI/Components/Image.swift +++ b/Sources/SkipUI/SkipUI/Components/Image.swift @@ -17,6 +17,7 @@ import androidx.compose.material.icons.twotone.__ import androidx.compose.material3.Icon import androidx.compose.material3.LocalTextStyle import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.draw.paint @@ -45,7 +46,10 @@ import androidx.compose.ui.layout.ScaleFactor import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImagePainter import coil3.compose.SubcomposeAsyncImage +import coil3.compose.rememberAsyncImagePainter +import coil3.compose.rememberConstraintsSizeResolver import coil3.request.ImageRequest #elseif canImport(CoreGraphics) import struct CoreGraphics.CGFloat @@ -144,36 +148,63 @@ public struct Image : View, Renderable, Equatable { @Composable private func RenderAssetImage(asset: AssetImageInfo, label: Text?, aspectRatio: Double?, contentMode: ContentMode?, context: ComposeContext) { let url = asset.url let androidContext = LocalContext.current - let dm = androidContext.resources.displayMetrics - let maxPx = max(Int(dm.widthPixels), Int(dm.heightPixels)) - let cacheKey = "\(url.description)#\(maxPx)x\(maxPx)" - let model = remember(asset.url, maxPx) { - // Coil refuses to use its memory cache for .size(Size.ORIGINAL) requests! - // We're using maxPx as an arbitrary bound to force it to cache properly - // Coil memory-cache size validation is in MemoryCacheService.isCacheValueValidForSize: - // See compose-source/io-coil-kt-coil3/coil-core-android/commonMain/coil3/memory/MemoryCacheService.kt:127. + + let shouldTint = (templateRenderingMode == .template) || (templateRenderingMode == nil && asset.isTemplateImage) + let tintColor = shouldTint ? EnvironmentValues.shared._foregroundStyle?.asColor(opacity: 1.0, animationContext: context) ?? Color.primary.colorImpl() : nil + + if EnvironmentValues.shared._subcomposeAsyncImage { + let dm = androidContext.resources.displayMetrics + let maxPx = max(Int(dm.widthPixels), Int(dm.heightPixels)) + + let cacheKey = "\(url.description)#\(maxPx)x\(maxPx)" + let model = remember(asset.url, maxPx) { + // Coil refuses to use its memory cache for .size(Size.ORIGINAL) requests! + // We're using maxPx as an arbitrary bound to force it to cache properly + // Coil memory-cache size validation is in MemoryCacheService.isCacheValueValidForSize: + // See compose-source/io-coil-kt-coil3/coil-core-android/commonMain/coil3/memory/MemoryCacheService.kt:127. + return ImageRequest.Builder(androidContext) + .fetcherFactory(AssetURLFetcher.Factory()) // handler for asset:/ and jar:file:/ URLs + .decoderFactory(coil3.svg.SvgDecoder.Factory()) + //.decoderFactory(coil3.gif.GifDecoder.Factory()) + .decoderFactory(PdfDecoder.Factory()) + .data(asset.url) + .size(coil3.size.Size(width: maxPx, height: maxPx)) + .memoryCacheKey(cacheKey) + .diskCacheKey(cacheKey) + .build() + } + + SubcomposeAsyncImage(model: model, contentDescription: nil, loading: { _ in + + }, success: { state in + RenderPainter(painter: self.painter, tintColor: tintColor, scale: scale, aspectRatio: aspectRatio, contentMode: contentMode, context: context) + }, error: { state in + + }) + return + } + + // Default: same Coil strategy as ``AsyncImage`` (constraint size resolver + ``rememberAsyncImagePainter``). + let sizeResolver = rememberConstraintsSizeResolver() + let cacheKey = "\(url.description)#layout" + let model = remember(asset.url, sizeResolver) { return ImageRequest.Builder(androidContext) .fetcherFactory(AssetURLFetcher.Factory()) // handler for asset:/ and jar:file:/ URLs .decoderFactory(coil3.svg.SvgDecoder.Factory()) //.decoderFactory(coil3.gif.GifDecoder.Factory()) .decoderFactory(PdfDecoder.Factory()) .data(asset.url) - .size(coil3.size.Size(width: maxPx, height: maxPx)) + .size(sizeResolver) .memoryCacheKey(cacheKey) .diskCacheKey(cacheKey) .build() } + let painter = rememberAsyncImagePainter(model: model, contentScale: ContentScale.Fit) - let shouldTint = (templateRenderingMode == .template) || (templateRenderingMode == nil && asset.isTemplateImage) - let tintColor = shouldTint ? EnvironmentValues.shared._foregroundStyle?.asColor(opacity: 1.0, animationContext: context) ?? Color.primary.colorImpl() : nil - - SubcomposeAsyncImage(model: model, contentDescription: nil, loading: { _ in - - }, success: { state in - RenderPainter(painter: self.painter, tintColor: tintColor, scale: scale, aspectRatio: aspectRatio, contentMode: contentMode, context: context) - }, error: { state in - - }) + let innerContext = context.content() + Box(modifier: context.modifier.then(sizeResolver), contentAlignment: androidx.compose.ui.Alignment.Center) { + RenderPainter(painter: painter, tintColor: tintColor, scale: scale, aspectRatio: aspectRatio, contentMode: contentMode, context: innerContext) + } } @Composable private func RenderSymbolImage(name: String, url: URL, label: Text?, aspectRatio: Double?, contentMode: ContentMode?, context: ComposeContext) { diff --git a/Sources/SkipUI/SkipUI/Environment/EnvironmentValues.swift b/Sources/SkipUI/SkipUI/Environment/EnvironmentValues.swift index 315f111a..6adf6e8d 100644 --- a/Sources/SkipUI/SkipUI/Environment/EnvironmentValues.swift +++ b/Sources/SkipUI/SkipUI/Environment/EnvironmentValues.swift @@ -894,6 +894,11 @@ extension EnvironmentValues { set { setBuiltinValue(key: "_badgeProminence", value: newValue, defaultValue: { BadgeProminence.standard }) } } + var _subcomposeAsyncImage: Bool { + get { builtinValue(key: "_subcomposeAsyncImage", defaultValue: { false }) as! Bool } + set { setBuiltinValue(key: "_subcomposeAsyncImage", value: newValue, defaultValue: { false }) } + } + var _symbolVariants: SymbolVariants { get { builtinValue(key: "_symbolVariants", defaultValue: { SymbolVariants.none }) as! SymbolVariants } set { setBuiltinValue(key: "_symbolVariants", value: newValue, defaultValue: { SymbolVariants.none }) } diff --git a/Sources/SkipUI/SkipUI/View/AdditionalViewModifiers.swift b/Sources/SkipUI/SkipUI/View/AdditionalViewModifiers.swift index 3255f069..c85c9c3d 100644 --- a/Sources/SkipUI/SkipUI/View/AdditionalViewModifiers.swift +++ b/Sources/SkipUI/SkipUI/View/AdditionalViewModifiers.swift @@ -1491,6 +1491,31 @@ final class DisabledModifier: EnvironmentModifier { } } +extension View { + /// When `true`, SkipUI's ``Image`` / ``AsyncImage`` use Coil's ``SubcomposeAsyncImage`` (slower; avoids first-frame `Empty` state with cached images). Default is `false. + // SKIP @bridge + public func subcomposeAsyncImage(_ enabled: Bool = true) -> any View { + #if SKIP + return ModifiedContent(content: self, modifier: SubcomposeAsyncImageModifier(enabled)) + #else + return self + #endif + } +} + +final class SubcomposeAsyncImageModifier: EnvironmentModifier { + let enabled: Bool + + init(_ enabled: Bool) { + self.enabled = enabled + super.init() + self.action = { + $0.set_subcomposeAsyncImage(enabled) + return ComposeResult.ok + } + } +} + final class PaddingModifier: RenderModifier { let insets: EdgeInsets From 69f3a066d28a1637699ee19de6682f95c55f52e6 Mon Sep 17 00:00:00 2001 From: Dan Fabulich Date: Thu, 14 May 2026 17:43:55 -0700 Subject: [PATCH 2/2] Fix Complex Layout ImagePlayground --- Sources/SkipUI/SkipUI/Components/Image.swift | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/Sources/SkipUI/SkipUI/Components/Image.swift b/Sources/SkipUI/SkipUI/Components/Image.swift index eef65109..59adbb10 100644 --- a/Sources/SkipUI/SkipUI/Components/Image.swift +++ b/Sources/SkipUI/SkipUI/Components/Image.swift @@ -17,7 +17,6 @@ import androidx.compose.material.icons.twotone.__ import androidx.compose.material3.Icon import androidx.compose.material3.LocalTextStyle import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.draw.paint @@ -203,7 +202,14 @@ public struct Image : View, Renderable, Equatable { let innerContext = context.content() Box(modifier: context.modifier.then(sizeResolver), contentAlignment: androidx.compose.ui.Alignment.Center) { - RenderPainter(painter: painter, tintColor: tintColor, scale: scale, aspectRatio: aspectRatio, contentMode: contentMode, context: innerContext) + let hasValidIntrinsic = !painter.intrinsicSize.isUnspecified && !painter.intrinsicSize.width.isNaN() && painter.intrinsicSize.width > 0 && !painter.intrinsicSize.height.isNaN() && painter.intrinsicSize.height > 0 + if hasValidIntrinsic { + RenderPainter(painter: painter, tintColor: tintColor, scale: scale, aspectRatio: aspectRatio, contentMode: contentMode, context: innerContext) + } else { + // Without a valid intrinsic, RenderPainter will try to render the painter with + // fillSize, which can break layout. We're rendering a 0x0 Box as a placeholder. + Box(modifier: innerContext.modifier.then(Modifier.size(0.dp)), contentAlignment: androidx.compose.ui.Alignment.Center) {} + } } }