How can I load an UIImage into a SwiftUI Image asynchronously?

33,387

Solution 1

SwiftUI 3

Starting from iOS 15 we can now use AsyncImage:

AsyncImage(url: URL(string: "https://example.com/icon.png")) { image in
    image.resizable()
} placeholder: {
    ProgressView()
}
.frame(width: 50, height: 50)

SwiftUI 2

Here is a native SwiftUI solution that supports caching and multiple loading states:

import Combine
import SwiftUI

struct NetworkImage: View {
    @StateObject private var viewModel = ViewModel()

    let url: URL?

    var body: some View {
        Group {
            if let data = viewModel.imageData, let uiImage = UIImage(data: data) {
                Image(uiImage: uiImage)
                    .resizable()
                    .aspectRatio(contentMode: .fit)
            } else if viewModel.isLoading {
                ProgressView()
            } else {
                Image(systemName: "photo")
            }
        }
        .onAppear {
            viewModel.loadImage(from: url)
        }
    }
}
extension NetworkImage {
    class ViewModel: ObservableObject {
        @Published var imageData: Data?
        @Published var isLoading = false

        private static let cache = NSCache<NSURL, NSData>()

        private var cancellables = Set<AnyCancellable>()

        func loadImage(from url: URL?) {
            isLoading = true
            guard let url = url else {
                isLoading = false
                return
            }
            if let data = Self.cache.object(forKey: url as NSURL) {
                imageData = data as Data
                isLoading = false
                return
            }
            URLSession.shared.dataTaskPublisher(for: url)
                .map { $0.data }
                .replaceError(with: nil)
                .receive(on: DispatchQueue.main)
                .sink { [weak self] in
                    if let data = $0 {
                        Self.cache.setObject(data as NSData, forKey: url as NSURL)
                        self?.imageData = data
                    }
                    self?.isLoading = false
                }
                .store(in: &cancellables)
        }
    }
}

(The above code doesn't use any third-party libraries, so it's easy to change the NetworkImage in any way.)


Demo

enter image description here

import Combine
import SwiftUI

struct ContentView: View {
    @State private var showImage = false

    var body: some View {
        if showImage {
            NetworkImage(url: URL(string: "https://stackoverflow.design/assets/img/logos/so/logo-stackoverflow.png"))
                .frame(maxHeight: 150)
                .padding()
        } else {
            Button("Load") {
                showImage = true
            }
        }
    }
}

(I used an exceptionally large Stack Overflow logo to show the loading state.)

Solution 2

Pass your Model to ImageRow struct which contains url.

import SwiftUI
import Combine

struct ContentView : View {
    var listData: Post
    var body: some View {
        List(model.post) { post in
            ImageRow(model: post) // Get image
        }
    }
}

/********************************************************************/
// Download Image

struct ImageRow: View {
    let model: Post
    var body: some View {
        VStack(alignment: .center) {
            ImageViewContainer(imageUrl: model.avatar_url)
        }
    }
}

struct ImageViewContainer: View {
    @ObjectBinding var remoteImageURL: RemoteImageURL

    init(imageUrl: String) {
        remoteImageURL = RemoteImageURL(imageURL: imageUrl)
    }

    var body: some View {
        Image(uiImage: UIImage(data: remoteImageURL.data) ?? UIImage())
            .resizable()
            .clipShape(Circle())
            .overlay(Circle().stroke(Color.black, lineWidth: 3.0))
            .frame(width: 70.0, height: 70.0)
    }
}

class RemoteImageURL: BindableObject {
    var didChange = PassthroughSubject<Data, Never>()
    var data = Data() {
        didSet {
            didChange.send(data)
        }
    }
    init(imageURL: String) {
        guard let url = URL(string: imageURL) else { return }

        URLSession.shared.dataTask(with: url) { (data, response, error) in
            guard let data = data else { return }

            DispatchQueue.main.async { self.data = data }

            }.resume()
    }
}
/********************************************************************/

Solution 3

A simpler and cleaner way to load an image in SwiftUI is to use the renowned Kingfisher library.

  1. Add Kingfisher via Swift Package Manager

Select File > Swift Packages > Add Package Dependency. Enter https://github.com/onevcat/Kingfisher.git

in the "Choose Package Repository" dialog. In the next page, specify the version resolving rule as "Up to Next Major" with "5.8.0" as its earliest version.

After Xcode checking out the source and resolving the version, you can choose the "KingfisherSwiftUI" library and add it to your app target.

  1. import KingfisherSwiftUI
  2. KFImage(myUrl)

Done! It's that easy

Solution 4

Define the imageLoader as @ObjectBinding:

@ObjectBinding private var imageLoader: ImageLoader

It would make more sense to init the view with the url for the image :

struct SampleView : View {

    var imageUrl: URL

    private var image: UIImage {
        imageLoader.image(for: imageUrl)
    }

    @ObjectBinding private var imageLoader: ImageLoader

    init(url: URL) {
        self.imageUrl = url
        self.imageLoader = ImageLoader()
    }

    var body: some View {
        Image(uiImage: image)
            .frame(width: 200, height: 300)
            .aspectRatio(contentMode: ContentMode.fit)
    }
}

For example :

//Create a SampleView with an initial photo
var s = SampleView(url: URL(string: "https://placebear.com/200/300")!)
//You could then update the photo by changing the imageUrl
s.imageUrl = URL(string: "https://placebear.com/200/280")!

