Anyone an idea how to create an Alert in SwiftUI that contains a TextField?
You can simply use UIAlertController
directly. No need to roll your own alert dialog UI:
private func alert() {
let alert = UIAlertController(title: "title", message: "message", preferredStyle: .alert)
alert.addTextField() { textField in
textField.placeholder = "Enter some text"
}
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel) { _ in })
showAlert(alert: alert)
}
func showAlert(alert: UIAlertController) {
if let controller = topMostViewController() {
controller.present(alert, animated: true)
}
}
private func keyWindow() -> UIWindow? {
return UIApplication.shared.connectedScenes
.filter {$0.activationState == .foregroundActive}
.compactMap {$0 as? UIWindowScene}
.first?.windows.filter {$0.isKeyWindow}.first
}
private func topMostViewController() -> UIViewController? {
guard let rootController = keyWindow()?.rootViewController else {
return nil
}
return topMostViewController(for: rootController)
}
private func topMostViewController(for controller: UIViewController) -> UIViewController {
if let presentedController = controller.presentedViewController {
return topMostViewController(for: presentedController)
} else if let navigationController = controller as? UINavigationController {
guard let topController = navigationController.topViewController else {
return navigationController
}
return topMostViewController(for: topController)
} else if let tabController = controller as? UITabBarController {
guard let topController = tabController.selectedViewController else {
return tabController
}
return topMostViewController(for: topController)
}
return controller
}
Most of this code is just boilerplate to find the ViewController that should present the alert.
Call alert()
e.g. from the action
of a button:
struct TestView: View {
var body: some View {
Button(action: { alert() }) { Text("click me") }
}
}
Please beware though that there seems to be a bug in beta 5 and onward that can sometimes cause the emulator to freeze once a text field is shown: Xcode 11 beta 5: UI freezes when adding textFields into UIAlertController
func dialog(){
let alertController = UIAlertController(title: "Contry", message: "Write contrt code here", preferredStyle: .alert)
alertController.addTextField { (textField : UITextField!) -> Void in
textField.placeholder = "Country code"
}
let saveAction = UIAlertAction(title: "Save", style: .default, handler: { alert -> Void in
let secondTextField = alertController.textFields![0] as UITextField
print("county code : ",secondTextField)
})
let cancelAction = UIAlertAction(title: "Cancel", style: .default, handler: nil )
alertController.addAction(saveAction)
alertController.addAction(cancelAction)
UIApplication.shared.windows.first?.rootViewController?.present(alertController, animated: true, completion: nil)
}
Usage
Button(action: { self.dialog()})
{
Text("Button")
.foregroundColor(.white).fontWeight(.bold)
}
As the Alert
view provided by SwiftUI
doesn't do the job you will need indeed to use UIAlertController
from UIKit
. Ideally we want a TextFieldAlert
view that we can presented in the same way we would present the Alert
provided by SwiftUI
:
struct MyView: View {
@Binding var alertIsPresented: Bool
@Binding var text: String? // this is updated as the user types in the text field
var body: some View {
Text("My Demo View")
.textFieldAlert(isPresented: $alertIsPresented) { () -> TextFieldAlert in
TextFieldAlert(title: "Alert Title", message: "Alert Message", text: self.$text)
}
}
}
We can achieve this writing a couple of classes and adding a modifier in a View
extension.
1) TextFieldAlertViewController
creates a UIAlertController
(with a text field of course) and presents it when it appears on screen. User changes to the text field are reflected into a Binding<String>
that is passed during initializazion.
class TextFieldAlertViewController: UIViewController {
/// Presents a UIAlertController (alert style) with a UITextField and a `Done` button
/// - Parameters:
/// - title: to be used as title of the UIAlertController
/// - message: to be used as optional message of the UIAlertController
/// - text: binding for the text typed into the UITextField
/// - isPresented: binding to be set to false when the alert is dismissed (`Done` button tapped)
init(title: String, message: String?, text: Binding<String?>, isPresented: Binding<Bool>?) {
self.alertTitle = title
self.message = message
self._text = text
self.isPresented = isPresented
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// MARK: - Dependencies
private let alertTitle: String
private let message: String?
@Binding private var text: String?
private var isPresented: Binding<Bool>?
// MARK: - Private Properties
private var subscription: AnyCancellable?
// MARK: - Lifecycle
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
presentAlertController()
}
private func presentAlertController() {
guard subscription == nil else { return } // present only once
let vc = UIAlertController(title: alertTitle, message: message, preferredStyle: .alert)
// add a textField and create a subscription to update the `text` binding
vc.addTextField { [weak self] textField in
guard let self = self else { return }
self.subscription = NotificationCenter.default
.publisher(for: UITextField.textDidChangeNotification, object: textField)
.map { ($0.object as? UITextField)?.text }
.assign(to: \.text, on: self)
}
// create a `Done` action that updates the `isPresented` binding when tapped
// this is just for Demo only but we should really inject
// an array of buttons (with their title, style and tap handler)
let action = UIAlertAction(title: "Done", style: .default) { [weak self] _ in
self?.isPresented?.wrappedValue = false
}
vc.addAction(action)
present(vc, animated: true, completion: nil)
}
}
2) TextFieldAlert
wraps TextFieldAlertViewController
using the UIViewControllerRepresentable
protocol so that it can be used within SwiftUI.
struct TextFieldAlert {
// MARK: Properties
let title: String
let message: String?
@Binding var text: String?
var isPresented: Binding<Bool>? = nil
// MARK: Modifiers
func dismissable(_ isPresented: Binding<Bool>) -> TextFieldAlert {
TextFieldAlert(title: title, message: message, text: $text, isPresented: isPresented)
}
}
extension TextFieldAlert: UIViewControllerRepresentable {
typealias UIViewControllerType = TextFieldAlertViewController
func makeUIViewController(context: UIViewControllerRepresentableContext<TextFieldAlert>) -> UIViewControllerType {
TextFieldAlertViewController(title: title, message: message, text: $text, isPresented: isPresented)
}
func updateUIViewController(_ uiViewController: UIViewControllerType,
context: UIViewControllerRepresentableContext<TextFieldAlert>) {
// no update needed
}
}
3) TextFieldWrapper
is a simple ZStack
with a TextFieldAlert
on the back (only if isPresented
is true) and a presenting view on the front. The presenting view is the only one visibile.
struct TextFieldWrapper<PresentingView: View>: View {
@Binding var isPresented: Bool
let presentingView: PresentingView
let content: () -> TextFieldAlert
var body: some View {
ZStack {
if (isPresented) { content().dismissable($isPresented) }
presentingView
}
}
}
4) The textFieldAlert
modifier allows us to smoothly wrap any SwiftUI view in a TextFieldWrapper
and obtain the desired behaviour.
extension View {
func textFieldAlert(isPresented: Binding<Bool>,
content: @escaping () -> TextFieldAlert) -> some View {
TextFieldWrapper(isPresented: isPresented,
presentingView: self,
content: content)
}
}
Step1: Make root view as ZStack
Step2: Add variable for show/hide
@State var showAlert = false
Step 3: Add this custom layout inside root view (ZStack)
if $showAlert.wrappedValue {
ZStack() {
Color.grayBackground
VStack {
//your custom layout text fields buttons
}.padding()
}
.frame(width: 300, height: 180,alignment: .center)
.cornerRadius(20).shadow(radius: 20)
}
As already was mentioned Alert
is provide not many functionality and so almost useless in any non-standard cases when using in SwiftUI.
I ended up with a bit extensive solution - View
that may behave as alert with high customisation level.
Create ViewModel
for popUp:
struct UniAlertViewModel {
let backgroundColor: Color = Color.gray.opacity(0.4)
let contentBackgroundColor: Color = Color.white.opacity(0.8)
let contentPadding: CGFloat = 16
let contentCornerRadius: CGFloat = 12
}
we also need to configure buttons, for this purpose let's add one more type:
struct UniAlertButton {
enum Variant {
case destructive
case regular
}
let content: AnyView
let action: () -> Void
let type: Variant
var isDestructive: Bool {
type == .destructive
}
static func destructive<Content: View>(
@ViewBuilder content: @escaping () -> Content
) -> UniAlertButton {
UniAlertButton(
content: content,
action: { /* close */ },
type: .destructive)
}
static func regular<Content: View>(
@ViewBuilder content: @escaping () -> Content,
action: @escaping () -> Void
) -> UniAlertButton {
UniAlertButton(
content: content,
action: action,
type: .regular)
}
private init<Content: View>(
@ViewBuilder content: @escaping () -> Content,
action: @escaping () -> Void,
type: Variant
) {
self.content = AnyView(content())
self.type = type
self.action = action
}
}
add View that can become our customizable popUp:
struct UniAlert<Presenter, Content>: View where Presenter: View, Content: View {
@Binding private (set) var isShowing: Bool
let displayContent: Content
let buttons: [UniAlertButton]
let presentationView: Presenter
let viewModel: UniAlertViewModel
private var requireHorizontalPositioning: Bool {
let maxButtonPositionedHorizontally = 2
return buttons.count > maxButtonPositionedHorizontally
}
var body: some View {
GeometryReader { geometry in
ZStack {
backgroundColor()
VStack {
Spacer()
ZStack {
presentationView.disabled(isShowing)
let expectedWidth = geometry.size.width * 0.7
VStack {
displayContent
buttonsPad(expectedWidth)
}
.padding(viewModel.contentPadding)
.background(viewModel.contentBackgroundColor)
.cornerRadius(viewModel.contentCornerRadius)
.shadow(radius: 1)
.opacity(self.isShowing ? 1 : 0)
.frame(
minWidth: expectedWidth,
maxWidth: expectedWidth
)
}
Spacer()
}
}
}
}
private func backgroundColor() -> some View {
viewModel.backgroundColor
.edgesIgnoringSafeArea(.all)
.opacity(self.isShowing ? 1 : 0)
}
private func buttonsPad(_ expectedWidth: CGFloat) -> some View {
VStack {
if requireHorizontalPositioning {
verticalButtonPad()
} else {
Divider().padding([.leading, .trailing], -viewModel.contentPadding)
horizontalButtonsPadFor(expectedWidth)
}
}
}
private func verticalButtonPad() -> some View {
VStack {
ForEach(0..<buttons.count) {
Divider().padding([.leading, .trailing], -viewModel.contentPadding)
let current = buttons[$0]
Button(action: {
if !current.isDestructive {
current.action()
}
withAnimation {
self.isShowing.toggle()
}
}, label: {
current.content.frame(height: 35)
})
}
}
}
private func horizontalButtonsPadFor(_ expectedWidth: CGFloat) -> some View {
HStack {
let sidesOffset = viewModel.contentPadding * 2
let maxHorizontalWidth = requireHorizontalPositioning ?
expectedWidth - sidesOffset :
expectedWidth / 2 - sidesOffset
Spacer()
if !requireHorizontalPositioning {
ForEach(0..<buttons.count) {
if $0 != 0 {
Divider().frame(height: 44)
}
let current = buttons[$0]
Button(action: {
if !current.isDestructive {
current.action()
}
withAnimation {
self.isShowing.toggle()
}
}, label: {
current.content
})
.frame(maxWidth: maxHorizontalWidth, minHeight: 44)
}
}
Spacer()
}
}
}
to simplify usage let's add extension to View
:
extension View {
func assemblyAlert<Content>(
isShowing: Binding<Bool>,
viewModel: UniAlertViewModel,
@ViewBuilder content: @escaping () -> Content,
actions: [UniAlertButton]
) -> some View where Content: View {
UniAlert(
isShowing: isShowing,
displayContent: content(),
buttons: actions,
presentationView: self,
viewModel: viewModel)
}
}
And usage:
struct ContentView: View {
@State private var isShowingAlert: Bool = false
@State private var text: String = ""
var body: some View {
VStack {
Button(action: {
withAnimation {
isShowingAlert.toggle()
}
}, label: {
Text("Show alert")
})
}
.assemblyAlert(isShowing: $isShowingAlert,
viewModel: UniAlertViewModel(),
content: {
Text("title")
Image(systemName: "phone")
.scaleEffect(3)
.frame(width: 100, height: 100)
TextField("enter text here", text: $text)
Text("description")
}, actions: buttons)
}
}
}
Demo:
Although not exactly the same, if all you're looking for is a native, modal-like view with an edit box, you could use a popover. It works out of the box (minus a sizing bug) without needing to traverse the view hierarchy.