Stupid SwiftUI Tricks
It’s been a while since I wrote one of these, but I put a bit of code together recently, and felt like writing it up.
SwiftUI makes it easy to pop up alerts using .alert() modifiers, but the way they work often doesn’t suit the ways that I need to use them. You typically end up needing four different components in four separate places to make it all happen:
A unique piece of @State to track whether the alert is shown or not,
A piece of code to set that flag when you want it shown,
The .alert() modifier itself, dangling off some arbitrary bit of the view hierarchy,
And, in most cases, the code that picks up after the alert, in response to the user clicking one of its buttons.
(That last one doesn’t apply to basic messages with no response required from the user, but I rarely find myself needing those, outside of error messages — and I already have a centralised system in place for reporting error messages, taking care of things like making it easy for users to contact Customer Support with the error details attached, and detecting and ignoring CancellationError.)
It’s not a huge amount of work, but it starts to clutter up the place, and it gets particularly annoying if the alert comes in the middle of some processing that you want to continue with afterwards. Confirming you want to overwrite some data, for example. Or, if the user imports some stereo audio into a mono track, asking them if they want to convert the track to stereo, or the imported audio to mono. You need to set aside all the state for the interrupted task — source data, destination URLs and more — to pick up again afterward, and clear it out when the task is complete.
Wouldn’t it be nice if you could instead just write something like this?
try await alertBox("Overwrite Item?",
message: "An item named “\(name)” already exists at that location. Do you want to overwrite it?",
confirm: "Overwrite", isDestructive: true)
That would pop up the alert, get the user’s response, and either return normally if they pick Overwrite, or throw a CancellationError() if they cancel (which would then get handled by the same cleanup code that handles any other error).
I mean, that’s the whole point of async-await, right? To let us do asynchronous tasks in the middle of other pieces of code, continue without having to explicitly manage continuation state, and allow us to centralise error handling, cancellation, and cleanup code?
We often think about that in terms of long-running tasks being performed in the background, but it can apply just as much to user interactions, where the app is only waiting for a response.
Well, something I like about SwiftUI is that, when there are gaps in the API, it’s often very easy to plug them ourselves, in ways that “feel built-in”, because the tools used by Apple to build SwiftUI are, in many cases, available for us to take advantage of too.
The way I’ve approached this example, there are two parts, the AlertBox (which apps interact with to request alerts) and AlertBoxPresentation (which, as you might guess, actually presents the alert).
The AlertBox struct is tiny, with a single variable, and it looks something like this:
struct AlertBox
{
struct Configuration
{
var title: Text
var message: Text
var actions: ()->AnyView
}
@Binding fileprivate var configuration: Configuration?
func callAsFunction<V: View>(_ title: Text, message: Text, @ViewBuilder actions: @escaping () -> V)
{
configuration = .init(title, message: message, actions: { AnyView(actions()) })
}
}
This is the lowest-level function, letting you fully control the alert’s buttons and their responses. Since SwiftUI Buttons have their own callbacks, it doesn’t even use async-await, but AlertBox has extensions providing various convenience methods which do. For example, here’s the one I showed at the top of the post:
extension AlertBox
{
func callAsFunction(_ title: LocalizedStringKey,
message: LocalizedStringKey,
confirm: LocalizedStringKey,
cancel: LocalizedStringKey = "Cancel",
isDestructive: Bool = false) async throws
{
try await withCheckedThrowingContinuation
{
promise in
callAsFunction(Text(title), message: Text(message))
{
Button(Text(confirm),
role: isDestructive ? .destructive : nil,
action: { promise.resume() })
.keyboardShortcut(isDestructive ? nil : .defaultAction)
Button(Text(cancel),
role: .cancel,
action: { promise.resume(throwing: CancellationError()) })
.keyboardShortcut(.cancelAction)
}
}
}
Naming these functions callAsFunction does what it says on the tin: lets us call the AlertBox struct itself as though it were a function. We can then inject the struct into the SwiftUI environment for views to pick up later — this is the same trick Apple use to put actions like dismissing sheets (DismissAction) or renaming documents (RenameAction) into the Environment:
// Inside a View somewhere...
@Environment(\.alertBox) private var alertBox
// Then...
try await alertBox("Title", message: "Message", confirm: "OK")
Now, one criticism you might have is that this encourages defining alerts in various places scattered around the codebase, often in the middle of processing code. This is a problem if alerts could be duplicated in many places, such as asking to overwrite a file (that might even be the motivation behind SwiftUI’s built-in approach, to try and encourage defining alerts centrally).
However, since AlertBox is a nice simple concrete struct, you can extend it with your own commonly-used alerts, to make your own central repository of them — ensuring consistent messaging, and without scattering cluttery state all over the place:
// In the real world, you'd likely want some File Coordination in here.
// And besides, system file pickers already ask for user confirmation.
// This is just an example!
extension AlertBox
{
func callAsFunction(askToOverwrite url: URL) async throws
{
if FileManager.default.fileExists(atPath: url.path(percentEncoded: false))
{
try await self("Overwrite item?",
message: "An item named “\(url.lastPathComponent)” already exists at that location. Do you want to overwrite it?",
confirm: "Overwrite", isDestructive: true)
}
}
}
// ...elsewhere...
try await alertBox(askToOverwrite: target)
You can probably think of others. For example, I have a generic one that takes a Pickable Enum Type1 and returns the picked value, and more app-specific ones to handle things like warning about permissions, or converting file formats.
But how does this stuff actually end up on screen? Well, you’ll notice that AlertBox‘s configuration is a Binding. And, we mentioned that AlertBox was injected into the SwiftUI Environment, but didn’t explain how.
AlertBoxPresentation handles this: it’s a view modifier that’s responsible for applying the .alert() modifier to handle actually showing the alert — but instead of hardcoding a specific alert, sets it up with configurable parameters. A ViewModifier can have its own state, which it uses to store that configuration, and whether or not the alert should be displayed. That configuration is passed as a Binding when creating the AlertBox instance that gets set in the Environment:
struct AlertBoxPresentation: ViewModifier
{
@State private var configuration: AlertBox.Configuration?
func body(content: Content) -> some View
{
content
.alert(configuration?.title ?? Text(""),
isPresented: showAlert,
actions: { configuration?.actions() },
message: { configuration?.message })
.environment(\.alertBox, AlertBox(configuration: $configuration))
}
private var showAlert: Binding<Bool>
{
Binding { configuration != nil }
set: { _ in configuration = nil }
}
}
And somewhere at the base of your view hierarchy, you call .alertBoxPresentation(), and… that’s it for setup:
NavigationStack // Or whatever
{
// ... your views go here ...
}
.alertBoxPresentation()
A fun thing to notice about all this is that AlertBox doesn’t know anything about the .alertBoxPresentation() modifier and how it’s implemented. It just stores a couple of bits of Text and some standard SwiftUI Buttons.
So if you want to, you can write different implementations of the presentation modifier, and as long as they all drop an AlertBox struct into a view’s environment, any alert boxes in that view hierarchy will seamlessly adopt the alternative implementation.
Maybe you’re making a game, and want custom themed alert boxes that fit in with the game’s visual style. Or maybe you just want to switch to using action sheets instead of alerts when run on iPhone, to make the controls more reachable given the size of modern iPhones. Either way, write your presentation modifier, and it Just Works™.
This isn’t so very different from how built-in SwiftUI types like ButtonStyle or ToggleStyle work to provide default behaviour or appearance based on context (e.g. different default button appearances in toolbars vs sidebars, or a toggle appearing as a checkbox vs a switch) while still allowing customisation where you need it.
Complete source for a simple implementation of AlertBox and AlertBoxPresentation can be found in this gist on GitHub.
The PickableEnum I use in my code has been updated in the many years since I wrote that article, to make use of features that have been added to the language, like CaseIterable. ↫