Search code examples
jsonswiftswiftuicombine

How to use StateObject and Combine with JSON loader?


I'm currently trying to load JSON in Swift to use it in my UI. I think I've managed to get the JSON to load properly, but I can't test it due to the multiple errors I'm getting in my code.

JSONReader.swift:

import Foundation

struct DatabaseObject: Decodable {
    let name: String
    let books: Books
    let memoryVerses: MemoryVerses
    
    struct Books: Codable {
        let Romans: Book
        let James: Book
        
        struct Book: Codable {
            let abbreviation: String
            let chapters: [Chapter]
            
            struct Chapter: Codable {
                let sections: [Section]
                let footnotes: Footnotes
                
                struct Section: Codable {
                    let title: String
                    let verses: [String]
                }
                
                struct Footnotes: Codable {
                    let a, b, c, d, e, f, g, h, i, j, k, l, m, n, o, p, q, r, s, t, u, v, w, x, y, z: String
                }
                
            }
            
        }
        
    }
    struct MemoryVerses: Codable {
        let singles: [String]
        let multiples: [String]
    }
}

public class JSONReaderSuperclass {
    @Published var contentData: (status: String, result: DatabaseObject?)
    init() {
        contentData = (status: "loading", result: nil)
    }
}

public class JSONReader: JSONReaderSuperclass, ObservableObject {
    
    private func parse(jsonData: Data) -> (status: String, result: DatabaseObject?) {
        do {
            let decodedData = try JSONDecoder().decode(DatabaseObject.self, from: jsonData)
            print(decodedData)
            return (status: "success", result: decodedData)
        } catch {
            return (status: "fail", result: nil)
        }
    }
    private func loadJson(fromURLString urlString: String,
                          completion: @escaping (Result<Data, Error>) -> Void) {
        if let url = URL(string: urlString) {
            let urlSession = URLSession(configuration: .default).dataTask(with: url) { (data, response, error) in
                if let error = error {
                    completion(.failure(error))
                }
                
                if let data = data {
                    completion(.success(data))
                }
            }
            urlSession.resume()
        }
    }
    override init() {
        super.init()
        DispatchQueue.main.async {
            self.loadJson(fromURLString: "redacted for anonymity") { result in
                switch result {
                    case .success(let data):
                        self.contentData = self.parse(jsonData: data)
                    case .failure:
                        self.contentData = (status: "fail", result: nil)
                }
            }
        }
    }
}

ContentView.swift:

import SwiftUI

struct ContentView: View {
    @StateObject var databaseObject = JSONReader()
    var body: some View {
        switch ($databaseObject.status) {
            case "loading":
                Text("Loading...")
            case "success":
                VersePicker(databaseObject: $databaseObject.result)
                    .frame(maxWidth: .infinity, maxHeight: .infinity)
                    .navigationTitle("Content Judge")
                    .navigationBarTitleDisplayMode(.inline)
            case "fail":
                Text("An unknown error occured. Check your internet connection and try again.")
        }
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

VersePicker.swift:

import SwiftUI

enum Book: String, CaseIterable, Identifiable {
    case romans
    case james

    var id: String { self.rawValue }
}

struct VersePicker: View {
    var databaseObject: DatabaseObject
    @State private var selectedBook = Book.romans
    @State private var selectedChapter: Int = 1
    @State private var selectedVerse: Int = 1
    
    var body: some View {
        VStack {
            Picker("Book", selection: $selectedBook) {
                ForEach(Book.allCases) { book in
                    Text(book.rawValue.capitalized)
                        .tag(book)
                }
            }
            HStack {
                Picker("Chapter", selection: $selectedChapter) {
                    ForEach(1...100, id: \.self) { number in
                        Text("\(number)")
                    }
                }
                Picker("Verse", selection: $selectedVerse) {
                    ForEach(1...100, id: \.self) { number in
                        Text("\(number)")
                    }
                }
            }
            .frame(maxHeight: .infinity)
            Spacer()
            NavigationLink(destination: VerseDisplay()) {
                Label("Go", systemImage: "arrow.right.circle")
            }
        }
        .padding()
    }
}

struct VersePicker_Previews: PreviewProvider {
    static var previews: some View {
        VersePicker(databaseObject: JSONReader().result)
    }
}

I'm getting the following errors:

  • ContentView.swift:13 - "Value of type 'ObservedObject.Wrapper' has no dynamic member 'status' using key path from root type 'JSONReader'"
  • ContentView.swift:17 - "Cannot convert value of type 'Binding' to expected argument type 'DatabaseObject'"
  • ContentView.swift:17 - "Value of type 'ObservedObject.Wrapper' has no dynamic member 'result' using key path from root type 'JSONReader'"
  • VersePicker.swift:55 - "Value of type 'JSONReader' has no member 'result'"

Any idea what I'm doing wrong? I'm completely new to Swift, so I'm at a loss.


Solution

  • First remove the tuple and use two separate properties instead

    public class JSONReaderSuperclass {
        @Published var status: String = ""
        @Published var result: DatabaseObject? = nil
    }
    

    here I suggest you change status to be an enum instead.

    Then we need to change the switch in the init because we removed the tuple

    switch result {
    case .success(let data):
        let decoded = self.parse(jsonData: data)
        self.status = decoded.status
        self.result = decoded.result
    case .failure:
        self.status = "fail"
        self.result = nil
    }
    

    As a future change I suggest you don't do the download and decoding in the init but instead put it in a public function that you call from .onAppear or .task in your view.

    In VersePicker you need to make the databaseObject property optional (or remove it since you don't seem to use it)

    var databaseObject: DatabaseObject?
    

    And finally in ContentViewyou need to make some changes as well, to adjust for the new properties but also when you only read a @State, @StateObject or similar property you should not prefix the property with the $ sign, you only do that when you pass the property to a function or view that will change it.

    Here is the body of ContentView

    var body: some View {
        switch (databaseObject.status) {
            case "loading":
                Text("Loading...")
            case "success":
                VersePicker(databaseObject: databaseObject.result)
                    .frame(maxWidth: .infinity, maxHeight: .infinity)
                    .navigationTitle("Content Judge")
                    .navigationBarTitleDisplayMode(.inline)
            case "fail":
                Text("An unknown error occured. Check your internet connection and try again.")
        default:
            fatalError() //Avoid this by changing status from string to an enum
        }
    }