Search code examples
swiftswiftuipolymorphismdispatch

SwiftUI: dynamic view dispatch? Polymorphic views?


I'm a Swift/SwiftUI beginner and am trying to make a program that visualizes a few different types of data (w/ swift 5.7, targeting macOS).

I have a root "Data" protocol, and a few different protocols for its subtypes. A simplified example:

protocol Data {}
protocol HasA: Data { var a: String {get} }
protocol HasB: Data { var b: String {get} }

struct StructA: HasA { let a: String = "I am A"}
struct StructB: HasB { let b: String = "I am B"}

Now, I'd like to have a view that picks a visualization strategy based on the specific type of data it receives. This doesn't work, but here's how I'd write in a dynamic language:

struct DataView: View {
    let data: Data 
    var body: some View {
        // this doesn't actually work
        if data is HasA {
            ViewA(data) //  ERROR: type 'any Data' does not conform to the expected type 'HasA'
        } else if data is  {
            ViewDefault(data)
        }
    }
}

I've tried many, many different ways to accomplish this, without success. So clearly, there's something fundamentally wrong with my strategy. How should I be approaching this problem? Generics? Using concrete classes only? More existential types?

Some things I've tried that didn't work

  1. Generics in many different forms, all of which ended up with type 'any Data' does not conform to expected type 'Data' errors.

  2. Protocol extensions of the form:

    extension Data { func getView() -> View { ...} }
    extension HasA { func getView() -> View { ...} }
    

    This will compile, but at runtime, it only calls the Data.getView() implementation, not the HasA.getView() implementation.

  3. Adding a required func getView() -> any View method to the Data protocol, and then implementing it on the concrete classes. This leads to Type 'any View' cannot conform to 'View' errors.

  4. Runtime dispatch via protocol conformance - this is what's in the example above, i.e., if data is HasA {return ViewA(data)}. I didn't really expect this to work, and it doesn't - the compiler complains about protocol conformance.

Edited to add

In addition to @EDUsta's solution below, in my real production solution, I also had to remove all associated types and generic parameters from the data protocols that the view layer uses - otherwise downcasting wouldn't work.


Solution

  • With your current approach, at some point, you need to know the underlying type/protocol to act upon it. If you'd like to continue with this approach, this would work out of the box:

    struct DataView: View {
        let data: Data
        var body: some View {
            switch data {
            case is HasA:
                ViewA(data: data as! HasA)
            default:
                EmptyView()
            }
            // or
            if let data = data as? HasA {
                ViewA(data: data)
            } else {
                EmptyView()
            }
        }
    }
    

    The reason is, is HasA is not enough for a (smart) cast, like Kotlin.

    I'd suggest another approach with SwiftUI though, AnyView! AnyView erases the type of the View, so you can combine this with your third approach:

    protocol NVData {
        func getView() -> AnyView
    }
    
    protocol HasA: NVData {
        var a: String { get }
    }
    
    protocol HasB: NVData {
        var b: String { get }
    }
    
    extension HasA {
        func getView() -> AnyView {
            AnyView(ViewA(data: self))
        }
    }
    
    extension HasB {
        func getView() -> AnyView {
            AnyView(EmptyView())
        }
    }
    
    struct StructA: HasA { let a: String = "I am A" }
    struct StructB: HasB { let b: String = "I am B" }
    
    struct DataView: View {
        let data: any NVData
        var body: some View {
            data.getView()
        }
    }
    
    struct ViewA: View {
        let data: HasA
    
        var body: some View {
            Text(data.a)
        }
    }
    

    Deriving the View from the Data inverts the dependency, though. Something to keep in mind.