Communication entre une App Mobile créée avec React Native et une app Apple Watch créée avec SwifUI

Axel de Sainte Marie
13 min readApr 21, 2024

J’ai été sollicité il y a peu sur les possibilités de communication entre une app développée en React Native et une app développée pour une Apple Watch ou un Android Wear. S’il n’est pas possible aujourd’hui de développer une application Apple Watch ou Android Wear en React Native, voyons ce qu’il est possible de faire avec un minimum de connaissance.

N’ayant à disposition pour le moment qu’une Apple Watch, je commencerai par tester mon projet dessus, dans l’éventualité plus tard de le prolonger pour le faire fonctionner avec un Android Wear.

L’idée est d’utiliser la bibliothèque react-native-wacth-connectivity dévelopée par Michael Ford, pour envoyer une information depuis l’app sur la montre, et en retour de récupérer une information de la montre (à voir ce qui est disponible via le SDK) pour l’envoyer à l’app.

Mise en place de l’environnement

Commençons par créer une nouvelle application pour notre projet.

npx react-native@0.73.6 init DemoApp --version 0.73.6

Installons notre librairie.

yarn add react-native-watch-connectivity // Installation de la lib RN
cd ios && pod install && cd .. // Installation des Pod iOS
yarn ios // Lancement de l'app

Mon app démarre avec l’écran par défaut de React Native. Modifions cet écran (App.tsx) pour avoir un écran contenant un seul bouton qui enverra les données à la montre.

import React, { useState } from 'react';

import {
SafeAreaView,
StyleSheet,
Text,
TouchableOpacity,
View,
} from 'react-native';

const App = () => {

const [watchData, setWatchData] = useState<string>("No data");

const onSendingDataRequest = () => {

}

return <SafeAreaView style={styles.mainContainer}>
<View style={styles.mainView}>
<TouchableOpacity onPress={onSendingDataRequest} style={styles.button}>
<Text style={styles.buttonText}>Send Data to the Watch</Text>
</TouchableOpacity>
</View>
<View style={styles.watchData}>
<Text style={styles.dataTitle}>Data from the Watch</Text>
<Text style={styles.dataBody}>{watchData}</Text>
</View>
</SafeAreaView>
}

const styles = StyleSheet.create({
mainContainer: {
flex: 1,
backgroundColor: "#303F61"
},
mainView: {
flex: 1,
justifyContent: "center",
alignItems: "center"
},
button: {
paddingHorizontal: 20,
height: 60,
backgroundColor: "#FFF",
borderRadius: 30,
justifyContent: "center",
alignItems: "center"
},
buttonText: {
fontSize: 12,
fontWeight: "bold"
},
watchData: {
flex: 1,
alignItems: "center"
},
dataTitle: {
color:"#FFF",
fontSize: 16,
fontWeight: "bold"
},
dataBody: {
color:"#FFF",
fontSize: 14,
fontWeight: "400",
paddingTop:20
}
});

export default App;
Simple app to send and receive data to and from the watch

Intéressons-nous maintenant à l’application de la montre. Ouvrons notre projet iOS.

open ios/DemoApp.xcworkspace/

Une fois notre projet ouvert, ajoutons une application Watch dans nos “target”. (J’ai également téléchargé un simulateur Apple Watch pour lancer le projet).

Une fois installé, je peux lancer l’app depuis Xcode.

Interface par défaut d’une app Apple Watch

On voit que l’app affiche le mythique “Hello, world!”. Le but est donc de changer ce texte, par un texte qui sera envoyé depuis l’app.

Envoie de données de l’app vers la montre

Développement du code de l’app

Nous revenons donc à notre fichier App.tsx dans lequel nous allons utiliser la méthode sendMessage de notre bilbliothèque react-native-watch-connectivity.

