Let's get native with React Native

15 November 2022

15 Nov 2022

15/11/22

Jason Crawley

7 MIN READ

7 MIN READ

React Native is a great framework for developing native iOS and Android apps. It provides components that wrap existing native components, allowing us to develop apps with a single code base. But what happens if you need to execute platform-specific code, and there isn’t an appropriate React Native component available? We can bridge the native code ourselves with a native module.

Bluetooth

Bluetooth is functionality we expect on both iOS and Android. For the moment I’ll ignore the existing library react-native-ble-manager and pretend we need to implement React Native modules for both iOS and Android. This example may come in handy if you need to implement a custom Bluetooth protocol later on. 

Below, I will show you how to create a simple screen listing all nearby Bluetooth devices as they are found. This screen will use RTK Query to monitor device names received from a custom native module.

iOS native module

To create a native module in Swift we need to create a class defining our module. React Native has been written in Objective-C, so we will need to expose this class to Objective-C along with any methods needed by React Native. A .m file is needed to export the module and any methods to React Native.

The BluetoothBridge module below exposes startScan() and stopScan() methods to React Native. Internal callbacks are set up to listen for Bluetooth devices within range of the phone. The event channel Device will be used to send device names to React Native as they are received.

import Foundation
import React
import CoreBluetooth

@objc(BluetoothBridge)
class BluetoothBridge:  RCTEventEmitter, CBCentralManagerDelegate, CBPeripheralDelegate {

  var centralManager: CBCentralManager? = nil
 
  override func supportedEvents() -> [String]! {
    return ["Device"]
  }
  
  @objc func startScan() {
    
    if(centralManager != nil) {
      centralManager?.stopScan()
    }
    centralManager =  CBCentralManager(delegate: self, queue: nil)
  }
  
  
  func centralManagerDidUpdateState(_ central: CBCentralManager) {
    switch (central.state) {
    case .poweredOn:
      centralManager?.scanForPeripherals(withServices: [])
      break
    case .unknown:
      break
    case .resetting:
      break
    case .unsupported:
      break
      
    case .unauthorized:
      break
    case .poweredOff:
      break
    @unknown default:
      break
    }
  }
  
  
  func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String : Any], rssi RSSI: NSNumber) {
    
    if(peripheral.name != nil) {
      discoveredDevices[peripheral.name!] = peripheral
      self.sendDevice(device: peripheral)
    }
  }
  
  
  @objc func stopScan() {
    centralManager?.stopScan()
    centralManager = nil
    
    discoveredDevices = [String: CBPeripheral]()
  }
  
  @objc override static func requiresMainQueueSetup() -> Bool {
      return false
  }
  
  @objc func sendDevice(device: CBPeripheral) {
    
    self.sendEvent(withName: "Device", body: [
      "name": device.name!,
    ])
  }
  
}

The supportedEvents() method is required by the RCTEventEmitter interface. This returns an array of strings for each channel we will emit to React Native. BluetoothBridge is using a single channel Device. The channels can contain primitive or JSON data. For convenience I have created the method sendDevice() to convert a CBPeripheral into JSON and send it down the Device channel to React Native.

Scanning for devices is started by the startScan() method, which instantiates the CBCentralManager. The centralManagerDidUpdateState() method will be called when the Bluetooth state changes. When the poweredOn state is received the app will start scanning for devices, and when a device is discovered, centralManager() is called. This will send the device to React Native via the sendDevice() method. 

Before we can use this module we need to expose it to React Native, including the required methods. The BluetoothBridgeModule.m file exports the module via the RCT_EXTERN_MODULE macro. The startScan() and stopScan() methods are exposed by the macro RCT_EXTERN_METHOD.

#import <Foundation/Foundation.h>

#import "React/RCTBridgeModule.h"

@interface RCT_EXTERN_MODULE(BluetoothBridge, NSObject)

RCT_EXTERN_METHOD(startScan)
RCT_EXTERN_METHOD(stopScan)


@end

Android native module

Creating an Android native module is a similar process to that for iOS, although the syntax is necessarily different. First we create the BluetoothBridge class, and then expose it to React Native by making an AppPackage class.

The BluetoothBridge.java class extends the abstract class ReactContextBaseJavaModule. Any methods we want available in React Native need to be exposed via the @ReactMethod annotations. This class performs the same functionality as its iOS counterpart. The startScan() method will start scanning for Bluetooth devices, and as devices are discovered they will be sent to React Native via the channel Device.

package com.rnbridgingproject;

import android.annotation.SuppressLint;
import android.bluetooth.BluetoothAdapter;
import android.bluetooth.BluetoothDevice;
import android.bluetooth.le.BluetoothLeScanner;
import android.bluetooth.le.ScanCallback;
import android.bluetooth.le.ScanFilter;
import android.bluetooth.le.ScanResult;
import android.bluetooth.le.ScanSettings;
import android.os.ParcelUuid;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;

