array.contains(where: ...) returns true in debugger console, but false in application

I am encountering a strange issue. I have a class that manages a selection of generic items T in an Array. It's a work in progress, but I'l try to give a gist of the setup.

class FileManagerItemModel: NSObject, Identifiable, Codable, NSCopying, Transferable, NSItemProviderReading, NSItemProviderWriting {

    var id: URL
    
    static func == (lhs: FileManagerItemModel, rhs: FileManagerItemModel) -> Bool {
        lhs.fileURL == rhs.fileURL
    }

    var fileURL: URL {
        FileManagerItemModel.normalizedFileURL(type: type,
                                               rootURL: rootURL,
                                               filePath: filePath)
    }

    init(type: FileManagerItemType, rootURL: URL, fileURL: URL) {
        self.type = type
        self.rootURL = rootURL
        self.filePath = FileManagerItemModel.filePathRelativeToRootURL(fileURL: fileURL, rootURL: rootURL) ?? "[unknown]"
        self.id = FileManagerItemModel.normalizedFileURL(type: type,
                                                         rootURL: rootURL,
                                                         filePath: filePath)
    }
}

The class that manages the selection of these FileManagerItemModels is like so:

@Observable
class MultiSelectDragDropCoordinator<T: Hashable>: ObservableObject, CustomDebugStringConvertible {
    private(set) var multiSelectedItems: [T] = []

    func addToSelection(_ item: T) {
        if !multiSelectedItems.contains(where: { $0 == item }) {
            multiSelectedItems.append(item)
        }
    }
...
}

My issue is that the check if !multiSelectedItems.contains(where: { $0 == item }) in func addToSelection fails. The if is always executed, even if multiSelectedItems contains the given item.

Now, my first thought would be to suspect the static func == check. But that check works fine and does what it should do. Equality is defined by the whole fileURL.

So, the if should have worked. And If I put a breakpoint in func addToSelection on the if, and type po multiSelectedItems.contains(where: { $0 == item }) in the debug console, it actually returns true if the item is in multiSelectedItems. And it properly return false if the item is not in multiSelectedItems.

Still, if I then continue stepping through the app after the breakpoint was hit and I confirmed that the contains should return true, the app still goes into the if, and adds a duplicate item.

I tried assigning to a variable, I tried using a function and returning the true/false. Nothing helps.

Does anyone have an idea on why the debugger shows one (the correct and expected) thing but the actual code still does something different?

Answered by DTS Engineer in 823684022

Is there a reason for your FileManagerItemModel class to subclass NSObject?

I ask because the concept of equality is expressed differently in Swift and Objective-C, and NSObject subclasses have to follow the Objective-C rules. So, the correct fix here depends on whether you need to be an NSObject subclass or not.

In summary:

  • In Swift, equality is handled by two protocols: Equatable and Hashable.

  • OTOH, all NSObject subclasses are automatically equatable and hashable based pointer equality. If you want different behaviour, override the default implementation of the isEqual(_:) method and the hash property getter.

There’s a great article, Adopting Common Protocols that goes into this in more depth.

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

Accepted Answer

Thank you all people who viewed and maybe put some thought in this. I did not stop thinking about it myself, and found out what the issue was.

Basically I was believing that Identifiable was used to identify single items in the collection (Array), but it is actually Hashable. So it's not the id that's used as the identification of an item in the collection.

A part of my app that I did not show (I didn't want to make this question more TL;DR than it already was), is that: if a selection is made, I update my datasource.

That means that my datasource has a new bunch of FileManagerItemModel items, which all have the same id as in the collection, but not the same hashValue. And so, even though the ids are the same as before, the items could not be matched anymore because the hashValues changed.

The quick solution is to add this to FileManagerItemModel:

    override var hash: Int {
        id.absoluteString.hashValue
    }

And change the .contains check into this:

multiSelectedItems.contains(where: { $0.hashValue == item.hashValue })

But this is not going to be the final implementation. Now that I understand my mistake, I should be able to make a cleaner solution.

Thank you for thinking with me. Even though nobody had time to reply anything, surely one of you thought 'maybe hash value?', and synchronicity took care of the rest. ;)

Is there a reason for your FileManagerItemModel class to subclass NSObject?

I ask because the concept of equality is expressed differently in Swift and Objective-C, and NSObject subclasses have to follow the Objective-C rules. So, the correct fix here depends on whether you need to be an NSObject subclass or not.

In summary:

  • In Swift, equality is handled by two protocols: Equatable and Hashable.

  • OTOH, all NSObject subclasses are automatically equatable and hashable based pointer equality. If you want different behaviour, override the default implementation of the isEqual(_:) method and the hash property getter.

There’s a great article, Adopting Common Protocols that goes into this in more depth.

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

Thank you Quinn! Yes, the reason is that I use the FileManagerItemModel for drag & drop operations with a custom preview. For .onDrag(data, preview), I need to pass the data parameter as a closure returning an NSItemProvider. So in the closure, I create the NSItemProvider as NSItemProvider(object: item), where item is of type FileManagerItemModel.

And in order to do that, the FileManagerItemModel needs to implement NSItemProviderWriting. And that requires the FileManagerItemModel to be derived from NSObject (and to be a class instead of a struct too).

So, now I manage equality in FileManagerItemModel like so:


class FileManagerItemModel: NSObject, NSCopying, NSItemProviderReading, NSItemProviderWriting, Identifiable, Codable, Transferable {

    

    // MARK: - Identification. The items are identified by type, rootURL and filePath.


    // MARK: Swift Identifiable and Equatable

    

    var id: URL

    

    static func == (lhs: FileManagerItemModel, rhs: FileManagerItemModel) -> Bool {

        lhs.id == rhs.id

    }

    

    // MARK: ObjC equality

    

    override var hash: Int {

        id.absoluteString.hashValue

    }

    

    override func isEqual(_ object: Any?) -> Bool {

        guard let other = object as? FileManagerItemModel else { return false }

        return self.hashValue == other.hashValue

    }

...

    init(type: FileManagerItemType, rootURL: URL, filePath: String) {

        self.type = type

        self.rootURL = rootURL

        self.filePath = filePath

        self.id = FileManagerItemModel.normalizedFileURL(type: type,

                                                         rootURL: rootURL,

                                                         filePath: filePath)

    }

...

    private static func normalizedFileURL(type: FileManagerItemType, rootURL: URL, filePath: String) -> URL {

        switch type {

        case .genericFolder(_):

            return rootURL.appending(folderPathString: filePath)

        default:

            return rootURL.appending(filePathString: filePath) ?? rootURL

        }

    }

...

}

I'm sure this will not be the final implementation, but it works for now.

Note that this is a pet project that I am using to learn things, while at the same time having something useful in the end (a two-window file manager, a bit like Norton Commander). So my design decisions are not always the best. My main objective is to find out what works and what does not.

In-app multi-item drag&drop between two different Views is currently a bit of a difficult thing, when using SwiftUI. Let's just say that I have been learning a lot. ;)

Written by RetepV1st in 823703022
I'm sure this will not be the final implementation

It’s definitely not correct. For an NSObject subclass, if you want to change how equality works, you must override the isEqual(_:) method. Adopting Common Protocols has an example.

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

array.contains(where: ...) returns true in debugger console, but false in application
 
 
Q