Build and update lists automatically with SwiftUI and Combine

Viewed 968

I'm starting to learn SwiftUI development, I'm making my first basic SwiftUI based news application which I plan on open sourcing but I'm currently stuck. I've been reading Apple's documentation and looking at examples on how to automatically handle data changes in SwiftUI using combine etc. I've found an article, that's suppose to automatically update the list. I haven't been able to see any immediate data changes or anything being logged.

I'm using the same structure as NewsAPI but as an example I've uploaded it to a GitHub repo. I've made a small project and tried updating the data in my repo and trying to see any changes made in my data. I'm honestly trying my best and could really use some pointers or corrections in what my errors may be. I think my confusion lies in @ObservedObject and @Published and how to handle any changes in my content view. The article doesn't show anything they did to handle data changes so maybe I'm missing something?

import Foundation
import Combine

struct News : Codable {
    var articles : [Article]
}

struct Article : Codable,Hashable {
    let description : String?
    let title : String?
    let author: String?
    let source: Source
    let content: String?
    let publishedAt: String?
}

struct Source: Codable,Hashable {
    let name: String?
}


class NewsData: ObservableObject {
    @Published var news: News = News(articles: [])
    
    init() {
        guard let url = URL(string: "https://raw.githubusercontent.com/ca13ra1/data/main/data.json") else { return }
        let request = URLRequest(url: url)
        URLSession.shared.dataTask(with: request) { data, response, error in
            if let data = data {
                if let response = try? JSONDecoder().decode(News.self, from: data) {
                    DispatchQueue.main.async() {
                        self.news = response
                        print("data called")
                    }
                }
            }
        }
        .resume()
    }
}

My View

import SwiftUI
import Combine

struct ContentView: View {
    @ObservedObject var data: NewsData
    var body: some View {
        List(data.news.articles , id: \.self) { article in
            Text(article.title ?? "")
        }
    }
}
2 Answers

The data binding in SwiftUI does no extend to synching state with the server. If thats what you want then you need to use some other mechanism to tell the client that there is new data in the server (ie GraphQL, Firebase, send a push, use a web socket, or poll the server).

A simple polling solution would look like this and note you should not be doing network requests in init, only when you get an on appear from the view because SwiftUI eager hydrates its views even when you cannot see them. Similarly you need to cancel polling when you are off screen:

struct Article: Codable, Identifiable {
  var id: String
}

final class ViewModel: ObservableObject {
  @Published private(set) var articles: [Article] = []
  private let refreshSubject = PassthroughSubject<Void, Never>()
  private var timerSubscription: AnyCancellable?
  init() {
    refreshSubject
      .map {
        URLSession
          .shared
          .dataTaskPublisher(for: URL(string: "someURL")!)
          .map(\.data)
          .decode(type: [Article].self, decoder: JSONDecoder())
          .replaceError(with: [])
      }
      .switchToLatest()
      .receive(on: DispatchQueue.main)
      .assign(to: &$articles)
  }
  func refresh() {
    refreshSubject.send()
    guard timerSubscription == nil else { return }
    timerSubscription = Timer
      .publish(every: 5, on: .main, in: .common)
      .autoconnect()
      .sink(receiveValue: { [refreshSubject] _ in
        refreshSubject.send()
      })
  }
  func onDisappear() {
    timerSubscription = nil
  }
}

struct MyView: View {
  @StateObject private var viewModel = ViewModel()
  var body: some View {
    List(viewModel.articles) { article in
      Text(article.id)
    }
    .onAppear { viewModel.refresh() }
    .onDisappear { viewModel.onDisappear() }
  }
}

I've found a nifty swift package which allows me to easily repeat network calls. It's called swift-request. Thanks to @pawello2222 for helping me solve my dilemma.

import Request

class NewsData: ObservableObject {
    @Published var news: News = News(articles: [])
    
    init() {
        test()
    }
    
    func test() {
        AnyRequest<News> {
            Url("https://raw.githubusercontent.com/ca13ra1/data/main/data.json")
        }
        .onObject { data in
            DispatchQueue.main.async() {
                self.news = data
            }
        }
        .update(every: 300)
        .update(publisher: Timer.publish(every: 300, on: .main, in: .common).autoconnect())
        .call()
    }
}

It's now working as expected, probably the easier option.

Demo:

demo

Related