Search code examples
swiftuiobservableobjectobservedobject

How to keep reference of data when using ObservableObject


I am new to Swiftui and I struggle to understand how to properly retain data created in ObservableObject when rendering views? Or a completely different approach to the problem maybe?

More specifically, it is about getting HTTP data in each row in a List().

Right now, it makes the HTTP call far too often when parent views are rendered, which causes all rows to be reloaded.

The same issue can be found here: Keep reference on view/data model after View update

public class VideoFetcher: ObservableObject {
    @Published var video: VideoResponse?
    @Published var coverImage: UIImage?
    @Published var coverImageLoading = false
    @Published var categories: String?
    @Published var loading = false
    @Published var error = false

    func load(mediaItemSlug: String = "", broadcasterSlug: String = "") {
        self.loading = true

        Video.findBySlug(
            mediaItemSlug: mediaItemSlug,
            broadcasterSlug: broadcasterSlug,
            successCallback: {video -> Void in                
                self.video = video
                self.loading = false

                self.setCategories()
                self.loadCoverImage()
            },
            errorCallback: {(error, _) -> Void in
                self.loading = false
                self.error = true
            })
    }

    func loadCoverImage() {
        guard self.video!.coverImageUrl != "" else {
            return
        }

        self.coverImageLoading = true

        let downloader = ImageDownloader()
        let urlRequest = URLRequest(url: URL(string: self.video!.coverImageUrl)!)
        let filter = AspectScaledToFillSizeFilter(size: CGSize(width: 520.0, height: 292.499999963))

        downloader.download(urlRequest, filter: filter) { response in
            if case .success(let image) = response.result {
                self.coverImage = image
                self.coverImageLoading = false
            }
        }
    }

    func setCategories() {
        if (self.video!.broadcaster.categories.count > 0) {
            let categoryNames = self.video!.broadcaster.categories.map { category in
                return category.name == "" ? "(no name)" : category.name
            }

            self.categories = categoryNames.joined(separator: " • ");
        }
    }
}

List() row:

struct VideoCard: View {
    @ObservedObject var fetcher = VideoFetcher()
    ...
    init() {
        // Causes reload each render
        self.fetcher.load()
    }

    var body: some View {
        ...
        .onAppear {
            // Loads that on appear but fetcher.video is nil after view re-rendered because load() wasn't called
            self.fetcher.load()
        }
    }
}

Solution

  • Thanks, Chris. I thought I was doing something wrong on an architectural level but I added caching and that solved my problem.

    import Alamofire
    import AlamofireImage
    import Cache
    
    public class VideoFetcher: ObservableObject {
        @Published var video: VideoResponse?
        @Published var coverImage: UIImage?
        @Published var coverImageLoading = false
        @Published var broadcasterImage: UIImage?
        @Published var categories: String?
        @Published var loading = false
        @Published var error = false
    
        func load(mediaItemSlug: String = "", broadcasterSlug: String = "") {
            let videoCache = try? AppCache.video!.object(forKey: mediaItemSlug)
    
            if (videoCache != nil) {
                self.video = videoCache
                self.setCategories()
                self.loadCoverImage()
    
                return
            }
    
            self.loading = true
    
            Video.findBySlug(
                mediaItemSlug: mediaItemSlug,
                broadcasterSlug: broadcasterSlug,
                successCallback: {video -> Void in
                    try? AppCache.video!.setObject(video, forKey: mediaItemSlug)
    
                    self.video = video
                    self.loading = false
    
                    self.setCategories()
                    self.loadCoverImage()
                    self.loadBroadcasterImage()
                },
                errorCallback: {(error, _) -> Void in
                    self.loading = false
                    self.error = true
                })
        }
    
        func loadCoverImage() {
            let coverImageUrl = self.video!.coverImageUrl
    
            guard coverImageUrl != "" else {
                return
            }
    
            let urlRequest = URLRequest(url: URL(string: coverImageUrl)!)
            let cachedImage = AppCache.image!.image(for: urlRequest, withIdentifier: coverImageUrl)
    
            if (cachedImage != nil) {
                self.coverImage = cachedImage
                return
            }
    
            self.coverImageLoading = true
    
            let downloader = ImageDownloader(imageCache: AppCache.image!)
            let filter = AspectScaledToFillSizeFilter(size: CGSize(width: 520.0, height: 292.499999963))
    
            downloader.download(urlRequest, filter: filter) { response in
                if case .success(let image) = response.result {
                    AppCache.image!.add(image, for: urlRequest, withIdentifier: coverImageUrl)
    
                    self.coverImage = image
                    self.coverImageLoading = false
                }
            }
        }
    
        func loadBroadcasterImage() {
            let broadcasterImage = self.video!.broadcaster.avatarImageUrl
    
            guard broadcasterImage != "" else {
                return
            }
    
            let urlRequest = URLRequest(url: URL(string: broadcasterImage)!)
            let cachedImage = AppCache.image!.image(for: urlRequest, withIdentifier: broadcasterImage)
    
            if (cachedImage != nil) {
                self.broadcasterImage = cachedImage
                return
            }
    
            let downloader = ImageDownloader(imageCache: AppCache.image!)
            let filter = AspectScaledToFillSizeFilter(size: CGSize(width: 16, height: 16))
    
            downloader.download(urlRequest, filter: filter) { response in
                if case .success(var image) = response.result {
                    image = image.af.imageRoundedIntoCircle()
                    AppCache.image!.add(image, for: urlRequest, withIdentifier: broadcasterImage)
                    self.broadcasterImage = image
                }
            }
        }
    
        func setCategories() {
            let categories = self.video!.broadcaster.categories
    
            if (categories.count > 0) {
                let categoryNames = categories.map { category in
                    return category.name == "" ? "(no name)" : category.name
                }
    
                self.categories = categoryNames.joined(separator: " • ");
            }
        }
    }