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