Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
signalapp
GitHub Repository: signalapp/Signal-iOS
Path: blob/main/SignalUI/ViewControllers/ModalActivityIndicatorViewController.swift
1 views
//
// Copyright 2017 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//

public import SignalServiceKit

// A modal view that be used during blocking interactions (e.g. waiting on response from
// service or on the completion of a long-running local operation).
public class ModalActivityIndicatorViewController: OWSViewController {
    public enum Constants {
        public static let defaultPresentationDelay: TimeInterval = 0.05
    }

    public private(set) var wasCancelled: Bool = false

    private let canCancel: Bool
    private let isInvisible: Bool
    private var wasDimissed: Bool = false
    private var wasPresented: Bool = false
    private var presentTimer: Timer?
    private let presentationDelay: TimeInterval
    private var asyncTask: Task<Void, Never>?
    private lazy var customTransitioningDelegate = TransitioningDelegate()

    public init(canCancel: Bool, presentationDelay: TimeInterval, isInvisible: Bool = false) {
        self.canCancel = canCancel
        self.presentationDelay = presentationDelay
        self.isInvisible = isInvisible
        super.init()
    }

    // MARK: -

    @MainActor
    public class func present(
        fromViewController: UIViewController,
        title: String? = nil,
        canCancel: Bool,
        presentationDelay: TimeInterval = Constants.defaultPresentationDelay,
        backgroundBlockQueueQos: DispatchQoS = .default,
        backgroundBlock: @escaping (ModalActivityIndicatorViewController) -> Void,
    ) {
        present(
            fromViewController: fromViewController,
            title: title,
            canCancel: canCancel,
            presentationDelay: presentationDelay,
            isInvisible: false,
            backgroundBlockQueueQos: backgroundBlockQueueQos,
            backgroundBlock: backgroundBlock,
        )
    }

    @MainActor
    public class func presentAsInvisible(
        fromViewController: UIViewController,
        backgroundBlock: @escaping (ModalActivityIndicatorViewController) -> Void,
    ) {
        present(
            fromViewController: fromViewController,
            title: nil,
            canCancel: false,
            presentationDelay: Constants.defaultPresentationDelay,
            isInvisible: true,
            backgroundBlockQueueQos: .default,
            backgroundBlock: backgroundBlock,
        )
    }

    @MainActor
    private class func present(
        fromViewController: UIViewController,
        title: String?,
        canCancel: Bool,
        presentationDelay: TimeInterval,
        isInvisible: Bool,
        backgroundBlockQueueQos: DispatchQoS,
        backgroundBlock: @escaping (ModalActivityIndicatorViewController) -> Void,
    ) {
        AssertIsOnMainThread()

        let vc = ModalActivityIndicatorViewController(
            canCancel: canCancel,
            presentationDelay: presentationDelay,
            isInvisible: isInvisible,
        )
        vc.title = title
        vc.present(
            from: fromViewController,
            asyncBlock: { viewController in
                DispatchQueue.global(qos: backgroundBlockQueueQos.qosClass).async {
                    backgroundBlock(viewController)
                }
            },
        )
    }

    // MARK: -

    /// Presents a `ModalActivityIndicatorViewController`, behind which the
    /// given async block runs. Callers are expected to dismiss the modal at the
    /// completion of the async block.
    ///
    /// Use this API if you need fine-grained control over the modal dismissal
    /// behavior, or if you want a cancellable modal.
    ///
    /// - SeeAlso ``presentAndPropagateResult(from:presentationDelay:wrappedAsyncBlock:)``
    @MainActor
    public class func present(
        fromViewController: UIViewController,
        title: String? = nil,
        canCancel: Bool = false,
        presentationDelay: TimeInterval = Constants.defaultPresentationDelay,
        isInvisible: Bool = false,
        asyncBlock: @escaping @MainActor (ModalActivityIndicatorViewController) async -> Void,
    ) {
        AssertIsOnMainThread()

        let vc = ModalActivityIndicatorViewController(
            canCancel: canCancel,
            presentationDelay: presentationDelay,
            isInvisible: isInvisible,
        )
        vc.title = title
        vc.present(
            from: fromViewController,
            asyncBlock: asyncBlock,
        )
    }

    /// Presents a `ModalActivityIndicatorViewController` for the duration of
    /// the given async block, automatically dismissing the modal when the block
    /// exits and propagating the block's result.
    ///
    /// Use this API if you want to simply show a modal during a non-cancellable
    /// async block.
    ///
    /// - SeeAlso ``present(fromViewController:canCancel:presentationDelay:isInvisible:asyncBlock:)``.
    @MainActor
    public class func presentAndPropagateResult<T, E>(
        from viewController: UIViewController,
        title: String? = nil,
        canCancel: Bool = false,
        presentationDelay: TimeInterval = Constants.defaultPresentationDelay,
        wrappedAsyncBlock: @escaping () async throws(E) -> T,
    ) async throws(E) -> T {
        let result: Result<T, E> = await withCheckedContinuation { continuation in
            present(
                fromViewController: viewController,
                title: title,
                canCancel: canCancel,
                presentationDelay: presentationDelay,
                asyncBlock: { modal in
                    let result = await Result(catching: wrappedAsyncBlock)
                    modal.dismiss {
                        continuation.resume(returning: result)
                    }
                },
            )
        }

        return try result.get()
    }

    @MainActor
    private func present(
        from viewController: UIViewController,
        asyncBlock: @escaping @MainActor (ModalActivityIndicatorViewController) async -> Void,
    ) {
        // Present this modal _over_ the current view contents.
        modalPresentationStyle = .overFullScreen
        if
            let navigationController = viewController as? UINavigationController,
            let topViewController = navigationController.topViewController
        {
            overrideUserInterfaceStyle = topViewController.overrideUserInterfaceStyle
        } else {
            overrideUserInterfaceStyle = viewController.overrideUserInterfaceStyle
        }
        viewController.present(self, animated: false) {
            self.asyncTask = Task { await asyncBlock(self) }
            if self.wasCancelled {
                self.asyncTask?.cancel()
            }
        }
    }

    // MARK: -

    public func dismiss(completion: (() -> Void)? = nil) {
        AssertIsOnMainThread()

        // If already dismissed, wait a beat then call completion.
        guard wasDimissed == false else {
            DispatchQueue.main.async {
                completion?()
            }
            return
        }

        if wasPresented {
            modalPresentationStyle = .custom
            transitioningDelegate = customTransitioningDelegate
        }
        dismiss(animated: wasPresented, completion: completion)
        wasDimissed = true
    }

    /// A helper for a common dismissal pattern.
    ///
    /// This can be invoked on any queue, and it'll switch to the main queue if
    /// needed. The completion block will be invoked on the main queue.
    ///
    /// - Parameter completionIfNotCanceled:
    ///     If the modal hasn't been canceled, dismiss it and then call this
    ///     block. Note: If the modal was canceled, the block isn't invoked.
    public func dismissIfNotCanceled(completionIfNotCanceled: @escaping () -> Void = {}) {
        DispatchQueue.main.async {
            if self.wasCancelled {
                return
            }
            self.dismiss(completion: completionIfNotCanceled)
        }
    }

    // MARK: -

    override public var title: String? {
        didSet {
            guard isViewLoaded else { return }
            updateUIOnTextChange()
        }
    }

    private lazy var titleLabel: UILabel = {
        let label = UILabel()
        label.font = .dynamicTypeHeadline
        label.adjustsFontForContentSizeCategory = true
        label.textColor = .Signal.label
        label.textAlignment = .natural
        label.numberOfLines = 5
        label.lineBreakMode = .byWordWrapping
        return label
    }()

    private lazy var textStack: UIStackView = {
        let stackView = UIStackView(arrangedSubviews: [])
        stackView.isLayoutMarginsRelativeArrangement = true
        stackView.directionalLayoutMargins = .init(top: 12, leading: 8, bottom: 0, trailing: 12)
        stackView.axis = .vertical
        stackView.alignment = .leading
        stackView.spacing = 2
        return stackView
    }()

    private lazy var cancelButton: UIButton = {
        let button = UIButton(
            configuration: .borderedProminent(),
            primaryAction: UIAction { [weak self] _ in
                self?.cancelPressed()
            },
        )
        button.configuration?.title = CommonStrings.cancelButton
        button.configuration?.titleTextAttributesTransformer = .defaultFont(.dynamicTypeBodyClamped.medium())
        button.titleLabel?.adjustsFontForContentSizeCategory = true
        button.configuration?.baseForegroundColor = .Signal.label
        button.configuration?.baseBackgroundColor = .Signal.secondaryFill
        button.configuration?.contentInsets = NSDirectionalEdgeInsets(hMargin: 16, vMargin: 12)
        if #available(iOS 26, *) {
            button.configuration?.cornerStyle = .capsule
        } else {
            button.configuration?.cornerStyle = .fixed
            button.configuration?.background.cornerRadius = 14
        }

        return button
    }()

    private lazy var activityIndicator = CircularProgressView(frame: .zero)

    private lazy var contentStack: UIStackView = {
        activityIndicator.lineWidth = 3
        activityIndicator.translatesAutoresizingMaskIntoConstraints = false

        let aiContainer = UIView.container()
        aiContainer.layoutMargins = .init(margin: 12)
        aiContainer.addSubview(activityIndicator)
        NSLayoutConstraint.activate([
            activityIndicator.widthAnchor.constraint(equalToConstant: 40),
            activityIndicator.heightAnchor.constraint(equalTo: activityIndicator.widthAnchor),

            activityIndicator.topAnchor.constraint(equalTo: aiContainer.layoutMarginsGuide.topAnchor),
            activityIndicator.centerYAnchor.constraint(equalTo: aiContainer.layoutMarginsGuide.centerYAnchor),

            activityIndicator.leadingAnchor.constraint(greaterThanOrEqualTo: aiContainer.layoutMarginsGuide.leadingAnchor),
            activityIndicator.centerXAnchor.constraint(equalTo: aiContainer.layoutMarginsGuide.centerXAnchor),
        ])
        let stackView = UIStackView(arrangedSubviews: [textStack, aiContainer])
        stackView.axis = .vertical
        stackView.alignment = .fill
        stackView.spacing = 18
        return stackView
    }()

    private lazy var panelView: UIVisualEffectView = {
        if #available(iOS 26, *) {
            let glassEffect = UIGlassEffect(style: .regular)
            glassEffect.tintColor = UIColor.Signal.background.withAlphaComponent(2 / 3)
            let view = UIVisualEffectView(effect: glassEffect)
            view.clipsToBounds = true
            view.cornerConfiguration = .uniformCorners(radius: .fixed(canCancel ? 36 : 24))
            return view
        }

        let view = UIVisualEffectView(effect: UIBlurEffect(style: .prominent))
        view.clipsToBounds = true
        view.layer.cornerRadius = 28
        return view
    }()

    private lazy var backdropView: UIView = {
        let view = UIView()
        view.backgroundColor = .Signal.backdrop
        return view
    }()

    override public func viewDidLoad() {
        super.viewDidLoad()

        view.isOpaque = false
        view.backgroundColor = .clear
        view.tintColor = .Signal.label

        guard isInvisible == false else { return }

        backdropView.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(backdropView)
        NSLayoutConstraint.activate([
            backdropView.topAnchor.constraint(equalTo: view.topAnchor),
            backdropView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            backdropView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            backdropView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
        ])

        panelView.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(panelView)
        NSLayoutConstraint.activate([
            panelView.leadingAnchor.constraint(greaterThanOrEqualTo: contentLayoutGuide.leadingAnchor),
            panelView.centerXAnchor.constraint(equalTo: contentLayoutGuide.centerXAnchor),
            panelView.topAnchor.constraint(greaterThanOrEqualTo: contentLayoutGuide.topAnchor),
            panelView.centerYAnchor.constraint(equalTo: contentLayoutGuide.centerYAnchor),
            panelView.heightAnchor.constraint(lessThanOrEqualTo: panelView.widthAnchor, multiplier: 1),
        ])

        contentStack.translatesAutoresizingMaskIntoConstraints = false
        panelView.layoutMargins = .init(margin: 16)
        panelView.contentView.addSubview(contentStack)
        NSLayoutConstraint.activate([
            contentStack.topAnchor.constraint(equalTo: panelView.layoutMarginsGuide.topAnchor),
            contentStack.leadingAnchor.constraint(equalTo: panelView.layoutMarginsGuide.leadingAnchor),
            contentStack.trailingAnchor.constraint(equalTo: panelView.layoutMarginsGuide.trailingAnchor),
            contentStack.bottomAnchor.constraint(equalTo: panelView.layoutMarginsGuide.bottomAnchor),
        ])

        if canCancel {
            cancelButton.translatesAutoresizingMaskIntoConstraints = false
            cancelButton.addConstraint(cancelButton.widthAnchor.constraint(equalToConstant: 240))
            contentStack.addArrangedSubview(cancelButton)
        }

        updateUIOnTextChange()

        // Hide the modal until the presentation animation completes.
        if presentationDelay > 0 {
            backdropView.alpha = 0
            panelView.effect = nil
            contentStack.alpha = 0
        } else {
            wasPresented = true
        }
    }

    override public func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)

        activityIndicator.startAnimating()

        // Hide the modal and wait for a second before revealing it,
        // to avoid "blipping" in the modal during short blocking operations.
        //
        // NOTE: It will still intercept user interactions while hidden, as it
        //       should.
        if presentationDelay > 0 {
            presentTimer?.invalidate()
            presentTimer = Timer.scheduledTimer(
                withTimeInterval: presentationDelay,
                repeats: false,
            ) { [weak self] _ in
                self?.presentTimerFired()
            }
        }
    }

    override public func viewWillDisappear(_ animated: Bool) {
        super.viewWillDisappear(animated)

        clearTimer()
    }

    override public func viewDidDisappear(_ animated: Bool) {
        super.viewDidDisappear(animated)

        activityIndicator.stopAnimating()

        clearTimer()
    }

    private func updateUIOnTextChange() {
        if let title = self.title?.nilIfEmpty {
            titleLabel.text = title
            if titleLabel.superview == nil {
                textStack.insertArrangedSubview(titleLabel, at: 0)
            }
            textStack.isHiddenInStackView = false
        } else {
            textStack.isHiddenInStackView = true
        }
    }

    private var panelViewVisualEffect: UIVisualEffect {
        guard #available(iOS 26, *) else { return UIBlurEffect(style: .prominent) }

        let glassEffect = UIGlassEffect(style: .regular)
        glassEffect.tintColor = UIColor.Signal.background.withAlphaComponent(2 / 3)
        return glassEffect
    }

    private func makeViewVisible(animated: Bool) {
        defer {
            wasPresented = true
        }

        guard animated else {
            backdropView.alpha = 1
            panelView.effect = panelViewVisualEffect
            contentStack.alpha = 1
            return
        }

        UIView.performWithoutAnimation {
            self.panelView.transform = .scale(1.2)
        }

        let animator = UIViewPropertyAnimator(duration: 0.25, springDamping: 1, springResponse: 0.25)
        animator.addAnimations {
            self.backdropView.alpha = 1
            self.panelView.effect = self.panelViewVisualEffect
            self.panelView.transform = .identity
            self.contentStack.alpha = 1
        }
        animator.startAnimation()
    }

    // MARK: -

    private func clearTimer() {
        presentTimer?.invalidate()
        presentTimer = nil
    }

    private func presentTimerFired() {
        AssertIsOnMainThread()
        clearTimer()
        makeViewVisible(animated: true)
    }

    @objc
    private func cancelPressed() {
        AssertIsOnMainThread()

        guard wasDimissed == false else { return }

        dismiss()
        wasCancelled = true
        asyncTask?.cancel()
    }

    private final class TransitioningDelegate: NSObject, UIViewControllerTransitioningDelegate {

        func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
            DismissAnimator()
        }

        // Returning nil falls back to the default presentation animation
        func animationController(
            forPresented presented: UIViewController,
            presenting: UIViewController,
            source: UIViewController,
        ) -> UIViewControllerAnimatedTransitioning? {
            nil
        }
    }

    private final class DismissAnimator: NSObject, UIViewControllerAnimatedTransitioning {

        func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
            0.25
        }

        func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
            guard let fromVC = transitionContext.viewController(forKey: .from) as? ModalActivityIndicatorViewController else {
                transitionContext.completeTransition(false)
                return
            }

            UIView.animate(
                withDuration: transitionDuration(using: transitionContext),
                delay: 0,
                usingSpringWithDamping: 1,
                initialSpringVelocity: 0,
                options: .curveEaseInOut,
            ) {
                fromVC.backdropView.alpha = 0
                fromVC.panelView.effect = nil
                fromVC.contentStack.alpha = 0
            } completion: { _ in
                transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
            }
        }
    }
}

#if DEBUG

private class MAIVCPreviewViewController: UIViewController {

    private let canCancel: Bool

    init(title: String?, canCancel: Bool) {
        self.canCancel = canCancel
        super.init(nibName: nil, bundle: nil)
        self.title = title
    }

    required init?(coder: NSCoder) { owsFail("") }

    override func viewDidLoad() {
        super.viewDidLoad()

        let vc = ModalActivityIndicatorViewController(canCancel: canCancel, presentationDelay: 0)
        vc.title = title
        present(vc, animated: false)
        view.addSubview(vc.view)
        vc.didMove(toParent: self)

        DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
            vc.viewDidAppear(true)
        }
    }
}

@available(iOS 17, *)
#Preview("No Title, Can't Cancel") {
    MAIVCPreviewViewController(title: nil, canCancel: false)
}

@available(iOS 17, *)
#Preview("No Title, Can Cancel") {
    MAIVCPreviewViewController(title: nil, canCancel: true)
}

@available(iOS 17, *)
#Preview("Title, Can Cancel") {
    MAIVCPreviewViewController(title: "Preparing...", canCancel: true)
}

#endif