Configuring SwiftUI views
Discover page available: SwiftUIOne of the key ways that SwiftUI is different compared to Apple’s previous UI frameworks is how its views are created and configured. In fact, it could be argued that when using SwiftUI, we never actually create any views at all — instead we simply describe what we want our UI to look like, and then the system takes care of the actual rendering.
This week, let’s take a look at a few different techniques for structuring those view descriptions, and the sort of pros and cons that each of those approaches gives us in terms of code structure and flexibility.
Initializers, modifiers and inheritance
Overall, there are three different ways to configure a SwiftUI view — by passing arguments to its initializer, using modifiers, and through its surrounding environment. For example, here we’re configuring a Text
view that acts as the body
of a TitleView
— using both its initializer, and by applying modifiers to it in order to change its font and text color:
struct TitleView: View {
var title: String
var body: some View {
Text(title)
.font(.headline)
.italic()
.foregroundColor(.blue)
}
}
The above way of chaining modifiers together, rather than mutating a single value, is a big part of what separates SwiftUI’s declarative programming style from how UIs are constructed when using imperative frameworks, such as UIKit or AppKit.
The above is an example of direct configuration, as we’re explicitly setting up and modifying our Text
view by directly calling methods on it. However, SwiftUI also supports indirect configuration, as many different modifiers and properties are automatically propagated down through each given view hierarchy.
That sort of indirect, inherited configuration can be incredibly useful in situations when we want multiple sibling views to adopt the same sort of configuration or styling — like in the following case, in which we configure both a Text
and a List
to display all of their text using a monospaced font, simply by assigning that font to their parent VStack
:
struct ListView: View {
var title: String
var items: [Item]
@Binding var selectedItem: Item?
var body: some View {
VStack {
Text(title).bold()
List(items, selection: $selectedItem) { item in
Text(item.title)
}
}.font(.system(.body, design: .monospaced))
}
}
The fact that entire SwiftUI view hierarchies can be configured through their parent is incredibly powerful, as it lets us apply shared styles and configurations without having to modify each view separately. Not only does that often lead to less code, but it also establishes a single source of truth for our shared configurations — like fonts, colors, and so on — without requiring us to introduce any sort of abstraction to make that happen.
Let’s take a look at another example, in which we change an entire navigation stack’s accentColor
simply by assigning it to our root NavigationView
— which will cause that color to be applied to all child views, including those managed by the system, such as any navigation bar items that we’ve defined:
struct ContactListView: View {
@ObservedObject var contacts: ContactList
var body: some View {
NavigationView {
List(contacts) { contact in
...
}
.navigationBarItems(
trailing: Button(
action: { ... },
label: {
// This image will be colored purple
Image(systemName: "person.badge.plus")
}
)
)
}.accentColor(.purple)
}
}
However, sometimes we might want to apply a set of styles to a group of views without having to change their relationship to their parent view. For example, let’s say that we’re building a view for displaying an address within an app, which consists of a series of stacked Text
views:
struct AddressView: View {
var address: Address
var body: some View {
VStack(alignment: .leading) {
Text(address.recipient)
.font(.headline)
.padding(3)
.background(Color.secondary)
Text(address.street)
.font(.headline)
.padding(3)
.background(Color.secondary)
HStack {
Text(address.postCode)
Text(address.city)
}
Text(address.country)
}
}
}
Above we’re assigning the exact same styling to our first two labels, so let’s see if we can unify that code to avoid having to repeat it. In this case, we can’t apply our modifiers to our labels’ parent view, since we only want to apply the given styles to a subset of its children.
Thankfully, SwiftUI also ships with a Group
type, which lets us treat a set of views as a group — without affecting their layout, drawing, or position within our overall view hierarchy. Using that type, we can group our two labels together, and then apply our set of modifiers to both of them at the same time:
struct AddressView: View {
var address: Address
var body: some View {
VStack(alignment: .leading) {
Group {
Text(address.recipient)
Text(address.street)
}
.font(.headline)
.padding(3)
.background(Color.secondary)
...
}
}
}
The power of Group
is that it applies its modifiers directly to its children, rather than to itself. Compare that to if we would’ve grouped our labels using another VStack
instead, which would’ve caused the padding and background color to be applied to that stack, rather than to our labels individually.
Views versus extensions
As our SwiftUI-based views grow in complexity, we likely need to start using multiple ways of grouping and sharing our various configurations and styles, in order to keep our code easy to work with. So far, we’ve mostly been dealing with styling through modifiers, but a major part of our UI configuration also comes down to how we structure our views themselves.
Let’s say that we’re working on a form that lets a user sign up for an account within an app. To make our form look a bit nicer, we’re prefixing each of our text fields with icons from Apple’s SF Symbols library — giving us an implementation that looks like this:
struct SignUpForm: View {
...
@State private var username = ""
@State private var email = ""
var body: some View {
Form {
Text("Sign up").font(.headline)
HStack {
Image(systemName: "person.circle.fill")
TextField("Username", text: $username)
}
HStack {
Image(systemName: "envelope.circle.fill")
TextField("Email", text: $email)
}
Button(
action: { ... },
label: { Text("Continue") }
)
}
}
}
Above we’re using the same HStack
+ Image
+ TextField
combination twice, and while that isn’t necessarily a problem given that we’re configuring each of our two text fields quite differently — let’s say that we also wanted to turn that combination into a stand-alone component that we could reuse in other places throughout our app.
An initial idea on how to do that might be to create a new View
type which takes an iconName
and title
to display, as well as a @Binding
reference to the text
property that we wish to update whenever our component’s text field was edited — like this:
struct IconPrefixedTextField: View {
var iconName: String
var title: String
@Binding var text: String
var body: some View {
HStack {
Image(systemName: iconName)
TextField(title, text: $text)
}
}
}
With the above in place, we can now go back to SignUpForm
and replace our previously duplicated HStack
configurations with instances of our new IconPrefixedTextField
component:
struct SignUpForm: View {
...
var body: some View {
Form {
...
IconPrefixedTextField(
iconName: "person.circle.fill",
title: "Username",
text: $username
)
IconPrefixedTextField(
iconName: "envelope.circle.fill",
title: "Email",
text: $email
)
...
}
}
}
However, while the above change will enable us to reuse our new IconPrefixedTextField
type outside of SignUpForm
, it’s questionable whether it actually ended up improving our original code. After all, we didn’t really make our sign up form’s implementation simpler — in fact, our above call site arguably looks more complex than what it did before.
Instead, let’s take some inspiration from SwiftUI’s own API design, and see what things would look like if we implemented our text view configuration code as a View
extension instead. That way, any view could be prefixed with an icon, simply by calling the following method:
extension View {
func prefixedWithIcon(named name: String) -> some View {
HStack {
Image(systemName: name)
self
}
}
}
With the above in place, we can now add any SF Symbols icon directly to SwiftUI’s native TextField
views — or to any other view — like this:
struct SignUpForm: View {
...
var body: some View {
Form {
...
TextField("Username", text: $username)
.prefixedWithIcon(named: "person.circle.fill")
TextField("Email", text: $email)
.prefixedWithIcon(named: "envelope.circle.fill")
...
}
}
}
Picking between building a new View
implementation and an extension can sometimes be quite difficult, and there’s really no clear-cut right-or-wrong way of doing things here. However, when we find ourselves creating new View
types that just pass properties along to other views, it’s probably worth asking ourselves whether that code would work better as an extension instead.
Modifier types
Apart from writing View
extensions, SwiftUI also enables us to define custom view modifiers as types conforming to the ViewModifier
protocol. Doing so enables us to write modifiers that have their own properties, state and lifecycle — which can be used to extend SwiftUI with all sorts of new functionality.
For example, let’s say that we wanted to add inline validation to our sign up form from before, by turning each text field’s border green once the user entered a valid string. While that’s something that we could’ve implemented within our SignUpForm
view directly, let’s instead build that feature as a completely reusable ViewModifier
:
struct Validation<Value>: ViewModifier {
var value: Value
var validator: (Value) -> Bool
func body(content: Content) -> some View {
// Here we use Group to perform type erasure, to give our
// method a single return type, as applying the 'border'
// modifier causes a different type to be returned:
Group {
if validator(value) {
content.border(Color.green)
} else {
content
}
}
}
}
Looking at the above implementation, we can see that a ViewModifier
looks very much like a view, in that it has a body
that returns some View
. The difference is that a modifier operates on an existing view (passed in as Content
), rather than being completely stand-alone. The benefit is that we can now add our new validation functionality to any text field (or any view, really), just like when using a View
extension, without requiring us build any form of wrapper type:
TextField("Username", text: $username)
.modifier(Validation(value: username) { name in
name.count > 4
})
.prefixedWithIcon(named: "person.circle.fill")
Just like when picking between an extension and a brand new View
implementation, choosing when to implement a given view configuration as a ViewModifier
is most likely going to be a matter of preference and style in many situations.
However, both the ViewModifier
and View
types have the advantage that they can contain their own set of state and properties, while extensions are much more lightweight. We’ll take a much closer look at SwiftUI-based state and data management in upcoming articles.
Conclusion
Just like its predecessors, SwiftUI offers a number of ways for us to structure our UI code and the way we configure our various views. While many of our custom components are likely going to be implemented as stand-alone View
types, building our own extensions and modifiers can enable us to share styles and configurations across a code base in a much more lightweight manner — and can let us apply those configurations to more than just one type of view.
How have you been structuring your SwiftUI code so far? Have you used any of the techniques covered in this article already, or will you try them out? Let me know — along with your questions, comments and feedback — either via email or Twitter.
Thanks for reading! 🚀