Modal Dialogs with SwiftUI
A centralized way to display modal dialogs from anywhere inside a SwiftUI app.
A modal dialog is an interaction that interrupts the normal workflow and prevents anything happening except the dialog. The old Alert is a perfect example. When an Alert comes up you can do nothing but engage with the Alert.
SwiftUI has the Alert, but if you want to roll your own modal dialogs you have a take some things into account. You can use a SwiftUI Alert anywhere in your app. When the Alert is activated, it rises about all other content and puts up an input block to the rest of your app. Once the Alert has been addressed, it and the block go away.
And this is a key point: it rises above all other content no matter where in your View hierarchy it has been dispatched. If you want this same behavior in a custom modal dialog, you have to place it at the top of your View hierarchy, generally on the ContentView
or whatever you are using as your main View.
I’ve come up with a way that makes it easier to mange and allows you to invoke your dialog anywhere in your app. The solution lies with two things: ObservableObject
(in the form of a model) and enum
s with associated values.
A little while ago I wrote two blog articles: MultiDatePicker
and custom sheets using .overlay
. I’m going to combine these two in this article to show you what I came up with.
The Problem
First, it is not important that you use MultiDataPicker
. I am using it here because its a control that takes a binding element and could work well as a modal dialog. But I could also use a TextField
just as well. The custom sheets article basically moves the presentation of the sheet into a ViewModifier
. I will use some of that here.
Secondly, I am creating a single modal dialog, but if you want to have different types (eg, pick a date, enter a phone number, rate a restaurant, etc.), you can easily adapt this example.
The idea is that from anywhere in your app you want to present the user with a modal dialog. The dialog should be above everything else and it should provide an input blocker so the user cannot use the app without addressing the modal dialog. Now I’m not saying using modal dialogs is a good or bad idea. But sometimes you need to get some info from the user at a particular point in time. For example:
You are on a travel site and pick the tab at the bottom for hotels.
The tab shows you a view with a list of hotels. You tap one of the hotels and navigates to a detail view about that hotel.
You pick the “Reservations” button, taking you a step deeper into the app to the reservations view.
Now you are on the Reservations view with a place to set your dates. You have traveled to:
ContentView
->Hotel List View
->Hotel Detail View
->Reservations View
.You now tap the dates field. If the app uses the SwiftUI
DatePicker
you are golden because it already operates as a modal dialog. But the developer of this app wants you to pick a date range (eg, check in, check out) decided to useMultiDatePicker
instead.The
MultiDatePicker
appears floating above all other contents - the Navigation bar and the Tab bar, too, not just the content of the Reservations Screen (which might actually be a sub-area of another screen).
The Solution
The Dialog Model
We begin with the Dialog Model. This is a class
that implements ObservableObject
because we want to use it to set the type of dialog to open.
class DialogModel: NSObject, ObservableObject { @Published var dialogType: DialogType = .none }
You see that it has a single @Published
value for the dialogType
. That is an enum
defined as follows:
public enum DialogType { case none case dateDialog(Binding<ClosedRange<Date>?>) // add more enum cases and associated values for each dialog type. }
What’s interesting about this enum
is that for the dateDialog
member, there is an associated value of ClosedRange<Date>?
wrapped as a Binding
. And it just so happens the MultiDatePicker
has an initializer that also calls for an optional closed date range.
The Dialog Modifier
The next thing we are going to do is create a ViewModifier
to activate the modal dialog based on the dialogType
value of the DialogModel
.
private struct DialogModifier: ViewModifier { // 1. @ObservedObject var dialogModel: DialogModel func body(content: Content) -> some View { content // 2. .overlay( Group { switch dialogModel.dialogType { // 3. case .dateDialog(let dateRange): ZStack { // 4. Color.black.opacity(0.35) .zIndex(0) .onTapGesture { withAnimation { self.dialogModel.dialogType = .none } } // 5. MultiDatePicker(dateRange: dateRange) .zIndex(1) } .edgesIgnoringSafeArea(.all) default: EmptyView() } } ) } }
Some important bits about this DialogModifier
:
This
ViewModifier
needs to use theDialogModel
instance, so it is declared as an@ObservedObject
because it implements theObservableObject
protocol and because we want this to react to changes in its@Published
member,dialogType
.An overlay is used to place Views above the
content
. AGroup
is used to select what to put into the overlay: anEmptyView
if no dialog is being displayed or aZStack
with the overlay Views.A
switch
andcase
statement select for the dialog and grabs theBinding
from the enum’s associated value.A simple
Color
is used for the blocker and a tap gesture set to make it possible to dismiss the dialog.The
MultiDatePicker
itself, passing theenum
’s associated value (dateRange
) at initialization.
One thing to note: use static .zIndex
values if using transitions with Views (which I did not use here, but you might want them).
Now let’s make this clean to use in the application with a little View
extension:
extension View { func dialog(_ model: DialogModel) -> some View { self.modifier(DialogModifier(dialogModel: model)) } }
Applying the Dialog Modifier
This little extension function makes it a little bit nicer to apply the modifier:
import SwiftUI @main struct PlanWizardApp: App { // 1. @StateObject var dialogModel = DialogModel() var body: some Scene { WindowGroup { ContentView() // 2. .environmentObject(dialogModel) // 3. .dialog(dialogModel) } } }
Declare and instantiate the
DialogModel
. You want this to stick around so use@StateObject
.Pass the
DialogModel
down into the app content views.Apply the
DialogModifier
through the handy extension function, passing along theDialogModel
.
That has set everything up. I told you that the dialogs needed to be at the top level, so it is in the App
definition! When the DialogModel
’s dialogType
enum
is set, the DialogModifier
will take notice and display the appropriate dialog as an overlay on ContentView
.
Showing the Dialog
So how do you actually make this work? Let’s assume you’ve got a Form
someplace and it is from there you want to pop up the MultiDatePicker
and display a date:
// 1. @EnvironmentObject var dialogModel: DialogModel // 2. @State private var dateRange: ClosedRange<Date>? = nil Form { // ... Section(header: Text("Dates")) { HStack { Text("\(dateRange)") // 3. Spacer() Button("Pick Dates") { // 4. self.dialogModel.dialogType = .dateDialog(self.$dateRange) } } } }
Bring in the
DialogModel
for use in this View (see step 4).Declare a
var
to hold the selected date range. In a real app you probably have this as part of some data model.Display the range selected. You will need a little more code here, but the idea is to show that this value has in fact changed.
Here is the key. When the
Pick Dates
button is tapped, theDialogModel
is set with the.dateDialog
value and the associated value for theenum
is set as a binding toself.dateRange
. This is passed into theMultiDatePicker
by theDateModifier
. And because thedialogType
is an@Published
var
of theDialogModel
, SwiftUI will cause theDateModifier
to be executed and theMultiDatePicker
will appear as an overlay ofContentView
inside aZStack
.
Summary
Maybe the ending was a little anticlimactic, but I think it paid off nicely.
Overlays only apply to the View they are attached to. If you want a “dialog” to appear above all other content you have to place the overlay on the topmost View. In this case,
ContentView
. And do so in theApp
definition.Overlays are modifiers, so a good place to encapsulate them is with a custom modifier like
DialogModifier
. It can check a flag to see if the dialog view should be displayed or not. In this case itsdialogType
of theDialogModel
.You need to communicate the desire to see the dialog from anywhere in the app code all the way up to the
app
definition. The best way to do that in SwiftUI is with anObservableObject
shared throughout the app using@EnvironmentObject
(you could also define this as an@Environment
if you prefer).You also need to communicate data from the dialog back to whatever wants to see it (generally to a
@State
var declared in the View that triggered the dialog’s appearance). One way to do that is through anenum
with an associated value that is aBinding
.Combining the
enum
setter with@Published
in theObservableObject
model make a good way to trigger the appearance of the dialog as well as provide a data bridge using aBinding
.
So there you have it. I hope if you need dialogs (or even a centralized location for .sheet
and .alert
modifiers) you will find this solution handy.
Sheets with SwiftUI
An alternative to full screen action sheets using overlays in SwiftUI
If you’ve been using SwiftUI for a while now, you have undoubtedly come across the .sheet
modifier. This takes a View and turns it into a slide-up view. Sheets are a great way to quickly display ancillary information or get some quick input from the user.
The problem with .sheet
is that on the iPhone, it is a full screen affair. If you want to bring up a few details, a full screen sheet may not be what you want; the .sheet
on the iPad is different; it floats up to the middle of the screen and does not take over the entire display.
What I’ve come up with is a different take on the sheet, using the .overlay
modifier. In this article I will show you my HalfSheet
and QuarterSheet
overlays.
The code for this article is available in my GitHub Repository.
Normally, an article like this takes you on a journey, from the inception to building up the code to the final version. I’ve decided to just jump right into it and explain how it works. I’ll begin with how to use my half- and quarter-size sheets. Bear in mind that these are overlays and come with the caveats around overlays. Which are:
Overlays are the width of the view they are overlaying. You can modify that using
.frame
with the size of the screen. I have not done that in this exercise.Overlays only overlay the view they are attached to. If you are expecting the overlay to always be on top, you should use
.overlay
at the highest level (eg,ContentView
).
How to Use The Sheets
The syntax for these sheet overlays is:
.halfSheet(isPresented: Binding<Bool>, content: ()->Content) .quarterSheet(isPresented: Binding<Bool>, content: ()->Content)
You pass a binding to the sheet overlay and supply the content you want to see inside the sheet overlay. For example:
AnyView() .halfSheet(isPresented: self.$showHalfSheet) { SheetContents(title: "1/2 with Modifier") } .quarterSheet(isPresented: self.$showQuarterSheet) { SheetContents(title: "1/4 with Modifier") }
The SheetContents()
view is irrelevant and you can see it in the screen shots. It’s just the content of the sheet.
To show the sheet, the app should change the binding within a withAnimation
block. For example:
Button("Show 1/2 Sheet") { withAnimation { self.showHalfSheet.toggle() } }.padding()
The withAnimation
is necessary to trigger the transitions that are set up on the sheet overlays, which is shown later in this article.
So what are halfSheet
and quarterSheet
exactly? Let’s leave that for a moment and look at the overlay content itself.
The Code
PartialSheet
If you look at the code, you will find PartialSheet
. This is actually the overlay content being shown as the sheet. It is what wraps SheetContents
that you don’t see. Both the quarter and half sheet overlays use this.
struct PartialSheet<Content: View>: View { @Binding var isPresented: Bool var content: Content let height: CGFloat @State private var showingContent = false init(isPresented: Binding<Bool>, heightFactor: CGFloat, @ViewBuilder content: () -> Content) { _isPresented = isPresented height = heightFactor self.content = content() } var body: some View { GeometryReader { reader in ZStack(alignment: .bottom) { BlockingView(isPresented: self.$isPresented, showingContent: self.$showingContent) .zIndex(0) // important to fix the zIndex so that transitions work correctly if showingContent { self.content .zIndex(1) // important to fix the zIndex so that transitins work correctly .frame(width: reader.size.width, height: reader.size.height * self.height) .clipped() .shadow(radius: 10) .transition(.asymmetric(insertion: .move(edge: .bottom), removal: .move(edge: .bottom))) } } } .edgesIgnoringSafeArea(.all) } }
PartialSheet
has three properties: the isPresented
binding, the content
to show inside (eg, SheetContents
from above), and the height
which is the percentage of the height to use.
The content comes in the form of a @ViewBuilder
which is what lets this accept any View to use as the sheet (content
inside).
I’ve used a GeometryReader
to be able to get the dimensions of the area for the overlay which is used in the .frame
and sets the height from the height
value passed in.
I’ve used a ZStack
to layer the components. There is BlockingView
which is just a Color
with a .onTapGesture
to let the user tap in this area to dismiss the overlay; it is the translucent area between the sheet and the main app contents (see the screen shots).
When using transitions with
ZStack
it is important to use fixed.zIndex
values. This tells SwiftUI that these views should be reused and not re-created. If you leave off the.zIndex
, SwiftUI will create new instances when the transitions happen and the transitions will not work as you expect.
Above the BlockingView
is the actual content
with a bunch of modifiers. One of the modifiers is the .frame
to give it its height and a .transition
to handle is appearance and disappearance. The .move
will bring the view onto and off of the screen from the bottom. There is also a .shadow
(and use .clipped
so the shadow does not leak into the content).
The .edgesIgnoringSafeArea
is applied to the outer component (GeometryReader
) so you get a nice effect on the edges of the screen.
BlockingView
The BlockingView
provides a means to shield the main app content from gestures while the overlay sheet is visible. You do not have to use this, but I think its a nice feature and consistent with the presentation of pop-ups and other overlays; it can be detrimental to your app if you allow the user to engage with the content while an overlay is visible.
private struct BlockingView: View { @Binding var isPresented: Bool @Binding var showingContent: Bool // showContent is called when the Color appears and then delays the // appearance of the sheet itself so the two don't appear simultaneously. func showContent() { DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { withAnimation { self.showingContent = true } } } // hides the sheet first, then after a short delay, makes the blocking // view disappear. func hideContent() { withAnimation { self.showingContent = false } DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { withAnimation { self.isPresented = false } } } var body: some View { Color.black.opacity(0.35) .onTapGesture { self.hideContent() } .onAppear { self.showContent() } } }
The BlockingView
is pretty simple: it shows a Color
(I picked black
but white
gives a frosted feel). When the Color
appears it triggers the showContent()
function. An .onTapGesture
captures a tap by the user and calls the hideContent()
function.
The idea here is that you want to dim the view - THEN - show the sheet. When the sheet disappears, you want the sheet to go away BEFORE the Color
fades out. The showContent()
and hideContent()
functions use asyncAfter
to introduce a short delay while these effects run. And, importantly, they both use withAnimation
blocks to change the state. This allows that transition on the content in PartialSheet
view run correctly.
Test Run
You now have enough parts to make use of them, like this:
@State var showSheet = false var body: some View { VStack { // just for something to look at Text("Hello World") Button("Show Sheet") { withAnimation { self.showSheeet.toggle() } } } .overlay( Group { if self.showSheet { PartialSheet(isPresented: self.$showSheet, heightFactor: 0.5) { SheetContents(title: "Trial Run") } } else { EmptyView() } } ) }
The .overlay
uses a Group
so that it shows either the PartialSheet
or an EmptyView
. The button changes the Bool
and the sheet is displayed, SwiftUI running the transition to show it.
Let’s all well and good, but kind of messy. Your app code certainly needs to own and manage the boolean that is used to present the overlay sheet (eg, showSheet
). But the .overlay
and its content is just asking a lot, I think, if you need to use this in several places in your app.
And this is where .halfSheet
and .quarterSheet
come in. These are custom extension functions on View
which makes use of ViewModifier
.
View Extension
If you open the View+Modifiers.swift
file, you will see how halfSheet
and quarterSheet
are defined:
extension View { func halfSheet<Content: View>(isPresented: Binding<Bool>, @ViewBuilder content: () -> Content) -> some View { self.modifier(PartialSheetModifier(isPresented: isPresented, heightFactor: 0.5 sheet: AnyView(content()))) } func quarterSheet<Content: View>(isPresented: Binding<Bool>, @ViewBuilder content: () -> Content) -> some View { self.modifier(PartialSheetModifier(isPresented: isPresented, heightFactor: 0.25 sheet: AnyView(content()))) } }
The halfSheet
function, for example, applies the PartialSheetModifier
(defined below) and passes down the isPresented
binding, a heightFactor
of 0.5
(to make it a half sheet) and the content view. Using this, as shown at the beginning of this article, makes it easier for the developer to toss in a half or quarter sheet in the same vein as the SwiftUI .sheet
modifier.
Notice that
@ViewBuilder
continues to follow through the code. However, when it reaches this point, we want to actually execute the builder and create the View, which is what happens in the call to the sheet initializers -content()
. As you’ll read in a moment, the customViewModifier
is expecting anAnyView
not a builder.
The final piece of this is PartialSheetModifier
:
private struct PartialSheetModifier: ViewModifier { @Binding var isPresented: Bool let heightFactor: CGFloat let sheet: AnyView func body(content: Content) -> some View { content .blur(radius: isPresented ? 4.0 : 0.0) .overlay( Group { if isPresented { PartialSheet(isPresented: self.$isPresented, heightFactor: heightFactor) { sheet } } else { EmptyView() } } ) } }
PartialSheetModifier
is a ViewModifier
which is given the content (the View being modified, like a VStack
) so you can add your own modifiers. Here, the content
is given a blur
effect if the sheet is being presented, and here you see the actual .overlay
finally. As you read above in the trial run, the .overlay
is a Group
with a test that presents the sheet or an EmptyView
.
To sum this up
An
.overlay
is used to show the “sheet” which is whatever content you want in that sheet.@ViewBuilder
is used to make it as flexible as possible to show content.The
.overlay
is placed into a customViewModifier
which itself is placed inside of a View extension function (eg,halfSheet
).The
halfSheet
andquarterSheet
View extension functions usePartialSheet
just to pass in a specific height value (0.5 or 0.25).The
PartialSheet
is aZStack
with aColor
to block the user from interacting with the main app and the actual sheet content.Tap gestures to activate or dismiss the overlay sheet are done within
withAnimation
blocks so a transition can be used to hide and show thePartialSheet
.
I hope you’ve found this useful and can use it or even parts and concepts in your own apps.