How to use UIKit in SwiftUI

Using UIView and UIViewController in SwiftUI


The good thing about SwiftUI is whenever you hit a roadblock you can always come back to good old UIKit (and AppKit/WatchKit). Today I’m going to show you some examples of how to do it.

Representable Protocol

Apple provided you with 5 representable protocol to wrap UIKit/AppKit/WatchKit into SwiftUI.

UIKit/AppKit/WatchKit Protocol
UIView UIViewRepresentable
NSView NSViewRepresentable
WKInterfaceObject WKInterfaceObjectRepresentable
UIViewController UIViewControllerRepresentable
NSViewController NSViewControllerRepresentable

These protocols have a very same life cycle and methods, with a purpose to bring the reactive capability to UIKit/AppKit/WatchKit (I will use just UIKit in the rest of the post, but everything applies to all three).

Simple Views

Let’s start with the simplest form of view where we only set some state and render. I will use UIActivityIndicator as an example.

First, we need to create a representable SwiftUI view for UIActivityIndicator that conform to UIViewRepresentable

struct ActivityIndicator: UIViewRepresentable {

    func makeUIView(context: Context) -> UIActivityIndicatorView {
        let v = UIActivityIndicatorView()

        return v
    }

    func updateUIView(_ activityIndicator: UIActivityIndicatorView, context: Context) {
        activityIndicator.startAnimating()
    }
}

Use this view and you will see spinning indicator.

struct ContentView : View {

    var body: some View {
        ActivityIndicator()        
    }
}

You can expose startAnimating and stopAnimating in the form of binding value.

struct ActivityIndicator: UIViewRepresentable {
    @Binding var isAnimating: Bool

    func makeUIView(context: Context) -> UIActivityIndicatorView {
        let v = UIActivityIndicatorView()

        return v
    }

    func updateUIView(_ activityIndicator: UIActivityIndicatorView, context: Context) {
        if isAnimating {
            activityIndicator.startAnimating()
        } else {
            activityIndicator.stopAnimating()
        }
    }
}

This will let parent view inject isAnimating state and control the animation, func updateUIView will be getting called repeatedly with the latest configuration.

Advanced Views

Not every view are created simple, if your view has relied on old ways of data binding like target/action, delegate you need Coordinator. Coordinator is a bridge between UIView and SwiftUI, its main job is to listen to UIKit whether as target or delegate and communicate that back to SwiftUI. The best way to understand is to see it in action.

We will use UIPageControl as an example here.

struct PageControl: UIViewRepresentable {
    var numberOfPages: Int
    @Binding var currentPage: Int

    func makeCoordinator() -> Coordinator {
        Coordinator(self)
    }

    func makeUIView(context: Context) -> UIPageControl {
        let pageControl = UIPageControl()
        pageControl.numberOfPages = numberOfPages
        pageControl.addTarget(
            context.coordinator,
            action: #selector(Coordinator.updateCurrentPage(sender:)),
            for: .valueChanged)

        return pageControl
    }

    func updateUIView(_ pageControl: UIPageControl, context: Context) {
        pageControl.currentPage = currentPage
    }

    class Coordinator: NSObject {
        var pageControl: PageControl

        init(_ pageControl: PageControl) {
            self.pageControl = pageControl
        }

        @objc func updateCurrentPage(sender: UIPageControl) {
            pageControl.currentPage = sender.currentPage
        }
    }
}

In this example Coordinator act as a target for UIPageControl‘s .valueChanged event and keep currentPage in sync with the view.

UIViewController

It is no difference between UIView and UIViewControll since everything is a view in SwiftUI.

We use UIPageViewController as an example here. You will see there is not much different the only change is Coordinator act as delegate and dataSuorce instead of target.

struct PageViewController: UIViewControllerRepresentable {
    var controllers: [UIViewController]
    @Binding var currentPage: Int

    func makeCoordinator() -> Coordinator {
        Coordinator(self)
    }

    func makeUIViewController(context: Context) -> UIPageViewController {
        let pageViewController = UIPageViewController(
            transitionStyle: .scroll,
            navigationOrientation: .horizontal)
        pageViewController.dataSource = context.coordinator
        pageViewController.delegate = context.coordinator

        return pageViewController
    }

    func updateUIViewController(_ pageViewController: UIPageViewController, context: Context) {
        pageViewController.setViewControllers(
            [controllers[currentPage]], direction: .forward, animated: true)
    }

    class Coordinator: NSObject, UIPageViewControllerDataSource, UIPageViewControllerDelegate {
        var parent: PageViewController

        init(_ pageViewController: PageViewController) {
            self.parent = pageViewController
        }

        func pageViewController(
            _ pageViewController: UIPageViewController,
            viewControllerBefore viewController: UIViewController) -> UIViewController?
        {
            guard let index = parent.controllers.firstIndex(of: viewController) else {
                return nil
            }
            if index == 0 {
                return parent.controllers.last
            }
            return parent.controllers[index - 1]
        }

        func pageViewController(
            _ pageViewController: UIPageViewController,
            viewControllerAfter viewController: UIViewController) -> UIViewController?
        {
            guard let index = parent.controllers.firstIndex(of: viewController) else {
                return nil
            }
            if index + 1 == parent.controllers.count {
                return parent.controllers.first
            }
            return parent.controllers[index + 1]
        }

        func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) {
            if completed,
                let visibleViewController = pageViewController.viewControllers?.first,
                let index = parent.controllers.firstIndex(of: visibleViewController)
            {
                parent.currentPage = index
            }
        }
    }
}

Life cycle

In the lifetime of a representable view, SwiftUI initializes a view/view controller and a coordinator, updates them (one or more time), and deinitializes them. For each phase, SwiftUI calls the following methods in order:

Initialization phase:

makeCoordinator()
make[UIView|UIViewController|NSView|NSViewController|WKInterfaceObject](context:)
update[UIView|UIViewController|NSView|NSViewController|WKInterfaceObject](_:context:)

Update phase:

update[UIView|UIViewController|NSView|NSViewController|WKInterfaceObject](_:context:)

Deinitialization phase:

dismantle[UIView|UIViewController|NSView|NSViewController|WKInterfaceObject](_:coordinator:)

Conclusion

This backward compatibility gives me peace of mind about adopting SwiftUI in my next project (which would take another year since its only compatible with iOS13). Knowing that I can always come back and rely on the old framework is a big win for me.

SwiftUI caught my attention the most for this year in WWDC, I still have some concerns and questions about this new declarative UI which I will cover in upcoming weeks. Feel free to follow me on Twitter and ask your questions related to this post. Thanks for reading and see you next time.