DJFriar
DJFriar

Reputation: 340

UITableView with sections from a local JSON file

I'm still stuck, and I've been banging my head against a wall for weeks on this. So here I am asking one more time, this time in a more complete manner. I just want to make this work. I've been trying to use Swift 4 as much as possible (since I'm learning it seems easier to stick to one set of rules/syntax, but at this point I don't care what language is used as long as it works so I can move on to the rest of the things I need to do to the app.

Goal: Look at local version of JSON, and compare it to hosted version. If hosted is newer, replace the local version with the newer one. Then parse the local JSON file to create the UITableView, and divide it into sections by state.

Issue: It sorta worked with an old method that was parsing it live from the website, but the sections where showing duplicates and the wrong counts. It now seems to be comparing local to hosted correctly, but the UITableView is not being populated at all now. I suspect all my issues are in the tableView sections, but I've tried it 10 trillion different ways and none of them work. I assume I'm not properly pointing it to the local JSON file.

Code: Here is my entire ViewController:

import UIKit
import os.log
import Foundation

class BonusListViewController: UITableViewController {

    var bonuses = [JsonFile.JsonBonuses]()

    let defaults = UserDefaults.standard

    override func viewDidLoad() {
        super.viewDidLoad()

        // MARK: Data Structures
        // Settings Struct
        struct Constants {
            struct RiderData {
                let riderNumToH = "riderNumToH"
                let pillionNumToH = "pillionNumToH"
            }
            struct RallyData {
                let emailDestinationToH = "emailDestinationToH"
            }
        }

        //MARK: Check for updated JSON file
        checkJSON()

        //MARK: Trigger JSON Download
        /*
        downloadJSON {
            print("downloadJSON Method Called")
        }
        */
    }
    // MARK: - Table View Configuration
    // MARK: Table view data source
    override func numberOfSections(in tableView: UITableView) -> Int {
        print("Found \(bonuses.count) sections.")
        return bonuses.count
    }

    override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        print("Found \(bonuses.count) rows in section.")
        return bonuses.count
    }

    override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = UITableViewCell(style: .default, reuseIdentifier: nil)
        cell.textLabel?.text = bonuses[indexPath.section].name.capitalized
        return cell
    }

    override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        performSegue(withIdentifier: "showDetail", sender: self)
    }
    // MARK: - Table View Header
    override func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat {
        return 30
    }
    override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
        return bonuses[section].state
    }
    override func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat {
        return 3
    }

    // MARK: Functions
    // MARK: - Download JSON from ToH webserver

    func downloadJSON(completed: @escaping () -> ()) {
        let url = URL(string: "http://tourofhonor.com/BonusData.json")
        URLSession.shared.dataTask(with: url!) { [weak self] (data, response, error) in
            if error == nil {
                do {
                    let posts = try JSONDecoder().decode(JsonFile.self, from: data!)
                    DispatchQueue.main.async {
                        completed()
                    }
                    print("Downloading Updated JSON (Version \(posts.meta.version))")
                    print(posts.bonuses.map {$0.bonusCode})
                    print(posts.bonuses.map {$0.state})
                    self?.bonuses = posts.bonuses
                    self?.defaults.set("downloadJSON", forKey: "jsonVersion") //Set version of JSON for comparison later
                    DispatchQueue.main.async {
                        //reload table in the main queue
                        self?.tableView.reloadData()
                    }
                } catch {
                    print("JSON Download Failed")
                }
            }
        }.resume()
    }


    func checkJSON() {
        //MARK: Check for updated JSON file
        let defaults = UserDefaults.standard
        let hostedJSONFile = "http://tourofhonor.com/BonusData.json"
        let jsonURL = URL(string: hostedJSONFile)
        var hostedJSONVersion = ""
        let jsonData = try! Data(contentsOf: jsonURL!)
        let jsonFile = try! JSONSerialization.jsonObject(with: jsonData, options: .mutableContainers) as! [String : Any]
        let metaData = jsonFile["meta"] as! [String : Any]
        hostedJSONVersion = metaData["version"] as! String
        let localJSONVersion = defaults.string(forKey: "jsonVersion")
        if localJSONVersion != hostedJSONVersion {
            print("L:\(localJSONVersion!) / H:\(hostedJSONVersion)")
            print("Version Mismatch: Retrieving lastest JSON from server.")
            updateJSONFile()
        } else {
            //Retrieve the existing JSON from documents directory
            print("L:\(localJSONVersion!) / H:\(hostedJSONVersion)")
            print("Version Match: Using local file.")
            let fileURL = defaults.url(forKey: "pathForJSON")
            do {
                let localJSONFileData = try Data(contentsOf: fileURL!, options: [])
                let myJson = try JSONSerialization.jsonObject(with: localJSONFileData, options: .mutableContainers) as! [String : Any]
                //Use my downloaded JSON file to do stuff
                print(myJson)
                DispatchQueue.main.async {
                    //reload table in the main queue
                    self.tableView.reloadData()
                }
            } catch {
                print(error)
            }
        }
    }

    func updateJSONFile() {
        print("updateJSONFile Method Called")
        let hostedJSONFile = "http://tourofhonor.com/BonusData.json"
        let jsonURL = URL(string: hostedJSONFile)
        let itemName = "BonusData.json"
        let defaults = UserDefaults.standard
        do {
            let directory = try FileManager.default.url(for: .documentDirectory, in: .userDomainMask, appropriateFor:nil, create:false)
            let fileURL = directory.appendingPathComponent(itemName)
            let jsonData = try Data(contentsOf: jsonURL!)
            let jsonFile = try JSONSerialization.jsonObject(with: jsonData, options: .mutableContainers) as? [String : Any]
            let metaData = jsonFile!["meta"] as! [String : Any]
            let jsonVersion = metaData["version"]
            print("JSON VERSION ", jsonVersion!)
            try jsonData.write(to: fileURL, options: .atomic)
            defaults.set(fileURL, forKey: "pathForJSON") //Save the location of your JSON file to UserDefaults
            defaults.set(jsonVersion, forKey: "jsonVersion") //Save the version of your JSON file to UserDefaults
            DispatchQueue.main.async {
                //reload table in the main queue
                self.tableView.reloadData()
            }
        } catch {
            print(error)
        }
    }

    // MARK: - Navigation

    override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
        if let destination = segue.destination as? BonusDetailViewController {
            destination.bonus = bonuses[(tableView.indexPathForSelectedRow?.row)!]
        }
    }
}

