SwiftUI: restrict extension to custom view struct / return custom view type

1k Views Asked by At

What I want to achieve:

CustomView()
    .doSomething() // ← should only be available on CustomView
    .doSomethingElse() // ← should only be available on CustomView

AnyOtherView()
    .doSomething() // ← should not compile

Pretty much like SwiftUI's Text implementation has that exact functionality:

enter image description here

What I tried

struct CustomView: View {
    ...
}

extension CustomView {
    func doSomething() -> some CustomView {
        self.environment(\.someKey, someValue)
    }

    func doSomethingElse() -> some CustomView {
        self.environment(\.someOtherKey, someOtherValue)
    }
}

I get the following error: "An 'opaque' type must specify only 'Any', 'AnyObject', protocols, and/or a base class".

What I also tried:

extension CustomView {
    func doSomething() -> CustomView {
        self.environment(\.someKey, someValue)
    }

    func doSomethingElse() -> CustomView {
        self.environment(\.someOtherKey, someOtherValue)
    }
}

I get the following error: Cannot convert return expression of type 'some View' to return type 'CustomView'. Xcode provides the following fix:

extension CustomView {
    func doSomething -> CustomView {
        self.environment(\.someKey, someValue) as! CustomView
    }
}

But force casting does not really look like a great solution.

How can I fix this? I only want to extend CustomView. I don't want to extend View and return some View because that would expose functionality to all views (which is not what I want). If I extend CustomView and simply return some View, then I cannot use both functions at the same time.

Edit: Why do I want to achieve that?

I am building up a Swift Package to provide CustomView to multiple projects. And to make CustomView easy to use I wanted to make it configurable with view modifiers instead of a simple initializer.

I could use my provided CustomView like that:

CustomView(value1: someValue, value2: someOtherValue)

... but I wanted to make it more SwiftUI-Like in the way of optional view modifiers like that:

CustomView()
    .value1(someValue)
    .value2(someOtherValue)

That would look nice if I needed other view modifiers on that view like tint(...) or fixedSize(), etc. Much like you would configure Text, which you customize with view modifiers instead of the initializer, since customizing is optional.

3

There are 3 best solutions below

1
Leu On BEST ANSWER

As others have pointed out, you cannot pipe any modifier in your functions that have a return type other than your CustomView

With that said, you can do something like:

struct CustomView: View {
    @State var myBackground = Color.clear
    
    var body: some View {
        Text("Hello World!")
            .background(myBackground)
    }
}

extension CustomView {
    func customBackground(_ newBackground: Color) -> some CustomView {
        self.myBackground = newBackground
        return self
    }

    func clearBackground() -> some CustomView {
        self.myBackground = .clear
        return self
    }
}

And use it as needed:

struct AnotherView: View {

    var body: some View {
      VStack {
        CustomView()
          .customBackground(.blue)
          .clearBackground()
          .customBackground(.red)
           // ... and so on

        Text("Hi everyone!")
          .customBackground(.blue) // <--- this will fail compiling
      }
    }

}

But you cannot use any other modifier that erases your type. To reach your desired behavior, all the changes done in your functions/modifiers must be done only on properties accessible by your struct directly and have a return value that you guarantees is your view's type

3
Ashley Mills On

Not a solution, I'm afraid, but an explanation.

The problem, as you've discovered, is that the result of

self.environment(\.someKey, someValue)

is not CustomView. The function doesn't modify the existing view, it creates a new one.

Let's say .someKey is an Int, then you can test this out as follows:

extension CustomView {
    func doSomething() -> some View {
        let newView = self.environment(\.someKey, 23)
        print(type(of: newView))
        return newView
    }
}

and you'll see that the actual returned type is

ModifiedContent<CustomView, _EnvironmentKeyWritingModifier<Int>>

This means that if you also declare doSomethingElse() in an extension on CustomView you won't be able to apply it to the result of doSomething().

Nor can you force cast the result of doSomething() to CustomView as they are just different types, so it will crash.

1
Sweeper On

How do you know environment returns CustomView? You don't - what type it returns is an implementation detail of SwiftUI. Your whole premise falls apart when you see that environment returns something like ModifiedContent<CustomView, _EnvironmentKeyWritingModifier<T>> (as shown in Ashley Mills' answer), doesn't it?

Let's inline doSomething:

CustomView()
    .environment(\.someKey, someValue)
    .doSomethingElse()

You are not calling doSomethingElse on CustomView() here - you are calling it on the return value of environment, and environment doesn't even return CustomView, so why should doSomethingElse be available?

One way to rephrase what you want is to keep track of the "root" of the call chain - a way to say "this method should be available on call chains that has CustomView as its root"

You can sort of do this by returning from doSomething a wrapper that also has the root's type, and adding the doSomething and doSomethingElse to also the wrapper types. But IMO this is really convoluted for what it's worth.

protocol ViewChain: View {
    associatedtype ChainRoot
}

struct WrappedViewChain<Body: View, ChainRoot: View>: ViewChain {
    let wrapped: Body
    let rootType: ChainRoot.Type
    
    var body: some View {
        wrapped.body
    }
}

extension CustomView: ViewChain {
    typealias ChainRoot = CustomView
}

extension ViewChain where ChainRoot == CustomView {
    func doSomething() -> WrappedViewChain<some View, CustomView> {
        WrappedViewChain(wrapped: environment(\.someKey, someValue), rootType: CustomView.self)
    }
    
    func doSomethingElse() -> WrappedViewChain<some View, CustomView> {
        WrappedViewChain(wrapped: environment(\.someOtherKey, someOtherValue), rootType: CustomView.self)
    }
}

This essentially made it so that doSomething() and doSomethingElse "remembers" the root of the chain in the second type parameter of WrappedViewChain.

Note that this still won't work if you make it "forget" the root somewhere along the way, but Text behaves like this too.

CustomView()
    .frame(width: 100) // forgets the root
    .doSomething() // now this doesn't work
CustomView()
    .doSomething() // OK
    .frame(width: 100) // forgets the root
    .doSomethingElse() // now this doesn't work