SwiftUI AdaptiveView Protocol: Default vs Custom Implementation Issue

I’m trying to create a SwiftUI protocol called AdaptableView, which allows conforming views to define two different layouts (compactBody and expandedBody) based on available width. Additionally, I want to allow adding shared modifications to these views via a body(content:) method.

My protocol definition:

@MainActor
protocol AdaptableView: View {
    associatedtype Compact: View
    associatedtype Expanded: View
    
    var windowClass: WindowClass { get }
    @ViewBuilder var compactBody: Self.Compact { get }
    @ViewBuilder var expandedBody: Self.Expanded { get }
}

extension AdaptableView {
    var body: some View {
        adaptableBody
    }
    
    private var adaptableBody: ConditionalContent<Self.Compact, Self.Expanded> {
        switch windowClass {
        case .compact:
            .init(condition: .isTrue(compactBody))
        case .expanded:
            .init(condition: .isFalse(expandedBody))
        }
    }
}

This works fine for selecting between compactBody and expandedBody, but I also want to allow shared customization for both states.

Adding a body(content:) Method
I introduced an additional requirement:

associatedtype AdaptableBody: View
@ViewBuilder func body(content: ConditionalContent<Self.Compact, Self.Expanded>) -> Self.AdaptableBody

To make it optional, I added a default implementation:

extension AdaptableView {
    var body: some View {
        body(content: adaptableBody)
    }

    func body(content: ConditionalContent<Compact, Expanded>) -> some View {
        content
    }
}

This compiles, but if I try to override body(content:) in a conforming view, it never gets called.

struct ContentView: AdaptableView {
    @Environment(\.windowClass) var windowClass
    
    var compactBody: some View {
        Text("compact")
    }
    
    var expandedBody: some View {
        Text("expanded")
    }
    
    func body(content: ConditionalContent<Compact, Expanded>) -> some View { 
        content
    }
}

This compiles without errors as long as the default implementation of body(content:) exists. However, the custom implementation in ContentView is never called.

If I delete the default implementation, I start getting errors:

  1. “Unsupported recursion for reference to type alias ‘Compact’ of type ‘ContentView’”
  2. If I change Compact and Expanded to Self.Compact and Self.Expanded, I get: “Type ‘ContentView’ does not conform to protocol ‘AdaptableView’”

Why does my custom body(content:) implementation never get called?|
Why does removing the default implementation cause conformance issues?|

2 Likes

OK so... the errors here are really not helpful. In essence though, as far as I can tell, Swift is complaining about ambiguity. When trying to enforce conformance to your existing protocol, it's not sure how to resolve certain associated types to their concrete types.

If I'm understanding what you're trying to do, breaking up your protocols and extensions into multiple, individual ones seems to work:

import SwiftUI

enum WindowClass { case compact, expanded }

@MainActor
protocol AdaptableViewDefining {
    
    associatedtype CompactBody: View
    associatedtype ExpandedBody: View
    associatedtype AdaptedBody: View
    
    var windowClass: WindowClass { get }
    
    @ViewBuilder var compactBody: CompactBody { get }
    @ViewBuilder var expandedBody: ExpandedBody { get }
    @ViewBuilder var adaptedBody: AdaptedBody { get }

}

extension AdaptableViewDefining {
    
    @ViewBuilder var adaptedBody: some View {
        switch windowClass {
            case .compact: compactBody
            case .expanded: expandedBody
        }
    }
    
}

protocol AdaptableViewModifiying: AdaptableViewDefining {
    
    associatedtype ModifiedBody: View
    
    @ViewBuilder func body(content: AdaptedBody) -> ModifiedBody
    
}

protocol AdaptableView: View, AdaptableViewDefining, AdaptableViewModifiying { }

extension AdaptableView {
    
    var body: some View {
        body(content: adaptedBody)
    }
    
    @ViewBuilder func body(content: AdaptedBody) -> AdaptedBody {
        content
    }
    
}

And as a test, you can stick breakpoints in the default body(content:) above and the one below:

struct AdaptableViewThatAddsPadding: AdaptableView {
    
    var windowClass: WindowClass = .expanded
    
    @ViewBuilder var compactBody: some View { Text("Compact!") }
    
    @ViewBuilder var expandedBody: some View { Text("Expanded!") }
    
    @ViewBuilder func body(content: AdaptedBody) -> some View {
        content.padding()
    }
    
}

struct AdaptableViewThatDoesntModify: AdaptableView {
    
    var windowClass: WindowClass = .expanded
    
    @ViewBuilder var compactBody: some View { Text("Compact!") }
    
    @ViewBuilder var expandedBody: some View { Text("Expanded!") }
    
}

print(AdaptableViewThatAddsPadding().body) // AnyView(ModifiedContent<AnyView, _PaddingLayout>...
print(AdaptableViewThatDoesntModify().body) // AnyView(_ConditionalContent<AnyView, AnyView>...
1 Like

That can be simplified to the following. I have no idea if it's possible to do it without the useless break into two protocols. The reason for it is only to pick up that the return value of adaptedBody is AdaptedBody.

Note: Swift 6 doesn't require any further annotation with @MainActor.

struct ContentView: AdaptableView {
  func body(content: AdaptedBody) -> some View {
    HStack {
      content
      Text("And then some!")
    }
  }
}

struct DefaultContentView: AdaptableView { }

#Preview {
  ContentView()
  DefaultContentView()
}

extension AdaptableView {
  var windowClass: WindowClass { .compact }
  var compactBody: some View { Text("Compact!") }
  var expandedBody: some View { Text("Expanded!") }
}
import SwiftUI

enum WindowClass { case compact, expanded }

protocol UselessAdaptedBodyProtocol: View {
  associatedtype AdaptedBody: View
  var adaptedBody: AdaptedBody { get }
}

protocol AdaptableView: UselessAdaptedBodyProtocol {
  associatedtype CompactBody: View
  associatedtype ExpandedBody: View
  associatedtype ModifiedBody: View
  @ViewBuilder var compactBody: CompactBody { get }
  @ViewBuilder var expandedBody: ExpandedBody { get }
  @ViewBuilder func body(content: AdaptedBody) -> ModifiedBody
  var windowClass: WindowClass { get }
}

extension AdaptableView {
  @ViewBuilder var adaptedBody: some View {
    switch windowClass {
    case .compact: compactBody
    case .expanded: expandedBody
    }
  }

  var body: some View { body(content: adaptedBody) }
  func body(content: AdaptedBody) -> some View { content }
}

I've asked here, if this can be done with only one protocol instead of two: