0

I have been trying to asynchronously load an image in my app using combine. Currently all the other pieces of data are loading fine, but my image seem to be stuck in a progress view. Why? I am not too familiar with how combine works as I have been trying to follow a tutorial and adapting it to fit my needs, which is why I ran into this problem.

This is my code:

Main View:

import SwiftUI

struct ApodView: View {
    @StateObject var vm = ApodViewModel()
    
    var body: some View {
        ZStack {
            // Background Layer
            Color.theme.background
                .ignoresSafeArea()
            
            // Content Layer
            VStack() {
                Text(vm.apodData?.title ?? "Placeholder")
                    .font(.title)
                    .fontWeight(.bold)
                    .multilineTextAlignment(.center)
                    .foregroundColor(Color.theme.accent)
                
                ApodImageView(apodData: vm.apodData ?? ApodModel(date: "", explanation: "", url: "", thumbnailUrl: "", title: ""))
           
                ZStack() {
                    
                    Color.theme.backgroundTextColor
                    
                    ScrollView(showsIndicators: false) {
                        Text(vm.apodData?.explanation ?? "Loading...")
                            .font(.body)
                            .fontWeight(.semibold)
                            .foregroundColor(Color.theme.accent)
                            .multilineTextAlignment(.center)
                            .padding()
                    }
                }
                .cornerRadius(10)
            }
            .padding()
        }
    }
}

ImageView:

import SwiftUI

struct ApodImageView: View {
    
    @StateObject var vm: ApodImageViewModel
    
    init(apodData: ApodModel) {
        _vm = StateObject(wrappedValue: ApodImageViewModel(apodData: apodData))
    }
    
    var body: some View {
        ZStack {
            if let image = vm.image {
                Image(uiImage: image)
                    .resizable()
                    .scaledToFit()
            } else if vm.isLoading {
                ProgressView()
            } else {
                Image(systemName: "questionmark")
                    .foregroundColor(Color.theme.secondaryText)
            }
        }
        .frame(maxWidth: .infinity, maxHeight: 250)
        .cornerRadius(10)
    }
}

Image ViewModel:

import Foundation
import SwiftUI
import Combine

class ApodImageViewModel: ObservableObject {
    
    @Published var image: UIImage?
    @Published var isLoading: Bool = false
    
    private let apodData: ApodModel
    private let dataService: ApodImageService
    private var cancellables = Set<AnyCancellable>()
    
    init(apodData: ApodModel) {
        self.apodData = apodData
        self.dataService = ApodImageService(apodData: apodData)
        self.addSubscribers()
        self.isLoading = true
    }
    
    private func addSubscribers() {
        dataService.$image
            .sink { [weak self] _ in
                self?.isLoading = false
            } receiveValue: { [weak self] returnedImage in
                self?.image = returnedImage
            }
            .store(in: &cancellables)
    }
}

Networking For Image:

import Foundation
import SwiftUI
import Combine

class ApodImageService: ObservableObject {
    
    @Published var image: UIImage?
    
    private var imageSubscription: AnyCancellable?
    private let apodData: ApodModel
    
    init(apodData: ApodModel) {
        self.apodData = apodData
        getApodImage()
    }
    
    private func getApodImage() {
        guard let url = URL(string: apodData.thumbnailUrl ?? apodData.url) else { return }
        imageSubscription = NetworkingManager.download(url: url)
            .tryMap({ data -> UIImage? in
                return UIImage(data: data)
            })
            .sink(receiveCompletion: NetworkingManager.handleCompletion, receiveValue: { [weak self] returnedImage in
                self?.image = returnedImage
                self?.imageSubscription?.cancel()
            })
    }
}

General Networking Code:

import Foundation
import Combine

class NetworkingManager {
    
    enum NetworkingError: LocalizedError {
        case badURLResponse(url: URL)
        case unknown
        
        var errorDescription: String? {
            switch self {
            case .badURLResponse(url: let url): return "Bad response from URL: \(url)"
            case .unknown: return "Unknown Error Returned"
            }
        }
    }
    
    static func download(url: URL) -> AnyPublisher<Data, Error> {
        return URLSession.shared.dataTaskPublisher(for: url)
            .subscribe(on: DispatchQueue.global(qos: .background))
            .tryMap({ try handleURLResponse(output: $0, url: url) })
            .receive(on: DispatchQueue.main)
            .eraseToAnyPublisher()
    }
  • You have a lot of pieces there. I would try to reduce the problem and add some print statements to see if the image is being downloaded. If it is being downloaded you probably have a Publish issue (for instance publisher within publisher within publisher ... while you are listening for changes on a much higher level published property – Avba Nov 08 '21 at 10:22
  • So, if at this stage it's largely a straight copy of a recent, well used tutorial then I would verifying there's not a network related problem. Add print statements and check the api endpoint being used works from the _target_device_ . So many things can go wrong - from misstyped URL names, to simulators with accidentally disabled or poor simulated networking, to needing API keys and not having them Otherwise, if have been tinkering :-) it’d be helpful to understand what’s been changed from the tutorial code, as well as the target platform and any Console messages. – shufflingb Nov 08 '21 at 11:20
  • I agree that I was thinking it's a Publish issue, as the only thing that seems to make sense to me is that once it makes its API call and receives the values that contain the image URL, it isn't notifying the image ViewModel to grab that image and instead just sits there waiting. How would I fix this in this scenario? – Jungwon Nov 08 '21 at 20:42
  • As for the other issues, the image view itself downloads images when I directly pass in an image URL through the preview, so I feel the issue isn't a non network related problem. – Jungwon Nov 08 '21 at 20:44

1 Answers1

0

Where you have:

private func addSubscribers() {
    dataService.$image
        .sink { [weak self] _ in
            self?.isLoading = false
        } receiveValue: { [weak self] returnedImage in
            self?.image = returnedImage
        }
        .store(in: &cancellables)
}

You are subscribing to the published value of the image property. That image property stream will never complete. It is an infinite sequence tracking the value of that property over time "forever".

I don't think your receiveCompletion will ever be called so self?.isLoading = false will never happen.

Scott Thompson
  • 22,629
  • 4
  • 32
  • 34
  • Then what should I subscribe to instead? And why does this function work when I pass an image URL directly into it then? – Jungwon Nov 10 '21 at 02:23