4

I am new to SwiftUI and also Combine. I have a simple List and I am loading iTunes data and building the list. Loading of the images works - but they are flickering, because it seems my dispatch on the main thread keeps firing. I am not sure why. Below is the code for the image loading, followed by where it's implemented.

struct ImageView: View {
    @ObservedObject var imageLoader: ImageLoaderNew
    @State var image: UIImage = UIImage()

    init(withURL url: String) {
        imageLoader = ImageLoaderNew(urlString: url)
    }

    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:60, height:60)
                .background(Color.gray)
        }
    }
}

class ImageLoaderNew: ObservableObject
{    
    @Published var dataIsValid = false
    var data: Data?

    // The dispatch fires over and over again. No idea why yet
    // this causes flickering of the images in the List. 
    // I am only loading a total of 3 items. 

    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
                print(response?.url as Any) // prints over and over again.
            }
        }
        task.resume()
    }
}

And here it's implemented after loading the JSON, etc.

List(results, id: \.trackId) { item in
    HStack {

        // All of these image end up flickering
        // every few seconds or so.
        ImageView(withURL: item.artworkUrl60)
            .padding(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 5))

        VStack(alignment: .leading) {
            Text(item.trackName)
                .foregroundColor(.gray)
                .font(.headline)
                .truncationMode(.middle)
                .lineLimit(2)

            Text(item.collectionName)
                .foregroundColor(.gray)
                .font(.caption)
                .truncationMode(.middle).lineLimit(1)
            }
        }
    }
    .frame(height: 200.0)
    .onAppear(perform: loadData) // Only happens once

I am not sure why the images keep loading over and over again (yet). I must be missing something simple, but I am definitely not wise to the ways of Combine quite yet. Any insight or solution would be much appreciated.

4 Answers 4

4

I was facing a similar issue in a SwiftUI remote image loader I was working on. Try making ImageView equatable to avoid redrawing after the uiImage has been set to something other than the initialized nil value or if it goes back to nil.

struct ImageView: View, Equatable {

    static func == (lhs: ImageView, rhs: ImageView) -> Bool {
        let lhsImage = lhs.image
        let rhsImage = rhs.image
        if (lhsImage == nil && rhsImage != nil) || (lhsImage != nil && rhsImage == nil) {
            return false
        } else {
            return true
        }
    }

    @ObservedObject var imageLoader: ImageLoaderNew
    @State var image: UIImage?

// ... rest the same
Sign up to request clarification or add additional context in comments.

1 Comment

should be marked ad the answer.
3

I would change the order of assigning as below (because otherwise dataIsValid force refresh, but data is not set yet)

DispatchQueue.main.async {
    self.data = data             // 1) set data
    self.dataIsValid = true.     // 2) notify that data is ready

everything else does not seem important. However consider possibility to optimise below, because UIImage construction from data might be also long enough (for UI thread)

func imageFromData(_ data: Data) -> UIImage {
    UIImage(data: data) ?? UIImage()
}

so I would recommend to move not only data, but entire image construction into background thread and refresh only when final image is ready.

Update: I've made your code snapshot work locally, with some replacements and simplifications of course, due to not all parts originally available, and proposed above fix. Here is some experimenting result:

enter image description here

as you see no flickering is observed and no repeated logging in console. As I did not made any major changes in you code logic I assume the issue resulting in reported flickering is not in provided code - probably some other parts cause recreation of ImaveView that gives that effect.

Here is my code (completely, tested with Xcode 11.2/3 & iOS 13.2/3):

struct ImageView: View {
    @ObservedObject var imageLoader: ImageLoaderNew
    @State var image: UIImage = UIImage()

    init(withURL url: String) {
        imageLoader = ImageLoaderNew(urlString: url)
    }

    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:60, height:60)
                .background(Color.gray)
        }
    }
}

class ImageLoaderNew: ObservableObject
{
    @Published var dataIsValid = false
    var data: Data?

    // The dispatch fires over and over again. No idea why yet
    // this causes flickering of the images in the List.
    // I am only loading a total of 3 items.

    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
                self.dataIsValid = true
                print(response?.url as Any) // prints over and over again.
            }
        }
        task.resume()
    }
}

struct TestImageFlickering: View {
    @State private var results: [String] = []
    var body: some View {
        NavigationView {
            List(results, id: \.self) { item in
                HStack {
                    
                    // All of these image end up flickering
                    // every few seconds or so.
                    ImageView(withURL: item)
                        .padding(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 5))
                    
                    VStack(alignment: .leading) {
                        Text("trackName")
                            .foregroundColor(.gray)
                            .font(.headline)
                            .truncationMode(.middle)
                            .lineLimit(2)
                        
                        Text("collectionName")
                            .foregroundColor(.gray)
                            .font(.caption)
                            .truncationMode(.middle).lineLimit(1)
                    }
                }
            }
            .frame(height: 200.0)
            .onAppear(perform: loadData)
        } // Only happens once
    }
    
    func loadData() {
        var urls = [String]()
        for i in 1...10 {
            urls.append("https://placehold.it/120.png&text=image\(i)")
        }
        self.results = urls
    }
}

