Confirmation Dialogs in SwiftUI
Replicating Apple's Reminders app
In our apps, we often need users to confirm an action they initiated, mostly because it’s a destructive operation. In SwiftUI, this requires the interplay of a number of views and state variables: the view representing the screen itself, some sort of confirmation dialog, potentially a toolbar, and usually a Cancel and a Done button, and the state variables that drive the visibility of those views.
In this post, we will explore how to replicate the confirmation dialog from Apple’s Reminders app, and we’ll learn how to turn this in to a reusable solution using SwiftUI’s custom view modifiers. Along the way, we will also look into preventing users from dismissing sheets, and we’ll discover Apple’s API for doing so lacks an important feature.
What we’re going to build
The code in this post is based on Make It So, a replica of Apple’s Reminders app. My goal is to see if it’s possible to replicate Apple’s Reminders app using just pure SwiftUI and Firebase. You can check out the code from this repository (make sure to use the develop
branch) if you want to run the app and follow along.
Here is the final state of the application, alongside the original behaviour of the Reminders app:
As you can see, the app will ask the user’s confirmation only if they made a change to the reminder and then try to leave the edit dialog by tapping Cancel or swiping down.
Now, let’s see how we can implement this!
Detecting Changes
Before we can dive into the implementation of the confirmation dialog, we need to detect if the user has actually changed any data in the edit dialog. Since we’re using struct
s to hold our data, this is a lot easier than you might think: struct
s are value types, which means we can use a simple equality check to test if two reminders are the same:
To make handling data in the edit dialog easier, I’ve created a simple view model that wraps a Reminder
and exposes a property isModified
that performs a simple inequality check to indicate whether the reminder has been edited by the user:
What’s great about this approach: if the user undoes all their changes, both reminder
and original
will be equal, and isModified
will return false
- just as expected.
Requesting the user’s confirmation
Building great user experiences is about removing friction as much as possible, but applying it at the right places.
For example, if a user makes changes a reminder in the edit dialog, we can assume they did so on purpose. Asking them whether they want to save their changes just adds unnecessary friction.
However, if the user taps the Cancel button, it might be worth asking if they want to discard the changes they made - after all, this is a destructive operation, and they will lose the changes. Just imagine they wrote down an important thought in the notes field of the reminder - it would be rather upsetting to lose this data!
This is even more important on mobile UIs, where it is easy to accidentally touch the Cancel button.
With iOS 15, Apple introduced a new view modifier confirmationDialog
to create confirmation dialogs. In the spirit of many of SwiftUI’s APIs, this is a cross-platform way to express what we want to achieve, not how we want to achieve it. The system will take care of using the most appropriate platform-specific UI elements to render the confirmation dialog.
Let’s take a look at how to create a simple confirmation dialog that we can use to ask the user whether they’d like to discard their edits:
Here, we add two simple Button
s to the confirmation dialog:
- The first one is marked as
.destructive
, and when the user taps it, the details dialog will be dismissed (thanks to thedismiss
action we grabbed from the environment). - The second one is marked as
.cancel
, and tapping it will just close the confirmation dialog, so the user can continue editing. iOS will automatically display this button at the bottom of the action sheet.
We want to display the confirmation dialog when the user has modified the reminder and then taps the Cancel button to leave the screen, so let’s add a toolbar with a Cancel and Done button:
When the user taps the Cancel button, we check the isModified state of the view model. If the reminder has been modified by the user, we will set presentingConfirmationDialog
to true
, which will cause the confirmation dialog to appear.
Side Note
Previous versions of SwiftUI required us to use the actionSheet
view modifier to implement confirmation dialogs. When comparing the code for creating a confirmation dialog using the new confirmationDialog
view modifier to the code for creating an action sheet using actionSheet
, it seems like the SwiftUI team has been working on streamlining some of SwiftUI’s APIs. For example, instead of using API-specific inner structs (such as Alert.Button
) and their custom instances like .destructive
, .cancel
, we can now use regular Button
s with their role
attribute set to the respective enum case. Buttons are a cross-cutting concern in SwiftUI, and they can appear in many different shapes and forms. While cleaning up the SwiftUI DSL might be inconvenient for those of us who have a lot of code written in SwiftUI already, this has some major benefits:
- By using the same concept across different concerns, it becomes easier to move things around. For example, it’s now possible to use the same code for creating a
Button
on aToolbar
or inside aconfirmationDialog
. - You have to learn fewer APIs. No need to learn the syntax for adding buttons to an
actionSheet
any more - knowing how a plain oldButton
works is enough. This reduces mental load and makes developers more efficient. - Overall, this will make SwiftUI more scalable and future-proof. To learn more about DSLs and cross-cutting concerns, check out the excellent DSL Engineering book by Markus Voelter (site, PDF).
Committing to the user’s will
Once the user decides whether they want to commit, discard, or cancel, we need to act on their decision.
So far, we’ve already covered the following cases:
- The user wants to continue editing (the confirmation dialog will automatically disappear once the user taps on its Cancel button).
- The user wants to leave the edit dialog and discard any changes they made - we just dismiss the edit dialog.
The only thing left is to save any changes if the user taps the Done button. To implement this in a flexible and reusable way, we add a callback closure (named onCommit
) to the edit dialog’s initialiser:
This closure takes one parameter, reminder
, which will contain the modified data. To send the modified data back to the caller (in our case the list view displaying the user’s reminders), all we need to do is call doCommit
from the Done button on the edit dialog’s toolbar.
Preventing interactive dismissal
With this in place, we can now make sure the user doesn’t accidentally discard the changes they made.
There is one little snag, though: swiping down the sheet which is hosting the edit dialog will dismiss the edit dialog and discard any changes the user made. This is not at all what the user expects, so we need to fix this.
In UIKit, you can implement UIAdaptivePresentationControllerDelegate
to control whether the user can dismiss a sheet. Up to now, there was no pure SwiftUI equivalent. The good news is that Apple introduced the interactiveDismissDisabled
view modifier in iOS 15 - this view modifier allows us to turn of this feature either completely, or based on a boolean.
So to prevent the user from swiping down and involuntarily losing their changes, we can apply interactiveDismissDisabled
and pass in the isModified
attribute of the view model:
This means user will still be able to dismiss the edit dialog by swiping down - but only if they didn’t make any changes to the reminder. If they made any changes, the view model is marked as modified, and swiping down will be disabled.
Confirming interactive dismissal
This works great, and in many cases it might be exactly what you need. Apple’s Reminders app takes it one step further and shows a confirmation dialog when the user tries to dismiss the edit dialog by swiping down. It’s the same the app shows when the user taps on the Cancel button.
Unfortunately, it turns our that implementing this behaviour isn’t possible with the current version of interactiveDismissDisabled
, as there is no way to react to the user’s attempt to dismiss the sheet. In UIKit apps, we can use UIAdaptivePresentationControllerDelegate
to implement this behaviour - this protocol has a method presentationControllerDidAttemptToDismiss
that will be called when the user tries to dismiss the view controller. It is safe to assume that Apple will eventually make this functionality available via the interactiveDismissDisabled
view modifier, but in the meantime, I thought it’d be a fun exercise to implement a drop-in solution that we can use while we wait for Apple to resolve FB9782213 (which I filed to request the addition of this behaviour to the view modifier).
My solution is inspired by a couple of answers to this StackOverflow question and this gist, and I’ve tried to model it like I believe the SwiftUI team at Apple would. The best way to predict the future is to invent it, they say - but Apple still might choose a different API and behaviour.
The core of the solution is a view that conforms to UIViewControllerRrepresentable
, which allows us to respond to presentationControllerShouldDismiss
and presentationControllerDidAttemptToDismiss
:
To make this accessible on any view, I’ve added an extension on View
that contains two overloaded versions of interactiveDismissDisabled
. The first one takes an additional parameter that is a closure. By implementing this closure, you can react to the user attempting to dismiss the sheet. The second overloaded version takes a binding to a Bool
as its second parameter. This makes it even easier to use this method to drive the display state of a confirmation dialog. Here’s how you can use this:
(Obviously, you only need to use one of the two options!)
I’ve extracted this code into a gist, including a simple sample app - feel free to use my implementation in your apps, but please be aware that my solution will (hopefully) be sherlocked in the not-too-distant future, and I will only be maintaining this on a best-effort basis.
A reusable confirmation dialog
As a final step in this post, let’s turn what we’ve got so far into a reusable solution. Being able to easily reuse this solution in other screens of our app will allow us to create a more cohesive experience for our users.
If you use SwiftUI, you have used its built-in view modifiers. In the following snippet, font()
, padding()
, and foregroundColor()
all are view modifiers:
View modifiers are a key reason why building UIs with SwiftUI is such a pleasant experience. If we didn’t have view modifiers, we’d have to use a view’s initialiser parameters to configure the view, which is not a very scalable approach at all.
SwiftUI also allows use to create custom view modifiers ourselves, and maybe you’ve done this before to make your code easier to read.
To implement a custom view modifier , we need to create a struct that conforms to the ViewModifier
protocol. The only requirement of this protocol is the body
method. It has a similar role as a view’s body
method - in fact, the result of both methods is some View
, as both return a view. However, a view modifier’s body
method has a single parameter content
. This parameter contains the view the view modifier is applied to.
Here is the declaration of ViewModifier
(taken from the SwiftUI.swift
file:
Let’s implement a view modifier the confirmation dialog implementation we’ve created so far. It should:
- use a toolbar to display the Done and Cancel buttons
- provide a way for the caller to signal if the user has made any changes to the data on the screen
- display a confirmation dialog if the user tries to cancel the dialog after having modified data on the screen
- prevents interactive dismissal of the sheet if the data has been modified
We’ll start by creating the skeleton for the view modifier and moving all the code we wrote for the toolbar
, confirmationDialog
, and interactiveDismissDisabled
into the body
method of the new modifier:
As you can see, we’re using the content
parameter of the body
function where we previously had the view for the edit screen.
To make the above code work, we need to add some missing bits and pieces:
Just like normal views, view modifiers can access the environment, which allows us to access the dismiss
action, making is easy to dismiss the dialog once the user taps the Done button.
It’s also worth noting that view modifiers can hold state, which is why we are able to use presentingConfirmationDialog
to show / hide the confirmation dialog as needed.
Swift will automatically synthesise an initialiser for us, based on the uninitialised properties isModified
, onCancel
, and onCommit
. It’s important to keep the properties in exactly this order, as this will be their order on the parameter list of the initialiser.
isModified
is a property that the caller can use to indicate if the data shown in the dialog has been modified.
We can now use the view modifier like this:
As this is a bit cumbersome to write, view modifiers usually go along an extension on View
that defines a convenience method that makes applying the view modifier easier:
This makes calling the view modifier look a lot more pleasant to the eye:
Closure
Providing confirmation prompts is important for any changes that might be hard to revert, such as in a complex edit form, or when deleting data.
In this article, I walked you through an implementation of a confirmation dialog for the edit screen in MakeItSo, an application that tries to replicate Apple’s Reminders app as closely as possible. As you saw, we were able to replicate most of the behaviour using SwiftUI’s built-in features, and most of the implementation was straightforward and didn’t require a lot of code.
For example, it was easy to detect whether the user made any changes since our data model makes use of structs: since structs are value objects, we can detect changes by comparing the currently edited todo item to the original one using a simple equality check. And since we set up a view model for the edit screen, we were able to nicely encapsulate this logic inside the view model and this keep the view code clean and tidy.
In iOS 15, Apple has deprecated a few view modifiers, and provided new ones. In the past you might have used actionSheet
, which is now deprecated, and will be superseded by confirmationDialog
. The API has changed in a few subtle ways, making it slightly easier to use. The ability to hide the title is a welcome addition, and also the fact SwiftUI will automatically use the platform specific behaviour.
Leaving an edit dialog should always be a conscious decision, and thanks to the new interactiveDismissDisabled
view modifier, it is now possible to prevent users from leaving a sheet by swiping down if there are some unsaved changes (or any other unfinished business). Unfortunately, it’s not possible to display a confirmation dialog when the user tries to dismiss a dirty edit dialog. I showed you how to implement a fallback solution for this shortcoming, based on UIKit. However, as we can expect Apple to fill this gap in the near future (maybe even with the next beta version), we should only see this as a temporary solution, and I’ve filed a feedback with Apple to address this.
I hope you enjoyed this little detour into implementing a confirmation dialog as much as I did researching this topic and implementing a solution.
Thanks for reading 🔥
Improve your app's UX with SwiftUI's task view modifier
Mastering the art of the pause
SwiftUI Hero Animations with NavigationTransition
Replicating the App Store Hero Animation
Styling SwiftUI Views
How does view styling work?
Previewing Stateful SwiftUI Views
Interactive Previews for your SwiftUI views
Asynchronous programming with SwiftUI and Combine
The Future of Combine and async/await