import React, { useState } from 'react';
import {
ActivityIndicator,
Alert,
SafeAreaView,
StyleSheet,
Text,
TouchableOpacity,
View,
} from 'react-native';
import { WatchPayload, sendMessage, getReachability, getIsPaired } from 'react-native-watch-connectivity';
const App = () => {
const [watchData, setWatchData] = useState<string>("No data");
// (1)
const [viewDidAppear, setViewDidAppear] = useState<boolean>(false);
const [isReachable, setIsReachable] = useState<boolean>(false);
// (3)
const onViewWillAppear = async () => {
await checkReachability();
setViewDidAppear(true);
}
// (4)
const checkReachability = async () => {
const watchPaired: boolean = await getIsPaired();
if (watchPaired === true) {
const watchReachability: boolean = await getReachability();
setIsReachable(watchReachability);
} else {
Alert.alert(
"Erreur",
"Aucune montre apairée.",
[
{text:"Réessayer", onPress:checkReachability}
]
);
}
}
// (5)
const onSendingDataRequest = () => {
sendMessage({
text: "Hello Watch,\nby React Native App",
}, (reply: WatchPayload) => {
console.log("response", reply);
}, error => {
console.log("error", error);
});
}
// (2)
const getContent = () => {
if (viewDidAppear === false) {
return <View onLayout={onViewWillAppear} style={styles.mainView}>
<ActivityIndicator color={"#FFFFFF"} />
</View>
}
const mainButtonAction: () => void = isReachable === true ? onSendingDataRequest : checkReachability;
const mainButtonTitle: string = isReachable === true ? "Send Data to the Watch" : "Chack watch reachability";
return <View style={{ flex: 1 }}>
<View style={styles.mainView}>
<TouchableOpacity onPress={mainButtonAction} style={styles.button}>
<Text style={styles.buttonText}>{mainButtonTitle}</Text>
</TouchableOpacity>
</View>
<View style={styles.watchData}>
<Text style={styles.dataTitle}>Data from the Watch</Text>
<Text style={styles.dataBody}>{watchData}</Text>
</View>
</View>
}
return <SafeAreaView style={styles.mainContainer}>
{getContent()}
</SafeAreaView>
}
const styles = StyleSheet.create({
mainContainer: {
flex: 1,
backgroundColor: "#303F61"
},
mainView: {
flex: 1,
justifyContent: "center",
alignItems: "center"
},
button: {
paddingHorizontal: 20,
height: 60,
backgroundColor: "#FFF",
borderRadius: 30,
justifyContent: "center",
alignItems: "center"
},
buttonText: {
fontSize: 12,
fontWeight: "bold"
},
watchData: {
flex: 1,
alignItems: "center"
},
dataTitle: {
color: "#FFF",
fontSize: 16,
fontWeight: "bold"
},
dataBody: {
color: "#FFF",
fontSize: 14,
fontWeight: "400",
paddingTop: 20
}
});
export default App;

(1) : Nous souhaitons afficher le bouton permettant d’envoyer des informations à l’app de la montre, seulement quand nous saurons si la montre est “atteignable” ou non. Pour cela, nous utilisons une variable viewDidAppear (2) qui est initialisé à false. Tant que cette variable est à false nous affichons un “loader” dans une vue qui lancera la méthode onViewWillAppear (3) à son affichage (grâce à la méthode onLayout des props d’une vue).

(3) : À l’appel de la méthode onViewWillAppear, nous lançons la méthode checkReachability (4) en async/await afin d’attendre son résultat pour afficher les informations à l’écran.

(4) : Nous vérifions d’abord qu’une montre est appairée au téléphone avec la méthode getIsPaired. Si aucune montre n’est appairée, on affiche une alerte car on ne peut continuer. Si non, on test que la montre est “atteignable” avec la méthode getReachability. Une fois que nous avons cette information nous pouvons afficher l’écran avec un bouton d’action qui enverra notre message à la montre grâce à la méthode onSendingDataRequest (5). Si non, le bouton d’action permettra de revérifier la connexion à la montre via la méthode checkReachability.

(5) : Dans notre méthode onSendingDataRequest nous utilisons la méthode sendMessage de notre bibliothèque react-native-watch-connectivity. La méthode prend en premier paramètre le payload à envoyer à la montre. Ce payload est un dictionnaire de données classique sous la forme clé/valeur. Dans notre exemple, la clé est text. Nous l’utiliserons côté montre pour en récupérer la valeur.

