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.
For you to follow this tutorial, you will need all of the following requirements:
Once you have you have all the following then let us continue in the article.
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):
1platform :ios, '9.0' 2 target 'PROJECT_NAME' do 3 use_frameworks! 4 pod 'PusherSwift', '~> 4.1.0' 5 pod 'Alamofire', '~> 4.4.0' 6 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.
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.
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:
1import UIKit 2 3 class UserTableViewController: UITableViewController { 4 5 override func viewDidLoad() { 6 super.viewDidLoad() 7 } 8 }
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:
1private func loadUsersFromApi() { 2 users = [ 3 [ 4 "id": 1, 5 "name" : "John Doe", 6 ], 7 [ 8 "id": 2, 9 "name": "Jane Doe" 10 ] 11 ] 12 }
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:
1override func viewDidLoad() { 2 super.viewDidLoad() 3 4 loadUsersFromApi() 5 }
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:
1// MARK: - Table view data source 2 3 override func numberOfSections(in tableView: UITableView) -> Int { 4 return 1 5 } 6 7 override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 8 return users.count 9 } 10 11 override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { 12 let cell = tableView.dequeueReusableCell(withIdentifier: "user", for: indexPath) 13 cell.textLabel?.text = users[indexPath.row]["name"] as! String? 14 return cell 15 } 16 17 override func tableView(_ tableView: UITableView, moveRowAt sourceIndexPath: IndexPath, to destinationIndexPath: IndexPath) { 18 let movedObject = users[sourceIndexPath.row] 19 users.remove(at: sourceIndexPath.row) 20 users.insert(movedObject, at: destinationIndexPath.row) 21 } 22 23 override func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCellEditingStyle, forRowAt indexPath: IndexPath) { 24 if editingStyle == .delete { 25 self.users.remove(at: indexPath.row) 26 self.tableView.deleteRows(at: [indexPath], with: .automatic) 27 } 28 }
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:
1navigationItem.title = "Users List" 2 navigationItem.rightBarButtonItem = self.editButtonItem 3 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:
1public func showAddUserAlertController() { 2 let alertCtrl = UIAlertController(title: "Add User", message: "Add a user to the list", preferredStyle: .alert) 3 4 // Add text field to alert controller 5 alertCtrl.addTextField { (textField) in 6 self.textField = textField 7 self.textField.autocapitalizationType = .words 8 self.textField.placeholder = "e.g John Doe" 9 } 10 11 // Add cancel button to alert controller 12 alertCtrl.addAction(UIAlertAction(title: "Cancel", style: .cancel, handler: nil)) 13 14 // "Add" button with callback 15 alertCtrl.addAction(UIAlertAction(title: "Add", style: .default, handler: { action in 16 if let name = self.textField.text, name != "" { 17 self.users.append(["id": self.users.count, "name" :name]) 18 self.tableView.reloadData() 19 } 20 })) 21 22 present(alertCtrl, animated: true, completion: nil) 23 }
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.
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).
Go back to the loadUsersFromApi
method and replace the contents with the following code:
1private func loadUsersFromApi() { 2 indicator.startAnimating() 3 4 Alamofire.request(self.endpoint + "/users").validate().responseJSON { (response) in 5 switch response.result { 6 case .success(let JSON): 7 self.users = JSON as! [NSDictionary] 8 self.tableView.reloadData() 9 self.indicator.stopAnimating() 10 case .failure(let error): 11 print(error) 12 } 13 } 14 }
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:
1var endpoint = "http://localhost:4000" 2 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:
1private func setupActivityIndicator() { 2 indicator = UIActivityIndicatorView(frame: CGRect(x: 0, y: 0, width: 50, height: 50)) 3 indicator.activityIndicatorViewStyle = .white 4 indicator.backgroundColor = UIColor.darkGray 5 indicator.center = self.view.center 6 indicator.layer.cornerRadius = 05 7 indicator.hidesWhenStopped = true 8 indicator.layer.zPosition = 1 9 indicator.isOpaque = false 10 indicator.tag = 999 11 tableView.addSubview(indicator) 12 }
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
:
1override func viewDidLoad() { 2 // other stuff... 3 setupActivityIndicator() 4 loadUsersFromApi() 5 }
Adding this before calling the loadUsersFromApi
call will ensure the indicator has been created before it is referenced in the load users function call.
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:
1if let name = self.textField.text, name != "" { 2 self.users.append(["id": self.users.count, "name" :name]) 3 self.tableView.reloadData() 4 }
with this:
1if let name = self.textField.text, name != "" { 2 let payload: Parameters = ["name": name, "deviceId": self.deviceId] 3 4 Alamofire.request(self.endpoint + "/add", method: .post, parameters:payload).validate().responseJSON { (response) in 5 switch response.result { 6 case .success(_): 7 self.users.append(["id": self.users.count, "name" :name]) 8 self.tableView.reloadData() 9 case .failure(let error): 10 print(error) 11 } 12 } 13 }
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.
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:
1override func tableView(_ tableView: UITableView, moveRowAt sourceIndexPath: IndexPath, to destinationIndexPath: IndexPath) { 2 let movedObject = users[sourceIndexPath.row] 3 users.remove(at: sourceIndexPath.row) 4 users.insert(movedObject, at: destinationIndexPath.row) 5 }
with this:
1override func tableView(_ tableView: UITableView, moveRowAt sourceIndexPath: IndexPath, to destinationIndexPath: IndexPath) { 2 let movedObject = users[sourceIndexPath.row] 3 4 let payload:Parameters = [ 5 "deviceId": self.deviceId, 6 "src":sourceIndexPath.row, 7 "dest": destinationIndexPath.row, 8 "src_id": users[sourceIndexPath.row]["id"]!, 9 "dest_id": users[destinationIndexPath.row]["id"]! 10 ] 11 12 Alamofire.request(self.endpoint+"/move", method: .post, parameters: payload).validate().responseJSON { (response) in 13 switch response.result { 14 case .success(_): 15 self.users.remove(at: sourceIndexPath.row) 16 self.users.insert(movedObject, at: destinationIndexPath.row) 17 case .failure(let error): 18 print(error) 19 } 20 } 21 }
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.
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:
1override func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCellEditingStyle, forRowAt indexPath: IndexPath) { 2 if editingStyle == .delete { 3 self.users.remove(at: indexPath.row) 4 self.tableView.deleteRows(at: [indexPath], with: .automatic) 5 } 6 }
and replace it with the following code:
1override func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCellEditingStyle, forRowAt indexPath: IndexPath) { 2 if editingStyle == .delete { 3 let payload: Parameters = [ 4 "index":indexPath.row, 5 "deviceId": self.deviceId, 6 "id": self.users[indexPath.row]["id"]! 7 ] 8 9 Alamofire.request(self.endpoint + "/delete", method: .post, parameters:payload).validate().responseJSON { (response) in 10 switch response.result { 11 case .success(_): 12 self.users.remove(at: indexPath.row) 13 self.tableView.deleteRows(at: [indexPath], with: .automatic) 14 case .failure(let err): 15 print(err) 16 } 17 } 18 } 19 }
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.
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:
1private func listenToChangesFromPusher() { 2 // Instantiate Pusher 3 pusher = Pusher(key: "PUSHER_APP_KEY", options: PusherClientOptions(host: .cluster("PUSHER_APP_CLUSTER"))) 4 5 // Subscribe to a pusher channel 6 let channel = pusher.subscribe("userslist") 7 8 // Bind to an event called "addUser" on the event channel and fire 9 // the callback when the event is triggerred 10 let _ = channel.bind(eventName: "addUser", callback: { (data: Any?) -> Void in 11 if let data = data as? [String : AnyObject] { 12 if let name = data["name"] as? String { 13 14 // We only want to run this block if the update was from a 15 // different device 16 if (data["deviceId"] as! String) != self.deviceId { 17 self.users.append(["id": self.users.count, "name": name]) 18 self.tableView.reloadData() 19 } 20 } 21 } 22 }) 23 24 // Bind to an event called "removeUser" on the event channel and fire 25 // the callback when the event is triggerred 26 let _ = channel.bind(eventName: "removeUser", callback: { (data: Any?) -> Void in 27 if let data = data as? [String : AnyObject] { 28 if let _ = data["index"] as? Int { 29 let indexPath = IndexPath(item: (data["index"] as! Int), section:0) 30 31 // We only want to run this block if the update was from a 32 // different device 33 if (data["deviceId"] as! String) != self.deviceId { 34 self.users.remove(at: indexPath.row) 35 self.tableView.deleteRows(at: [indexPath], with: .automatic) 36 } 37 } 38 } 39 }) 40 41 // Bind to an event called "moveUser" on the event channel and fire 42 // the callback when the event is triggerred 43 let _ = channel.bind(eventName: "moveUser", callback: { (data: Any?) -> Void in 44 if let data = data as? [String : AnyObject] { 45 if let _ = data["deviceId"] as? String { 46 let sourceIndexPath = IndexPath(item:(data["src"] as! Int), section:0) 47 let destinationIndexPath = IndexPath(item:(data["dest"] as! Int), section:0) 48 let movedObject = self.users[sourceIndexPath.row] 49 50 // We only want to run this block if the update was from a 51 // different device 52 if (data["deviceId"] as! String) != self.deviceId { 53 self.users.remove(at: sourceIndexPath.row) 54 self.users.insert(movedObject, at: destinationIndexPath.row) 55 self.tableView.reloadData() 56 } 57 } 58 } 59 }) 60 61 pusher.connect() 62 }
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:
1override func viewDidLoad() { 2 // other stuff... 3 listenToChangesFromPusher() 4 }
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.
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:
1{ 2 "main": "index.js", 3 "dependencies": { 4 "bluebird": "^3.5.0", 5 "body-parser": "^1.16.0", 6 "express": "^4.14.1", 7 "pusher": "^1.5.1", 8 "sqlite": "^2.8.0" 9 } 10 }
This file will contain all the packages we intend to use to build our backend application.
Next file to create will be config.js:
1module.exports = { 2 appId: 'PUSHER_APP_ID', 3 key: 'PUSHER_APP_KEY', 4 secret: 'PUSHER_APP_SECRET', 5 cluster: 'PUSHER_APP_CLUSTER', 6 };
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:
1-- Up 2 CREATE TABLE Users ( 3 id INTEGER NOT NULL, 4 name TEXT, 5 position INTEGER NOT NULL, 6 updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 7 PRIMARY KEY (id) 8 ); 9 INSERT INTO Users (id, name, position) VALUES (1, 'John Doe', 1); 10 -- Down 11 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:
1// ------------------------------------------------------ 2 // Import all required packages and files 3 // ------------------------------------------------------ 4 let Pusher = require('pusher'); 5 let express = require('express'); 6 let bodyParser = require('body-parser'); 7 let Promise = require('bluebird'); 8 let db = require('sqlite'); 9 let app = express(); 10 let pusher = new Pusher(require('./config.js')); 11 // ------------------------------------------------------ 12 // Set up Express 13 // ------------------------------------------------------ 14 app.use(bodyParser.json()); 15 app.use(bodyParser.urlencoded({ extended: false })); 16 // ------------------------------------------------------ 17 // Define routes and logic 18 // ------------------------------------------------------ 19 app.get('/users', (req, res, next) => { 20 try { 21 // Fetch all users from the database 22 db.all('SELECT * FROM Users ORDER BY position ASC, updated_at DESC') 23 .then(result => res.json(result)) 24 } catch (err) { 25 next(err) 26 } 27 }) 28 app.post("/add", (req, res, next) => { 29 try { 30 let payload = {name:req.body.name, deviceId: req.body.deviceId} 31 // Add the user to the database 32 db.run("INSERT INTO Users (name, position) VALUES (?, (SELECT MAX(id) + 1 FROM Users))", payload.name).then(query => { 33 payload.id = query.stmt.lastID 34 pusher.trigger('userslist', 'addUser', payload) 35 return res.json(payload) 36 }) 37 } catch (err) { 38 next(err) 39 } 40 }) 41 app.post("/delete", (req, res, next) => { 42 try { 43 let payload = {id:parseInt(req.body.id), index:parseInt(req.body.index), deviceId: req.body.deviceId} 44 // Delete the user from the database 45 db.run(`DELETE FROM Users WHERE id=${payload.id}`).then(query => { 46 pusher.trigger('userslist', 'removeUser', payload) 47 return res.json(payload) 48 }) 49 } catch (err) { 50 next(err) 51 } 52 }) 53 app.post("/move", (req, res, next) => { 54 try { 55 let payload = { 56 deviceId: req.body.deviceId, 57 src: parseInt(req.body.src), 58 dest: parseInt(req.body.dest), 59 src_id: parseInt(req.body.src_id), 60 dest_id: parseInt(req.body.dest_id), 61 } 62 // Update the position of the user 63 db.run(`UPDATE Users SET position=${payload.dest + 1}, updated_at=CURRENT_TIMESTAMP WHERE id=${payload.src_id}`).then(query => { 64 pusher.trigger('userslist', 'moveUser', payload) 65 res.json(payload) 66 }) 67 } catch (err) { 68 next(err) 69 } 70 }) 71 app.get('/', (req, res) => { 72 res.json("It works!"); 73 }); 74 75 // ------------------------------------------------------ 76 // Catch errors 77 // ------------------------------------------------------ 78 app.use((req, res, next) => { 79 let err = new Error('Not Found'); 80 err.status = 404; 81 next(err); 82 }); 83 84 // ------------------------------------------------------ 85 // Start application 86 // ------------------------------------------------------ 87 Promise.resolve() 88 .then(() => db.open('./database.sqlite', { Promise })) 89 .then(() => db.migrate({ force: 'last' })) 90 .catch(err => console.error(err.stack)) 91 .finally(() => app.listen(4000, function(){ 92 console.log('App listening on port 4000!') 93 }));
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:
1$ npm install 2 $ node index.js
When the installation is complete and the application is ready you should see the message App listening on port 4000!
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.
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.