Introduction to BLE Mobile Development [iOS] – Part 2

This post is an excerpt from a course developed by Anas Imtiaz, Ph.D. which is available within the Bluetooth Developer Academy.

In a previous post (Introduction to BLE Mobile Development for iOS), we covered:

  • Setting up Xcode for BLE development
  • Bluetooth permissions
  • Scanning for BLE peripherals
  • Connecting to a BLE peripheral

In today’s post, we will take this a step further and look at developing a UI-based iOS app (using SwiftUI) that can perform most of the previous functionalities, with some additional features:

  • Display the status of Bluetooth on the device (turned on or off)
  • Act as a Central to discover BLE Peripherals and display them as a list in the UI
  • Start and stop scanning by clicking dedicated buttons in the UI

Note: we will be using SwiftUI to develop the user interface which requires iOS13 and Xcode 11. If you are on previous versions you can still follow along since the Core Bluetooth APIs are exactly the same regardless of the method used to build the user interface.

For this tutorial, we’ll be using:

  • MacBook Pro 2017 
  • macOS Catalina (10.15.2) 
  • XCode version 11.3.1
  • iPhone 11 (iOS version 13.3.1)
  • Swift 5.1
  • Optional: Another iDevice (we’re using iPad Pro (1st generation, iPadOS 11.3))

The prerequisites for this tutorial include:

  • An Apple computer: Macbook, Mac Mini, iMac
  • macOS
  • XCode 11+
  • iPhone with BLE (iOS13+)
  • A BLE Peripheral device
  • Enrollment in the Apple Developer Program (Free, unless you want to deploy to the App Store in which case it is USD99 per year)
  • Basic understanding of Swift

Note: you can find the reference code for this tutorial towards the end of the post.

Setting up the Project

To start things off, fire up Xcode on your Mac and select Create a new Xcode Project. For the template select Single View App for iOS.

On the next screen, enter the app name and other details. Make sure to select SwiftUI for the user interface for this app.

Next, select the location where you want to save the project and the press Create.

On the left hand side you will see the list of files. The main file here that we will use for our UI is ContentView.swift.

On the right-hand side, you will see a live preview pane which will refresh every time the UI is being updated. It may be paused to start with, so hit the Resume button to build and preview the boilerplate code.

You should see the “Hello, World!” text on the preview.

Building the User Interface

(Note: We will keep this section brief in order to keep the focus of the tutorial on Core Bluetooth operations)

We are building an app that is able to scan for BLE peripherals. For its functionality, as a BLE scanner, we want to display a list of devices that we have found as well as the ability to start and stop scanning.

For operation as a BLE peripheral, we want our app to be able to start and stop advertising itself as a peripheral device (covered in the full course in the Bluetooth Developer Academy).

Finally, we also want our app to display the status of our Bluetooth hardware i.e. whether its switched on or off. The user interface we want will look something like this:

From top to bottom, we want some text for the title, a list of devices, status text, and a group of four buttons for control.

Before we move forward, let’s take a quick detour into the world of SwiftUI which is Apple’s latest way of building user interfaces in a declarative way. In SwiftUI, if we want to add a text label, we simply say:

Text("my text")

If we want to make this text italic we add a modifier:

Text("my text")
  .italic()

If we want to change its color to red, we add another modifier:

Text("my text")
  .italic()
  .foregroundColor(.red)

This is true for other UI elements as well where we simply add modifiers to achieve the desired look and functionality.

Another important concept is that of stacks. UI elements are organised in horizontal and vertical stacks (HStack and VStack). For example, two buttons laid side by side will be in HStack:

Two buttons laid vertically can be in a VStack:

And they can also be nested where there are groups of VStack inside HStack or vice versa.

Looking at our UI sketch above, we want a main VStack that holds all the elements inside starting with a Text object, a List object, a Text object saying STATUS following by another Text object which is showing the actual status, and finally a HStack with two VStacks each having a pair of Button objects. Additionally, for spacing between objects, we will use Spacer().

Adding all those UI elements in ContentView.swift, our starting point for the user interface will look like this.

And our ContentView.swift should have the following code:

import SwiftUI
struct ContentView: View {
    var body: some View {
        VStack (spacing: 10) {
            Text("Bluetooth Devices")
                .font(.largeTitle)
                .frame(maxWidth: .infinity, alignment: .center)
            List() {
                Text("placeholder 1")
                Text("placeholder 2")
            }.frame(height: 300)
            Spacer()
            Text("STATUS")
                .font(.headline)
            // Status goes here
            Text("Bluetooth status here")
                .foregroundColor(.red)
            Spacer()
            HStack {
                VStack (spacing: 10) {
                    Button(action: {
                        print("Start Scanning")
                    }) {
                        Text("Start Scanning")
                    }
                    Button(action: {
                        print("Stop Scanning")
                    }) {
                        Text("Stop Scanning")
                    }
                }.padding()
                Spacer()
                VStack (spacing: 10) {
                    Button(action: {
                        print("Start Advertising")
                    }) {
                        Text("Start Advertising")
                    }
                    Button(action: {
                        print("Stop Advertising")
                    }) {
                        Text("Stop Advertising")
                    }
                }.padding()
            }
            Spacer()
        }
    }
}
struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

