Original post at http://www.gfrigerio.com/remote-images-in-swiftui/
Almost every iOS app needs to display images, you can have them in your app's bundle or you load them from an external URL, and that's what this article is all about.
As usual you can find the source code on GitHub at this link
Building the UI
In the example I'm presenting a list of stargazers from a given GitHub repository, and I want to show their avatar and the name. The view is really simple
struct StargazerView: View {
var stargazer:User
var body: some View {
HStack {
ImageView(withURL: stargazer.avatarUrl)
Text(stargazer.login)
}
}
}
struct StargazersView: View {
@ObservedObject var viewModel:StargazersViewModel
var body: some View {
VStack {
List(viewModel.stargazers) { stargazer in
StargazerView(stargazer: stargazer)
}
Button(action: {
self.viewModel.getNextStargazers { success in
print("next data received")
}
})
{
Text("next")
}
}
}
}
struct ImageView: View {
@ObservedObject var imageLoader:ImageLoader
@State var image:UIImage = UIImage()
init(withURL url:String) {
imageLoader = ImageLoader(urlString:url)
}
var body: some View {
VStack {
Image(uiImage: image)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width:100, height:100)
}.onReceive(imageLoader.didChange) { data in
self.image = UIImage(data: data) ?? UIImage()
}
}
}
Let's take a look at ImageView. I need a UIImage that I'll convert to a SwiftUI Image as you can see with the call to Image(uiImage:image) and I initialise it with an empty image, but you can set something from your asset catalog, for example an empty avatar image.
We'll take a look at ImageLoader, but first let's see how our view deals with the image being fetched and become available after the view is loaded. At init we create an instance of ImageLoader with the url we want to fetch, and this is an ObservedObject. We need to use a type conforming to ObervableObject in order to use it as @ObservedObject, and this object will need to notify the view when the data is ready. I'll show you two ways to do that, a PassthroughtSubject and a Published property wrapper.
ImageLoader
Let's see the PassthroughtSubject implementation first, it is the one you'll find on GitHub
class ImageLoader: ObservableObject {
var didChange = PassthroughSubject<Data, Never>()
var data = Data() {
didSet {
didChange.send(data)
}
}
init(urlString:String) {
guard let url = URL(string: urlString) else { return }
let task = URLSession.shared.dataTask(with: url) { data, response, error in
guard let data = data else { return }
DispatchQueue.main.async {
self.data = data
}
}
task.resume()
}
}
We need to set a data variable, that's the remote image we're loading. We can initialise it with empty data, so obviously not a valid image. After we fetch the remote data via URLSession dataTask we can change data, and as you can see on didSet we call send on didChange, a PassthroughSubject. Our ImageView registered itself as a receiver of this publisher, so when we set the data variable the receiver is notified and can read it, then try to create a UIImage from it.
Note that you can use .onReceive to deal with a publisher, we could have listened to the publisher on the init function as well.
If you're interested in how a publisher works please refer to my other articles Combine first Example and Networking with Combine.
Using @Published
There is a second, simpler way to implement ImageLoader in order to use it as ObservedObject. On GitHub you'll find only the first implementation, so if you like the alternative implementation you need to copy and past from this article
class ImageLoader: ObservableObject {
@Published var data:Data?
init(urlString:String) {
guard let url = URL(string: urlString) else { return }
let task = URLSession.shared.dataTask(with: url) { data, response, error in
guard let data = data else { return }
DispatchQueue.main.async {
self.data = data
}
}
task.resume()
}
}
As you can see data is still the variable we want to update, but by using the @Published property wrapper we don't need to create our own publisher like a PassthroughSubject and notify our subscribers. The property wrapper takes care of it.
Here's the alternative version of ImageView.
var body: some View {
VStack {
Image(uiImage: imageLoader.data != nil ? UIImage(data:imageLoader.data!)! : UIImage())
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width:100, height:100)
}
}
Here we can check the data property of imageLoader directly. If it is nil we use an empty UIImage, otherwise we can use data to create a UIImage. Once data is set @Published will notify ImageView that will reload Image using the updated version of data from imageLoader.
I don't like testing for nil, so a third option could be having a bool as the published value in ImageLoader, like this
class ImageLoader: ObservableObject {
@Published var dataIsValid = false
var data:Data?
init(urlString:String) {
guard let url = URL(string: urlString) else { return }
let task = URLSession.shared.dataTask(with: url) { data, response, error in
guard let data = data else { return }
DispatchQueue.main.async {
self.dataIsValid = true
self.data = data
}
}
task.resume()
}
}
// ImageView
func imageFromData(_ data:Data) -> UIImage {
UIImage(data: data) ?? UIImage()
}
var body: some View {
VStack {
Image(uiImage: imageLoader.dataIsValid ? imageFromData(imageLoader.data!) : UIImage())
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width:100, height:100)
}
}
So that's it, maybe the second implementation is easier and will be the most popular but I like using publishers so I started with the one with Combine, I think it gives more control even if this is a simple example and it feels overkill to use a PassthroughSubject.
Happy coding!
Top comments (0)