Build a realtime table using Swift
A basic understanding of Swift and Node.js is needed to follow this tutorial.
More often than not, when you build applications to be consumed by others, you will need to represent the data in some sort of table or list. Think of a list of users for example, or a table filled with data about the soccer league. Now, imagine the data that populated the table was to be reordered or altered, it would be nice if everyone viewing the data on the table sees the changes made instantaneously.
In this article, you will see how you can use iOS and Pusher to create a table that is updated across all your devices in realtime. You can see a screen recording of how the application works below.
In the recording above, you can see how the changes made to the table on the one device gets mirrored instantly to the other device. Let us consider how to make this using Pusher and Swift.
Requirements for building a realtime table on iOS
For you to follow this tutorial, you will need all of the following requirements:
- A MacBook Pro
- Xcode installed on your machine
- Basic knowledge of Swift and using Xcode
- Basic knowledge of JavaScript (Node.js)
- Node.js and NPM installed on your machine
- Cocoapods ****installed on your machine.
- A Pusher application.
Once you have you have all the following then let us continue in the article.
Preparing our environment to create our application
Launch Xcode and create a new project. Follow the new application wizard and create a new Single-page application. Once the project has been created, close Xcode and launch the terminal.
In the terminal window, cd
to the root of the app directory and run the command pod init
. This will generate a Podfile.
Update the contents of the Podfile to the contents below (replace PROJECT_NAME
with your project name):
platform :ios, '9.0'
target 'PROJECT_NAME' do
use_frameworks!
pod 'PusherSwift', '~> 4.1.0'
pod 'Alamofire', '~> 4.4.0'
end
Save the Podfile and then run the command: pod install
on your terminal window. Running this command will install all the third-party packages we need to build our realtime app.
Once the installation is complete, open the **.xcworkspace**
file in your project directory root. This should launch Xcode. Now we are ready to start creating our iOS application.
Building the User Interface of our realtime table on iOS
Once Xcode has finished loading, we can now start building our interface.
Open the Main.storyboard
file. Drag and drop a Navigation Controller to the storyboard and set the entry point to the new Navigation Controller. You should now have something like this in your storyboard:
As seen in the screenshot, we have a simple navigation controller and we have made the table view controller attached to it our Root View Controller.
Now we need to add a reuse identifier to our table cells. Click on the prototype cell and add a new reuse identifier.
We have named our reuse identifier user but you can call the reuse identifier whatever you want. Next, create a new TableViewController
and attach to it to the root view controller using the storyboard’s identity inspector as seen below:
Great! Now we are done with the user interface of the application, let us start creating the logic that will populate and make our iOS table realtime.
Populating our iOS table with user data and manipulating it
The first thing we want to do is populate our table with some mock data. Once we do this, we can then add and test all the possible manipulations we want the table to have such as moving rows around, deleting rows and adding new rows to the table.
Open your UserTableViewController
. Now remove all the functions of the file except viewDidLoad
so that we have clarity in the file. You should have something like this when you are done:
import UIKit
class UserTableViewController: UITableViewController {
override func viewDidLoad() {
super.viewDidLoad()
}
}
Now let us add some mock data. Create a new function that is supposed to load the data from an API. For now, though, we will hardcode the data. Add the function below to the controller:
private func loadUsersFromApi() {
users = [
[
"id": 1,
"name" : "John Doe",
],
[
"id": 2,
"name": "Jane Doe"
]
]
}
Now instantiate the users
property on the class right under the class declaration:
var users:[NSDictionary] = []
And finally, in the viewDidLoad
function, call the loadUsersFromApi
method:
override func viewDidLoad() {
super.viewDidLoad()
loadUsersFromApi()
}
Next, we need to add all the functions that’ll make our table view controller compliant with the
UITableViewController
and thus display our data. Add the functions below to the view controller:
// MARK: - Table view data source
override func numberOfSections(in tableView: UITableView) -> Int {
return 1
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return users.count
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "user", for: indexPath)
cell.textLabel?.text = users[indexPath.row]["name"] as! String?
return cell
}
override func tableView(_ tableView: UITableView, moveRowAt sourceIndexPath: IndexPath, to destinationIndexPath: IndexPath) {
let movedObject = users[sourceIndexPath.row]
users.remove(at: sourceIndexPath.row)
users.insert(movedObject, at: destinationIndexPath.row)
}
override func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCellEditingStyle, forRowAt indexPath: IndexPath) {
if editingStyle == .delete {
self.users.remove(at: indexPath.row)
self.tableView.deleteRows(at: [indexPath], with: .automatic)
}
}
The above code has 5 functions. The first function tells the table how many sections our table has. The next function tells the table how many users (or rows) the table has. The third function is called every time a row is created and is responsible for populating the cell with data. The fourth and fifth function are callbacks that are called when data is moved or deleted respectively.
Now, if you run your application, you should see the mock data displayed. However, we cannot see the add or edit button. So let us add that functionality.
In the viewDidLoad
function add the following lines:
navigationItem.title = "Users List"
navigationItem.rightBarButtonItem = self.editButtonItem
navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .add, target: self, action: #selector(showAddUserAlertController))
In the code above, we have added two buttons, the left, and right button. The left being the add button and the right being the edit button.
In the add button, it calls a showAddUserAlertController
method. We don’t have that defined yet in our code so let us add it. Add the function below to your view controller:
public func showAddUserAlertController() {
let alertCtrl = UIAlertController(title: "Add User", message: "Add a user to the list", preferredStyle: .alert)
// Add text field to alert controller
alertCtrl.addTextField { (textField) in
self.textField = textField
self.textField.autocapitalizationType = .words
self.textField.placeholder = "e.g John Doe"
}
// Add cancel button to alert controller
alertCtrl.addAction(UIAlertAction(title: "Cancel", style: .cancel, handler: nil))
// "Add" button with callback
alertCtrl.addAction(UIAlertAction(title: "Add", style: .default, handler: { action in
if let name = self.textField.text, name != "" {
self.users.append(["id": self.users.count, "name" :name])
self.tableView.reloadData()
}
}))
present(alertCtrl, animated: true, completion: nil)
}
The code simply creates an alert when the add button is clicked. The alert has a textField
which will take the name of the user you want to add and append it to the users
property.
Now, let us declare the textField
property on the controller right after the class declaration:
var textField: UITextField!
Now, we have a working prototype that is not connected to any API. If you run your application at this point, you will be able to see all the functions and they will work, but won’t be persisted since it is hardcoded.
Great, but now we need to add a data source. To do this, we will need to create a Node.js backend and then our application will be able to call this to retrieve data. Also, when the data is modified by reordering or deleting, the request is sent to the backend and the changes are stored there.
Adding API calls to our iOS table application
Now, let us start by retrieving the data from a remote source that we have not created yet (we will create this later in the article).
Loading users from the API
Go back to the loadUsersFromApi
method and replace the contents with the following code:
private func loadUsersFromApi() {
indicator.startAnimating()
Alamofire.request(self.endpoint + "/users").validate().responseJSON { (response) in
switch response.result {
case .success(let JSON):
self.users = JSON as! [NSDictionary]
self.tableView.reloadData()
self.indicator.stopAnimating()
case .failure(let error):
print(error)
}
}
}
The method above uses Alamofire to make calls to a self.endpoint
and then stores the response to self.users
. It also calls an indicator.startAnimating()
, this is supposed to show an indicator that data is loading.
Before we create the loading indicator, let us import Alamofire
. Under the import UIKit
statement, add the line of code below:
import Alamofire
That’s all! Now, let’s create the loading indicator that is already being called in the loadUsersFromApi
function above.
First, declare the indicator
and the endpoint
in the class right after the controller class declaration:
var endpoint = "http://localhost:4000"
var indicator = UIActivityIndicatorView()
💡 The
endpoint
would need to be changed to the URL of your web server when you are developing for a live environment.
Now, create a function to initialize and configure the loading indicator. Add the function below to the controller:
private func setupActivityIndicator() {
indicator = UIActivityIndicatorView(frame: CGRect(x: 0, y: 0, width: 50, height: 50))
indicator.activityIndicatorViewStyle = .white
indicator.backgroundColor = UIColor.darkGray
indicator.center = self.view.center
indicator.layer.cornerRadius = 05
indicator.hidesWhenStopped = true
indicator.layer.zPosition = 1
indicator.isOpaque = false
indicator.tag = 999
tableView.addSubview(indicator)
}
The function above will simply set up our UIActivityIndicatorView
, which is just a spinner that indicates that our data is loading. After setting up the loading view, we then add it to the table view.
💡 We set the
hidesWhenStopped
property totrue
, this means that every time we stop the indicator usingstopAnimating
the indicator will automatically hide.
Now, in the viewDidLoad
function, above the call to loadUsersFromApi
, add the call to setupActivityIndicator
:
override func viewDidLoad() {
// other stuff...
setupActivityIndicator()
loadUsersFromApi()
}
Adding this before calling the loadUsersFromApi
call will ensure the indicator has been created before it is referenced in the load users function call.
Adding users to the API then to the table locally
Now, let’s hook the “Add” button to our backend so that when the user is added using the textfield, a request is sent to the endpoint.
In the showAddUserAlertController
we will make some modifications. Replace the lines below:
if let name = self.textField.text, name != "" {
self.users.append(["id": self.users.count, "name" :name])
self.tableView.reloadData()
}
with this:
if let name = self.textField.text, name != "" {
let payload: Parameters = ["name": name, "deviceId": self.deviceId]
Alamofire.request(self.endpoint + "/add", method: .post, parameters:payload).validate().responseJSON { (response) in
switch response.result {
case .success(_):
self.users.append(["id": self.users.count, "name" :name])
self.tableView.reloadData()
case .failure(let error):
print(error)
}
}
}
Now, in the block of code above, we are sending a request to our endpoint instead of just directly manipulating the users
property. If the request is successful, we then append the new data to the users
property. If you notice, however, in the payload
we referenced self.deviceId
, so we need to create this property. Add the code below right after the class declaration:
let deviceId = UIDevice.current.identifierForVendor!.uuidString
💡 We are adding the device ID so we can differentiate who made what call to the backend and avoid manipulating the data multiple times if it was the same device that sent the request. When we integrate Pusher, the listener will be doing the same manipulations to the
user
property. However, if it’s the same device that made the request then it should skip updating the property.
Moving users in the API then to the table locally
The next thing is adding the remote move functionality. Let’s hook that up to communicate with the endpoint.
In your code, replace the function below:
override func tableView(_ tableView: UITableView, moveRowAt sourceIndexPath: IndexPath, to destinationIndexPath: IndexPath) {
let movedObject = users[sourceIndexPath.row]
users.remove(at: sourceIndexPath.row)
users.insert(movedObject, at: destinationIndexPath.row)
}
with this:
override func tableView(_ tableView: UITableView, moveRowAt sourceIndexPath: IndexPath, to destinationIndexPath: IndexPath) {
let movedObject = users[sourceIndexPath.row]
let payload:Parameters = [
"deviceId": self.deviceId,
"src":sourceIndexPath.row,
"dest": destinationIndexPath.row,
"src_id": users[sourceIndexPath.row]["id"]!,
"dest_id": users[destinationIndexPath.row]["id"]!
]
Alamofire.request(self.endpoint+"/move", method: .post, parameters: payload).validate().responseJSON { (response) in
switch response.result {
case .success(_):
self.users.remove(at: sourceIndexPath.row)
self.users.insert(movedObject, at: destinationIndexPath.row)
case .failure(let error):
print(error)
}
}
}
In the code above, we set the payload to send to the endpoint and send it using Alamofire. Then, if we receive a successful response from the API, we make changes to the user
property.
Deleting a row in the API then locally on the table
The next thing we want to do is delete the data from the API before deleting it locally. To do this, look for the function below:
override func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCellEditingStyle, forRowAt indexPath: IndexPath) {
if editingStyle == .delete {
self.users.remove(at: indexPath.row)
self.tableView.deleteRows(at: [indexPath], with: .automatic)
}
}
and replace it with the following code:
override func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCellEditingStyle, forRowAt indexPath: IndexPath) {
if editingStyle == .delete {
let payload: Parameters = [
"index":indexPath.row,
"deviceId": self.deviceId,
"id": self.users[indexPath.row]["id"]!
]
Alamofire.request(self.endpoint + "/delete", method: .post, parameters:payload).validate().responseJSON { (response) in
switch response.result {
case .success(_):
self.users.remove(at: indexPath.row)
self.tableView.deleteRows(at: [indexPath], with: .automatic)
case .failure(let err):
print(err)
}
}
}
}
Just like the others, we have just sent the payload we generated to the API and then, if there is a successful response, we delete the row from the users
property.
Now, the next thing would be to create the backend API. However, before we do that, let us add the realtime functionality into the app using Pusher.
Adding realtime functionality to our table on iOS
Now that we are done with hooking up the API, we need to add some realtime functionality so that any other devices will pick up the changes instantly without having to reload the table manually.
First, import the Pusher SDK to your application. Under the import Alamofire
statement, add the following:
import PusherSwift
Now, let us declare the pusher
property in the class right under the class declaration:
var pusher: Pusher!
Great. Now add the function below to the controller:
private func listenToChangesFromPusher() {
// Instantiate Pusher
pusher = Pusher(key: "PUSHER_APP_KEY", options: PusherClientOptions(host: .cluster("PUSHER_APP_CLUSTER")))
// Subscribe to a pusher channel
let channel = pusher.subscribe("userslist")
// Bind to an event called "addUser" on the event channel and fire
// the callback when the event is triggerred
let _ = channel.bind(eventName: "addUser", callback: { (data: Any?) -> Void in
if let data = data as? [String : AnyObject] {
if let name = data["name"] as? String {
// We only want to run this block if the update was from a
// different device
if (data["deviceId"] as! String) != self.deviceId {
self.users.append(["id": self.users.count, "name": name])
self.tableView.reloadData()
}
}
}
})
// Bind to an event called "removeUser" on the event channel and fire
// the callback when the event is triggerred
let _ = channel.bind(eventName: "removeUser", callback: { (data: Any?) -> Void in
if let data = data as? [String : AnyObject] {
if let _ = data["index"] as? Int {
let indexPath = IndexPath(item: (data["index"] as! Int), section:0)
// We only want to run this block if the update was from a
// different device
if (data["deviceId"] as! String) != self.deviceId {
self.users.remove(at: indexPath.row)
self.tableView.deleteRows(at: [indexPath], with: .automatic)
}
}
}
})
// Bind to an event called "moveUser" on the event channel and fire
// the callback when the event is triggerred
let _ = channel.bind(eventName: "moveUser", callback: { (data: Any?) -> Void in
if let data = data as? [String : AnyObject] {
if let _ = data["deviceId"] as? String {
let sourceIndexPath = IndexPath(item:(data["src"] as! Int), section:0)
let destinationIndexPath = IndexPath(item:(data["dest"] as! Int), section:0)
let movedObject = self.users[sourceIndexPath.row]
// We only want to run this block if the update was from a
// different device
if (data["deviceId"] as! String) != self.deviceId {
self.users.remove(at: sourceIndexPath.row)
self.users.insert(movedObject, at: destinationIndexPath.row)
self.tableView.reloadData()
}
}
}
})
pusher.connect()
}
In this block of code, we have done quite a lot. First, we instantiate Pusher with our application’s key and cluster (replace with the details provided to you on your Pusher application dashboard). Next, we subscribed to the channel userslist
. We will listen for events on this channel.
In the first channel.bind
block, we bind to the addUser
event and then when an event is picked up, the callback runs.
In the callback, we check for the device ID and, if it is not a match, we append the new user to the local user
property. It does the same for the next two blocks of channel.bind
. However, in the others, it removes and moves the position respectively.
The last part is pusher.connect
which does exactly what it says.
To listen to the changes, add the call to the bottom of the viewDidLoad
function:
override func viewDidLoad() {
// other stuff...
listenToChangesFromPusher()
}
That is all! We have created a realtime table that is responsive to changes received when the data is manipulated. The last part is creating the backend that will be used to save the data and to trigger Pusher events.
Creating the Backend for our realtime iOS table
To get started, create a directory for the web application and then create some new files inside the directory:
First, create a file called package.json:
{
"main": "index.js",
"dependencies": {
"bluebird": "^3.5.0",
"body-parser": "^1.16.0",
"express": "^4.14.1",
"pusher": "^1.5.1",
"sqlite": "^2.8.0"
}
}
This file will contain all the packages we intend to use to build our backend application.
Next file to create will be config.js:
module.exports = {
appId: 'PUSHER_APP_ID',
key: 'PUSHER_APP_KEY',
secret: 'PUSHER_APP_SECRET',
cluster: 'PUSHER_APP_CLUSTER',
};
This will be the location of all your configuration values. Fill in the values using the data from your Pusher application’s dashboard.
Next, create an empty database.sqlite
file in the root of your web app directory.
Next, create a directory called migrations
inside the web application directory and inside it create the next file 001-initial-schema.sql and paste the content below:
-- Up
CREATE TABLE Users (
id INTEGER NOT NULL,
name TEXT,
position INTEGER NOT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id)
);
INSERT INTO Users (id, name, position) VALUES (1, 'John Doe', 1);
-- Down
DROP TABLE Users;
In the above, we declare the migrations to run when the application is started.
💡 The
-- Up
marks the migrations that should be run and the-- Down
is the rollback of the migration if you want to step back and undo the migration.
Next we will create the main file index.js:
// ------------------------------------------------------
// Import all required packages and files
// ------------------------------------------------------
let Pusher = require('pusher');
let express = require('express');
let bodyParser = require('body-parser');
let Promise = require('bluebird');
let db = require('sqlite');
let app = express();
let pusher = new Pusher(require('./config.js'));
// ------------------------------------------------------
// Set up Express
// ------------------------------------------------------
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: false }));
// ------------------------------------------------------
// Define routes and logic
// ------------------------------------------------------
app.get('/users', (req, res, next) => {
try {
// Fetch all users from the database
db.all('SELECT * FROM Users ORDER BY position ASC, updated_at DESC')
.then(result => res.json(result))
} catch (err) {
next(err)
}
})
app.post("/add", (req, res, next) => {
try {
let payload = {name:req.body.name, deviceId: req.body.deviceId}
// Add the user to the database
db.run("INSERT INTO Users (name, position) VALUES (?, (SELECT MAX(id) + 1 FROM Users))", payload.name).then(query => {
payload.id = query.stmt.lastID
pusher.trigger('userslist', 'addUser', payload)
return res.json(payload)
})
} catch (err) {
next(err)
}
})
app.post("/delete", (req, res, next) => {
try {
let payload = {id:parseInt(req.body.id), index:parseInt(req.body.index), deviceId: req.body.deviceId}
// Delete the user from the database
db.run(`DELETE FROM Users WHERE id=${payload.id}`).then(query => {
pusher.trigger('userslist', 'removeUser', payload)
return res.json(payload)
})
} catch (err) {
next(err)
}
})
app.post("/move", (req, res, next) => {
try {
let payload = {
deviceId: req.body.deviceId,
src: parseInt(req.body.src),
dest: parseInt(req.body.dest),
src_id: parseInt(req.body.src_id),
dest_id: parseInt(req.body.dest_id),
}
// Update the position of the user
db.run(`UPDATE Users SET position=${payload.dest + 1}, updated_at=CURRENT_TIMESTAMP WHERE id=${payload.src_id}`).then(query => {
pusher.trigger('userslist', 'moveUser', payload)
res.json(payload)
})
} catch (err) {
next(err)
}
})
app.get('/', (req, res) => {
res.json("It works!");
});
// ------------------------------------------------------
// Catch errors
// ------------------------------------------------------
app.use((req, res, next) => {
let err = new Error('Not Found');
err.status = 404;
next(err);
});
// ------------------------------------------------------
// Start application
// ------------------------------------------------------
Promise.resolve()
.then(() => db.open('./database.sqlite', { Promise }))
.then(() => db.migrate({ force: 'last' }))
.catch(err => console.error(err.stack))
.finally(() => app.listen(4000, function(){
console.log('App listening on port 4000!')
}));
In the code above, we loaded all the required packages including Express and Pusher. After instantiating them, we create the routes we need.
The routes are designed to do pretty basic things such as adding a row to the database, deleting a row from the database and updating rows in the database. For the database, we are using the SQLite NPM package.
In the last block, we migrate the database using the /migrations/001-initial-schema.sql
file into the database.sqlite
file. Then we start the express application after everything is done.
Open the terminal and cd
to the root of the web application directory and run the commands below to install the NPM dependencies and run the application respectively:
$ npm install
$ node index.js
When the installation is complete and the application is ready you should see the message App listening on port 4000!
Testing the application
Once you have your local node web server running, you will need to make some changes so your application can talk to the local web server. In the info.plist
file, make the following changes:
With this change, you can build and run your application and it will talk directly with your local web application.
Conclusion
This article has demonstrated how you can create tables in iOS that respond in realtime to changes made on other devices. This is very useful and can be applied to data that has to be updated dynamically and instantly across all devices.
22 November 2017
by Neo Ighodaro