This project demonstrates how to use Square’s Workflow library to compose workflows that render screens that back the views in your application. It involves a simple counter app that leverages the same Screen
and Workflow
types to back identical looking UIs built using either SwiftUI, UIKit, or a hybrid “declarative UIKit” paradigm.
Applications built under this approach are best thought of as having the layers screen, view, and workflow (SVW), as opposed to other layerings like MVC, MVVM, VIPER etc. All code in our application will fall under one of (or be used by one or more of) these layers.
Using the counter example, we start with a Counter.Screen
struct that describes how the user sees and interacts with the application when the counter is displayed.
extension Counter {
struct Screen {
let value: Int
let increment: () -> Void
let decrement: () -> Void
}
}
extension Counter.Screen {
var valueText: String { "The value is \(value)" }
var incrementTitle: String { "+" }
var decrementTitle: String { "-" }
}
This screen will back a view that uses these values to know both what to display, and what to do upon user interaction. It will display the current value
, execute the increment
closure when the increment button is tapped, and execute the decrement
closure when the decrement button is tapped. As a result, thanks to a workflow, a new value of Counter.Screen
will be passed into the view, with the updated value
, as described later.
A Counter.View
, in this example built with SwiftUI, is dependent on a Counter.Screen
in order to derive its body.
extension Counter {
struct View {
init() {}
}
}
extension Counter.View: BodyProvider {
typealias Screen = Counter.Screen
func body(with screen: Screen) -> some View {
VStack {
Text(screen.valueText)
Button(action: screen.increment) {
Text(screen.incrementTitle)
}
Button(action: screen.decrement) {
Text(screen.decrementTitle)
}
}
}
}
extension Counter.Screen: BodyBackingScreen {
typealias View = Counter.View
}
The resulting view has a body of a simple VStack
with Text
and two Button
s. Notice that Counter.View
declares an explicit dependency on Counter.Screen
, and in fact derives its entire body based on the properties of the screen. The reverse relationship must also be explicitly declared. All that is left is to define a workflow that renders this screen based on a state, constructing it such that actions performed will update this state and cause a new screen to be rendered.
Finally, the Counter.Workflow
type defines
- An internal state with an initial value
- How and what to render by constructing a screen based on that state
- Actions that can be performed as the result of user interaction with that rendering
- How to update its internal state given an action
- What to output upon an action that completes the workflow
(In many cases, the state may also include asynchronous workers, which have their own state and are typically can be started as the result of an action.)
extension Counter {
struct Workflow {}
}
We can define the workflow incrementally, first by conforming to the Workflow
protocol. This requires that we provide an Output
type: Void
in this case, since we don't care what the workflow outputs upon completion, only that it has completed.
It also needs an initial state and type, in this case the Int
0
(the initial value shown by the counter).
Finally, it needs to know what to render given its current state and a render context. Here, this rendering is a Counter.Screen
as defined above; the workflow uses its current state for the screen’s value
property, and closures that send Action
s (defined below) to a sink belonging to the workflow. A new rendering will be created, and hence the view backed by such a screen will be updated each time the workflow’s state updates.
extension Counter.Workflow: Workflow {
typealias Output = Void
func makeInitialState() -> Int { 0 }
func render(
state value: Int,
context: RenderContext<Self>
) -> Counter.Screen {
context.render { (sink: Sink<Action>) in
.init(
value: value,
increment: { sink.send(.increment) },
decrement: { sink.send(.decrement) }
)
}
}
}
Enums are a natural fit to define a workflow’s possible actions:
extension Counter.Workflow {
enum Action {
case increment
case decrement
case finish
}
}
They must conform to WorkflowAction
by providing our workflow’s type as an associated WorkflowType
, and a function that describes how one should update such a workflow’s state. This function is also responsible for returning an output if that action should complete the workflow, or nil
if no such completion is triggered. In this example (not shown) a back button could trigger a finish
action which would output ()
; all other actions would have no output.
extension Counter.Workflow.Action: WorkflowAction {
typealias WorkflowType = Counter.Workflow
func apply(toState value: inout Int) -> Void? {
switch self {
case .increment:
value += 1
case .decrement:
value -= 1
case .finish:
return ()
}
return nil
}
}
While Workflow can be used out of the box without any of this project’s own dependencies, I’ve had the best experience using the techniques shown here, especially when it comes to defining the view layer.
Ergo is a library that aims to simplify Workflow and provides the UI-framework-specific libraries ErgoSwiftUI
ErgoUIKit
, and ErgoDeclarativeUIKit
. Along with the main library, use the UI library/libraries that is/are appropriate for the UI framework(s) in your app.
When using Workflow, the views that comprise your user interface depend on a Screen
type. Depending on which UI framework you are using, this dependency is established in Ergo by conforming your view type to a protocol inheriting ScreenBacked
.
protocol ScreenBacked {
associatedtype Screen: WorkflowUI.Screen
}
In SwiftUI, your view will conform to BodyProvider
, and must return a SwiftUI View
as its body given a screen.
protocol BodyProvider: ScreenBacked {
associatedtype Body: View
init()
func body(with screen: Screen) -> Body
}
In UIKit, your view will conform to Updating
, and must indicate how it should be initialized and updated with a screen.
protocol Updating: ScreenBacked {
init(screen: Screen)
func update(with screen: Screen)
}
In Declarative UIKit, your view will conform to LayoutProvider
, and must return a Layoutless Layout
type given a screen. See more on Declarative UIKit below.
protocol LayoutProvider: ScreenBacked {
func layout(with screen: some ScreenProxy<Screen>) -> AnyLayout
}
You can see examples of all three conformances in Workflow Demo’s CounterSwiftUIView
, CounterUIKitView
, and CounterDeclarativeUIKitView
, respectively.
Ergo also provides its own Worker
class to represent asynchronous work your application needs to perform. A Worker
is generic over Input
and Output
and can be in one of three states:
enum State: CaseAccessible {
case ready
case working(Input)
case failed(Output.Failure)
}
A Worker
that has successfully completed its work and has successful output returns to the ready
state and reports its output back to the workflow in which it is running. A Worker
can support either working to produce a singular Output
when started with an Input
((Input) -> async Output
) or a continuous stream of Output
s until it has finished ((Input) -> AsyncStream<Output>
). Ergo uses EnumKit to facilitate access to this state.
As an example, consider a worker in a DemoList.Workflow
that works to update the list of demos shown. This workflow’s state would contain a Worker<Void, Result<[Demo], Error>>
called e.g. updateWorker
. It requires no input to start updating the list, and outputs either an array of demos (if it succeeds) or an error (if it fails). An example implementation of this workflow’s Action
’s apply
method would be responsible for starting this worker from the updateDemos
action.
case .updateDemos:
state.updateWorker.start()
We’ll send this action when the user taps the navigation bar’s right bar button item. The worker must also be referenced in the workflow’s render function, along with the action to send if it succeeds. In this case, if the worker is successful, we should dispatch the show
action to show the successfully loaded demos.
func render(
state: State,
context: RenderContext<Self>
) -> BackStack.Item {
context.render { (sink: Sink<Action>) in
.init(
screen: DemoList.Screen(
demos: state.demos,
selectDemo: { sink.send(.demo($0)) },
canSelectDemo: { _ in canSelectDemos },
isUpdatingDemos: state.updateWorker.isWorking
).asAnyScreen(),
barContent: .init(
rightItem: .init(
content: .text("Update"),
handler: { sink.send(.updateDemos) }
)
)
)
} running: {
state.updateWorker.mapSuccess(Action.show)
}
}
Now, all our Action
’s apply
function has to do is update the state with demos returned in the show
action:
case let .show(demos):
state.demos = demos
While SwiftUI boasts live previewing (“hot reloading”), no such functionality is found out of the box in UIKit. To compensate, this project demonstrates using Inject to support live previewing of UIKit views. When Inject is running, saving any changes to UI code will immediately cause the running app to reflect those changes. While not necessary to run the demo app, it can make for a reliable way to quickly iterate on your app’s user interface, especially combined with the modularization techniques shown below.
To exhaustively test all screen, workflow, and view code regardless of UI framework, this project makes use of swift-snapshot-testing, ViewInspector, and ErrorAssertions.
As described above, this project also demonstrates a reimagining of UIKit that uses similar declarative principles as SwiftUI. For example, a Counter.View
built with Declarative UIKit as opposed to SwiftUI would be implemented as follows, and is shown within the demo app:
extension Counter {
final class View: UIView {}
}
extension Counter.View: LayoutProvider {
typealias Screen = Counter.Screen
func layout(with screen: some ScreenProxy<Screen>) -> AnyLayout {
UIStackView.vertical.layout {
UILabel.default
.text(screen.valueText)
UIButton.default
.title(screen.incrementTitle)
.tapped(screen.increment)
UIButton.default
.title(screen.decrementTitle)
.tapped(screen.decrement)
}.centeringInParent()
}
}
extension Counter.Screen: LayoutBackingScreen {
typealias View = Counter.View
}
Refer also to the demo list view, which is implemented solely in Declarative UIKit. This choice provided a more concise and readable definition than even the SwiftUI equivalent.
extension DemoList {
final class View: UIView {}
}
extension DemoList.View: LayoutProvider {
typealias Screen = DemoList.Screen
func layout(with screen: some ScreenProxy<Screen>) -> AnyLayout {
UITableView.style(.insetGrouped).content(
items: screen.demos,
text: \.name,
loading: screen.isUpdatingDemos,
canSelectItem: screen.canSelectDemo.value
).itemSelected(screen.selectDemo).fillingParent()
}
}
extension DemoList.Screen: LayoutBackingScreen {
typealias View = DemoList.View
}
As defined above, the view consists of an inset grouped table view that displays rows with the names of the screen’s demos, or a row with a spinner if the screen is updating the demos. If a demo is selected (when possible as determined by the screen), the screen’s selectDemo
closure is executed.
These views make use of ErgoDeclarativeUIKit
and the Metric dependency—along with its Geometric and Telemetric submodules—in addition to Layoutless mentioned above. ReactiveCocoa and ReactiveDataSources power much of the declarative interface to UIKit elements.
This project follows a modular architecture with a clear separation of concerns. Central to its structure is the top-level Modules
directory, which itself is subdivided into the Models
, Services
, and Features
modules. Each feature module has an associated app used to showcase that feature in isolation. The full application, then, is simply the Root
feature’s app.
Crucially, the Root
feature’s child features, DemoList
and Counter
, have no knowledge of each other and, as their respective apps show, can exist in isolation—it is the responsibility of the parent feature, and only the parent feature, to detemermine the interaction between its child features.
Our simple app relies on a single model type, representing one of the three demos it is capable of showing.
enum Demo: Hashable {
case swiftUI
case uiKit(declarative: Bool)
}
A service that simulates loading demos from a network. It provides a single spec, LoadingSpec
, that clients can adopt.
protocol LoadingSpec {
associatedtype DemoLoadingResult
func loadDemos() async -> DemoLoadingResult
}
The main client provided in DemoAPI
simply sleeps for a given time, then either randomly returns the demos, or fails.
extension API: LoadingSpec {
func loadDemos() async -> Demo.LoadingResult {
do {
try await sleep(.updateTime)
return randomBool() ? .success(Demo.allCases) : .failure(.loadError)
} catch {
return .failure(.sleepError(error))
}
}
}
For testing purposes, the sleep
and randomBool
closures can be injected upon initialization.
The root feature of the application, which synthesizes the DemoList
and Counter
features. Under this feature, selecting a demo in the DemoList
feature will start the demo in the Counter
feature. When the user is finished interacting with the demo, Root
returns the user to the DemoList
feature.
A feature that displays a list of Demo
s, which can be “updated” using the DemoService
.
A feature (described above) that shows a number value which can be incremented and decremented.
Each feature module has a corresponding app to showcase its functionality. To run an app, simply select the associated scheme and invoke Product > Run. To set an environment variable, edit the scheme and select the Arguments tab under Run. All relevant environment variables for each app are already added, but can be disabled or updated.
Displayed as “Workflow Demo”, this is our “application.” Users are presented with a list of three demos: SwiftUI, UIKit, and Declarative UIKit. This list can be updated. Selecting an item in the list will start a counter demo built with the associated UI framework. The back button then returns the user to the list of demos.
An app to showcase the DemoList
feature in isolation. As a result, the demos are not selectable, and their rows display no disclosure indicator. Users can however tap “Update” in the navigation bar to reload the list, which may fail.
canUpdateDemos
: Whether updating the demos succeeds. Optional, defaults totrue
.updateDuration
: How long in seconds it takes to update the demos. Optional, defaults to 1.
An app to showcase the Counter
feature in isolation. As such, we are not coming from a DemoList
in this app, so the type of demo shown is indicated by an environment variable.
demo
: The demo to launch into. One ofswiftUI
,uiKit
, ordeclarativeUIKit
. Required.
Each module in this project comes with full unit test coverage. The developer can fully test a module by selecting its scheme and running the associated test plan. Feature modules provide unit tests for their screen, view, and workflow layers. Outside of the modules themselves, integration tests and snapshot tests are provided at the project level, and UI tests are provided for each app. See Square’s tutorial to learn how to write unit tests and integration tests for your workflows and their actions.