Passer au contenu

Live Activity iOS dans un projet Flutter

Nous allons créer une Live Activity avec une implémentation de chronomètre pour un projet Flutter. Le projet inclura une implémentation côté Flutter ainsi qu’une implémentation native dans le projet Xcode.

Implémentation côté Flutter

Anchor link to

Nous allons créer une classe qui implémentera trois méthodes :

  • startLiveActivity()
  • updateLiveActivity()
  • stopLiveActivity().
class DynamicIslandManager {
final String channelKey;
late final MethodChannel _methodChannel;
DynamicIslandManager({required this.channelKey}) {
_methodChannel = MethodChannel(channelKey);
}
Future<void> startLiveActivity({required Map<String, dynamic> jsonData}) async {
try {
await _methodChannel.invokeListMethod('startLiveActivity', jsonData);
} catch (e, st) {
log(e.toString(), stackTrace: st);
}
}
Future<void> updateLiveActivity(
{required Map<String, dynamic> jsonData}) async {
try {
await _methodChannel.invokeListMethod('updateLiveActivity', jsonData);
} catch (e, st) {
log(e.toString(), stackTrace: st);
}
}
Future<void> stopLiveActivity() async {
try {
await _methodChannel.invokeListMethod('stopLiveActivity');
} catch (e, st) {
log(e.toString(), stackTrace: st);
}
}
}

Nous allons construire une application de chronomètre, et pour transmettre les données du minuteur du chronomètre aux méthodes natives, nous devons créer un modèle de données et l’envoyer à l’invocation du canal de méthode dans un format de carte de type JSON.

class DynamicIslandStopwatchDataModel {
final int elapsedSeconds;
DynamicIslandStopwatchDataModel({
required this.elapsedSeconds,
});
Map<String, dynamic> toMap() {
return <String, dynamic>{
'elapsedSeconds': elapsedSeconds,
};
}
}

Chaque méthode inclura le code nécessaire pour appeler les méthodes natives correspondantes.

C’était la configuration initiale requise pour l’exécuter côté Flutter. Ensuite, une interface utilisateur basique d’application de chronomètre avec les appels de méthode nécessaires a été implémentée.

class _StopWatchScreenState extends State<StopWatchScreen> {
int seconds = 0;
bool isRunning = false;
Timer? timer;
/// la clé de canal est utilisée pour envoyer des données du côté Flutter vers le côté Swift via
/// un pont unique (lien entre Flutter & Swift)
final DynamicIslandManager diManager = DynamicIslandManager(channelKey: 'PW');
void startTimer() {
setState(() {
isRunning = true;
});
// invocation de la méthode startLiveActivity
diManager.startLiveActivity(
jsonData: DynamicIslandStopwatchDataModel(elapsedSeconds: 0).toMap(),
);
timer = Timer.periodic(const Duration(seconds: 1), (timer) {
setState(() {
seconds++;
});
// invocation de la méthode updateLiveActivity
diManager.updateLiveActivity(
jsonData: DynamicIslandStopwatchDataModel(
elapsedSeconds: seconds,
).toMap(),
);
});
}
void stopTimer() {
timer?.cancel();
setState(() {
seconds = 0;
isRunning = false;
});
// invocation de la méthode stopLiveActivity
diManager.stopLiveActivity();
}
@override
void dispose() {
timer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Stopwatch App'),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text(
'Stopwatch: $seconds seconds',
style: const TextStyle(fontSize: 24),
),
const SizedBox(height: 20),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
ElevatedButton(
onPressed: isRunning ? null : startTimer,
child: const Text('Start'),
),
const SizedBox(width: 20),
ElevatedButton(
onPressed: isRunning ? stopTimer : null,
child: const Text('Stop'),
),
],
),
],
),
),
);
}
}

Implémentation côté natif

Anchor link to

Configuration du projet Xcode

Anchor link to
  1. Ouvrez votre projet sur Xcode et dans l’onglet Général, définissez le déploiement minimum sur iOS 16.1
Onglet Général de Xcode montrant le déploiement minimum défini sur iOS 16.1
  1. Dans Info.plist, ajoutez une nouvelle clé NSSupportsLiveActivities et définissez sa valeur sur YES (Booléen).

Info.plist

Anchor link to
<key>NSSupportsLiveActivities</key>
<true/>
  1. Dans la barre d’actions, sélectionnez Fichier > Nouveau > Cible et recherchez WidgetExtensionand puis créez un nouveau widget.

Implémentation du code

Anchor link to

Créons une classe appelée LiveActivityManager qui gérera les activités en direct pour notre Dynamic Island. Cette classe contiendra trois méthodes : startLiveActivity(), updateLiveActivity() et stopLiveActivity().

