Live Activity для iOS в проекте Flutter
Мы создадим Live Activity с реализацией секундомера для проекта Flutter. Проект будет включать как реализацию на стороне Flutter, так и нативную реализацию в проекте Xcode.
Реализация на стороне Flutter
Anchor link toМы создадим класс, который будет реализовывать три метода:
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); } }}Мы будем создавать приложение-секундомер, и чтобы передать данные таймера секундомера в нативные методы, нам нужно создать модель данных и отправить ее при вызове method channel в формате, подобном JSON-карте (map).
class DynamicIslandStopwatchDataModel { final int elapsedSeconds;
DynamicIslandStopwatchDataModel({ required this.elapsedSeconds, });
Map<String, dynamic> toMap() { return <String, dynamic>{ 'elapsedSeconds': elapsedSeconds, }; }}Каждый метод будет содержать необходимый код для вызова соответствующих нативных методов.
Это была начальная настройка, необходимая для запуска на стороне Flutter. После этого был реализован базовый пользовательский интерфейс приложения-секундомера с необходимыми вызовами методов.
class _StopWatchScreenState extends State<StopWatchScreen> { int seconds = 0; bool isRunning = false; Timer? timer;
/// channel key is used to send data from flutter to swift side over /// a unique bridge (link between flutter & swift) final DynamicIslandManager diManager = DynamicIslandManager(channelKey: 'PW');
void startTimer() { setState(() { isRunning = true; });
// invoking startLiveActivity Method diManager.startLiveActivity( jsonData: DynamicIslandStopwatchDataModel(elapsedSeconds: 0).toMap(), );
timer = Timer.periodic(const Duration(seconds: 1), (timer) { setState(() { seconds++; }); // invoking the updateLiveActivity Method diManager.updateLiveActivity( jsonData: DynamicIslandStopwatchDataModel( elapsedSeconds: seconds, ).toMap(), ); }); }
void stopTimer() { timer?.cancel(); setState(() { seconds = 0; isRunning = false; });
// invoking the stopLiveActivity Method 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'), ), ], ), ], ), ), ); }}Реализация на нативной стороне
Anchor link toКонфигурация проекта Xcode
Anchor link to- Откройте свой проект в Xcode и на вкладке General установите минимальную версию развертывания iOS 16.1

- В
Info.plistдобавьте новый ключNSSupportsLiveActivitiesи установите его значение вYES(Boolean).
Info.plist
Anchor link to<key>NSSupportsLiveActivities</key><true/>- На панели действий выберите File > New > Target, найдите
WidgetExtensionи создайте новый виджет.
Реализация на стороне кода
Anchor link toДавайте создадим класс LiveActivityManager, который будет управлять live activities для нашего Dynamic Island. Этот класс будет содержать три метода: startLiveActivity(), updateLiveActivity() и stopLiveActivity().
Swift
import ActivityKitimport Flutterimport 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)) } }}Каждый метод имеет свою специфическую функциональность. startLiveActivity() (как следует из названия) отвечает за запуск Live Activity, что активирует функциональность Dynamic Island. Аналогично, stopLiveActivity() и updateLiveActivity() обрабатывают остановку Live Activity и обновление данных, отображаемых на Dynamic Island.
Далее откройте StopWatchDIWidgetLiveActivity.swift и измените структуру StopwatchDIWidgetAttributes (как показано в фрагменте кода). Эта структура атрибутов служит моделью данных, которая содержит информацию для отображения в пользовательском интерфейсе (непосредственно из Flutter), а также может изменять UI по мере необходимости.
Swift
struct StopwatchWidgetAttributes: ActivityAttributes { public typealias stopwatchStatus = ContentState
public struct ContentState: Codable, Hashable { var elapsedTime: Int }}Теперь осталось только создать UI для Dynamic Island! (Ура, мы почти у цели!) Весь пользовательский интерфейс для Dynamic Island должен быть создан с помощью SwiftUI. Для этой статьи был разработан простой UI (но вы можете настроить его по своему усмотрению).
Этот код UI должен быть написан внутри структуры StopwatchWidgetLiveActivity. Удалите существующий код из структуры и следуйте приведенному ниже коду:
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) } }}Не забудьте также изменить код AppDelegate.
AppDelegate.swift
import UIKitimport 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) }}Вот и все! Это все, что нужно для реализации Dynamic Island и его связывания с кодом Flutter!
Реализация с помощью кода Pushwoosh
Anchor link toПлагин Pushwoosh для Flutter предоставляет два метода для управления Live Activity в проекте.
/// Default setup Live Activity Future<void> defaultSetup() async { await _channel.invokeMethod("defaultSetup"); }
/// Default start Live Activity /// [activityId] activity ID /// [attributes] attributes /// [content] content Future<void> defaultStart(String activityId, Map<String, dynamic> attributes, Map<String, dynamic> content) async { await _channel.invokeMethod("defaultStart", {"activityId": activityId, "attributes": attributes, "content": content}); }Метод defaultSetup() позволяет вам управлять структурой Live Activity и токенами на стороне Pushwoosh.
Вызывайте этот метод во время инициализации приложения.
Pushwoosh.initialize({"app_id": "XXXXX-XXXXX", "sender_id": "XXXXXXXXXXXX"});/*** Call this method `defaultSetup()`*/ Pushwoosh.getInstance.defaultSetup();Во время инициализации приложения Pushwoosh отправит ваш токен для запуска пуш-уведомлений (push-to-start token) на сервер, что в дальнейшем позволит вам запускать Live Activity через вызов API. Чтобы запустить Live Activity, вам нужно сделать API-запрос. Ниже приведен пример запроса:
{ "request": { "application": "XXXXX-XXXXX", "auth": "YOUR_AUTH_API_TOKEN", "notifications": [ { "content": "Message", "title":"Title", "live_activity": { "event": "start", // `start` event "content-state": { "data": { // You need to pass the parameters in a dictionary with the key 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" } ] }}Более подробная документация по API
Если вы хотите запустить простое Live Activity, например, из вашего приложения, вы можете использовать следующий метод во Flutter: defaultStart(String activityId, Map<String, dynamic> attributes, Map<String, dynamic> content)
Этот метод можно вызывать при любом событии, которое инициирует запуск Live Activity.
// Function to start Live Activity void startLiveActivity() { // Create your activity ID String activityId = "stopwatch_activity";
// Define the attributes you want to send to the Live Activity Map<String, dynamic> attributes = { 'title': 'Stopwatch Activity', 'description': 'This is a live activity for a stopwatch.' };
// Define the content state to update on the Dynamic Island Map<String, dynamic> content = { 'elapsedSeconds': 0 };
// Call Pushwoosh's defaultStart method to trigger the Live Activity Pushwoosh.getInstance().defaultStart(activityId, attributes, content); }