- 1. Mark the User’s Favorite Landmarks
- 2. Filter the List View
- 3. Add a Control to Toggle the State
- 4. Use an Observable Object for Storage
- 5. Adopt the Model Object in Your Views
- 6. Create a Favorite Button for Each Landmark
In the Landmarks app, a user can flag their favorite places, and filter the list to show just their favorites. To create this feature, you’ll start by adding a switch to the list so users can focus on just their favorites, and then you’ll add a star-shaped button that a user taps to flag a landmark as a favorite.
Open the project you finished in the previous tutorial, and select Landmark.swift in the Project navigator.
Add an isFavorite property to the Landmark structure.
The landmarkData.json file has a key with this name for each landmark. Because Landmark conforms to Codable, you can read the value associated with the key by creating a new property with the same name as the key.
import Foundation
import SwiftUI
import CoreLocation
struct Landmark: Hashable, Codable, Identifiable {
var id: Int
var name: String
var park: String
var state: String
var description: String
var isFavorite: Bool
private var imageName: String
var image: Image {
Image(imageName)
}
private var coordinates: Coordinates
struct Coordinates: Hashable, Codable {
var latitude: Double
var longitude: Double
}
var locationCoordinate: CLLocationCoordinate2D {
CLLocationCoordinate2D(latitude: coordinates.latitude, longitude: coordinates.longitude)
}
}Select LandmarkRow.swift in the Project navigator.
After the spacer, add a star image inside an if statement to test whether the current landmark is a favorite.
In SwiftUI blocks, you use if statements to conditionally include views.
if landmark.isFavorite {
Image(systemName: "start.fill")
}Complete LandmarkRow.swift file:
import SwiftUI
struct LandmarkRow: View {
var landmark: Landmark
var body: some View {
HStack {
landmark.image
.resizable()
.frame(width: 50, height: 50)
Text(landmark.name)
Spacer()
if landmark.isFavorite {
Image(systemName: "star.fill")
}
}
}
}
struct LandmarkRow_Previews: PreviewProvider {
static var previews: some View {
Group {
LandmarkRow(landmark: landmarks[0])
LandmarkRow(landmark: landmarks[1])
}
.previewLayout(.fixed(width: 300, height: 70))
}
}
Because system images are vector based, you can change their color with the foregroundStyle(_:) modifier.
if landmark.isFavorite {
Image(systemName: "star.fill")
.foregroundStyle(.yellow)
}Complete code:
import SwiftUI
struct LandmarkRow: View {
var landmark: Landmark
var body: some View {
HStack {
landmark.image
.resizable()
.frame(width: 50, height: 50)
Text(landmark.name)
Spacer()
if landmark.isFavorite {
Image(systemName: "star.fill")
.foregroundStyle(.yellow)
}
}
}
}
struct LandmarkRow_Previews: PreviewProvider {
static var previews: some View {
Group {
LandmarkRow(landmark: landmarks[0])
LandmarkRow(landmark: landmarks[1])
}
.previewLayout(.fixed(width: 300, height: 70))
}
}
Running Landmarks app.
You can customize the list view so that it shows all of the landmarks, or just the user’s favorites. To do this, you’ll need to add a bit of state to the LandmarkList type.
State is a value, or a set of values, that can change over time, and that affects a view’s behavior, content, or layout. You use a property with the @State attribute to add state to a view.
Select LandmarkList.swift in the Project navigator.
Add a @State property called showFavoritesOnly, with its initial value set to false.
Because you use state properties to hold information that is specific to a view and its subviews, you always create state as
private.
import SwiftUI
struct LandmarkList: View {
@State private var showFavoritesOnly = false
var body: some View {
NavigationView {
List(landmarks) { landmark in
NavigationLink {
LandmarkDetailView(landmark: landmark)
} label: {
LandmarkRow(landmark: landmark)
}
}
.navigationTitle("Landmarks")
}
}
}
struct LandmarkList_Previews: PreviewProvider {
static var previews: some View {
LandmarkList()
}
}When you make changes to your view’s structure, like adding or modifying a property, the canvas automatically refreshes.
If the canvas isn’t visible, select Editor > Canvas to show it.
Compute a filtered version of the landmarks list by checking the showFavoritesOnly property and each landmark.isFavorite value.
var filteredLandmarks: [Landmark] {
landmarks.filter { landmark in
(!showFavoritesOnly || landmark.isFavorite)
}
}
Use the filtered version of the list of landmarks in the List.
Change the initial value of showFavoritesOnly to true to see how the list reacts.
import SwiftUI
struct LandmarkList: View {
@State private var showFavoritesOnly = true
var filteredLandmarks: [Landmark] {
landmarks.filter { landmark in
(!showFavoritesOnly || landmark.isFavorite)
}
}
var body: some View {
NavigationView {
List(filteredLandmarks) { landmark in
NavigationLink {
LandmarkDetailView(landmark: landmark)
} label: {
LandmarkRow(landmark: landmark)
}
}
.navigationTitle("Landmarks")
}
}
}
struct LandmarkList_Previews: PreviewProvider {
static var previews: some View {
LandmarkList()
}
}To give the user control over the list’s filter, you need to add a control that can alter the value of showFavoritesOnly. You do this by passing a binding to a toggle control.
A binding acts as a reference to a mutable state. When a user taps the toggle from off to on, and off again, the control uses the binding to update the view’s state accordingly.
Create a nested ForEach group to transform the landmarks into rows.
To combine static and dynamic views in a list, or to combine two or more different groups of dynamic views, use the ForEach type instead of passing your collection of data to List.
import SwiftUI
struct LandmarkList: View {
@State private var showFavoritesOnly = true
var filteredLandmarks: [Landmark] {
landmarks.filter { landmark in
(!showFavoritesOnly || landmark.isFavorite)
}
}
var body: some View {
NavigationSplitView {
List {
ForEach(filteredLandmarks) { landmark in
NavigationLink {
LandmarkDetailView(landmark: landmark)
} label: {
LandmarkRow(landmark: landmark)
}
}
.navigationTitle("Landmarks")
}
} detail: {
Text("Select a Landmark")
}
}
}
struct LandmarkList_Previews: PreviewProvider {
static var previews: some View {
LandmarkList()
}
}
Add a Toggle view as the first child of the List view, passing a binding to showFavoritesOnly.
You use the $ prefix to access a binding to a state variable, or one of its properties.
import SwiftUI
struct LandmarkList: View {
@State private var showFavoritesOnly = true
var filteredLandmarks: [Landmark] {
landmarks.filter { landmark in
(!showFavoritesOnly || landmark.isFavorite)
}
}
var body: some View {
NavigationSplitView {
List {
Toggle(isOn: $showFavoritesOnly) {
Text("Favorites only")
}
ForEach(filteredLandmarks) { landmark in
NavigationLink {
LandmarkDetailView(landmark: landmark)
} label: {
LandmarkRow(landmark: landmark)
}
}
.navigationTitle("Landmarks")
}
} detail: {
Text("Select a Landmark")
}
}
}
struct LandmarkList_Previews: PreviewProvider {
static var previews: some View {
LandmarkList()
}
}
Before moving on, return the default value of showsFavoritesOnly to false.
@State private var showFavoritesOnly = false
Use the Live preview and try out this new functionality by tapping the toggle.
To prepare for the user to control which particular landmarks are favorites, you will first store the landmark data using the Observable() macro.
With Observation, a view in SwiftUI can support data changes without using property wrappers or bindings. SwiftUI watches for any observable property changes that could affect a view, and displays the correct version of the view after a change.
In the project's navigation pane, select Model Data.
Declare a new model type using the Observable() macro.
SwiftUI updates a view only when an observable property changes and the view´s body reads the property directly.
Move the landmarcks array into the model.
Now that you have created the ModelData object, you need to update your views to adopt it as the data store for your app.
In LandmarkList, add an @Environment property wrapper to the view, and an environment(_:) modifier to the preview.
The modelData property gets ist value automatically, as long as the environment(_:) modifier has been applied to a parent. The @Environment property wrapper enables you to read the model data of the current view. Adding an environment(_:) modifier passes the data object down through the environment.
Use modelData.landmarks as the data when filtering landmarks.
Update the LandmarkDetail view to work with the ModelData object in the environment.
Update the LandmarkRow preview to work with the ModelData object.
Update the ContentView preview to add the model object to the environment, which makes the object available to any subview.
A preview fails if any subview requires a model object in the environment, but the view you are previewing does not have the environment(_:) modifier.
Next, you will update the app instance to put the model object in the environment when you run the app in the simulator or on a device.
Update the LandmarksApp to create a model instance and supply it to ContentView using the environment(_:) modifier.
Switch back to LandmarkList to verify that everything is working properly.
The Landmarks app can now switch between a filtered and unfiltered view of the landmarks, but the list of favorite landmarks is still hard coded. To allow the user to add and remove favorites, you need to add a favorite button to the landmark detail view.
You will first create a reusable Favorite button.
Add an isSet binding that indicates the button´s current state, and provide a constant value for the preview.
The binding property wrapper enables you to read and write between a property that stores data and a view that displays and changes the data. Because you use a binding, changes made inside this view propagate back to the data source.
Create a Button with an action that toggles the isSet state, and that changes its appearence based on the state.
The title string that you provide for the button’s label doesn’t appear in the UI when you use the iconOnly label style, but VoiceOver uses it to improve accessibility.
As your program grows, it is a good idea to add hierarchy. Before moving on, create a few more groups.
Collect the general purpose CircleImage, MapView, and FavoriteButton files into a Helpers group, and the landmark views into a Landmarks group.
Next, you’ll add the FavoriteButton to the detail view, binding the button’s isSet property to the isFavorite property of a given landmark.
Switch to LandmarkDetail, and compute the index of the input landmark by comparing it with the model data.
To support this, you also need access to the environment’s model data.
Inside the body property, add the model data using a Bindable wrapper. Embed the landmark´s name in an HStack with a new FavoriteButton; provide a binding to the isFavorite property with the dollar sign ($).
Use landmarkIndex with the modelData object to ensure that the button updates the isFavorite property of the landmark stored in your model object.
Switch back to LandmarkList, and make sure the Live preview is on.
As you navigate from the list to the detail and tap the button, those changes persist when you return to the list. Because both views access the same model object in the environment, the two views maintain consistency.


