Day 54 - SwiftUI Custom UI Component
Table of Contents
We will start applying the new techniques we have learned to create our application. We’ll use SwiftData to create a book object and a custom RatingView
component created using @Binding
for users to record how much they liked each book.
Today, we’ll work on three topics where we’ll apply our newly acquired SwiftData skills with List
, @Binding
, and more.
- Creating a Book with SwiftData
- Creating a Star Rating Custom Component
- Creating a List with
@Query
Creating a Book with SwiftData #
Our first task in this project will be to design a SwiftData model for our books and then create a new view to add books to the database.
Let’s start by creating the model. Create a new file named Book.swift, import SwiftData, and then write this code:
@Model
class Book {
var title: String
var author: String
var genre: String
var review: String
var rating: Int
}
This class needs an initializer to provide values for all its properties. When you start typing in in Xcode, it can automatically create this for you.
This class is sufficient to store the book’s title, author, genre, a brief summary of the user’s thoughts about the book, and also the numerical score the user gave for the book.
Now that we have our data model, we can ask SwiftData to create a model container for it. This means opening the BookwormApp.swift file, adding import SwiftData
to the top of the file, and then adding this modifier to WindowGroup
:
.modelContainer(for: Book.self)
Our next step is to write a form where new entries can be created. This will bring together many of the skills we’ve learned so far: Form
, @State
, @Environment
, TextField
, TextEditor
, Picker
, sheet()
, and more, plus all your new SwiftData knowledge.
Start by creating a new SwiftUI view called “AddBookView”. In terms of properties, we need an environment property to access the model context.
@Environment(\.modelContext) var modelContext
Since this form will store all the data needed to create a book, we need @State
properties for each value of the book. Now add these properties:
@State private var title = ""
@State private var author = ""
@State private var rating = 3
@State private var genre = "Fantasy"
@State private var review = ""
Finally, we need one more property to store all possible genre options, so we can make a picker using ForEach
. Add this final property to AddBookView
:
let genres = ["Fantasy", "Horror", "Kids", "Mystery", "Poetry", "Romance", "Thriller"]
Now we can start making the form itself. Replace the existing body with this:
NavigationStack {
Form {
Section {
TextField("Name of book", text: $title)
TextField("Author's name", text: $author)
Picker("Genre", selection: $genre) {
ForEach(genres, id: \.self) {
Text($0)
}
}
}
Section("Write a review") {
TextEditor(text: $review)
Picker("Rating", selection: $rating) {
ForEach(0..<6) {
Text(String($0))
}
}
}
Section {
Button("Save") {
// add the book
}
}
}
.navigationTitle("Add Book")
}
When it comes to filling in the button’s action, we’ll create an instance of the Book
class using all the values in our form, and then add the object to the model context.
Add this code in place of the //add the book
comment:
let newBook = Book(title: title, author: author, genre: genre, review: review, rating: rating)
modelContext.insert(newBook)
This completes the form for now, but we still need to find a way to show and hide this form.
To show AddBookView
, we need to return to ContentView.swift and follow the usual steps for a sheet:
- Add a
@State
property to track whether the sheet is shown. - Add a button to change this property (in this case to the toolbar).
- Add a
sheet()
modifier that showsAddBookView
when the property is true.
Start by adding an import for SwiftData to ContentView.swift, then add these properties to the ContentView
struct:
@Environment(\.modelContext) var modelContext
@Query var books: [Book]
@State private var showingAddScreen = false
This gives us a model context we can use later to delete books, a query that reads all the books we have (so we can test that everything is working), and a Boolean that tracks whether the add screen is being shown.
For the ContentView
body, we’ll use a navigation stack so we can add a title and a button in the top right corner, but otherwise, we’ll just keep some text showing how many items we have in our books
array. Remember, this is where we need to add the sheet()
modifier to show an AddBookView
when needed.
Replace the existing body
property of ContentView
with this:
NavigationStack {
Text("Count: \(books.count)")
.navigationTitle("Bookworm")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Add Book", systemImage: "plus") {
showingAddScreen.toggle()
}
}
}
.sheet(isPresented: $showingAddScreen) {
AddBookView()
}
}
We’ve now designed our SwiftData model, created a form to add data, then updated ContentView
so it can present the form when needed. The final step is to make the form dismiss itself when the user adds a book.
We’ve done this before, so hopefully you know what to do. We need to start by adding another environment property to AddBookView
so we can dismiss the current view:
@Environment(\.dismiss) var dismiss
Finally, add a dismiss()
call to the end of our Save button’s action closure.
You should now be able to run the app and add a sample book. The Count should increase by 1 as you add books.
Creating a Star Rating Custom Component #
SwiftUI makes it really easy to create custom UI components because they are just views that have some kind of @Binding
for us to read.
To demonstrate this, we’re going to create a star rating view that lets users enter ratings from 1 to 5 by tapping images. We’ll also make this view flexible so we can use it anywhere. We’ll make six customizable properties:
- What label should be placed before the rating (defaulting to an empty string)
- The maximum integer rating (defaulting to 5)
- The off and on images that set what images should be used when the star is highlighted or not (defaulting to
nil
for the off image and a filled star for the on image; if we findnil
for the off image then we’ll use the on image) - The off and on colors that set what colors should be used when the star is highlighted or not (defaulting to gray for off and yellow for on)
We also need one extra property to store a @Binding
integer, so we can report back the user’s selection to whatever is using the star rating.
So, create a new SwiftUI view called “RatingView”, and start by giving it these properties:
@Binding var rating: Int
var label = ""
var maximumRating = 5
var offImage: Image?
var onImage = Image(systemName: "star.fill")
var offColor = Color.gray
var onColor = Color.yellow
Before filling in the body
property, please try to build the code – you’ll see it fails, because our #Preview
code isn’t passing in a binding to use for rating
.
SwiftUI has a special and simple solution for this called constant bindings. These are bindings that have fixed values, which on the one hand means they can’t be changed by the user interface, but on the other hand means we can create them trivially – perfect for previews.
So, replace the existing preview code with this:
#Preview {
RatingView(rating: .constant(4))
}
Now let’s turn to the body
property. This will be a HStack
containing any label that was provided, plus as many stars as have been requested – except of course they can contain any image they want, so they might not be stars at all.
The logic for choosing which image is shown is fairly simple, but it’s perfect for extracting into its own method to reduce the complexity of our code. The logic is this:
- If the number passed in is greater than the current rating, return the off image if it was set, otherwise return the on image.
- If the number passed in is equal to or less than the current rating, return the on image.
We can encapsulate that in a single method, so add this to RatingView
now:
func image(for number: Int) -> Image {
if number > rating {
offImage ?? onImage
} else {
onImage
}
}
And now implementing the body
property is surprisingly easy: use the label if we have one, then use ForEach
to count from 1 to the maximum rating plus 1, calling image(for:)
repeatedly. We’ll also apply a foreground color based on the rating, and wrap each star in a button that sets the rating.
Replace the current body property with this:
HStack {
if label.isEmpty == false {
Text(label)
}
ForEach(1..<maximumRating + 1, id: \.self) { number in
Button {
rating = number
} label: {
image(for: number)
.foregroundStyle(number > rating ? offColor : onColor)
}
}
}
That completes the rating view already, so return to AddBookView
and replace the second section with this to put it into action:
Section("Write a review") {
TextEditor(text: $review)
RatingView(rating: $rating)
}
Our default values are sensible, so it looks great right out of the box – go ahead and try it now.
You’ll probably see that things aren’t quite working right: no matter which star rating you tap, it will select a 5-star rating.
This problem affects hundreds of people, no matter how much experience they have. The problem is that when we have rows inside a form or a list, SwiftUI likes to assume the rows themselves are tappable. This makes it easier for users to select, because they can tap anywhere in the row to trigger the button inside.
In our case we have multiple buttons, so SwiftUI is pressing them all in turn, which means we always end up with 5.
We can disable all the “tap the row to trigger buttons” behavior with one extra modifier added to the HStack
:
.buttonStyle(.plain)
This forces SwiftUI to make each button operate independently, so now everything works as planned. And it’s much nicer to use too: star ratings are more natural and more common here, so there’s no need to go into a detail view with a picker.
Creating a List with @Query #
Currently, our ContentView
has a query property like this:
@Query var books: [Book]
And we’re using it with a simple text view in the body
:
Text("Count: \(books.count)")
To bring this screen to life, we’re going to replace that text view with a List
showing all the books that have been added, along with their ratings and authors.
We could use the rating view we made earlier here, but it’s much more fun to try something else. While the RatingView
control could be used in any project, we can make a new EmojiRatingView
that displays a rating specific to this project. All it will do is show one of five different emojis depending on the rating, and it’s a great example of how simple view composition is in SwiftUI.
So, create a new SwiftUI view called “EmojiRatingView”, and give it this code:
struct EmojiRatingView: View {
let rating: Int
var body: some View {
switch rating {
case 1:
Text("1")
case 2:
Text("2")
case 3:
Text("3")
case 4:
Text("4")
default:
Text("5")
}
}
}
#Preview {
EmojiRatingView(rating: 3)
}
Tip: I’ve used numbers in my text because emojis can cause damage to e-readers, but you can replace these with emojis you think represent the various ratings.
Now we can return to ContentView
and take a first pass at the user interface. This will replace the existing text view with a list and a ForEach
over books
. We don’t need to provide an identifier for the ForEach
because all SwiftData models automatically conform to Identifiable
.
Inside the list will be a NavigationLink
pointing to the current book, and inside that will be the new EmojiRatingView
plus the book’s title and author. Replace the existing text field with this:
List {
ForEach(books) { book
NavigationLink(value: book) {
HStack {
EmojiRatingView(rating: book.rating)
.font(.largeTitle)
VStack(alignment: .leading) {
Text(book.title)
.font(.headline)
Text(book.author)
.foregroundStyle(.secondary)
}
}
}
}
}
Tip: Make sure you leave the previous modifiers in place – navigationTitle()
etc.
The navigation won’t fully work yet because we haven’t added the navigationDestination()
function, but that’s okay – we’ll come back to this screen soon. Let’s create the detail view first.
You can also read this article in Turkish.
Bu yazıyı Türkçe olarak da okuyabilirsiniz.