iOS Share Extension with SwiftUI and SwiftData
Writing a Share Extension for iOS using SwiftUI and SwiftData was an exercise in trial-and-error along with a frustrating lack of documentation from Apple. I could find very few resources on how to develop a Share Extension and want to share what I’ve learned in case it might help others out. I’d love to hear other people’s experiences with this so if you have something you can share, I’d love to hear it, I’m on Mastodon.
Note: This article assumes that you already have a SwiftUI and SwiftData main application, the goal of this post is to show you how I created a Share Extension that uses SwiftUI and SwiftData.
Create the Share Extension
First, you need to create the Share Extension itself. In Xcode do the following:
- File > New Target…
- Then select “Share Extension”
After creating the Share Extension, you’ll need to make sure that both your main app and the Share Extension are in the same App Group. This is necessary so that the Share Extension can have access to the same database used by Swift Data. Otherwise, iOS sees the main app and the Share Extension as completely separate apps wich use their own databases.
To set the Share Extension’s group do the following:
- Modify the Target created for your Share Extension under Signing & Capabilities, add a new Capability. There is a “+ Capability” button on the screen below the Signing & Capabilities section.
- Select App Groups
- If you already have an App Group for your main App, select that group. Otherwise, first add the App Group to your main app then follow the instructions above to add the App Group you just created to your application.
Deciding when the Share Extension is active
Once the Share Extension is created, the template will have a few files in it. It’ll have a ShareViewController.swift file, a MainInterface.storyboard file and an Info.plist file. The Info.plist file has properties set telling iOS that this target is a Share Extension.
You can use the NSExtensionActivationRule in the Share Extension’s Info.plist to tell iOS when to present your application that can be shared to. The options in the NSExtensionActivationRule allow you to present your Share Extension when you are sharing links, images, text and other media types.
The initial app creation has an NSExtensionActivationRule set by default using the value TRUEPREDICATE, this allows the extension to always be presented as an app to share to. But from what I’ve read, you cannot have this value set if you submit to the App Store, instead you need to modify the activation rule to list the specific situations your Share Extension should be presented.
For the app I am writing, I am interested in sharing links so I updated the
NSExtensionActivationRule by changing its type to Dictionary and adding the
NSExtensionActivationSupportsWebURLWithMaxCount
property. I set the property to 1 since I’m sharing individual links, there
does seem to be some scenarios where the Share Sheet thinks that I’m trying to
share multiple links and doesn’t present my app. Most of the time it is fine, so
I haven’t dug in deeper to understand the issue yet.
Replacing the ViewController and Storyboard with SwiftUI
Since we plan on using the SwiftUI in the Share Extension, we can start by removing the templated code that we won’t need. First we’ll update the ShareViewController. The ShareViewController inherits from the SLComposeServiceViewController and gives you a way to use the default iOS share behavior. This is where you choose share and it brings up a share dialog where you can add text before sending the post to your app. I tried to figure out how to modify this code but could not make it work. Instead, we’re going to replace all of this and build the UI using SwiftUI.
Info: You can’t use Previews in a Share Extension so you either need to have your SwiftUI view outside of the ShareExtension or actually run the app to see the UI itself.
- In the ShareViewController, remove SLComposeServiceViewController and replace it with UIViewController.
- Remove everything in the controller and replace it with an override for viewDidLoad().
You can delete the Storyboard as we won’t be using it. Inside the Info.plist,
remove the NSExtensionMainStoryboard property. We need to tell iOS how to
start up the Share Extension so we instead add NSExtensionPrincipalClass
property under the NSExtension property. Give the value a name, you’ll use
that name to annotate the ViewController afterwards. I chose the name of my
ViewController and set it to ShareViewController. Back in the
ShareViewController.swift file, add this annotation to the class
@objc(ShareViewController)
class ShareViewController: UIViewController {
// ...
}
Once the Share Extension is called, it will use this class as the starting point for the extension. I’m not entirely sure this is the right way to do things, but it does work. I’ve seen a few other posts say you can also modify the Info.plist so that iOS knows which class to use, I couldn’t make that work so I went with this.
Adding a SwiftUI View
Create a new SwiftUI View either in your Share Extension or in your main app. I chose to use my main app so I could get previews working. Make sure you add the SwiftUI View to the Share Extension target so you can use it there. I named my SwiftUI View ShareView.
Adding the SwiftUI view involves a few steps. First import SwiftUI so we can use the UIHostingController to host our SwiftUI view. To the ShareViewController, we make a few modifications:
// Add the SwiftUI import
import SwiftUI
import UIKit
@objc(ShareViewController)
class ShareViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
// New code added below to host the SwiftUI View, ShareView is a SwiftUI
// View I created
let contentView = UIHostingController(rootView: ShareView())
self.addChild(contentView)
self.view.addSubview(contentView.view)
// set up constraints
contentView.view.translatesAutoresizingMaskIntoConstraints = false
contentView.view.topAnchor.constraint(equalTo: self.view.topAnchor).isActive = true
contentView.view.bottomAnchor.constraint (equalTo: self.view.bottomAnchor).isActive = true
contentView.view.leftAnchor.constraint(equalTo: self.view.leftAnchor).isActive = true
contentView.view.rightAnchor.constraint (equalTo: self.view.rightAnchor).isActive = true
}
}
This creates a UIHostingController that has the ShareView as its root view. I
then set the frame of the contentView to the bounds of my existing
ShareViewController. This code came from TnvMadhav’s post on Share
Extensions,
and I don’t fully understand what its doing but it works for me. I’m not
familiar with working with UIKit so its something I’ll have to spend time to
better understand.
Update: After toying around with this on iPadOS. I found that kait.dev’s instructions allowed the view to display properly on iPadOS as well as handle resizes if the orientation of the device changes.
With this wired up, you can run the extension, Share a Safari page, and have the SwiftUI View load up.
Adding SwiftData to the Share Extensions
Using SwiftData from the new SwiftUI View is still not ready yet. Unlike the
main app where SwiftData is configured at the app level with a .modelContainer
modifier, we need to create and wire up the model container ourselves. The first
step is to create a reusable piece of code to wire up the model container so it
can be used across both the main app and the Share Extension. I did this by
creating a ModelConfiguration.swift file in my main app and adding this code:
import SwiftData
public func ConfigureModelContainer() -> ModelContainer {
let schema = Schema([
// This is a Model from the app I'm working on
LinkItem.self
])
// Set up your ModelConfiguration however you need it
let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false, cloudKitDatabase: .none)
do {
return try ModelContainer(for: schema, configurations: [modelConfiguration])
} catch {
fatalError(error.localizedDescription)
}
}
Make sure you add this file and your Models to the Share Extension target or your app won’t compile.
This allows me to use the code like this in my App:
import SwiftUI
import SwiftData
@main
struct LinksApp: App {
var body: some Scene {
let sharedModelContainer = ConfigureModelContainer()
WindowGroup {
NewContentView()
}
.modelContainer(sharedModelContainer)
}
}
There are probably better ways to do this so I’d love to hear from others on how this could be done better. Now, in the ShareViewController we can create our model container like this:
override func viewDidLoad() {
super.viewDidLoad()
// Create the Model container
let modelContainer = ConfigureModelContainer()
// Note that I've added the modelContainer modifier to ShareView()
let contentView = UIHostingController(rootView: ShareView().modelContainer(modelContainer))
self.addChild(contentView)
self.view.addSubview(contentView.view)
// set up constraints
contentView.view.translatesAutoresizingMaskIntoConstraints = false
contentView.view.topAnchor.constraint(equalTo: self.view.topAnchor).isActive = true
contentView.view.bottomAnchor.constraint (equalTo: self.view.bottomAnchor).isActive = true
contentView.view.leftAnchor.constraint(equalTo: self.view.leftAnchor).isActive = true
contentView.view.rightAnchor.constraint (equalTo: self.view.rightAnchor).isActive = true
}
From the ShareView, you can now use SwiftData like you would normally meaning you can add Queries or insert data using the modelContext.
Canceling the Share Extension
If you want to cancel the request from your SwiftUI view, I found that passing the NSExtensionContext into my SwiftUI View was the easiest way to do this. Modify your view to have an optional NSExtensionContext like this:
struct ShareView: View {
var extensionContext: NSExtensionContext?
...
}
Then pass the extensionContext into the view by changing the UIHostingController portion of the code like this:
let contentView = UIHostingController(rootView: ShareView(extensionContext: self.extensionContext).modelContainer(modelContainer))
You can then use the extensionContext to cancel or complete the action from within the SwiftUI view. You could use code like this:
extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
Running Javascript when using the Share Extension
Depending on the type of input you want to pass into your Share Extension, you’ll need to configure the NSExtensionActivationRule to enable the Share Extension with various types of media types such as plain text, URLs, images, etc. For my application, I want to receive URLs such as when sharing from Safari but I also want to get the Title of the page I’m sharing. For that, you can use the NSExtensionJavaScriptPreprocessingFile which runs the Javascript file on the page. This lets you easily grab the URL, title, and optionally other information such as a selection of text from the page.
First, update the Info.plist by adding the NSExtensionJavaScriptPreprocessingFile property under the NSExtensionAttributes key. Give the value a string name, I called mine Action. The Share Extension will then look for an Action.js file when the Share Extension is run. The Action.js file needs to be set up in a specific way as described by these (seemingly very old?) docs from Apple. The primary thing to note is that the file must contain the following:
A prototype with a run and finalize functions. The finalize appears to
only be run on iOS but can be used to directly modify the page if your extension
needs that.
A global object named ExtensionPreprocessingJS, this is what you set your
Prototype to.
My Action.js file looks like this:
var Action = function() {};
Action.prototype = {
run: function(parameters) {
parameters.completionFunction({
"URL": document.URL,
"title": document.title
});
},
finalize: function(parameters) {
var customJavaScript = parameters["customJavaScript"];
eval(customJavaScript);
}
};
var ExtensionPreprocessingJS = new Action();
The key piece here is the parameters.completionFunction() call with a
Javascript object passing back a URL and title which were grabbed from the
document object run on the page. The Javascript object will be available from
the extensionContext’s inputItems nested somewhat deeply down in an
extensionItem attachment. The properties are available as an NSDictionary.
Retrieving the data from Javascript
The last part is to retrieve the data from the Javascript that was run. This could be done inside the ShareViewController, but I found it easier to do the work inside the SwiftUI view. From there I can use async and await on several of the functions that have callbacks. I don’t know how to do something like that from the ShareViewController itself. I have a function on my SwiftUI view that I use for this:
func getPageDataFromExtensionContext() async {
guard let extensionItems = extensionContext?.inputItems as? [NSExtensionItem] else { return }
// The Javascript will return a Dictionary which is defined as a propertyList here
let typeIdentifier = UTType.propertyList.identifier
for extensionItem in extensionItems {
if let itemProviders = extensionItem.attachments {
for itemProvider in itemProviders {
if itemProvider.hasItemConformingToTypeIdentifier(typeIdentifier) {
let dict = try? await itemProvider.loadItem(forTypeIdentifier: typeIdentifier) as? NSDictionary
guard let dict = dict,
let jsValues = dict[NSExtensionJavaScriptPreprocessingResultsKey] as? NSDictionary else { return }
// Title and url are State on the View
title = jsValues["title"] as? String ?? ""
url = jsValues["URL"] as? String ?? ""
}
}
}
}
}
Helpful links
While there weren’t a ton of docs I could find on how to do this, I did find several blog posts that got me working.
- https://tnvmadhav.me/guides/how-to-build-a-simple-share-extension-in-swift/
- https://medium.com/@damisipikuda/how-to-receive-a-shared-content-in-an-ios-application-4d5964229701
- https://medium.com/@henribredtprivat/create-an-ios-share-extension-with-custom-ui-in-swift-and-swiftui-2023-6cf069dc1209
- https://kait.dev/posts/implementing-swiftui-share-extension