FlutterでDynamic Island
この記事は UMITRON Advent Calendar 2022 22日目の記事です。
こんにちは。ソフトウェアエンジニアの杉岡です。
皆様のスマホは iPhoneでしょうか?それともAndroidでしょうか?私自身は大学生の頃からiPhoneを愛用しています。
最近、充電の持ちが悪くなってきているので、そろそろ新しい端末に買い換えたいなぁーと思っている今日この頃です。
という訳で最新のiPhone14 Proで初登場した「Dynamic Island」をFlutterのアプリで実装してみます。
Dynamic Islandとは
今回の環境
Flutter: 3.3.8
Dart: 2.18.4
Xcode: 14.1
Simulator: iPhone 14 Pro / iOS16.1
今回の実装に使う Widget Extensionが Xcode14.1 以降にしか対応していないのでXcodeのバージョンをご確認ください。
また利用するFlutter プラグインも同様にSDKとFlutterバージョン指定があるのでお気をつけください。
environment:
sdk: '>=2.17.3 <3.0.0'
flutter: ">=2.5.0"
利用するプラグイン
基本的な実装は上記のリンクのドキュメントを参考にすれば行えると思います。私自身はXcode力がないのでそこそこハマりながら実装しました。
live_activitiesのドキュメントと合わせて読み進めて頂けると良いかもしれません。
実装(Xcode編)
アプリを作ります。
「umi_advent_calendar_app」という名前にしました。Platformは iOSのみにしています。
$ flutter create umi_advent_calendar_app --platforms=ios
$ cd umi_advent_calendar_app
$ tree -L 2
.
├── README.md
├── analysis_options.yaml
├── ios
│ ├── Flutter
│ ├── Runner
│ ├── Runner.xcodeproj
│ └── Runner.xcworkspace
├── lib
│ └── main.dart
├── pubspec.lock
├── pubspec.yaml
├── test
│ └── widget_test.dart
└── umi_advent_calendar_app.iml
「live_activities」をインストールします。
https://pub.dev/packages/live_activities/install
$ flutter pub add live_activities
$ flutter pub get
Xcodeを開きます。
$ Open ios/Runner.xcworkspace
File >> New >> Target をクリックしてください。
Choose a template for your new target:
というダイアログが開いたと思います。 「Widget Extension」を探して 「Next」をクリックしてください。
Product Nameを付けて Finish をクリックして下さい。
Include Configuration Intentにチェックが付いている場合は外して下さい。
このような構成になっているはずです。
次に Push Notifications を Capability に追加します。
次に Runner と Widget Extension の Info.plist に Supports Live Activities を追加し、値を YES とします。
Xcodeの設定として最後に Runner と Widget Extension に 同じ名前で App Groupを追加します。
今回は groups.umi としてます。
Xcode編のまとめ
Widget Extensionを作成する
Push Notifications を Capability に追加する
Runner と Widget Extension の Info.plist に Supports Live Activities を追加し、値を YES とする
App Group を Runner と Widget Extensionに追加する
実装(swift編)
Xcode上で UmiAppWidgetLiveActivityを開きます。下記のようになっていると思います。
import ActivityKit
import WidgetKit
import SwiftUI
struct UmiAppWidgetAttributes: ActivityAttributes {
public struct ContentState: Codable, Hashable {
// Dynamic stateful properties about your activity go here!
var value: Int
}
// Fixed non-changing properties about your activity go here!
var name: String
}
struct UmiAppWidgetLiveActivity: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: UmiAppWidgetAttributes.self) { context in
// Lock screen/banner UI goes here
VStack {
Text("Hello")
}
.activityBackgroundTint(Color.cyan)
.activitySystemActionForegroundColor(Color.black)
} dynamicIsland: { context in
DynamicIsland {
// Expanded UI goes here. Compose the expanded UI through
// various regions, like leading/trailing/center/bottom
DynamicIslandExpandedRegion(.leading) {
Text("Leading")
}
DynamicIslandExpandedRegion(.trailing) {
Text("Trailing")
}
DynamicIslandExpandedRegion(.bottom) {
Text("Bottom")
// more content
}
} compactLeading: {
Text("L")
} compactTrailing: {
Text("T")
} minimal: {
Text("Min")
}
.widgetURL(URL(string: "http://www.apple.com"))
.keylineTint(Color.red)
}
}
}
UmiAppWidgetAttributes の struct を 下記のように書き換えて下さい。
struct LiveActivitiesAppAttributes: ActivityAttributes, Identifiable {
public struct ContentState: Codable, Hashable { }
var id = UUID()
}
ActivityConfiguration(for: UmiAppWidgetAttributes.self) も上記の structを使うように修正します。
struct UmiAppWidgetLiveActivity: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: LiveActivitiesAppAttributes.self) { context in
ここまで一旦Xcodeでの作業は終了です。
実装(flutter編)
まず、live_activities を Import します。
import 'package:live_activities/live_activities.dart';
プラグインを初期化するコードを書きます。
この際に appGroupId には上で作成した App Group名指定します。今回の場合だと 「group.umi」になります。
final _liveActivitiesPlugin = LiveActivities();
_liveActivitiesPlugin.init(appGroupId: "group.umi");
_liveActivitiesPlugin.createActivity を使って dynamic activityを作成します。
final Map<String, dynamic> activityModel = {};
_liveActivitiesPlugin.createActivity(activityModel);
コードの全体像は下記のような感じになります。
import 'package:flutter/material.dart';
import 'package:live_activities/live_activities.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: const MyHomePage(title: 'Flutter Demo Home Page'),
);
}
}
class MyHomePage extends StatefulWidget {
const MyHomePage({super.key, required this.title});
final String title;
@override
State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
int _counter = 0;
final _liveActivitiesPlugin = LiveActivities();
String? activityId;
@override
void initState() {
super.initState();
_liveActivitiesPlugin.init(
appGroupId: 'group.umi',
);
}
@override
void dispose() {
_liveActivitiesPlugin.dispose();
super.dispose();
}
void _incrementCounter() {
setState(() {
_counter++;
});
}
Future _createActivity() async {
final Map<String, dynamic> activityModel = {};
activityId = await _liveActivitiesPlugin.createActivity(activityModel);
}
@override
Widget build(BuildContext context) {
if (activityId == null) {
_createActivity();
}
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Text(
'You have pushed the button this many times:',
),
Text(
'$_counter',
style: Theme.of(context).textTheme.headline4,
),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: _incrementCounter,
tooltip: 'Increment',
child: const Icon(Icons.add),
),
);
}
}
動作確認
いつものように flutter run します。
SimulatorはiPhone14 Pro、もしくは iPhone14 Pro Max、かつ iOS16.1 以上であることを確認して下さい。
無事、Dynamic Islandが表示されました 👍
Flutter から値を送る
Flutterの修正
_counter を Widget Extensionに送ります。
Future _createActivity() async {
_liveActivitiesPlugin.init(appGroupId: "group.umi");
activityId = await _liveActivitiesPlugin.createActivity({
'count': _counter,
});
}
また、 _incrementCounter が呼ばれた時に値を更新します。
Future _incrementCounter() async {
setState(() {
_counter++;
});
await _liveActivitiesPlugin.updateActivity(activityId!, {
'count': _counter,
});
}
全体のコードは下記のようにしました。
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:live_activities/live_activities.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: const MyHomePage(title: 'Flutter Demo Home Page'),
);
}
}
class MyHomePage extends StatefulWidget {
const MyHomePage({super.key, required this.title});
final String title;
@override
State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
int _counter = 0;
final _liveActivitiesPlugin = LiveActivities();
String? activityId;
@override
void initState() {
super.initState();
_liveActivitiesPlugin.init(
appGroupId: 'group.umi',
);
}
@override
void dispose() {
_liveActivitiesPlugin.dispose();
super.dispose();
}
Future _incrementCounter() async {
setState(() {
_counter++;
});
await _liveActivitiesPlugin.updateActivity(activityId!, {
'count': _counter,
});
}
Future _createActivity() async {
activityId = await _liveActivitiesPlugin.createActivity({
'count': _counter,
});
}
@override
Widget build(BuildContext context) {
if (activityId == null) {
_createActivity();
}
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Text(
'You have pushed the button this many times:',
),
Text(
'$_counter',
style: Theme.of(context).textTheme.headline4,
),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: _incrementCounter,
tooltip: 'Increment',
child: const Icon(Icons.add),
),
);
}
}
Swiftの修正
sharedDefault を定義します。 suiteName には App Group名を入れて下さい。
let sharedDefault = UserDefaults(suiteName: "group.umi")!
コード全体は下記のようにしました。
import ActivityKit
import WidgetKit
import SwiftUI
struct LiveActivitiesAppAttributes: ActivityAttributes, Identifiable {
public struct ContentState: Codable, Hashable { }
var id = UUID()
}
let sharedDefault = UserDefaults(suiteName: "group.umi")!
struct UmiAppWidgetLiveActivity: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: LiveActivitiesAppAttributes.self) { context in
VStack {
Text("UMITRON")
}
.activityBackgroundTint(Color.cyan)
.activitySystemActionForegroundColor(Color.black)
} dynamicIsland: { context in
let count = sharedDefault.integer(forKey: "count")
return DynamicIsland {
DynamicIslandExpandedRegion(.leading) {
Text("カウント")
}
DynamicIslandExpandedRegion(.trailing) {
Text("\(count)")
}
DynamicIslandExpandedRegion(.bottom) {
Text("UMITRON Advent Calendar 2022")
// more content
}
} compactLeading: {
Text("カウント")
} compactTrailing: {
Text("\(count)")
} minimal: {
Text("Min")
}
.widgetURL(URL(string: "http://www.apple.com"))
.keylineTint(Color.red)
}
}
}
let pizzaName = sharedDefault.string(forKey: "name")! // put the same key as your Dart map
let pizzaPrice = sharedDefault.float(forKey: "price")
let quantity = sharedDefault.integer(forKey: "quantity")
プラグインにも例が記述されていますがデータタイプにデータの取得方法が変わってきますので注意して下さい。
まとめ
以上、https://pub.dev/packages/live_activities の翻訳みたいな内容になってしまいましたが如何だったでしょうか?こちらの記事で1人でもFlutterで「Dynamic Island」の実装の手助けになれば幸いです。
さいごに
ウミトロンでは一緒に働く仲間を募集しています。
水産養殖×テクノロジーという非常にユニークな事業領域で、共に持続可能な水産養殖を地球に実装していきませんか?
この記事が気に入ったらサポートをしてみませんか?