Search code examples
swiftxcodesegue

Why does my segue not wait until completion handler finished?


I have a page based app, using RootViewController, ModelViewController, DataViewController, and a SearchViewController.

In my searchViewController, I search for an item and then add or remove that Item to an array which is contained in a Manager class(and UserDefaults), which the modelViewController uses to instantiate an instance of DataViewController with the correct information loaded using the dataObject. Depending on whether an Item was added or removed, I use a Bool to determine which segue was used, addCoin or removeCoin, so that the RootViewController(PageView) will show either the last page in the array, (when a page is added) or the first (when removed).

Everything was working fine until I ran into an error which I can not diagnose, the problem is that when I add a page, the app crashes, giving me a "unexpectadely found nil when unwrapping an optional value"

This appears to be the problem function, in the searchViewController 'self.performSegue(withIdentifier: "addCoin"' seems to be called instantly, even without the dispatchque:

@objc func addButtonAction(sender: UIButton!) {

    print("Button tapped")

    if Manager.shared.coins.contains(dataObject) {
        Duplicate()
    } else if Manager.shared.coins.count == 5 {
        max()
    } else {
        Manager.shared.addCoin(coin: dataObject)

        CGPrices.shared.getData(arr: true, completion: { (success) in
            print(Manager.shared.coins)

            DispatchQueue.main.async {
                self.performSegue(withIdentifier: "addCoin", sender: self)
            }
        })

    }

    searchBar.text = ""
}

Meaning that In my DataViewController, this function will find nil:

func getIndex() {
    let index = CGPrices.shared.coinData.index(where: { $0.id == dataObject })!
    dataIndex = index
}

I can't find out why it does not wait for completion.

I also get this error about threads:

[Assert] Cannot be called with asCopy = NO on non-main thread.

which is why I try to do the push segue using dispatch que

Here is my searchViewController full code:

import UIKit

class SearchViewController: UIViewController, UISearchBarDelegate {

    let selectionLabel = UILabel()
    let searchBar = UISearchBar()
    let addButton = UIButton()
    let removeButton = UIButton()

    var filteredObject: [String] = []
    var dataObject = ""

    var isSearching = false

    //Add Button Action.
    @objc func addButtonAction(sender: UIButton!) {

        print("Button tapped")

        if Manager.shared.coins.contains(dataObject) {
            Duplicate()
        } else if Manager.shared.coins.count == 5 {
            max()
        } else {
            Manager.shared.addCoin(coin: dataObject)

            CGPrices.shared.getData(arr: true, completion: { (success) in
                print(Manager.shared.coins)

                DispatchQueue.main.async {
                    self.performSegue(withIdentifier: "addCoin", sender: self)
                }
            })

        }

        searchBar.text = ""
    }

    //Remove button action.
    @objc func removeButtonActon(sender: UIButton!) {

        print("Button tapped")

        if Manager.shared.coins.contains(dataObject) {
            Duplicate()
        } else if Manager.shared.coins.count == 5 {
            max()
        } else {
            Manager.shared.removeCoin(coin: dataObject)

            self.performSegue(withIdentifier: "addCoin", sender: self)
        }

        searchBar.text = ""
    }

    //Prepare for segue, pass removeCoinSegue Bool depending on remove or addCoin.
    override func prepare(for segue: UIStoryboardSegue, sender: Any?) {

        if segue.identifier == "addCoin" {

            if let destinationVC = segue.destination as? RootViewController {
                destinationVC.addCoinSegue = true
            }

        } else if segue.identifier == "addCoin" {

            if let destinationVC = segue.destination as? RootViewController {
                destinationVC.addCoinSegue = false
            }
        }
    }

    //Remove button action.
    @objc func removeButtonAction(sender: UIButton!) {

        if Manager.shared.coins.count == 1 {
            removeAlert()
        } else {
            Manager.shared.removeCoin(coin: dataObject)

            print(Manager.shared.coins)
            print(dataObject)

            searchBar.text = ""
            self.removeButton.isHidden = true

            DispatchQueue.main.async {
                self.performSegue(withIdentifier: "removeCoin", sender: self)
            }
        }
    }

    //Search/Filter the struct from CGNames, display both the Symbol and the Name but use the ID as dataObject.
    func filterStructForSearchText(searchText: String, scope: String = "All") {

        if !searchText.isEmpty {
            isSearching = true

            filteredObject = CGNames.shared.coinNameData.filter {

                // if you need to search key and value and include partial matches
                // $0.key.contains(searchText) || $0.value.contains(searchText)

                // if you need to search caseInsensitively key and value and include partial matches
                $0.name.range(of: searchText, options: .caseInsensitive) != nil || $0.symbol.range(of: searchText, options: .caseInsensitive) != nil
                }
                .map{ $0.id }

        } else {
            isSearching = false
            print("NoText")
        }
    }

    //Running filter function when text changes.
    func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {

        filterStructForSearchText(searchText: searchText)

        if isSearching == true && filteredObject.count > 0 {

            addButton.isHidden = false
            dataObject = filteredObject[0]
            selectionLabel.text = dataObject

            if Manager.shared.coins.contains(dataObject) {
                removeButton.isHidden = false
                addButton.isHidden = true
            } else {
                removeButton.isHidden = true
                addButton.isHidden = false
            }

        } else {
            addButton.isHidden = true
            removeButton.isHidden = true
            selectionLabel.text = "e.g. btc/bitcoin"
        }

    }

    override func viewDidLoad() {
        super.viewDidLoad()

        //Setup the UI.
        self.view.backgroundColor = .gray
        setupView()
    }

    override func viewDidLayoutSubviews() {

    }

    //Hide keyboard
    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        self.view.endEditing(true)
    }

    //Alerts
    func removeAlert() {
        let alertController = UIAlertController(title: "Can't Remove", message: "\(dataObject) can't be deleted, add another to delete \(dataObject)", preferredStyle: .alert)

        alertController.addAction(UIAlertAction(title: "Okay", style: .default, handler: nil))

        self.present(alertController, animated: true, completion: nil)
    }

    func Duplicate() {
        let alertController = UIAlertController(title: "Duplicate", message: "\(dataObject) is already in your pages!", preferredStyle: .alert)

        alertController.addAction(UIAlertAction(title: "Okay", style: .default, handler: nil))

        self.present(alertController, animated: true, completion: nil)
    }

    func max() {
        let alertController = UIAlertController(title: "Maximum Reached", message: "\(dataObject) can't be added, you have reached the maximum of 5 coins. Please delete a coin to add another.", preferredStyle: .alert)

        alertController.addAction(UIAlertAction(title: "Okay", style: .default, handler: nil))

        self.present(alertController, animated: true, completion: nil)
    }
}

and here is the DataViewController

import UIKit

class DataViewController: UIViewController {

    @IBOutlet weak var dataLabel: UILabel!

    //Variables and Objects.

    //The dataObject carries the chosen cryptocurrencies ID from the CoinGecko API to use to get the correct data to load on each object.
    var dataObject = String()

    //The DefaultCurrency (gbp, eur...) chosen by the user.
    var defaultCurrency = ""

    //The Currency Unit taken from the exchange section of the API.
    var currencyUnit = CGExchange.shared.exchangeData[0].rates.gbp.unit
    var secondaryUnit = CGExchange.shared.exchangeData[0].rates.eur.unit
    var tertiaryUnit = CGExchange.shared.exchangeData[0].rates.usd.unit

    //Index of the dataObject
    var dataIndex = Int()

    //Objects
    let cryptoLabel = UILabel()
    let cryptoIconImage = UIImageView()
    let secondaryPriceLabel = UILabel()
    let mainPriceLabel = UILabel()
    let tertiaryPriceLabel = UILabel()

    //Custom Fonts.
    let customFont = UIFont(name: "AvenirNext-Heavy", size: UIFont.labelFontSize)
    let secondFont = UIFont(name: "AvenirNext-BoldItalic" , size: UIFont.labelFontSize)

    //Setup Functions

    //Get the index of the dataObject
    func getIndex() {
        let index = CGPrices.shared.coinData.index(where: { $0.id == dataObject })!
        dataIndex = index
    }

    //Label
    func setupLabels() {

        //cryptoLabel from dataObject as name.
        cryptoLabel.text = CGPrices.shared.coinData[dataIndex].name

        //Prices from btc Exchange rate.

        let btcPrice = CGPrices.shared.coinData[dataIndex].current_price!
        let dcExchangeRate = CGExchange.shared.exchangeData[0].rates.gbp.value
        let secondaryExchangeRate = CGExchange.shared.exchangeData[0].rates.eur.value
        let tertiaryExchangeRate = CGExchange.shared.exchangeData[0].rates.usd.value

        let realPrice = (btcPrice * dcExchangeRate)
        let secondaryPrice = (btcPrice * secondaryExchangeRate)
        let tertiaryPrice = (btcPrice * tertiaryExchangeRate)

        secondaryPriceLabel.text = "\(secondaryUnit)\(String((round(1000 * secondaryPrice) / 1000)))"
        mainPriceLabel.text = "\(currencyUnit)\(String((round(1000 * realPrice)  /1000)))"
        tertiaryPriceLabel.text = "\(tertiaryUnit)\(String((round(1000 * tertiaryPrice) / 1000)))"
    }

    //Image
    func getIcon() {

        let chosenImage = CGPrices.shared.coinData[dataIndex].image
        let remoteImageUrl = URL(string: chosenImage)

        guard let url = remoteImageUrl else { return }

        URLSession.shared.dataTask(with: url) { (data, response, err) in

            guard let data = data else { return }

            do {
                DispatchQueue.main.async {
                    self.cryptoIconImage.image = UIImage(data: data)
                }

            }
            }.resume()
    }

    override func viewDidLoad() {
        super.viewDidLoad()

        //        for family in UIFont.familyNames.sorted() {
        //            let names = UIFont.fontNames(forFamilyName: family)
        //            print("Family: \(family) Font names: \(names)")
        //        }
        // Do any additional setup after loading the view, typically from a nib.

        self.setupLayout()
        self.getIndex()
        self.setupLabels()
        self.getIcon()
    }

    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)

        self.dataLabel!.text = dataObject
        view.backgroundColor = .lightGray
    }

}

Edit: CGPrices Class with getData method:

import Foundation

class CGPrices {

    struct Coins: Decodable {
        let id: String
        let name: String
        let symbol: String
        let image: String
        let current_price: Double?
        let low_24h: Double?
        //let price_change_24h: Double?
    }

    var coinData = [Coins]()

    var defaultCurrency = ""
    var coins = Manager.shared.coins
    var coinsEncoded = ""

    static let shared = CGPrices()

    func encode() {
        for i in 0..<coins.count {
            coinsEncoded += coins[i]
            if (i + 1) < coins.count { coinsEncoded += "%2C" }
        }
        print("encoded")
    }

    func getData(arr: Bool, completion: @escaping (Bool) -> ()) {

        encode()

        let urlJSON = "https://api.coingecko.com/api/v3/coins/markets?vs_currency=btc&ids=\(coinsEncoded)"

        guard let url = URL(string: urlJSON) else { return }

        URLSession.shared.dataTask(with: url) { (data, response, err) in

            guard let data = data else { return }

            do {
                let coinsData = try JSONDecoder().decode([Coins].self, from: data)
                self.coinData = coinsData
                completion(arr)

            } catch let jsonErr {
                print("error serializing json: \(jsonErr)")
                print(data)
            }

            }.resume()

    }

    func refresh(completion: () -> ()) {
        defaultCurrency = UserDefaults.standard.string(forKey: "DefaultCurrency")!
        completion()
    }

}

Solution

  • I figured it out.

    The problem was inside my getData method I was not updated the coins array:

     var coinData = [Coins]()
    
    var defaultCurrency = ""
    
    var coins = Manager.shared.coins
    var coinsEncoded = ""
    
    static let shared = CGPrices()
    
    func encode() {
        for i in 0..<coins.count {
            coinsEncoded += coins[i]
            if (i+1)<coins.count { coinsEncoded+="%2C" }
        }
        print("encoded")
    }
    

    I needed to add this line in getData:

    func getData(arr: Bool, completion: @escaping (Bool) -> ()) {
    
    //Adding this line to update the array so that the URL is appended correctly.
        coins = Manager.shared.coins
    
        encode()
    
    let urlJSON = "https://api.coingecko.com/api/v3/coins/markets?vs_currency=btc&ids=\(coinsEncoded)"
    

    This would fix the finding nil in the DataViewController, but the app would still crash do to updating UI Elements on a background thread, as the segue was called inside the completion handler of the getData method. to fix this, I used DispatchQue.Main.Async on the segue inside the getData method in the addButton function, to ensure that everything is updated on the main thread, like so:

     @objc func addButtonAction(sender: UIButton!) {
        print("Button tapped")
        if Manager.shared.coins.contains(dataObject) {
            Duplicate()
        } else if Manager.shared.coins.count == 5 {
            max()
        } else {
    
            Manager.shared.addCoin(coin: dataObject)
    
                print("starting")
    
            CGPrices.shared.getData(arr: true) { (arr) in
                print("complete")
                print(CGPrices.shared.coinData)
    //Here making sure it is updated on main thread.
                DispatchQueue.main.async {
                     self.performSegue(withIdentifier: "addCoin", sender: self)
                }
    
            }
    
        }
        searchBar.text = ""
    }
    

    Thanks for all the comments as they helped me to figure this out, and I learned a lot in doing so. Hopefully this can help someone else in their thought process when debugging, as one can get so caught up in one area of a problem, and forget to take a step back and look to other areas.