We will continue to add to this initial UI code while we are exploring the CoreBluetooth framework in the next sections.

iPhone as a BLE Central Device

Setting Up

Since we are using SwiftUI for our user interface, we will create a separate class as an Observable Object to manage Bluetooth connections. This will be a class that will handle all Bluetooth operations that any View can subscribe to in order to get updates about the peripheral device.

The reason for having it as an observable object is to trigger a refresh and reload of the UI elements automatically whenever there is any data update.

Go to File –> New –> File

Choose iOS Swift File for the template. Click Next and save it as BLEManager.swift.

In this new file, define a new class BLEManager that conforms to NSObject and ObservableObject classes.

import Foundation
class BLEManager: NSObject, ObservableObject {
}

This is our empty class right now. We want to have our iOS device act as a BLE Central. So, we need to import the CoreBluetooth framework, define a variable of type CBCentralManager, and define the required CBCentralManagerDelegate methods. Add the CBCentralManagerDelegate to your class definition.

import Foundation
import CoreBluetooth
class BLEManager: NSObject, ObservableObject, CBCentralManagerDelegate {
    var myCentral: CBCentralManager!
}

XCode will throw up an error saying: Type ‘BLEManager’ does not conform to protocol ‘CBCentralManagerDelegate’.

Use the auto-fix feature to get the stubs for the required methods that are currently missing in this class. In this case, the missing method is centralManagerDidUpdateState.

Xcode will add the following in your class:

func centralManagerDidUpdateState(_ central: CBCentralManager) {
}

Initialising CentralManager

When our BLEManager class is instantiated we want to initialise its myCentral instance variable and assign its delegate to self so that the delegate methods are called. This is done by overriding the default initializers init() method to perform the initialisation tasks.

Add the following function to your BLEManager class to initialise the central manager.

    override init() {
        super.init()
        myCentral = CBCentralManager(delegate: self, queue: nil)
        myCentral.delegate = self
    }

Note: If you followed the Introduction to Mobile Development for iOS tutorial, we used a single ViewController and included our BLE functionality as part of the same class.

In this tutorial, we are using a slightly different approach for the definition of BLE functions as we are creating a separate class object and observing it through the new SwiftUI framework. The APIs are exactly the same and the only difference is the initial setup. So what we are doing right now is exactly the same process as the other tutorial.

Before we finish the initial set up, let’s also add a boolean class variable that is set to true if Bluetooth is switched on BLEManager has been initialised, and false if it is switched off on our iPhone.

Since we want to display the status on our UI and automatically update the UI whenever the status changes, we will define it with the @Published attribute.

@Published var isSwitchedOn = false

The @Published variable, as part of an @ObservableObject class will trigger an automatic UI refresh whenever its value changes and we can query the value to present the appropriate view. We will assign the values of isSwitchedOn in our didUpdateState delegate method as follows.

func centralManagerDidUpdateState(_ central: CBCentralManager) {
    if central.state == .poweredOn {
        isSwitchedOn = true
    }
    else {
        isSwitchedOn = false
    }
}

As a checkpoint, make sure your BLEManager class looks like the following at this time.

import Foundation
import CoreBluetooth
class BLEManager: NSObject, ObservableObject, CBCentralManagerDelegate {
    var myCentral: CBCentralManager!
    @Published var isSwitchedOn = false
    override init() {
        super.init()
        myCentral = CBCentralManager(delegate: self, queue: nil)
        myCentral.delegate = self
    }
    func centralManagerDidUpdateState(_ central: CBCentralManager) {
        if central.state == .poweredOn {
            isSwitchedOn = true
        }
        else {
            isSwitchedOn = false
        }
    }
}

Finally, we need to instantiate this class in our ContentView, so go ahead to ContentView.swift, and add the following as the first line inside the ContentView definition.

@ObservedObject var bleManager = BLEManager()

To update the status, we will use a Text object that checks the value of isSwitchedOn variable. We can do it like this:

// Status goes here
if bleManager.isSwitchedOn {
    Text("Bluetooth is switched on")
        .foregroundColor(.green)
}
else {
    Text("Bluetooth is NOT switched on")
        .foregroundColor(.red)
}

Copy the code above to replace the existing code for the status text in ContentView.swift.

Now our UI and initial BLEManager class is set up and ready for use. Go ahead to build and run the code on your phone to see it in action.

CoreBluetooth Central Permissions

