Overview

This guide will walk you through getting a Workflow up and running in a new iOS project. If you would like to see an existing project, clone the repo and view the UIKitExample scheme in SwiftCurrent.xcworkspace.

The app in this guide is going to be very simple. It consists of a screen that will launch the Workflow, a screen to enter an email address, and an optional screen for when the user enters an email with @wwt.com in it. Here is a preview of what the app will look like:

Preview image of app

Adding the Dependency

For instructions on SPM and CocoaPods, check out our installation page..

IMPORTANT NOTE

SwiftCurrent is so convenient that you may miss the couple of lines that are calls to the library. To make it easier, we’ve marked our code snippets with // SwiftCurrent to highlight items that are coming from the library.

Create the Convenience Protocols for Storyboard Loading

It is best practice to use the StoryboardLoadable protocol to connect your FlowRepresentable to your storyboard. Additionally, to limit the amount of duplicate code, you can make a convenience protocol for each storyboard.

import UIKit
import SwiftCurrent_UIKit

extension StoryboardLoadable { // SwiftCurrent
    // Assumes that your storyboardId will be the same as your UIViewController class name
    static var storyboardId: String { String(describing: Self.self) }
}

protocol MainStoryboardLoadable: StoryboardLoadable { }
extension MainStoryboardLoadable {
    static var storyboard: UIStoryboard { UIStoryboard(name: "Main", bundle: Bundle(for: Self.self)) }
}

NOTE: StoryboardLoadable is only available in iOS 13.0 and later.

Create Your View Controllers

Create two view controllers that both conform to MainStoryboardLoadable and inherit from UIWorkflowItem.

First view controller:

import UIKit
import SwiftCurrent_UIKit

class FirstViewController: UIWorkflowItem<String, String>, MainStoryboardLoadable { // SwiftCurrent
    private let name: String

    @IBOutlet private weak var emailTextField: UITextField!
    @IBOutlet private weak var welcomeLabel: UILabel! {
        willSet(this) {
            this.text = "Welcome \(name)!"
        }
    }

    required init?(coder: NSCoder, with name: String) { // SwiftCurrent
        self.name = name
        super.init(coder: coder)
    }

    required init?(coder: NSCoder) { nil }

    @IBAction private func savePressed(_ sender: Any) {
        proceedInWorkflow(emailTextField.text ?? "") // SwiftCurrent
    }
}

Second view controller:

import UIKit
import SwiftCurrent_UIKit

// This screen shows an employee only screen
class SecondViewController: UIWorkflowItem<String, String>, MainStoryboardLoadable { // SwiftCurrent
    private let email: String
    required init?(coder: NSCoder, with email: String) { // SwiftCurrent
        self.email = email
        super.init(coder: coder)
    }

    required init?(coder: NSCoder) { nil }

    @IBAction private func finishPressed(_ sender: Any) {
        proceedInWorkflow(email) // SwiftCurrent
    }

    func shouldLoad() -> Bool { // SwiftCurrent
        return email.contains("@wwt.com")
    }
}

What Is Going on With These View Controllers?

Where are the FlowRepresentables you mentioned earlier?

You could declare these view controllers with class FirstViewController: UIWorkflowItem , FlowRepresentable, MainStoryboardLoadable , but the FlowRepresentable is not specifically needed, so we excluded it from our example.

Why is FlowRepresentable not needed in the declaration?

These view controllers adhere to FlowRepresentable by the combination of UIWorkflowItem and StoryboardLoadable
  1. The UIWorkflowItem class implements a subset of the requirements for FlowRepresentable.
  2. .
  3. StoryboardLoadable implements the remaining subset and requires that it is only applied to a FlowRepresentable.

Why these initializers?

StoryboardLoadable helps guide XCode to give you compiler errors with the appropriate fix-its to generate required init?(coder: NSCoder, with args: String). These initializers allow you to load from a storyboard while also having compile-time safety in your properties. You will notice that both view controllers store the argument string on a private let property.

What’s this shouldLoad()?

FlowRepresentable.shouldLoad() is part of the FlowRepresentable protocol. It has default implementations created for your convenience but is still implementable if you want to control when a FlowRepresentable should load in the workflow. It is called after init but before viewDidLoad().

Launching the Workflow

Next, we create a Workflow that is initialized with our FlowRepresentables and launch it from a view controller that is already loaded onto the screen (in our case, the ‘ViewController’ class provided by Xcode).

import UIKit
import SwiftCurrent
import SwiftCurrent_UIKit

class ViewController: UIViewController {
    @IBAction private func launchWorkflow() {
        let workflow = Workflow(FirstViewController.self) // SwiftCurrent
                            .thenPresent(SecondViewController.self) // SwiftCurrent

        launchInto(workflow, args: "Some Name") { passedArgs in // SwiftCurrent
            workflow.abandon()
            guard case .args(let emailAddress as String) = passedArgs else {
                print("No email address supplied")
                return
            }
            print(emailAddress)
        }
    }
}

