0

I have created a UITableview and added a few cells displaying the names of a couple of stocks(Apple, Tesla). I also added a right-detail text label to my cells in which I want to display the current stock price of the stocks. So far, using Finnhub.io, I was able to create an API call and store the current price data in a variable called decodedData. I was also able to print out that data in my debug console. The only problem that I'm currently facing is not showing the debug console's data in the UI cells. If anyone has any ideas on how to solve this issue, please let me know.

Here is my code for making the API call and getting the URL:

struct StockManager {
    
    let decoder = JSONDecoder()
    var returnValue:String = ""
    
   
    
    let stockUrl = "https://finnhub.io/api/v1/quote?token=AUTH_TOKEN"
    
    mutating func fetchStock(stockName: String) -> String{
        var stockValue:String = ""
        
        let urlString = "\(stockUrl)&symbol=\(stockName)"
        stockValue = performRequest(urlString: urlString)
        
        return stockValue
    }
    
    func performRequest(urlString: String) -> String  {
        
        var retValue: String = ""
        
        
        //Create a URL
        
        if let url = URL(string: urlString){
            
            //Create a url session
            let session = URLSession(configuration: .default)
            
            //Create task
            
            let task = session.dataTask(with: url) { (data, response, error) in
                if error != nil{
                    print(error)
                    return
                }
                
                if let safeData = data{
                self.parseJSON(stockData: safeData)
                }
                //Start task
                
            }
            task.resume()
        }
        
        
        return retValue
    }
    
    func parseJSON(stockData: Data) -> String {
        
        var returnValue: String = ""
        var jsonValue: String = ""
        
        do{
            let decodedData = try decoder.decode(StockData.self, from: stockData)
            let c:String = String(decodedData.c)
            let h:String = String(decodedData.h)
            let l:String = String(decodedData.l)
            
            jsonValue =  String(decodedData.c)
            
            print(decodedData.c)
                        
          //  let stockData = StockData(c: c, h: h, l: l)
            
            
        }catch{
            print("Error decoding, \(error)")
        }
        
        return jsonValue

    }
    
}

And here is my code for creating the table view and cells:

var listOfStocks = ["AAPL", "TSLA"]
var listOfStocks2 = ["Apple": "AAPL", "Tesla": "TSLA"]


var stockManager = StockManager()

override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
    return listOfStocks.count
}

override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { 
    // create cell
    let cell = tableView.dequeueReusableCell(withIdentifier: "StockListCell", for: indexPath)
    let jsonValue: String = ""
    
    cell.textLabel?.text = listOfStocks[indexPath.row]

    
    if let stock = cell.textLabel?.text{
        
        stockManager.fetchStock(stockName: stock)
        cell.detailTextLabel?.text = stock

    }
    return cell
}

I want to be able to show my current stock price by coding in the function above (override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath)). Please let me know if anyone has an answer to this issue, thanks!

Rich Tolley
  • 3,812
  • 1
  • 30
  • 39

2 Answers2

2

So the first problem is that your network call won't work correctly as you've written it. The method you've written is synchronous, meaning that it immediately returns a value, whereas the network call it contains is asynchronous, meaning that it will return some kind of value (either the data you want or an error) after some time, using the dataTask callback (block).

So you need to change the way the code works. There are many ways of doing this: I'm going to sketch one possible simple one, which won't cover all cases but will at least get you something that works. In this, instead of making the network call in each call to -cellForRowAtIndexPath: we can add a method and call it in viewDidLoad This method will look like this:

func loadStocks() {
    self.stockData = []

    self.stockManager.fetchStocks(
      stockNames: listOfStocks,
      callback: updateTable(data:error:))
  }

(we assign [] to the instance variable to clear previous results if you are fetching for a second time - in case you might want to add a pull to refresh method as well as the viewDidLoad call, for example.)

UpdateTable is another method on the view controller; it looks like this

func updateTable(data: StockData?, error: Error?) {

    DispatchQueue.main.async { [weak self]  in
      if let data = data { 
        guard let self = self else { return }
        self.stockData.append(data)
        self.tableView.reloadData()
      } else if let error = error { 
        print("failed to load data: \(error)")
        //or handle the error in some other way, 
      }
    }
  }

We are passing it as a parameter here so it can be used as a callback, rather than calling it directly

It will instead be called when each network request completes: if it succeeds, you will get a StockData object: if it fails, an Error. The parameters are optional because we don't know which it will be. We then add the new data to an instance variable array of StockData (which drives the table) and call reload on the tableView. The DispatchQueue.main.async is there because the network request will return on a background thread, and we can't update UI from there - UI must always be updated on the main thread. The [weak self] and guard let self = self else { return } are an annoying detail required to make sure we don't get a reference cycle.

The StockDataManager & StockData object now look like this:

struct StockData: Codable {

  var name: String?
  let c: Double
  let h: Double
  let l: Double

  func stockDescription() -> String {
    return "\(name ?? "unknown") : \(c), h: \(h), l: \(l)"
  }
}