At this point, our app builds successfully but crashes on launch. In your debug console, you will be shown an error as follows.

What just happened?

We forgot to add the right entitlement for Bluetooth usage in the Info.plist file. This is a mandatory requirement so that the users are presented with the right message when permission to access Bluetooth is being asked by the phone.

To fix this, go to your Info.plist file and add a new row. From the dropdown select “Privacy – Bluetooth Always Usage Description” and add a message that you want to be displayed.

Now let’s try again and this time everything should work well.

When you run the app you will see the user interface with the status that Bluetooth is not switched on. You will also be presented with the permissions dialog. Once you grant permission to use Bluetooth, the status should update as shown on the UI to indicate that it is now powered on as shown below.

(Note that the relevant key here is NSBluetoothAlwaysUsageDescription since iOS13. Prior to iOS13, the key for Bluetooth permissions was NSBluetoothPeripheralUsageDescription. Therefore, for compatibility it is recommended to have both these keys defined in the plist file.)

The UI looks good now, however, our list is empty since we haven’t scanned and found any peripheral devices yet.

Let’s now find devices to populate our list.

Scan for Peripheral Devices

As in the previous tutorial, we will use the scanForPeripherals method to start scanning. This will trigger callbacks to the didDiscoverPeripheral method where we will keep track of all the devices that we find and then populate our UI with them. 

The way we will tackle this is by creating an array where we can append the name and RSSI of every device we discover by scanning.

Let’s define a struct called Peripheral to hold this information on top of our BLEManager.swift file – after the import statements (we can do this in a separate file too but for convenience, we will do it here).

struct Peripheral: Identifiable {
    let id: Int
    let name: String
    let rssi: Int
}

Our struct has three variables, with the name and rssi being the obvious ones. The id is of type Int which is added because we are subclassing from the Identifiable class.

The reason for doing this is purely for user interface purposes. We have a List view in our UI that will have several rows. Each row needs to have a separate identifier so that when we pass this our array of Peripheral objects as the data source of our List, it becomes super easy to display them. An excellent explanation of working with Identifiable items can be found here:

https://www.hackingwithswift.com/books/ios-swiftui/working-with-identifiable-items-in-swiftui

Now, declare an array for Peripheral objects in the BLEManager class. We will do this with the @Published attribute as well so that the UI is refreshed when new peripherals are discovered.

@Published var peripherals = [Peripheral]()

Now we will define the operation of the didDiscoverPeripheral delegate method. If you start typing didDiscover, Xcode will start showing a list of methods from where you can select the right method to autocomplete its definition.

Fill up this method as follows.

func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String : Any], rssi RSSI: NSNumber) {
    var peripheralName: String!
   
    if let name = advertisementData[CBAdvertisementDataLocalNameKey] as? String {
        peripheralName = name
    }
    else {
        peripheralName = "Unknown"
    }
   
    let newPeripheral = Peripheral(id: peripherals.count, name: peripheralName, rssi: RSSI.intValue)
    print(newPeripheral)
    peripherals.append(newPeripheral)
}

What we are doing here is that as soon as we discover a new peripheral, we define a new Peripheral object with its name as the name being advertised by the peripheral and its rssi as peripheral.rssi. Note that we are using CBAdvertisementDataLocalNameKey key to access the name being advertised. We can also use peripheral.name but this may return the name of the device that exists in its database as the GAP device name.

We also assign the id as the length of the array making the id unique. Finally, we append this newPeripheral to our peripherals array. What we are doing here is appending the name of every peripheral we find in our scan and the peripheral rssi to the array of structs. Since this array is @Published and is part of an @ObservableObject, any changes to it will trigger a UI refresh automatically.

We still haven’t fed this array as the data source, so go into your ContentView.swift file and replace the List object we had earlier with the following.

List(bleManager.peripherals) { peripheral in
    HStack {
        Text(peripheral.name)
        Spacer()
        Text(String(peripheral.rssi))
    }
}.frame(height: 300)

Briefly, what is happening here is that we have a List view with its data source as bleManager.peripherals array. For each element (peripheral) in this array, we define a row as HStack (horizontal stack of elements) where we display a Text view with the peripheral name, some space, and then a Text view with the peripheral RSSI. That’s pretty much it, our list view is ready to use.

One final piece of the puzzle left is that we still don’t have a method to start the scanning. We have a Button which we wish to use to do this. So let’s go to BLEManager and define a function called startScanning() as follows.

   func startScanning() {
        print("startScanning")
        myCentral.scanForPeripherals(withServices: nil, options: nil)
    }

And another one called stopScanning() as follows.

    func stopScanning() {
        print("stopScanning")
        myCentral.stopScan()
    }

Now we just need to add these functions as the corresponding Button actions in our user interface.

Make the following changes to the Start/Stop scanning buttons in ContentView.swift.

