Understanding protocols + any, & how to switch to concrete types

Premise

I’m currently trying to build out a SwiftUI View to display cells of various sizes, each containing some content view struct that I want to additionally conform to a custom protocol.

Container Views will hold these content views, and will require a closure returning any View & CustomProtocol which I’ve been able to implement with concrete types.

In addition to just displaying these cells, I want the content inside cells to be selectable by the user, and to store those selections. For this I've tried a couple different methods to store these values, but in both attempts, I have not been able to convert the stored values into concrete instances of a conforming content view.

Issues

As for the errors I am encountering, both are at the final @ViewBuilder funcs, where I'm experiencing branch-mismatch on the switch statement, or nonconformance of switch to View without @ViewBuilder. I'm also experiencing vague errors about the use of some, any, and protocols on their own in the first func. I understand why I’m experiencing branch mismatch on a switch statement, and vaguely understand the concept behind any Protocolnot conforming to Protocol. To the best of my understanding, the switch statement func is the closest-to-correct execution I have, and a custom resultBuilder might be the answer, but I’m in over my head with those at the moment, and have not been able to successfully implement my own

Context to Code

I will include a minimum replicable code sample below outlining my design. The protocol Secondary represents my custom protocol, followed by an example content view, container cell view, and finally the main view. The main view’s struct also includes two funcs attempting to convert stored value to struct instances.

Minimum Replicable Code

import SwiftUI
protocol Secondary {
    
    associatedtype Assoc: Secondary & View
    
    var height: CGFloat { get }
    var tint: Color { get }
    
    init()
    init(_ assoc: Assoc, height: CGFloat?)
    
    func height(_ height: CGFloat) -> Assoc
}

struct TestCellContent: View, Secondary {
    
    typealias Assoc = TestCellContent
    
    var height: CGFloat
    var tint: Color
    
    init() {
        self.height = 150
        self.tint = .blue
    }
    
    init(_ assoc: Self, height: CGFloat? = nil) {
        self.tint = assoc.tint
        self.height = height ?? assoc.height
    }
    
    var body: some View {
        Text("Example Content")
            .foregroundStyle(tint)
            .frame(height: height)
    }
    
    func height(_ height: CGFloat) -> Self {
        .init(self, height: height)
    }
}

struct InfoCellContent: View, Secondary {
    
    typealias Assoc = InfoCellContent
    
    var height: CGFloat
    var tint: Color
    
    init() {
        self.height = 120
        self.tint = .blue
    }
    
    init(_ assoc: Self, height: CGFloat? = nil) {
        self.tint = assoc.tint
        self.height = height ?? assoc.height
    }
    
    var body: some View {
        Text("Informative Content")
            .foregroundStyle(tint)
            .frame(height: height)
    }
    
    func height(_ height: CGFloat) -> Self {
        .init(self, height: height)
    }
}

struct SmallContainerCell<Content: View & Secondary>: View {
    var height: CGFloat = 100
    
    var content: Content
    
    init(_ content: @escaping () -> Content) {
        self.content = content()
    }
    
    var body: some View {
        content
            .height(height)
    }
}

enum CellType {
    case test, info
}

struct Storage: Identifiable {
    var id: UUID = UUID()
    
    var content: any Secondary & View
    var conentType: CellType
}
    

struct SummaryView: View {
    
    @State private var cells: [Storage]
    
    var body: some View {
        VStack {
            ForEach(cells) { cell in
                
                VStack {
                    SmallContainerCell {
                        makeCell(for: cell)
                    }
                    
                    SmallContainerCell {
                        makeCell(for: cell.conentType)
                    }
                }
            }
        }
    }
    
    @ViewBuilder func makeCell(for input: Storage) -> some Secondary & View {
        input.content
    }
    
    @ViewBuilder func makeCell(for type: CellType) -> some Secondary & View {
        switch type {
        case .test:
            TestCellContent()
        case .info:
            InfoCellContent()
        }
    }
}

First let’s talk about why your original code didn’t work.

@ViewBuilder func makeCell(for input: Storage) -> some Secondary & View {
    input.content // any Secondary & View
}

The any and some keywords mean different things, and express different types. The way I think about it is basically: some means “some concrete type, determinable at compile-time, conforming to xyz”, and any means “any type conforming to xyz, not always determinable at compile-time, but always determinable at run-time”. (There’s probably a more formal, more correct definition — I’m sure someone else will correct me!)

Additionally, some “hides” the concrete type, but the compiler is still aware of what the actual type is. If I write:

func someNumber() -> some BinaryInteger { 1 }

The compiler can still reason and see that what you’re returning is an Int and that you’re keeping your promise: you’re returning a concrete type that conforms to BinaryInteger, it’s just now hidden behind the some BinaryInteger type.

With Storage.content: any Secondary & View, the compiler can’t tell what the concrete type is in the same way, because you can assign anything to content, from anywhere in your code at runtime. It can’t ensure you’re keeping your promise.

Next up you have:

@ViewBuilder func makeCell(for type: CellType) -> some Secondary & View {
    switch type {
    case .test:
        TestCellContent()
    case .info:
        InfoCellContent()
    }
}

This one give us:

Return type of instance method 'makeCell(for:)' requires that '_ConditionalContent<TestCellContent, InfoCellContent>' conform to 'Secondary'

