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)