Développement du code de la montre

Passons maintenant au code capable de récupérer ces données sur la montre.

Tout d’abord il va falloir mettre en place une classe permettant de gérer la communication entre l’app de la montre et l’app en React Native. Depuis Xcode dans le dossier de l’app de la montre, nous allons créer ce fichier AppWatchConnection qui sera une sous-classe de NSObject et développée en Swift.

Donc depuis Xcode, on effectue dans le dossier de l’app Watch, un “New file…” et on choisit “watchOS > WatchKit Class”.

Puis nous allons configurer ce fichier de connexion.

import WatchKit
import WatchConnectivity

class AppWatchConnection: NSObject {
var session: WCSession

init(session: WCSession = .default) {
self.session = session
super.init()
if WCSession.isSupported() {
session.delegate = self
session.activate()
}
}
}

Il manque une méthode WCSessionDelegate auquel la ligne session.delegate = self se réfère. (À ce stade, vous devez avoir une erreur, indiquant qu’elle est manquante).

Nous allons créer une extension, nous permettant de la définir.

import WatchKit
import WatchConnectivity

extension AppWatchConnection: WCSessionDelegate {
func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {

}
}

class AppWatchConnection: NSObject {
var session: WCSession

init(session: WCSession = .default) {
self.session = session
super.init()
if WCSession.isSupported() {
session.delegate = self
session.activate()
}
}
}

Maintenant nous pouvons revenir au code de l’app watch qui permet d’afficher “Hello, World!”. Je n’ai pas changé l’app par défaut, donc il s’agit du fichier ContentView.swift. Nous lui ajoutons l’initialisation de notre connecteur.

import SwiftUI

struct ContentView: View {
var appWatchConnection = AppWatchConnection()

var body: some View {
VStack {
Image(systemName: "globe")
.imageScale(.large)
.foregroundStyle(.tint)
Text("Hello, world!")
}
.padding()
}
}

#Preview {
ContentView()
}

Revenons à notre “connecteur” AppWatchConnection, nous allons ajouter une variable receivedMessage qui contiendra le texte à afficher sur la montre (en lieu et place de “Hello, World !”). Cette variable doit être accessible depuis une méthode “d’écoute”. Pour cela elle sera préfixé par “@Published”, et notre classe devra hériter de ObservableObject en plus de NSObject. Dans notre WCSessionDelegate nous ajoutons une session chargée de réceptionner les messages. À la réception d’un nouveau message, nous pourrons vérifier qu’il contient une valeur pour la clé text (que nous avons défini dans notre app ReactNative lors de l’utilisation de la méhode sendMessage), et transmettre cette valeur à notre variable receivedMessage.

Voilà maintenant à quoi ressemble le code de notre “connecteur”.

import WatchKit
import WatchConnectivity

extension AppWatchConnection: WCSessionDelegate {

func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {

}

func session(_ session: WCSession, didReceiveMessage message: [String : Any], replyHandler: @escaping ([String : Any]) -> Void) {
guard let messageFromApp = message["text"] as? String else { return }
DispatchQueue.main.async {
self.receivedMessage = messageFromApp
}
}
}

final class AppWatchConnection: NSObject, ObservableObject {
var session: WCSession

@Published var receivedMessage = "Hello, World.\nWaiting for a message..."

init(session: WCSession = .default) {
self.session = session
super.init()
if WCSession.isSupported() {
session.delegate = self
session.activate()
}
}
}

Une fois que le connecteur a reçu le message et l’a transmis à la variable receivedMessage, il faut que notre app l’affiche. Pour cela, il va falloir modifier notre fichier ContentView.

import SwiftUI

struct ContentView: View {
@ObservedObject var appWatchConnection = AppWatchConnection()

var body: some View {
VStack {
Image(systemName: "globe")
.imageScale(.large)
.foregroundStyle(.tint)
Text(self.appWatchConnection.receivedMessage)

}
.padding()
}
}

#Preview {
ContentView()
}

