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?
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!