How to use Playground to speed up app development on iOS

iOS Xcode Playground Mar 1, 2021 · 6 min read

A lot of iOS developers are very excited about SwiftUI and Combine these days and looking forward to the day when they can start coding with these two amazing frameworks. There are a lot of benefits of using SwiftUI, one of which being previews. To be able to see the result of a custom UI view without running it on a simulator or a real device is amazing and a huge time-saver. Do you know how much time we, iOS developers, waste on this? A lot, more than that we can imagine. So, why can't we just migrate to SwiftUI and Combine now? First, it takes a lot of time to rewrite the legacy codebase. Second, these frameworks are still quite young and lack some important features of UIKit so they can't replace UIKit entirely, at least, not right now. Third, there are still some annoying bugs not fixed yet. Fourth, they are only supported on iOS13+. As you can see, problems keep cropping up. Well, it might be a good idea to start a new hobby project with them right now. For serious production projects, I recommend waiting for a couple of years at least. For now, we can use Playground as a preview, which is one of the most useful features of SwiftUI, and debug UI bugs without running a project and navigating to a custom view that we want to test.

Create Xcode project

Create a new Xcode project by going to File -> New -> Project... -> iOS -> App and name it PlaygroundDemo. Let's create two simple UIViewControllers named ButtonViewController and LabelViewController. In AppDelegate, we set up the key window whose rootViewController is set to an instance of UINavigationController. The rootViewController of the UINavigationController is set to an instance of ButtonViewController. When we run the project, it shows the ButtonViewController with a button in the middle of a screen. When we tap on it, it navigates to the LabelViewController with a label in the middle as well.

import UIKit

@main
final class AppDelegate: UIResponder, UIApplicationDelegate {
    var window: UIWindow?

    func application(
        _ application: UIApplication,
        didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
    ) -> Bool {
        window = UIWindow(frame: UIScreen.main.bounds)
        window?.rootViewController = UINavigationController(rootViewController: ButtonViewController())
        window?.makeKeyAndVisible()

        return true
    }
}
import UIKit

final class ButtonViewController: UIViewController {
    private lazy var button: UIButton = {
        let button = UIButton()
        button.translatesAutoresizingMaskIntoConstraints = false
        button.addTarget(self, action: #selector(touchUpInside(button:)), for: .touchUpInside)
        button.setTitleColor(.black, for: .normal)
        button.setTitleColor(.gray, for: .highlighted)
        button.setTitle("Button", for: .normal)

        return button
    }()

    override func loadView() {
        super.loadView()

        view.addSubview(button)

        NSLayoutConstraint.activate([
            button.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            button.centerYAnchor.constraint(equalTo: view.centerYAnchor)
        ])
    }

    override func viewDidLoad() {
        super.viewDidLoad()

        view.backgroundColor = .white
    }

    @objc private func touchUpInside(button: UIButton) {
        let viewController = LabelViewController()
        navigationController?.pushViewController(viewController, animated: true)
    }
}
import UIKit

final class LabelViewController: UIViewController {
    private lazy var label: UILabel = {
        let label = UILabel()
        label.translatesAutoresizingMaskIntoConstraints = false
        label.textAlignment = .center
        label.text = "Label"

        return label
    }()