Nous avons changer l’initialisation de notre connecteur en le pre-fixant par @ObservedObject, afin “d’écouter” les modifications qui y ont lieu. Nous avons également remplacer le text “Hello, World!” par l’appel de la variable receivedMessage de notre connecteur : self.appWatchConnection.receivedMessage.

Et voilà, avec ce code en place, il nous suffit de recompiler nos apps, et nous allons pouvoir voir le résultat.

Transmission du. message de l’app vers la montre

Compliquons un peu l’histoire…

Maintenant que nous avons réussi notre premier envoie, voyons comment envoyer un payload envoyant différentes clés / données, avec des données provenant du téléphone.

Pour cela nous allons utiliser la bibliothèque react-native-device-info afin de récupérer des informations sur le téléphone.

yarn add react-native-device-info
cd ios && pod install && cd ..
yarn ios

Dans mon fichier App.tsx, je vais importer ma nouvelle bibliothèque :

import DeviceInfo from 'react-native-device-info';

Puis je vais remplacer le contenu de la méthode onSendingDataRequest, afin de transmettre les informations choisies (méthode qui va passer en asynchrone pour pouvoir utiliser les méthodes de react-native-device-info).

const onSendingDataRequest = async () => {
let batteryLevel: number = await DeviceInfo.getBatteryLevel();
batteryLevel = batteryLevel * 100;
const deviceName: string = await DeviceInfo.getDeviceName();
let deviceFreeDiskStorage: number = await DeviceInfo.getFreeDiskStorage();
deviceFreeDiskStorage = (deviceFreeDiskStorage / 1000000000); // B to GB
sendMessage({
text: "Hello, Watch !",
deviceName,
deviceFreeDiskStorage: deviceFreeDiskStorage.toFixed(2),
batteryLevel: batteryLevel.toFixed(2),
}, (reply: WatchPayload) => {
console.log("response", reply);
}, error => {
console.log("error", error);
});
}

Je récupère le niveau de batterie dans la variable batteryLevel. DeviceInfo nous renvoie une donnée entre 0 et 1 (-1 sur le simulateur). Donc je vais la transformer en % en multipliant la donnée par cent.

L’espace disponible sur le disque est exprimé en octets, je le transforme donc en Gigaoctets. J’envoie les 2 valeurs en tant que décimales à 2 chiffres après la virgule (.toFixed(2)).

Maintenant que nous avons récupéré ces données, il faut les interpréter sur la montre.

Je vais d’abord changer le code de mon connecteur AppWatchConnection afin qu’il soit capable de récupérer les nouvelles données.

import WatchKit
import WatchConnectivity

extension AppWatchConnection: WCSessionDelegate {

func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {

}

func session(_ session: WCSession, didReceiveMessage message: [String : Any], replyHandler: @escaping ([String : Any]) -> Void) {
guard let messageFromApp = message["text"] as? String else { return }
guard let deviceNameFromApp = message["deviceName"] as? String else { return }
guard let deviceFreeDiskStorageFromApp = message["deviceFreeDiskStorage"] as? String else { return }
guard let batteryLevelFromApp = message["batteryLevel"] as? String else { return }
DispatchQueue.main.async {
self.receivedMessage = messageFromApp
self.receivedBatteryLevel = "I have \(batteryLevelFromApp)% of battery"
self.receivedDeviceFreeDiskStorage = "and \(deviceFreeDiskStorageFromApp)GB of storage left."
self.receivedDeviceName = "My name is \(deviceNameFromApp)."
}
}
}

final class AppWatchConnection: NSObject, ObservableObject {
var session: WCSession

@Published var receivedMessage = "Hello, World.\nWaiting for a message..."
@Published var receivedBatteryLevel = ""
@Published var receivedDeviceFreeDiskStorage = ""
@Published var receivedDeviceName = ""

init(session: WCSession = .default) {
self.session = session
super.init()
if WCSession.isSupported() {
session.delegate = self
session.activate()
}
}
}

