Improving SwiftUI modal presentation API
SwiftUI has some presentation modifiers to display alerts, sheets and full screen views. On one hand, presenting an alert is nice and easy, it feels really well designed. But on the other hand presenting a modal screen (or action sheets or popovers) is too cumbersome.
What makes presenting an alert so nice is the fact that the API uses a binding.
.presentation($showAlert, alert: { Alert(title: Text("Alert!")) })
// ----------
// ^ this is a binding to a boolean
With this simple line the framework takes care of presenting the alert when the flag showAlert
is true
, and setting it to false
when the alert is dismissed. You don’t have to manage the presentation or maintain the state, it’s all taken care for you.
This makes it really easy to present alerts. Usually you just need to keep a State
property around and set it to true
when appropriate.
@State var showAlert = false
...
VStack {
Button(action: { self.showAlert.toggle() }) {
Text("Show alert!")
}
}
.presentation($showAlert, alert: { Alert(title: Text("Alert!")) })
Modal presentation
Let’s take a closer look at the modifier to present a modal:
.presentation(Modal?)
The first thing that you notice is how this method doesn’t accept a binding to a boolean. Instead, to decide when to show the modal, it uses an optional. If you pass nil, the modal is not presented; if you pass a Modal
the view is presented.
This seemingly small difference causes a big impact in the way we use it.
First of all, we still need to know when to present it or not, for the simple case let’s use a boolean like we did for the alert. But now instead of just passing it as a binding, we need to perform the check ourselves.
.presentation(showModal ? Modal(Text("Modal screen")) : nil)
This is not that bad, not as nice as passing a binding but not the worse.
@State var showModal = false
...
VStack {
Button(action: { self.showModal.toggle() }) {
Text("Show modal!")
}
}
.presentation(showModal ? Modal(Text("Modal screen")) : nil)
But there is a big problem in this code. When the modal is dismissed the flag it’s still set to true
, and thus any change that triggers the UI to be re-rendered may cause the modal to appear again. Or more obviously, when the button is pressed again the modal won’t show up, because toggle will change the flag from true
to false
. ☹️
Our State and UI are out of sync!
This API is causing the very same problem that SwiftUI promised to fix.
To implement a proper modal presentation we need to make sure that the flag is changed properly. Maybe your modal screen is so custom that you want to change the flag from the modally presented view, but for the majority of cases it would be enough to change the flag when the screen is dismissed. We can use the onDismiss
closure from the Modal
view for that:
.presentation(showModal ? Modal(Text("Modal screen"), onDismiss: {
self.showModal.toggle()
}) : nil)
Now our UI and State are in sync again. 😃
Even if this is problematic and cumbersome it makes sense from an API design point of view. An alert has a fix UI that the framework controls, so it can know when to change the state. Instead, a modal is a completely custom piece of UI and the framework can’t know when to change our state.
That said, I think we could have a simpler API for the typical use case.
An improved modal API
The default modal presentation doesn’t prove any visual way for the user to close the modal, but as of iOS 13 the user can swipe down the view to make it disappear.
This is another reason why it would make sense for the modal API to take care of changing the flag for us.
This interaction is really nice, but 90% of the time you will want to add some affordance for the user to know how to close the modal. And usually this is just done by a Done button.
Assuming this is the UI we want, is quite simple to build an improved modal API that makes our code look like this:
VStack {
Button(action: { self.showModal.toggle() }) {
Text("Show modal!")
}
}
.presentation($showModal, title: Text("Example modal"), modal: { Text("Modal screen!") })
Now we’re talking! This looks much better, similar to how the Alert
API looks like. 🎉
With this new method we just need to pass a binding to the boolean, like the Alert did.
We can also pass a title for the modal screen, which is gonna be used to create a NavigationView
with a Done
button that will help the user close the screen.
Finally you pass the View that you want to present modally. The method takes care of wrapping that view inside a Modal
and changing the status of the flag when appropriate.
The method looks like this:
extension View {
func presentation<T: View>(_ isShown: Binding<Bool>, title: Text, modal: () -> T) -> some View {
let view = NavigationView {
modal()
.navigationBarTitle(title)
.navigationBarItems(trailing: Button(action: {
isShown.value.toggle()
}, label: { Text("Done") } ))
}
return presentation(isShown.value ?
Modal(
view,
onDismiss: {
isShown.value.toggle()
}
)
: nil
)
}
}
This may not make sense as part of the framework, but there is no doubt that it’s a vital method to keep around on your projects.
But don’t take it as is! Tweak it so it makes sense for your app: maybe say Close instead of Done, or add an option to add a Cancel button. The possibilities are endless!
Conclusion
What I want you to take from this post, apart from a nice presentation helper method, is not that SwiftUI presentation API is not ideal. Quite the opposite!
I want you to appreciate how thanks to the design of the framework (based on value types, protocols and function modifiers) it’s really easy for us to learn from its API and extend it to accommodate for our needs.
Check out the rest of SwiftUI posts.