Introduction to Custom Alert Controllers in iOS

Standard system alerts in iOS serve their purpose, but they limit your ability to match the visual identity of your app and provide a truly engaging user experience. A custom alert controller gives you complete control over the appearance, behavior, and content of notifications, dialogs, and input forms. By replacing the built-in UIAlertController with a custom implementation, you can enhance branding, add fluid animations, support complex layouts, and create interactions that feel native to your app. This guide covers everything needed to build a robust custom alert controller—from initial design and layout to animation, accessibility, and reusability.

Why Build a Custom Alert Controller?

Default alerts are functional but rigid. Custom alert controllers offer several advantages:

  • Brand Consistency: Apply your app’s color palette, typography, and rounded corners to every alert.
  • Rich Content: Include images, form fields, attributed strings, or even web views.
  • Custom Behaviors: Control dismissal gestures, overlay tap actions, and transition animations.
  • Seamless Integration: Adapt to dark mode, accessibility settings, and different screen sizes with Auto Layout.

With these benefits, a custom alert controller becomes a powerful tool for creating a polished user interface that feels intentional rather than generic.

Designing the Alert View

The foundation of any custom alert is its view hierarchy. Start by creating a UIView subclass or, more commonly, a UIViewController subclass that owns the alert content. The alert view should include a container view with a clear background (often semi-transparent black for an overlay) and a centered content view for title, message, buttons, and other elements.

Key UI Elements

  • Overlay: A full-screen UIView that dims the content behind the alert. Usually semi-transparent black (e.g., alpha 0.4) to focus attention.
  • Alert Container: A rounded rectangle with white or system background, constrained to a maximum width (e.g., 300 pt on iPhone).
  • Title Label: Bold headline text, typically with a larger font size.
  • Message Label: Body text for the alert message. Support multiline and customization.
  • Buttons: Styled UIButton instances for actions (OK, Cancel, etc.).

Adding Auto Layout Constraints

Use Auto Layout to make the alert responsive. The container should be centered horizontally and vertically, with internal constraints that stack elements vertically. For example:

// Inside your custom alert view controller's viewDidLoad
alertContainer.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(alertContainer)

NSLayoutConstraint.activate([
    alertContainer.centerXAnchor.constraint(equalTo: view.centerXAnchor),
    alertContainer.centerYAnchor.constraint(equalTo: view.centerYAnchor),
    alertContainer.widthAnchor.constraint(equalToConstant: 300),
    // Height will be determined by intrinsic content
])

Creating the Alert View Controller

Subclass UIViewController to manage the alert’s lifecycle. Use modalPresentationStyle = .overFullScreen to show the alert without removing the underlying view controller from the view hierarchy. This allows the background to remain visible through the semi-transparent overlay.

Initialization and Configuration

Instead of hard-coding content, design your custom alert controller with a configuration method or initializer. For example:

class CustomAlertViewController: UIViewController {
    private var titleText: String?
    private var messageText: String?
    private var doneAction: (() -> Void)?

    convenience init(title: String, message: String, doneHandler: @escaping () -> Void) {
        self.init(nibName: nil, bundle: nil)
        self.titleText = title
        self.messageText = message
        self.doneAction = doneHandler
        modalPresentationStyle = .overFullScreen
        modalTransitionStyle = .crossDissolve
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        setupUI()
    }

    private func setupUI() {
        // Add overlay, container, labels, and button
        // Use titleText, messageText to configure labels
    }
}

This approach keeps the controller reusable and testable. You can later extend it to support multiple buttons, text fields, or custom views.

Presenting and Dismissing the Alert

Present the custom alert controller modally from any view controller. The built-in present(_:animated:completion:) method works, but you can also add your own show method for convenience:

extension UIViewController {
    func showCustomAlert(title: String, message: String, done: @escaping () -> Void) {
        let alert = CustomAlertViewController(title: title, message: message, doneHandler: done)
        present(alert, animated: true, completion: nil)
    }
}

For dismissal, call dismiss(animated:completion:) inside the button action. Ensure that the dismissal animates smoothly—using the default modal transition style usually suffices, but you can override dismissViewControllerAnimated:completion: for custom animations.

Handling Tap‑Outside Dismissal

Many apps allow users to dismiss an alert by tapping the dimmed overlay. To implement this, add a tap gesture recognizer to the overlay view. Be careful: only dismiss if the tap is on the overlay itself, not on the alert container. Use the gesture’s location to check if it lies inside the container bounds.

