Skip to content

Instantly share code, notes, and snippets.

@ryotapoi
Last active February 7, 2025 01:37
Show Gist options
  • Save ryotapoi/0ea06c5b1f4e7aa2be1fcba463a224e5 to your computer and use it in GitHub Desktop.
Save ryotapoi/0ea06c5b1f4e7aa2be1fcba463a224e5 to your computer and use it in GitHub Desktop.
//
// AlertController.swift
//
// Created by ryotapoi on 2025/01/20.
//
import UIKit
/// Represents an action displayed in an alert.
///
/// An instance of `AlertAction` encapsulates the title, style, and a result value associated with a button in the alert.
/// This result is returned when the action is triggered.
/// - Note: The generic type `T` represents any type of result that the action may yield.
public struct AlertAction<T: Sendable> {
/// The text displayed on the button.
public let title: String
/// The style of the button, corresponding to `UIAlertAction.Style`.
public let style: UIAlertAction.Style
/// The value returned when the action is selected.
public let result: T
/// Initializes a new `AlertAction` with the specified title, style, and result.
///
/// - Parameters:
/// - title: The text to display on the button.
/// - style: The style of the button.
/// - result: The result associated with this action.
public init(title: String, style: UIAlertAction.Style, result: T) {
self.title = title
self.style = style
self.result = result
}
}
/// Manages the presentation of an alert and asynchronous handling of user interaction.
///
/// `AlertController` provides a unified API to present an alert on a view controller and await the user's response asynchronously.
/// It supports both presenting a modal alert that returns a result and simply displaying the alert.
/// The result is delivered using Swift's concurrency model.
public class AlertController<T: Sendable>: NSObject, UIAdaptivePresentationControllerDelegate {
// MARK: - Internal Implementation
/// A subclass of `UIAlertController` that executes a handler upon deinitialization.
///
/// This class is used to provide a fallback result if the alert is dismissed without any action.
private class DismissAwareAlertController: UIAlertController {
/// A closure that is executed when the alert controller is deallocated.
var onDismissHandler: (@Sendable () -> Void)?
deinit {
// Execute the dismissal handler when the alert controller is deallocated.
onDismissHandler?()
}
/// Sets the dismissal handler that will be called when the alert is dismissed.
///
/// - Parameter handler: A closure to execute upon dismissal.
func setOnDismissHandler(_ handler: @escaping @Sendable () -> Void) {
self.onDismissHandler = handler
}
}
// MARK: - Private Properties
/// A strong reference to the alert controller during its setup phase.
///
/// This reference ensures that the alert controller is retained during configuration.
private var retainedAlertController: DismissAwareAlertController?
/// A weak reference to the alert controller when it is presented.
///
/// This allows access to the alert controller without preventing its deallocation once it is no longer needed.
private weak var presentedAlertController: DismissAwareAlertController?
/// Returns the active alert controller, whether during setup or after presentation.
private var activeAlertController: DismissAwareAlertController? {
retainedAlertController ?? presentedAlertController
}
/// The `CheckedContinuation` used to return the asynchronous result.
///
/// It is set when an asynchronous operation is awaiting a result and resumed either by an action or by the dismissal handler.
private var continuation: CheckedContinuation<T, Never>?
/// The default result returned when the alert is dismissed without any action.
private var dismissResult: T
// MARK: - Public Properties
/// Provides access to the popover presentation settings of the UIAlertController.
/// Through this property, you can access the popover presentation settings of the UIAlertController.
public var popoverPresentationController: UIPopoverPresentationController? {
return activeAlertController?.popoverPresentationController
}
// MARK: - Initializer
/// Creates a new instance of `AlertController` configured with the given parameters.
///
/// - Parameters:
/// - title: The title text for the alert.
/// - message: The descriptive message for the alert.
/// - preferredStyle: The style of the alert (e.g., `.alert` or `.actionSheet`).
/// - dismissResult: The result to return if the alert is dismissed without selecting an action.
public init(
title: String?, message: String?, preferredStyle: UIAlertController.Style, dismissResult: T
) {
self.retainedAlertController = DismissAwareAlertController(
title: title, message: message, preferredStyle: preferredStyle
)
self.presentedAlertController = self.retainedAlertController
self.dismissResult = dismissResult
super.init()
// Set a dismissal handler that resumes the continuation with the default dismissResult.
// This ensures that if the alert is dismissed without any action, the waiting task receives a result.
self.retainedAlertController?.setOnDismissHandler { [weak self] in
guard let self = self else { return }
Task { @MainActor in
self.resume(returning: self.dismissResult)
}
}
}
deinit {
#if DEBUG
print("AlertController deinitialized") // For debugging: confirm deallocation timing.
#endif
}
// MARK: - Public Methods
/// Adds an action to the alert.
///
/// - Parameters:
/// - action: An `AlertAction` instance representing the button to be displayed.
/// - isPreferred: A Boolean indicating whether this action is the preferred action. Defaults to `false`.
public func addAction(_ action: AlertAction<T>, isPreferred: Bool = false) {
let uiAction = UIAlertAction(title: action.title, style: action.style) { [weak self] _ in
// Resume the asynchronous operation with the result associated with this action.
self?.resume(returning: action.result)
}
activeAlertController?.addAction(uiAction)
if isPreferred {
activeAlertController?.preferredAction = uiAction
}
}
/// Adds multiple actions to the alert.
///
/// - Parameter actions: An array of `AlertAction` instances to be added to the alert.
public func addActions(_ actions: [AlertAction<T>]) {
for action in actions {
addAction(action)
}
}
/// Presents the alert on the specified view controller and asynchronously waits for the result.
///
/// This method displays the alert modally and suspends the current task until the user selects an action
/// or dismisses the alert. The result is either the value associated with the selected action or the
/// default dismiss result.
///
/// - Parameters:
/// - viewController: The view controller on which to present the alert.
/// - animated: Pass `true` to animate the presentation; otherwise, pass `false`.
/// - Returns: The result corresponding to the user's action or the default dismiss result.
@MainActor
public func presentAndWait(on viewController: UIViewController, animated: Bool) async -> T {
await withTaskCancellationHandler { [weak self] in
await withCheckedContinuation { continuation in
guard let self = self else {
fatalError("AlertController was unexpectedly deallocated during presentation.")
}
guard let retainedAlertController = self.retainedAlertController else {
#if DEBUG
fatalError("retainedAlertController is nil. Ensure proper initialization of AlertController.")
#else
continuation.resume(returning: self.dismissResult)
return
#endif
}
self.continuation = continuation
viewController.present(retainedAlertController, animated: animated)
// Release the strong reference after presentation.
self.retainedAlertController = nil
}
} onCancel: { [weak self] in
// Do not resume here; onDismissHandler will handle resuming the continuation.
Task { @MainActor in
self?.activeAlertController?.dismiss(animated: animated, completion: nil)
}
}
}
/// Presents the alert on the specified view controller.
///
/// This method displays the alert and returns control to the caller once the presentation animation completes.
///
/// - Parameters:
/// - viewController: The view controller on which to present the alert.
/// - animated: Pass `true` to animate the presentation; otherwise, pass `false`.
@MainActor
public func present(on viewController: UIViewController, animated: Bool) async {
await withCheckedContinuation { [weak self] continuation in
guard let self = self else {
fatalError("AlertController was unexpectedly deallocated during presentation.")
}
guard let retainedAlertController = self.retainedAlertController else {
#if DEBUG
fatalError("retainedAlertController is nil. Ensure proper initialization of AlertController.")
#else
// If the retained alert controller is nil, resume immediately.
continuation.resume()
return
#endif
}
viewController.present(retainedAlertController, animated: animated) {
continuation.resume()
}
// Release the strong reference after presentation.
self.retainedAlertController = nil
}
}
/// Asynchronously waits until an action is selected or the alert is dismissed.
///
/// This method suspends the current task until the user interacts with the alert.
/// If an action is selected, its associated result is returned.
/// Otherwise, the default dismiss result is returned.
///
/// - Returns: The result associated with the user's action, or the default dismiss result if no action is selected.
@MainActor
public func waitForDismissOrAction() async -> T {
await withTaskCancellationHandler { [weak self] in
await withCheckedContinuation { continuation in
guard let self = self else {
fatalError("AlertController was unexpectedly deallocated during waiting.")
}
self.continuation = continuation
}
} onCancel: { [weak self] in
// Do not resume here; onDismissHandler will handle resuming the continuation.
Task { @MainActor in
self?.activeAlertController?.dismiss(animated: true, completion: nil)
}
}
}
/// Asynchronously dismisses the alert.
///
/// This method dismisses the alert and suspends the current task until the dismissal animation completes.
/// It returns control to the caller once the dismissal is finished.
/// - Parameter flag: Pass `true` to animate the dismissal; otherwise, pass `false`.
@MainActor
public func dismiss(animated flag: Bool) async {
await withCheckedContinuation { continuation in
activeAlertController?.dismiss(animated: flag) {
continuation.resume()
}
}
}
// MARK: - Private Methods
/// Resumes the suspended asynchronous operation with the specified result.
///
/// This method is called internally when the alert is dismissed or an action is selected,
/// returning the result to the awaiting task.
///
/// - Parameter returning: The result to pass back to the awaiting task.
private func resume(returning: T) {
guard let continuation = continuation else { return }
continuation.resume(returning: returning)
self.continuation = nil
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment