Show A List Of Bluetooth Devices Using SwiftUI

A long time ago I wrote a post about how to show a list of nearby Bluetooth devices on iOS using CoreBluetooth. I see some people still look at this post from time to time so thought it may be time to update it, as many things have changed on iOS in that time.

For this post I will try to do this in the quickest and low complexity way possible to get started quickly. Then in a forthcoming post I will address some of the issues faced from this quick approach.

Setting Up The Project

Create a new Xcode project using the iOS SwiftUI template named BluetoothListApp.

We will be needing to use the CoreBluetooth framework to interact with an iOS’s Bluetooth functionality. To link this framework to the project, select the project and under the General tab in Frameworks, Libraries and Embedded Content, tap the + button. Select CoreBluetooth from the menu. Navigate to Link Binary With Libraries in the Build Phases tab you will see the CoreBluetooth.framework has been added.

Add two new files to the project, a SwiftUI file PeripheralListView.swift and a plain Swift file PeripheralListViewModel.swift. Remove the Content.swift file and swap its usage in the BluetoothListApp file with PeripheralListView.swift.

Bluetooth UsageDescription

Usage of Bluetooth APIs require a description in the app’s info.plist, so add a new entry in the info.plist for the key NSBluetoothAlwaysUsageDescription, citing the reason your app needs to use Bluetooth, remembering this will be user-facing. If you struggle to find your info.plist, in recent Xcode versions it has moved to Project Info tab. From iOS 13, the requesting of this permission is now handled by iOS, where a permission prompt will appear as soon as the CBCentralManager is initialised.

Creating The ViewModel

Open the PeripheralListViewModel and update it to look like this:

// 1
import CoreBluetooth
import Foundation
import SwiftUI

@Observable
final class PeripheralListViewModel: NSObject {
    private let bluetoothManager: CBCentralManager // 1
    private(set) var state: CBManagerState = .unknown // 2
    
    // 4
    private var discoveredPeripherals: Set<CBPeripheral> = []
    private(set) var displayPeripherals: [CBPeripheral] = []
      
    init(
        bluetoothManager: CBCentralManager
    ) {
        self.bluetoothManager = bluetoothManager
        super.init()
        bluetoothManager.delegate = self // 2
    }
    
    // 3
    func scan() {
        bluetoothManager.scanForPeripherals(withServices: nil, options: nil)
    }
}

extension PeripheralListViewModel: CBCentralManagerDelegate {
    // 2
    func centralManagerDidUpdateState(_ central: CBCentralManager) {
        state = central.state
    }
    
    // 4
    func centralManager(
        _ central: CBCentralManager,
        didDiscover peripheral: CBPeripheral,
        advertisementData: [String : Any],
        rssi RSSI: NSNumber
    ) {
        if !discoveredPeripherals.contains(peripheral) {
            discoveredPeripherals.insert(peripheral)
            displayPeripherals.append(peripheral)
        }
    }
}
  1. Here we have added the necessary imports and created a class PeripheralListViewModel that is a subclass of NSObject. The class inheritance of NSObject is due to the CBCentralManagerDelegate conforming to NSObjectProtocol, which is needed for communication with the CBCentralManager. As all of the work with CoreBluetooth will be done in this class we will hold a reference to the CBCentralManager, the interface for scanning and connecting to Bluetooth peripherals. I am also using SwiftUI’s Observable macro for this example.

  2. We want information from the CBCentralManager, which it provides via a delegate pattern, so the PeripheralListViewModel conforms to the CBCentralManagerDelegate protocol via extension and sets the class as the CBCentralManager’s delegate in the initialiser. We need to know the current state of the CBCentralManager so we know whether bluetooth is on, authorised and whether we are able to scan for peripherals. To do this the delegate function centralManagerDidUpdateState(_ central: CBCentralManager) is implemented, where it will be called when the manager’s state changes. We then simply forward this to our view by setting the state variable on the PeripheralListViewModel, allowing our SwiftUI PeripheralListView to observe it and act upon change.

  3. The PeripheralListViewModel’s scan function invokes the CBCentralManager to start scanning and will be called by the PeripheralListView. The CBCentralManager scanForPeripherals function takes two arguments. The first is an array of service UUIDs [CBUUID], which will make the CBCentralManager only return peripherals advertising those service UUIDs. This could be useful if you only wanted to discover peripherals that can perform certain functions. As we want to discover all peripherals around us we will pass nil for this argument. The second options argument is a dictionary but we must use specific keys listed here.

The option CBCentralManagerScanOptionAllowDuplicatesKey controls how the CBCentralManager handles duplicate discoveries of an advertising packet from a peripheral. By default (false) duplicates are reduced to one single discovery event for the peripheral but if set to true a discovery event will be fired for every duplicate. The latter can reduce battery life so should be used sparingly, as documented here. For this we don’t want to use non-default configuration so pass nil to options.

