Last active
February 7, 2025 01:37
-
-
Save ryotapoi/0ea06c5b1f4e7aa2be1fcba463a224e5 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// | |
// 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