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
?
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:
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.
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With