1

Similar questions to this have been asked so I apologize, but none of them have been able to help me.

I am struggling to return the value from this asynchronous request to Firebase with a completion handler. The value I am retrieving from Firebase is an array and it does exist. But

Here is my function for making the request to Firebase:

class SearchManager {

    var searchResults = [String]()
    var listOfMosaics = [String]()

    // Retrieves company list from Firebase
    func getMosaicTitles(completionHandler: @escaping (_ mosaics: [String]) -> ()) {
        Database.database().reference().child("mosaics").observeSingleEvent(of: .value, with: { (snapshot) in
            guard let allMosaics = snapshot.value as? [String] else {
                print("unable to unwrapp datasnapshot")
                return
            }
            completionHandler(allMosaics)
        })
    }

    // resets search results array
    func resetSearch() {
        searchResults = []
    }

    // takes list of all mosaics and filters based on search text
    func filterAllMosaics(searchText: String) {
        searchResults = listOfMosaics.filter { $0.contains(searchText) }

    }

}

And in the AppDelegate I call it like this posting a Notification:

    let searchManager = SearchManager()

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {

    makeRootViewLaunchScreen()
    FirebaseApp.configure()
    searchManager.getMosaicTitles { (results) in
        self.searchManager.listOfMosaics = results
        NotificationCenter.default.post(name: NSNotification.Name("mosaicsReturned"), object: nil)
        self.stopDisplayingLaunchScreen()
    }
    // Adds border to bottom of the nav bar
    UINavigationBar.appearance().shadowImage = UIImage.imageWithColor(color: UIColor(red:0.00, green:0.87, blue:0.39, alpha:1.0))
    // Override point for customization after application launch.
    return true
}

func makeRootViewLaunchScreen() {
    let mainStoryboard: UIStoryboard = UIStoryboard(name: "LaunchScreen", bundle: nil)
    let viewController = mainStoryboard.instantiateViewController(withIdentifier: "launchScreen")
    UIApplication.shared.keyWindow?.rootViewController = viewController
}

// reassigns root view after Firebase request complete
func stopDisplayingLaunchScreen() {
    let mainStoryboard: UIStoryboard = UIStoryboard(name: "Main", bundle: nil)
    let viewController = mainStoryboard.instantiateViewController(withIdentifier: "centralViewController")
    UIApplication.shared.keyWindow?.rootViewController = viewController
}

In the viewDidLoad of the viewController that supports the tableView that uses the retrieved array to populate it I add a Notification Observer.

    var listOfMosaics = [String]()
var searchResults = [String]() {
    didSet {
        tableView.reloadData()
    }
}

