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:

preview

AsyncImage and AsyncImageCompat both allow you to:

  1. Download and display a remote image based on its URL.
  2. The returned image is already a SwiftUI Image, which you can then resize, change aspect ratio, etc.
  3. Specify a custom placeholder view.
  4. Specify an error view based on the error returned by the remote call.
  5. Use custom Transition to animate state changes.
  6. 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))
}

Next Post Previous Post