How to display an image in SwiftUI protected by header-based authentication

Viewed 366

I have been using AsyncImage for some time now to render remote images in my SwiftUI app.

https://developer.apple.com/documentation/swiftui/asyncimage

I now have a requirement to render images that are protected assets - I must supply a bearer token in the header to download these images.

It is clear that AsyncImage is not up to the job here as the request is not customisable in anyway.

I have an ImageLoader protocol;

protocol ImageLoader {
    func loadImage(for url: URL) async throws -> UIImage
}

Which describes a service I have written for downloading remote images.

How can I provide this service to views nested multiple levels down within my view hierarchy.

For example;

struct UserAvatarView: View {
    
    private let name: String
    private let initials: String
    private let image: UIImage?
    
    init(name: String, initials: String, image: UIImage?) {
        self.name = name
        self.initials = initials
        self.image = image
    }
    
    var body: some View {
        GeometryReader { proxy in
            ZStack {
                if let image = image {
                    Image(uiImage: image)
                        .resizable()
                        .scaledToFill()
                        .clipped()
                        .aspectRatio(contentMode: .fill)
                        .clipShape(Rectangle())
                        .frame(width: proxy.size.width - 4, height: proxy.size.width - 4)
                        .clipShape(Circle())
                        .padding(2)
                } else {
                    Circle()
                        .fill(Color(AvatarColor.makeColor(forText: name)))
                        .padding(2)
                    
                    Text(initials)
                        .font(Font(UIFont.preferredFont(for: .body, weight: .regular, size: proxy.size.width / 2.55)))
                        .foregroundColor(.white)
                }
                
                Circle()
                    .strokeBorder(Color.white, lineWidth: 2)
            }
        }
        .shadowed()
    }
}

This view could be nested many levels down, I have written it to be as dumb as possible, but at some point I need to fetch the image and update this view.

Should this view itself fetch the image?

I could implement a UserAvatarViewModel that accepts an ImageLoader instance - but it is not clear to me how I would create that model with the required dependency.

I am keen to avoid a singleton instance and I do not believe the view should be responsible for creating the view model, which rules out Environment for passing the loader down.

I had considered the upper most view be responsible for all the heavy lifting, but then I could be passing properties to views, only for them to pass them to their child views, which feels like an anti-pattern again.

1 Answers

You just need replace AsyncImage component with your own component. I created this component, is similar to AsyncImage but this receives a custom async function to get the image

struct CustomAsyncImage<Content: View, Placeholder: View>: View {

  @State var uiImage: UIImage?

  let getImage: () async -> UIImage?
  let content: (Image) -> Content
  let placeholder: () -> Placeholder

  init(
      getImage: @escaping () async -> UIImage?,
      @ViewBuilder content: @escaping (Image) -> Content,
      @ViewBuilder placeholder: @escaping () -> Placeholder
  ){
      self.getImage = getImage
      self.content = content
      self.placeholder = placeholder
  }

  var body: some View {
      if let uiImage = uiImage {
          content(Image(uiImage: uiImage))
      }else {
          placeholder()
              .task {
                  self.uiImage = await getImage()
              }
      }
  }
}

This is an example of use.

CustomAsyncImage(getImage: getImage) { image in
  image
    .resizable()
    .scaledToFit()
    .frame(maxWidth: .infinity)
} placeholder: {
    Text("PlaceHolder")
}

Where getImage is an async function that return an UIImage, you can make that function as you want.

I gonna show you my function as example.

func getImage() async -> UIImage? {
    guard let url = URL(string: BASE_URL + "cards/5fd36d31937b6b32d47ad9db/attachments/5fd36d31937b6b32d47ad9de/download") else {
        fatalError("Missing URL")
    }
    
    var request = URLRequest(url: url)
    request.addValue("OAuth oauth_consumer_key=\"\(API_KEY)\", oauth_token=\"\(ACCOUNT_TOKEN)\"", forHTTPHeaderField: "Authorization")
    
    do {
        let (data, response) = try await URLSession.shared.data(for: request)
        
        guard (response as? HTTPURLResponse)?.statusCode == 200 else { fatalError("Error while fetching attchment") }
        
        return UIImage(data: data)
    } catch {
        return nil
    }
}
Related