Sheet happens. Working with modal views in SwiftUI
Developing pet projects is one of the best ways to learn new things. Since SwiftUI 1.0 I've been writing an app for saving and reading articles. I rewrote it twice from scratch, added and removed features based on my user experience, used new APIs, etc.
I had a few interesting cases implementing modal views a.k.a. sheets in SwiftUI. The main goal of this article is to summarize my experience and use it as a reference for the future. I simplify code examples to focus on specific logic. Let's start with showing.
Note: Examples are written in Swift 5.3.2 and work on iOS 14.0 with Xcode 12.4 (12D4e).
Showing sheets
There are two options for showing sheets.
The first one is based on Binding<Bool>
value. It can be stored via @State
property wrapper and good for sheets that need no external data:
struct ArticlesView: View {
@State var isSettingsViewPresented = false
var body: some View {
Button("Show Settings") {
isSettingsViewPresented = true
}
.sheet(isPresented: $isSettingsViewPresented) {
SettingsView()
}
}
}
struct SettingsView: View {
var body: some View {
Text("Settings")
}
}
The second one is based on Binding<Item?>
value where the item is an optional Identifiable
object. it's a good choice if we want to pass any data to sheets. For instance, a selected article for reading. Pay attention that the selected article is not optional in sheet content closure:
// A simple struct with article data
struct Article: Identifiable {
let id: UUID
let title: String
}
struct ArticlesView: View {
@State var article: Article?
var body: some View {
Button("Show Article") {
article = .init(id: .init(), title: "Awesome article")
}
.sheet(item: $article) { article in
ArticleDetailsView(article: article)
}
}
}
// A view for article showing
struct ArticleDetailsView: View {
@State var article: Article
var body: some View {
Text(article.title)
}
}
An interesting moments:
- if
id
of the selected article will be changed, the presented sheet will be dismissed and replaced with a new sheet; - if the selected article becomes nil at any moment, the sheet will be dismissed as well.
For both showing options we can optionally add onDismiss
closure that executed when the sheet dismisses:
struct ArticlesView: View {
@State var article: Article?
var body: some View {
Button("Show Article") {
article = .init(id: .init(), title: "Awesome article")
}
.sheet(item: $article, onDismiss: onDismiss) { article in
ArticleDetailsView(article: article)
}
}
private func onDismiss() {
print("dismisses")
}
}
By default, we can dismiss sheets via swipe gestures. But what if we want to dismiss sheets based on another user action or app logic?
Dismissing sheets
There are two options to dismiss sheets too. If we use Binding<Bool>
, we can pass it to a sheet via @Binding
property wrapper and update its value. Bindings allow to change values in parent views:
// in ArticlesView
.sheet(isPresented: $isSettingsViewPresented) {
SettingsView(isSettingsViewPresented: $isSettingsViewPresented)
}
struct SettingsView: View {
@Binding var isSettingsViewPresented: Bool
var body: some View {
ZStack {
Text("Settings")
VStack {
Spacer()
Button("Dismiss") {
isSettingsViewPresented = false
}
}
}
}
}
Another option is more universal and based on presentationMode
environment:
struct SettingsView: View {
@Environment(\.presentationMode) var presentationMode
var body: some View {
ZStack {
Text("Settings")
VStack {
Spacer()
Button("Dismiss") {
presentationMode.wrappedValue.dismiss()
}
}
}
}
}
This environment indicates whether a view is currently presented by another view.
Sheets and ForEach
The app with only one article is a bit useless, so let's create List
and make articles rows via ForEach
:
struct ArticlesView: View {
let articles: [Article] = [.init(id: .init(), title: "Awesome article"),
.init(id: .init(), title: "Another awesome article")]
@State var isArticleDetailsViewPresented: Bool = false
var body: some View {
NavigationView {
List {
ForEach(articles) { article in
Text(article.title)
.onTapGesture {
isArticleDetailsViewPresented = true
}
.sheet(isPresented: $isArticleDetailsViewPresented) {
ArticleDetailsView(article: article)
}
}
}
.navigationTitle("Articles")
}
}
}
Here we use Binding<Bool>
for sheet state and update its value on tap action. Because we have the articles in enumeration, we can pass it to ArticleDetailsView
. Everything looks fine, the app builds successfully. But ArticleDetailsView
presents only with "Awesome article"
title for the first article. Ok, remember this case and try to update sheets logic in the next article section after another example.
Multiple sheets
Previously we used one sheet for the selected article. In my app, I show different views according to the internet connection. If the internet is available, I show SafariView
with the url of the selected article, and WebArchiveView
with saved web archive data.
At first, we update our models:
struct Article: Identifiable {
let id: UUID
let title: String
let url: URL
}
extension URL: Identifiable {
public var id: String {
absoluteString
}
}
extension String: Identifiable {
public var id: String {
self
}
}
URL and String types must conform Identifiable
protocol for using in sheet bindings.
Next, we update ArticlesView with multiple sheets. To simplify the example let's show different views based on Bool
state:
struct ArticlesView: View {
let article: Article = .init(id: .init(),
title: "Sheet happens",
url: URL(string: "https://blog.artemnovichkov.com/editor/sheet-happens")!)
@State var url: URL?
@State var title: String?
@State var isOn = true
var body: some View {
HStack {
Button("Show Article") {
if isOn {
url = article.url
}
else {
title = article.title
}
}
.sheet(item: $url) { url in
SafariView(url: url)
}
.sheet(item: $title) { title in
WebArchiveView(title: title)
}
Toggle("", isOn: $isOn)
}
.padding()
}
}
Finally, we add views with different states and body content:
struct SafariView: View {
@State var url: URL
var body: some View {
Text("Content of " + url.absoluteString)
}
}
struct WebArchiveView: View {
@State var title: String
var body: some View {
Text("Web archive data of \(title) article")
}
}
Here we have another strange behavior โ only WebArchiveView
will be shown. It's a known issue in SwiftUI, but don't give up, let's fix it!
Fixing multiple sheets
We create Sheet
enum with cases for every sheet. It must conform Identifiable
as well:
enum Sheet: Identifiable {
case url(URL)
case webArchive(String)
var id: String {
switch self {
case .url(let url):
return url.id
case .webArchive(let title):
return title.id
}
}
}
In ArticlesView
we replace states with only one with Sheet
value and use one sheet
modifier:
struct ArticlesView: View {
let article: Article = .init(id: .init(),
title: "Sheet happens",
url: URL(string: "https://blog.artemnovichkov.com/editor/sheet-happens")!)
@State var sheet: Sheet?
@State var isOn = true
var body: some View {
HStack {
Button("Show Article") {
if isOn {
sheet = .url(article.url)
}
else {
sheet = .webArchive(article.title)
}
}
.sheet(item: $sheet) { sheet in
switch sheet {
case .url(let url):
SafariView(url: url)
case .webArchive(let title):
WebArchiveView(title: title)
}
}
Toggle("", isOn: $isOn)
}
.padding()
}
}
Now, with sheet state, both views will be presented ๐
If we want to reuse these sheets in another view, we can conform View
protocol and return related views in body:
enum Sheet: View, Identifiable {
case url(URL)
case webArchive(String)
var id: String {
switch self {
case .url(let url):
return url.id
case .webArchive(let title):
return title.id
}
}
var body: some View {
switch self {
case .url(let url):
SafariView(url: url)
case .webArchive(let title):
WebArchiveView(title: title)
}
}
}
// in ArticlesView
.sheet(item: $sheet) { $0 }
The final code for the example is available here.
Updates for iOS & iPadOS 14.5 Beta 3
According to release notes, we can now apply multiple sheet(isPresented:onDismiss:content:)
and fullScreenCover(item:onDismiss:content:)
modifiers in the same view hierarchy. (74246633). But if we need to support previous versions (oh, come on, of course we need ๐), we can use the way described above.
Conclusion
As we can see, SwiftUI has a simple and declarative way for showing sheets with bindings magic. But in real apps with different layouts we must use it wisely and know edge cases of its behavior. Feel free to ping on Twitter, ask your questions related to this article, highlight grammar mistakes , or share your cases. Thanks for reading!
Good blog. I completely forgot about .sheet(item:) O_o
One thing Iโve learned though, is that sheets can behave differently between iOS 13 and 14. Really gotta test that one. I have a workaround in my code for iOS 14, where I had to first move two sheets out of a .navigationBarItems() like is demonstrated here:
https://stackoverflow.com/questions/64385259/swiftui-sheets-bolded/64392424#64392424
I decided to hang them on an EmptyView() which works in iOS 14, but not in iOS 13, so the final result was the following atrocity against mankind:
Group {
Text("").hidden()
.sheet(isPresented: self.$isShowing1) {
Sheet1()
.environmentObject(self.viewModel1)
}
Text("").hidden()
.sheet(isPresented: self.$isShowing2) {
Sheet2()
}
}
https://19216811.support/