import com.facebook.react.bridge.Arguments;
import com.facebook.react.bridge.ReactContext;
import com.facebook.react.bridge.ReactContextBaseJavaModule;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.bridge.ReactMethod;
import com.facebook.react.bridge.WritableMap;
import com.facebook.react.modules.core.DeviceEventManagerModule;
import com.wilsontenantreact.nedap.openGate.log.RNOpenGateLogger;
import java.util.*;

public class BluetoothBridge extends ReactContextBaseJavaModule {


    private BluetoothLeScanner scanner;

    private ReactApplicationContext context;


    private final scanCallback = new ScanCallback(){
        @Override
        public void onScanResult(int callbackType, scanResult result){

            if(result.getDeice().getName() != null){
                WritableMap params = Arguments.createMap();
                params.putString("name", result.getDevice().getName());

                sendEvent("Device", params);
            }
        }
    }

    public BluetoothBridge(ReactApplicationContext context) {
        super(context);
        this.context = context;
        bleCallback = new BluetoothScanCallback();
    }

    @NonNull
    @Override
    public String getName() {
        return "BluetoothBridge";
    }

    @SuppressLint("MissingPermission")
    @ReactMethod
    public void startScan() {

        scanner = BluetoothAdapter.getDefaultAdapter().getBluetoothLeScanner();

        if(scanner != null) {
            ArrayList<ScanFilter> filters =new ArrayList<>();
            scanFilters = new ArrayList<>()

            ScanSettings settings = new ScanSettings.Builder().
                    setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY).
                    build();


            scanner.startScan(filters, settings, bleCallback)


        }
    }

    private void sendEvent(String eventName, @Nullable WritableMap params) {
        context.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class).emit(eventName, params);
    }

    @ReactMethod
    public void stopScan() {
        if(scanner != null) {
            scanner.stopScan(bleCallback);
            scanner = null;
        }
    }


    // Required for RN built in EventEmitter Calls, otherwise the app will give warnings
    // (Warning: `new NativeEventEmitter` was called with anon null argument without the required `removeListeners` method.)
    @ReactMethod
    public void addListener(String eventName) {

    }

    @ReactMethod
    public void removeListeners(Integer count) {

    }
}

Event channels are implemented differently on Android. For convenience I have created the sendEvent() method, which calls emit on the RCTDeviceEventEmitter module we get from the ReactApplicationContext provided at construction. The event name parameter is the name of the channel to send the event on, and params is the JSON data to send on the channel. 

The startScan() method gets the BluetoothLeScanner from the Bluetooth adapter. If a scanner exists, filters and configuration are set up to perform the scan. Here we are using the bare minimum, so we have no filters and are scanning using the SCAN_FOR_LOW_LATENCY constant. startScan() is called on the scanner object with this configuration and the Bluetooth callback, bleCallback. This callback will be called every time a device is discovered and will send the name of the device to React Native via the `Device` channel, using the sendEvent() method.

In order to connect our native module to React Native we will need an AppPackage class. This class implements the ReactPackage interface. The createNativeModules method returns the list of NativeModules, including BluetoothBridge. We do not need to use native components, so the createViewManager() method just returns an empty list.

package com.wilsontenantreact;

import androidx.annotation.NonNull;

import com.facebook.react.ReactPackage;
import com.facebook.react.bridge.NativeModule;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.uimanager.ViewManager;
import com.wilsontenantreact.nedap.NedapBridge;
import com.wilsontenantreact.bluetooth.BluetoothBridge;

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;


import java.util.List;

public class AppPackage implements ReactPackage {

    @NonNull
    @Override
    public List<NativeModule> createNativeModules(@NonNull ReactApplicationContext reactContext) {
        List<NativeModule> modules = new ArrayList<>();

        modules.add(new BluetoothBridge(reactContext));

        return modules;
    }

    @NonNull
    @Override
    public List<ViewManager> createViewManagers(@NonNull ReactApplicationContext reactContext) {
        return Collections.emptyList();
    }
}

In order to use the AppPackage class defined above we need to add it to the list of packages returned by the getPackages method within MainApplication.java.

@Override
protected List<ReactPackage> getPackages() {
 @SuppressWarnings("UnnecessaryLocalVariable")
 List<ReactPackage> packages = new PackageList(this).getPackages();

 packages.add(new AppPackage());
 return packages;
}

Using the module

With the above Android and iOS implementations done we are now ready to use the native module. First, I like to create a service that wraps the native calls and provides common structures in a single place. Since this endpoint requires monitoring and a lot of asynchronous processing, I will use an RTK Query API to handle this processing for me. Finally, I will use a simple React Native UI to display a list of devices.

Define the service

Native modules in React Native are accessed via the constant NativeModules. This constant will expose all registered native modules and does not enforce type safety. For convenience, and to ensure type safety, I like to create a service wrapping all the calls required for a specific service. Below is the code for the BluetoothService wrapping the BluetoothBridge modules defined for iOS and Android. 

import {
  EmitterSubscription,
  NativeEventEmitter,
  NativeModules,
} from 'react-native';

export namespace BluetoothService {
  const eventEmitter = new NativeEventEmitter(NativeModules.BluetoothBridge);