The key for option CBCentralManagerScanOptionSolicitedServiceUUIDsKey allows us to pass an array of [CBUUID] again but this option instead makes the central device, in this case our phone, advertise that it offers this service to the peripheral, so as in the name soliciting.

  1. To receive the peripherals discovered by the CBCentralManager the centralManager(_:didDiscover:advertisementData:rssi:) delegate function is implemented. This function will be called on the PeripheralListViewModel every time a new peripheral is discovered. To manage these peripherals there are two properties on the PeripheralListViewModel; a set of CBPeripheral called discoveredPeripherals and an array of CBPeripheral called displayPeripherals.

The reason for having two properties is that it’s necessary to check the discovered peripheral to see if it has been discovered before, as we don’t want it to be displayed twice. So in the didDiscover delegate function the discoveredPeripherals is firstly checked to see if the peripheral exists in the set, if it doesn’t it is added to both the set and the array. The displayPeripherals array is used as the data for the PeripheralListView’s list, as a set cannot fulfil this role as it does not conform to RandomAccessCollection protocol. We could do the contains check on the displayPeripherals array but this would result in slower performance over larger data sets, due to the array’s contain function time complexity performance of O(n) vs the the set’s O(1).

You may be scratching your head like I was, thinking, “but wait I thought CBCentralManagerScanOptionAllowDuplicatesKey was set to false so all the peripheral discovery events should be coalesced by the CBCentralManager so only one event per peripheral?”. Sadly no, a peripheral’s advertising data can be sent over different packets, meaning events are emitted for the initial discovery and then any further information. A more comprehensive answer than mine here.

Creating The View

With that complete now let’s tackle the PeripheralListView. Add the below code to the view.

import SwiftUI
import CoreBluetooth

struct PeripheralListView: View {
    @State var viewModel: PeripheralListViewModel
    
    var body: some View {
        // 1
        switch viewModel.state {
        case .poweredOn:
            List {
                // 2
                ForEach(viewModel.displayPeripherals, id: \.identifier) { peripheral in
                    VStack(alignment: .leading) {
                        Text(peripheral.name ?? "No name")
                        Text(peripheral.identifier.uuidString)
                            .font(.caption)
                    }
                }
            }.task {
                // 3
                viewModel.scan()
            }
        default:
            Text("BLE not available")
        }
    }
}
  1. In the body, the viewModel’s CBCentralManagerState state value is switched over. When the state is poweredOn we know we can attempt to scan for peripherals and render a list of discovered peripherals. For now all other states are combined into default and a Text is displayed – of course this would not fly in a production app!

  2. For the ForEach function to be able to compute the views a unique identifier needs to be given. CBPeripheral via its inheritance of CBPeer has a unique, persistent identifier property identifier, so that can be used via key path in the ForEach initialiser. The UI is simple for each peripheral, a name and its identifier. The name is optional so use nil-coalescing to set the text to “No name” if nil.

  3. The PeripheralListViewModel scan function is called from a task modifier on the List view. This modifier will only fire when the view is about to appear so it seems a safe place for now to enact this call, as the view will only appear when the CBCentralManager state is poweredOn. If we were to call scan in another state it will not work and an API misuse warning will be generated in the console.

Finishing Up

Now all that is left to do is update the BluetoothListApp struct to incorporate the changes to the PeripheralListView.

import CoreBluetooth
import SwiftUI

@main
struct BluetoothListApp: App {
    var body: some Scene {
        WindowGroup {
            PeripheralListView(
                viewModel: PeripheralListViewModel(
                    bluetoothManager: CBCentralManager()
                )
            )
        }
    }
}

Connect your device to your MacBook and run the app. You should see something like the below.

Problems And Conclusion

As promised, this would be the most simplistic way to do this, however there are some noticeable caveats:

  • The view and viewModel now have dependencies on types from CoreBluetooth, which makes things like testing a bit harder. For example can we easily test the PeripheralListView or PeripheralListViewModel without using the whole CoreBluetooth framework?
  • Only the poweredOn CBManager state is handled by the UI. Useful UI to inform and help the user is needed for other states such as poweredOff or unauthorized states. No way to start or stop scanning from user interaction.
  • At the beginning of scanning UI hangs/hitches can be observed in areas with many devices, as each new peripheral discovered changes our viewModel’s internal state, causing SwiftUI to update its view hierarchy.
  • The CoreBluetooth API is relatively old in ergonomics when thinking of modern Swift, there are no async await, Combine or closures supported. If using this in many places across an app can this be modernised with a wrapper?

These problems are mostly all simply fixed with some architectural changes and a slight increase in complexity. In a coming post I will try to show a balanced way of alleviating them. Thank you for reading.

See all the code in GitHub.