Je déclare d’abord mes 3 nouvelles variables receivedBatteryLevel, receivedDeviceFreeDiskStorage, receivedDeviceName, qui sont des chaines vides par défaut, dans ma classe AppWatchConnection. Puis dans le SessionDelegate, je vais tester l’existence de ces données, les récupérer et les attribuer à mes variables nouvellement créées. J’en ai profité pour les intégrer à des phrases pour afficher un texte “compréhensible” et non seulement une suite de données.

Il me faut maintenant les afficher sur la montre via ma vue ContentView.

import SwiftUI
import HealthKit

struct ContentView: View {
@ObservedObject var appWatchConnection = AppWatchConnection()

var body: some View {
VStack {
Text(self.appWatchConnection.receivedMessage).font(.system(size: 16, weight: .bold)).frame(maxWidth:.infinity, alignment: .leading).padding([.bottom], 10)
Text(self.appWatchConnection.receivedDeviceName).font(.system(size: 16, weight: .medium)).frame(maxWidth:.infinity, alignment: .leading)
Text(self.appWatchConnection.receivedBatteryLevel).font(.system(size: 16, weight: .medium)).frame(maxWidth:.infinity, alignment: .leading)
Text(self.appWatchConnection.receivedDeviceFreeDiskStorage).font(.system(size: 16, weight: .medium)).frame(maxWidth:.infinity, alignment: .leading)
}.frame(maxHeight: .infinity, alignment: .top)
}
}

#Preview {
ContentView()
}

J’ai enlevé l’image du globe, et j’ai rajouté mes vues Text, en les formatant légèrement.

Et voilà le résultat :

Résultat sur l’écran de l’Apple Watch

Envoie de données de la montre vers l’app

Nous avons réussi notre premier objectif de transmettre des données de l’app vers la montre. Il ne nous reste plus maintenant qu’à développer le chemin inverse et que la montre envoie des infos à l’app.

Nous allons suivre la même logique avec un bouton qui va d’abord envoyer une phrase simple, puis nous essaierons d’envoyer des données de la montre. (Possiblement les mêmes données que celles envoyées par l’app).

Développement du code de la montre

Pour le code de la montre, c’est extrêmement simple. Nous allons ajouter un bouton dans notre vue, qui appellera une méthode sendMessage chargée d’envoyer notre texte à l’app, via la méthode du même nom du SessionDelegate de notre connecteur.

Revenons donc à notre vue ContentView qui se transforme ainsi :

import SwiftUI
import HealthKit

struct ContentView: View {
@ObservedObject var appWatchConnection = AppWatchConnection()

var body: some View {
VStack {
Text(self.appWatchConnection.receivedMessage).font(.system(size: 16, weight: .bold)).frame(maxWidth:.infinity, alignment: .leading).padding([.bottom], 10)
Text(self.appWatchConnection.receivedDeviceName).font(.system(size: 16, weight: .medium)).frame(maxWidth:.infinity, alignment: .leading)
Text(self.appWatchConnection.receivedBatteryLevel).font(.system(size: 16, weight: .medium)).frame(maxWidth:.infinity, alignment: .leading)
Text(self.appWatchConnection.receivedDeviceFreeDiskStorage).font(.system(size: 16, weight: .medium)).frame(maxWidth:.infinity, alignment: .leading)
Button { self.sendMessage()} label: { Text("Send") }
}.frame(maxHeight: .infinity, alignment: .top)
}

private func sendMessage() {
let message: [String: Any] = ["text": "Hello, App! I'm the watch !"]
self.appWatchConnection.session.sendMessage(message, replyHandler: nil) { (error) in
print(error.localizedDescription)
}
}
}

#Preview {
ContentView()
}

Et voilà, c’est tout côté montre !

Développement du code de l’app

Dans notre app React Native, nous allons utiliser la méthode watchEvents qui va être chargée d’écouter les évènements provenant de l’app.

Dans notre fichier App.tsx, modifions l’import de notre bibliothèque react-native-watch-connectivity, pour y inclure watchEvents.

import { WatchPayload, sendMessage, getReachability, getIsPaired, watchEvents } from 'react-native-watch-connectivity';

Au lancement de notre vue, nous allons donc initialiser la méthode watchEvents, et stocker dans notre variable watchData la donnée reçue de la montre.