let tapGesture = UITapGestureRecognizer(target: self, action: #selector(overlayTapped(_:)))
overlayView.addGestureRecognizer(tapGesture)
tapGesture.cancelsTouchesInView = false

@objc private func overlayTapped(_ sender: UITapGestureRecognizer) {
    let location = sender.location(in: view)
    if !alertContainer.frame.contains(location) {
        dismiss(animated: true, completion: nil)
    }
}

Adding Animations

Animations make custom alerts feel polished. Standard transitions include fade‑in, scale bounce, or slide‑up. Implement these in viewWillAppear and viewWillDisappear, or use UIViewPropertyAnimator for interactive control.

Example: Scale + Fade Animation

Start the alert container with a scale of 0.5 and alpha 0, then animate to full size and opacity:

override func viewWillAppear(_ animated: Bool) {
    super.viewWillAppear(animated)
    alertContainer.transform = CGAffineTransform(scaleX: 0.5, y: 0.5)
    alertContainer.alpha = 0
    overlayView.alpha = 0
}

override func viewDidAppear(_ animated: Bool) {
    super.viewDidAppear(animated)
    UIView.animate(withDuration: 0.3,
                   delay: 0,
                   usingSpringWithDamping: 0.7,
                   initialSpringVelocity: 0.5,
                   options: .curveEaseInOut) {
        self.alertContainer.transform = .identity
        self.alertContainer.alpha = 1
        self.overlayView.alpha = 1
    }
}

For dismissal, reverse the animation before calling dismiss or use the completion handler of the present/dismiss block.

Handling User Input

Custom alerts often need to collect input via text fields, switches, or sliders. Add these as subviews inside the alert container. For text fields, use UITextField and configure a delegate to respond to the return key or button tap.

Example: Alert with a Text Field

Extend your custom alert controller to accept a text field:

class InputAlertViewController: CustomAlertViewController {
    var textField: UITextField!
    var textHandler: ((String?) -> Void)?

    convenience init(title: String, message: String, placeholder: String, handler: @escaping (String?) -> Void) {
        self.init(title: title, message: message, doneHandler: {})
        self.textHandler = handler
        // Override setupUI to add textField
    }
}

When the user taps the action button, read the text field’s text and pass it to the handler. Always dismiss the keyboard before dismissing the alert to avoid interface glitches.

Accessibility Considerations

Custom alerts must be accessible. VoiceOver users rely on proper traits, labels, and dynamic type. Follow these guidelines:

  • Set isAccessibilityElement appropriately on the container view so VoiceOver treats the alert as a single element, or make each element focusable.
  • Use accessibilityLabel and accessibilityHint for buttons and labels.
  • Support Dynamic Type by using UIFontMetrics or prefer preferredFont(forTextStyle:).
  • Reduce transparency: If the user has “Reduce Transparency” enabled, show a solid background instead of a blurred overlay.
  • Test with VoiceOver to confirm the alert can be dismissed and that all controls are reachable.

Dynamic Type Example

titleLabel.font = UIFontMetrics.default.scaledFont(for: UIFont.systemFont(ofSize: 17, weight: .bold))
titleLabel.adjustsFontForContentSizeCategory = true

Reusability and Configuration

To avoid repeating code, design your custom alert controller as a reusable component. Use a builder pattern or a configuration struct to set title, message, buttons, text fields, and styling options.

Configuration Struct Example

struct AlertConfiguration {
    let title: String
    let message: String
    let primaryButtonTitle: String
    let primaryAction: (() -> Void)?
    let secondaryButtonTitle: String?
    let secondaryAction: (() -> Void)?
    var font: UIFont?
    var tintColor: UIColor?
    var cornerRadius: CGFloat = 12
    var allowTapOutsideToDismiss = true
}

Then the custom alert controller initializer takes an AlertConfiguration and builds the view dynamically. This makes it easy to present consistent alerts throughout the app.

Best Practices

  • Keep the alert width fixed for consistency; use a maximum width constraint to handle larger devices.
  • Never block the main thread; animations and layout should be smooth.
  • Handle rotation and size class changes by using Auto Layout constants that adapt.
  • Consider using a shared instance or a factory class for common alert types (error, success, confirmation).
  • Log an analytics event whenever an alert appears to track user engagement.

Full Example Implementation

Below is a more complete example of a custom alert controller that you can adapt. It includes an overlay, container, title, message, two buttons, and spring animation.

class FullAlertController: UIViewController {
    private let config: AlertConfiguration
    private let overlayView = UIView()
    private let containerView = UIView()
    private let titleLabel = UILabel()
    private let messageLabel = UILabel()
    private lazy var primaryButton: UIButton = {
        let btn = UIButton(type: .system)
        btn.setTitle(config.primaryButtonTitle, for: .normal)
        btn.addTarget(self, action: #selector(primaryTapped), for: .touchUpInside)
        return btn
    }()
    private lazy var secondaryButton: UIButton? = config.secondaryButtonTitle.map { title in
        let btn = UIButton(type: .system)
        btn.setTitle(title, for: .normal)
        btn.addTarget(self, action: #selector(secondaryTapped), for: .touchUpInside)
        return btn
    }

    init(config: AlertConfiguration) {
        self.config = config
        super.init(nibName: nil, bundle: nil)
        modalPresentationStyle = .overFullScreen
        modalTransitionStyle = .crossDissolve
    }

    required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }

    override func viewDidLoad() {
        super.viewDidLoad()
        setupViews()
        setupConstraints()
        applyStyling()
    }

    private func setupViews() {
        overlayView.backgroundColor = UIColor.black.withAlphaComponent(0.4)
        overlayView.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(overlayView)

        containerView.backgroundColor = .systemBackground
        containerView.layer.cornerRadius = config.cornerRadius
        containerView.clipsToBounds = true
        containerView.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(containerView)

        titleLabel.text = config.title
        titleLabel.font = config.font ?? UIFont.preferredFont(forTextStyle: .headline)
        titleLabel.textAlignment = .center
        titleLabel.translatesAutoresizingMaskIntoConstraints = false
        containerView.addSubview(titleLabel)

        messageLabel.text = config.message
        messageLabel.font = UIFont.preferredFont(forTextStyle: .body)
        messageLabel.textAlignment = .center
        messageLabel.numberOfLines = 0
        messageLabel.translatesAutoresizingMaskIntoConstraints = false
        containerView.addSubview(messageLabel)

        containerView.addSubview(primaryButton)
        if let secondaryButton = secondaryButton {
            containerView.addSubview(secondaryButton)
        }
    }

    private func setupConstraints() {
        NSLayoutConstraint.activate([
            overlayView.topAnchor.constraint(equalTo: view.topAnchor),
            overlayView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            overlayView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            overlayView.bottomAnchor.constraint(equalTo: view.bottomAnchor),

            containerView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            containerView.centerYAnchor.constraint(equalTo: view.centerYAnchor),
            containerView.widthAnchor.constraint(equalToConstant: 300),

            titleLabel.topAnchor.constraint(equalTo: containerView.topAnchor, constant: 20),
            titleLabel.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 16),
            titleLabel.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -16),

            messageLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 12),
            messageLabel.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 16),
            messageLabel.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -16),

            primaryButton.topAnchor.constraint(equalTo: messageLabel.bottomAnchor, constant: 16),
            primaryButton.centerXAnchor.constraint(equalTo: containerView.centerXAnchor),
        ])
        if let secondaryButton = secondaryButton {
            NSLayoutConstraint.activate([
                secondaryButton.topAnchor.constraint(equalTo: primaryButton.bottomAnchor, constant: 8),
                secondaryButton.centerXAnchor.constraint(equalTo: containerView.centerXAnchor),
                secondaryButton.bottomAnchor.constraint(equalTo: containerView.bottomAnchor, constant: -16)
            ])
        } else {
            primaryButton.bottomAnchor.constraint(equalTo: containerView.bottomAnchor, constant: -16).isActive = true
        }
    }

    private func applyStyling() {
        if let tintColor = config.tintColor {
            primaryButton.tintColor = tintColor
            secondaryButton?.tintColor = tintColor
        }
    }

    @objc private func primaryTapped() {
        config.primaryAction?()
        dismiss(animated: true, completion: nil)
    }

    @objc private func secondaryTapped() {
        config.secondaryAction?()
        dismiss(animated: true, completion: nil)
    }
}

Further Enhancements

  • Add a blur effect to the overlay instead of a solid color (use UIVisualEffectView).
  • Support multiple button layouts (stacked horizontally or vertically).
  • Implement UIViewControllerTransitioningDelegate for fully custom present/dismiss transitions.
  • Allow alerts to be cancelled by pressing the Escape key on iPadOS with a hardware keyboard.

References

For more details on view controller presentation and animation, refer to Apple’s official documentation. The UIViewControllerAnimatedTransitioning protocol is useful for advanced custom transitions. For accessibility guidelines, see Human Interface Guidelines on Accessibility.

Conclusion

Building a custom alert controller in iOS gives you the freedom to create notifications that align perfectly with your app’s design language and user needs. By carefully designing the view hierarchy, handling presentation and dismissal with smooth animations, supporting user input, and paying attention to accessibility and reusability, you can replace the standard alert with a component that feels much more integrated. Start with the examples provided, then iterate by adding features like text fields, custom transitions, and configuration structs. Your users will appreciate the polished, brand‑consistent experience.