Nick Lee
Nick Lee

Reputation: 13

SwiftUI and Combine not working smoothly when downloading image asynchrously

When I tried to use SwiftUI & Combine to download image asynchrously, it works fine. Then, I try to implement this into a dynamic list, and I found out there is only one row(the last row) will be show correctly, images in other cells are missing. I have trace the code with breakpoints and I'm sure the image download process is success in others, but only the last row will trigger the @ObjectBinding to update image. Please check my sample code and let me know if there's any wrong. Thanks!

struct UserView: View {
    var name: String
    @ObjectBinding var loader: ImageLoader

    init(name: String, loader: ImageLoader) {
        self.name = name
        self.loader = loader
    }

    var body: some View {
        HStack {
            Image(uiImage: loader.image ?? UIImage())
                .onAppear {
                    self.loader.load()
            }
            Text("\(name)")
        }
    }
}

struct User {
    let name: String
    let imageUrl: String
}

struct ContentView : View {
    @State var users: [User] = []
    var body: some View {
        NavigationView {
            List(users.identified(by: \.name)) { user in
                UserView(name: user.name, loader: ImageLoader(with: user.imageUrl))
            }
            .navigationBarTitle(Text("Users"))
            .navigationBarItems(trailing:
                Button(action: {
                    self.didTapAddButton()
                }, label: {
                    Text("+").font(.system(size: 36.0))
                }))
        }
    }

    func didTapAddButton() {
        fetchUser()
    }

    func fetchUser() {
        API.fetchData { (user) in
            self.users.append(user)
        }
    }
}

class ImageLoader: BindableObject {

    let didChange = PassthroughSubject<UIImage?, Never>()

    var urlString: String
    var task: URLSessionDataTask?
    var image: UIImage? = UIImage(named: "user") {
        didSet {
            didChange.send(image)
        }
    }

    init(with urlString: String) {
        print("init a new loader")
        self.urlString = urlString
    }

    func load() {
        let url = URL(string: urlString)!
        let task = URLSession.shared.dataTask(with: url) { (data, _, error) in
            if error == nil {
                DispatchQueue.main.async {
                    self.image = UIImage(data: data!)
                }
            }
        }
        task.resume()
        self.task = task
    }

    func cancel() {
        if let task = task {
            task.cancel()
        }
    }
}

class API {
    static func fetchData(completion: @escaping (User) -> Void) {
        let request = URLRequest(url: URL(string: "https://randomuser.me/api/")!)
        let task = URLSession.shared.dataTask(with: request) { (data, _, error) in
            guard error == nil else { return }

            do {
                let json = try JSONSerialization.jsonObject(with: data!, options: []) as? [String: Any]
                guard
                    let results = json!["results"] as? [[String: Any]],
                    let nameDict = results.first!["name"] as? [String: String],
                    let pictureDict = results.first!["picture"] as? [String: String]
                    else { return }

                let name = "\(nameDict["last"]!) \(nameDict["first"]!)"
                let imageUrl = pictureDict["thumbnail"]
                let user = User(name: name, imageUrl: imageUrl!)
                DispatchQueue.main.async {
                    completion(user)
                }
            } catch let error {
                print(error.localizedDescription)
            }
        }
        task.resume()
    }
}

every images should be downloaded successfully no matter how many items in the list.

Upvotes: 1

Views: 1283

Answers (1)

kontiki
kontiki

Reputation: 40489

There seems to be a bug in @ObjectBinding. I am not sure and I cannot confirm yet. I want to create a minimal example code to be sure, and if so, report a bug to Apple. It seems that sometimes SwiftUI does not invalidate a view, even if the @ObjectBinding it is based upon has its didChange.send() called. I posted my own question (@BindableObject async call to didChange.send() does not invalidate its view (and never updates))

In the meantime, I try to use EnvironmentObject whenever I can, as the bug doesn't seem to be there.

Your code then works with very few changes. Instead of using ObjectBinding, use EnvironmentObject:

enter image description here

Code Replacing @ObjectBinding with @EnvironmentObject:


import SwiftUI
import Combine

struct UserView: View {
    var name: String
    @EnvironmentObject var loader: ImageLoader

    init(name: String) {
        self.name = name
    }

    var body: some View {
        HStack {
            Image(uiImage: loader.image ?? UIImage())
                .onAppear {
                    self.loader.load()
            }
            Text("\(name)")
        }
    }
}

struct User {
    let name: String
    let imageUrl: String
}

struct ContentView : View {
    @State var users: [User] = []
    var body: some View {
        NavigationView {
            List(users.identified(by: \.name)) { user in
                UserView(name: user.name).environmentObject(ImageLoader(with: user.imageUrl))
            }
            .navigationBarTitle(Text("Users"))
                .navigationBarItems(trailing:
                    Button(action: {
                        self.didTapAddButton()
                    }, label: {
                        Text("+").font(.system(size: 36.0))
                    }))
        }
    }

    func didTapAddButton() {
        fetchUser()
    }

    func fetchUser() {
        API.fetchData { (user) in
            self.users.append(user)
        }
    }
}

class ImageLoader: BindableObject {

    let didChange = PassthroughSubject<UIImage?, Never>()

    var urlString: String
    var task: URLSessionDataTask?
    var image: UIImage? = UIImage(named: "user") {
        didSet {
            didChange.send(image)
        }
    }

    init(with urlString: String) {
        print("init a new loader")
        self.urlString = urlString
    }

    func load() {
        let url = URL(string: urlString)!
        let task = URLSession.shared.dataTask(with: url) { (data, _, error) in
            if error == nil {
                DispatchQueue.main.async {
                    self.image = UIImage(data: data!)
                }
            }
        }
        task.resume()
        self.task = task
    }

    func cancel() {
        if let task = task {
            task.cancel()
        }
    }
}

class API {
    static func fetchData(completion: @escaping (User) -> Void) {
        let request = URLRequest(url: URL(string: "https://randomuser.me/api/")!)
        let task = URLSession.shared.dataTask(with: request) { (data, _, error) in
            guard error == nil else { return }

            do {
                let json = try JSONSerialization.jsonObject(with: data!, options: []) as? [String: Any]
                guard
                    let results = json!["results"] as? [[String: Any]],
                    let nameDict = results.first!["name"] as? [String: String],
                    let pictureDict = results.first!["picture"] as? [String: String]
                    else { return }

                let name = "\(nameDict["last"]!) \(nameDict["first"]!)"
                let imageUrl = pictureDict["thumbnail"]
                let user = User(name: name, imageUrl: imageUrl!)
                DispatchQueue.main.async {
                    completion(user)
                }
            } catch let error {
                print(error.localizedDescription)
            }
        }
        task.resume()
    }
}

Upvotes: 3

Related Questions