【Flutter 3.16】Share Extensionの画面をFlutterで作り、プラグインを呼ぶ
Overview
- Flutter 3.16では、Flutterの画面をiOS App Extensionから呼び出したり、Dartのコードを読んだりできるようになりました。
- この機能を用いて、Share ExtensionをFlutterの画面で開くものをを実装します
- App Extension上のFlutterから、プラグインの機能を呼べるようにします
receive_sharing_intentをベースにしています。
App Extensionを作る
- Xcode上で、「File/New/Target」をから、「Share Extension」を選択します。
- ShareExtensionなど、適当な名前を設定します。(ここではShareExtension)
- deployment targetをRunner.appと一緒にします。
-
ios/ShareExtension/Info.plist
を下記のように設定します。画像や動画が不要な場合、適宜項目を削除します。
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>AppGroupId</key>
<string>$(CUSTOM_GROUP_ID)</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionAttributes</key>
<dict>
<key>PHSupportedMediaTypes</key>
<array>
<string>Video</string>
<string>Image</string>
</array>
<key>NSExtensionActivationRule</key>
<dict>
<key>NSExtensionActivationSupportsText</key>
<true/>
<key>NSExtensionActivationSupportsWebURLWithMaxCount</key>
<integer>1</integer>
<key>NSExtensionActivationSupportsImageWithMaxCount</key>
<integer>100</integer>
<key>NSExtensionActivationSupportsMovieWithMaxCount</key>
<integer>100</integer>
<key>NSExtensionActivationSupportsFileWithMaxCount</key>
<integer>1</integer>
</dict>
</dict>
<key>NSExtensionMainStoryboard</key>
<string>MainInterface</string>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.share-services</string>
</dict>
</dict>
</plist>
-
Runner/Info.plist
に下記の項目を追加します。receive_sharing_intentではURLスキームの設定が必要でしたが、今回はApp Extensionのみなので、CFBundleURLTypesの指定は不要です。
<key>AppGroupId</key>
<string>$(CUSTOM_GROUP_ID)</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>画像をアップロードするために、アルバムへの権限を許可してください。</string>
-
Runner.entitlements
とShareExtension.entitlements
を下記の要領で設定します。
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.(Bundle IDが入る)</string>
</array>
</dict>
</plist>
-
iOS/Podfiles
を下記のように指定します。
target 'Runner' do
use_frameworks!
use_modular_headers!
flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
target 'ShareExtension' do
inherit! :search_paths
end
end
- Xcode上で、RunnerとShare Extensionの両方の
Signing & Capabilities
タブからApp GroupsのCapabilityを追加します。(+ Capability
のところ) - 両方のApp Groupsに
info.(Bundle ID)
を追加します。 - RunnerのBuild Phasesの
Target Dependencies
にShareExtension
を指定し、Embed Foundation Extension
がThin Binary
の上に来るようにします。Embed Foundation Extension
にもShareExtension.appex
を追加します。 - Share ExtensionのBuild Settingsで、
Packaging
のDefines Module
をYes
に、Swift Compiler - General
のObjective-C Bridging Header
をShareExtension/ShareExtension-Bridging-Header.h
に設定します。 - ShareExtensionのFrameworks and Librariesで
Flutter.xcframework
を追加します。追加するのは<path_to_flutter_sdk>/bin/cache/artifacts/engine/ios/extension_safe/Flutter.xcframework
ですが、Profile, Releaseビルドの場合はios
の場所がios-profile
やios-release
に変わります。これは自動では変わらないので、ビルドのたびに変更する必要があります。 - Flutter側では、下記のプラグインとパッケージを使用します。auto_routeは必須ではありませんが、実際に共有用の画面をアプリ本体と異なる画面で表示する場合には何かしらの画面遷移を管理するパッケージが入っているだろうということで、ここではauto_routeを例にします。
dependencies:
auto_route: ^6.0.5
shared_preference_app_group: ^1.0.0+1
dev_dependencies:
auto_route_generator: ^6.0.0
build_runner: ^2.3.3
- 上記のパッケージやプラグインが入っていることを前提に、
ShareExtension-Bridging-Header.h
,ShareExtensionPluginRegistrant.h
,ShareExtensionPluginRegistrant.m
,ShareViewController.swift
を追加し、下記のようにします。
#import "ShareExtensionPluginRegistrant.h"
#ifndef ShareExtensionPluginRegistrant_h
#define ShareExtensionPluginRegistrant_h
#import <Flutter/Flutter.h>
@interface ShareExtensionPluginRegistrant : NSObject
+ (void)registerWithRegistry:(NSObject<FlutterPluginRegistry>*)registry;
@end
#endif /* ShareExtensionPluginRegistrant_h */
#import <Foundation/Foundation.h>
#import "ShareExtensionPluginRegistrant.h"
#if __has_include(<shared_preference_app_group/SharedPreferenceAppGroupPlugin.h>)
#import <shared_preference_app_group/SharedPreferenceAppGroupPlugin.h>
#else
@import shared_preference_app_group;
#endif
@implementation ShareExtensionPluginRegistrant
+ (void)registerWithRegistry:(NSObject<FlutterPluginRegistry>*)registry {
[SharedPreferenceAppGroupPlugin registerWithRegistrar:[registry registrarForPlugin:@"SharedPreferenceAppGroupPlugin"]];
}
@end
- ここでは話を簡単にするために、
SwiftViewController.swift
の内容は下記のようにします。とりあえずShare Extension上でFlutterのViewを作成します。FlutterViewControllerの引数initialRoute
は後に説明します。
import UIKit
import Flutter
class ShareViewController: UIViewController, FlutterPluginRegistry {
private var flutterViewController: FlutterViewController = FlutterViewController(project: nil, initialRoute: "/share-extension", nibName: nil, bundle: nil)
@objc func registrar(forPlugin pluginKey: String) -> FlutterPluginRegistrar? {
return flutterViewController.registrar(forPlugin: pluginKey)
}
@objc func hasPlugin(_ pluginKey: String) -> Bool {
return flutterViewController.hasPlugin(pluginKey)
}
@objc func valuePublished(byPlugin pluginKey: String) -> NSObject? {
return flutterViewController.valuePublished(byPlugin: pluginKey)
}
override func viewDidLoad() {
super.viewDidLoad()
ShareExtensionPluginRegistrant.register(with: self)
showFlutter()
}
func showFlutter() {
addChild(flutterViewController)
view.addSubview(flutterViewController.view)
flutterViewController.view.frame = view.bounds
}
}
これでたぶんiOSネイティブの実装はいったん終わりです。次にFlutterへ移りましょう。
Flutter側で受信する
一例として、下記のようなページのウィジェットを作成します。別途本体ではSharedPreferenceAppGroup.set
で何かしらのキーを指定し、保存しているものとします。
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:shared_preference_app_group/shared_preference_app_group.dart';
()
class ShareExtensionPage extends StatelessWidget {
const ShareExtensionPage({super.key});
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text("Share Extension Page Test"),
),
body: FutureBuilder(
future: Future(() async {
await SharedPreferenceAppGroup.setAppGroup(
"group.(Bundle Id)");
return await SharedPreferenceAppGroup.get("(本体側で保持したキー)")
as String?;
}),
builder: (context, snapshot) {
if (snapshot.hasData) {
return Text(snapshot.requireData ?? "no data");
} else {
return const CircularProgressIndicator();
}
},
),
);
}
}
続いてauto_routeのルート定義を更新します。SplashRoute
以下はアプリを通常起動したときの遷移として、initial: true
を指定しています。しかしApp Extensionでも通常起動したときと同じ遷移では困りますので、上記のSwift側で指定したinitialRoute
と同じパス名をこちらでも指定します。こうすることで、App Extensionから起動したときはinitial: true
よりFlutterViewController
の引数が優先されることになります。
part 'app_router.gr.dart';
()
class AppRouter extends _$AppRouter {
final List<AutoRoute> routes = [
AutoRoute(page: SplashRoute.page, initial: true),
...
AutoRoute(path: "/share-extension", page: ShareExtensionRoute.page)
];
}
Flutter側で共有した内容を取得できるようにする
この部分は大いにreceive_sharing_intentを参考にしました。
import UIKit
import Flutter
import Social
import MobileCoreServices
import Photos
class ShareViewController: UIViewController, FlutterPluginRegistry {
private let imageContentType = kUTTypeImage as String
private let videoContentType = kUTTypeMovie as String
private let textContentType = kUTTypeText as String
private let urlContentType = kUTTypeURL as String
private let fileURLType = kUTTypeFileURL as String;
private let sharedKey = "ShareKey"
private let hostAppBundleIdentifier = "info.shiosyakeyakini.miria"
private var shareText: [String] = []
private var shareFiles: [SharedMediaFile] = []
private let flutterViewController: FlutterViewController = FlutterViewController(project: nil, initialRoute: "/share-extension", nibName: nil, bundle: nil)
@objc func registrar(forPlugin pluginKey: String) -> FlutterPluginRegistrar? {
return flutterViewController.registrar(forPlugin: pluginKey)
}
@objc func hasPlugin(_ pluginKey: String) -> Bool {
return flutterViewController.hasPlugin(pluginKey)
}
@objc func valuePublished(byPlugin pluginKey: String) -> NSObject? {
return flutterViewController.valuePublished(byPlugin: pluginKey)
}
override func viewDidLoad() {
super.viewDidLoad()
// This is called after the user selects Post. Do the upload of contentText and/or NSExtensionContext attachments.
if let content = extensionContext!.inputItems[0] as? NSExtensionItem {
if let contents = content.attachments {
for (index, attachment) in (contents).enumerated() {
if attachment.hasItemConformingToTypeIdentifier(imageContentType) {
handleImages(content: content, attachment: attachment, index: index)
} else if attachment.hasItemConformingToTypeIdentifier(textContentType) {
handleText(content: content, attachment: attachment, index: index)
} else if attachment.hasItemConformingToTypeIdentifier(fileURLType) {
handleFiles(content: content, attachment: attachment, index: index)
} else if attachment.hasItemConformingToTypeIdentifier(urlContentType) {
let attributedTitle = content.attributedTitle?.string
let attributedContent = content.attributedContentText?.string
handleUrl(content: content, attachment: attachment, index: index, title: attributedTitle ?? attributedContent);
} else if attachment.hasItemConformingToTypeIdentifier(videoContentType) {
handleVideos(content: content, attachment: attachment, index: index)
}
}
}
}
ShareExtensionPluginRegistrant.register(with: self)
addChild(flutterViewController)
view.addSubview(flutterViewController.view)
flutterViewController.view.frame = view.bounds
}
private func save() {
let userDefaults = UserDefaults(suiteName: "group.\(self.hostAppBundleIdentifier)")
let encoder = JSONEncoder()
let json = try? encoder.encode(SendData(text: self.shareText, files: self.shareFiles))
guard let encoded = json else {
return
}
userDefaults?.set(String(data: encoded, encoding: .utf8), forKey: self.sharedKey)
userDefaults?.synchronize()
}
private func handleText (content: NSExtensionItem, attachment: NSItemProvider, index: Int) {
attachment.loadItem(forTypeIdentifier: textContentType, options: nil) { [weak self] data, error in
if error == nil, let item = data as? String, let this = self {
this.shareText.append(item)
if index == (content.attachments?.count)! - 1 {
this.save()
}
}
}
}
private func handleUrl (content: NSExtensionItem, attachment: NSItemProvider, index: Int, title: String?) {
attachment.loadItem(forTypeIdentifier: urlContentType, options: nil) { [weak self] data, error in
if error == nil, let item = data as? URL, let this = self {
let text: String
if(title != nil) {
text = "[\(title ?? "")](\(item.absoluteString))"
} else {
text = item.absoluteString
}
this.shareText.append(text)
if index == (content.attachments?.count)! - 1 {
this.save()
}
}
}
}
private func handleImages (content: NSExtensionItem, attachment: NSItemProvider, index: Int) {
attachment.loadItem(forTypeIdentifier: imageContentType, options: nil) { [weak self] data, error in
if error == nil, let url = data as? URL, let this = self {
// Always copy
let fileName = this.getFileName(from: url, type: .image)
let newPath = FileManager.default
.containerURL(forSecurityApplicationGroupIdentifier: "group.\(this.hostAppBundleIdentifier)")!
.appendingPathComponent(fileName)
let copied = this.copyFile(at: url, to: newPath)
if(copied) {
this.shareFiles.append(SharedMediaFile(path: newPath.absoluteString, thumbnail: nil, duration: nil, type: .image))
}
if index == (content.attachments?.count)! - 1 {
this.save()
}
}
}
}
private func handleVideos (content: NSExtensionItem, attachment: NSItemProvider, index: Int) {
attachment.loadItem(forTypeIdentifier: videoContentType, options: nil) { [weak self] data, error in
if error == nil, let url = data as? URL, let this = self {
// Always copy
let fileName = this.getFileName(from: url, type: .video)
let newPath = FileManager.default
.containerURL(forSecurityApplicationGroupIdentifier: "group.\(this.hostAppBundleIdentifier)")!
.appendingPathComponent(fileName)
let copied = this.copyFile(at: url, to: newPath)
if(copied) {
guard let sharedFile = this.getSharedMediaFile(forVideo: newPath) else {
return
}
this.shareFiles.append(sharedFile)
}
if index == (content.attachments?.count)! - 1 {
this.save()
}
}
}
}
private func handleFiles (content: NSExtensionItem, attachment: NSItemProvider, index: Int) {
attachment.loadItem(forTypeIdentifier: fileURLType, options: nil) { [weak self] data, error in
if error == nil, let url = data as? URL, let this = self {
// Always copy
let fileName = this.getFileName(from :url, type: .file)
let newPath = FileManager.default
.containerURL(forSecurityApplicationGroupIdentifier: "group.\(this.hostAppBundleIdentifier)")!
.appendingPathComponent(fileName)
let copied = this.copyFile(at: url, to: newPath)
if (copied) {
this.shareFiles.append(SharedMediaFile(path: newPath.absoluteString, thumbnail: nil, duration: nil, type: .file))
}
if index == (content.attachments?.count)! - 1 {
this.save()
}
}
}
}
func getExtension(from url: URL, type: SharedMediaType) -> String {
let parts = url.lastPathComponent.components(separatedBy: ".")
var ex: String? = nil
if (parts.count > 1) {
ex = parts.last
}
if (ex == nil) {
switch type {
case .image:
ex = "PNG"
case .video:
ex = "MP4"
case .file:
ex = "TXT"
}
}
return ex ?? "Unknown"
}
func getFileName(from url: URL, type: SharedMediaType) -> String {
var name = url.lastPathComponent
if (name.isEmpty) {
name = UUID().uuidString + "." + getExtension(from: url, type: type)
}
return name
}
func copyFile(at srcURL: URL, to dstURL: URL) -> Bool {
do {
if FileManager.default.fileExists(atPath: dstURL.path) {
try FileManager.default.removeItem(at: dstURL)
}
try FileManager.default.copyItem(at: srcURL, to: dstURL)
} catch (let error) {
print("Cannot copy item at \(srcURL) to \(dstURL): \(error)")
return false
}
return true
}
private func getSharedMediaFile(forVideo: URL) -> SharedMediaFile? {
let asset = AVAsset(url: forVideo)
let duration = (CMTimeGetSeconds(asset.duration) * 1000).rounded()
let thumbnailPath = getThumbnailPath(for: forVideo)
if FileManager.default.fileExists(atPath: thumbnailPath.path) {
return SharedMediaFile(path: forVideo.absoluteString, thumbnail: thumbnailPath.absoluteString, duration: duration, type: .video)
}
var saved = false
let assetImgGenerate = AVAssetImageGenerator(asset: asset)
assetImgGenerate.appliesPreferredTrackTransform = true
// let scale = UIScreen.main.scale
assetImgGenerate.maximumSize = CGSize(width: 360, height: 360)
do {
let img = try assetImgGenerate.copyCGImage(at: CMTimeMakeWithSeconds(600, preferredTimescale: Int32(1.0)), actualTime: nil)
try UIImage.pngData(UIImage(cgImage: img))()?.write(to: thumbnailPath)
saved = true
} catch {
saved = false
}
return saved ? SharedMediaFile(path: forVideo.absoluteString, thumbnail: thumbnailPath.absoluteString, duration: duration, type: .video) : nil
}
private func getThumbnailPath(for url: URL) -> URL {
let fileName = Data(url.lastPathComponent.utf8).base64EncodedString().replacingOccurrences(of: "==", with: "")
let path = FileManager.default
.containerURL(forSecurityApplicationGroupIdentifier: "group.\(hostAppBundleIdentifier)")!
.appendingPathComponent("\(fileName).jpg")
return path
}
class SharedMediaFile: Codable {
var path: String; // can be image, video or url path. It can also be text content
var thumbnail: String?; // video thumbnail
var duration: Double?; // video duration in milliseconds
var type: SharedMediaType;
init(path: String, thumbnail: String?, duration: Double?, type: SharedMediaType) {
self.path = path
self.thumbnail = thumbnail
self.duration = duration
self.type = type
}
// Debug method to print out SharedMediaFile details in the console
func toString() {
print("[SharedMediaFile] \n\tpath: \(self.path)\n\tthumbnail: \(self.thumbnail)\n\tduration: \(self.duration)\n\ttype: \(self.type)")
}
}
struct SendData: Encodable {
let text: [String]
let files: [SharedMediaFile]
}
enum SharedMediaType: Int, Codable {
case image
case video
case file
}
func toData(data: [SharedMediaFile]) -> Data {
let encodedData = try? JSONEncoder().encode(data)
return encodedData!
}
}
これでFlutter側で受け取ってみます。
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preference_app_group/shared_preference_app_group.dart';
()
class ShareExtensionPage extends StatefulWidget {
const ShareExtensionPage({super.key});
State<StatefulWidget> createState() =>
ShareExtensionPageState();
}
class ShareExtensionPageState extends State<ShareExtensionPage> {
var sharedPreference = "";
var sharedData = "";
String? error;
void didChangeDependencies() {
super.didChangeDependencies();
Future(() async {
try {
await SharedPreferenceAppGroup.setAppGroup(
"group.(Bundle ID)");
sharedPreference =
await SharedPreferenceAppGroup.get("account_settings") as String? ??
"";
sharedData =
await SharedPreferenceAppGroup.get("ShareKey") as String? ?? "";
await SharedPreferenceAppGroup.setString("ShareKey", "");
setState(() {});
} catch (e) {
setState(() {
error = e.toString();
});
}
});
}
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text("Share Extension Page Test"),
),
body: Text(error ??
"SharedPreference: $sharedPreference\n共有されたデータ: $sharedData"),
);
}
}
かなり大変でしたが、これでひとまず「共有したものをFlutterでごにょごにょする」ところまでできました。
Share Extensionを閉じる
閉じる手段がない!ことに気が付きました。dart:ui
にはexit(0)
メソッドがありますが、これは強制終了なのでUX的によろしくなく、SystemNavigator.pop
はShareExtensionではうまく機能しませんでした。
そこでMethodChannelを用いてこれを閉じるネイティブコードを作成します。
まずはios/ShareExtension/ShareViewController.swift
のviewDidLoadでMethodChannelを登録します。ここで呼び出す処理はself.extensionContext!.completeRequest(returningItems: nil)
で、これがExtensionの終了メソッドとなります。
let shareExtensionChannel = FlutterMethodChannel(name: "(メソッドチャンネル名)", binaryMessenger: flutterViewController as! FlutterBinaryMessenger)
shareExtensionChannel.setMethodCallHandler({
(call: FlutterMethodCall, result: @escaping FlutterResult) -> Void in
switch call.method {
case "exit":
self.extensionContext!.completeRequest(returningItems: nil)
default:
result(FlutterMethodNotImplemented)
}
})
ShareExtensionPluginRegistrant.register(withRegistry: self)
そしてこれを任意のタイミングでFlutter側で
// メソッドチャンネルを定義
static const shareExtensionMethodChannel =
MethodChannel("(メソッドチャンネル)");
// ネイティブの終了処理を呼ぶ
shareExtensionMethodChannel.invokeMethod("exit");
とすることで、Flutter側で終了するタイミングを制御できるようになりました。
さいごに
Miriaさんでは、receive_sharing_intentからの移行というかたちでこれを行いました。この部分についてのコミットは下記のものです。
Discussion