BrendanS
BrendanS

Reputation: 331

Using SwiftUI ForEach to iterate over [any Protocol] where said protocol is Identifiable

In a ViewModel I have:

public var models: [any Tabbable]

And Tabbable starts with

public protocol Tabbable: Identifiable {
   associatedtype Id
   var id: Id { get }
   /// ...
}

In Swift in my ViewModel I can use the Array.forEach to:

models.forEach { _ in
   print("Test")
}

But in SwiftUI I can't use ForEach to:

ForEach(viewModel.models) { _ in
   Text("Test")
}

Due to:

Type 'any Tabbable' cannot conform to 'Identifiable'

Any suggestions? This is with Swift 5.7. Do I need to go back to making something like AnyTabbable? Thanks in advance.

Upvotes: 7

Views: 3293

Answers (3)

alobaili
alobaili

Reputation: 904

Scott's answer explains very well the reason why the compiler throws that error. I was experimenting with a way to give the compiler the necessary information to make this code compile and found a solution, but it requires adding constraints to your protocol and extending ForEach.

you need to constraint the ID type of your protocol to a concrete Hashable type, like UUID. It has very specific syntax. This can be a downside if your use case depends on different types for ID.

protocol Tabbable: Identifiable where ID == UUID {
    var id: Self.ID { get }
}

Then you need to extend ForEach and give it an initializer that have all the necessary information to infer the id parameter for you, again, it has very specific syntax:

extension ForEach where ID == UUID, Content: View, Data.Element == any Tabbable {
    init(_ data: Data, @ViewBuilder content: @escaping (any Tabbable) -> Content) {
        self.init(data, id: \.id, content: content)
    }
}

Then you will be able to pass a value of type [any Tabbable] to a ForEach as expected. I don't fully understand why the extension is needed, but without it, the code would not compile unless you pass id explicitly when initializing ForEach.

Upvotes: 2

Scott Thompson
Scott Thompson

Reputation: 23701

Consider this example in the form of a Playground:

import UIKit
import SwiftUI

public protocol Tabbable: Identifiable {
    associatedtype Id
    var id: Id { get }
    
    var name : String { get }
}

struct TabbableA : Tabbable{
    typealias Id = Int
    
    var id : Id = 3
    var name = "TabbableA"
}

struct TabbableB : Tabbable {
    typealias Id = UUID
    
    var id : Id = UUID()
    var name = "TabbableB"
}

struct ViewModel {
    public var models: [any Tabbable]
}

let tabbableA = TabbableA()
let tabbableB = TabbableB()
let models: [any Tabbable] = [tabbableA, tabbableB]

struct ContentView {
    @State var viewModel : ViewModel = ViewModel(models: models)
    
    var body : some View {
        ForEach(viewModel.models) { model in
            Text(model.name)
        }
    }
}

In this case, we have the type TabbableA where each instance has an id property that is an integer (for the sake of the sample they only use "3" but it's the type that is significant). In TabbableB each instance's id is a UUID. Then I create an array where one item is an instance of TabableA and another is an instance of TabbableB. The array is of type [any Tabbable].

Then I try to use ForEach on the array. But some elements in the array use Int ids and some use UUID ids. The system doesn't know what type to use to uniquely identify views. Ints and UUIDs can't be directly compared to one another to determine equality. While each item that went into the array is Tabbable, and therefore conforms to Identifiable, the elements coming out of the array, each of which is of type any Tabbable, do not conform to Identifiable. So the system rejects my code.

Another way to think about this: A value whose type is any SomeProtocol is a box that can contain anything that conforms to SomeProtocol, but the box itself does not conform to SomeProtocol

Upvotes: 10

Đỗ Long Thành
Đỗ Long Thành

Reputation: 499

You can provide a KeyPath to the id (or any unique variable): parameter which specifies how to retrieve the ID in ForEach. It is because all items in ForEach must be unique. You can create a structure, which conforms to your protocol. At that moment it should work. The second option is to remove Identifiable from the protocol and then you can use that directly.

    public protocol Tabbable {
        var id: String { get }
        /// ...
    }
    
    public var models: [Tabbable]

    ForEach(viewModel.models, id: \.id) { _ in
       Text("Test")
    }

or

    public protocol TabbableType: Identifiable {
        associatedtype Id
        var id: Id { get }
        /// ...
    }

    struct Tabbable: TabbableType {
        var id: String { get }
        /// ...
    }

    public var models: [Tabbable]

    ForEach(viewModel.models) { _ in
       Text("Test")
    }

Upvotes: 1

Related Questions