SwiftUI: Showing an Alert with a Text Field
In Swift Talk Episode 198, we made a wrapper around UIKit alerts.
SwiftUI provides a built-in API just for this: the alert method on View
allows us to present an alert to the user, and under the hood it almost certainly uses a UIAlertController
.
For our purposes, we wanted an alert controller with a text field. UIAlertController
supports this, but so far, SwiftUI's built-in Alert
struct does not. To route around this limitation, we've found a hack that seems to work — for us at least! (we haven't tested this in a project other than our own).
We'll start by defining an API similar to SwiftUI's builtin alert API. It will have the usual properties, and an action
parameter that gets called whenever the user presses either the OK or Cancel button.
public struct TextAlert {
public var title: String
public var placeholder: String = ""
public var accept: String = "OK"
public var cancel: String = "Cancel"
public var action: (String?) -> ()
}
extension View {
public func alert(isPresented: Binding<Bool>, _ alert: TextAlert) -> some View {
// ...
}
}
Now for the hacky part. When we call alert
on a view, we wrap that view inside a custom UIViewControllerRepresentable
. Inside, we create a UIHostingController
for the view, so that we can call .present
on it.
struct AlertWrapper<Content: View>: UIViewControllerRepresentable {
@Binding var isPresented: Bool
let alert: TextAlert
let content: Content
func makeUIViewController(context: UIViewControllerRepresentableContext<AlertWrapper>) -> UIHostingController<Content> {
UIHostingController(rootView: content)
}
// ...
}
To store the current alert controller we need to create a coordinator:
struct AlertWrapper<Content: View>: UIViewControllerRepresentable {
// ...
final class Coordinator {
var alertController: UIAlertController?
init(_ controller: UIAlertController? = nil) {
self.alertController = controller
}
}
func makeCoordinator() -> Coordinator {
return Coordinator()
}
}
Finally, we need to show and hide the alert whenever our binding changes. SwiftUI will automatically observe the binding and call updateUIViewController(_:context:)
each time that happens. Inside, there are two code paths: when the binding's value is true
but we're not yet presenting the alert controller, we need to present it; when the binding's value is false
but we are presenting the alert controller, we need to dismiss it.
func updateUIViewController(_ uiViewController: UIHostingController<Content>, context: UIViewControllerRepresentableContext<AlertWrapper>) {
uiViewController.rootView = content
if isPresented && uiViewController.presentedViewController == nil {
var alert = self.alert
alert.action = {
self.isPresented = false
self.alert.action($0)
}
context.coordinator.alertController = UIAlertController(alert: alert)
uiViewController.present(context.coordinator.alertController!, animated: true)
}
if !isPresented && uiViewController.presentedViewController == context.coordinator.alertController {
uiViewController.dismiss(animated: true)
}
}
Note that we're setting the hosting controller's root view in the first line of the updateViewController
method. This method will be called if our isPresented
binding for the alert changes, but also when something changes that affects the content of the alert wrapper. If we omit this first line, SwiftUI will no longer update the view inside the hosting controller.
Now we can use our text alert:
struct ContentView: View {
@State var showsAlert = false
var body: some View {
VStack {
Text("Hello, World!")
Button("alert") {
self.showsAlert = true
}
}
.alert(isPresented: $showsAlert, TextAlert(title: "Title", action: {
print("Callback \($0 ?? " ")")
}))
}
}
We hope that SwiftUI will catch up with UIAlertController
, making these kinds of hacks unnecessary. In the meantime, if anyone knows of a simpler way to do this, do let us know and we'll update this post!
Here's the full code.
Our SwiftUI Collection has 33 episodes and growing, with 8 public episodes. In our latest series, we port App Architecture's sample app from MVC to SwiftUI — the first episode is free to watch.