17.
Sheets & Alert Views
Written by Bill Morefield
In a previous chapter, you learned how to use standard navigation to switch between views in your app. However, sometimes you need to display a view to the user only under certain conditions. You’ll often use these views when showing important messages that interrupt the user’s current context and need direct feedback or response before continuing.
Presenting a view outside the navigation stack lets the user’s focus remain on the task they initiated. It also provides a way for your app to provide critical information or request essential feedback.
Starting in SwiftUI 3.0, Apple appears to be shifting the approach to these views. The initial versions of SwiftUI focused on the type of view to display. The changes to APIs and new modifiers in SwiftUI 3.0 indicate a shift to the view’s purpose instead of the kind of view. In this chapter, you’ll expand the app to use different conditional views in SwiftUI. Along the way, you’ll explore the new SwiftUI 3.0 APIs to prepare your app for the future.
Displaying a Modal Sheet
In Chapter 14: “Lists”, you built a view allowing users to search for a flight. One element deferred then was the ability to view details or interact with those results. You’re going to add that ability in this chapter. Modal sheets help focus the user’s attention on the current view without building through the overall navigation hierarchy. The modal sheet slides a new view over the current view.
SwiftUI provides two ways to display a modal, both based on a @State variable in the view. The first method uses a Bool variable that you set to true when the sheet should display. The second uses an optional state variable that shows the modal when the variable becomes non-nil. You’ll use the Bool method for this modal.
All modals provide these two options; you’ll see an example using an optional variable later in this chapter.
Open the starter project for this chapter; you’ll find the project from the end of the last chapter. Go to SearchResultRow.swift. Notice the view for each row now resides in a separate view. That will make the code changes for this chapter a little cleaner. Add the following new variable after flight:
@State private var isPresented = false
This line defines a @State variable that indicates when to show the modal sheet. Change the view to:
// 1
Button(
action: {
isPresented.toggle()
}, label: {
FlightSearchSummary(flight: flight)
})
// 2
.sheet(
// 3
isPresented: $isPresented,
// 4
onDismiss: {
print("Modal dismissed. State now: \(isPresented)")
},
// 5
content: {
FlightSearchDetails(flight: flight)
}
)
Here’s what the elements of the modal sheet do:
- You wrap the row inside a button. The action of the button toggles the state variable.
- To tell SwiftUI you want to display a modal, you call
sheet(isPresented:onDismiss:content:). This call must attach to an element of the view. - Here, you pass the
isPresentedstate variable you added earlier, which tells SwiftUI to show the modal when the variable becomestrue. When the user dismisses the modal, SwiftUI sets the state back tofalse. - The optional
onDismiss:is a closure you can use to execute code after the user dismisses the modal. In an app, this would be the place to react to user actions in the modal. You print a message to the console and show that the state variable’s value is nowfalse. - You provide the view to show on the modal sheet as the closure for
sheet(isPresented:onDismiss:content:). For the moment, you’ll use the existingFlightSearchDetails(flight:)view.
Build and run, navigate to Search Flights and tap any row to see the modal appear. Swipe down on the modal to dismiss it. In the debug console, you’ll see the state variable become false after you dismiss the modal:
Programmatically Dismissing a Modal
You probably noticed that the navigation view disappears in the modal sheet. That’s because a modal sheet takes over the whole screen and no longer wraps the view in any existing navigation view. You can even create a new navigation stack on the modal.
You should also add a button to dismiss the modal, primarily since some platforms, such as Catalyst apps, don’t support the swipe gesture.
Open FlightSearchDetails.swift. First, you’ll need a variable to store a @Binding to the passed display flag from FlightRow. So add the following code after flight:
@Binding var showModal: Bool
You’ll add the button next to the header at the top of the modal. Replace FlightDetailHeader with this:
HStack {
FlightDetailHeader(flight: flight)
Spacer()
Button("Close") {
showModal = false
}
}
You are adding a Button with an action to set the binding to false. Assigning false to the Binding programmatically from the button tells SwiftUI to close the modal.
Since the view now expects the caller to pass in the state, you need to update the preview to do so. Change the preview to read:
FlightSearchDetails(
flight: FlightData.generateTestFlight(date: Date()),
showModal: .constant(true)
)
.environmentObject(AppEnvironment())
Using .constant(true) provides a pseudo-state that lets the preview behave correctly.
Now, go back to SearchResultRow.swift and change the call to FlightSearchDetails in the closure to sheet(isPresented:onDismiss:content:) to pass in the state:
FlightSearchDetails(
flight: flight,
showModal: $isPresented
)
Run the app and tapping on a row to bring up the modal with a Close button in the navigation bar. Tapping the button dismisses the modal, just as swiping down does.
SwiftUI allows you to prevent the swipe action from dismissing a view. Go back to FlightSearchDetails.swift. Add the following code to the end of the ZStack view (after the onAppear(perform:) modifier):
.interactiveDismissDisabled()
Note that you’re applying this to the view being shown in the sheet and not to the sheet modifier in SearchResultRow.swift. Run the app, and you’ll see swiping down no longer dismisses the view. The view does dip, but it returns to the displayed state when you stop your gesture. You now have to use the Close button to dismiss the modal.
A modal is an excellent choice when your view needs the user’s full attention. Used correctly, they help your user focus on relevant information and improve the app experience. However, modal views interrupt the app experience, so you should use them sparingly. SwiftUI provides three more specialized modal views to help you capture the user’s attention: alerts, action sheets and popovers. You’ll learn how to use each of those now.
Showing Partial Sheets
The sheets that you’ve created all take up the entire view. Starting with SwiftUI 4.0, you can create sheets occupying only part of the view. Open FlightDetails.swift and add the following state variable after flight:
@State private var showTerminalInfo = false
As in the previous sections, you’ll use this state variable to show a sheet when the user taps the view. Add the following code to the end of the ZStack before the onAppear(perform:) modifier:
.onTapGesture {
showTerminalInfo.toggle()
}
This code toggles the showTerminalInfo when the user taps the ZStack. Now add the following modifier after the onTapGesture(count:perform:) you just added:
.sheet(isPresented: $showTerminalInfo) {
Group {
if flight.gate.hasPrefix("A") {
TerminalAView()
} else {
TerminalBView()
}
}
.presentationDetents([.medium, .large])
}
The presentationDetents(_:) modifier allows you to provide a set of sizes you want to support for the sheet. Run the app, tap on Flight Status and select any flight. Now tap on the terminal map, and the new sheet appears, but it only covers half of the view.
Notice the grab bar circled in the screenshot. You can drag this to change between the sizes or tap it to cycle through the possible sizes. SwiftUI will always start with the smallest provided option and cycle through them in increasing amounts of the screen covered. The large option allows the sheet to fill the entire view. Besides swiping the view to dismiss it, you can tap anywhere outside a view smaller than large. You can also specify values as a fraction of the view size using the .fraction(_:) modifier. You can use the height(_:) modifier to specify a sheet height in points.
Change the orientation to landscape. Now the sheet fills the entire view and will be challenging to swipe away. In some situations, a smaller sheet can still fill the whole view, such as an iPhone in landscape orientation. You should ensure the user can dismiss the view even if swiping down and tapping outside the sheet aren’t options.
Open TerminalAView.swift and add the following presentation property above the body of the view:
@Environment(\.dismiss) var dismiss
The @Environment(\.dismiss) value provides access to the DismissAction for the current view. Calling this method will pop the current view from a NavigationStack or programmatically dismiss a modal view like a sheet.
Add the following method to the end of the ZStack:
.onTapGesture {
dismiss()
}
When the user taps the view, you call the dismiss() instance to dismiss the sheet. Note that you do this in the view you want to dismiss, in this case the TerminalAView view shown in the sheet. Make the same modification to TerminalBView.swift.
Run the app, tap on Flight Status and select any flight. Now tap on the terminal map, and the sheet appears. Change the orientation to landscape. Tap on the sheet itself, and the dismiss() method clears it.
Sometimes you have information you need the user to pay close attention to. In the next section, you’ll see how to create views for that purpose.
Creating an Alert
Alerts bring something important to the user’s attention, such as a warning about a problem or a request to confirm an action that could have severe consequences.
You’re going to add a button to help the user rebook a canceled flight. It won’t do anything yet — you’re waiting on the back-end team to finish that API. Instead, you’ll display an alert telling the user to contact the airline much as you would in the event of an error.
Open FlightSearchDetails.swift. You can set alerts, like modals, to display based on a state variable. Add the following state after the showModal Binding:
@State private var rebookAlert = false
Add the following after the FlightDetailHeader HStack and before FlightInfoPanel:
// 1
if flight.status == .canceled {
// 2
Button("Rebook Flight") {
rebookAlert = true
}
// 3
.alert(isPresented: $rebookAlert) {
// 4
Alert(
title: Text("Contact Your Airline"),
message:
Text("We cannot rebook this flight. Please contact") +
Text(" the airline to reschedule this flight.")
)
)
}
}
Here’s what you’re doing with this code:
-
The view only displays when the flight status is
.canceled. -
The button sets
rebookAlerttotruewhen tapped. -
You call
alert(isPresented:content:)on theButtonto create the alert. You also pass in the state variable telling SwiftUI to show the alert whenrebookAlertbecomestrue. -
In the closure,
Alertdefines the alert message to show the user. You don’t provide any additional buttons, so the user’s only option is to tap the OK button to dismiss the alert.
Build and run. Tap Search Flights, then tap any Canceled flight (look for Pacific 228 From Dallas/Ft. Worth). Tap on the Rebook Flight button, and the alert appears.
Starting with SwiftUI 4.0, you can add TextField and SecureText views in addition to Buttons. In the case of re-booking a flight, you might want to allow the user to enter a contact phone number. Add two new state properties after rebookFlight:
// 1
.alert("Contact Your Airline", isPresented: $rebookAlert) {
// 2
Button("OK", role: .cancel) {
}
// 3
} message: {
Text("We cannot rebook this flight. Please contact") +
Text(" the airline to reschedule this flight.")
}
This code should all look familiar since you’re doing the same task with different formatting.
- The alert now includes the title inside the
alert(_:isPresented:actions:message:)modifier. You still use the samerebookAlertboolean to trigger the alert when it becomestrue. - Instead of an
Alertstruct, you provide a button for the options you want to show in the alert. Note the use of the.cancelrole on the button. You tell SwiftUI this button cancels the alert, and therefore SwiftUI will automatically setrebookAlerttofalse. If you do not include a cancel button, then SwiftUI will add one for you. - You now pass the message for the alert as an additional parameter of the
alert(_:isPresented:actions:message:)modifier.
Run the app, and you’ll see this works as the previous version did. Unless you need backward compatibility, you should use this new format for alerts.
Notice in this new API, you add a Button to the view. Starting with SwiftUI 4.0, you can also add TextField and SecureText views. In the case of re-booking a flight you might want to allow the user to enter a contact phone number. Add two new state properties after rebookFlight:
@State private var phone = ""
@State private var password = ""
Now replace the current .alert modifier with:
.alert("Contact Your Airline", isPresented: $rebookAlert) {
TextField("Phone", text: $phone)
SecureField("Password", text: $password)
Button("Call Me") {
}
Button("Cancel", role: .cancel) {
}
} message: {
Text("We cannot rebook this flight.") +
Text("Please enter your phone number and confirm your password.")
}
You add the TextField and SecureField to allow users to enter values into the state variables you added. The SecureField view doesn’t show the user’s text and is useful when the user needs to enter sensitive information. Notice you also have two buttons, one to confirm and one to cancel the action. The closure of both remains empty but could contain any code and use the values from the TextField and SecureField.
You can also trigger the alert with a modal sheet by binding it to an optional variable. In the next section, you’ll use this method and implement an action sheet.
Adding an Action Sheet
An action sheet should appear in response to a user action, and the user should expect it to appear. For example, you might want to use an action sheet to confirm an action or let the user select between multiple options.
In this section, you’ll add a button to let the user check in for a flight and display an action sheet to confirm the request.
Instead of the Bool state variable you used for the modal sheet and alert, you’ll use an optional variable. You can use either of these methods with any of the modal views in this chapter.
There are a couple of reasons you would use this method over the Bool variable. First, none of the views discussed in this chapter can be used more than once for a view. If you try to attach two alert views, for example, only the last one will work. You can attach an alert, modal, or action sheet to sibling views in a view hierarchy, but you can’t attach more than one to the same view (or to a child and parent). Using an optional enum, you can use just one, but specify which content you need to display based on the enum.
The second reason to use the optional variable over the Bool is to access the variable’s data inside the closure. The variable must implement the Identifiable protocol discussed in the previous chapter.
You’ll create a simple struct that implements Identifiable for this action sheet for your next step. Create a new Swift file named CheckInInfo.swift under the Models group. Change the contents of the file to read:
import SwiftUI
struct CheckInInfo: Identifiable {
let id = UUID()
let airline: String
let flight: String
}
Here, you define a new CheckInInfo struct that implements Identifiable. To meet the protocol requirements, you include an id member of type UUID.
By definition, a UUID provides a unique value and implements the Hashable protocol, making it a perfect unique identifier when you don’t care about anything other than it is unique. You then add airline and flight strings, which you’ll provide when creating the message.
Now, inside FlightSearchDetails, add the following state variable to hold CheckInInfo after the password property:
@State private var checkInFlight: CheckInInfo?
Next, add the following code after the if flight.status == .canceled { condition that wraps the alert you added in the last section and before the FlightInfoPanel view:
// 1
if flight.isCheckInAvailable {
Button("Check In for Flight") {
// 2
checkInFlight =
CheckInInfo(
airline: flight.airline,
flight: flight.number
)
}
// 3
.actionSheet(item: $checkInFlight) { checkIn in
// 4
ActionSheet(
title: Text("Check In"),
message: Text("Check in for \(checkIn.airline)" +
"Flight \(checkIn.flight)"),
// 5
buttons: [
// 6
.cancel(Text("Not Now")),
// 7
.destructive(Text("Reschedule")) {
print("Reschedule flight.")
},
// 8
.default(Text("Check In")) {
print(
"Check-in for \(checkIn.airline) \(checkIn.flight)."
)
}
]
)
}
}
This code looks similar to the code you used to create the modal sheet and the alert, except that the action sheet uses the optional variable in place of a Bool. It also needs information about the buttons to display.
Here’s how the new elements in this code work:
- You only show this button for a flight that has check-in available
- The button’s action sets
checkInFlightto a new instance ofCheckInInfothat stores the airline and number of the flight. - As you did with the alert, you add the action sheet to the button. Here, you use
actionSheet(item:content:)and notactionSheet(isPresented:content:). You pass the optional variable as theitem:parameter. When the variable becomes non-nil, as it will when the button’s action executes, SwiftUI displays the action sheet. WhencheckInFlightbecomes non-nil, it triggers the same way the alert’s Boolean binding told SwiftUI to display the alert. You also provide a parameter inside the closure. When SwiftUI shows the sheet, this parameter contains the contents of the bindable value that triggered it. - You create an action sheet using the passed-in variable’s contents to display the name of the flight to the user on the action sheet.
- An alert provides a limited ability to gather feedback. You have many more options with an action sheet, though all must be buttons. Here, you pass an array of
ActionSheet.Buttonitems to thebuttons:parameter for those you wish to use in this action sheet. - The first defined button is the Cancel button. Providing a cancel button gives the user a clear back-out option. When the user selects this option, you do nothing, so you don’t need any parameter other than text for this button.
- You use the
.destructivetype method for actions that have destructive or dangerous results. SwiftUI displays the text in red to highlight this action’s seriousness.action:provides code that SwiftUI executes when the user selects this option. Here, you display a message to the debug console. - The default button for the action sheet uses
action:to display a message to the debug console.
Build and run. Select Search Flights and then tap any outgoing flight that’s not yet departed. Next, tap the Check In for Flight button, and the action sheet will appear.
If you tap the Not Now button, nothing happens since you provided no action parameter. Tap either the Check In or Reschedule button, and the appropriate message appears in the console window in the debug area of Xcode.
In the next section, you’ll explore how the new SwiftUI 3.0 alert can work in place on an action sheet.
Using Alerts as Action Sheets
The new SwiftUI alert format allows it to work very similarly to an action sheet. In this section, you’ll implement the action sheet from the last section using the new alert.
Add the following state to the top of the view after checkInFlight:
@State private var showCheckIn = false
The new alert API doesn’t work with an optional parameter. Instead, you must use a boolean to indicate when SwiftUI should show the alert. Replace the current button labeled Check In for Flight with:
Button("Check In for Flight") {
checkInFlight =
CheckInInfo(
airline: flight.airline,
flight: flight.number
)
showCheckIn = true
}
The only change is that you set the showCheckIn true and set the checkInFlight property with information on the flight. Last, replace the current .actionSheet modifier (starting at comment three in the earlier code) with:
// 1
.alert(
"Check In",
isPresented: $showCheckIn,
presenting: checkInFlight
) { checkIn in
// 2
Button("Check In") {
print(
"Check-in for \(checkIn.airline) \(checkIn.flight)."
)
}
// 3
Button("Reschedule", role: .destructive) {
print("Reschedule flight.")
}
// 4
Button("Not Now", role: .cancel) { }
// 5
} message: { checkIn in
Text("Check in for \(checkIn.airline)" +
"Flight \(checkIn.flight)")
}
You’re replacing the action sheet with the impressive length alert(_:isPresented:presenting:actions:message:) modifier. As when you changed the alert to this new API earlier, you’ll see a lot of the same code. Here’s what’s changed:
- The title moves to a parameter to the
alert(_:isPresented:presenting:actions:message:)modifier. You pass the new boolean to theisPresentedparameter to indicate when SwiftUI should show the view. The newpresentingparameter provides the function that binding to a nullable object previously did. You pass in thecheckInFlightproperty here to make it available inside the alert asflight. - You provide each option for the alert as a standard SwiftUI button view. You can use
flightto access the object passed in through thepresentingparameter. - You mark this button as
destructive, letting SwiftUI format it appropriately. - As earlier, if you do not provide a button with the
cancelrole, SwiftUI will add one for you. Notice that you do not need to setshowCheckIntofalseas the framework assumes this from the button role. - The message for the action sheet before now becomes another parameter. The object passed to the
presentingparameter is available inside the closure as with the alert.
Run the app, and you’ll see the new alert doing the same role as the action sheet, though with a different user interface. Which to use will likely depend on the purpose of the view in your app. Notice that adding a third button causes this new button layout. You can combine TextField and SecureField views with multiple buttons.
As of SwiftUI 3.0, SwiftUI has not deprecated the actionSheet modifier as with the older Alert struct, but as you can see, the new alert API can handle both roles. In keeping with the new focus on purpose, SwiftUI 3.0 also added another new modifier named confirmationDialog. It works almost exactly like the alert dialog you just implemented. You can replace alert with confirmationDialog in the code you did in this section, and it will work with no other changes.
Replace the alert modifier with confirmationDialog and run the app.
You’ll notice the result looks much like the original action sheet. The confirmationDialog displays as an action sheet on smaller devices and a popover on larger devices. You can also specify a popover directly in SwiftUI. In the next section, you’ll add a popover to the app.
Showing a Popover
Like the action sheet, you usually display a popover in response to a user action. Popovers work best on larger-screen devices, such as iPads and Macs. On devices with smaller screens, a full-screen view, such as a modal sheet, better serves your needs. If the screen is too tiny, SwiftUI renders the popover as a modal sheet instead.
Your popover should save state changes immediately when it displays because the user can dismiss it at any time.
Creating and using a popover works much like an alert and action sheet. You can use a Boolean or optional type as with the other modal views. You’ll use a Bool state variable for this example, as you did with the alert.
You’ll add a button that shows a popover with a new FlightTimeHistory view that shows the flight’s recent history in a list.
Start by opening FlightSearchDetails.swift and adding the code for a new state variable after the existing ones:
@State private var showFlightHistory = false
Now, add the following code after the conditional wrapping the alert you added in the last section and before the FlightInfoPanel view:
Button("On-Time History") {
showFlightHistory.toggle()
}
.popover(
isPresented: $showFlightHistory,
arrowEdge: .top) {
FlightTimeHistory(flight: flight)
}
Again the code resembles that used to add an alert to the view earlier. Alerts, action sheets and popovers all perform the same task — providing a temporary view to inform the user and, optionally, gather a response. As a result, they operate in similar ways. popover(isPresented:attachmentAnchor:arrowEdge:content:) watches the showFlightHistory state variable to see if it should show the pop-up.
Popovers traditionally show an arrow pointing back to the control that initiated the popover. arrowEdge defines the arrow’s direction. Here, .top instructs the popover sheet to display an arrow at its top, pointing to the control. That means the popover shows below the control.
Otherwise, this code should look familiar. The button toggles showFlightHistory to true, causing the popover to appear.
If you’re using an iPhone device or simulator, you’ll see that the popover renders as a modal due to the screen size. Also, note how the new modal nicely stacks on top of your existing modal. You can dismiss it by swiping down, as you would with a modal view.
Now, build and run with an iPad target and follow the same steps to display the on-time history. You’ll now see the view render as a pop-up that includes a small arrow back to the button you tapped to display the view. You can dismiss it by tapping anywhere outside the view. Note that it also is stacked nicely on top of the existing modal view.
Note: The beta for iOS 16.4 introduced a new
.presentationCompactAdaptation(_:)modifier which tells SwiftUI to show a popover on iPhone as well. As with most SwiftUI model views, you apply it to the view being shown.
As you can see, Apple provides you with different options to grab the users’ attention. Try using the best choice for each situation and scenario.
Key Points
- Modal sheets display on top of the view. You can use either a
Boolstate variable or an optional state variable that implements theIdentifiableprotocol to tell SwiftUI to display them. - The alert, action sheet and popover views provide a standard way to display information to the user and collect feedback.
- Alerts generally display information about unexpected situations or confirm actions that have severe consequences.
- Action sheets and popovers display in response to a user action. You use action sheets for smaller screen devices and popovers on larger screens.
- SwiftUI 3.0 introduced a new API for alerts that provides more flexibility and an easier to understand implementation.
- SwiftUI 4.0 introduced the ability to set and constrain the size of sheets other than full screen.
- SwiftUI 4.0 allows you to prompt the user for text input when showing an alert.
Where to Go From Here?
As mentioned in a previous chapter, the first stop for information on user interfaces on Apple platforms should be the Human Interface Guidelines on Modality for the appropriate SwiftUI operating systems:
- iOS: https://developer.apple.com/design/human-interface-guidelines/ios/app-architecture/modality/
- macOS: https://developer.apple.com/design/human-interface-guidelines/macos/app-architecture/modality/
- watchOS: https://developer.apple.com/design/human-interface-guidelines/watchos/app-architecture/modal-sheets/
The WWDC 2019 SwiftUI Essentials video also provides an overview of Apple’s guidelines on how views, navigation and lists fit together: