- 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.
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?
) { ... }
/// 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()
}
}
}