Skip to content

Instantly share code, notes, and snippets.

@importRyan
Last active January 7, 2022 22:36
Show Gist options
  • Save importRyan/60cef19a017c4cc5f479d0d422c720c1 to your computer and use it in GitHub Desktop.
Save importRyan/60cef19a017c4cc5f479d0d422c720c1 to your computer and use it in GitHub Desktop.
UIActivityViewController in SwiftUI for iPad or iPhone

iPad-safe SwiftUI UIActivityViewController

  • Background preparation of UIActivityViewController to avoid stuttering UI
  • Situates popover for iPad modal presentation
  • Tolerant of SwiftUI's arbitrary timing of moving a view controller into view hierarchy

Some approaches to presenting a UIActivityViewController in SwiftUI work for some compositions, but sporadically fail for others. In those cases, your UI can appear to hang because the dismiss callback is never reached.

Since on iPad UIActivityController presentation is modal (and requires a valid origin rect), the host VC must be in the active view hierarchy before calling present. However, that insertion may not occur when expected (e.g., when updateUIViewController is called).

Reliable presentation requires explicitly detecting when the VC has moved to an active window, as below.

Consumer

View

@ViewBuilder private var exportFiles: some View {
    if vm.showExportFilesCTA {
        CTAButton("Export") { vm.exportFiles() }
        .background(export)
    }
}

@ViewBuilder var export: some View {
    if let export = vm.export {
        UIActivityPopover(items: [export], didDismiss: vm.didDismissExportPopover)
    }
}

VM

@Published private(set) var export: Any? = nil
func didDismissExportPopover(
        selectedActivity: UIActivity.ActivityType?,
        didPerformSelection: Bool,
        modifiedItems: [Any]?,
        error: Error?
    ) { ... }

Representable

/// Eagerly presents a `UIActivityViewController` centered on the parent view for iPhone or iPad.
///
struct UIActivityPopover: UIViewControllerRepresentable {

    let items: [Any]
    var activities: [UIActivity] = []
    let didDismiss: UIActivityViewController.CompletionWithItemsHandler

    func makeUIViewController(context: Context) -> HostVC {
        HostVC(items, activities, didDismiss)
    }

    func updateUIViewController(_ vc: HostVC, context: Context) {
        vc.prepareActivity()
    }
}

extension UIActivityPopover {

    class HostVC : UIViewController {

        private let items: [Any]
        private let activities: [UIActivity]
        private let didDismiss: UIActivityViewController.CompletionWithItemsHandler
        private var vc: UIActivityViewController? = nil
        private var didPrepare = false
        private var didPresent = false

        init(_ items: [Any],
             _ activities: [UIActivity],
             _ didDismiss: @escaping UIActivityViewController.CompletionWithItemsHandler) {
            self.items = items
            self.activities = activities
            self.didDismiss = didDismiss
            super.init(nibName: nil, bundle: nil)
        }

        required init?(coder: NSCoder) { fatalError() }
    }
}

extension UIActivityPopover.HostVC {

    /// Prepare popover in background (can take a second)
    ///
    func prepareActivity() {
        guard didPrepare == false else { return }
        didPrepare = true

        DispatchQueue.global().async { [self] in
            guard self.vc == nil else { return }
            vc = UIActivityViewController(activityItems: items, applicationActivities: activities)
            vc?.completionWithItemsHandler = didDismiss
            DispatchQueue.main.async { [self] in
                self.presentActivity()
            }
        }
    }

    /// Present popover in situ for iPad and iPhone.
    ///
    private func presentActivity() {
        let hasWindow = viewIfLoaded?.window != nil
        guard didPresent == false, hasWindow else { tryUntilHasWindow(); return }
        didPresent = true

        if UIDevice.current.idiom == .pad {
            let pop = vc!.popoverPresentationController
            pop?.sourceView = self.view
            pop?.sourceRect = self.view.frame
        }

        self.present(vc!, animated: true, completion: nil)
    }

    /// SwiftUI may call `updateUIViewController` before moving this controller into a window. 
    /// In this situation, presenting the `UIActivityViewController` will not work, `didDismiss` 
    /// will not be called, and this export will appear to hang.
    ///
    /// This method polls for when presentation is safe.
    ///
    private func tryUntilHasWindow() {
        DispatchQueue.main.after(0.1) { [weak self] in
            self?.presentActivity()
        }
    }
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment