Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to write a "style" view modifier that affects all the nested views of a certain type?

Tags:

swift

swiftui

I'm basically trying to create something similar to the built-in buttonStyle, labelStyle etc modifiers. They affect all the nested buttons/labels, no matter how deeply nested they are. e.g.

VStack {
    VStack {
        VStack {
            Button("OK") {}
            ...
        }
        ...
    }
    ...
}
.buttonStyle(.bordered) // this changes the style of the button, even if it is deeply nested

My goal is to do the same with my custom view. Let's call it TwinTextsView:

VStack {
    VStack {
        VStack {
            TwinTexts(text1: "Foo", text2: "Bar")
            ...
        }
        ...
    }
    ...
}
.twinTextsStyle(CustomStyle()) // this changes the style of TwinTexts, even if it is deeply nested

where CustomStyle can be implemented like a ButtonStyle, with a makeBody method taking in a configuration with two views, representing the texts

struct TwinTextsStyleConfiguration {
    // not sure what types these should be. AnyView?
    let text1: Text1
    let text2: Text2
}

protocol TwinTextsStyle {
    associatedtype Body: View
    
    @ViewBuilder
    func makeBody(configuration: TwinTextsStyleConfiguration) -> Body
}

struct CustomStyle: TwinTextsStyle {
    func makeBody(configuration: TwinTextsStyleConfiguration) -> some View {
        // let's suppose I want to put one text on top of the other.
        VStack {
            configuration.text1
            configuration.text2
        }
    }
}

This style will then be used by TwinTexts like this:

struct TwinTexts: View {
    let text1: String
    let text2: String
    
    var body: some View {
        // note that these are not of type "Text". I don't know what type they are as I will be adding view modifiers to them
        // here I am using lineLimit as an example 
        let text1 = Text(text1).lineLimit(1)
        let text2 = Text(text2).lineLimit(1)
        // not sure how I would get the "style" here
        style.makeBody(.init(text1: text1, text2: text2))
    }
}

One idea I thought of is using a custom Environment, since .environment applies to all the nested views. There are two problems:

  • style.makeBody would return any View, which can't be used in body
struct TwinTextsStyleKey: EnvironmentKey {
    static let defaultValue: any TwinTextsStyle = DefaultStyle()
}

extension EnvironmentValues {
    var twinTextsStyle: any TwinTextsStyle {
        get { self[TwinTextsStyleKey.self] }
        set { self[TwinTextsStyleKey.self] = newValue }
    }
}

extension View {
    func twinTextsStyle(_ style: some TwinTextsStyle) -> some View {
        self.environment(\.twinTextsStyle, style)
    }
}

struct TwinTexts: View {
    let text1: String
    let text2: String
    @Environment(\.twinTextsStyle) var style
    var body: some View {
        let text1 = Text(text1).lineLimit(1)
        let text2 = Text(text2).lineLimit(1)
        style.makeBody(.init(text1: text1, text2: text2)) // this returns "any View"
    }
}
  • I don't know what types I should use for the views in TwinTextsStyleConfiguration.

    I looked at ButtonStyleConfiguration.Label, and according to the docs, this is a "type-erased" view. Is this an actual use case for AnyView?

like image 925
Sweeper Avatar asked Aug 31 '25 01:08

Sweeper


1 Answers

I've implemented several styles and views that can be styled using a similar API to the in-built views, they all follow similar patterns. As Benzy Neez has found, you can quickly get into a tangle with generics.

You are absolutely correct in that this is a perfect use case for AnyView. It's impossible at compile time to know:

  • What style is currently in force for a given view
  • What content view(s) have been passed in to a given instance of the view (if you are allowing arbitrary content using view builders).

The type-erased views you see in the in-built configuration types are, as far as I can tell, functionally identical to AnyView.

Your steps to creating a style, and a view that can be styled, are:

1. Define a protocol representing the style:

protocol XXStyle {
    
    associatedtype Body: View
    
    @ViewBuilder 
    func makeBody(configuration: Self.Configuration) ->      
      Self.Body
    
    typealias Configuration = XXStyleConfiguration

}

This is pretty much identical everywhere, just altering XX for your actual name.

2. Define a configuration type

struct XXStyleConfiguration {
    let viewContent: AnyView
    let modelContent: ??
}

This type should contain everything the style implementation needs to render. If you're passing in things with view builders, you'd store them as AnyView. If you have things like strings, store them as strings here.

3. Environment and view modifier chores

Styles live in the environment, because that's how you get a modifier applied once to a container to be detectable by all views inside that container. Make a key for your style, with a default value of some automatic or built-in implementation of your style protocol:

struct XXStyleKey: EnvironmentKey {
    static let defaultValue: any XXStyle = DefaultXXStyle()
}

extension EnvironmentValues {
    var xxStyle: any XXStyle {
        get { self[XXStyleKey.self] }
        set { self[XXStyleKey.self] = newValue }
    }
}

Add view modifiers to apply this automatically so you can use it like .buttonStyle().

extension View {
    func xxStyle(_ style: any xxStyle) -> some View {
        self.environment(\.xxStyle, style)
    }
}

extension XXStyle where Self == ThisXXStyle {
    static var this: Self { ThisXXStyle() }
}

4. View implementation

Define your view as follows:

struct XX<ViewContent: View>: View {

    @Environment(\.xxStyle) private var style
    let configuration: XXStyleConfiguration
    init(_ modelStuff: ??, @ViewBuilder content: () -> ViewContent) {
        configuration = .init(
            viewContent: .init(content()), 
            modelContent: modelStuff)
    }

    var body: some View {
        AnyView(style.makeBody(configuration: configuration))
    }
}

Pass in the data and / or views that form your implementation, and store them in the configuration which you create on init. Then get the style from the environment, which at compile time is any style, get the body from it, then wrap it in AnyView so the type system doesn't explode.

It's quite a process, but it does work, and I don't feel bad about the use of AnyView here because it's exactly what the in-built views like Button are doing, even if you don't apply a style.

like image 69
jrturton Avatar answered Sep 02 '25 20:09

jrturton