override func viewDidLoad() {
    super.viewDidLoad()
    listOfMosaics = searchManager.listOfMosaics
    configureSearchBar()
    configureSearchBarTextField()
    self.tableView.separatorColor = UIColor(red:0.00, green:0.87, blue:0.39, alpha:1.0)

    NotificationCenter.default.addObserver(self, selector: #selector(updateListOfMosaics), name: NSNotification.Name("mosaicsReturned"), object: nil)
}

@objc func updateListOfMosaics(notification: Notification) {
    listOfMosaics = searchManager.listOfMosaics
}

But when I call the below code it doesn't work the arrays print as empty and as a result it doesn't update my tableView.

extension SearchResultsTableViewController: UISearchBarDelegate {

func searchBarSearchButtonClicked(_ searchBar: UISearchBar) {
    searchManager.resetSearch()
    searchManager.filterAllMosaics(searchText: searchBar.text!)
    tableView.reloadData()
    print(listOfMosaics)
    print(searchResults)


   }
 }

Thank you in advanced for the help.

Ben Nalle
  • 537
  • 2
  • 8
  • 21
  • 3
    you're still not dealing with the asynchronous aspect of it by the looks of it. You need to wait until `searchManager.getMosaicTitles` is finished calling before trying access it's data. – TNguyen Oct 16 '17 at 17:06
  • Do I even need a completion handler then? – Ben Nalle Oct 16 '17 at 17:58
  • Yes you do, completion handler is the way, how else would you know when the data is done being loaded? The problem lies with that you need a way to let the people who are waiting for that data to load that it's done loading (or if it wasn't loaded properly due to internet connection issues, etc.). Sounds to me like this is a job for Observers – TNguyen Oct 16 '17 at 18:03
  • @TNguyen Thanks for pointing me in the direction of Observers. I've attempted to use NotificationCenter Observers and updated my question with the resulting code because I still can't get it to work with the Observers. – Ben Nalle Oct 16 '17 at 21:34
  • There's a few things you need to be careful of, one thing is that you need to be sure that the observer is registered before the notification is posted to it. Another thing is that the general UX is that you'd show a loading icon or loading view of that sort while you're preparing the data and not let the user interact with whatever it is that is waiting – TNguyen Oct 16 '17 at 22:14
  • @TNguyen thanks. Is there a way I can have all of this happen before the LaunchScreen disappears? I was hoping I could do that by placing the code in the AppDelegate but that didn't do it. – Ben Nalle Oct 16 '17 at 22:29
  • Yes you can, although it would be done programatically. What I would do in your case is I would set the current rootview to be a loading screen / the launch screen and wait until that request is done (i.e. the completion handler is called). Then I would change the rootview back to what it was before after the data has loaded – TNguyen Oct 16 '17 at 22:31
  • @TNguyen I understand what you are saying and appreciate you taking the time to guide me through this. But I don't understand how I can set the current rootview to be the launch screen. This is the code I was going to put in my AppDelegate: 'self.window.rootViewController = // would want to assign launch screen here' and then reassign the root view in completion handler – Ben Nalle Oct 17 '17 at 14:21
  • it doesn't necessarily have to be the launch screen it can be whatever you like but basically you can instantiate the view controller and set it to be the rootview controller. Just like this https://stackoverflow.com/questions/22653993/programmatically-change-rootviewcontroller-of-storyboard and then whenever it's done loading you just change it back to the old one – TNguyen Oct 17 '17 at 14:31
  • @TNguyen I was able to make those changes. My navigation bar title disappeared but I'm not worried about getting that back. However the arrays still print as empty. Could it be because there observer is set in another file and the notification is posted before the observer registers like you said? To fix this to I place the observer somewhere else? – Ben Nalle Oct 17 '17 at 15:03
  • It should be Show Loading screen -> Try to load data from server/database -> Once completed, Stop showing the loading screen -> Allow user to interact with data (So at this point the data should be correct because we didn't allow the user to interact without before it's done loading) – TNguyen Oct 17 '17 at 16:52
  • @TNguyen I've updated the code to show what I've done. I feel as though I've done exactly what you suggested above. – Ben Nalle Oct 17 '17 at 17:01
  • run `listOfMosaics = searchManager.listOfMosaics` in `viewDidAppear` the problem is what you suspected before, it's that it's not being observed before the event is fired. We can run it in `viewDidAppear` for example because we know for sure it should have been loaded now. I was more so thinking of using observers with the idea of using the observer to hide/show the loading screen. And can you also be sure that the result isnt empty – TNguyen Oct 17 '17 at 17:12
  • @TNguyen I've verified that the results isn't empty by adding a print statement and it prints the correct results. I've added the `listOfMosaics = searchManager.listOfMosaics` to the viewDidLoad and it didn't work. – Ben Nalle Oct 17 '17 at 17:47
  • can you put a print statements inside of `searchManager.getMosaicTitles { (results) in self.searchManager.listOfMosaics = results NotificationCenter.default.post(name: NSNotification.Name("mosaicsReturned"), object: nil) self.stopDisplayingLaunchScreen() }` to see that it is being ran. Without taking a look at the project myself it looks fine to me it should at least be populating `searchManager` with the correct array. Also can you get rid of the observers now I don't think we need them anymore :) – TNguyen Oct 17 '17 at 22:27
  • Take a look at my updated answer below. I've tested this so it should work for you. – Saul Oct 18 '17 at 09:57

3 Answers3

1

This should work for you now. I think you didn't pass the instance of SearchManager from your AppDelegate to your ViewController. I'm guessing you created a new instance of SearchManager in your ViewController, which has an empty array.

Search Manager:

class SearchManager {

    var searchResults = [String]()
    var listOfMosaics = [String]()

