Build a realtime table using Swift

Introduction

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.

realtime-table-swift-demo

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):

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.

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:

realtime-table-swift-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.

realtime-table-swift-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:

realtime-table-swift-custom-class

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:

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.

realtime-table-swift-add-user

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:

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 to true, this means that every time we stop the indicator using stopAnimating 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.

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:

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.

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:

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.

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:

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.

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:

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.

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:

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!

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:

realtime-table-swift-allow-arbitraty-loads

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.