Search code examples
iosswiftswiftuiobservation

SwiftUI: pass binding as generic?


I have the following code:

func randomString(length: Int) -> String {
  let letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
  return String((0..<length).map{ _ in letters.randomElement()! })
}


enum ViewRouter: Hashable, Identifiable, Equatable {
    
    static func ==(lhs:ViewRouter, rhs:ViewRouter) -> Bool {
        lhs.id == rhs.id
    }
    
    func hash(into hasher: inout Hasher) {
        hasher.combine(id)
    }

    var id: Int {
        switch self {
        case .carDetails(let car):
            return car.id.hashValue
        case .foodDetails(let food):
            return food.id.hashValue
        }
    }
    
    case carDetails(Car)
    case foodDetails(Food)
    
}

@Observable class TopViewmodel {
    var navPath = NavigationPath()
}

struct TopView: View {
    
    @State var model = TopViewmodel()
    @State var car = Car()
    @State var foodItem = Food()
    
    let timer = Timer.publish(every: 2, on: .main, in: .common).autoconnect()

    
    var body: some View {
        
        NavigationStack(path: $model.navPath) {
            VStack {
                Button {
                    model.navPath.append(ViewRouter.foodDetails(foodItem))
                } label: {
                    Text(foodItem.name)
                }
                Button {
                    model.navPath.append(ViewRouter.carDetails(car))
                } label: {
                    Text(car.name)
                }
            }
        }
        .navigationDestination(for: ViewRouter.self) { d in
            switch d {
            case .carDetails(let car):
                ItemView(item: car) // <- Error: Cannot convert value of type 'Car' to expected argument type 'Binding<any ItemViewable>'
            case .foodDetails(let food):
                ItemView(item: food)
            }
        }
        .onReceive(timer) { input in
            car.name = car.name + randomString(length: 6)
        }
        
    }
    
    
    
}




// MARK: ITEMS
@Observable class Car:ItemViewable {
    var id = UUID()
    var name:String = "My Car"
    func changeName() {
        name = name + randomString(length: 5)
    }
}
@Observable class Food:ItemViewable {
    var id = UUID()
    var name:String = "Apple"
    func changeName() {
        name = name + randomString(length: 5)
    }
}




// MARK: ITEM VIEW
protocol ItemViewable {
    var name:String { get set }
    func changeName()
}
struct ItemView: View {
    
    @Binding var item:ItemViewable
    
    var body: some View {
        
        VStack {
            Text("Item name: \(item.name)")
            Button {
                item.changeName()
            } label: {
                Text("Change name")
            }
        }
        
    }
    
}

Objective: Have ItemView work with different models as long as they adopt ItemViewable protocol. This allows me to decouple models that adopt ItemViewable from the presentation layer and have one view handle any of those models as long as they adopt ItemViewable. Not just read, but also edit abilities while on ItemView. (thus the need of a Binding).

In this example I have two "items", Car and Food. I want to be able to display and edit (binding) the name property of both using only one view. If other parts of the app change the name property on either (timer in here as a dev test...), I want ItemView to immediately show the change but I also want to trigger the name change from within ItemView when I press the button on it.

Error: at the moment I am not sure how to properly pass in car or food as a binding and I get this error: Cannot convert value of type 'Car' to expected argument type 'Binding<any ItemViewable>'


Solution

  • You could change your ItemView to be generic e.g.:

    struct ItemView<T: ItemViewable>: View {
        
        @Binding var item: T
        
        var body: some View {
            
            VStack {
                Text("Item name: \(item.name)")
                Button {
                    item.changeName()
                } label: {
                    Text("Change name")
                }
            }
        }
    }
    

    and then your navigation becomes:

    .navigationDestination(for: ViewRouter.self) { d in
        switch d {
        case .carDetails(_):
            ItemView(item: $car) 
        case .foodDetails(_):
            ItemView(item: $foodItem)
        }
    }
    

    Edit:

    In response to the comment. If you want to use the value from the enum you would need to create a new binding and pass that along. But I don´t know exactly why one would do it that way.

    e.g.:

    case .carDetails(let car):
        let binding = Binding<Car> {
            car
        } set: { value, transaction in
            // uncertain what car you want to manipulate here
        }
    
        ItemView(item: binding)