struct TestImageFlickering_Previews: PreviewProvider {
    static var previews: some View {
        TestImageFlickering()
    }
}

2 Comments

I switched the order - and it still gets called over and over (print(response?.url as Any). Something else is going on.
@Asperi changing the order will not help, but "I would recommend to move not only data, but entire image construction into background thread and refresh only when final image is ready" could help, if the final image will be created as fix sized
1

I faced a similar problem when working with remote images.

I was using an ObservableObject like yours as an image loader, and found out that was the problem. SwiftUI creates the ImageView struct multiple times and if you instantiate a new ImageLoader and load the data each time you end up with hundreds of URLSession requests. Even if the network request is cached you have to convert Data to UIImage hundreds of times.

I solved the flickering by implementing a caching mechanism in a separate class. I still use ImageLoader so I can have an ObservableObject for my SwiftUI view, but I don't create a URLSession data task there, I get the UIImage from a different object and the UIImage itself is cached, not the Data coming from a network request.

class ImageLoader: ObservableObject {
    @Published var image = UIImage()
    
    func load(url:URL) {
        loadImage(fromURL: url)
    }
    
    func load(urlString:String) {
        guard let url = URL(string: urlString) else { return }
        loadImage(fromURL: url)
    }
    
    // MARK: - Private
    private var imageCache = ImageCache.shared
    
    private func loadImage(fromURL url:URL) {
        imageCache.imageForURL(url) { image in
            if let image = image {
                DispatchQueue.main.async {
                    self.image = image
                }
            }
        }
    }
}

And the actual image loading is done in another class

typealias ImageCacheCompletion = (UIImage?) -> Void

class ImageCache {
    static let shared = ImageCache()
    
    func imageForURL(_ url:URL, completion: @escaping ImageCacheCompletion) {
        if let cachedImage = cache.first(where: { record in
            record.urlString == url.absoluteString
        }) {
            completion(cachedImage.image)
        }
        else {
            loadImage(url: url, completion: completion)
        }
    }
    
    // MARK: - Private
    private var cache:[ImageCacheRecord] = []
    private let cacheSize = 10
    
    private func imageFromData(_ data:Data) -> UIImage? {
        UIImage(data: data)
    }
    
    private func loadImage(url:URL, completion: @escaping ImageCacheCompletion) {
        RESTClient.loadData(atURL: url) { result in
            switch result {
            case .success(let data):
                if let image = self.imageFromData(data) {
                    completion(image)
                    self.setImage(image, forUrl: url.absoluteString)
                }
                else {
                    completion(nil)
                }
            case .failure(_ ):
                completion(nil)
            }
        }
    }
    
    private func setImage(_ image:UIImage, forUrl url:String) {
        if cache.count >= cacheSize {
            cache.remove(at: 0)
            
        }
        cache.append(ImageCacheRecord(image: image, urlString: url))
    }
    
    // MARK: - ImageCacheRecord
    
    struct ImageCacheRecord {
        var image:UIImage
        var urlString:String
    }
}

An example of this implementation is on GitHub

you may need to tweak some parameters like the cache size to suit your needs and as you can see I use a FIFO approach so there is room for improvement in the cache class, but that's a good starting point.

Comments

0
// The dispatch fires over and over again. No idea why yet
// this causes flickering of the images in the List. 
// I am only loading a total of 3 items. 

    init(urlString: String) { ....

means that the init is calling over and over again

it could be called only from this part

// All of these image end up flickering
// every few seconds or so.
    ImageView(withURL: item.artworkUrl60)
        .padding(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 5))

In which case SwiftUI will do that? I don't see anything but changing the layout. It seems, that resizing of ImageView is where to find this flickering.

I will try to play with this part

.resizable()
.aspectRatio(contentMode: .fit)

or apply

ImageView(withURL: item.artworkUrl60)
    .padding(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 5))
    .fixedSize()

What is suggested by Asperi (create the fix sized image from downloaded data in background) and remove

.resizable()
.aspectRatio(contentMode: .fit)

from your layout, is probably the best approach

or just move .frame(width:60, height:60) from ImageView and apply it in your List

ImageView(withURL: item.artworkUrl60)
    .frame(width:60, height:60)
    .padding(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 5))

I will try also to fix the sizes of all List components ..., till you find which one makes the flickering.

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.