What Is Going on Here?

Where is the type safety I heard about?

The Workflow has compile-time type safety on the input/output types of the supplied FlowRepresentables. This means that you will get a build error if the output of FirstViewController does not match the input type of SecondViewController.

What’s going on with this passedArgs?

The onFinish closure for UIViewController.launchInto(_:args:withLaunchStyle:onFinish:) provides the last passed AnyWorkflow.PassedArgs in the workflow. For this workflow, that could be the output of FirstViewController or SecondViewController depending on the email signature typed in FirstViewController. To extract the value, we unwrap the variable within the case of .args() as we expect this workflow to return some argument.

Why call abandon()?

Calling Workflow.abandon() closes all the views launched as part of the workflow, leaving you back on ViewController.

Testing

Installing Test Dependencies

For our test example, we are using a library called UIUTest. It is optional for testing SwiftCurrent, but in order for the example to be copyable, you will need to add the UIUTest Swift Package to your test target.

Creating Tests

import XCTest
import UIUTest
import SwiftCurrent

// This assumes your project was called GettingStarted.
@testable import GettingStarted

class SecondViewControllerTests: XCTestCase {
    func testSecondViewControllerDoesNotLoadWhenInputIsEmpty() {
        let ref = AnyFlowRepresentable(SecondViewController.self, args: .args(""))
        let testViewController = (ref.underlyingInstance as! SecondViewController)

        XCTAssertFalse(testViewController.shouldLoad(), "SecondViewController should not load")
    }

    func testSecondViewControllerLoadsWhenInputIsContainsWWTEmail() {
        let ref = AnyFlowRepresentable(SecondViewController.self, args: .args("Awesome.Possum@wwt.com"))
        let testViewController = (ref.underlyingInstance as! SecondViewController)

        XCTAssert(testViewController.shouldLoad(), "SecondViewController should load")
    }

    func testProceedPassesThroughInput() {
        // Arrange
        var proceedInWorkflowCalled = false
        let expectedString = "Awesome.Possum@wwt.com"
        let ref = AnyFlowRepresentable(SecondViewController.self, args: .args(expectedString))
        var testViewController = (ref.underlyingInstance as! SecondViewController)
        // Mimicking the lifecycle of the view controller
        _ = testViewController.shouldLoad()
        testViewController.loadForTesting() // UIUTest helper

        testViewController.proceedInWorkflowStorage = { passedArgs in
            proceedInWorkflowCalled = true
            XCTAssertEqual(passedArgs.extractArgs(defaultValue: "defaultValue used") as? String, expectedString)
        }

        // Act
        (testViewController.view.viewWithAccessibilityIdentifier("finish") as? UIButton)?.simulateTouch() // UIUTest helper

        // Assert
        XCTAssert(proceedInWorkflowCalled, "proceedInWorkflow should be called")
    }
}

While this team finds that testing our view controllers with UIUTest allows us to decrease the visibility of our properties and provide better coverage, UIUTest is not needed for testing SwiftCurrent. If you do not want to take the dependency, you will have to elevate visibility or find a way to invoke the finishPressed method.

What is going on with testSecondViewControllerDoesNotLoadWhenInputIsEmpty?

This test is super simple. We create the view controller in a way that will go through the correct init, with expected arguments. Then we call shouldLoad to validate if the provided input gets us the results we want.

What is going on with testProceedPassesThroughInput?

At a high level, we are loading the view controller for testing (similar to before but now with an added step of triggering lifecycle events). We update the proceedInWorkflow closure so that we can confirm it was called. Finally, we invoke the method that will call proceed. The assert is verifying that the output is the same as the input, as this view controller is passing it through.

I added UIUTest, why isn’t it hitting the finish button?

It’s easy to forget to set the accessibility identifier on the button, please check that first. Second, if you don’t call loadForTesting(), your view controller doesn’t make it to the window and the hit testing of simulateTouch() will also fail. Finally, make sure the button is visible and tappable on the simulator you are using.

Interoperability With SwiftUI

You can use your SwiftUI Views that are FlowRepresentable in your UIKit workflows. Start with your View.

import SwiftUI
import SwiftCurrent

struct SwiftUIView: View, FlowRepresentable { // SwiftCurrent
    weak var _workflowPointer: AnyFlowRepresentable? // SwiftCurrent

    var body: some View {
        Text("FR2")
    }
}

Now in your UIKit workflow, simply use a HostedWorkflowItem.

launchInto(Workflow(HostedWorkflowItem<SwiftUIView>.self)) // SwiftCurrent