ViewBuilder Tutorial: Creating Reusable SwiftUI Views
Learn how to use ViewBuilder to create reusable SwiftUI views. Understand how to refactor views, including how to use type constraints and convenience initializers. By Renan Benatti Dias.
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Contents
ViewBuilder Tutorial: Creating Reusable SwiftUI Views
25 mins
- Getting Started
- Exploring RayGem’s Views
- Understanding the Problem
- Exploring Possible Solutions
- Using a Boolean to Add and Remove Views
- Using an Enum to Select the Kind of View
- Understanding ViewBuilder
- Using ViewBuilder Closures
- Creating GemList
- Refactoring the FavoriteGems View
- Refactoring AllGemsView
- Adding a Convenience Initializer to GemList
- Creating the Search Feature
- Building SearchView
- The Type Problem
- Updating GemList to Accept Other Collection Types
- Using GemList Inside the Search Feature
- Where to Go From Here?
SwiftUI, Apple’s latest UI framework, helps you quickly build simple and more complex views. But what about when different features in your app need nearly the same view? That’s where @ViewBuilder
, an essential underlying component of SwiftUI itself, can help.
Creating SwiftUI views that work across multiple features can be challenging because each feature has different use cases. You might end up copying code, modifying for each feature. The right way to solve this problem is by extracting repeatable code into reusable views.
In this tutorial, you’ll learn:
- How to identify what portion of a view can be abstracted and made reusable.
- How to refactoring repeating code.
- How to use closures and @ViewBuilder to build views.
- What generic types are and how to use type constraints to build a view.
You’ll learn all of this while building RayGem, an app that displays information about different gemstones. Rockhounds and Crystal Gem fans, you’ll love this one!
Getting Started
To get started, download the project materials by clicking Download Materials at the top or bottom of this tutorial. Then, open the starter project in Xcode.
Build and run. You’ll see:
RayGem lists a collection of gems, which are precious or semiprecious stones. Users can read interesting facts and save their favorites.
However, this project has a problem: it repeats code across multiple features.
You’ll fix this problem as you complete the tutorial. But before you start refactoring code and adding new stuff, it’s important to understand the features that are already there.
Exploring RayGem’s Views
The first view you’ll explore, AllGems, holds the app’s main list. Users can scroll, find all gems and tap one to open its detail view.
AllGems uses a simple List
view with a custom view for each row, GemRow. This custom view displays the gem’s image, name and color.
Next, you have the Favorites list. You’ll find the gems you have favorited by tapping the heart button at the top of the details view. When the list is empty, you’ll see a message explaining how to favorite gems in the view.
The Favorites list also uses a simple List
with the same custom GemRow view to display the rows.
Finally, the Search feature helps users find gems by name using a search bar in the navigation view.
Open the search tab, and you’ll find a placeholder view:
You’ll build this feature later in the tutorial.
Now that you understand the features, take a closer look at the problem.
Understanding the Problem
RayGem feels like a well-designed, feature-rich app. Each view displays a simple list that uses the same custom view for its rows, creating a consistent user experience throughout the app.
But, there’s a small problem.
Open AllGemsView.swift and look at the body
:
var body: some View {
List {
ForEach(gems) { gem in
NavigationLink(destination: DetailsView(gem: gem)) {
GemRow(gem: gem)
}
}
}
.navigationTitle("Gems")
}
Now, look at FavoriteGems.swift‘s body
:
var body: some View {
List {
if gems.isEmpty {
EmptyFavoriteMessage()
}
ForEach(gems) { gem in
NavigationLink(destination: DetailsView(gem: gem)) {
GemRow(gem: gem)
}
}
}
.navigationTitle("Favorites")
}
Both have the same code, a List
with a ForEach
to iterate over the gems. Both also use GemRow
for the row of the list.
The only difference is that Favorites shows a message if you don’t have favorite gems.
If you continued this way, the Search feature would also use the same list and custom row to display results.
RayGem has code repeating not only in two features, but would also have it in the new Search feature.
Why is this a problem?
Well, code duplication is a code smell, which means it indicates a deeper problem within the coding. It may cause inconsistency later if you update one of the lists but forget to make the same changes in the others.
Both lists should appear identical, changing only the data they present. By repeating the code that builds them, you’re obliged to update everywhere you repeat the same code whenever you have to add or change something.
That would mean updating AllGemsView, FavoriteGems and SearchView every time you want to change something in the List
view.
The source code would be larger, harder to maintain and take longer to build.
Exploring Possible Solutions
SwiftUI makes it easy to refactor and reuse code in many features. If both views only used List
to display gems, with no other view inside, it would be straightforward to extract the code into another view and use it in both features.
But, FavoriteGems.swift has a conditional view that shows in the list if there’s no favorite gem to show yet. The message view explains how to favorite a gem.
Before you start building a new view to extract this code, you’ll go through a couple of possible solutions to this problem.
Using a Boolean to Add and Remove Views
One way to solve this problem is by creating a new view for listing gems and passing a Boolean in the initializer to add or remove the message view.
struct GemList: View {
let gems: [Gem]
let showMessage: Bool
var body: some View {
// 1
List {
// 2
ForEach(gems) { gem in
NavigationLink(destination: DetailsView(gem: gem)) {
GemRow(gem: gem)
}
}
// 3
if showMessage {
EmptyFavoriteMessage()
}
}
.listStyle(.insetGrouped)
}
}
This new view extracts the repeated code from AllGemsView.swift and FavoriteGems.swift. It builds the list and uses a Boolean to add and remove a view at the bottom. Here’s how this code works in detail:
- You use a
List
as the root of the view. - Next, you build each row of the list using the array of Gems.
- Finally, you use the new
showMessage
Boolean to add or removeEmptyFavoriteMessage
That way, you’d always pass false
inside AllGemsView.swift, and true
if gems is empty inside FavoriteGems.swift:
struct FavoriteGems: View {
// Properties...
var body: some View {
GemList(gems: gems, showMessage: gems.isEmpty)
.navigationTitle("Favorites")
}
}
Here, you remove the repeated code from FavoriteGems.swift and use the new GemList
view to build the list, passing the gems and a Boolean to add or remove the view at the bottom.
This solution has a couple of problems:
- Adding a
Boolean
means that if you add or remove other views for other purposes, you have to add aBoolean
to each view. - You’re tied to the view already built into
GemList
. If you add other views for other features, you’d have to change the code inside GemList.swift.
That also means GemList.swift will grow as you add more features and they need to be hard-coded into the new view, inside the body
.
Using an Enum to Select the Kind of View
You could solve the problem by creating an enum
for each view you add inside GemList.swift. Depending on the case you pass, you would then add or remove views from the body
.
This solution fixes the problem of passing many Boolean values into the initializer, but it has the same problems the other solution has. You’d have to add new cases to the enum
and more views inside GemList.swift every time a new feature requires a custom view inside the list, making it even bigger.
Fortunately, Swift has a handy feature called @ViewBuilder
.
Understanding ViewBuilder
@ViewBuilder
is a kind of result builder that’s specifically designed to help create child views. Result builders create functions that build a result from a sequence of elements. SwiftUI uses this in its own native views, controls and components. It also uses this in the body
to compose your views.
You can also use result builders to create your own Domain-Specific Language, or DSL. A DSL is like a miniature language-within-a-language used to solve problems within a particular area or domain. This topic, while undeniably fascinating, is outside the scope of this tutorial.
Using ViewBuilder Closures
When you use @ViewBuilder
, you can add a closure in the initializer that builds a view using it inside the body
. That way, instead of hard-coding views inside GemList.swift, you pass this view creation responsibility to the feature that will use it.
Many SwiftUI views already use @ViewBuilder
. Button
, for example, has an initializer, init(action:label:)
, that takes a @ViewBuilder
closure to build its label. Button
can use any view a developer wants as the label of the button.
VStack
and HStack
also use @ViewBuilder
to take all sorts of views as their contents.
Using @ViewBuilder
helps you add views while keeping GemList focused on building a list of gems.
You’ll use @ViewBuilder
in the initializer of a new view, GemList.swift, to take a closure for adding an explanatory message to the user when the Favorites list is empty.
Now that you know about @ViewBuilder
, it’s time to create GemList.swift.
Creating GemList
Now it’s time to add the GemList view to the project. Inside the Views group, create a new SwiftUI view and name it GemList.swift.
Begin by finding preview code:
struct GemList_Previews: PreviewProvider {
static var previews: some View {
GemList()
}
}
Comment out this code for now. Otherwise, your subsequent changes in this section will cause Xcode to complain. Not to worry; you’ll fix the previews later in this tutorial.
Next, find this line:
struct GemList: View {
And replace it with:
struct GemList<Content>: View where Content: View {
let gems: FetchedResults<Gem>
let messageView: Content
This code adds a generic type, Content
, to GemList and adds a constraint so it has to be View
. messageView
stores the view the closure builds in the initializer to use later inside the body
.
It also adds a property, gems
, for storing gems to display them in the list.
Next, add the following initializer just after the declaration of messageView:
init(_ gems: FetchedResults<Gem>, @ViewBuilder messageView: () -> Content) {
self.gems = gems
self.messageView = messageView()
}
This initializer takes a FetchedResults
of gems to populate the list. It also takes a closure marked with @ViewBuilder
to build the message view.
Then you store the view that this closure builds in messageView
.
Now, replace the contents of body
with:
// 1
List {
ForEach(gems) { gem in
// 2
NavigationLink(destination: DetailsView(gem: gem)) {
GemRow(gem: gem)
}
}
// 3
messageView
}
.listStyle(.insetGrouped)
Here’s a breakdown of the code above:
- You declare a
List
with aForEach
view for listing gems. - Here, you use a
NavigationLink
with the custom view,GemRow
, for each row of the list. - Finally, you add the
messageView
you stored from the closure, at the bottom of the list.
Use messageView
, inside the body
, like any other SwiftUI view. If you pass a Text
, Image
, Button
or any combination of views inside the closure, it’ll store in messageView
and add it to the bottom of the list.
The code to build the list, inside the body
of GemList.swift, is identical to what’s inside AllGemsView.swift and FavoriteGems.swift. By extracting this code to GemList.swift, you’re able to use this to replace the repeating code inside both these other views.
There’s no problem using this with FavoriteGems.swift because you can add a view in the messageView
closure. That’s where you’ll show the subview letting the user know how to favorite a gem if she’s not yet done so.
Now, roll up your sleeves. It’s time for some refactoring!
Refactoring the FavoriteGems View
Open FavoriteGems.swift and replace the contents of the body
with:
// 1
GemList(gems) {
// 2
if gems.isEmpty {
EmptyFavoriteMessage()
}
}
// 3
.navigationTitle("Favorites")
Here’s a code breakdown:
- First, you replace
List
with your new view,GemList
, passing the gems you favorited and a closure with a view. - Here, you check if gems is empty. If so, you add
EmptyFavoriteMessage
to display a message explaining how to favorite gems. - Finally, you use
navigationTitle(_:)
to changeGemList
with the title for theNavigationView
.
Build and run. Tap Favorites to see the message explaining how to favorite a gem.
Favorite some gems and open Favorites again to see them.
Success!
FavoriteGems looks the same, but you refactored the code to use the new GemList.swift view.
Time to refactor AllGemsView.swift to use GemList.swift, too.
Refactoring AllGemsView
Open AllGemsView.swift and replace the body
‘s contents with:
// 1
GemList(gems) {
// 2
EmptyView()
}
// 3
.navigationTitle("Gems")
Here’s the breakdown:
- You also replace the
List
, from AllGemsView, withGemList
, passing all gems and a closure. - Inside the closure, you add an
EmptyView
sinceAllGemsView
doesn’t show any message if the list is empty. - Finally, you change
GemList
with the title for theNavigationView
.
Build and run to see your results:
Nice job! You removed the repeating code from AllGemsView.swift and you’re also using GemList
.
Both features use the same view to list gems, removing repeated code and making both views shorter and cleaner.
But you can make AllGemsView.swift even neater!
Adding a Convenience Initializer to GemList
Even though GemList.swift fits in both the AllGemsView
and the FavoriteGems
view, you may not always want to add a view as a message when the list is empty. AllGemsView doesn’t show a message like FavoriteGems and yet it still needs to pass a closure when using this custom list. You’ll solve this problem by writing an extension to add a convenience initializer. This new initializer will let you use GemList without passing this closure when you don’t want to add views at the bottom of the list.
Open GemList.swift and add the following extension at the bottom of the file:
// 1
extension GemList where Content == EmptyView {
// 2
init(_ gems: FetchedResults<Gem>) {
// 3
self.init(gems) {
EmptyView()
}
}
}
Here’s the breakdown of the extension:
- Creates an extension of GemList where
Content
has to be anEmptyView
. - Adds a new initializer that takes
gems
. - Calls the original initializer passing the gems and a closure with an
EmptyView
.
With this initializer, use GemList by passing a FetchedResults
of gems.
Back inside AllGemsView.swift, find the following code:
GemList(gems) {
EmptyView()
}
And replace it with:
GemList(gems)
Build and run to make sure everything works.
Now, if you want to use GemList
but don’t need to display a subview of additional content, you can use this new, simpler initializer. It’s still adding an EmptyView
behind the scenes to meet the needs of GemList
‘s designated initializer, but you don’t need to worry about that.
You’re done refactoring AllGemsView.swift and FavoriteGems.swift. It’s time to start the Search feature.
Creating the Search Feature
The Search feature allows users to search gems by name. You’ll use the searchable(text:placement:prompt:)
modifier, new as of iOS 15, to add a search bar to the NavigationView
.
NSPredicate
to filter the results from a FetchedResults
. For teaching purposes here, you’ll instead use the filter(_:)
function to filter gems by name.
The search UI should be pretty straightforward: type the name of a gem in the search bar, and gems matching that text populate the list.
This is another great place to use GemList.swift since the search feature also lists gems.
Building SearchView
Inside Views group, open SearchView.swift and add the following three properties just above the body
:
// 1
@State var text = ""
// 2
var results: [Gem] {
gems.filter {
$0.name.lowercased()
.contains(text.lowercased())
}
}
// 3
var showEmptyMessage: Bool {
!text.isEmpty && results.isEmpty
}
Here’s what you added:
- A
@State
propertytext
to store the text the user enters in the search field. - A computed variable,
results
, for filtering gems with thetext
property. You’ll use this to populate GemList. - Another computed property,
showEmptyMessage
, to show an empty message when you don’t find any gem name containing that text.
This looks great, but there’s a small problem that stops you from using GemList here.
The Type Problem
When you use filter(_:)
to filter gems, it returns an Array
of gems, not FetchedResults
.
GemList
expects a parameter of FetchedResults
though, so passing results
to GemList
generates a compiler error.
To fix this, you’ll have to change the type inside GemList.swift to a more generic collection type that accepts both FetchedResults
and Array
.
Updating GemList to Accept Other Collection Types
Back inside GemList.swift, find the following line:
struct GemList<Content>: View where Content: View {
And replace it with:
struct GemList<Content, Data>: View
where Content: View,
Data: RandomAccessCollection,
Data.Element: Gem {
This code does three things. It adds a new generic type named Data
to GemList
. It then constrains Data
to be a RandomAccessCollection
. Finally, it also constrains the elements of this collection to be Gem
objects.
RandomAccessCollection
is a protocol that defines a collection where its elements can be efficiently and randomly accessed.
By constraining Data
to a RandomAccessCollection
and its elements to Gem
, GemList starts to accept any type that conforms to this protocol, as long as it’s a collection of Gems. Both Array
and FetchedResults
conform to RandomAccessCollection
, allowing you to pass either.
Next, find the following line:
let gems: FetchedResults<Gem>
And change it to:
let gems: Data
Then, change the first line of the initializer from:
init(_ gems: FetchedResults<Gem>, @ViewBuilder messageView: () -> Content) {
To:
init(_ gems: Data, @ViewBuilder messageView: () -> Content) {
By changing gems
from FetchedResults
to the generic type Data
, you can pass any collection that conforms to RandomAccessCollection
and has elements that are Gem
objects.
That way, you can use GemList
inside AllGemsView.swift and FavoriteGems.swift, passing FetchedResults
, while also passing an Array
inside SearchView.swift.
You’ll also have to update the convenience initializer to account for this change. Inside the extension of GemList
, replace the initializer with:
init(_ gems: Data) {
Fantastic! GemList
can now be used inside SearchView.swift. But before you do that, it’s time to get previews working. After all, seeing the preview right alongside your code is one of the best parts of SwiftUI! Uncomment the preview code you commented out at the bottom of the file. Then, replace its contents with:
static let gems = [roseGem, lapisGem]
static var previews: some View {
// 1
NavigationView {
GemList(gems) {
// 2
Text("This is at the bottom of the list...")
.padding()
.listRowBackground(Color.clear)
.frame(maxWidth: .infinity)
}
.navigationTitle("Gems")
}
}
Here’s what that code does:
- You create a
GemList
inside aNavigationView
with two gems. - You add a trailing closure for
Content
with aText
to display at the bottom of the list.
Resume automatic previews and take a look at the canvas:
Using GemList Inside the Search Feature
Back inside SearchView.swift, replace the contents of body
with:
// 1
GemList(results) {
// 2
if showEmptyMessage {
Text("No gem found with the name \"\(text)\"")
.padding()
.foregroundColor(.secondary)
.listRowBackground(Color.clear)
}
}
// 3
.searchable(
text: $text,
placement: .navigationBarDrawer(displayMode: .always),
prompt: "Search by name"
)
.navigationTitle("Search")
Here’s what’s happening:
- You use
GemList
, passing the propertyresults
to list the results shown after the user enters text in the search field. - Then you use
showEmptyMessage
to conditionally add a view for displaying a message when no gems exist with that text. - You use the
searchable(text:placement:prompt:)
modifier with thetext
variable to add a search bar to the navigation bar.
Build and run. Tap Search and type in a gem name. You’ll see something like this:
Congratulations! You successfully created a reusable view with @ViewBuilder
, then refactored existing views to take advantage of this. You then really made the project sparkle by quickly adding a search feature using your new reusable view and the .searchable
modifier. You rock!
Where to Go From Here?
You can download the completed project files by clicking Download Materials at the top or bottom of the tutorial.
In this tutorial, you learned:
- How to use a
@ViewBuilder
to build views inside other views. - Why code duplication should be avoided and how to create views to extract repeating code.
- How to use generic types and type constraints to accept different kinds of collections in a view.
Learning when and how to build reusable views will help you maintain high quality code.
Want to dive deeper into @ViewBuilder
? Check out the developer documentation.
Up for a really deep dive? Watch this video from WWDC 21 on how to use result builders to write your own domain-specific language (DSL).
Did you love working with RayGem? Look back at when it first appeared and learn about writing multiplatform apps that run across all Apple devices in Multiplatform App Tutorial: SwiftUI and Xcode 12.
To learn more about how to build apps using SwiftUI, check out our book SwiftUI Apprentice.
We hope you enjoyed this tutorial. Please join the forum discussion below if you have any questions or comments!