FlutterでDynamic Island

この記事は UMITRON Advent Calendar 2022 22日目の記事です。


こんにちは。ソフトウェアエンジニアの杉岡です。
皆様のスマホは iPhoneでしょうか?それともAndroidでしょうか?私自身は大学生の頃からiPhoneを愛用しています。
最近、充電の持ちが悪くなってきているので、そろそろ新しい端末に買い換えたいなぁーと思っている今日この頃です。

という訳で最新のiPhone14 Proで初登場した「Dynamic Island」をFlutterのアプリで実装してみます。

Dynamic Islandとは

https://support.apple.com/ja-jp/guide/iphone/iph28f50d10d/ios

Phone 14 ProおよびiPhone 14 Pro Maxでは、再生中のミュージック、タイマー、AirDropの接続、「マップ」の経路案内などの通知や現在進行中のアクティビティがホーム画面またはApp上でDynamic Islandに表示され確認できます。iPhoneがロック解除されるとDynamic Islandが表示されます。

https://support.apple.com/ja-jp/guide/iphone/iph28f50d10d/ios

今回の環境

  • 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 をクリックしてください。

Xcode

Choose a template for your new target: 
というダイアログが開いたと思います。 「Widget Extension」を探して 「Next」をクリックしてください。

Widget Extension

Product Nameを付けて Finish をクリックして下さい。
Include Configuration Intentにチェックが付いている場合は外して下さい。

Please input Product Name

このような構成になっているはずです。

次に Push Notifications Capability に追加します。

Capabilityをクリック


Push Notificationsを追加

次に Runner Widget Extension Info.plist Supports Live Activities を追加し、値を YES とします。

Information Property Listのホバーすると右側に +マークが出てきます

Xcodeの設定として最後に RunnerWidget Extension に 同じ名前で App Groupを追加します。
今回は groups.umi としてます。

Runner と Widget Extension同じグループを指定して下さい

Xcode編のまとめ

  • Widget Extensionを作成する

  • Push Notifications Capability に追加する

  • Runner Widget Extension Info.plist Supports Live Activities を追加し、値を YES とする

  • App Group RunnerWidget 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 以上であることを確認して下さい。

flutter run


Push Notifications

無事、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」の実装の手助けになれば幸いです。


さいごに

ウミトロンでは一緒に働く仲間を募集しています。
水産養殖×テクノロジーという非常にユニークな事業領域で、共に持続可能な水産養殖を地球に実装していきませんか?


この記事が気に入ったらサポートをしてみませんか?