let messageListener = () => {}

useEffect(() => {
return () => {
messageListener()
}
}, []);

useEffect(() => {
if (isReachable === true) {
watchEvents.on('message', (message: {text:string}) => {
setWatchData(message.text);
})
}
}, [isReachable]);

Je n’initialise watchEvents qu’après avoir vérifié que ma montre est “joingable”.

Et voilà, il n’y a plus qu’à compiler et à tester.

Envoie de données dans les 2 sens !

Compliquons un peu l’histoire…

Nous allons maintenant récupérer des informations de la montre à envoyer à l’app.

Dans notre vue ContentView, avant d’envoyer le message nous allons récupérer le niveau de batterie, le nom localisé de la montre et la version de l’OS grâce à WKInterfaceDevice.

Voici le code de la méthode sendMessage ainsi modifié :

private func sendMessage() {
let currentDevice = WKInterfaceDevice.current()
let watchName = currentDevice.localizedModel
let watchVersion = currentDevice.systemVersion
currentDevice.isBatteryMonitoringEnabled = true
let chargeLevel = currentDevice.batteryLevel
currentDevice.isBatteryMonitoringEnabled = false
let message: [String: Any] = ["text": "Hello, App! I'm the watch !", "batteryLevel": chargeLevel, "watchName": watchName, "watchVersion": watchVersion]
self.appWatchConnection.session.sendMessage(message, replyHandler: nil) { (error) in
print(error.localizedDescription)
}
}

Côté app, nous allons récupérer ces informations et les afficher à l’écran. Modifions donc notre fichier App.tsx, pour lui ajouter d’abord 3 nouvelles variables en plus de watchData.

const [watchData, setWatchData] = useState<string>("No data");
const [watchName, setWatchName] = useState<string>("");
const [watchOSVersion, setWatchOsVersion] = useState<string>("");
const [watchBatteryLevel, setWatchBatteryLevel] = useState<number>(0);

Modifions la récupération des données de la montre pour peupler ces variables avec les données ainsi récupérées.

 useEffect(() => {
if (isReachable === true) {
watchEvents.on('message', (message: {text:string, batteryLevel:number, watchName:string, watchVersion:string}) => {
const watchBatteryLevelPercentage: string = (message.batteryLevel*100).toFixed(2);
setWatchData(message.text);
setWatchBatteryLevel("I have "+watchBatteryLevelPercentage+"% of battery left.");
setWatchName("My name is "+message.watchName+".");
setWatchOsVersion("And my OS Version number is : "+message.watchVersion+".");
})
}
}, [isReachable])

Modifions le rendu de notre vue pour avoir ses informations disponibles.

<View style={styles.watchData}>
<Text style={styles.dataTitle}>Data from the Watch</Text>
<Text style={styles.dataBody}>{watchData}</Text>
<Text style={styles.dataBody}>{watchBatteryLevel}</Text>
<Text style={styles.dataBody}>{watchName}</Text>
<Text style={styles.dataBody}>{watchOSVersion}</Text>
</View>

Et voilà, nous recompilons et testons le résultat.

Et voilà !

Voyons ce que cela donne sur un iPhone et une Apple Watch réels

Après avoir publié mon app de démo sur TestFlight j’ai pu tester la communication entre les 2 apps de mon Apple Watch Série 3 vers mon iPhone 14.

Conclusion

Grâce à la librairie react-native-watch-connectivity, on peut donc très facilement interagir avec une app écrite pour l’Apple Watch. Il serait intéressant de tester maintenant react-native-wear-connectivity afin d’avoir l’équivalent pour les montres Android.

Je dois remercier Arnaud Derosin pour son excellent article sur le sujet, qui m’a permis de mieux comprendre le développement nécessaire sur l’Apple Watch (mon dernier développement remontant à l’Apple Watch Série 1 en Objective C, il m’a fallut une grosse piqure de rappel).

--

--

Axel de Sainte Marie

Show Runner de projets mobiles. Développeur React Native et passionné par les challenges du monde mobile.