@ViewConfigurable - A better way to build SwiftUI components

TL;DR: SwiftUI makes customizing views feel effortless — until you build your own reusable components. Here’s how we built a macro to fix that. Try it out with SPM: https://github.com/grindrllc/view-configurable
We’ve all been there before.
You create a nice simple View for your team to use. Perhaps something like this:
public struct GrindrButton: some View { private let title: String private let onAction: () -> Void public init(title: String, onAction: @escaping () -> Void) { self.title = title self.onAction = onAction } public var body: some View { ... }}
Everything is going great and people are adopting this awesome new component 🎉. Then, along come some requests:
- “Can I change the text color of the button?”
- “Can I change the button background color?”
- “Product wants this to have a corner radius of 5, not 8”
- “Hey font needs to be bold. Thx. Have it ready by tomorrow”
- … and on and on
Now that nice pretty component is starting to look like this
public struct GrindrButton: some View { private let title: String private let backgroundColor: Color private let textColor: Color private let font: Font private let cornerRadius: CGFloat private let depressAnimation: Animation = .default private let onAction: () -> Void public init(title: String, backgroundColor: Color = .yellow, textColor: Color = .black, font: Font = .body, cornerRadius: CGFloat = 5, depressAnimation: Animation = .default // ... more configurations onAction: @escaping () -> Void) { self.title = title self.backgroundColor = backgroundColor self.textColor = textColor self.font = font /// ...etc } public var body: some View { ... }}
Everyone who uses `GrindrButton` now needs to:
- Make sure all initializer params are in order
- Worry about which of these params they actually need
- When they click into this file, it’s a mile long!
Native SwiftUI components are super flexible. Why don’t they have this issue?
At Grindr, we are building out our components in SwiftUI and this was a constant pain-point. To solve it, we asked ourselves “Why are Apple’s components so much cleaner and easier to use?” For example, Apple does not make you hand in the font, color, size, etc into the initializer. That would look like this:
var body: some View { Text("Hello World", foregroundColor: .black, font: .body, style: .italic, ...)}
Instead, they took a different approach. At a high level, SwiftUI components have a separation between what is required (data) and what is optional (customizations). Let’s look at a few examples:
Text:
- Required: String
- Customizations: font, color, font-weight, …etc
// A string (ie - "Hello") is requiredText("Hello") // Everything below is an optional customization .font(.body) .fontWeight(.bold) .foregroundStyle(.green)
TextField:
- Required: String, Binding<String>
- Customizations: style, color, …etc
// "Hello" and $text are requiredTextField("Hello", text: $text) // Everything below is an optional customization .textFieldStyle(.roundedBorder) .foregroundStyle(Color.blue) // ...etc
Why can’t we have this?
Then we asked ourselves, “why can’t we have this?” Turns out, we can! We just have to write a ton of boilerplate code whenever we want to add another customization. Here is what our initial solution looked like:
public struct GrindrButton: View { private let title: String private let onAction: () -> Void // customizations private var textColor: Color = .blue private var font: Font = .body public init(title: String, onAction: @escaping () -> Void) { self.title = title self.onAction = onAction } var body: some View { // ... }}public extension GrindrButton { func textColor(_ color: Color) -> Self { var mutableSelf = self mutableSelf.textColor = color return mutableSelf } func titleFont(_ font: Font) -> Self { var mutableSelf = self mutableSelf.font = font return mutableSelf }}
All of a sudden, we had a SwiftUI-like syntax for our custom components! Consumers of our button could write code that looked like this
var body: some View { GrindrButton(title: "Click Me", onAction: {}) .textColor(.blue) .titleFont(.callout)}
What’s the catch?
Well whenever we wanted to add a customization, we need to add a private variable and create a new extension function with the weird mutableSelf syntax. We wondered — can this be automated?
Macros to the rescue 🦸
We wrote a macro called @ViewConfigurable to automate this. Here is how it works:
@ViewConfigurable // Step 1 - apply macro to viewpublic struct GrindrButton: some View { // required private let title: String private let onAction: () -> Void // configurable private var config = ViewConfiguration() // Step 2 - add this var public struct ViewConfiguration { // Step 3 - declare your ViewConfiguration struct var titleColor: Color = .black var buttonBackgroundColor: Color = .yellow var titleFont: Font = .body // ..etc } public init(title: String, onAction: @escaping () -> Void) { ... } public var body: some View { // Step 4 - Use the "config" var for customizations Button(action: onAction) { Text(title) .font(config.titleFont) .foregroundStyle(config.titleColor) } .background(config.buttonBackgroundColor) }}
The macro will automatically generate those extension functions based variable names it sees in ViewConfiguration. It follows these simple steps:
- Creates an extension of your view type with the proper scope (public, internal, private)
- Inspects the ViewConfiguration struct
- For each variable in the structure (i.e. var titleColor: Color)
- Creates a function in the extension, using the variable name. For example, var titleColor: Color would create func titleColor(_ value: Color) -> Self
So, for the component above, a consumer could use it like this:
GrindrButton("Click Me", onAction: {}) .titleColor(.blue) .buttonBackgroundColor(.red) .titleFont(.callout)// or they could just stick with the defaults 👇GrindrButton("Click Me", onAction: {})
How has it worked so far?
It’s a new way of thinking about component building, but it’s been very helpful for us as a team! Having concise views makes it easier to read and reason about. Also, having small initializers makes it far easier to add components to your view — and then you can customize it just like you would any other SwiftUI view.
How can I use this?
We created a public git repo for this project: https://github.com/grindrllc/view-configurable . Paste this link into SPM to start using the @ViewConfigurable macro!