TL;DR
SwiftUI’s black magic (as @cukr points out upthread) is underscore-prefixed View
requirements that Xcode hides from clients. To see all the requirements, you have to go to the interface at:
path/to/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS.sdk/System/Library/Frameworks/SwiftUI.framework/Modules/SwiftUI.swiftmodule/arm64.swiftinterface
As for achieving type erasure, you can follow the ‘AnyHashable’, standard-library approach (as shown upthread) by treating the hidden requirements as the only requirements.
I managed to solve this, by first understanding that my protocol needs other requirements than the recursive body
property. That’s because, this approach lacks genuine functionality — e.g. “What purpose does a protocol with a body
property constrained to itself serve?” The more fundamental problem, though, is that, well, type-erasing erases type information. So, if your protocol’s requirements don’t rely on some lower-level types for their functionality, type erasing removes type information, and by extension this requirement’s functionality.
SwiftUI’s solution to his problem is hidden requirements. These are simply underscore-prefixed requirements that Xcode hides from clients of Apple frameworks. They are used as the View
protocol's actual requirements. This way, clients can conveniently declare views that relay functionality to built-in views, and built-in views can actually implement these requirements.
Another option is to define an internal protocol (i.e. _PrimitiveView
) where you have your actual requirements. Then you can dynamic cast the root view to that protocol. If the cast fails, you can recursively cast the body
properties of your views until you find a primitive view. This method is probably less performant compared to the first one; however, I haven’t tested it.
SwiftUI's Hidden Requirements
If you’re interested in learning more about View
’s hidden requirements you can go to the SwiftUI interface file (the path is specified above). If you search for protocol View
you’ll find this definition:
@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *)
@_typeEraser(AnyView) public protocol View {
// [My comment] *Hidden* requirements:
static func _makeView(view: SwiftUI._GraphValue<Self>, inputs: SwiftUI._ViewInputs) -> SwiftUI._ViewOutputs
static func _makeViewList(view: SwiftUI._GraphValue<Self>, inputs: SwiftUI._ViewListInputs) -> SwiftUI._ViewListOutputs
@available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *)
static func _viewListCount(inputs: SwiftUI._ViewListCountInputs) -> Swift.Int?
// [My comment] "Normal" requirements:
associatedtype Body : SwiftUI.View
@SwiftUI.ViewBuilder @_Concurrency.MainActor(unsafe) var body: Self.Body { get }
}
The reason clients don’t have to interact with these underscore-prefixed methods is that SwiftUI provides default implementations for them. These default implementations call the body
’s respective hidden requirements. For example, _makeView(view:inputs:)
is probably implemented as: Body._makeView(view: view.body, inputs: inputs)
. Primitive views, of course, provide their own implementations, as described above.
Failable Initializer
One improvement to the aforementioned type-erasing strategy is offering a failable initializer accepting a value of type Any
. It’s a simple modification. To achieve it, you have to conditionally conform ConcreteTypeErased
to TypeErasedBox
where the Base
conforms to MyProtocol
. This enables dynamic casting when you package a generic value into the box, as is done here:
extension TypeErased {
private init?<T>(_genericValue value: T) {
let box = ConcreteTypeErased(baseProto: value)
guard let properBox = box as? TypeErasedBox else {
return nil
}
self.box = properBox
}
}
Finally, to accept values of type Any
— which is you can imagine as a box containing this generic value — you have to open the value:
extension TypeErased {
init?(_ value: Any) {
func openValue<T>(value: T) -> T { value }
let opened = _openExistential(value, openValue)
self.init(_genericValue: opened)
}
}
SE-0309 Implications
I know all of this is quite complicated, but once SE-0309 is implemented, this process will be significantly simplified, since you won’t have to create TypeErasedBox
- and ConcreteTypeErased
-style protocols; instead, you’ll be able to just use View
as your box with some extensions.