Add an iOS Action Extension to your Flutter app
Enhance your Flutter app with an iOS Action Extension
When building an application, you often want to simplify your users' life by providing some shortcuts to access some features. It can be, in the case of a messaging app, to send content to a contact from another app on the user's device without making the user leave that app.
What are Action extensions
Action Extensions are custom actions that are added to the share sheet to invoke your application from any other app. If you don't know what the share sheet is, it is the panel that appears when a user taps the share button within a running app.
When you create an Action Extension, you have to declare the type of content it accepts. Your extension will then appear in the share sheet each time content matching the accepted type is shared.
Additional information about Action extensions is provided in the links section. In this article, we will focus on the technical part of how to add an action extension to a Flutter application.
Let's build
I want to keep things simple. We will make an application that will simply expose an Action Extension to receive a photo, and display this photo in the Flutter application
Please note that to understand what we are going to do in the following, you need to have some knowledge of native iOS development, especially of concepts like app security app groups, UserDefaults, UIKit etc...
For now, let's just create an empty Flutter app. To do so, run the Flutter create command:
Let's add the Extension
Creating the extension
Ok, we're good; we've created the Flutter app. Let's now open the ios project in XCode.
PS: Make sure to open the Runner.xcworkspace
Go to File -> New -> Target menu action, and select "Action Extension" in the panel :
Once this is done, a new folder will be added to your project :
This folder contains :
ActionViewController: It is responsible for presenting the interface and handling user interactions within the Action Extension. It can display custom views, buttons, or other UI elements to facilitate the desired action. The content selected by the user in the host app is typically passed to the Action Extension, allowing it to manipulate or process that data
MainInterface.storyboard: Used to design and define the user interface (UI) of the action extension
Info.plist: This serves a similar purpose as in a regular iOS app. It contains configuration settings and metadata specific to the Action Extension
The next step is to change the display name. This step is important because that name will be displayed beneath the app icon in the share sheet.
To do this, click on Runner -> [Action Extension] under the targets section:
By default, the project created contains code to display an image from the extension context.
The extension context is an instance of the NSExtensionContext class, which provides various methods and properties for communication and interaction between the extension and its host app. It serves as a bridge between the extension and the host app, facilitating data sharing and coordination.
In our Info.plist
, let's modify the NSExtensionActivationRule
under NSExtension
-> NSExtensionAttributes
to let our extension appear only when an image is shared:
SUBQUERY ( extensionItems, $extensionItem, SUBQUERY ( $extensionItem.attachments, $attachment, ( ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.image" ) ).@count > 0 ).@count >
Testing the extension
To test our extension, let's go to XCode, then select our extension scheme; then run it as you would run an iOS app.
A window will show up, we will then have to select an app to run. That will be the app from which we will share the content to trigger the extension. We will select the Photos app, then click the Run button.
The Photos app will launch.
Sharing the content
To share the content with the host app, we need to achieve a few steps:
Let's add our app and extension under the same group. For each target, select it on the right panel, then go to the Signing & Capabilities tab and add the App Groups capability. Add a new group and name it
group.YOUR_HOST_APP_BUNDLE_IDENTIFIER
in this casegroup.com.example.actionExtensionExample
In the
Info.plist
file of both our targets, let's add a new entry named AppGroupId with the value$(CUSTOM_GROUP_ID)
Let's go back and add a new User-Defined setting in our target's build settings tab:
- The next step is to add a custom scheme to our host application. This step is important because we will use a deep link to open our host app. Here is the code to add to the
Info.plist
...
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLSchemes</key>
<array>
<string>ImportMedia-$(PRODUCT_BUNDLE_IDENTIFIER)</string>
</array>
</dict>
<dict/>
</array>
...
The final file content can be found here: https://github.com/stevenosse/articles_sample_apps/blob/main/action_extension_example/ios/Runner/Info.plist
We're going to edit the sample code so it fits our needs.
Now, we want to open our Flutter app when the user taps the "Done" button and display that image there.
@IBAction func done() {
guard let url = self.imageURL else {
self.extensionContext!.completeRequest(returningItems: self.extensionContext!.inputItems, completionHandler: nil)
return
}
let fileName = "\(url.deletingPathExtension().lastPathComponent).\(url.pathExtension)"
let newPath = FileManager.default
.containerURL(forSecurityApplicationGroupIdentifier: self.appGroupId)!
.appendingPathComponent(fileName)
let copied = self.copyFile(at: url, to: newPath)
if (copied) {
let sharedFile = ImportedFile(
path: newPath.absoluteString,
name: fileName
)
self.importedMedia.append(sharedFile)
}
let userDefaults = UserDefaults(suiteName: self.appGroupId)
userDefaults?.set(self.toData(data: self.importedMedia), forKey: self.sharedKey)
userDefaults?.synchronize()
self.redirectToHostApp()
}
So, when the "Done" button is tapped, we copy the files from the extension context into the application group container. Then we add an entry to the UserDefaults to keep track of the saved data.
The full file can be found here: ActionViewController.swift
Receiving the content
In our AppDelegate.swift
, we need to add a new constructor. This one will be called when the app is opened through a deep link:
override func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey : Any] = [:]) -> Bool {
let controller : FlutterViewController = window?.rootViewController as! FlutterViewController
self.initChannels(controller: controller)
if (self.hasMatchingSchemePrefix(url: url)) {
return self.handleUrl(url: url, setInitialData: false)
}
return super.application(app, open: url, options:options)
}
Here we are achieving a few steps:
We initiate the event channel
We check the conformity with the received URL and handle it when a match is found.
Our
handleUrl
will get our previously saved data fromUserDefaults
and send them through our EventChannel
public func handleUrl(url: URL?, setInitialData: Bool) -> Bool {
if let url = url {
let appDomain = Bundle.main.bundleIdentifier!
let appGroupId = (Bundle.main.object(forInfoDictionaryKey: "AppGroupId") as? String) ?? "group.\(Bundle.main.bundleIdentifier!)"
let userDefaults = UserDefaults(suiteName: appGroupId)
if let key = url.host?.components(separatedBy: "=").last,
let json = userDefaults?.object(forKey: key) as? Data {
let sharedArray = decode(data: json)
let sharedMediaFiles: [ImportedFile] = sharedArray.compactMap {
guard let path = getAbsolutePath(for: $0.path) else {
return nil
}
return ImportedFile.init(
path: path,
name: $0.name
)
}
latestMedia = sharedMediaFiles
if(setInitialData) {
initialMedia = latestMedia
}
self.eventSinkMedia?(toJson(data: latestMedia))
}
return true
}
latestMedia = nil
return false
}
In our Flutter app, we need to create an EventChannel that will receive the shared content (sent through the event channel we declared in the AppDelegate). Here is the code to do that:
import 'dart:async';
import 'dart:convert';
import 'package:flutter/services.dart';
class ImportFileChannel {
static const String _eventChannelName = 'com.example.actionExtensionExample/import';
static const EventChannel _eventChannel = EventChannel(_eventChannelName);
Stream<List<ImportedFile>> getMediaStream() {
final stream = _eventChannel.receiveBroadcastStream("media").cast<String?>();
Stream<List<ImportedFile>> sharedMediaStream = stream.transform<List<ImportedFile>>(
StreamTransformer<String?, List<ImportedFile>>.fromHandlers(
handleData: (String? data, EventSink<List<ImportedFile>> sink) {
if (data == null) {
sink.add([]);
return;
}
final List<ImportedFile> args = (json.decode(data) as List<dynamic>).map((e) {
return ImportedFile.fromJson(e);
}).toList();
sink.add(args);
},
),
);
return sharedMediaStream;
}
}
class ImportedFile {
final String path;
final String name;
ImportedFile({required this.name, required this.path});
factory ImportedFile.fromJson(Map<String, dynamic> json) {
return ImportedFile(
name: json['name'],
path: json['path'],
);
}
}
That's it!
Wrapping up
We've added an action extension to our Flutter application. The extension allows a user to preview the shared content, then imports the image to our host Flutter app and display it there.
To achieve this, here are the steps we followed:
When the extension is selected from the share sheet, we display the selected image in our extension view
When the users tap the "Done" button, we copy the shared file into the Group container (which is shared by both the App and the extension) and add an entry to the UserDefaults
Then we redirect to the host app. To achieve this, we use a deep link.
The host app then receives the deep link and sends the shared data (from the shared UserDefaults) to the Flutter app through an Event Channel
The Flutter app can read the shared data from the EventChannel and display the image from the payload
The full of this article can be found here: action extension example
Useful links:
Approach highly inspired by the receive_sharing_intent package