    override func loadView() {
        super.loadView()

        view.addSubview(label)

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

    override func viewDidLoad() {
        super.viewDidLoad()

        view.backgroundColor = .white
    }
}

Create Playground

Create a new Playground by going to File -> New -> Playground... -> iOS -> Blank and name it Playground.playground. Copy and paste the code in the ButtonViewController to see its preview without running it on a simulator or a real device.

import PlaygroundSupport
import UIKit

final class ButtonViewController: UIViewController {
    private lazy var button: UIButton = {
        let button = UIButton()
        button.translatesAutoresizingMaskIntoConstraints = false
        button.addTarget(self, action: #selector(touchUpInside(button:)), for: .touchUpInside)
        button.setTitleColor(.black, for: .normal)
        button.setTitleColor(.gray, for: .highlighted)
        button.setTitle("Button", for: .normal)

        return button
    }()

    override func loadView() {
        super.loadView()

        view.addSubview(button)

        NSLayoutConstraint.activate([
            button.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            button.centerYAnchor.constraint(equalTo: view.centerYAnchor)
        ])
    }

    override func viewDidLoad() {
        super.viewDidLoad()

        view.backgroundColor = .white
    }

    @objc private func touchUpInside(button: UIButton) {
        let viewController = LabelViewController()
        navigationController?.pushViewController(viewController, animated: true)
    }
}

PlaygroundPage.current.liveView = ButtonViewController()

It works like a charm if you are working on a project by yourself. But, if you work in a team, you should think about how to advance UI development for the whole team as well. Why not add the Playground to the Xcode project and share it with the team? We can do it by selecting the Xcode project from the Project Navigator, Control + Click, choose New File... -> iOS -> Blank Playground, and copy and paste the code above. We should push this change to our shared remote repository, make sure that other teammates have pulled it locally, and maybe add it to .gitignore. Why? Well, you may not want to track changes in it because everyone can work on different UI views and may accidentally push their own changes into the Playground. The above code just serves as an example for the team on how to use the Playground. So far so good until our UIViewControllers become more complex with a lot of both local and third-party dependencies. To make it work, we must also copy and paste those dependencies into the Playground. Oops! Then, you may start thinking "Can't Playground automatically see and compile the code in the Xcode project?" and write the code below.

import PlaygroundSupport

PlaygroundPage.current.liveView = ButtonViewController()
// Playground execution failed:
// error: ButtonViewController.xcplaygroundpage:4:35: error: cannot find 'ButtonViewController' in scope
// PlaygroundPage.current.liveView = ButtonViewController()

Well, it is quite sensible and logical to do like that but it doesn't currently work. Apple is promising to role out changes to make it work on Xcode 12.5. For now, we can achieve the same result by creating a Framework. But, we have to create a new Workspace first.

Create Workspace

Create a new Workspace by going to File -> Save as Workspace... and name it the same as the Xcode project. Close the Xcode project and open the Workspace.

Create Framework

Create a new Framework by going to File -> New -> Target... -> iOS -> Framework, name it PlaygroundDemoUI, and set the Embed in Application to None. Select the ButtonViewController and LabelViewController one by one, open the File Inspector, and check the PlaygroundDemoUI framework under the Target Membership for each file.

import PlaygroundDemoUI
import PlaygroundSupport

PlaygroundPage.current.liveView = ButtonViewController()
// Playground execution failed:
// error: ButtonViewController.xcplaygroundpage:4:35: error: cannot find 'ButtonViewController' in scope
// PlaygroundPage.current.liveView = ButtonViewController()

But, we get the same error. Why? Because the access level in both ButtonViewController and LabelViewController is internal. We must make them public at least to make them visible to the Playground from the PlaygroundDemoUI framework. You might say "No way!". I am also against this because it will clutter my public interface with unintended classes that are only intended to be used in the Playground. But, there is a much better solution. Do you remember how you can write tests for internal classes or structs without making them public? Yes, that is right. It is @testable import.

@testable import PlaygroundDemoUI
import PlaygroundSupport

PlaygroundPage.current.liveView = ButtonViewController()

Playground Pages

Xcode supports nested pages with PlaygroundPage. So, instead of adding the Playground.playground file to .gitignore, we can create a new PlaygroundPage for each UIViewController and push the changes to our shared remote repository. For that, select the Playground.playground -> Control + Click -> New Playground Page. Do this for both ButtonViewController and LabelViewController and give them the corresponding names.

@testable import PlaygroundDemoUI
import PlaygroundSupport

PlaygroundPage.current.liveView = ButtonViewController()
@testable import PlaygroundDemoUI
import PlaygroundSupport

PlaygroundPage.current.liveView = LabelViewController()

Now, the members of your team not only code UI views, but also provide previews as a demo.

Conclusion

Of course, Playground is not meant to replace SwiftUI anyhow. We are still waiting for SwiftUI to mature and be production-ready. But, we can at least benefit from using Playground as a preview that can help us speed up app development and improve communication, code reviews, and collaboration within a team.