SwiftUI Modifiers
Being a declarative system, SwiftUI has a different way of handling views compared to UIKit or AppKit. Instead of dealing directly with the things you see on screen (e.g. UIView
, NSView
, CALayer
, etc.), you deal with types that conform to the View
protocol. You should think of these types not as actual views, but rather view descriptions. This is one of the reasons SwiftUI is able to efficiently rebuild your view hierarchy – it actually doesn’t. It’s just recomputing the description of your view hierarchy, calculating the difference, and then updating the actual views behind the scenes.
Regular Modifiers
When you want to make a change to a view in SwiftUI, you use something called a “view modifier”. This is another type that conforms to the ViewModifier
protocol. This protocol has a single requirement:
protocol ViewModifier {
func body(content: Self.ViewModifier.Content) -> Self.Body
}
This method is responsible for actually applying the modifier to the content. Let’s look at a simple example. We want a view modifier that applies a rainbow effect to the background of any view. We start by creating our ViewModifier
type.
struct RainbowBackground: ViewModifier {
func body(content: Content) -> some View {
content
.padding()
.background(Color.purple)
.padding()
.background(Color.blue)
.padding()
.background(Color.green)
.padding()
.background(Color.yellow)
.padding()
.background(Color.orange)
.padding()
.background(Color.red)
}
}
Any time you apply a modifier to a view, you actually return a new type. If you’ve ever seen ModifiedContent<Content, Modifier>
, then you’ve seen this in action. Since views in SwiftUI are really just descriptions, applying a modifier to one can create a brand new description and yet still be fast.
Now, we could stop here. This is a fully functional view modifier, which can be used as myView.modifier(RainbowBackground())
. However, SwiftUI convention adds one more step to make this nicer to use. We extend the View
protocol with a function that applies our modifier. This lets us get rid of the boilerplate in our calls.
extension View {
func rainbow() -> some View {
self.modifier(RainbowBackground())
}
}
With this, we can now call myView.rainbow()
instead. Much nicer, right?
Environment Modifiers
There are some modifiers in SwiftUI that actually affect the entire view hierarchy of the element they’re applied to. One example of this is the .accentColor(_:)
modifier. If you apply this to a view, it will affect that view and all of its children.
struct ContentView: View {
var body: some View {
VStack(spacing: 10) {
// Binding.constant(_:) is super convenient for creating previews
Slider(value: .constant(0.5))
Button("Tap Me") {}
}
.padding()
.accentColor(.red)
}
}
Both the button and the slider have their accent color set to red in this example. It’s up to the view itself to determine how this actually changes its rendering though. In the button, the text “Tap Me” is in red, but in the slider, it’s the filled in portion of the track that is red.
Compare this to the .background(_:)
modifier. If we apply a background to the VStack
, you’ll see that it affects the VStack
as a whole, and not the inner views. (We give the slider and button a black border, so that it’s easy to see their bounds.)
struct ContentView: View {
var body: some View {
VStack(spacing: 10) {
Slider(value: .constant(0.5))
.border(Color.black)
Button("Tap Me") {}
.border(Color.black)
}
.padding()
.accentColor(.red)
.background(Color.yellow)
}
}
To give another hint as to how this cascading modifier effect works, let’s apply the same .accentColor(_:)
modifier to one of the inner views.
struct ContentView: View {
var body: some View {
VStack(spacing: 10) {
Slider(value: .constant(0.5))
.border(Color.black)
.accentColor(.blue)
Button("Tap Me") {}
.border(Color.black)
}
.padding()
.accentColor(.red)
.background(Color.yellow)
}
}
What we end up with is a button that is still red, but a slider that is now blue.
So these modifiers can override values set higher up the view hierarchy. Does this sound familiar? What other system in SwiftUI works this way? If you guessed the Environment, then you’d be right!
And that’s actually how these modifiers work; they use the Environment to pass down values through the view hierarchy, and those values can be replaced with different ones at any level, which also get passed down through that sub-hierarchy.
We’re going to build our own environment modifier, but, before we do, let’s ask the question, “Why would I want an environment modifier over a normal one?”. Sometimes we want a modification to apply to multiple views individually1 (like accent color), but it would be really tedious to have to manually do that for each view. Imagine having to write the same modifier on all of your subviews…
struct ContentView: View {
var body: some View {
VStack {
Text("Label 1")
.accentColor(.blue)
Text("Label 2")
.accentColor(.blue)
Text("Label 3")
.accentColor(.blue)
Text("Label 4")
.accentColor(.blue)
Text("Label 5")
.accentColor(.blue)
Text("Label 6")
.accentColor(.blue)
Text("Label 7")
.accentColor(.blue)
Text("Label 8")
.accentColor(.blue)
Text("Label 9")
.accentColor(.blue)
Text("Label 10")
.accentColor(.blue)
}
}
}
Ok, now that we know why we’d want to create an environment modifier, let’s look at how we create one. The first step is to create a new EnvironmentKey
. This is a type that can be used to key into the Environment (kind of like a dictionary) and contains the default value of our environment item. Then we extend EnvironmentValues
to support our custom key. This is what allows us to actually read and write the value from/to the Environment.
struct SecondaryAccentColorKey: EnvironmentKey {
static var defaultValue: Color? = nil
}
extension EnvironmentValues {
var secondaryAccentColor: Color? {
get { self[SecondaryAccentColorKey.self] }
set { self[SecondaryAccentColorKey.self] = newValue }
}
}
Once these are in place, we create a convenience function on View
, just like we did for our RainbowBackground
modifier earlier.
extension View {
func secondaryAccentColor(_ color: Color?) -> some View {
self.environment(\.secondaryAccentColor, color)
}
}
We now have an environment modifier all set up, but if you try to use it, nothing will happen. This is because individual views are now responsible for handling this new environment value. By default, no view will do anything with it, since they don’t know about it. So we need to add code to handle our environment value in our views. We’ll create a simple outlined rectangle view to illustrate this. The view will use the accent color as the rects fill, and the secondary accent color as the border.
struct OutlinedRect: View {
@Environment(\.secondaryAccentColor) var secondaryAccentColor
var body: some View {
Rectangle()
// accentColor is marked internal. Apple created a special case for it on the Color type
.fill(Color.accentColor)
.border(secondaryAccentColor ?? .clear, width: 10)
}
}
We can now add this custom view to our preview, and apply a secondary accent color.
struct ContentView: View {
var body: some View {
VStack(spacing: 10) {
Slider(value: .constant(0.5))
.border(Color.black)
.accentColor(.blue)
Button("Tap Me") {}
.border(Color.black)
OutlinedRect()
.frame(width: 300, height: 100)
}
.padding()
.accentColor(.red)
.background(Color.yellow)
.secondaryAccentColor(.blue)
}
}
As you can see, the secondary accent color that we added only affects our OutlinedRect
. All of our other views are unchanged. Environment modifiers can be incredibly useful in certain situations, but they do come with the tradeoff of needing to manually add support to all of your views.
Conclusion
That concludes our dive into SwiftUI modifiers. You’ve seen how easy it is to implement both regular and environment modifiers, and why you might choose one over the other. As always, if you have any comments, or know of different/better ways to do anything, let me know on Twitter. I hope this was helpful, see you next time!
-
Another way to accomplish this is to use a
Group
. This view acts as a container, but applies any modifiers on itself to each of its subviews. So if you have a group that contains 10Text
views, and you apply.background(Color.red)
to the group, you’ll end up with 10Text
views that have red backgrounds, as opposed to a container with a red background that contains those 10Text
views. While this is an alternative to using an environment modifier, it does require you to change the structure of your view hierarchy to accommodate the group. ↩︎