UITableView sometimes not updating

Hey everybody,

first of all, I'm new in Swift (but not new in software development).


I'm currently trying to create a private app which is used at work for another person.

It should be possible to create/add/edit or delete customers which are displayed in UITableView and Managed in UITableViewController.


I created a small caching system:

1. Fetch data from API (an URL which returns JSON)

2. Store data (in my case, an array of customers: [Customer]) on device (as JSON or is there any better way to store an array with custom objects?)

3. If user restarts app or refreshes table within X minutes, load data from cache, otherwise fetch again from API.


Fetching first time from the API works fine and the data is getting inserted into the table. But getting the data from cache, does not work even if it's exactly the same array [Customer].


I found out, that

override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {

is not even get called, and I have absolutely no idea why it works fetching from API but not from cache.


CustomerListController:

//
//  CustomerController.swift
//
//  Copyright © 2020 XXXXX. All rights reserved.
//

import UIKit

class CustomerListController: BaseTableController {
    
    /// The table view instance
    @IBOutlet var customerTableView: UITableView!
    
    /// List of all customers, fetched from the API
    var customersList: [Customer] = []
    
    /// List of all customers, filtered by search controller
    var filteredCustomersList: [Customer] = []
    
    /// Array of customers, sorted in sections
    var sectionCustomers: [Dictionary<String, [Customer]>.Element] = []
    
    /// Array of filtered customers, sorted in sections
    var filteredSectionCustomers: [Dictionary<String, [Customer]>.Element] = []
    
    /// The search controller
    var searchController: UISearchController!
    
    /**
     * viewDidLoad()
     */
    override func viewDidLoad() {
        super.viewDidLoad()
        
        //        self.customerTableView.tintColor = Constants.COLOR_ON_PRIMARY_BLUE
        //        self.navigationController?.navigationBar.barTintColor = .red
        
        UITabBar.appearance().tintColor = Constants.COLOR_ON_PRIMARY_BLUE
        UITabBar.appearance().barTintColor = Constants.COLOR_PRIMARY_BLUE
        
        // Do any additional setup after loading the view.
        self.customerTableView.delegate = self
        self.customerTableView.dataSource = self
        self.title = "Customers"
        
        self.configureRefreshControl()
        self.configureSearchController()
        
        // @NOTE: SectionBar foreground color
        self.customerTableView.tintColor = Constants.COLOR_PRIMARY_BLUE
        
        // @NOTE: Loads the data into the table
        self.loadData()
    }
    
    /**
     * Loads the data from cache or refreshes it from the JSON API.
     */
    func loadData() {
        let current = Date()
        let lastCustomerFetch = UserDefaults.standard.object(forKey: Constants.UD_CUSTOMER_LAST_FETCH) as? Date
        
        print("Last fetch: \(lastCustomerFetch!)")
        print("--------------------------------------------------------------------")

        if lastCustomerFetch != nil {
            if lastCustomerFetch!.distance(to: current) >= 900 {
                self.fetchJSONDataAndUpdateTable(cachedAt: current, onFetchComplete: { () -> Void in
                    self.reloadTableData()
                })
            } else {
                
                do {
                    let storedObjItem = UserDefaults.standard.object(forKey: Constants.UD_CUSTOMER_CACHE)
                    self.customersList = try JSONDecoder().decode([Customer].self, from: storedObjItem as! Data)
                } catch {
                    Alert(title: "Error", message: error.localizedDescription).show()
                }
                
                print("Updated customers from cache (\(self.customersList.count) entries)")
                print(self.customersList)
                
                // @NOTE: Reload table view
                self.reloadTableData()
            }
        } else {
            self.fetchJSONDataAndUpdateTable(cachedAt: current, onFetchComplete: { () -> Void in
                self.reloadTableData()
            })
        }
    }
    
    /**
     * Fetches the json data from an url.
     * - Parameter cachedAt: The caching time.
     * - Parameter onFetchComplete: What happens, if the fetch has been completed.
     */
    func fetchJSONDataAndUpdateTable(cachedAt: Date, onFetchComplete: @escaping () -> Void) {
        
        // @NOTE: Direct JSON URL
        var apiParameters = [
            "method": "customers",
            "limit": 25
        ]
        guard let url = APIUtilities.buildAPIURL(parameters: &apiParameters) else {
            Alert(title: "Error", message: "Failed to build api url.").show()
            return
        }
        
        // @NOTE: Fetches the JSON from tha API-URL and handles it's result.
        JSONUtilities.getJSONFromURL(url: url, handleURLData: { (data) in
            do {
                let decodedResponseJSON = try JSONDecoder().decode(GetCustomerJSONStruct.self, from: data)
                
                // @NOTE: Store basic data in memory
                self.customersList = decodedResponseJSON.data ?? []
                
                // @NOTE: Sort into alphabetical sections
                self.sectionCustomers = self.orderCustomersIntoSections()   
                
                // @NOTE: Store basic data in cache
                if let encodedCustomers = try? JSONEncoder().encode(self.customersList) {
                    UserDefaults.standard.set(encodedCustomers, forKey: Constants.UD_CUSTOMER_CACHE)
                }
                UserDefaults.standard.set(cachedAt, forKey: Constants.UD_CUSTOMER_LAST_FETCH)
            
                print("Updated customers from API (\(self.customersList.count) entries)")
                print(self.customersList)
                
                onFetchComplete()
            } catch {
                DispatchQueue.main.async {
                    Alert(title: "Error", message: "Failed to decode JSON.").show()
                }
            }
        })
    }
    
    /*
     * Reloads the table view containing the customer data.
     */
    func reloadTableData() {
        print("Refreshing data...")
        DispatchQueue.main.async {
            self.customerTableView.reloadData()
            print("Refresh complete!")
        }
    }
    
    /**
     * Order the customers into alphabet-based bsections.
     */
    func orderCustomersIntoSections() -> [Dictionary<String, [Customer]>.Element] {
        var orderedSections: [String:[Customer]] = [:]
        
        // @NOTE: Order customers into sections relative to the first character of their display name
        for customer in self.customersList {
            var firstDisplayCharacter = String(customer.getDisplayName().string.prefix(1)).uppercased()
            
            let range = firstDisplayCharacter.rangeOfCharacter(from: CharacterSet.letters)
            if range == nil {
                firstDisplayCharacter = "#"
            }
            
            // @NOTE: Create new section if it doesn't exist yet
            if orderedSections[firstDisplayCharacter] == nil {
                orderedSections[firstDisplayCharacter] = []
            }
            
            orderedSections[firstDisplayCharacter]?.append(customer)
        }
        
        return orderedSections.sorted { (first, second) -> Bool in
            return first.key < second.key
        }
    }
    
    /**
     * Adds the refresh control to the table view.
     */
    func configureRefreshControl () {
        self.customerTableView.refreshControl = UIRefreshControl()
        self.customerTableView.refreshControl?.tintColor = Constants.COLOR_ON_PRIMARY_BLUE
        self.customerTableView.refreshControl?.addTarget(self, action: #selector(handleRefreshControl), for: .valueChanged)
        self.customerTableView.refreshControl?.attributedTitle = NSAttributedString(
            string: "Pull down to refresh",
            attributes: [.foregroundColor: Constants.COLOR_ON_PRIMARY_BLUE]
        )
    }
    
    /**
     * Adds the search field on top of the table.
     */
    func configureSearchController() {
        self.searchController = super.configureSearchController(
            placeholder: "Search...",
            scopeButtonTitles: [Constants.CUSTOMER_SEARCH_SCOPE_NAME, Constants.CUSTOMER_SEARCH_SCOPE_ADDRESS],
            tintColor: Constants.COLOR_ON_PRIMARY_BLUE,
            backgroundColor: Constants.COLOR_PRIMARY_BLUE
        )
        
        self.searchController.searchResultsUpdater = self
        self.searchController.searchBar.delegate = self
        
        self.navigationItem.searchController = self.searchController
        self.navigationItem.hidesSearchBarWhenScrolling = false
    }
    
    /**
     * Handles the table view refresh control.
     */
    @objc func handleRefreshControl() {
        // @TODO: Refresh

        // Dismiss the refresh control.
        DispatchQueue.main.async {
            self.customerTableView.refreshControl?.endRefreshing()
        }
    }
    
    /**
     * Filters the customers by the given scope.
     */
    func filterCustomersForSearch(searchText: String, scope: String) {
        let lowercasedSearchText = searchText.lowercased()
        
        self.filteredSectionCustomers = []
        
        if self.isSearchBarEmpty() {
            self.filteredSectionCustomers = self.sectionCustomers
        } else {
            
            switch(scope) {
                case Constants.CUSTOMER_SEARCH_SCOPE_NAME:
                    for (sectionTitle, entry) in self.sectionCustomers {
                        var tempCustomers: [Customer] = []
                        
                        for customer in entry {
                            if customer.getDisplayName().string.lowercased().contains(lowercasedSearchText) {
                                tempCustomers.append(customer)
                            }
                        }
                        
                        if tempCustomers.count > 0 {
                            self.filteredSectionCustomers.append((sectionTitle, tempCustomers))
                        }
                    }
                    break;
                case Constants.CUSTOMER_SEARCH_SCOPE_ADDRESS:
                    for (sectionTitle, entry) in self.sectionCustomers {
                        var tempCustomers: [Customer] = []
                        
                        for customer in entry {
                            if customer.getDisplayAddress().lowercased().contains(lowercasedSearchText) {
                                tempCustomers.append(customer)
                            }
                        }
                        
                        if tempCustomers.count > 0 {
                            self.filteredSectionCustomers.append((sectionTitle, tempCustomers))
                        }
                    }
                    break;
                default:
                    break;
            }
        }
        
        self.reloadTableData()
    }
    
    /**
     * - Returns: TRUE, if the search bar is empty, otherwise FALSE.
     */
    func isSearchBarEmpty() -> Bool {
        return self.searchController.searchBar.text?.isEmpty ?? true
    }
    
    /**
     * - Returns: TRUE, if the user is searching, otherwise FALSE.
     */
    func isUserSearching() -> Bool {
        let searchBarScopeIsFiltering = self.searchController.searchBar.selectedScopeButtonIndex != 0
        return self.searchController.isActive && (!self.isSearchBarEmpty() || searchBarScopeIsFiltering)
    }
}

extension CustomerListController {
    
    /**
     * This is called for every table cell.
     */
    override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        guard let cell = tableView.dequeueReusableCell(withIdentifier: "CustomerCell") as? CustomerCell else { return UITableViewCell() }
        let currentCustomer = self.isUserSearching()
            ? self.filteredSectionCustomers[indexPath.section].value[indexPath.row]
            : self.sectionCustomers[indexPath.section].value[indexPath.row]
        
        cell.customerDisplayName.attributedText = currentCustomer.getDisplayName()
        cell.customerDisplayAddress.text = currentCustomer.getDisplayAddress()
        
        return cell
    }
    
    /**
     * Returns the amount of sections.
     */
    override func numberOfSections(in tableView: UITableView) -> Int {
        return self.isUserSearching() ? self.filteredSectionCustomers.count : self.sectionCustomers.count
    }
    
    /**
     * Returns the amount of rows in a section.
     */
    override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return self.isUserSearching()
            ? self.filteredSectionCustomers[section].value.count
            : self.sectionCustomers[section].value.count
    }
    
    /**
     * Set's the title for sections.
     */
    override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
        return (ListUtilities.getKeysFromTuples(
            tuple: (self.isUserSearching() ? self.filteredSectionCustomers : self.sectionCustomers)
            ) as? [String])?[section]
    }
    
    /**
     * The section index title on the right side of the table view.
     */
    override func sectionIndexTitles(for tableView: UITableView) -> [String]? {
        return ListUtilities.getKeysFromTuples(
            tuple: self.isUserSearching() ? self.filteredSectionCustomers : self.sectionCustomers
            ) as? [String]
    }
    
    /**
     * Called, whenever a user deselectes a table cell.
     */
    override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        performSegue(withIdentifier: "showCustomerDetail", sender: self)
    }
    
    /**
     * Prepares the segue to open.
     */
    override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
        if let destination = segue.destination as? CustomerDetailController {
            let indexPath = self.customerTableView.indexPathForSelectedRow
            
            guard let selectedSection = indexPath?.section else { return }
            guard let selectedRow = indexPath?.row else { return }
            
            let customerList = self.isUserSearching()
                ? self.filteredSectionCustomers
                : self.sectionCustomers
            
            destination.customer = customerList[selectedSection].value[selectedRow]
        }
    }
}

extension CustomerListController: UISearchBarDelegate, UISearchResultsUpdating {
    
    /**
     * Called when the suer changes the search scope.
     */
    func searchBar(_ searchBar: UISearchBar, selectedScopeButtonIndexDidChange selectedScope: Int) {
        self.filterCustomersForSearch(searchText: searchBar.text!, scope: searchBar.scopeButtonTitles![selectedScope])
    }
    
    /**
     * Called when the search results need tp be updated (e.g. when the user types a character in the search bar)
     */
    func updateSearchResults(for searchController: UISearchController) {
        let searchBar = self.searchController!.searchBar
        let scope = searchBar.scopeButtonTitles![searchBar.selectedScopeButtonIndex]
        
        self.filterCustomersForSearch(searchText: self.searchController!.searchBar.text!, scope: scope)
    }
}

Class "BaseTableController" extends from UITableViewController and does not contain important things.


I hope, you can help me and I reall appreciate your support.

Thank you!

Looks like your self.sectionCustomers = self.orderCustomersIntoSections() call is missing after you load from your file.


I guess it depends on the amount of data you are storing. Saving to a file works fine. You can also use a database.

Oh well, that worked, thank you very much.


But for an unknown reason, searching doesn't work anymore. Filtering the array works fine, but methods are not called and if I'm trying to scroll down the table view, my app crashed with "Index out of range" on line 296.

As I said, filteredCustomerSections is filtered correctly, but I think this happens because it won't call the methods like:

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

Is your function "reloadTableData()" called if you search?


In general I would not check in every function if user is filtering and then access different lists.

I would always use self.filteredSectionCustomers and make sure, that if there is nothing in the search field, to simply copy your self.sectionCustomers to self.filteredSectionCustomers. This way self.filteredSectionCustomers is always correct filled. If you change self.filteredSectionCustomers you will also have to reload your tableview

Unfortunately, "reloadTableData" won't call if I search...


# EDIT: Got it to work, missed a method call...

Thanks for your help, I really appreciate that!

UITableView sometimes not updating
 
 
Q