iosjsonswiftuitableviewdiffabledatasource

*** Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: 'Fatal: supplied identifiers are not unique.' ***


Hello everyone 🙋‍♂️I'm parsing the following JSON to a UITableView using a UITableViewDiffableDataSource for nice searching animations.

The JSON : https://www.pathofexile.com/api/trade/data/items

And here's the repo : https://github.com/laurentdelorme/PathOfData

From the JSON, I'm able to load all 13 different categories, as mapped in the Model file.I'm then able to push the data inside these categories to another tableView (the one using UITableViewDiffableDataSource) and to display everything nicely.

However, there is ONE category that make my app crash when I try to push its content to the DetailViewController, which is the "Maps" category on the initial ViewController.

Here's my model :

struct ItemCategories: Codable {
    var result: [ItemCategory]
}

struct ItemCategory: Codable {
    var label: String
    var entries: [Item]
}

struct Item: Codable, Hashable {
    var name: String?
    var type: String?
    var text: String?
}

Here's my ViewController :

import UIKit

class ViewController: UITableViewController {

    let urlString = "https://www.pathofexile.com/api/trade/data/items"
    var categories = [ItemCategory]()


    override func viewDidLoad() {
        super.viewDidLoad()
        title = "Path of Data"
        navigationController?.navigationBar.prefersLargeTitles = true
        parseJSON()

        for family: String in UIFont.familyNames
        {
            print(family)
            for names: String in UIFont.fontNames(forFamilyName: family)
            {
                print("== \(names)")
            }
        }
    }

    func parseJSON() {
        guard let url = URL(string: urlString) else { return }
        guard let data = try? Data(contentsOf: url) else { return }

        let decoder = JSONDecoder()

        guard let jsonItemCategories = try? decoder.decode(ItemCategories.self, from: data) else { return }

        categories = jsonItemCategories.result
        tableView.reloadData()
    }


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

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

        var categoryName = categories[indexPath.row].label
        if categoryName == "" { categoryName = "Unknown" }
        cell.textLabel?.text = categoryName

        let font = UIFont(name: "Fontin-SmallCaps", size: 30)
        cell.textLabel?.font = font
        cell.textLabel?.textColor = .systemOrange

        let numberOfItemsInCategory = String(categories[indexPath.row].entries.count)
        cell.detailTextLabel?.text = numberOfItemsInCategory + " items"
        return cell
    }

    override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {

        if let vc = storyboard?.instantiateViewController(identifier: "Detail") as? DetailViewController {

            let listLabel: String? = categories[indexPath.row].label
            vc.title = listLabel

            let itemList = categories[indexPath.row].entries
            vc.items = itemList

            print(itemList)

            vc.category = categories[indexPath.row].label

            navigationController?.pushViewController(vc, animated: true)
        }
    }
}

Here's the DetailViewController :

import UIKit
import SafariServices

class DetailViewController: UITableViewController {

    enum Section {
        case main
    }

    var category: String!
    var items: [Item] = []
    var transformedItems: [Item] = []
    var filteredItems: [Item] = []

    var isSearching: Bool = false

    var dataSource: UITableViewDiffableDataSource<Section,Item>!

    override func viewDidLoad() {
        super.viewDidLoad()
        navigationController?.navigationBar.prefersLargeTitles = true
        navigationController?.navigationBar.tintColor = .systemOrange
        replacenNilNameFor(items: items)
        configureDataSource()
        updateData(on: items)
        congifureSearchController()
    }

    func replacenNilNameFor(items: [Item]) {
        for item in items {
            if item.name == nil {
                guard item.type != nil else { return }
                let newItem = Item(name: item.type, type: nil, text: nil)
                transformedItems.append(newItem)
            } else {
                transformedItems.append(item)
            }
        }
        self.items = transformedItems
    }

    func configureDataSource() {
        dataSource = UITableViewDiffableDataSource<Section, Item>(tableView: self.tableView, cellProvider: { tableView, indexPath, item -> UITableViewCell? in
            let cell = tableView.dequeueReusableCell(withIdentifier: "Detail", for: indexPath)
            cell.textLabel?.text = item.name
            cell.detailTextLabel?.text = item.type

            let font = UIFont(name: "Fontin-SmallCaps", size: 25)
            cell.textLabel?.font = font
            cell.textLabel?.textColor = self.setLabelColor(for: self.category)

            return cell
        })
    }

