25
Aug
2021
SwiftUI Load Remote Image (Any Version)
Reading time: 3 min
This recipe provides a drop-in replacement for iOS 15 AsyncImage. This allows you to load images from remote sources on any SwiftUI version, with the code being exactly the same (minus the Compat
at the end of name :]).
The end result looks like this:
AsyncImage
and AsyncImageCompat
both allow you to:
- Download and display a remote image based on its URL.
- The returned image is already a SwiftUI
Image
, which you can then resize, change aspect ratio, etc. - Specify a custom placeholder view.
- Specify an error view based on the error returned by the remote call.
- Use custom
Transition
to animate state changes. - Specify the scale parameter to support different screen densities.
OK, here's the code for AsyncImageCompat
, which offers the same functionality and same code signatures as AsyncImage
:
struct AsyncImageCompat<Content: View>: View {
let url: URL?
let scale: CGFloat
let transaction: Transaction
@ViewBuilder let content: (AsyncImagePhaseCompat) -> Content
@State private var phase: AsyncImagePhaseCompat = .empty
init(url: URL?, scale: CGFloat = 1) where Content == Image {
self.init(url: url, scale: scale) { phase in
phase.image ?? Image(systemName: "gearshape")
}
}
init<I, P>(url: URL?,
scale: CGFloat = 1,
@ViewBuilder content: @escaping (Image) -> I,
@ViewBuilder placeholder: @escaping () -> P)
where Content == _ConditionalContent<I, P>, I : View, P : View {
self.init(url: url, scale: scale) { phase in
if let image = phase.image {
content(image)
} else {
placeholder()
}
}
}
init(url: URL?,
scale: CGFloat = 1,
transaction: Transaction = Transaction(),
@ViewBuilder content: @escaping (AsyncImagePhaseCompat) -> Content) {
self.url = url
self.scale = scale
self.transaction = transaction
self.content = content
}
var body: some View {
content(phase)
.onAppear(perform: load)
}
private func load() {
guard phase.isEmpty,
let url = url
else {
return
}
URLSession.shared.dataTask(with: url) { data, response, error in
if let data = data,
let uiImage = UIImage(data: data, scale: scale) {
withTransaction(transaction) {
phase = .success(Image(uiImage: uiImage))
}
} else if let error = error {
withTransaction(transaction) {
phase = .failure(error)
}
}
}.resume()
}
}
enum AsyncImagePhaseCompat {
case empty,
success(Image),
failure(Error)
var isEmpty: Bool {
if case .empty = self {
return true
} else {
return false
}
}
var image: Image? {
if case let .success(image) = self {
return image
} else {
return nil
}
}
var error: Error? {
if case let .failure(error) = self {
return error
} else {
return nil
}
}
}
And here's some code showing them side-by-side in action. As you can see, the only difference is the Compat
part of the name:
HStack {
AsyncImage(url: URL(string: "https://swiftuirecipes.com/user/themes/afterburner2/images/logo_small.png"),
transaction: .init(animation: .easeInOut(duration: 2)) ) { phase in
if let image = phase.image {
image // Displays the loaded image.
.resizable()
} else if phase.error != nil {
Color.red // Indicates an error.
} else {
Color.blue // Acts as a placeholder.
}
}
.frame(width: 128, height: 128)
.clipShape(RoundedRectangle(cornerRadius: 24))
AsyncImageCompat(url: URL(string: "https://swiftuirecipes.com/user/themes/afterburner2/images/logo_small.png"),
transaction: .init(animation: .easeInOut(duration: 2))) { phase in
if let image = phase.image {
image // Displays the loaded image.
.resizable()
} else if phase.error != nil {
Color.red // Indicates an error.
} else {
Color.blue // Acts as a placeholder.
}
}
.frame(width: 128, height: 128)
.clipShape(RoundedRectangle(cornerRadius: 24))
}