Table of Contents
Get Started
When you start creating UI like this, you will consider two major things, what is for display and how to display them.
For what is for display, we called it State, in this UI, the State are 3 items.
Each item have:
title
date created
and time
For how to display, it’s a normal iOS development technical, UIView, UITableView, UIImage etc.
State is a simple way to feeding data to UI, with State we can make the UI part easily for test and debug.
For above example, if we change the time of an item in State, the UI will be notified that State has some changes, then UI can update itself.
If you are familiar with MVC, the State is very like Model in MVC.
Start a Project
Let’s create a Folder List view step by step to demonstrate how to use State.
Create FolderListViewController UI Part
Create FolderListViewController.swift with xib, add a UITableView.
class FolderListViewController: UIViewController { @IBOutlet weak var tableView: UITableView! override func viewDidLoad() { super.viewDidLoad() // Do any additional setup after loading the view. } }
Create TableViewCell.swift and xib
TableViewCell is a UITableViewCell, it have a folder name and a delete button.
class TableViewCell: UITableViewCell { @IBOutlet weak var folderName: UILabel! @IBOutlet weak var deleteButton: UIButton! override func awakeFromNib() { super.awakeFromNib() // Initialization code } @IBAction func onDelete() { } }
Add TBaseFramework
import UIKit import TBaseFramework class FolderListViewController: UIViewController { @IBOutlet weak var tableView: UITableView!
TBaseFramework is our base framework that contains some basic functions, such as State.
Use data in State in UITableViewDataSource
We will give you a state name, for this example, it’s store.state.folderState.folders
You can check the definition of store.state.folderState.folders, because you want to understand what is it.
See, store.state.folderState.folders is a list of Folder, then check Folder’s definition.
The Folder have a name attribute, that is what we will display on the table view.
Now let’s implement UITableViewDataSource.
override func viewDidLoad() { super.viewDidLoad() // Do any additional setup after loading the view. print( store.state.folderState.folders ) tableView.register(UINib(nibName: "TableViewCell", bundle: nil), forCellReuseIdentifier: "cell") tableView.dataSource = self } func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell = tableView.dequeueReusableCell(withIdentifier: "cell") as! TableViewCell let folder = store.state.folderState.folders[indexPath.item] cell.folderName.text = folder.name return cell } func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { return store.state.folderState.folders.count }
We called this process Data Bind, but you will see nothing for now if run app, because the store.state.folderState.folders is empty. We need some test code to fill dummy data and test our UI.
Write Test Code
For UI testing, we have a UITestRootViewController that can help us easy to test UI.
Now create it in ViewController.swift.
class ViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() // Do any additional setup after loading the view. let root = UITestRootViewController(nibName: "UITestRootViewController", bundle: Bundle(for: UITestRootViewController.self)) self.addChild(root) self.view.addSubview(root.view) root.view.configureForAutoLayout() root.view.autoPinEdgesToSuperviewEdges() root.didMove(toParent: self)
Then we need a test entry for FolderListViewController.
Create a func initTestFolderListView(), we will write test code in it.
Then create a UITestEntry for FolderListViewController
func initTestFolderListView() { let testEntity = UITestEntry(title: "FolderListViewController") { viewContainer in } // update State store.dispatch(UITestAddTestEntry(testEntry: testEntity)) }
Call initTestFolderListView() in viewDidLoad()
override func viewDidLoad() { super.viewDidLoad() // Do any additional setup after loading the view. let root = UITestRootViewController(nibName: "UITestRootViewController", bundle: Bundle(for: UITestRootViewController.self)) self.addChild(root) self.view.addSubview(root.view) root.view.configureForAutoLayout() root.view.autoPinEdgesToSuperviewEdges() root.didMove(toParent: self) initTestFolderListView() }
Now if you run it and tap on the big + button, you will see a test entry FolderListViewController.
But no thing happened if we tap FolderListViewController, because we have created a UITestEntry but not added any test code.
Note:
In above code, we used store.dispatch(UITestAddTestEntry(testEntry: testEntity))
That is we Dispatch a UITestAddTestEntry to store, the store will add testEntry to State.
Our UITestRootViewController also use State, so after we Dispatch UITestAddTestEntry, the UITestRootViewController will add this test entry on test view.
Note:
If we want to update any field in State, we must call store.dispatch() to Dispatch an action, that’s only way to update State , we can’t directly modify any field in State.
Implement UITestEntry
UITestEntry will be executed when we tap it, in this test entry, we need to create FolderListViewController and show it.
func initTestFolderListView() { let testEntity = UITestEntry(title: "FolderListViewController") { viewContainer in // create FolderListViewController and add it to test view let vc = FolderListViewController(nibName: "FolderListViewController", bundle: Bundle(for: FolderListViewController.self)) viewContainer.addChild(vc) viewContainer.view.addSubview(vc.view) // for folder list view, we set it full size vc.view.configureForAutoLayout() vc.view.autoPinEdgesToSuperviewEdges() vc.didMove(toParent: viewContainer) }
We also need to add folder to store.state.folderState.folders, remember to dispatch action because we can’t modify store.state directly.
func initTestFolderListView() { let dummyFolder = Folder(name: "Test folder") store.dispatch(AddFolderAction(folder: dummyFolder, position: 0)) let testEntity = UITestEntry(title: "FolderListViewController") { viewContainer in // create FolderListViewController and add it to test view let vc = FolderListViewController(nibName:
We use AddFolderAction() here, in the real project, we will tell you which action you should use.
Now run app again, you will see the folder list view and 1 folder is added.
Test Add Folder
Next we want to implement add folder function and test it.
Let’s write test code first, we can add test action to test entry, the name of test action is ‘Add Folder’.
Add test action in initTestFolderListView()
func initTestFolderListView() { let dummyFolder = Folder(name: "Test folder") store.dispatch(AddFolderAction(folder: dummyFolder, position: 0)) let testEntity = UITestEntry(title: "FolderListViewController") { viewContainer in // create FolderListViewController and add it to test view let vc = FolderListViewController(nibName: "FolderListViewController", bundle: Bundle(for: FolderListViewController.self)) viewContainer.addChild(vc) viewContainer.view.addSubview(vc.view) // for folder list view, we set it full size vc.view.configureForAutoLayout() vc.view.autoPinEdgesToSuperviewEdges() vc.didMove(toParent: viewContainer) } testEntity.actions.append(UITestAction(title: "Add Folder", actionHandler: { let newFolder = Folder(name: "New Folder") store.dispatch(AddFolderAction(folder: newFolder, position: 0)) })) // update State store.dispatch(UITestAddTestEntry(testEntry: testEntity)) }
Modify FolderListViewController to observer State change and update table view, we do it by implement newState() function.
class FolderListViewController: UIViewController, UITableViewDataSource, StoreSubscriber { @IBOutlet weak var tableView: UITableView! override func viewDidLoad() { super.viewDidLoad() // Do any additional setup after loading the view. print( store.state.folderState.folders ) tableView.register(UINib(nibName: "TableViewCell", bundle: nil), forCellReuseIdentifier: "cell") tableView.dataSource = self store.subscribe(self) } func newState(state: AppState) { tableView.reloadData() } func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell = tableView.dequeueReusableCell(withIdentifier: "cell") as! TableViewCell let folder = store.state.folderState.folders[indexPath.item] cell.folderName.text = folder.name return cell }
Note:
newState() is a callback function, it will be called once any field of State has changed.
Now run app and tap on Add Folder action, you will see New Folder will be added.
Note:
We simply call tableView.reload() in newState(), that is not a good way.
- newState() will be called any time, after any fields in State is changed( even the fields you didn’t use in your view controller )
- tableView.reload() is too heavy operation, we must call it only when we real need to reload it.
- That way can’t use item add animation of UITableView.
Next we will introduce the right way to update UI in newState() in real project.
Add Folder without Reload
We need Event when add new folder, the event will tell you there is a new folder added to State, then you can update table view.
Add Event Handler in FolderListViewController
The event of new folder added is FolderDidAddEvent.
class FolderListViewController: UIViewController, UITableViewDataSource, StoreSubscriber, EventHandler { @IBOutlet weak var tableView: UITableView! override func viewDidLoad() { super.viewDidLoad() // Do any additional setup after loading the view. print( store.state.folderState.folders ) tableView.register(UINib(nibName: "TableViewCell", bundle: nil), forCellReuseIdentifier: "cell") tableView.dataSource = self store.subscribe(self) EventCenter.shared.addEventHandler(eventHandler: self) } deinit { EventCenter.shared.removeEventHandler(eventHandler: self) } func newState(state: AppState) { // tableView.reloadData() } func onEvent(event: Event) { if let e = event as? FolderDidAddEvent { tableView.insertRows(at: [IndexPath(item: e.position, section: 0)], with: .left) } }
Then we need to send event in our test action.
Note:
Event and State are two different things, because newState() can only tell us State has some changes, but it can’t tell us what particular field is changed.
Now send event in test code
func initTestFolderListView() { let dummyFolder = Folder(name: "Test folder") store.dispatch(AddFolderAction(folder: dummyFolder, position: 0)) let testEntity = UITestEntry(title: "FolderListViewController") { viewContainer in // create FolderListViewController and add it to test view let vc = FolderListViewController(nibName: "FolderListViewController", bundle: Bundle(for: FolderListViewController.self)) viewContainer.addChild(vc) viewContainer.view.addSubview(vc.view) // for folder list view, we set it full size vc.view.configureForAutoLayout() vc.view.autoPinEdgesToSuperviewEdges() vc.didMove(toParent: viewContainer) } testEntity.actions.append(UITestAction(title: "Add Folder", actionHandler: { let newFolder = Folder(name: "New Folder") store.dispatch(AddFolderAction(folder: newFolder, position: 0)) EventCenter.shared.send(event: FolderDidAddEvent(sender: self, newFolder: newFolder, position: 0)) })) // update State store.dispatch(UITestAddTestEntry(testEntry: testEntity)) }
Run app and test Add Folder, you will see the folder will be added with animation.
Reload All Folders
Sometimes we still need to reload all folders by tableView.reloadData(). For example, if user refresh folder list.
You must detect if store.state.folderState.folders has changed, if it changed, you can reload table view.
A local var previousState will help us easily to implement it.
class FolderListViewController: UIViewController, UITableViewDataSource, StoreSubscriber, EventHandler { @IBOutlet weak var tableView: UITableView! var previousState: AppState? override func viewDidLoad() { super.viewDidLoad() // Do any additional setup after loading the view. print( store.state.folderState.folders ) tableView.register(UINib(nibName: "TableViewCell", bundle: nil), forCellReuseIdentifier: "cell") tableView.dataSource = self store.subscribe(self) EventCenter.shared.addEventHandler(eventHandler: self) } deinit { EventCenter.shared.removeEventHandler(eventHandler: self) } func newState(state: AppState) { if state.folderState.folders != previousState?.folderState.folders { tableView.reloadData() } previousState = state }
Add new test action to test reload folders function.
Note:
We are using test driven development, every function will have a test code.
func initTestFolderListView() { let dummyFolder = Folder(name: "Test folder") store.dispatch(AddFolderAction(folder: dummyFolder, position: 0)) let testEntity = UITestEntry(title: "FolderListViewController") { viewContainer in // create FolderListViewController and add it to test view let vc = FolderListViewController(nibName: "FolderListViewController", bundle: Bundle(for: FolderListViewController.self)) viewContainer.addChild(vc) viewContainer.view.addSubview(vc.view) // for folder list view, we set it full size vc.view.configureForAutoLayout() vc.view.autoPinEdgesToSuperviewEdges() vc.didMove(toParent: viewContainer) } testEntity.actions.append(UITestAction(title: "Add Folder", actionHandler: { let newFolder = Folder(name: "New Folder") store.dispatch(AddFolderAction(folder: newFolder, position: 0)) EventCenter.shared.send(event: FolderDidAddEvent(sender: self, newFolder: newFolder, position: 0)) })) testEntity.actions.append(UITestAction(title: "Reload Folders", actionHandler: { let folders = [ Folder(name: "Reload Folder 1"), Folder(name: "Reload Folder 2"), Folder(name: "Reload Folder 3"), ] store.dispatch(FoldersLoadedAction(folders: folders)) })) // update State store.dispatch(UITestAddTestEntry(testEntry: testEntity)) }
Run and tap on Reload Folders action, you will see the list view reload without animation.
Delete Folder
It’s easily to implement and test delete folder.
First let’s add test action ‘Delete Folder’.
Of course, we should tell you the delete folder action is RemoveFolderAction and the event is FolderDidRemoveEvent.
In test code:
testEntity.actions.append(UITestAction(title: "Reload Folders", actionHandler: { let folders = [ Folder(name: "Reload Folder 1"), Folder(name: "Reload Folder 2"), Folder(name: "Reload Folder 3"), ] store.dispatch(FoldersLoadedAction(folders: folders)) })) testEntity.actions.append(UITestAction(title: "Delete Folder", actionHandler: { if let folder = store.state.folderState.folders[0] { store.dispatch(RemoveFolderAction(folder: folder)) EventCenter.shared.send(event: FolderDidRemoveEvent(sender: self, folder: folder, position: 0)) } }))
In FolderListViewController, we must handle FolderDidRemoveEvent
func onEvent(event: Event) { if let e = event as? FolderDidAddEvent { tableView.insertRows(at: [IndexPath(item: e.position, section: 0)], with: .left) } else if let e = event as? FolderDidRemoveEvent { tableView.deleteRows(at: [IndexPath(item: e.position, section: 0)], with: .right) } }
Run app and try Delete Folder.
What is Need in Your @IBaction
We tend to keep component simplify, in the most case we will only ask you send event in UI action.
For this demo project, we want to send UIRemoveFolderEvent when tap on Delete button in folder cell.
In TableViewCell
class TableViewCell: UITableViewCell { @IBOutlet weak var folderName: UILabel! @IBOutlet weak var deleteButton: UIButton! // we must know which Folder attached to this cell public var folder: Folder? override func awakeFromNib() { super.awakeFromNib() // Initialization code } override func setSelected(_ selected: Bool, animated: Bool) { super.setSelected(selected, animated: animated) // Configure the view for the selected state } @IBAction func onDelete() { EventCenter.shared.send(event: UIRemoveFolderEvent(sender: self, folder: folder!)) } }
In FolderListViewController, assign cell.folder
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell = tableView.dequeueReusableCell(withIdentifier: "cell") as! TableViewCell if let folder = store.state.folderState.folders[indexPath.item] { cell.folderName.text = folder.name cell.folder = folder } return cell }
Run app, when you tap on Delete button in a folder cell, you will see the output in console.
[Jul 30, 2021 at 2:40:50 PM]: send event UIRemoveFolderEvent
That’s all, other component will handle UIRemoveFolderEvent and do something.
Here is the project archive, you can download it and check.
Register Your Component
Our framework have a Component Manager, when we use your FolderListViewController, we will call Component Manager to create it.
The primary benefit of Component concept is the code will be more easily test and develop.
Next, let’s take a look how to register Component and use it.
In StateDemoProject.swift, please register your FolderListViewController as a Component.
import TBaseFramework class StateDemoProject: NSObject { public static let bundle = Bundle(for: StateDemoProject.self) public static func setup() { ComponentManager.shared.register(name: "FolderListViewController") { info in return FolderListViewController(nibName: "FolderListViewController", bundle: StateDemoProject.bundle) } } }
For using component, change ViewController.swift
class ViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() StateDemoProject.setup() // Do any additional setup after loading the view. let root = UITestRootViewController(nibName: "UITestRootViewController", bundle: Bundle(for: UITestRootViewController.self)) self.addChild(root) self.view.addSubview(root.view) root.view.configureForAutoLayout() root.view.autoPinEdgesToSuperviewEdges() root.didMove(toParent: self) initTestFolderListView() } func initTestFolderListView() { let dummyFolder = Folder(name: "Test folder") store.dispatch(AddFolderAction(folder: dummyFolder, position: 0)) let testEntity = UITestEntry(title: "FolderListViewController") { viewContainer in // create FolderListViewController and add it to test view let vc = ComponentManager.shared.create(name: "FolderListViewController") as! UIViewController viewContainer.addChild(vc) viewContainer.view.addSubview(vc.view)
It’s not too hard and actually it’s a factory design pattern.
Complex Test Code – Using TestDispatcher
In sometimes you may want to create more complex testing, for example, if you have a download progress bar, you surely want to fast test it without actually download something.
In that case we can create a timer to update State, to keep the testing code is module wise, we can create a TestDispatcher to do that.
Now let’s create TestFolderListDispatcher and implement InstallableComponent
import TBaseFramework class TestFolderListDispatcher: NSObject, InstallableComponent { func onInstall() { } func onUninstall() { } }
The component can be created by Component Manager and install it( Not mean install to disk ).
After component installed, it will keep alive and not be released. Then the component have a chance to process Event or do some background task.
So you can implement InstallableComponent protocol, and do something in onInstall() and onUninstall().
For demo purpose, let’s set a timer to update State in onInstall().
class TestFolderListDispatcher: NSObject, InstallableComponent { private var timer: Timer? func onInstall() { // remove all folders store.dispatch(FoldersLoadedAction(folders: [])) timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true, block: { _ in // add folder after 1 second let folder = Folder(name: "New Folder") store.dispatch(AddFolderAction(folder: folder, position: 0)) EventCenter.shared.send(event: FolderDidAddEvent(sender: self, newFolder: folder, position: 0)) }) } func onUninstall() { timer?.invalidate() } }
Then create a test action “Test Dispatcher”
testEntity.actions.append(UITestAction(title: "Delete Folder", actionHandler: { if let folder = store.state.folderState.folders[0] { store.dispatch(RemoveFolderAction(folder: folder)) EventCenter.shared.send(event: FolderDidRemoveEvent(sender: self, folder: folder, position: 0)) } })) testEntity.actions.append(UITestAction(title: "Test Dispatcher", actionHandler: { let dispatcher = TestFolderListDispatcher() ComponentManager.shared.install(component: dispatcher) })) // update State store.dispatch(UITestAddTestEntry(testEntry: testEntity))
Run app you will see the new folder created every second.
We can also handle event in TestFolderListDispatcher, for example, handle UIRemoveFolderEvent and simple dispatch RemoveFolderAction and send event FolderDidRemoveEvent.
Thus you can test your UI with less testing code, and don’t concerned with how to deal actually No-UI functions.
Here is some sample cases we will use TestDispatcher
- For Downloading/Uploading UI.
- For UI that need device audio recording, or movie playing.
- For UI that need network access, such as, a chat view.
Bugs Fix
Bug Fix, Folder Name Update
Because we will not always call talbeView.reloadData() in newState(), so if folder name is changed and newState() called, our code will not update UI.
We fixed it by move the update code into cell class.
class TableViewCell: UITableViewCell, StoreSubscriber { @IBOutlet weak var folderName: UILabel! @IBOutlet weak var deleteButton: UIButton! // we must know which Folder attached to this cell public var folder: Folder? override func awakeFromNib() { super.awakeFromNib() // Initialization code store.subscribe(self) } func newState(state: AppState) { self.folderName.text = folder?.name }
In FolderListViewController
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell = tableView.dequeueReusableCell(withIdentifier: "cell") as! TableViewCell if let folder = store.state.folderState.folders[indexPath.item] { cell.folder = folder cell.updateUI() } return cell }
Bug Fix, deinit
Please note we forgot to call store.unsubscribe(self) in deinit().