Blog

How to use view controller delegation in SwiftUI


Presenting UIKit view controllers in SwiftUI is simple, but things become more complicated when they communicate through delegation. In this post, we’ll look at a way to solve this.

UIViewControllerRepresentable

Since SwiftUI is still young, there are many situations where you may have to use native UIKit views or view controllers, e.g. to compose e-mails, share data etc. You may also have your own view controllers that you want to reuse in SwiftUI.

Presenting a UIKit view controller in SwiftUI is trivial. For instance, presenting a share sheet just requires you to create a UIViewControllerRepresentable that wraps the sheet:

struct ShareSheet: UIViewControllerRepresentable {
    
    typealias Callback = (
        _ activityType: UIActivity.ActivityType?, 
        _ completed: Bool, 
        _ returnedItems: [Any]?, 
        _ error: Error?) -> Void
      
    let activityItems: [Any]
    let applicationActivities: [UIActivity]? = nil
    let excludedActivityTypes: [UIActivity.ActivityType]? = nil
    let callback: Callback? = nil
      
    func makeUIViewController(context: Context) -> UIActivityViewController {
        let controller = UIActivityViewController(
            activityItems: activityItems,
            applicationActivities: applicationActivities)
        controller.excludedActivityTypes = excludedActivityTypes
        controller.completionWithItemsHandler = callback
        return controller
    }
      
    func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {
        // Nothing to see here, carry on
    }
}

You can then present the share sheet as a SwiftUI sheet:

...
.sheet(isPresented: $isSheetActive) { ShareSheet(someData) }
...

This will present the share sheet in a modal and let you share any data you like. You can also specify application activities, excluded activity types and a callback, although it’s not needed.

View controllers with delegation

Things become a little more complicated if a view controller communicates back using a delegate. Since SwiftUI views are structs, they can’t be used as delegates. We need something more.

One solution is to use a coordinator, which you can create and return as a nested class in your view:

struct MyView: View {
    
    class Coordinator {}
    
    func makeCoordinator() -> Coordinator {
        Coordinator()
    }
}

Although the view is a struct and can be recreated whenever SwiftUI tells it to, the coordinator will be kept as a single instance, and can therefore be used as a delegate.

If the delegating view controller is used in many places, you can reuse and compose coordinators to avoid duplicating code. This is however unusual.

Another approach is to have a delegate class for each view controller wrapper that supports delegation.

Say that we want to use a Vision-based document camera. The VNDocumentCameraViewController communicates events using a VNDocumentCameraViewControllerDelegate, so you must provide it with such a delegate to know what’s going on.

First, let’s wrap the view controller in a UIViewControllerRepresentable:

struct DocumentCamera: UIViewControllerRepresentable {

    init(delegate: VNDocumentCameraViewControllerDelegate) {
        self.delegate = delegate
    }

    private let delegate: VNDocumentCameraViewControllerDelegate
    
    func makeUIViewController(context: Context) -> VNDocumentCameraViewController {
        let controller = VNDocumentCameraViewController()
        controller.delegate = delegate
        return controller
    }
    
    func updateUIViewController(_ uiViewController: VNDocumentCameraViewController, context: Context) {}
}

The camera can now be presented as long as it’s given a delegate. If your view has a coordinator that implements VNDocumentCameraViewControllerDelegate, you can just provide that coordinator when you create a DocumentCamera.

We can also implement a DocumentCamera delegate that use action blocks to communicate delegate events back to the view:

extension DocumentCamera {
    
    class Delegate: NSObject, VNDocumentCameraViewControllerDelegate {
        
        init(
            didCancel: @escaping EmptyAction,
            didFail: @escaping ModelAction<Error>,
            didFinish: @escaping ModelAction<VNDocumentCameraScan>) {
            self.didCancel = didCancel
            self.didFail = didFail
            self.didFinish = didFinish
        }
        
        private let didCancel: EmptyAction
        private let didFail: ModelAction<Error>
        private let didFinish: ModelAction<VNDocumentCameraScan>
        
        func documentCameraViewControllerDidCancel(_ controller: VNDocumentCameraViewController) {
            didCancel()
        }
        
        func documentCameraViewController(_ controller: VNDocumentCameraViewController, didFailWithError error: Error) {
            didFail(error)
        }
        
        func documentCameraViewController(_ controller: VNDocumentCameraViewController, didFinishWith scan: VNDocumentCameraScan) {
            didFinish(scan)
        }
    }
}

This approach lets you bind the delegate events to actions directly within the view. A view can now just present the document camera with this delegate and provide its own actions, like this:

...
.sheet(isPresented: $isSheetActive) {
    DocumentCamera(delegate: DocumentCamera.Delegate(
        didCancel: { ... }
        didFail: { error in ... }
        didFinish: { scan in ... }
    ))
}
...

I personally prefer this approach, since it makes the DocumentCamera class provide you with everything you need. All you have to do is to inject the functions you want it to trigger.