Confirmation Dialogs in SwiftUI

Replicating Apple's Reminders app

November 26, 2021 - 9 min read

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:

Make It So and Apple's Reminders app side by side

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 structs to hold our data, this is a lot easier than you might think: structs are value types, which means we can use a simple equality check to test if two reminders are the same:

let a = Reminder(id: "build", title: "Build sample app")
let b = Reminder(id: "tweet", title: "Tweet about surprising findings", flagged: true)
let c = Reminder(id: "build", title: "Build sample app")
    
print(a == b) // false
print(a == c) // true

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:

class ReminderDetailsViewModel: ObservableObject {
  @Published var reminder: Reminder
  private var original: Reminder
  
  init(reminder: Reminder) {
    self.reminder = reminder
    original = reminder
  }
  
  var isModified: Bool {
    original != reminder
  }
}

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:

struct ReminderDetailsView: View {
  @Environment(\.dismiss) private var dismiss
  @ObservedObject private var viewModel: ReminderDetailsViewModel

  @State private var presentingConfirmationDialog: Bool = false

  // ...
  
  var body: some View {
    NavigationView {
      Form {
        Section {
          TextField("Title", text: $viewModel.reminder.title)
        }
        // ...
      }
      // ...
      .confirmationDialog("", isPresented: $presentingConfirmationDialog) {
        Button("Discard Changes", role: .destructive, action: { dismiss() })
        Button("Cancel", role: .cancel, action: { })
      }
    }
  }
}

Here, we add two simple Buttons 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 the dismiss 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:

struct ReminderDetailsView: View {
  @Environment(\.dismiss) private var dismiss
  @ObservedObject private var viewModel: ReminderDetailsViewModel

  @State private var presentingConfirmationDialog: Bool = false

  // ...
  
  var body: some View {
    NavigationView {
      Form {
        Section {
          TextField("Title", text: $viewModel.reminder.title)
        }
        // ...
      }
      .toolbar {
        ToolbarItem(placement: .cancellationAction) {
          Button("Cancel", role: .cancel) {
            if viewModel.isModified {
              presentingConfirmationDialog.toggle()
            }
            else {
              dismiss()
            }
          }
        }
        ToolbarItem(placement: .confirmationAction) {
          Button("Done") {
            // TODO: save the modified data
            dismiss()
          }
        }
      }
      .confirmationDialog("", isPresented: $presentingConfirmationDialog) {
        Button("Discard Changes", role: .destructive, action: { dismiss() })
        Button("Cancel", role: .cancel, action: { })
      }
    }
  }
}

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 Buttons 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 a Toolbar or inside a confirmationDialog.
  • You have to learn fewer APIs. No need to learn the syntax for adding buttons to an actionSheet any more - knowing how a plain old Button 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:

struct ReminderDetailsView: View {
  @Environment(\.dismiss) private var dismiss
  @ObservedObject private var viewModel: ReminderDetailsViewModel
  
  private var onCommit: (Reminder) -> Void
  
  @State private var presentingConfirmationDialog: Bool = false
  
  init(reminder: Reminder, onCommit: @escaping (Reminder) -> Void) {
    self.viewModel = ReminderDetailsViewModel(reminder: reminder)
    self.onCommit = onCommit
  }
  
  func doCommit() {
    onCommit(viewModel.reminder)
    dismiss()
  }

  var body: some View {
    NavigationView {
      Form {
        // ...
      }
      // ...
      .toolbar {
        // ...
        ToolbarItem(placement: .confirmationAction) {
          Button("Done", action: doCommit)
        }
      }
      // ...
    }
  }
}

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:

var body: some View {
  NavigationView {
    Form { /* ... */ }
    .navigationTitle("Details")
    .navigationBarTitleDisplayMode(.inline)
    .toolbar { /* ... */ }
    .interactiveDismissDisabled(viewModel.isModified)
    .confirmationDialog("", isPresented: $presentingConfirmationDialog) { /* ... */ }
  }
}

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:

private struct InteractiveDismissableView<T: View>: UIViewControllerRepresentable {
  let view: T
  let isDisabled: Bool
  let onAttemptToDismiss: (() -> Void)?
  
  func makeUIViewController(context: Context) -> UIHostingController<T> {
    UIHostingController(rootView: view)
  }
  
  func updateUIViewController(_ uiViewController: UIHostingController<T>, context: Context) {
    context.coordinator.dismissableView = self
    uiViewController.rootView = view
    uiViewController.parent?.presentationController?.delegate = context.coordinator
  }
  
  func makeCoordinator() -> Coordinator {
    Coordinator(self)
  }
  
  class Coordinator: NSObject, UIAdaptivePresentationControllerDelegate {
    var dismissableView: InteractiveDismissableView
    
    init(_ dismissableView: InteractiveDismissableView) {
      self.dismissableView = dismissableView
    }
    
    func presentationControllerShouldDismiss(_ presentationController: UIPresentationController) -> Bool {
      !dismissableView.isDisabled
    }
    
    func presentationControllerDidAttemptToDismiss(_ presentationController: UIPresentationController) {
      dismissableView.onAttemptToDismiss?()
    }
  }
}

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:

var body: some View {
  NavigationView {
    Form { /* ... */ }
    .navigationTitle("Details")
    .navigationBarTitleDisplayMode(.inline)
    .toolbar { /* ... */ }
    // Option 1: use a closure to handle the attempt to dismiss
    .interactiveDismissDisabled(isModified) {
      presentingConfirmationDialog.toggle()
    }
    // Option 2: bind attempt to dismiss to a boolean state variable that drives the UI
    .interactiveDismissDisabled(isModified, attemptToDismiss: $presentingConfirmationDialog)
    .confirmationDialog("", isPresented: $presentingConfirmationDialog) { /* ... */ }
  }
}

(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:

Text("Hello World")
  .font(.caption2)
  .padding(10)
  .foregroundColor(Color.blue)

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:

@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *)
public protocol ViewModifier {
  /// The type of view representing the body.
  associatedtype Body : View

  /// Gets the current body of the caller.
  ///
  /// `content` is a proxy for the view that will have the modifier
  /// represented by `Self` applied to it.
  @ViewBuilder func body(content: Self.Content) -> Self.Body

  /// The content view type passed to `body()`.
  typealias Content
}

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:

struct ConfirmationDialog: ViewModifier {

  // ...

  func body(content: Content) -> some View {
    NavigationView {
      content
        .toolbar {
          ToolbarItem(placement: .cancellationAction) {
            Button("Cancel", role: .cancel) {
              if isModified {
                presentingConfirmationDialog.toggle()
              }
              else {
                doCancel()
              }
            }
          }
          ToolbarItem(placement: .confirmationAction) {
            Button("Done", action: doCommit)
          }
        }
        .confirmationDialog("", isPresented: $presentingConfirmationDialog) {
          Button("Discard Changes", role: .destructive, action: doCancel)
          Button("Cancel", role: .cancel, action: { })
        }
    }
    .interactiveDismissDisabled(isModified, attemptToDismiss: $presentingConfirmationDialog)
  }
}

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:

struct ConfirmationDialog: ViewModifier {
  @Environment(\.dismiss) private var dismiss
  @State private var presentingConfirmationDialog: Bool = false
  
  var isModified: Bool
  var onCancel: (() -> Void)?
  var onCommit: () -> Void
  
  private func doCancel() {
    onCancel?()
    dismiss()
  }
  
  private func doCommit() {
    onCommit()
    dismiss()
  }
  
  func body(content: Content) -> some View {
    // ...
  }
}

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:

Form {
  Section {
    TextField("Title", text: $viewModel.reminder.title)
  }
  // ...
}
.navigationTitle("Details")
.navigationBarTitleDisplayMode(.inline)
.modifier(ConfirmationDialog(isModified: viewModel.isModified) {
  onCommit(viewModel.reminder)
})

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:

extension View {
  func confirmationDialog(isModified: Bool, onCancel: (() -> Void)? = nil, onCommit: @escaping () -> Void) -> some View {
    self.modifier(ConfirmationDialog(isModified: isModified,  onCancel: onCancel, onCommit: onCommit))
  }
}

This makes calling the view modifier look a lot more pleasant to the eye:

Form {
  Section {
    TextField("Title", text: $viewModel.reminder.title)
  }
  // ...
}
.navigationTitle("Details")
.navigationBarTitleDisplayMode(.inline)
.confirmationDialog(isModified: viewModel.isModified) {
  onCommit(viewModel.reminder)
}

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 🔥

Source Code