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:
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 FlowRepresentable
s you mentioned earlier?
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?
FlowRepresentable
by the combination of
UIWorkflowItem
and
StoryboardLoadable
- The
UIWorkflowItem
class implements a subset of the requirements forFlowRepresentable
. .
-
StoryboardLoadable
implements the remaining subset and requires that it is only applied to aFlowRepresentable
.
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 FlowRepresentable
s 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
.thenProceed(with: 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?
Workflow
has compile-time type safety on the input/output types of the supplied
FlowRepresentable
s. 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
?
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()
?
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
?
shouldLoad
to validate if the provided input gets us the results we want.
What is going on with testProceedPassesThroughInput
?
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?
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 View
s 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