Button(action: {
    self.bleManager.startScanning()
}) {
    Text("Start Scanning")
}
Button(action: {
    self.bleManager.stopScanning()
}) {
    Text("Stop Scanning")
}

That was a bit of work there setting up everything on the UI and linking it to our BLEManager. Build and run on your phone, and press start scanning.

Keep an eye on your debug console as well to see which functions are being executed (the print statements have been added to help with that).

As you can see several devices are quickly shown on the list (which is scrollable) so you can move up and down to view the complete list.

Congratulations! You have set up the very basics of scanning as a Central Manager and displaying the list on your phone.

Source Code

ContentView.swift:

import SwiftUI
 
struct ContentView: View {
    
    @ObservedObject var bleManager = BLEManager()
 
    var body: some View {
        VStack (spacing: 10) {
 
            Text("Bluetooth Devices")
                .font(.largeTitle)
                .frame(maxWidth: .infinity, alignment: .center)
            List(bleManager.peripherals) { peripheral in
                HStack {
                    Text(peripheral.name)
                    Spacer()
                    Text(String(peripheral.rssi))
                }
            }.frame(height: 300)
 
            Spacer()
 
            Text("STATUS")
                .font(.headline)
 
            // Status goes here
            if bleManager.isSwitchedOn {
                Text("Bluetooth is switched on")
                    .foregroundColor(.green)
            }
            else {
                Text("Bluetooth is NOT switched on")
                    .foregroundColor(.red)
            }
 
            Spacer()
 
            HStack {
                VStack (spacing: 10) {
                    Button(action: {
                        self.bleManager.startScanning()
                    }) {
                        Text("Start Scanning")
                    }
                    Button(action: {
                        self.bleManager.stopScanning()
                    }) {
                        Text("Stop Scanning")
                    }
                }.padding()
 
                Spacer()
 
                VStack (spacing: 10) {
                    Button(action: {
                        print("Start Advertising")
                    }) {
                        Text("Start Advertising")
                    }
                    Button(action: {
                        print("Stop Advertising")
                    }) {
                        Text("Stop Advertising")
                    }
                }.padding()
            }
            Spacer()
        }
    }
}
 
struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

BLEManager.swift:

//
//  BLEManager.swift
//  BLE_Background
//
//  Created by Mohammad Afaneh on 5/6/20.
//  Copyright © 2020 NovelBits. All rights reserved.
//
import Foundation
import CoreBluetooth
struct Peripheral: Identifiable {
    let id: Int
    let name: String
    let rssi: Int
}
class BLEManager: NSObject, ObservableObject, CBCentralManagerDelegate {
    
    var myCentral: CBCentralManager!
    @Published var isSwitchedOn = false
    @Published var peripherals = [Peripheral]()
    
        override init() {
            super.init()
     
            myCentral = CBCentralManager(delegate: self, queue: nil)
            myCentral.delegate = self
        }
    func centralManagerDidUpdateState(_ central: CBCentralManager) {
         if central.state == .poweredOn {
             isSwitchedOn = true
         }
         else {
             isSwitchedOn = false
         }
    }
    func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String : Any], rssi RSSI: NSNumber) {
        var peripheralName: String!
       
        if let name = advertisementData[CBAdvertisementDataLocalNameKey] as? String {
            peripheralName = name
        }
        else {
            peripheralName = "Unknown"
        }
       
        let newPeripheral = Peripheral(id: peripherals.count, name: peripheralName, rssi: RSSI.intValue)
        print(newPeripheral)
        peripherals.append(newPeripheral)
    }
    
    func startScanning() {
         print("startScanning")
         myCentral.scanForPeripherals(withServices: nil, options: nil)
     }
    
    func stopScanning() {
        print("stopScanning")
        myCentral.stopScan()
    }
    
}

Summary & Closing

In this tutorial, we covered a lot, including:

  • Setting up an Xcode project
  • Setting up an iOS device as a BLE Central to scan for Peripherals
  • Designing a UI that supports starting the scanning process, stopping the scanning process, and listing the discovered Peripherals
  • Display the Bluetooth status on the iOS device (whether it’s on or off)

“Learn The Basics of Bluetooth Low Energy EVEN If You Have No Coding Or Wireless Experience!"

Don't miss out on the latest articles & tutorials. Sign-up for our newsletter today!

Take your BLE knowledge to the next level.

If you’re looking to get access to full video courses covering more topics, then check out the Bluetooth Developer Academy.

As part of all the courses within the Academy, you’ll also be able to download the full source code to use as a reference or use within your own application.

By joining the Bluetooth Developer Academy, you will get access to a growing library of video courses.

The Academy also features access to a private community of Bluetooth experts, developers, and innovators. You’ll get to connect and interact with me and other experts in the Bluetooth space, learn from others’ experiences and knowledge, and share yours as well.

So, what are you waiting for?? Join today!