Using @Bindable with a protocol?

Hi!

if I try to use @Bindable with a protocol, the compiler complains that either (at the line where I use @Bindable):

'init(wrappedValue:)' is unavailable: The wrapped value must be an object that conforms to Observable

or (at the line of the TextField):

Referencing subscript 'subscript(dynamicMember:)' on 'Bindable' requires that 'any Book' be a class type

I am trying to do something like this:

@MainActor
protocol Book: Observable, AnyObject {
    var title: String
}

extension EnvironmentValues {
    @Entry var book: any Book = PreviewBook()
}

struct TitleEditView: View {
    @Environment(\.book) private var book

    var body: some View {
        @Bindable var book = book
        TextField("Title", text: $book.title)
    }
}

I am trying to use the protocol so I can use either an object providing constants for tests and previews or an object fetching the actual data from the network.

Any ideas?

1 Like

That's an interesting idea, to use an existential in the environment...

First off, the general problem that you run into is that the types any Book is its own type (an existential, which is basically a "box" that can store a any value that conforms to the protocol Book). This type itself, however, does not conform to Book. And since the Book protocol extends Observable, it follows that any Book does not conform to that either, which explains your first error message.
The second message basically comes from the same issue, any Book is, unlike a type that conforms to Book not a class type, i.e. it does not formally conform to AnyObject.

To construct a @Bindable for your book, you must first "unbox" it, and that is done by writing a generic function over the Book protocol that then gets your book existential passed as parameter. Inside that function the parameter is then the concrete "unboxed" instance of the type that conforms to Book (and the protocols that inherits from).

struct TitleEditView: View {
    @Environment(\.book) private var book

    var body: some View {
        textField(with: book)
    }

    private func textField(with unboxedBook: some Book) -> TextField<Text> {
        @Bindable var bindableBook = unboxedBook
        return TextField("Title", text: $bindableBook.title)
    }

Unfortunately that seems a little ugly, I didn't immediately see a way to write this as a nice @ViewBuilder function and omit the concrete return type. Neither did I see a pretty way to "return" the @Bindable somehow.


Two other things I see:
When playing around I noticed that Book is bound to @MainActor. As I see it, that is a problem for the @Entry in the EnvironmentValues, as this macro stores the default value (a PreviewBook in your case) in a static mutable property. This violates concurrency isolation, no idea why you did not get that error shown when compiling. :worried:

Second:
While above unboxing (when not isolating Book to @MainActor) works, it's a little ugly and makes me wonder whether you really need this. The environment is usually set from a view higher up the hierarchy, why do you need to abstract values via a protocol? If Book contains more than just "environment values", i.e. logic that needs to be different between preview and production code or the like, perhaps you want to reconsider encapsulation? Why can't a simple Book struct (instead of that being a protocol) not just contain something like a isPreview property (defaulting to false?) and logic relying on that resides in a completely different type?

I am usually all for using protocols to properly define your various APIs, but here it looks like it's causing friction between the existing APIs SwiftUI offers, so maybe it's not the best use-case to rely on existentials in environment values like this. Perhaps somebody else can elaborate/advice on this?

2 Likes

Many thanks for the explanation! I still struggle a bit to wrap my head around all this some any goodness, but your code worked and I was able to also get something like the following to compile:

    private func unboxedStringBinding(_ provider: some Book) -> Binding<String> {
        @Bindable var book = book
        return $book.title
    }

If I don't do this, the compiler complains if I use its methods from my Views (which are bound to @MainActor). Maybe SwiftUI ensures access to the static property is always on the @MainActor, too?

In my previous project I had injected concrete types into the environment, and was using subclasses for Previews and unit tests. However, I was getting lots of Preview crashes in my view hierarchy when I forgot one of the .environment() modifiers. So the above seemed like a approach worth trying out :grimacing:

That said, I would be very interested to learn how others would approach this!

1 Like

I am glad I could be of assistance! :smiley:

Yes, I actually played a little bit with the code after I posted and had the same idea, but didn't think it'd warrant another reply. I originally wanted to write a generic function that allows you to specify which binding to return, but that becomes very confusing quickly since you have to specify the KeyPath of the concrete type (that conforms to Book), but that is only known inside the method... add the @dynamicMemberLookup of Binding and Bindable to the mix and... well... :sweat_smile:

How did you then make it compile, even? It crashes for me, even in Swift 5 mode. Is the init method perhaps nonisolated? (Not sure if that would be an issue then)
You can actually expand the @Entry macro and see how it implements the mechanic. It's also explained in the documentation for EnvironmentKey. The macro for some reason uses a mutable static property for the default value although the protocol doesn't require that. But even if you implement it manually, that doesn't help, since the defaultValue property is nonisolated (and unless your init is not also nonisolated you get the error).

I feel you, I also noticed it can become tedious to always add the various environment modifiers to previews (though I don't see how that's a big issue in unit tests, is your logic that tightly coupled to things that are effectively view declarations?)
However, what I meant with my comment was more a question whether you really need existentials, i.e. any. Not just because they incur a performance cost (though that's most likely negligible), but since my hunch is that, in this case, your Book does too much, perhaps?
Also, it is already a reference type, i.e. your conforming types are classes. Is it perhaps worthwhile to consider subclassing in unit tests then again?
I tend to avoid that myself as I prefer to do more protocol oriented programming (than "old school" object oriented, see the famous Krusty talk...), but here you might reconsider to avoid the any.

Also also, perhaps as a frame challenge: If you have several "environment objects", perhaps introduce some kind of wrapper that keeps potential existentials hidden from the default value mechanism? Once more, so far having a property that just denotes something as a "preview" book rather than its own type conforming to the same protocol as non-preview books could also work, perhaps?
Of course this isn't meant to say "do this!", I am just throwing some things out there for you to consider. The unboxing above works and as long as you're sure your initializers don't clash with concurrency it's fine! :smiley:

I have a Default implementation which is an @Observable class and a Preview implementation which is a class where I even omitted @Observable because seemed not relevant for Previews. Otherwise pretty much what we have above...

Haha, thanks for the pointer, watched it yesterday ("Protocol-oriented Programming in Swift" from WWDC15 for anyone else interested), also the companion "Building Better Apps with Value Types in Swift" - oldie but goodie.