and here is the JsonFile.swift, which provides the struct for the JSON parsing:

import Foundation

struct JsonFile: Codable {
    struct Meta: Codable {
        let fileName: String
        let version: String
    }
    struct JsonBonuses: Codable {
        let bonusCode: String
        let category: String
        let name: String
        let value: Int
        let city: String
        let state: String
        let flavor: String
        let imageName: String
    }
    let meta: Meta
    let bonuses: [JsonBonuses]
}

I need someone to explain it like I'm 5. I feel like I understand what my functions are doing, but I can't for the life of me figure out why it doesn't work, nor why when it did work (using the old method), that the sections where completely out of whack. I'm sorry if you are seeing my ask questions, I'm just trying to learn how to do this so I can be self-sufficient, but this one piece has just not been making sense to me.

Upvotes: 0

Views: 703

Answers (2)

dan
dan

Reputation: 9825

Split your goal up into separate tasks and write a function for each.

You need to be able to:

  • Download your bonuses from the server
  • Save your bonuses to a local file
  • Load your bonuses from a local file

Your current downloadJSON function is close to what you want for the first one but I modified it slightly so it doesn't deal with the other parts of your controller directly and instead of just sends the bonuses back in the completion handler:

func downloadJSON(completed: @escaping ([JsonFile.JsonBonuses]?) -> ()) {
    let url = URL(string: "http://tourofhonor.com/BonusData.json")!

    URLSession.shared.dataTask(with: url) { (data, response, error) in
        if error == nil, let data = data {
            do {
                let posts = try JSONDecoder().decode(JsonFile.self, from: data)
                completed(posts.bonuses)
            } catch {
                print("JSON Download Failed")
            }
        } else {
            completed(nil)
        }
    }.resume()
}

Saving your json to a file is simple because your objects implement Codable:

func saveBonuses(_ bonuses: [JsonFile.JsonBonuses], to url: URL) {
    try? FileManager.default.removeItem(at: url)

    do {
        let data = try JSONEncoder().encode(bonuses)
        try data.write(to: url)
    } catch {
        print("Error saving bonuses to file:", error)
    }
}

Similar with loading from a file:

func loadBonusesFromFile(_ url: URL) -> [JsonFile.JsonBonuses]? {
    do {
        let data = try Data(contentsOf: url)
        let bonuses = try JSONDecoder().decode([JsonFile.JsonBonuses].self, from: data)
        return bonuses
    } catch {
        print("Error loading bonuses from file:", error)
        return nil
    }
}

These parts are all independent so now you need another function with logic that ties them together. We want to attempt to grab the json from the server and save it to a file, or if that fails load any json that was saved to a file previously and use that:

func loadBonuses(completion: @escaping ([JsonFile.JsonBonuses]?) -> Void) {
    let localBonusesURL = try! FileManager.default
        .url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
        .appendingPathComponent("Bonuses.json")

    downloadJSON { bonuses in
        if let bonuses = bonuses {
            completion(bonuses)
            saveBonuses(bonuses, to: localBonusesURL)
        } else {
            completion(loadBonusesFromFile(localBonusesURL))
        }
    }
}

Now you can use this new loadBonuses function when you load your view controller:

override func viewDidLoad() {
    super.viewDidLoad()

    loadBonuses { [weak self] bonuses in
        self?.bonuses = bonuses ?? []
        self?.tableView.reloadData()
    }
}

Upvotes: 1

Nirav Bhatt
Nirav Bhatt

Reputation: 6969

Before delving into how iOS UITableView works, get this straight:

  • You have table view to specify multiple data items.
  • These items are arranged into table view rows.

Now:

  • if those can be classified in some way, they are grouped into sections (visible through section titles).
  • If they cannot be classified, they are all shown into single (often invisible) section.

So first, think how you are displaying bonuses. Are they flat list (array), or grouped into some bigger chunks?

If it has no classification:

  • You have single section, and your numberOfSections method must return 1.
  • Your numberOfRowsInSection must return bonuses.count.
  • And most importantly, your cellForRowAt should look like this (notice that bonuses array is indexed by row index, not section index):

    override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = UITableViewCell(style: .default, reuseIdentifier: nil)
        cell.textLabel?.text = bonuses[indexPath.row].name.capitalized
        return cell
     }
    

If it has classification, then you must think of bonuses as array of arrays.

  • Your numberOfSections will return bonuses.count - the number of arrays.
  • Your numberOfRowsInSection will fetch an array (let's say x[] - note that x itself is an array) element from within bonuses array, and return x.count
  • Your cellForRowAt will again fetch an array element from bonuses (let's say x[]). Then, it will fetch the row item from with in x, like: x[indexPath.row], and your final code will look like (I have omitted unwrapping etc as compiler will tell you anyway):

    override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = UITableViewCell(style: .default, reuseIdentifier: nil)
        let x = bonuses[indexPath.section]
        let bonusItem = x[indexPath.row]
        cell.textLabel?.text = bonusItem.name.capitalized
        return cell
     }
    

Upvotes: 1

Related Questions