Why Do We Need a Reusable Alert View?
When building an app, we often display alerts in different places like this:
VStack {
Button(action: {
viewModel.showAlert1()
}) {
Text("Show Alert1")
}
.alert("Alert1", isPresented: $isShowingAlert1) {
Button(role: .cancel, action: {
print("do something")
viewModel.isShowingAlert1 = false
}) {
Text("OK")
}
} message: {
Text("Alert1 message")
}
Button(action: {
viewModel.showAlert2()
}) {
Text("Show Alert2")
}
.alert("Alert2", isPresented: $isShowingAlert2) {
Button(role: .cancel, action: {
print("do something")
viewModel.isShowingAlert2 = false
}) {
Text("Action1")
}
Button(action: {
print("do something")
viewModel.isShowingAlert2 = false
}) {
Text("Action2")
}
} message: {
Text("Alert2 message")
}
Button(action: {
viewModel.showAlert3()
}) {
Text("Show Alert3")
}
.alert("Alert3", isPresented: $isShowingAlert3) {
Button(role: .cancel, action: {
print("do something")
viewModel.isShowingAlert3 = false
}) {
Text("Action1")
}
Button(action: {
print("do something")
viewModel.isShowingAlert3 = false
}) {
Text("Action2")
}
Button(action: {
print("do something")
viewModel.isShowingAlert3 = false
}) {
Text("Action3")
}
} message: {
Text("Alert3 message")
}
}
As you can see, each alert follows the same structure: a title, a message, and buttons. Instead of duplicating similar code, letβs create a reusable alert system to keep our code clean and maintainable.
Please feel free to leave a comment. I'm happy to discuss my code to improve! π
How to Build the Reusable Alert View
1. Defining the Alert Model
First, we define the structure of an alert and its buttons.
import SwiftUI
struct AlertDetails: Equatable { // 3
let title: String
let message: String
let buttons: [AlertButton] // 1
}
struct AlertButton: Identifiable, Equatable { // 2, 3
let id = UUID().uuidString // 2
let title: String
let role: ButtonRole?
let action: (() -> Void)?
let accessibilityIdentifier: String // 4
init(title: String, role: ButtonRole? = nil, action: (() -> Void)? = nil, accessibilityIdentifier: String = "") {
self.title = title
self.role = role
self.action = action
self.accessibilityIdentifier = accessibilityIdentifier
}
// 3
static func == (lhs: AlertButton, rhs: AlertButton) -> Bool {
lhs.title == rhs.title && lhs.role == rhs.role
}
}
-
[AlertButton]
: An array ofAlertButton
to make this alert more customizable. -
Identifiable
: Required for using ForEach in SwiftUI. Using UUID string here to conform to the protocol. -
Equatable
: Put Equtable protocol toAlertDetails
andAlertButton
. This makes unit testing easier by allowing assertions. The action is not included in equality checks since itβs a closure. AlertDetails structure doesn't need to have==
static method because String protocol whichtitle
andmessage
already conforms to Equtable protocol and we let AlertButton conform to Equatable protocol. -
accessibilityIdentifier
: Useful for UI testing to identify a UI element.
2. Creating a ViewModifier for Alerts
We create a custom ViewModifier to handle alert presentation.
struct AlertView: ViewModifier {
@Binding var isShowing: Bool
let details: AlertDetails
func body(content: Content) -> some View {
content
.alert(details.title, isPresented: $isShowing) {
ForEach(details.buttons) { button in // 1
Button(role: button.role, action: {
button.action?()
isShowing = false // 2
}) {
Text(button.title)
}
.accessibilityIdentifier(button.accessibilityIdentifier)
}
} message: {
Text(details.message)
}
}
}
- Uses ForEach to dynamically generate alert buttons.
- Calls action when a button is tapped. Dismisses the alert after a button tap since we usually expect the alert view will be dimissed after a button is tapped.
3. Extending View for Simplicity
To simplify usage, we add an extension to View.
extension View {
func showAlert(isShowing: Binding<Bool>, details: AlertDetails) -> some View {
self.modifier(AlertView(isShowing: isShowing, details: details))
}
}
Instead of:
.modifier(AlertView(isShowing: isShowing, details: details))
We can now write:
.showAlert(isShowing: isShowing, details: details)
Of course, we can do the same for other custom modifiers.
4. Creating a Protocol for Alert Management
This is not necessary implementation but defining a protocol helps maintain consistency across different ViewModels.
protocol AlertPresentable {
var isShowingAlert: Bool { get set }
var alertDetails: AlertDetails { get }
}
View Model class conforming to this protocol can use this like this:
final class ContentViewModel: ObservableObject, AlertPresentable {
// AlertPresentable
@Published var isShowingAlert: Bool = false
@Published var alertDetails = AlertDetails(title: "", message: "", buttons: [AlertButton(title: "")])
}
π Let's try using this common alert view!
We've done with all implementations! Let's use this and see how it looks now.
In a view model class:
final class ContentViewModel: ObservableObject, AlertPresentable {
// AlertPresentable
@Published var isShowingAlert: Bool = false
@Published var alertDetails = AlertDetails(title: "", message: "", buttons: [AlertButton(title: "")])
func showAlert1() {
let button = AlertButton(title: "OK", action: {
print("alert1 button")
})
alertDetails = AlertDetails(title: "Alert1", message: "Alert1 message", buttons: [button])
isShowingAlert = true
}
func showAlert2() {
let button1 = AlertButton(title: "Action", action: {
print("alert2 button1")
})
let button2 = AlertButton(title: "Cancel", role: .cancel, action: {
print("alert2 button2")
})
alertDetails = AlertDetails(title: "Alert2", message: "Alert2 message", buttons: [button1, button2])
isShowingAlert = true
}
func showAlert3() {
let button1 = AlertButton(title: "Action1", action: {
print("alert3 button1")
})
let button2 = AlertButton(title: "Action2", action: {
print("alert3 button2")
})
let button3 = AlertButton(title: "Action3", action: {
print("alert3 button3")
})
alertDetails = AlertDetails(title: "Alert3", message: "Alert3 message", buttons: [button1, button2, button3])
isShowingAlert = true
}
}
In a view:
import SwiftUI
struct ContentView: View {
@StateObject private var viewModel = ContentViewModel()
var body: some View {
VStack {
Button(action: {
viewModel.showAlert1()
}) {
Text("Show Alert1")
}
.accessibilityIdentifier("alert1_button")
Button(action: {
viewModel.showAlert2()
}) {
Text("Show Alert2")
}
.accessibilityIdentifier("alert2_button")
Button(action: {
viewModel.showAlert3()
}) {
Text("Show Alert3")
}
.accessibilityIdentifier("alert3_button")
}
.padding()
.showAlert(isShowing: $viewModel.isShowingAlert, details: viewModel.alertDetails)
}
}
We can see how simple the view is now.
Testing the Alert
Unit Testing
There is nothing of particular note. What we are verifying is that the pre-display state is correct and that the alerts are displayed as expected.
I use Swift-Testing for unit tests instead of XCTest since I've used Xcode 16.2.
Only tests for alert1 is here for simplicity.
import Testing
@testable import SharedAlertSample
struct SharedAlertSampleTests {
private var viewModel: ContentViewModel
init() {
viewModel = ContentViewModel()
}
@Test func showAlert1() {
#expect(!viewModel.isShowingAlert, "β Alert should not be showing")
viewModel.showAlert1()
let expectedButton = AlertButton(title: "OK", action: {
print("alert1 button")
})
let expectedAlertDetails = AlertDetails(title: "Alert1", message: "Alert1 message", buttons: [expectedButton])
#expect(viewModel.isShowingAlert, "β Alert should be showing")
#expect(viewModel.alertDetails == expectedAlertDetails, "β Alert details are incorrect")
}
}
UI Testing
This is also a basic test. It verifies the state of the alert during and after it has been hidden.
Since I am writing UI tests using the Page Object Model, I start by implementing PageObject. (Won't write about the Page Object Model because it's off topic, but please refer to this article as it was easy to understand if you're curious.)
import XCTest
protocol PageObject {
var app: XCUIApplication { get }
}
struct ContentPageObject: PageObject {
let app: XCUIApplication
private let TIMEOUT = 5.0
private enum Identifiers {
static let alert1Button: String = "alert1_button"
static let alert2Button: String = "alert2_button"
static let alert3Button: String = "alert3_button"
}
private var showAlert1Button: XCUIElement {
return app.buttons[Identifiers.alert1Button]
}
private var alert1Title: XCUIElement {
return app.staticTexts["Alert1"]
}
private var alert1Message: XCUIElement {
return app.staticTexts["Alert1 message"]
}
private var alert1Button: XCUIElement {
return app.buttons["OK"]
}
func showAlert1() -> Self {
showAlert1Button.tap()
return self
}
@discardableResult
func verifyAlert1ForShowing() -> Self {
XCTAssertTrue(alert1Title.waitForExistence(timeout: TIMEOUT))
XCTAssertTrue(alert1Message.waitForExistence(timeout: TIMEOUT))
XCTAssertTrue(alert1Button.waitForExistence(timeout: TIMEOUT))
return self
}
func dimissAlert1() -> Self {
alert1Button.tap()
return self
}
@discardableResult
func verifyAlert1ForDismissing() -> Self {
XCTAssertTrue(alert1Title.waitForNonExistence(timeout: TIMEOUT))
return self
}
Then we can use those method here.
import XCTest
final class SharedAlertSampleUITests: XCTestCase {
private var app: XCUIApplication!
override func setUpWithError() throws {
app = XCUIApplication()
app.launch()
continueAfterFailure = false
}
@MainActor
func testShowAlert1() throws {
ContentPageObject(app: app)
.showAlert1()
.verifyAlert1ForShowing()
.dimissAlert1()
.verifyAlert1ForDismissing()
}
}
GitHub (Whole code is HERE!)
Please feel free to refer my whole code in GitHub!
Conclusion
I've written down the actual thought process, from implementation to testing of the production code. I'm sure there are still best implementations out there and I'd be happy to discuss them. Feel free to comment on it.
Hope this is useful for anybody who are into iOS Dev!
Happy coding! π
Top comments (0)