Swift

import ActivityKit
import Flutter
import Foundation
@available(iOS 16.1, *)
class LiveActivityManager {
private var stopwatchActivity: Activity<StopwatchWidgetAttributes>? = nil
func startLiveActivity(data: [String: Any]?, result: FlutterResult) {
let attributes = StopwatchWidgetAttributes()
if let info = data {
let state = StopwatchWidgetAttributes.ContentState(
elapsedTime: info["elapsedSeconds"] as? Int ?? 0
)
stopwatchActivity = try? Activity<StopwatchWidgetAttributes>.request(
attributes: attributes, contentState: state, pushType: nil)
} else {
result(FlutterError(code: "418", message: "Live activity didn't invoked", details: nil))
}
}
func updateLiveActivity(data: [String: Any]?, result: FlutterResult) {
if let info = data {
let updatedState = StopwatchWidgetAttributes.ContentState(
elapsedTime: info["elapsedSeconds"] as? Int ?? 0
)
Task {
await stopwatchActivity?.update(using: updatedState)
}
} else {
result(FlutterError(code: "418", message: "Live activity didn't updated", details: nil))
}
}
func stopLiveActivity(result: FlutterResult) {
do {
Task {
await stopwatchActivity?.end(using: nil, dismissalPolicy: .immediate)
}
} catch {
result(FlutterError(code: "418", message: error.localizedDescription, details: nil))
}
}
}

Chaque méthode a sa fonctionnalité spécifique. startLiveActivity() (comme son nom l’indique) est responsable du lancement de la Live Activity, ce qui déclenche la fonctionnalité Dynamic Island. De même, stopLiveActivity() et updateLiveActivity() gèrent l’arrêt de la Live Activity et la mise à jour des données affichées sur la Dynamic Island.

Ensuite, ouvrez StopWatchDIWidgetLiveActivity.swift et modifiez la structure StopwatchDIWidgetAttributes (comme montré dans l’extrait). Cette structure d’attributs sert de modèle de données qui contient les informations à afficher sur l’interface utilisateur (directement depuis Flutter) et peut également modifier l’interface utilisateur si nécessaire.

Swift

struct StopwatchWidgetAttributes: ActivityAttributes {
public typealias stopwatchStatus = ContentState
public struct ContentState: Codable, Hashable {
var elapsedTime: Int
}
}

Maintenant, il ne reste plus que l’interface utilisateur pour la Dynamic Island ! (Youhou, nous sommes arrivés jusqu’ici !) L’ensemble de l’interface utilisateur pour la Dynamic Island doit être créé avec SwiftUI. Pour cet article, une interface utilisateur simple a été conçue (mais n’hésitez pas à la personnaliser selon vos besoins).

Ce code d’interface utilisateur doit être écrit à l’intérieur de la structure StopwatchWidgetLiveActivity. Supprimez le code existant de la structure, et vous pouvez suivre le code ci-dessous :

SwiftUI

struct StopwatchWidgetLiveActivity: Widget {
func getTimeString(_ seconds: Int) -> String {
let hours = seconds / 3600
let minutes = (seconds % 3600) / 60
let seconds = (seconds % 3600) % 60
return hours == 0 ?
String(format: "%02d:%02d", minutes, seconds)
: String(format: "%02d:%02d:%02d", hours, minutes, seconds)
}
var body: some WidgetConfiguration {
ActivityConfiguration(for: StopwatchWidgetAttributes.self) { context in
HStack {
Text("Time ellapsed")
.font(.system(size: 20, weight: .semibold))
.foregroundColor(.white)
Spacer()
Image(systemName: "timer")
.foregroundColor(.white)
Spacer().frame(width: 10)
Text(getTimeString(context.state.elapsedTime))
.font(.system(size: 24, weight: .semibold))
.foregroundColor(.yellow)
}
.padding(.horizontal)
.activityBackgroundTint(Color.black.opacity(0.5))
}
dynamicIsland: { context in
DynamicIsland {
DynamicIslandExpandedRegion(.center) {
VStack(alignment: .center) {
Text("Pushwoosh Timer")
Spacer().frame(height: 24)
HStack {
Text("Time ellapsed")
.font(.system(size: 20, weight: .semibold))
.foregroundColor(.white)
Spacer()
Image(systemName: "timer")
Spacer().frame(width: 10)
Text(getTimeString(context.state.elapsedTime))
.font(.system(size: 24, weight: .semibold))
.foregroundColor(.yellow)
}.padding(.horizontal)
}
}
} compactLeading: {
Image(systemName: "timer").padding(.leading, 4)
} compactTrailing: {
Text(getTimeString(context.state.elapsedTime)).foregroundColor(.yellow)
.padding(.trailing, 4)
} minimal: {
Image(systemName: "timer")
.foregroundColor(.yellow)
.padding(.all, 4)
}
.widgetURL(URL(string: "http://www.pushwoosh.com"))
.keylineTint(Color.red)
}
}
}

