Why does Identifiable as any Hashable break List animations?

I have a grouped list in in which I can delete items. If the groups id property is declared as String, everything animates correctly. If I however change it to any Hashable the delete animation gets janky.

import SwiftUI
import Combine

class ItemGroup: Identifiable, Equatable {
    static func == (lhs: ItemGroup, rhs: ItemGroup) -> Bool {
        lhs.items == rhs.items
    }
    
    var id: any Hashable { "some static ID" }   // 🚨 change to `String` to fix delete animation
    
    let items: [String]
    
    init(items: [String]) {
        self.items = items
    }
}

class ViewModel: ObservableObject {
    private var items: [String]
    @Published private(set) var groups: [ItemGroup]
    
    private var updateItems = PassthroughSubject<Void, Never>()
        
    init(items: [String]) {
        self.items = items
        self.groups = []
        
        updateItems
            .compactMap { [weak self ] _ in
                guard let self = self else { return nil }
                return [ItemGroup(items: self.items)]
            }
            .assign(to: &$groups)
        
        updateItems.send()
    }
    
    func remove(at: IndexSet) {
        items.remove(atOffsets: at)
        updateItems.send()
    }
}

struct PlaygroundList: View {
    @StateObject var viewModel: ViewModel
    
    init(items: [String]) {
        _viewModel = StateObject(wrappedValue: ViewModel(items: items))
    }
    
    var body: some View {
        return List {
            ForEach(viewModel.groups) { group in
                Section {
                    ForEach(group.items, id: \.self) { item in
                        Text(item)
                    }.onDelete { viewModel.remove(at: $0) }
                } header: {
                    Text("Section")
                }
            }
        }
    }
}

struct PlaygroundList_Previews: PreviewProvider {
    static var previews: some View {
        PlaygroundList(items: ["A", "B", "C", "D", "E", "F"])
    }
}

I'm aware that there are a few points one could argue about in the code above. But those are not the point. The code only exists in this form to serve as illustration for my question: Why does it make a difference if id is declared as any Hashable instead of as String?

var id: any Hashable { ... } is not a proper implementation of the Identifiable protocol. If you try this on a struct you do get a compile error, here you do not get a compile error because it is ignored in favor of a default implementation provided by Swift for classes. The default implementation uses the class identity (an ObjectIdentifier based on the instance memory address I think).

4 Likes

Sweet, makes sense. I guess debugging it instead of only looking at the SwiftUI preview would have helped :roll_eyes: Thanks for the explanation, TIL :slight_smile:

1 Like