Build a ride sharing iOS app with push notifications
To follow this tutorial you will need a Mac with Xcode installed, knowledge of Xcode and Swift, basic knowledge of JavaScript (including Node.js), a Pusher account, and Cocoapods installed on your machine.
Ride sharing applications like Uber and Lyft let passengers request a ride from drivers in their area. When a passenger requests a ride, the application finds a driver as quickly as possible. If the passenger closes the app while they wait, they need a way to be notified that a car is on its way and again once it’s arrived.
In this article, we will be creating a simple make-believe Ride Sharing application with a focus on how you can integrate Pusher’s Beams API to deliver transactional push notifications.
We will be making two iOS applications to cater to the driver and the rider and a Node.js application to power them both. We will then add push notifications to alert the driver that a new ride request is available, and the passenger that they have a driver on their way, and once they arrive.
Prerequisites
- A Mac with Xcode installed. Download Xcode here.
- Knowledge of using Xcode.
- Knowledge of Swift.
- A Pusher account. Create one here.
- A Google Maps API key. Create one here.
- Basic knowledge of JavaScript/Node.js (Check out this tutorial).
- Cocoapods installed on your machine.
Once you have the requirements, let’s start.
About our applications
Through the course of this tutorial, we will be making three applications:
- The backend application (Web using Node.js). This will be the power house of both iOS applications. It will contain all the endpoints required for the application to function properly. It will also be responsible for sending the push notifications to the respective devices.
- The rider application (iOS using Swift). This will be the application the rider will use to request rides.
- The driver application (iOS using Swift). This will be the application the driver will use to accept requests from riders. The driver will be able to update the status of the ride as the situation warrants.
Here is a screen recording of what we will have when we are done:
💡 We will not be focusing too much on the Ride Sharing functionality but we will be focusing mostly on how you can integrate push notifications to the application.
Building the backend application (API)
The first thing we want to build is the API. We will be adding everything required to support our iOS applications and then add push notifications later on.
To get started, create a project directory for the API. In the directory, create a new file called package.json
and in the file paste the following:
{
"main": "index.js",
"scripts": {},
"dependencies": {
"body-parser": "^1.18.2",
"express": "^4.16.2",
"pusher": "^1.5.1",
"pusher-push-notifications-node": "^0.10.1"
}
}
Next run the command below in your terminal:
$ npm install
This will install all the listed dependencies. Next, create an index.js
file in the same directory as the package.json
file and paste in the following code:
// --------------------------------------------------------
// Pull in the libraries
// --------------------------------------------------------
const app = require('express')()
const bodyParser = require('body-parser')
const config = require('./config.js')
const Pusher = require('pusher')
const pusher = new Pusher({
appId: 'PUSHER_APP_ID',
key: 'PUSHER_APP_KEY',
secret: 'PUSHER_APP_SECRET',
cluster: 'PUSHER_APP_CLUSTER',
encrypted: true
})
// --------------------------------------------------------
// In-memory database
// --------------------------------------------------------
let rider = null
let driver = null
let user_id = null
let status = "Neutral"
// --------------------------------------------------------
// Express Middlewares
// --------------------------------------------------------
app.use(bodyParser.json())
app.use(bodyParser.urlencoded({extended: false}))
// --------------------------------------------------------
// Helpers
// --------------------------------------------------------
function uuidv4() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
var r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
}
// --------------------------------------------------------
// Routes
// --------------------------------------------------------
// ----- Rider --------------------------------------------
app.get('/status', (req, res) => res.json({ status }))
app.get('/request', (req, res) => res.json(driver))
app.post('/request', (req, res) => {
user_id = req.body.user_id
status = "Searching"
rider = { name: "Jane Doe", longitude: -122.088426, latitude: 37.388064 }
pusher.trigger('cabs', 'status-update', { status, rider })
res.json({ status: true })
})
app.delete('/request', (req, res) => {
driver = null
status = "Neutral"
pusher.trigger('cabs', 'status-update', { status })
res.json({ status: true })
})
// ----- Driver ------------------------------------------
app.get('/pending-rider', (req, res) => res.json(rider))
app.post('/status', (req, res) => {
status = req.body.status
if (status == "EndedTrip" || status == "Neutral") {
rider = driver = null
} else {
driver = { name: "John Doe" }
}
pusher.trigger('cabs', 'status-update', { status, driver })
res.json({ status: true })
})
// ----- Misc ---------------------------------------------
app.get('/', (req, res) => res.json({ status: "success" }))
// --------------------------------------------------------
// Serve application
// --------------------------------------------------------
app.listen(4000, _ => console.log('App listening on port 4000!'))
💡 You need to replace the
PUSHER_APP_*
keys with the real keys from the Pusher dashboard.
In the code above, we first pull in all the dependencies we need for the application to run. Next we set up some variables to hold data as an in-memory data store. We then define a UUID generator function which we will use to generate ID’s for objects. Next we define our applications routes:
POST /request
saves a new request for a driver.GET /request
gets the driver that is handling the request.DELETE /request
cancels a request for a ride.GET /pending-order
gets the pending requests.POST /status
changes the status of a ride.
That’s all we need in the API for now and we will revisit it when we need to send push notifications. If you want to test that the API is working, then run the following command on your terminal:
$ node index.js
This will start a new Node server listening on port 4000.
Building the Rider application
The next thing we need to do is build the client application. Launch Xcode and create a new ‘Single Application’ project. We will name our project RiderClient.
Once the project has been created, exit Xcode and create a new file called Podfile
in the root of the Xcode project you just created. In the file paste in the following code:
platform :ios, '11.0'
target 'RiderClient' do
use_frameworks!
pod 'GoogleMaps', '~> 2.6.0'
pod 'PusherSwift', '~> 5.1.1'
pod 'Alamofire', '~> 4.6.0'
end
In the file above, we specified the dependencies the project needs to run. Remember to change the target
above to the name of your project. Now in your terminal, run the following command to install the dependencies:
$ pod install
After the installation is complete, open the Xcode workspace file that was generated by Cocoapods. This will relaunch Xcode.
When Xcode has been relaunched, open the Main.storyboard
file and in there we will create the storyboard for our client application. Below is a screenshot of how we have designed our storyboard:
In the main View Controller, we have defined views that will display the status of the ride, the driver details and the CTA button.
💡 CTA is an abbreviation for call to action.
Create a new file in Xcode called MainController.swift
, and make it the custom class for the main View Controller above. Next paste in the following code:
import UIKit
import Alamofire
import GoogleMaps
class MainViewController: UIViewController, GMSMapViewDelegate {
var latitude = 37.388064
var longitude = -122.088426
var locationMarker: GMSMarker!
@IBOutlet weak var mapView: GMSMapView!
@IBOutlet weak var loadingIndicator: UIActivityIndicatorView!
@IBOutlet weak var loadingOverlay: UIView!
@IBOutlet weak var orderButton: UIButton!
@IBOutlet weak var orderStatusView: UIView!
@IBOutlet weak var orderStatus: UILabel!
@IBOutlet weak var cancelButton: UIButton!
@IBOutlet weak var driverDetailsView: UIView!
override func viewDidLoad() {
super.viewDidLoad()
mapView.camera = GMSCameraPosition.camera(withLatitude:latitude, longitude:longitude, zoom:15.0)
mapView.delegate = self
locationMarker = GMSMarker(position: CLLocationCoordinate2D(latitude: latitude, longitude: longitude))
locationMarker.map = mapView
orderStatusView.layer.cornerRadius = 5
orderStatusView.layer.shadowOffset = CGSize(width: 0, height: 0)
orderStatusView.layer.shadowColor = UIColor.black.cgColor
orderStatusView.layer.shadowOpacity = 0.3
updateView(status: .Neutral, msg: nil)
}
}
In the code above we have the View Controller class. In the viewDidLoad
we set up Google Maps, and call the updateView
method. The updateView
method is a helper function that simply alters the view displayed depending on the RideStatus
. Add the method to the class:
private func updateView(status: RideStatus, msg: String?) {
switch status {
case .Neutral:
driverDetailsView.isHidden = true
loadingOverlay.isHidden = true
orderStatus.text = msg != nil ? msg! : "💡 Tap the button below to get a cab."
orderButton.setTitleColor(UIColor.white, for: .normal)
orderButton.isHidden = false
cancelButton.isHidden = true
loadingIndicator.stopAnimating()
case .Searching:
loadingOverlay.isHidden = false
orderStatus.text = msg != nil ? msg! : "🚕 Looking for a cab close to you..."
orderButton.setTitleColor(UIColor.clear, for: .normal)
loadingIndicator.startAnimating()
case .FoundRide, .Arrival:
driverDetailsView.isHidden = false
loadingOverlay.isHidden = true
if status == .FoundRide {
orderStatus.text = msg != nil ? msg! : "😎 Found a ride, your ride is on it's way"
} else {
orderStatus.text = msg != nil ? msg! : "⏰ Your driver is waiting, please meet outside."
}
orderStatus.text = msg != nil ? msg! : "😎 Found a ride, your ride is on it's way"
orderButton.isHidden = true
cancelButton.isHidden = false
loadingIndicator.stopAnimating()
case .OnTrip:
orderStatus.text = msg != nil ? msg! : "🙂 Your ride is in progress. Enjoy."
cancelButton.isEnabled = false
case .EndedTrip:
orderStatus.text = msg != nil ? msg! : "🌟 Ride complete. Have a nice day!"
orderButton.setTitleColor(UIColor.white, for: .normal)
driverDetailsView.isHidden = true
cancelButton.isEnabled = true
orderButton.isHidden = false
cancelButton.isHidden = true
}
}
Next we have the orderButtonPressed
method that calls the sendRequest
method which sends a request to the API. The next method is the cancelButtonPressed
which also calls the sendRequest
method.
@IBAction func orderButtonPressed(_ sender: Any) {
updateView(status: .Searching, msg: nil)
sendRequest(.post) { successful in
guard successful else {
return self.updateView(status: .Neutral, msg: "😔 No drivers available.")
}
}
}
@IBAction func cancelButtonPressed(_ sender: Any) {
sendRequest(.delete) { successful in
guard successful == false else {
return self.updateView(status: .Neutral, msg: nil)
}
}
}
private func sendRequest(_ method: HTTPMethod, handler: @escaping(Bool) -> Void) {
let url = AppConstants.API_URL + "/request"
let params = ["user_id": AppConstants.USER_ID]
Alamofire.request(url, method: method, parameters: params)
.validate()
.responseJSON { response in
guard response.result.isSuccess,
let data = response.result.value as? [String:Bool],
let status = data["status"] else { return handler(false) }
handler(status)
}
}
Integrating realtime updates using Pusher
Next, let’s add some Pusher functionality to the View Controller so it can pick up changes to the RideStatus
in realtime.
First, you need to import
the Pusher swift SDK:
import PusherSwift
Then define the pusher
variable at the top of the class:
let pusher = Pusher(
key: AppConstants.PUSHER_API_KEY,
options: PusherClientOptions(host: .cluster(AppConstants.PUSHER_API_CLUSTER))
)
Next, add the following method to the class:
private func listenForUpdates() {
let channel = pusher.subscribe("cabs")
let _ = channel.bind(eventName: "status-update") { data in
if let data = data as? [String:AnyObject] {
if let status = data["status"] as? String,
let rideStatus = RideStatus(rawValue: status) {
self.updateView(status: rideStatus, msg: nil)
}
}
}
pusher.connect()
}
The method above just subscribes to a Pusher channel and binds to the status-update
event on the channel. When the event is triggered, the updateView
method is called.
Finally at the bottom of the viewDidLoad
method, add a call to the listenForUpdates
method:
listenForUpdates()
Now when the backend application triggers a status update event, our rider application will pick it up and change the UI as necessary.
Setting up Google Maps
Next, open your AppDelegate
class and import the following:
import GoogleMaps
Next you can replace the application(didFinishLaunchingWithOptions:)
method with the following code:
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {
GMSServices.provideAPIKey(AppConstants.GOOGLE_API_KEY)
return true
}
Defining secret keys and ride status
Create a new file called AppConstants.swift
and paste the following code in:
import Foundation
class AppConstants {
static let GOOGLE_API_KEY = "GOOGLE_MAPS_API_KEY"
static let PUSHER_API_KEY = "PUSHER_APP_KEY"
static let PUSHER_API_CLUSTER = "PUSHER_APP_CLUSTER"
static let API_URL = "http://127.0.0.1:4000"
static let USER_ID = UUID().uuidString
}
⚠️ You need to replace the placeholders above with the actual values from their respective dashboards.
Next, create a file called RideStatus.swift
this will be where we will define all the available ride statuses:
import Foundation
enum RideStatus: String {
case Neutral = "Neutral"
case Searching = "Searching"
case FoundRide = "FoundRide"
case Arrived = "Arrived"
case OnTrip = "OnTrip"
case EndedTrip = "EndedTrip"
}
That’s all for the client application. Let’s move on to creating the Rider application.
One last thing we need to do though is modify the info.plist
file. We need to add an entry to the plist
file to allow connection to our local server:
Let’s move on to the rider application.
Building the Driver application
Launch Xcode and create a new ‘Single Application’ project. We will name our project RiderDriver.
Once the project has been created, exit Xcode and create a new file called Podfile
in the root of the Xcode project you just created. In the file paste in the following code:
platform :ios, '11.0'
target 'RiderDriver' do
use_frameworks!
pod 'PusherSwift', '~> 5.1.1'
pod 'Alamofire', '~> 4.6.0'
pod 'GoogleMaps', '~> 2.6.0'
pod 'PushNotifications'
end
In the file above, we specified the dependencies the project needs to run. Remember to change the target
above to the name of your project. Now in your terminal, run the following command to install the dependencies:
$ pod install
After the installation is complete, open the Xcode workspace file that was generated by Cocoapods. This will relaunch Xcode.
When Xcode has been relaunched, open the Main.storyboard
file and in there we will create the storyboard for our client application. Below is a screenshot of how we have designed our storyboard:
In the main View Controller, we have defined views that will display the rider information and buttons needed to change the status of the ride. We also have a hidden view that will be displayed when there are no pending requests.
Create a new file in Xcode called MainController.swift
, and make it the custom class for the main View Controller above. Next paste in the following code:
import UIKit
import Alamofire
import GoogleMaps
class MainViewController: UIViewController, GMSMapViewDelegate {
var status: RideStatus!
var locationMarker: GMSMarker!
@IBOutlet weak var riderName: UILabel!
@IBOutlet weak var mapView: GMSMapView!
@IBOutlet weak var requestView: UIView!
@IBOutlet weak var noRequestsView: UIView!
@IBOutlet weak var cancelButton: UIButton!
@IBOutlet weak var statusButton: UIButton!
override func viewDidLoad() {
super.viewDidLoad()
status = .Neutral
requestView.isHidden = true
cancelButton.isHidden = true
noRequestsView.isHidden = false
Timer.scheduledTimer(
timeInterval: 2,
target: self,
selector: #selector(findNewRequests),
userInfo: nil,
repeats: true
)
}
}
The viewDidLoad
sets the initial setting for the UI. Then we register a timer that fires the findNewRequests
method every 2 seconds. Let’s define that method. Add the method below to the class:
@objc private func findNewRequests() {
guard status == .Neutral else { return }
Alamofire.request(AppConstants.API_URL + "/pending-rider")
.validate()
.responseJSON { response in
guard response.result.isSuccess,
let data = response.result.value as? [String:AnyObject] else { return }
self.loadRequestForRider(Rider(data: data))
}
}
The method will send a request to the backend and if there is a pending request, it loads it to the UI. It however does not fire the request unless the ride status is Neutral
.
Next lets define the loadRequestsForRider
method that is called when there is a pending ride request:
private func loadRequestForRider(_ rider: Rider) {
mapView.camera = GMSCameraPosition.camera(withLatitude:rider.latitude, longitude:rider.longitude, zoom:15.0)
mapView.delegate = self
locationMarker = GMSMarker(position: CLLocationCoordinate2D(latitude: rider.latitude, longitude: rider.longitude))
locationMarker.map = mapView
status = .Searching
cancelButton.isHidden = false
statusButton.setTitle("Accept Trip", for: .normal)
riderName.text = rider.name
requestView.isHidden = false
noRequestsView.isHidden = true
}
The method simply loads Google Maps using the longitude and latitude of the rider making the request. Then it also prepares the UI to display the request.
The next methods to define will be the methods that change the status of the ride and update the UI depending on various events:
private func sendStatusChange(_ status: RideStatus, handler: @escaping(Bool) -> Void) {
let url = AppConstants.API_URL+"/status"
let params = ["status": status.rawValue]
Alamofire.request(url, method: .post, parameters: params).validate()
.responseJSON { response in
guard response.result.isSuccess,
let data = response.result.value as? [String: Bool] else { return handler(false) }
handler(data["status"]!)
}
}
private func getNextStatus(after status: RideStatus) -> RideStatus {
switch self.status! {
case .Neutral,
.Searching: return .FoundRide
case .FoundRide: return .Arrived
case .Arrived: return .OnTrip
case .OnTrip: return .EndedTrip
case .EndedTrip: return .Neutral
}
}
@IBAction func cancelButtonPressed(_ sender: Any) {
if status == .FoundRide || status == .Searching {
sendStatusChange(.Neutral) { successful in
if successful {
self.status = .Neutral
self.requestView.isHidden = true
self.noRequestsView.isHidden = false
}
}
}
}
@IBAction func statusButtonPressed(_ sender: Any) {
let nextStatus = getNextStatus(after: self.status)
sendStatusChange(nextStatus) { successful in
self.status = self.getNextStatus(after: nextStatus)
switch self.status! {
case .Neutral, .Searching:
self.cancelButton.isHidden = true
case .FoundRide:
self.cancelButton.isHidden = false
self.statusButton.setTitle("Announce Arrival", for: .normal)
case .Arrived:
self.cancelButton.isHidden = false
self.statusButton.setTitle("Start Trip", for: .normal)
case .OnTrip:
self.cancelButton.isHidden = true
self.statusButton.setTitle("End Trip", for: .normal)
case .EndedTrip:
self.status = .Neutral
self.noRequestsView.isHidden = false
self.requestView.isHidden = true
self.statusButton.setTitle("Accept Trip", for: .normal)
}
}
}
The sendStatusChange
is a helper method that sends requests to the API to change the status of a ride. The getNextStatus
is a helper method that returns the next RideStatus
in line from the one passed to it.
The cancelButtonPressed
is fired when the cancel button is pressed and it requests the ride be canceled. Finally, the statusButtonPressed
just sends a request to change the status based on the current status of the ride. It also updates the UI to fit the status it was changed to.
Integrating realtime updates using Pusher
Next, let’s add some Pusher functionality to the View Controller so it can pick up changes to the RideStatus
in realtime.
First, you need to import
the Pusher swift SDK:
import PusherSwift
Then define the pusher
variable at the top of the class:
let pusher = Pusher(
key: AppConstants.PUSHER_API_KEY,
options: PusherClientOptions(host: .cluster(AppConstants.PUSHER_API_CLUSTER))
)
Next, add the following method to the class:
private func listenForStatusUpdates() {
let channel = pusher.subscribe(channelName: "cabs")
let _ = channel.bind(eventName: "status-update") { data in
if let data = data as? [String: AnyObject] {
if let status = data["status"] as? String, let rideStatus = RideStatus(rawValue: status) {
if rideStatus == .Neutral {
self.status = .Neutral
self.cancelButtonPressed(UIButton())
}
}
}
}
pusher.connect()
}
The method above just subscribes to a Pusher channel and binds to the status-update
event on the channel. When the event is triggered, the cancel button function is called.
Finally at the bottom of the viewDidLoad
method, add a call to the listenForStatusUpdates
method:
listenForStatusUpdates()
Now when the backend application triggers a status update event, our application will pick it up and change the UI as necessary.
Setting up Google Maps
Next, open your AppDelegate
class and import the following:
import GoogleMaps
Next you can replace the application(didFinishLaunchingWithOptions:)
method with the following code:
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {
GMSServices.provideAPIKey(AppConstants.GOOGLE_API_KEY)
return true
}
Defining secret keys and ride status
Create a new file called AppConstants.swift
and paste the following code in:
class AppConstants {
static let GOOGLE_API_KEY = "GOOGLE_API_KEY"
static let PUSHER_KEY = "PUSHER_API_KEY"
static let PUSHER_CLUSTER = "PUSHER_API_CLUSTER"
static let API_URL = "http://127.0.0.1:4000"
static let PUSH_NOTIF_INSTANCE_ID = "PUSHER_NOTIFICATION_INSTANCE_ID"
static let USER_ID = UUID().uuidString
}
⚠️ You need to replace the placeholders above with the actual values from their respective dashboards.
Next, create two files called Rider.swift
and RideStatus.swift
then paste the following code into the files:
// Rider.swift
import Foundation
struct Rider {
let name: String
let longitude: Double
let latitude: Double
init(data: [String:AnyObject]) {
self.name = data["name"] as! String
self.longitude = data["longitude"] as! Double
self.latitude = data["latitude"] as! Double
}
}
// RideStatus.swift
import Foundation
enum RideStatus: String {
case Neutral = "Neutral"
case Searching = "Searching"
case FoundRide = "FoundRide"
case Arrived = "Arrived"
case OnTrip = "OnTrip"
case EndedTrip = "EndedTrip"
}
That’s all for the rider application. One last thing we need to do though is modify the info.plist
file as we did in the client application.
Now we have created the applications and you can run them to see them in action. However, we have not added push notifications to the application. We need to do this so that the user can know there is an event on the service when the application is minimised.
Let’s set up push notifications.
Adding push notifications to our iOS applications
The first thing we need to do is make our server capable of sending push notifications.
At this point, the application works as expected out of the box. We now need to add push notifications to the application to make it more engaging even when the user is not currently using the application.
⚠️ You need to be enrolled to the Apple Developer program to be able to use the Push Notifications feature. Also Push Notifications do not work on Simulators so you will need an actual iOS device to test.
Pusher’s Beams API has first-class support for native iOS applications. Your iOS app instances subscribe to Interests; then your servers send push notifications to those interests. Every app instance subscribed to that interest will receive the notification, even if the app is not open on the device at the time.
This section describes how you can set up an iOS app to receive transactional push notifications about your food delivery orders through Pusher.
Configure APNs
Pusher relies on Apple Push Notification service (APNs) to deliver push notifications to iOS application users on your behalf. When we deliver push notifications, we use your APNs Key. This page guides you through the process of getting an APNs Key and how to provide it to Pusher.
Head over to the Apple Developer dashboard by clicking here and then create a new Key as seen below:
When you have created the key, download it. Keep it safe as we will need it in the next section.
⚠️ You have to keep the generated key safe as you cannot get it back if you lose it.
Creating your Pusher application
The next thing you need to do is create a new Pusher Push Notification application from the Pusher dashboard.
When you have created the application, you should be presented with a Quickstart wizard that will help you set up the application.
In order to configure Push Notifications you will need to get an APNs key from Apple. This is the same key as the one we downloaded in the previous section. Once you’ve got the key, upload it to the Quickstart wizard.
Enter your Apple Team ID. You can get the Team ID from here. Click on the continue to proceed to the next step.
Updating your Rider application to support push notifications
In your client application, if you haven’t already, open the Podfile
and add the following pod to the list of dependencies:
pod 'PushNotifications'
Now run the pod install
command as you did earlier to pull in the notifications package. Next open the AppDelegate
class and import the PushNotifications
package:
import PushNotifications
Now, as part of the AppDelegate
class, add the following:
let pushNotifications = PushNotifications.shared
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {
// [...]
self.pushNotifications.start(instanceId: "PUSHER_NOTIF_INSTANCE_ID")
self.pushNotifications.registerForRemoteNotifications()
// [...]
return true
}
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
// [...]
self.pushNotifications.registerDeviceToken(deviceToken) {
try? self.pushNotifications.subscribe(interest: "rider_\(AppConstants.USER_ID)")
}
// [...]
}
💡 Replace
PUSHER_PUSH_NOTIF_INSTANCE_ID
with the key given to you by the Pusher application.
In the code above, we set up push notifications in the application(didFinishLaunchingWithOptions:)
method and then we subscribe to the interest in the application(didRegisterForRemoteNotificationsWithDeviceToken:)
method.
The dynamic interest demos how you can easily use specific interests for specific devices or users. As long as the server pushes to the correct interest, you can rest assured that devices subscribed to the interest will get the push notification.
Next, we need to enable push notifications for the application. In the project navigator, select your project, and click on the Capabilities tab. Enable Push Notifications by turning the switch ON.
Updating your Driver application to support Push notifications
Your rider application also needs to be able to receive Push Notifications. The process is similar to the set up above. The only difference will be the interest we will be subscribing to in AppDelegate
which will be ride_requests.
Adding rich actions to our push notifications on iOS
As it currently stands, our application will be able to receive push notifications but let’s take it one step further and add rich actions to the application. This will add more engagement to the notification.
First, open the AppDelegate
class and import the following classes:
import PushNotifications
import UserNotifications
Next, you need to extend the AppDelegate
with the `` class. Then add the following code:
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {
// [...]
let center = UNUserNotificationCenter.current()
center.delegate = self
let cancelAction = UNNotificationAction(
identifier: "cancel",
title: "Reject",
options: [.foreground]
)
let acceptAction = UNNotificationAction(
identifier: "accept",
title: "Accept Request",
options: [.foreground]
)
let category = UNNotificationCategory(
identifier: "DriverActions",
actions: [acceptAction, cancelAction],
intentIdentifiers: []
)
center.setNotificationCategories([category])
// [...]
return true
}
In the code above, we are specifying the actions we want our push notifications to display.
In the same AppDelegate
class, add the following method which will handle the actions when they are selected on the push notification:
func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
let name = Notification.Name("status")
if response.actionIdentifier == "cancel" {
NotificationCenter.default.post(name: name, object: nil, userInfo: ["status": RideStatus.Neutral])
}
if response.actionIdentifier == "accept" {
NotificationCenter.default.post(name: name, object: nil, userInfo: ["status": RideStatus.FoundRide])
}
completionHandler()
}
In the code, we just send a local notification when the push notification action is tapped. Next, we will add an observer in our view controller that will trigger some code when the notification is received.
Open the MainViewController
class and add the following code in the viewDidLoad
method:
NotificationCenter.default.addObserver(
self,
selector: #selector(changeStatusFromPushNotification),
name: Notification.Name("status"),
object: nil
)
Next, add the changeStatusFromPushNotification
method to the class:
@objc private func changeStatusFromPushNotification(notification: Notification) {
guard
let data = notification.userInfo as? [String: RideStatus],
let status = data["status"] else { return }
sendStatusChange(status) { successful in
guard successful else { return }
if status == .Neutral {
self.status = .FoundRide
self.cancelButtonPressed(UIButton())
}
if status == .FoundRide {
self.status = .Searching
self.statusButtonPressed(UIButton())
}
}
}
This callback just triggers the sendStatusChange
method that we have already defined earlier in the tutorial.
Creating our notification service extension
Next, we need to create our Notification Service Extension.
💡 When receiving a notification in an iOS app, you may want to be able to download content in response to it or edit the content before it is shown to the user. In iOS 10, Apple now allows apps to do this through a new Notification Service Extension. - Codetuts
In Xcode, go to File > New > Target… and select Notification Service Extension then give the target a name and click Done.
If you look in the file browser in Xcode, you should see the new target added with two new files: NotificationService.swift
and info.plist
. We will be modifying these files to make sure it gets and provides the right information for our push notification.
Open the NotificationService
class and replace the didReceive
method with the following:
override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
self.contentHandler = contentHandler
bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent)
func failEarly() {
contentHandler(request.content)
}
guard
let content = (request.content.mutableCopy() as? UNMutableNotificationContent),
let apnsData = content.userInfo["data"] as? [String: Any],
let mapURL = apnsData["attachment-url"] as? String,
let attachmentURL = URL(string: mapURL.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)!),
let imageData = try? NSData(contentsOf: attachmentURL, options: NSData.ReadingOptions()),
let attachment = UNNotificationAttachment.create(imageFileIdentifier: "image.png", data: imageData, options: nil)
else {
return failEarly()
}
content.attachments = [attachment]
contentHandler(content.copy() as! UNNotificationContent)
}
In the code above, we try to get the content of the push notification. Since we want to display the map in the notification, we are expecting a static map URL from the custom data of the push notification. We use that and serve it as an attachment
which we add the to content of the push. We finally pass the content
to the contentHandler
.
Next, add the following extension to the same file:
extension UNNotificationAttachment {
static func create(imageFileIdentifier: String, data: NSData, options: [NSObject : AnyObject]?) -> UNNotificationAttachment? {
let fileManager = FileManager.default
let tmpSubFolderName = ProcessInfo.processInfo.globallyUniqueString
let tmpSubFolderURL = NSURL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(tmpSubFolderName, isDirectory: true)
do {
try fileManager.createDirectory(at: tmpSubFolderURL!, withIntermediateDirectories: true, attributes: nil)
let fileURL = tmpSubFolderURL?.appendingPathComponent(imageFileIdentifier)
try data.write(to: fileURL!, options: [])
let imageAttachment = try UNNotificationAttachment(identifier: imageFileIdentifier, url: fileURL!, options: options)
return imageAttachment
} catch let error {
print("error \(error)")
}
return nil
}
}
The create
method saves the static map to a temporary location on the device so it does not have to load from a URL.
One final change we want to make is in the info.plist
file. Here we want to register all the action identifiers for the push notification. Open the info.plist
file and add the following as highlighted in the image below;
That’s all we need to do on the application side. Now we need to make sure the API sends the push notifications.
Sending push notifications from our Node.js API
In the Node.js project, open our index.js
file and import the push notification package:
const PushNotifications = require('pusher-push-notifications-node')
const pushNotifications = new PushNotifications({
instanceId: 'YOUR_INSTANCE_ID_HERE',
secretKey: 'YOUR_SECRET_KEY_HERE'
})
💡 You should replace the placeholder values with the values from your Pusher dashboard.
Next, add the following helper functions:
function sendRiderPushNotificationFor(status) {
switch (status) {
case "Neutral":
var alert = {
"title": "Driver Cancelled :(",
"body": "Sorry your driver had to cancel. Open app to request again.",
}
break;
case "FoundRide":
var alert = {
"title": "🚕 Found a ride",
"body": "The driver is on the way."
}
break;
case "Arrived":
var alert = {
"title": "🚕 Driver is waiting",
"body": "The driver outside, please meet him."
}
break;
case "OnTrip":
var alert = {
"title": "🚕 You are on your way",
"body": "The driver has started the trip. Enjoy your ride."
}
break;
case "EndedTrip":
var alert = {
"title": "🌟 Ride complete",
"body": "Your ride cost $15. Open app to rate the driver."
}
break;
}
if (alert != undefined) {
pushNotifications.publish(['rider'], {apns: {aps: {alert, sound: "default"}}})
.then(resp => console.log('Just published:', resp.publishId))
.catch(err => console.log('Error:', err))
}
}
function sendDriverPushNotification() {
pushNotifications.publish(['ride_requests'], {
"apns": {
"aps": {
"alert": {
"title": "🚗 New Ride Request",
"body": `New pick up request from ${rider.name}.`,
},
"category": "DriverActions",
"mutable-content": 1,
"sound": 'default'
},
"data": {
"attachment-url": "https://maps.google.com/maps/api/staticmap?markers=color:red|37.388064,-122.088426&zoom=13&size=500x300&sensor=true"
}
}
})
.then(response => console.log('Just published:', response.publishId))
.catch(error => console.log('Error:', error));
}
Above we have two functions. The first is sendRiderPushNotificationFor
which sends a notification to the rider based on the status of the trip. The second method is the sendDriverPushNotification
which just sends a notification to the driver.
In the sendDriverPushNotification
we can see the format for the push notification is a little different than the first. This is because we are supporting rich actions so we have to specify the category
key and the mutable-content
key. The category
must match the name we specified in the AppDelegate
.
Next, you need to call the functions above in their respective routes. The first function should be added to the POST /status
route above the pusher.trigger
method call. The second function should be called in the POST /request
route above the pusher.trigger
method call.
Now, when we run our applications, we should get push notifications on our devices.
⚠️ When working with push notifications on iOS, the server must be served in HTTPS.
That’s all there is to adding push notifications using Pusher. Heres a screen recording of our applications in action:
Conclusion
In this article, we created a basic ride sharing service and used that to demonstrate how to use Pusher to send push notifications with rich actions. Hopefully you learnt how you can use Pusher to simplify the process of sending Push Notifications to your users.
The source code to the repository is available on GitHub.
16 April 2018
by Neo Ighodaro