    func setLabelColor(for category: String) -> UIColor {
        switch category {
        case "Prophecies":
            return UIColor(red: 0.6471, green: 0.1569, blue: 0.7569, alpha: 1.0)
        default:
            return UIColor(red: 0.6392, green: 0.549, blue: 0.4275, alpha: 1.0)
        }
    }

    func updateData(on items: [Item]) {
        var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
        snapshot.appendSections([.main])
        snapshot.appendItems(items)
        dataSource.apply(snapshot, animatingDifferences: true)
    }

    func congifureSearchController() {
        let searchController = UISearchController()
        searchController.searchResultsUpdater = self
        searchController.searchBar.placeholder = "Search for an item"
        searchController.searchBar.delegate = self
        navigationItem.searchController = searchController
    }

    override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        let endpoint = "https://pathofexile.gamepedia.com/"

        let activeArray = isSearching ? filteredItems : items
        let item = activeArray[indexPath.row]

        let url = URL(string: endpoint + formatNameFor(item: item))
        let sf = SFSafariViewController(url: url!)
        present(sf, animated: true)
    }

    func formatNameFor(item: Item) -> String {
        let name = item.name!
        let firstChange = name.replacingOccurrences(of: " ", with: "_")
        let secondChange = firstChange.replacingOccurrences(of: "'", with: "%27")
        return secondChange
    }
}




extension DetailViewController: UISearchResultsUpdating, UISearchBarDelegate {

    func updateSearchResults(for searchController: UISearchController) {
        guard let filter = searchController.searchBar.text, !filter.isEmpty else { return }
        isSearching = true
        filteredItems = items.filter { ($0.name?.lowercased().contains(filter.lowercased()))! }
        updateData(on: filteredItems)
    }

    func searchBarCancelButtonClicked(_ searchBar: UISearchBar) {
        isSearching = false
        updateData(on: items)
    }
}

And here's the error message I get when I try to accees the "Maps" category :

