Articles, podcasts and news about Swift development, by John Sundell.

Inline wrapping of UIKit or AppKit views within SwiftUI

Published on 28 Mar 2020
Discover page available: SwiftUI

The fact that any UIKit or AppKit view can be wrapped in order to become SwiftUI-compatible is incredibly useful, as that sort of provides an “escape hatch” for whenever SwiftUI does not yet natively support a given type of control or UI element.

However, if we need to rely on a lot of (for the lack of a better term) “legacy” views, having to constantly write wrappers for each of them can start to become a bit tedious — especially for simpler views that don’t require any sophisticated logic, or views that we only want to use in a single place.

For example, let’s say we wanted to use UIActivityIndicatorView to display a loading spinner within a SwiftUI-based iOS app. In order to do that, we’d have to write a wrapper that’ll look something like this:

struct ActivityIndicator: UIViewRepresentable {
    func makeUIView(context: Context) -> UIActivityIndicatorView {
        UIActivityIndicatorView(style: .medium)
    }

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

While writing a wrapper that conforms to UIViewRepresentable (or NSViewRepresentable on the Mac) isn’t a huge task — wouldn’t it be nice if we instead could just wrap any legacy view inline, right where we need to use it?

Let’s make that happen by writing a generic type that can be used to wrap any UIView. Let’s call it Wrap, and have it take two closures, each corresponding to one of the method requirements of UIViewRepresentable — like this:

struct Wrap<Wrapped: UIView>: UIViewRepresentable {
    typealias Updater = (Wrapped, Context) -> Void

    var makeView: () -> Wrapped
    var update: (Wrapped, Context) -> Void

    init(_ makeView: @escaping @autoclosure () -> Wrapped,
         updater update: @escaping Updater) {
        self.makeView = makeView
        self.update = update
    }

    func makeUIView(context: Context) -> Wrapped {
        makeView()
    }

    func updateUIView(_ view: Wrapped, context: Context) {
        update(view, context)
    }
}

To make an equivalent generic wrapper for macOS, simply replace all instances of “UI” with “NS”.

Note the usage of @autoclosure above, which will enable us to keep following the conventions of UIViewRepresentable and create our views lazily, without requiring any additional syntax at the call sites.

However, when updating our view, our new Wrap type currently requires us to always handle both the view itself, and the current Context. While having access to the Context argument might be important for some use cases, let’s make it optional — by also introducing two convenience APIs that’ll let us either accept just our view as a single argument, or to opt out of updates entirely in case our view is completely static:

extension Wrap {
    init(_ makeView: @escaping @autoclosure () -> Wrapped,
         updater update: @escaping (Wrapped) -> Void) {
        self.makeView = makeView
        self.update = { view, _ in update(view) }
    }

    init(_ makeView: @escaping @autoclosure () -> Wrapped) {
        self.makeView = makeView
        self.update = { _, _ in }
    }
}

With the above in place, we can now easily wrap any UIView completely inline, while also being able to update it whenever our underlying state changes — like this:

struct ContentView: View {
    @ObservedObject var viewModel: ViewModel

    var body: some View {
        ZStack {
            ...
            Wrap(UIActivityIndicatorView()) {
                if self.viewModel.isLoading {
                    $0.startAnimating()
                } else {
                    $0.stopAnimating()
                }
            }
        }
    }
}

Very nice! This of course doesn’t mean that we should completely abandon building proper wrappers for certain views, but for simpler ones the above Wrap type is incredibly convenient.