struct StockManager {

  let decoder = JSONDecoder()
  let stockUrl = "https://finnhub.io/api/v1/quote?token=AUTH_TOKEN"

  func fetchStocks(stockNames: [String], callback: @escaping (StockData?, Error?) -> Void) {
    for name in stockNames {
      self.fetchStock(stockName: name, callback: callback)
    }
  }

  func fetchStock(stockName: String, callback: @escaping (StockData?, Error?) -> Void)  {
    let urlString = "\(stockUrl)&symbol=\(stockName)"

    if let url = URL(string: urlString){

      let session = URLSession(configuration: .default)

      let task = session.dataTask(with: url) { (data, response, error) in

        if let data = data {
          do {
            var stockData = try decoder.decode(StockData.self, from: data)
            stockData.name = stockName
            callback(stockData, nil)
          } catch let error {
            callback(nil, error)
          }

        } else if let error = error {
          callback(nil, error)
        }
      }

      task.resume()
    }
  }
}

So instead of using return values, we pass in another method as a parameter, and call that when the network calls come back. This is far from the only way of doing this, and there are a load of details around error handing that could be done a lot better/differently, but as I say, it will work. I added a mutable string field to StockData because the response doesn't contain the stock name, which is a bit ugly tbh. I also added a stockDescription() method to print out the data conveniently - not sure what you want/need here. You might prefer to put this on the VC.

This are the tableview delegate methods (simpler but not much different from the original)

  func numberOfSections(in tableView: UITableView) -> Int {
    return 1
  }

  func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
      return stockData.count
  }

  func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
      let cell = tableView.dequeueReusableCell(withIdentifier: "StockListCell", for: indexPath)

      cell.textLabel?.text = stockData[indexPath.row].stockDescription()

      return cell
  }
}

One final note: be careful when posting code to remove things like auth tokens. I don't think it matters much for a free site like finnhub.io, but it can cause problems. (I've removed one from your original post - you can easily regenerate it on finnhub if you're worried about people using it).

Rich Tolley
  • 3,812
  • 1
  • 30
  • 39
  • Rich, that was a great explanation, however, I cannot access stockData since my cells are initialized in a completely different class. How would I fix that? – Rugved Kamat Mar 27 '21 at 03:57
  • I tried to create a new variable linking it to the StockData swift file, however, it asks me to fill in the parameters. Whatever I try, it's not giving me a connection from stockData(variable) to StockData(file). Any suggestions? – Rugved Kamat Mar 27 '21 at 05:57
  • So `stockData` in the VC should be a property, an array of structs of type `StockData` (declaration: `var stockData: [StockData] = []` - it might be better to call it `stockDataItems` for clarity. Possibly you are missing that from the ViewController? (I realise I didn't mention it explicitly, sorry about that) – Rich Tolley Mar 27 '21 at 09:41
  • Oh, I got it Rich, now everything worked out well. But there was one small issue, it says in my debug console that the value of "c" was nil and nothing displayed on my UITableview. How can I fix this problem? – Rugved Kamat Mar 27 '21 at 17:11
  • Oh Rich, I forgot to tell you my goal in detail. I want to display my stock name on the title of the cell, and on the right detail text label, I want to display the current stock price. – Rugved Kamat Mar 27 '21 at 17:17
  • Do you know how to fix this? – Rugved Kamat Mar 28 '21 at 01:41
  • So probably the issue with `c` is that it is optional - so making the relevant field in `StockData` into an optional should fix that. You may need to do it for other fields as well. From a quick look at finnhub I don't see any clear documentation on this unfortunately. – Rich Tolley Mar 28 '21 at 09:56
  • As for the labels, some of the standard `UITableViewCell` styles have left and right labels, so the easiest thing would be to use one and just assign the stock data to the right label: https://developer.apple.com/documentation/uikit/uitableviewcell/cellstyle - just look at the docs and play around with it a bit – Rich Tolley Mar 28 '21 at 09:58
1

You are actually doing it the wrong way, api calls take time and after you get the response from api, you then have to update the UI based on the results.

Problem: The problem with your code is that, at the time when cell is displayed, only the api call is made and even before getting the results your code will display the cell with empty / default data.

Solution:
(1) Make a function getStocks() in which you will call api for all the required stocks

var stockResponses = StockResponse

func getStocks()
{
    for stock in listOfStocks
    {
       //make performRequest here and store response in the stockResponses array
       //call tableViewName.reloadData() in every response
    }
}

(2) In cellForRowAt method, now use this array to display the data

cell.textLabel?.text = listOfStocks[indexPath.row]
cell.detailTextLabel?.text = stockResponses[indexPath.row] //show whatever data you want to show from response here
return cell

Hopefully this will display the data in your table. p.s tableView.reloadData() - doing it in this way is a bad approach, but for the sake of just making it to this stage of displaying you may use it, but I would recommend using DispatchGroup for making multiple api calls

Amais Sheikh
  • 421
  • 5
  • 12