Where's Crusty?

A journey into structs and protocols and abstract types and concrete types, with a little bit of type erasure and SwiftUI thrown in for good measure, all inspired by Crusty.

Getting started

It started simply enough. A SwiftUI view which would display a list of things, each of which is represented by a struct of type ThingOne:

struct ThingOne {
    var name: String
    var description: String
}


struct ContentView: View {
    let things: [ThingOne] = [
        ThingOne(name: "david", description: "Carbon-based bipedal life form"),
        ThingOne(name: "tinker bell", description: "Fairy"),
    ]
    
    var body: some View {
        List {
            ForEach(things, id: \.name) { thing in
                Text(thing.name)
            }
        }
    }
}

The whole id: \.name thing in the ForEach was a bit annoying, so let's make ThingOne conform to Identifiable.

struct ThingOne: Identifiable {
    var id = UUID()
    var name: String
    var description: String
}

Then the ForEach becomes:

             ForEach(things) { thing in

It all goes wrong

Much nicer, even through we're completely ignoring the description field for now. But then the scope of the app changes, and we suddenly have a ThingTwo. Okay, so the name ThingOne might have been a clue...

struct ThingTwo: Identifiable {
    var id = UUID()
    var name: String
    var number: Int
}

It has some things in common with ThingOne, but has a number field instead of the description String. And now we want to have a list of things, which could be either ThingOne or ThingTwo.

     let things = [
        ThingOne(name: "david", description: "Carbon-based bipedal life form"),
        ThingTwo(name: "ultimate answer", number: 42),
        ThingOne(name: "tinker bell", description: "Fairy"),
    ]

But the compiler doesn't like our heterogeneous array:

Heterogeneous collection literal could only be inferred to '[Any]'; add explicit type annotation if this is intentional

So we try the suggestion:

     let things: [Any] = [

But now the SwiftUI ForEach is unhappy:

Cannot convert value of type 'ForEach<[Any], Any, Text>' to closure result type '_'
Generic parameter 'ID' could not be inferred
Explicitly specify the generic arguments to fix this issue
Protocol type 'Any' cannot conform to 'Identifiable' because only concrete types can conform to protocols

Any seems to be too, well, Any-ish. So perhaps we should make ThingOne and ThingTwo conform to a protocol? In Objective-C these structs would probably have been classes, and the obvious answer would have been to make them subclass from an abstract class. But Crusty says to consider protocols instead.

If you haven't watched the Crusty talk, also known as 'Protocol-Oriented Programming in Swift' from 2015, then the references to Crusty probably won't make sense.

protocol Thing: Identifiable {
    var id: UUID { get set}
    var name: String { get set }
}

struct ThingOne: Thing {
    var id = UUID()
    var name: String
    var description: String
}

struct ThingTwo: Thing {
    var id = UUID()
    var name: String
    var number: Int
}

Our Thing protocol ensures that ThingOne and ThingTwo are both Identifiable, and that both will have an id and name. It'll also come in handy later on when we add the inevitable ThingThree and ThingFour.

If we temporarily make our array of Things only contain ThingOne, everything still works:

     let things: [ThingOne] = [
        ThingOne(name: "david", description: "Carbon-based bipedal life form"),
//        ThingTwo(name: "ultimate answer", number: 42),
        ThingOne(name: "tinker bell", description: "Fairy"),
    ]

But when we make an array of heterogeneous Things the compiler is unhappy:

     let things: [Thing] = [
        ThingOne(name: "david", type: "Carbon-based bipedal life form"),
        ThingTwo(name: "ultimate answer", number: 42),
        ThingOne(name: "tinker bell", type: "Fairy"),
    ]
Protocol 'Thing' can only be used as a generic constraint because it has Self or associated type requirements

Crusty? Oh, okay, Crusty says that Thing has Self of associated type requirements so it can only be used as a generic constraint. Helpful, I say, suspecting that he wrote the error message himself. Any time, he says. Any time? Okay, the mention of Any is interesting. What if we make an empty protocol, AnyThing?

protocol AnyThing {
}

protocol Thing: Identifiable {
    var id: UUID { get set}
    var name: String { get set }
}

struct ThingOne: Thing, AnyThing {
    var id = UUID()
    var name: String
    var description: String
}

struct ThingTwo: Thing, AnyThing {
    var id = UUID()
    var name: String
    var number: Int
}

This works a bit better, now we can have an array of AnyThing without the compiler getting upset:

    let things: [AnyThing] = [

Compiler is happy with the array, but not so happy with the ForEach:

Cannot convert value of type 'ForEach<[AnyThing], Any, Text>' to closure result type '_'
Generic parameter 'ID' could not be inferred
Explicitly specify the generic arguments to fix this issue
Protocol type 'AnyThing' cannot conform to 'Identifiable' because only concrete types can conform to protocols

When the number of errors exceeds the number of lines of code in question, something is definitely wrong.

I was going to ask Crusty for help—but I had spent too long trying to work out whether '_' at the end of the first error was actually a sad-faced emoji indicating that the compiler was trying to soften the fact that it couldn't understand what on earth I was trying to do—that he wandered off.

The 'Fix' solution introduces generics into the problem:

             ForEach<[AnyThing], <#ID: Hashable#>, Text>(things) { thing in
                Text(thing.name)
            }
Use of undeclared type '<#ID: Hashable#>'

We don't really fancy fighting angle-brackets right now (they have sharp points after all)—will leave this one for another time. Also—thinking ahead—although the list was to show things of different type, ultimately each one would have a different view type, not just the same Text, so we need a way to switch the displayed View depending on the type.

Putting a switch inside a SwiftUI component? No. Let's just have a function which returns the right View using case let ... as to test the type.

func viewForThing(thing: AnyThing) -> some View {
    switch thing {
        case let thingOne as ThingOne:
            return Text("ThingOne: \(thingOne.name)")
        case let thingTwo as ThingTwo:
            return Text("ThingTwo:  \(thingTwo.name)")
        default:
            return Text("unknown")
    }
}

Ultimately we're not going to be returning Text views for each thing but for now this will do. Or will it? Going off on a tangent, perhaps the default error case would be so much cooler if it was handled as an Image from SFSymbols so we can more easily spot if we've passed a thing that can't be handled?

         default:
            return Image(systemName: "xmark.octagon.fill")

Now, although we've said we're returning some View, the fact that we're now returning different Views upsets the compiler:

Function declares an opaque return type, but the return statements in its body do not have matching underlying types. 

Can a Text or an Image share an underlying type? Perhaps use AnyView for a bit of type erasure?

func viewForThing(thing: AnyThing) -> some View {
    switch thing {
        case let thingOne as ThingOne:
            return AnyView(Text("ThingOne: \(thingOne.name)"))
        case let thingTwo as ThingTwo:
            return AnyView(Text("ThingTwo:  \(thingTwo.name)"))
        default:
            return AnyView(Text("unknown"))
    }
}

That works! We finally see the list of three things, of two different types.

Improvements

Do ThingOne and ThingTwo have to conform to both Thing and AnyThing? Can we move the AnyThing conformance to Thing? Umm... yes.

And as a bonus feature, let us add an extension to Thing so that both ThingOne and ThingTwo can return a pretty name to display—in this example just capitalized but ultimately we'll doing more things to ensure that the names that are displayed match our style guidelines for the app.

import SwiftUI


protocol AnyThing {
}


protocol Thing: Identifiable, AnyThing {
    var id: UUID { get set}
    var name: String { get set }
}


extension Thing {
    func prettyName() -> String {
        return name.capitalized
    }
}


struct ThingOne: Thing {
    var id = UUID()
    var name: String
    var description: String
}


struct ThingTwo: Thing {
    var id = UUID()
    var name: String
    var number: Int
}


func viewForThing(thing: AnyThing) -> some View {
    switch thing {
        case let thingOne as ThingOne:
            return AnyView(Text("ThingOne: \(thingOne.prettyName())"))
        case let thingTwo as ThingTwo:
            return AnyView(Text("ThingTwo:  \(thingTwo.prettyName())"))
        default:
            return AnyView(Image(systemName: "xmark.octagon.fill"))
    }
}


struct ContentView: View {
    
    let things: [AnyThing] = [
        ThingOne(name: "david", description: "Carbon-based bipedal life form"),
        ThingTwo(name: "ultimate answer", number: 42),
        ThingOne(name: "tinker bell", description: "Fairy"),
    ]
    
    var body: some View {
        List {
            ForEach(0..<self.things.count) { index in
                viewForThing(thing: self.things[index])
            }
        }
    }
}


struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

Summary

It works, some parts are quite elegant, others not so elegant. The ForEach is a bit inelegant; would prefer to be able to get the thing rather than the index, but will fight the generics another day. Having to have a Thing and an AnyThing protocol seems a bit of duplication, but it keeps the compiler happy.

And if Crusty is around I'd love to hear where this approach could be improved!

Discuss?

Haven't added a commenting system to this app, and probably won't. So if you have any feedback—positive or negative—either say it via email to david@cocoacheerleaders.com or on Twitter at @CocoaCheer.