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)
}
}
}
}
}
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()
}
}
/********************************************************************/
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)
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
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.)
A simpler and cleaner way to load an image in SwiftUI is to use the renowned Kingfisher library.
Kingfisher
via Swift Package ManagerSelect 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.
import KingfisherSwiftUI
KFImage(myUrl)
Done! It's that easy
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) }
}
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With