  export interface BleDevice {
    name: string;
  }

  export function startScanForDevices(
    callback: (device: BleDevice) => void,
  ): EmitterSubscription | undefined {
    const eventListener = (event: any) => {
      callback(event as BleDevice);
    };

    const {BluetoothBridge} = NativeModules;
    BluetoothBridge.startScan();

    const sub = eventEmitter!.addListener('Device', eventListener);

    return sub;
  }

  export function stopScanForDevices(eventSub?: EmitterSubscription) {
    const {BluetoothBridge} = NativeModules;
    BluetoothBridge.stopScan();

    if (eventSub != null) {
      eventSub.remove();
    }
  }
}

The startScanForDevices function calls startScan on the BluetoothBridge native module. Both the iOS and Android native modules send discovered devices by the channel Device. A listener is added to this channel via the eventEmitter object. This listener will trigger the provided callback and is returned.

The stopScanForDevices function calls the stopScan method on the BluetoothBridge native module. Then the provided EmitterSubscription is removed, which will stop receiving events from the Device channel.

Define RTK query API

The BluetoothService could be used directly by a UI. However, for convenience, caching and managing asynchronous processing, I will use RTK Query. RTK Query is a framework using Redux that allows us to define APIs for asynchronous processing. Data returned and status data will be stored in Redux, and hooks will automatically be created to use this data and return the latest as the data changes. More information can be found in my previous post, RTK Query: a better way to Redux

In most cases RTK Query can be used to define an endpoint that will request data asynchronously and await a response. In our case the BluetoothService will periodically send us new devices discovered. We can configure our endpoint to stream updates. This endpoint will receive data while in scope, maintain a list of discovered devices, and update the Redux cache with this list. The bluetoothApi below defines the endpoint scanForDevices which will do this for us.

import {createApi, fakeBaseQuery} from '@reduxjs/toolkit/query/react';
import {BluetoothService} from '../../services/BluetoothService';

export const bluetoothApi = createApi({
  reducerPath: 'bluetoothApi',
  baseQuery: fakeBaseQuery(),
  endpoints: build => ({
    scanForDevices: build.query<BluetoothService.BleDevice[], void>({
      queryFn: () => {
        return {data: []};
      },
      async onCacheEntryAdded(
        args,
        {updateCachedData, cacheDataLoaded, cacheEntryRemoved},
      ) {
        await cacheDataLoaded;

        const devices: Record<string, BluetoothService.BleDevice> = {};

        const sub = BluetoothService.startScanForDevices(device => {
          devices[device.name] = device;

          updateCachedData(() => Object.values(devices));
        });

        await cacheEntryRemoved;

        BluetoothService.stopScanForDevices(sub);
      },
    }),
  }),
});

Through this API the hook useScanForDevicesQuery will be created from the scanForDevices endpoint definition. This hook will be used by the UI later on. When the component using this hook mounts it will first initialise a cache in Redux containing an empty array. This is determined by the data returned from the queryFn defined in the API. 

Once the Redux cache is initialised for the scanForDevices endpoint, the onCacheEntryAdded callback will be triggered. This blocks until the cache has been loaded via the call to await cacheDataLoaded. This will then create a record mapping device name to BleDevice (returned from the Bluetooth Service). A subscription is initialised by calling startScanForDevices

The startScanForDevices function registers a callback and returns a subscription. In this case the callback will add a received device to the record of devices and update the cache with values from the record. This is done by calling the updateCacheData function.

The scanForDevices endpoint will continue to receive device updates until the cacheEntryRemoved event occurs. This happens when the useScanForDevicesQuery hook goes out of scope–i.e., the component containing the hook is unmounted. When this occurs the stopScanForDevices function will be called on the BluetoothService.

Simple UI

To use bluetoothApi I have created a simple UI that will list devices as they are discovered. A screenshot of this UI is shown below.

ScanScreen is a React Native functional component for the purpose of displaying the list of devices discovered. This uses the useScanForDevicesQuery hook defined in bluetoothApi. Every time a device is discovered, data from this hook will be updated causing a re-render. Devices will be listed as simple Text components.

import React from 'react';
import {SafeAreaView, Text, View} from 'react-native';
import {bluetoothApi} from '../reducers/BluetoothApi/BluetoothApi';

export default function ScanScreen() {
  const devices = bluetoothApi.useScanForDevicesQuery();

  return (
    <View>
      <SafeAreaView />

      <View style={{margin: 20}}>
        <Text>Bluetooth devices</Text>
        <View style={{height: 20}} />

        {devices.data?.map(d => {
          return (
            <View>
              <Text>{d.name}</Text>
            </View>
          );
        })}
      </View>

      <SafeAreaView />
    </View

React Native is a powerful JavaScript framework allowing us to develop apps for multiple platforms with one code base. Most functionality for iOS and Android can be developed purely within React Native, but occasionally we need a custom native integration not covered by React Native or a third party library. The good news is these integrations are simple to create. By creating native modules for the custom integration, our new functionality can be used on both platforms as if it were shipped with React Native. Give it a go 🙂