That _ConditionalContent in there is your clue: ViewBuilder turns switch statements into _ConditionalContent views. That means what you’re really returning from this method is the internal, SwiftUI view _ConditionalContent, and _ConditionalContent doesn’t conform to Secondary.

The fix for this one is simple: drop Secondary, and just return some View.

Now let’s talk about a different approach that might fit what you’re doing better. Let’s start with CellType, since, other than Secondary, it’s more or less at the core of what you’re trying to express:

Let’s update this enum! We can add some associated values to make the relationship between cell types and the actual cell view more explicit. We can also make the enum into a view itself:

enum AnyCellView: View {
    
    case test(TestCellContent)
    case info(InfoCellContent)

    var body: some View {
        switch self {
            case .test(let view): view
            case .info(let view): view
        }
    }

}

Let’s also update Storage as well:

struct Storage: Identifiable {
    
    var id = UUID()
    var content: AnyCellView

}

Now we can update and simplify your ForEach loop to return cell.content directly:

ForEach(cells) {
    cell in
                
    VStack {
        SmallContainerCell {
            cell.content
        }
        
        SmallContainerCell {
            cell.content
        }
    }
}

Hopefully that helps and gives you a starting point! (Without more context, it’s possible that you’re in a situation where you might have to break out AnyView, but so far that shouldn’t be necessary.)

6 Likes

This has been super helpful, thank you! Associated Values in enums is not something I’m very familiar with and so it never occurred to me as a solution. In terms of any and some, that adds some more clarity to what's actually happening, I appreciate that!

As for AnyView wrapping, I agree with you as I’ve also associated it as something to avoid if possible. From my understanding, it would also mean I lose the guaranteed Secondary conformance that I rely on in the cell container view.

My only trouble with this approach is that I am relying on the conformance to Secondary in the SmallContainerView closure, to be able to call Secondary-specific functions in the body of the SmallContainerCell view struct.
I may have implemented the code incorrectly, but with these changes I still get 1 of 2 errors. Calling the enum's content var directly shows an error of non-conformance to the Secondary protocol in the result passed to SmallContainerView. Adding Secondary conformance to the var body of AnyCellView returns a very similar error as before regarding ConditionalContent. I assume this is because with Secondary conformance, the ViewBuilder can no longer do its job. This is where I was getting the impression that a custom resultBuilder was necessary.

Aside from that, I really appreciate the insight and the design around associated values in enums. I'm very unfamiliar with those, and that provides a very neat way to control the code. Thank you!

Update: Attaching Specific Error - Adding Secondary conformance to AnyCellView's var body

Return type of property 'body' requires that '_ConditionalContent<_ConditionalContent<BlankSummaryCell, TestSummaryCell>, DistributionSummaryCell>' conform to 'AnyCellView'

Hmm figuring out the best way to move forward is probably going to mean asking some more questions.

Why do you have SmallContainerCell at all? It looks like it’s enforcing height and some other things, but those things could just be enforced by the content view itself. What role are your container views playing here?

My 'minimum replaceable code' doesn't include some of the special functions of my protocol. I left them out since they seem tangential to the issue, but they are the justification for why I'm looking for protocol conformance.
These required funcs would return new instances of Self for each struct. My implantation would have them change an internal value which would change the content being presented. This would be such as an edit func or a way of passing in a "size" of a custom enum type. My current full code displays different content with different prominence based on that size enum.

The purpose of these container view were two fold

  1. To enforce these protocol-required funcs
  2. Create a custom carrier for presenting two cells side-by-side. My intention was to display cells along a VStack from a 1D array. By using a carrier container for two small cells, I could move the complexity to the container view rather than adding the dimension to the array, which I plan to store through @AppStorage, so I want to minimize complexity here.

Following your suggestions, I realized this carrier-ness can just be an if statement in the ForEach loop. I suppose I was overcomplicating things there.
In terms of those cell's vars that I want to control with funcs -> Self, there are two kinds.

  1. Relatively static: values such as size. These I can solve now by implementing your suggestions, and calling those funcs when I create an instance of a cell to store in the CellType enum. Since these won't change without changing the stored values in the Storage array, this is actually perfectly fine.
  2. Relatively dynamic: values such as edit These I want to control with an @State in the main view's struct, and without conformance to Secondary, I can't call these funcs as they're no longer guaranteed. I still haven't figured out how to do this, even with calling the CellType enum's var body directly from the SummaryView cell.

Lastly, I want to clarify that I don't have an inherent reason for using a no-parameter init for my cells, with funcs -> self to set parameter values. This is just something new I discovered that seems to flow more closely to the rest of SwiftUI. I don't have a problem deviating from that, although I experimented with parameter-containing inits for cells to see if that may solve the issue of changing dynamic values and didn't see a solution there either

One observation is that there’s a bit of mixed concerns here. It seems like you want your Secondary cells to act both as what I’d consider view models (storing information for other views to read and use to rendering content) rather than pure views (views that use their state to influence the rendering of their body.) I’m not sure if I’m right in that assessment, but if that’s correct then I’d consider separating those concerns in some way.

If I’m understanding you and you want to influence the state and rendering of a child view from an ancestor view, then even if your Secondary views have special methods on them, calling them won’t “update” the view; that’s not how SwiftUI works (for a few reasons.) The typical ways to do that are:

  1. Re-initializing the child view with new parameters
  2. Using View.environment to make values available to children
  3. Passing down a Binding to a child

In your case, 2 & 3 sound like they might fit best, but I’m not fully understanding what edit and other methods look like.