N’oubliez pas de modifier également le code AppDelegate.

AppDelegate.swift

import UIKit
import Flutter
@main
@objc class AppDelegate: FlutterAppDelegate {
private let liveActivityManager: LiveActivityManager = LiveActivityManager()
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
GeneratedPluginRegistrant.register(with: self)
let controller : FlutterViewController = window?.rootViewController as! FlutterViewController
let diChannel = FlutterMethodChannel(name: "PW", binaryMessenger: controller.binaryMessenger)
diChannel.setMethodCallHandler({ [weak self] (
call: FlutterMethodCall, result: @escaping FlutterResult) -> Void in
switch call.method {
case "startLiveActivity":
self?.liveActivityManager.startLiveActivity(
data: call.arguments as? Dictionary<String,Any>,
result: result)
break
case "updateLiveActivity":
self?.liveActivityManager.updateLiveActivity(
data: call.arguments as? Dictionary<String,Any>,
result: result)
break
case "stopLiveActivity":
self?.liveActivityManager.stopLiveActivity(result: result)
break
default:
result(FlutterMethodNotImplemented)
}
})
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
}

Et c’est tout ! Cela couvre tout ce qui est nécessaire pour implémenter la Dynamic Island et la lier au code Flutter !

Implémentation du code Pushwoosh

Anchor link to

Le plugin Flutter de Pushwoosh fournit deux méthodes pour gérer la Live Activity dans le projet.

/// Configuration par défaut de la Live Activity
Future<void> defaultSetup() async {
await _channel.invokeMethod("defaultSetup");
}
/// Démarrage par défaut de la Live Activity
/// [activityId] ID de l'activité
/// [attributes] attributs
/// [content] contenu
Future<void> defaultStart(String activityId, Map<String, dynamic> attributes, Map<String, dynamic> content) async {
await _channel.invokeMethod("defaultStart", {"activityId": activityId, "attributes": attributes, "content": content});
}

La méthode defaultSetup() vous permet de gérer la structure de la Live Activity et les jetons côté Pushwoosh.

Appelez cette méthode lors de l’initialisation de l’application.

Pushwoosh.initialize({"app_id": "XXXXX-XXXXX"});
/**
* Appelez cette méthode `defaultSetup()`
*/
Pushwoosh.getInstance.defaultSetup();

Pendant l’initialisation de l’application, Pushwoosh enverra votre jeton de démarrage par push au serveur, ce qui vous permettra plus tard de démarrer la Live Activity via un appel API. Pour démarrer la Live Activity, vous devez effectuer une requête API. Voici un exemple de la requête :

{
"request": {
"application": "XXXXX-XXXXX",
"auth": "YOUR_AUTH_API_TOKEN",
"notifications": [
{
"content": "Message",
"title":"Title",
"live_activity": {
"event": "start", // événement `start`
"content-state": {
"data": { // Vous devez passer les paramètres dans un dictionnaire avec la clé data.
"emoji": "dynamic data"
}
},
"attributes-type": "DefaultLiveActivityAttributes",
"attributes": {
"data": {
"name": "static data"
}
}
},
"devices": [ "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX" // HWID
],
"live_activity_id": "your_activity_id"
}
]
}
}

Documentation plus détaillée sur l’API

Si vous souhaitez démarrer une Live Activity simple, par exemple, depuis votre application, vous pouvez utiliser la méthode suivante dans Flutter : defaultStart(String activityId, Map<String, dynamic> attributes, Map<String, dynamic> content)

Cette méthode peut être appelée sur n’importe quel événement qui déclenche le démarrage de la Live Activity.

// Fonction pour démarrer la Live Activity
void startLiveActivity() {
// Créez votre ID d'activité
String activityId = "stopwatch_activity";
// Définissez les attributs que vous souhaitez envoyer à la Live Activity
Map<String, dynamic> attributes = {
'title': 'Stopwatch Activity',
'description': 'This is a live activity for a stopwatch.'
};
// Définissez l'état du contenu à mettre à jour sur la Dynamic Island
Map<String, dynamic> content = {
'elapsedSeconds': 0
};
// Appelez la méthode defaultStart de Pushwoosh pour déclencher la Live Activity
Pushwoosh.getInstance().defaultStart(activityId, attributes, content);
}