    func getMosaicTitles(completionHandler: @escaping (_ mosaics: [String]) -> ()) {
        Database.database().reference().child("mosaics").observeSingleEvent(of: .value, with: { (snapshot) in
            guard let allMosaics = snapshot.value as? [String] else {
                print("unable to unwrapp datasnapshot")
                completionHandler([]) // <- You should include this too.
                return
            }
            completionHandler(allMosaics)
        })
    }

    func resetSearch() {
        searchResults = []
    }

    func filterAllMosaics(searchText: String) {
        searchResults = listOfMosaics.filter { $0.contains(searchText) }
    }
}

View Controller:

class TableViewController: UITableViewController {

    var searchManager: SearchManager?
    var listOfMosaics = [String]()
    var searchResults = [String]() {
        didSet {
            tableView.reloadData()
        }
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        guard let searchManager = searchManager else { return }
        listOfMosaics = searchManager.listOfMosaics
        print("List of mosaics: \(listOfMosaics)")
    }

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

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

AppDelegate:

class AppDelegate: UIResponder, UIApplicationDelegate {

    var window: UIWindow?
    let searchManager = SearchManager()

    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?)  -> Bool {
        makeRootViewLaunchScreen()
        FirebaseApp.configure()
        searchManager.getMosaicTitles { results in
            self.searchManager.listOfMosaics = results
            self.stopDisplayingLaunchScreen()
        }
        return true
    }

    func makeRootViewLaunchScreen() {
        let mainStoryboard: UIStoryboard = UIStoryboard(name: "LaunchScreen", bundle: nil)
        let viewController = mainStoryboard.instantiateViewController(withIdentifier: "launchScreen")
        window?.rootViewController = viewController
        window?.makeKeyAndVisible()
    }

    func stopDisplayingLaunchScreen() {
        let mainStoryboard: UIStoryboard = UIStoryboard(name: "Main", bundle: nil)
        guard let viewController = mainStoryboard.instantiateViewController(withIdentifier: "centralViewController") as? TableViewController else { return }
        let navigationController = UINavigationController(rootViewController: viewController)
        viewController.searchManager = searchManager
        window?.rootViewController = navigationController
        window?.makeKeyAndVisible()
    }
}
Saul
  • 865
  • 2
  • 7
  • 10
  • This is amazing, it works perfectly. Thank you so much. When I started using the launch screen to hide the UI while the request is finishing my navigation bar disappeared. Do you know how to do the above without losing my navigation bar? – Ben Nalle Oct 18 '17 at 12:58
  • You're welcome, glad I could help! As for your disappearing navigation bar, you could display the new view controller using the updated stopDisplayingLaunchScreen() method above. This displays a navigation controller with the table view controller as the root view controller. – Saul Oct 18 '17 at 23:46
  • There is a lag between when the navigation bar is displayed and the tableView is displayed. Do you know how to prevent that from happening? – Ben Nalle Oct 19 '17 at 01:04
  • Hmm try the latest, I think it's because you've used: UIApplication.shared.keyWindow?.rootViewController when setting the view controller. – Saul Oct 19 '17 at 02:19
0

As @TNguyen says in his comment, it sounds like you aren't waiting for the async function getMosaicTitles() to complete.

You might want to disable the search bar button while the call is running, and enable it from the completion handler once the call is complete. Then the user won't be able to click the search button until the results have finished loading.

Duncan C
  • 128,072
  • 22
  • 173
  • 272
  • How do a lot of the apps on the app store avoid this? Based on my experience they seem to be able make these requests fast enough so the user doesn't have to wait – Ben Nalle Oct 16 '17 at 18:00
  • @BenNalle They deal with it by waiting for the data to fully load and showing the user some sort of waiting icon/view/etc.. (this part is up to you). Sure you can assume that it will load fast enough but that's a big assumption to make and an incorrect one at that. – TNguyen Oct 16 '17 at 18:02
  • is there a way to have the loading complete while the app's LaunchScreen is being displayed? – Ben Nalle Oct 16 '17 at 18:08
0

You can fetch the data from the database in a background thread and add a completion block, so that the tableView reloads only after the updated content is fetched.

Swathi
  • 115
  • 2