Solution 5

I would just use the onAppear callback

import Foundation
import SwiftUI
import Combine
import UIKit
    struct ImagePreviewModel {
        var urlString : String
        var width : CGFloat = 100.0
        var height : CGFloat = 100.0
    }

    struct ImagePreview: View {
        let viewModel: ImagePreviewModel
        @State var initialImage = UIImage()
        var body: some View {
            Image(uiImage: initialImage)
                .resizable()
                .aspectRatio(contentMode: .fit)
                .frame(width: self.width, height: self.height)
                .onAppear {
                    guard let url = URL(string: self.viewModel.urlString) else { return }
                    URLSession.shared.dataTask(with: url) { (data, response, error) in
                        guard let data = data else { return }
                        guard let image = UIImage(data: data) else { return }

                        RunLoop.main.perform {
                            self.initialImage = image
                        }

                    }.resume()
                }
        }
        var width: CGFloat { return max(viewModel.width, 100.0) }
        var height: CGFloat { return max(viewModel.height, 100.0) }
    }
Share:
33,387
Alberto Penas
Author by

Alberto Penas

Updated on June 17, 2021

Comments

  • Alberto Penas
    Alberto Penas almost 3 years

    In SwiftUI there are some .init methods to create an Image but none of them admits a block or any other way to load an UIImage from network/cache...

    I am using Kingfisher to load images from network and cache inside a list row, but the way to draw the image in the view is to re-render it again, which I would prefer to not do. Also, I am creating a fake image(only coloured) as placeholder while the image gets fetched. Another way would be to wrap all inside a custom view and only re-render the wrapper. But I haven't tried yet.

    This sample is working right now. Any idea to improve the current one will be great

    Some view using the loader

    struct SampleView : View {
    
        @ObjectBinding let imageLoader: ImageLoader
    
        init(imageLoader: ImageLoader) {
            self.imageLoader = imageLoader
        }
    
        var body: some View {
           Image(uiImage: imageLoader.image(for: "https://url-for-image"))
              .frame(width: 128, height: 128)
              .aspectRatio(contentMode: ContentMode.fit)
        }
    
    }
    
    import UIKit.UIImage
    import SwiftUI
    import Combine
    import class Kingfisher.ImageDownloader
    import struct Kingfisher.DownloadTask
    import class Kingfisher.ImageCache
    import class Kingfisher.KingfisherManager
    
    class ImageLoader: BindableObject {
    
        var didChange = PassthroughSubject<ImageLoader, Never>()
        private let downloader: ImageDownloader
        private let cache: ImageCache
        private var image: UIImage? {
            didSet {
                dispatchqueue.async { [weak self] in
                    guard let self = self else { return }
                    self.didChange.send(self)
                }
            }
        }
        private var task: DownloadTask?
        private let dispatchqueue: DispatchQueue
    
        init(downloader: ImageDownloader = KingfisherManager.shared.downloader,
             cache: ImageCache = KingfisherManager.shared.cache,
             dispatchqueue: DispatchQueue = DispatchQueue.main) {
            self.downloader = downloader
            self.cache = cache
            self.dispatchqueue = dispatchqueue
        }
    
        deinit {
            task?.cancel()
        }
    
        func image(for url: URL?) -> UIImage {
            guard let targetUrl = url else {
                return UIImage.from(color: .gray)
            }
            guard let image = image else {
                load(url: targetUrl)
                return UIImage.from(color: .gray)
            }
            return image
        }
    
        private func load(url: URL) {
            let key = url.absoluteString
            if cache.isCached(forKey: key) {
                cache.retrieveImage(forKey: key) {  [weak self] (result) in
                    guard let self = self else { return }
                    switch result {
                    case .success(let value):
                        self.image = value.image
                    case .failure(let error):
                        print(error.localizedDescription)
                    }
                }
            } else {
                downloader.downloadImage(with: url, options: nil, progressBlock: nil) {  [weak self] (result) in
                    guard let self = self else { return }
                    switch result {
                    case .success(let value):
                        self.cache.storeToDisk(value.originalData, forKey: url.absoluteString)
                        self.image = value.image
                    case .failure(let error):
                        print(error.localizedDescription)
                    }
                }
            }
        }
    
    }
    
    • DenFav
      DenFav almost 5 years
      In this case you need to use Image(uiImage: imageLoader.image to set an image to view. And start image downloading on init or before presenting the Image view struct
    • Alberto Penas
      Alberto Penas over 4 years
      Kingfisher now supports swiftUI
  • Alberto Penas
    Alberto Penas almost 5 years
    The code I left is a sample, I forgot to add it. Thanks for pointing it @ielyamani
  • Mario Burga
    Mario Burga over 4 years
    This does not work in Xcode 11 beta 7 and GM replaced BindableObject with ObservableObject and ObjectBinding with ObservedObject, but it does not work to load the remote image. Any suggestions
  • Mario Burga
    Mario Burga over 4 years
    Update: Work with that change --> @Published var data = Data()
  • mobibob
    mobibob over 2 years
    the version i am using today (10-10-2021) appears to have a dependency on ProgressView (part of preview). I can comment it out and ignore the problem, but I prefer to include. Any idea where ProgressView dependency comes from?