Перейти к содержанию

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;
/// ключ канала используется для отправки данных со стороны flutter на сторону swift через
/// уникальный мост (связь между flutter и swift)
final DynamicIslandManager diManager = DynamicIslandManager(channelKey: 'PW');
void startTimer() {
setState(() {
isRunning = true;
});
// вызов метода startLiveActivity
diManager.startLiveActivity(
jsonData: DynamicIslandStopwatchDataModel(elapsedSeconds: 0).toMap(),
);
timer = Timer.periodic(const Duration(seconds: 1), (timer) {
setState(() {
seconds++;
});
// вызов метода updateLiveActivity
diManager.updateLiveActivity(
jsonData: DynamicIslandStopwatchDataModel(
elapsedSeconds: seconds,
).toMap(),
);
});
}
void stopTimer() {
timer?.cancel();
setState(() {
seconds = 0;
isRunning = false;
});
// вызов метода 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'),
),
],
),
],
),
),
);
}
}

Реализация на нативной стороне

Anchor link to

Конфигурация проекта Xcode

Anchor link to
  1. Откройте свой проект в Xcode и на вкладке General установите минимальную версию развертывания iOS 16.1
Вкладка General в Xcode, показывающая минимальную версию развертывания, установленную на iOS 16.1
  1. В Info.plist добавьте новый ключ NSSupportsLiveActivities и установите его значение в YES (Boolean).

Info.plist

Anchor link to
<key>NSSupportsLiveActivities</key>
<true/>
  1. В строке меню выберите File > New > Target, найдите WidgetExtension и создайте новый виджет.

Реализация кода

Anchor link to

Давайте создадим класс LiveActivityManager, который будет управлять live activities для нашего Dynamic Island. Этот класс будет содержать три метода: startLiveActivity(), updateLiveActivity() и 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))
}
}
}

Каждый метод имеет свою специфическую функциональность. 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
}
}

Теперь осталось только создать пользовательский интерфейс для 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 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)
}
}

И это все! Мы рассмотрели все необходимое для реализации Dynamic Island и его связи с кодом Flutter!

Реализация кода Pushwoosh

Anchor link to

Плагин Pushwoosh для Flutter предоставляет два метода для управления Live Activity в проекте.

/// Настройка Live Activity по умолчанию
Future<void> defaultSetup() async {
await _channel.invokeMethod("defaultSetup");
}
/// Запуск Live Activity по умолчанию
/// [activityId] идентификатор activity
/// [attributes] атрибуты
/// [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"});
/**
* Вызовите этот метод `defaultSetup()`
*/
Pushwoosh.getInstance.defaultSetup();

Во время инициализации приложения Pushwoosh отправит ваш push-to-start токен на сервер, что в дальнейшем позволит вам запускать 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`
"content-state": {
"data": { // Вам нужно передать параметры в словаре с ключом 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.

// Функция для запуска Live Activity
void startLiveActivity() {
// Создайте свой идентификатор activity
String activityId = "stopwatch_activity";
// Определите атрибуты, которые вы хотите отправить в Live Activity
Map<String, dynamic> attributes = {
'title': 'Stopwatch Activity',
'description': 'This is a live activity for a stopwatch.'
};
// Определите состояние контента для обновления на Dynamic Island
Map<String, dynamic> content = {
'elapsedSeconds': 0
};
// Вызовите метод defaultStart от Pushwoosh, чтобы запустить Live Activity
Pushwoosh.getInstance().defaultStart(activityId, attributes, content);
}