This article explains how to use SwiftUI on iOS 12 or below projects. Specifically, to display previews of existing views and view controllers during development.

Preview a view controller using SwiftUI

The simplest thing to start with would be to display a view controller that represents a screen. For example, if had the following view controller:

class ViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = .lightGray
        let label = UILabel()
        label.text = "Hello world"
        label.backgroundColor = .gray

        view.addSubview(label)
        label.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            label.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            label.centerYAnchor.constraint(equalTo: view.centerYAnchor),
        ])
    }
}

We can implement UIViewControllerRepresentable to instatiate a ViewController for SwiftUI to use and then implement PreviewProvider to make Xcode render it.

import SwiftUI

// This prevents a runtime crash in case somebody initialises
// this type on a device with a version earlier than on an iOS 13
// as SwiftUI is not available
@available(iOS 13, *)
struct ViewControllerPreview: UIViewControllerRepresentable {
    func makeUIViewController(context _: Context) -> ViewController {
        return ViewController()
    }

    func updateUIViewController(_: ViewController, context _: Context) {}
}

// This is needed since the `some` keyword is only available on
// iOS 13 and above
@available(iOS 13, *)
struct ViewController_Previews: PreviewProvider {
    static var previews: some View {
        ViewControllerPreview()
    }
}

This produces the following preview, note that I’ve selected a iPhone SE (2nd generation) simulator on Xcode:

With a small snippet of code we can see how the ViewController looks without having to run the simulator and navigate to that screen.

Notes:

  • The project can have a deployment target of iOS 12 or below but an iOS 13 simulator needs to be selected for the preview to shoe. Xcode seems to use the selected simulator to render the preview.
  • To prevent runtime crashes, it is convenient to mark all SwiftUI related types with @available(iOS 13, *). This will ensure iOS does not try to load SwiftUI on an iOS 12 device.

Preview a custom UIView using SwiftUI

The next step is to preview a custom view, for example if we had the following:

class ContentView: UIView {
    init() {
        super.init(frame: .zero)
        setupViews()
    }

    required init?(coder _: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    private func setupViews() {
        let label = UILabel()
        label.text = "I am a ContentView"
        label.backgroundColor = .orange
        label.numberOfLines = 0
        addSubview(label)

        label.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            label.leadingAnchor.constraint(equalTo: leadingAnchor),
            label.trailingAnchor.constraint(equalTo: trailingAnchor),
            label.topAnchor.constraint(equalTo: topAnchor),
            label.bottomAnchor.constraint(equalTo: bottomAnchor),
        ])
    }
}

We would then create a preview of the ContentView by implementing UIViewRepresentable and PreviewProvider.

@available(iOS 13, *)
struct ContentViewWrapped: UIViewRepresentable {
    func makeUIView(context _: Context) -> UIView {
        return ContentView()
    }

    func updateUIView(_: UIView, context _: Context) {}
}

@available(iOS 13, *)
struct ContentViewWrapped_Previews: PreviewProvider {
    static var previews: some View {
        ContentViewWrapped()
    }
}

This results in a preview that stretches to the size of the simulator that is selected.

To constrains the view to it’s intrinsic size we would set it to fixedSize() and set the preview layout to sizeThatFits:

@available(iOS 13, *)
struct ContentViewWrappedFixed_Previews: PreviewProvider {
    static var previews: some View {
        ContentViewWrapped()
            .fixedSize()
            .previewLayout(.sizeThatFits)
    }
}

Unfortunately this does not have the intended result:

We would expect something similar to this:

Note that the above expected image was created using a Text component from SwiftUI:

@available(iOS 13, *)
struct Text_Previews: PreviewProvider {
    static var previews: some View {
        Text("I am a ContentView")
            .background(Color.orange)
            .previewLayout(.sizeThatFits)
    }
}

As of the time writing this article it does not look like SwiftUI’s UIViewRepresentable can calculate the size of a simple UIView that uses Auto Layout. Calculating the size manually and overriding intrinsicContentSize SwiftUI renders the view with that size. Therefore, using fixedSize() does not seem to work with custom views using Auto Layout. I guess we can go back to making views like we did on iOS 5 prior to the release of Auto Layout.

Hopefully, I’m either misunderstanding how SwiftUI works or this is a bug. Otherwise, if it is not possible to wrap UIView subclasses using UIViewRepresentable, it would make adopting SwiftUI on existing apps more difficult as one could not use the existing custom views with new SwiftUI code.

A way to bypass this problem is to place the custom UIView in a view controller and then preview the view controller. This is done by creating a ViewPreviewer type that implements UIViewControllerRepresentable and adding an extension to UIView to have a nicer entry point.

import SwiftUI

extension UIView {
    @available(iOS 13, *)
    func embedForPreview() -> some View {
        return ViewPreviewer(contentView: self)
    }
}

@available(iOS 13, *)
private struct ViewPreviewer: UIViewControllerRepresentable {
    let contentView: UIView

    func makeUIViewController(context _: Context) -> UIViewController {
        return ViewControllerContainer(view: contentView)
    }

    func updateUIViewController(_: UIViewController, context _: Context) {}
}

private class ViewControllerContainer: UIViewController {
    let container: UIView

    init(view: UIView) {
        container = view
        super.init(nibName: nil, bundle: nil)
    }

    required init?(coder _: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        view.addSubview(container)
        container.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            container.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            container.centerYAnchor.constraint(equalTo: view.centerYAnchor),
            container.leadingAnchor.constraint(greaterThanOrEqualTo: view.leadingAnchor),
            container.topAnchor.constraint(greaterThanOrEqualTo: view.topAnchor),
        ])
    }
}

We can then preview the ContentView view using:

@available(iOS 13, *)
struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
            .embedForPreview()
    }
}

By default the preview is shown in a simulator:

To make the preview bounding box smaller a fixed size for the preview can be provided:

@available(iOS 13, *)
struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
            .embedForPreview()
            .previewLayout(.fixed(width: 200, height: 200))
    }
}

This results on:

This is not ideal but at least it allows as to see how the view would layout on different sizes. For example, using .previewLayout(.fixed(width: 100, height: 100))

Conclusions

I find it useful to have previews of view controller using SwiftUI since this is faster that having to launch the simulator and navigate to that screen. Also, it helps with decoupling view controllers as it needs to be easy to initialise them when implementing UIViewControllerRepresentable.

Since this approach is simple and it helps with development while keeping production code the same, this is a handy trick for any codebase that uses Xcode 11.

I’d like to thank Nahuel Marisi for reviewing this article.