2020-02-28 14:40:20.470098+0100 PathOfData[2789:224548] *** Assertion failure in -[_UIDiffableDataSourceUpdate initWithIdentifiers:sectionIdentifiers:action:desinationIdentifier:relativePosition:destinationIsSection:], /BuildRoot/Library/Caches/com.apple.xbs/Sources/UIKitCore_Sim/UIKit-3901.4.2/_UIDiffableDataSource.m:1417
2020-02-28 14:40:20.474313+0100 PathOfData[2789:224548] *** Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: 'Fatal: supplied identifiers are not unique.'
*** First throw call stack:
(
    0   CoreFoundation                      0x00000001069f327e __exceptionPreprocess + 350
    1   libobjc.A.dylib                     0x0000000105077b20 objc_exception_throw + 48
    2   CoreFoundation                      0x00000001069f2ff8 +[NSException raise:format:arguments:] + 88
    3   Foundation                          0x0000000104a9fb51 -[NSAssertionHandler handleFailureInMethod:object:file:lineNumber:description:] + 191
    4   UIKitCore                           0x0000000119c4dcdf -[_UIDiffableDataSourceUpdate initWithIdentifiers:sectionIdentifiers:action:desinationIdentifier:relativePosition:destinationIsSection:] + 725
    5   UIKitCore                           0x0000000119c4e04e -[_UIDiffableDataSourceUpdate initWithItemIdentifiers:appendingToDestinationSectionIdentifier:] + 90
    6   UIKitCore                           0x0000000119c43408 -[__UIDiffableDataSource appendItemsWithIdentifiers:intoSectionWithIdentifier:] + 165
    7   libswiftUIKit.dylib                 0x0000000105e9f061 $s5UIKit28NSDiffableDataSourceSnapshotV11appendItems_9toSectionySayq_G_xSgtF + 241
    8   PathOfData                          0x0000000104723b41 $s10PathOfData20DetailViewControllerC06updateC02onySayAA4ItemVG_tF + 369
    9   PathOfData                          0x000000010472231f $s10PathOfData20DetailViewControllerC11viewDidLoadyyF + 767
    10  PathOfData                          0x00000001047223db $s10PathOfData20DetailViewControllerC11viewDidLoadyyFTo + 43
    11  UIKitCore                           0x0000000119e22f01 -[UIViewController _sendViewDidLoadWithAppearanceProxyObjectTaggingEnabled] + 83
    12  UIKitCore                           0x0000000119e27e5a -[UIViewController loadViewIfRequired] + 1084
    13  UIKitCore                           0x0000000119e28277 -[UIViewController view] + 27
    14  UIKitCore                           0x0000000119d773dd -[UINavigationController _startCustomTransition:] + 1039
    15  UIKitCore                           0x0000000119d8d30c -[UINavigationController _startDeferredTransitionIfNeeded:] + 698
    16  UIKitCore                           0x0000000119d8e721 -[UINavigationController __viewWillLayoutSubviews] + 150
    17  UIKitCore                           0x0000000119d6f553 -[UILayoutContainerView layoutSubviews] + 217
    18  UIKitCore                           0x000000011a98c4bd -[UIView(CALayerDelegate) layoutSublayersOfLayer:] + 2478
    19  QuartzCore                          0x000000010bbe7db1 -[CALayer layoutSublayers] + 255
    20  QuartzCore                          0x000000010bbedfa3 _ZN2CA5Layer16layout_if_neededEPNS_11TransactionE + 517
    21  QuartzCore                          0x000000010bbf98da _ZN2CA5Layer28layout_and_display_if_neededEPNS_11TransactionE + 80
    22  QuartzCore                          0x000000010bb40848 _ZN2CA7Context18commit_transactionEPNS_11TransactionEd + 324
    23  QuartzCore                          0x000000010bb75b51 _ZN2CA11Transaction6commitEv + 643
    24  UIKitCore                           0x000000011a4d03f4 _afterCACommitHandler + 160
    25  CoreFoundation                      0x0000000106955867 __CFRUNLOOP_IS_CALLING_OUT_TO_AN_OBSERVER_CALLBACK_FUNCTION__ + 23
    26  CoreFoundation                      0x00000001069502fe __CFRunLoopDoObservers + 430
    27  CoreFoundation                      0x000000010695097a __CFRunLoopRun + 1514
    28  CoreFoundation                      0x0000000106950066 CFRunLoopRunSpecific + 438
    29  GraphicsServices                    0x0000000109100bb0 GSEventRunModal + 65
    30  UIKitCore                           0x000000011a4a6d4d UIApplicationMain + 1621
    31  PathOfData                          0x000000010471fe6b main + 75
    32  libdyld.dylib                       0x00000001078c5c25 start + 1
)
libc++abi.dylib: terminating with uncaught exception of type NSException
(lldb)

I can't figure out what's going on here, so if someone has an idea, that would be awesome 😅

Thank you very much !


Solution

  • The error is clearly related to UIDiffableDataSource.

    The diffable datasource requires unique hash values of the item identifiers. Obviously there are two items with the same name, type and text.

    To ensure that the hash value is unique add an uuid property and use only this property for the hash value (implement the protocol methods). To decode Item properly you have to specify CodingKeys to prevent the uuid property from being decoded.

    struct Item: Codable {
        let uuid = UUID()
    
        private enum CodingKeys : String, CodingKey { case name, type, text }
    
        var name: String?
        var type: String?
        var text: String?
    }
    
    extension Item : Hashable {
        static func ==(lhs: Item, rhs: Item) -> Bool {
            return lhs.uuid == rhs.uuid
        }
    
        func hash(into hasher: inout Hasher) {
            hasher.combine(uuid)
        }
    }
    

    In iOS 13+ you can adopt Identifiable to get rid of the Hashable extension

    struct Item: Codable, Identifiable {
        let id = UUID()
    
        private enum CodingKeys : String, CodingKey { case name, type, text }
    
        var name: String?
        var type: String?
        var text: String?
    }
    

    And you are strongly discouraged from loading the data synchronously with Data(contentsOf:, Don't do that. Use asynchronous URLSession