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

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
  1. Откройте свой проект в Xcode и на вкладке General установите минимальную версию развертывания 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
}
}

Теперь осталось только создать 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 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 в проекте.

/// 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);
}