Have you heard of Unidirectional Data Flow, but have no idea what it involves? Or perhaps looked at some of the frameworks but they appear too complex?
This tutorial will show you how to build a Realm app using a very simple implementation of the 1-way flow architectural style without using any existing unidirectional frameworks.
Overview
The MVC design pattern has always been the cornerstone of Cocoa development (and UI programming in general), but alternatives have surfaced recently in the web community. One such alternative is the idea of Unidirectional Data Flow, as used by the React & Flux patterns developed at Facebook, which help solve some of the problems experienced with two-way bindings in complex applications.
The Unidirectional Data Flow pattern is also starting to be applied to native mobile development, where it can significantly simplify multi-threaded or callback-heavy code, and help with a large class of bugs related to tracking state using mutable properties in your view controllers.
As an example, we’re going to build a simple time tracking app. This will support:
- Adding a new project
- Starting and stopping an activity
- Viewing the elapsed time on each project
- Deleting a project
Introduction to Unidirectional Data Flow
The key elements of this style are:
- Encapsulate all the state for your application in a single data structure - ‘a single source of truth’ (the Store).
- Ensure all state changes (Actions) are performed directly on this data structure, and published as change notification events.
- Make sure views are only updated in response to state change notifications.
For more details, check out Benjamin Encz’s great introduction to Unidirectional Data Flow
Let’s get started, but if you want to jump to the end product, you can see the final code on GitHub here.
Tutorial
Create a new Xcode project, using the “Single View Application” template. Be sure “Language” is set to Swift, and that “Use Core Data” is unchecked.
Add the Realm and RealmSwift frameworks using your preferred dependency approach (see instructions for CocoaPods, Carthage, and binary installs here).
Add a new Swift file called ‘Store.swift’ and create the Project
and Activity
Realm objects - these will be used to record the application state.
import RealmSwift
class Project: Object {
dynamic var name: String = ""
let activities = List<Activity>()
}
class Activity: Object {
dynamic var startDate: NSDate?
dynamic var endDate: NSDate?
}
We’ll also take this opportunity to add a couple of computed properties to the Project
class, which will simplify some code a little later on.
extension Project {
var elapsedTime: NSTimeInterval {
return activities.reduce(0) { time, activity in
guard let start = activity.startDate,
let end = activity.endDate else { return time }
return time + end.timeIntervalSinceDate(start)
}
}
var currentActivity: Activity? {
return activities.filter("endDate == nil").first
}
}
Next we’re going to create the Store. The good news is that Realm already works very much like a unidirectional data store and we don’t need to write a lot of boilerplate code to implement this.
We’ll use the built-in Realm change notifications to trigger view updates - this will automatically detect & publish updates from realms on background threads, too.
First we’ll extend Realm
with a computed property that returns the current application state - in this case it’s just a list of all our projects.
// MARK: Application/View state
extension Realm {
var projects: Results<Project> {
return objects(Project.self)
}
}
Next we’ll build the actions, again by extending Realm
. Actions are the only methods that should change model data in the Realm, and they shouldn’t return a value - any changes to the model are published to the views via a notification. This ensures the view can be consistently redrawn each time the state is updated, regardless of where the change originated.
// MARK: Actions
extension Realm {
func addProject(name: String) {
do {
try write {
let project = Project()
project.name = name
add(project)
}
} catch {
print("Add Project action failed: \(error)")
}
}
func deleteProject(project: Project) {
do {
try write {
delete(project.activities)
delete(project)
}
} catch {
print("Delete Project action failed: \(error)")
}
}
func startActivity(project: Project, startDate: NSDate) {
do {
try write {
let act = Activity()
act.startDate = startDate
project.activities.append(act)
}
} catch {
print("Start Activity action failed: \(error)")
}
}
func endActivity(project: Project, endDate: NSDate) {
guard let activity = project.currentActivity else { return }
do {
try write {
activity.endDate = endDate
}
} catch {
print("End Activity action failed: \(error)")
}
}
}
At the bottom of the file, create a global instance of Store
.
let store = try! Realm()
Now let’s implement the View layer. Open your ‘ViewController.swift’ file and change it from a UIViewController
to a UITableViewController
subclass. Add a projects
property and override the required UITableViewDataSource
methods. We’ll also add a UITableViewCell
subclass while we’re at it - note that the cell will reset each of its subview properties whenever the project
property changes; again, this is important to ensure the view is updated consistently when the model changes.
class ViewController: UITableViewController {
let projects = store.projects
override func numberOfSectionsInTableView(tableView: UITableView) -> Int {
return 1
}
override func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return projects.count
}
override func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCellWithIdentifier("ProjectCell") as! ProjectCell
cell.project = projects[indexPath.row]
return cell
}
}
class ProjectCell: UITableViewCell {
@IBOutlet var nameLabel: UILabel!
@IBOutlet var elapsedTimeLabel: UILabel!
@IBOutlet var activityButton: UIButton!
var project: Project? {
didSet {
guard let project = project else { return }
nameLabel.text = project.name
if project.currentActivity != nil {
elapsedTimeLabel.text = "⌚️"
activityButton.setTitle("Stop", forState: .Normal)
} else {
elapsedTimeLabel.text = NSDateComponentsFormatter().stringFromTimeInterval(project.elapsedTime)
activityButton.setTitle("Start", forState: .Normal)
}
}
}
@IBAction func activityButtonTapped() {
guard let project = project else { return }
if project.currentActivity == nil {
// TODO: start a new activity
} else {
// TODO: complete the activity
}
}
}
Next we’ll register the view controller as a notification observer on our store, so we know to reload the view when the state changes. Implement Realm notifications on ViewController
as follows:
var notificationToken: NotificationToken?
override func viewDidLoad() {
super.viewDidLoad()
updateView()
notificationToken = store.addNotificationBlock { [weak self] (_) in
self?.updateView()
}
}
func updateView() {
tableView.reloadData()
}
Now we’ll wire everything up in Interface Builder. Open ‘Main.storyboard’ and do the following:
- Delete the existing ‘View Controller’ scene
- Drag on a Navigation Controller from the Object Library. This will also create a
UITableViewController
scene as the navigation root. - Tick “Is Initial View Controller” on the Navigation Controller
- Select the Root View Controller and change the Custom Class on the Identity Inspector to
ViewController
- Select the Table View Cell and change the Custom Class on the Identity Inspector to
ProjectCell
. Also set the Reuse Identifier on the Attributes Inspector to “ProjectCell” - Drag two
UILabel
s and aUIButton
onto the cell prototype and set up autolayout as desired. Connect them to thenameLabel
,elapsedTimeLabel
andactivityButton
outlets on the ProjectCell. While you’re there, connect the activity button’s TouchUpInside to theactivityButtonTapped
action on the cell. - Select the Navigation Item and change the title to something better than ‘Root View Controller’!
At this point, all of the view controller presentation code is done - whenever the state changes, the table will update accordingly. You should be able to build & run the app now, although it won’t be very exciting as there are no projects (and no way to add any)!
So let’s add some actions to show how state updates work - we’ll start with adding a new project. Because a project only needs a name, the simplest approach will be to put the ‘add project’ view in the table header.
In the storyboard, set up the ‘add project’ elements as follows:
- Drag a Bar Button Item from the Object Library to the right of the navigation bar. Xcode can be a bit temperamental here - I find it’s sometimes easier to drag to the navigation item in the Document Outline (left-hand Interface Builder pane).
- In the Attributes Inspector, change the bar button System Item setting to ‘Add’
- Drag a View to the top of the Table View, then add a Text Field and a Button and configure autolayout as you’d like it.
- Change the button title to something like ‘Add’.
- Open the Assistant Editor and make sure it’s displaying the ‘ViewController.swift’ file.
- Control-drag the text field to the ViewController.swift source and create a new outlet called
newProjectTextField
- Control-drag the ‘Add’ button in the header to ViewController.swift and create a new action called
addButtonTapped
. Don’t forget to change the drop-down to ‘action’! - Control-drag the ‘+’ bar button item to ViewController.swift and create a new action called
showNewProjectView
. Again, use the Document Outline if Xcode is being recalcitrant.
Hide the Assistant View and switch back to ‘ViewController.swift’. Add the code to show/hide the table header, and importantly, in the addButtonTapped
method, call the addProject
method on the store. You’ll also want to add a hideNewProjectView()
call to stateDidUpdate
.
func updateView() {
tableView.reloadData()
hideNewProjectView()
}
@IBAction func showNewProjectView(sender: AnyObject) {
tableView.tableHeaderView?.frame = CGRect(origin: CGPointZero, size: CGSize(width: view.frame.size.width, height: 44))
tableView.tableHeaderView?.hidden = false
tableView.tableHeaderView = tableView.tableHeaderView // tableHeaderView needs to be reassigned to recognize new height
newProjectTextField.becomeFirstResponder()
}
func hideNewProjectView() {
tableView.tableHeaderView?.frame = CGRect(origin: CGPointZero, size: CGSize(width: view.frame.size.width, height: 0))
tableView.tableHeaderView?.hidden = true
tableView.tableHeaderView = tableView.tableHeaderView
newProjectTextField.endEditing(true)
newProjectTextField.text = nil
}
@IBAction func addButtonTapped() {
guard let name = newProjectTextField.text else { return }
store.addProject(name)
}
If you run the app now, you should be able to add new projects - awesome! The table updates automatically after the addProject
method is called, even though we have no UI updating code in addButtonTapped
- changes to the application state automatically flow through to the views. This is unidirectional data flow in action.
The remaining actions are fairly straightforward - we can fill in the start/stop behaviour in ProjectCell.activityButtonTapped
:
@IBAction func activityButtonTapped() {
guard let project = project else { return }
if project.currentActivity == nil {
store.startActivity(project, startDate: NSDate())
} else {
store.endActivity(project, endDate: NSDate())
}
}
And also implement swipe-to-delete in ViewController
using the appropriate UITableViewController
method overrides:
override func tableView(tableView: UITableView, canEditRowAtIndexPath indexPath: NSIndexPath) -> Bool {
return true
}
override func tableView(tableView: UITableView, commitEditingStyle editingStyle: UITableViewCellEditingStyle, forRowAtIndexPath indexPath: NSIndexPath) {
store.deleteProject(projects[indexPath.row])
}
That’s it! Build & run your super-useful time-tracking app, and pat yourself on the back for having implemented Unidirectional Data Flow from scratch.
Recap
In this tutorial we’ve seen the concept of Unidirectional Data Flow, and how it’s possible to implement this approach in a Realm app using the built-in Realm notification features. In particular, we’ve applied the principles by:
- Storing all our application state in a ‘single source of truth’ (the Realm)
- Only performing state changes via the action methods we defined on the Realm extension
- Ensure all our view updates are performed in response to a Realm update notification.
Taking Things Further
There are a number of areas where our app could be improved, and these are important considerations worth taking into account if you’re adopting these techniques in your own apps.
- Reactive Updates: This pattern really lends itself to using a Functional Reactive Programming library to distribute state update events throughout your application. Take the plunge and learn how to use a library like ReactiveCocoa, RxSwift, or ReactKit.
- Local State: You’ll notice if you start/stop an existing project in the middle of adding a new one, the new project text field & button will disappear as we’re resetting the whole view each time the AppState changes. If you have some state like this that is view-specific and doesn’t belong at the AppState level, it’s okay to store it in the ViewController, however it’s best to define a struct to hold all local state and use a single mutable property. Using a Reactive library will help you consolidate update events on local and app state into a single handler.
- Count-Up Timer: To keep things simple we’re just showing a watch emoji on the currently running activity, however it would make more sense to update the label each second with the current elapsed time. While it’s possible to calculate this at the model layer and fire update events once per second, this is pretty heavyweight and isn’t relevant to the application state (it’s display only). It would be better to use a custom iOS equivalent of WKInterfaceTimer and let the label handle displaying the updated time values.
Now you’re an expert on Unidirectional Data Flow, it might be worth reviewing some of the existing frameworks to see if it will make your life easier. Check out:
- ReSwift - ReduxKit & Swift-Flow merged to form ReSwift - there’s also a router project to help with navigation.
- SwiftFlux - A Flux implementation for Swift
- Few.swift - An attempt at a React-style declarative view layer in Swift
Receive news and updates from Realm straight to your inbox