Create an iOS Share Extension with custom UI in Swift and SwiftUI (2023)

Henri Bredt
8 min readOct 8, 2023

I wanted to create a Share Extension for my app Thoughts — Inspiration Manager with the UI build in SwiftUI. The Share Extension API hasn’t been update by Apple in quite some time and is made for UIKit. In this article I share how I worked around this and reused SwiftUI views from my main app in the Share Extension.

Here’s what I wanted to achieve: Any selected text should be shareable with my App via the Share Extension. The Extension should show a Sheet with custom UI in which the user can edit the text and add additional info. Sounds simple, but I found that the documentation wasn’t all that detailed and that other tutorials didn’t work or were written for UIKit — so it took me some research to figure things out. Here’s what we will build in this tutorial:

What we will be building in this tutorial

Step 1: Add a Share Extension target to your project

Click the + Button or choose File ▸ New ▸ Target from Xcodes menu, then search for and select Share Extension. Enter a name and create the new target. When Xcodes asks you to activate the scheme select activate.

Add a Share Extension target

You will now find a new Group and some files in the Project Navigator. Xcode has automatically created a UIViewController subclass of type SLComposeServiceViewController in ShareViewController.swift. You’ll notice that by default Xcode has also created a MainInterface.storyboard and Info.plist file. You can run the project and test the default Share Extension, which was made to post content on a social media platform — not quite what we want yet, but you can give it a try.

The default Share Extension — not quite what we want.

Step 2: Set Activation Rule

Before we add custom UI, we have to configure the Share Extension. iOS needs to know for what kind of data your Share Extension should be shown. You can specify that in the Info.plist file of the Extension. By default you will see the key NSExtensionActivationRule has the value TRUEPREDICATE which means that the Share Extension will always be shown. Since we want our Share Extension only to be shown when Text was selected, we modify the NSExtensionActivationRule. Change the Type of the NSExtensionActivationRule from String to Dictionary. This will allow you to specify multiple allowed types. Click the small + to create a new Item. Our Extension should only support Text, so add the key NSExtensionActivationSupportsText of Type Boolean and set the Value to YES.

Info.plist

Other Activation Rules are available, read more in Apple’s Developer Documentation. After setting the Activation Rule, your Share Extension will only be shown in the system share sheet for the corresponding file types, in our case when Text is to be shared.

Step 3: Prepare the project to show your own UI

The default UI Apple presents is rarely useful and we want to show our own UI, build in SwiftUI. As we have seen, the Share Extension is build with UIKit, so we’ll have to bridge to SwiftUI with a UIHostingController.

Let’s prepare the project to show custom UI first. Remove all default code from ShareViewController.swift and create a simple UIViewController.

import UIKit

class ShareViewController: UIViewController {

override func viewDidLoad() {
super.viewDidLoad()
}

}

You can then delete the MainInterface.storyboard file—feels so good to get rid of this :D. We now need to tell the Share Extension, that ShareViewController.swift should be responsible for showing the UI. Open the Info.plist file. You will see a key NSExtensionMainStoryboard that references the storyboard file we have just deleted. Change that key to NSExtensionPrincipalClass and set the value to <NameOfTheShareTarget>.ShareViewController. It’s important to put the name of the Share Target first, so Xcode can find the file.

Info.plist

Step 4: Access the shared data

We now need to get the data that was shared with the Share Extension. We can access it via the inputItems of the extensionContext. In the next step we ensure we have access to the required data, if not we close the Share Extension immediately with the close() function. You might want to do some proper error handling / logging but that is out of the scope of this article.

import UIKit

class ShareViewController: UIViewController {

override func viewDidLoad() {
super.viewDidLoad()

// Ensure access to extensionItem and itemProvider
guard
let extensionItem = extensionContext?.inputItems.first as? NSExtensionItem,
let itemProvider = extensionItem.attachments?.first else {
close()
return
}
}

/// Close the Share Extension
func close() {
self.extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
}

}

After that we check that the provided data is of the correct type, we expect UTType.plainText. If the item does not conform to the Type Identifier, we again close the Share Extension.

override func viewDidLoad() {
super.viewDidLoad()

// Ensure access to extensionItem and itemProvider
guard
let extensionItem = extensionContext?.inputItems.first as? NSExtensionItem,
let itemProvider = extensionItem.attachments?.first else {...}


// Check type identifier
let textDataType = UTType.plainText.identifier
if itemProvider.hasItemConformingToTypeIdentifier(textDataType) {

} else {
close()
return
}

}

In the next Step we try to load the item from the itemProvider and and convert the provided item to a String. Yet again, if an error occurred or the conversion to a String failed, we close the Share Extension.

override func viewDidLoad() {
super.viewDidLoad()

...

// Check type identifier
let textDataType = UTType.plainText.identifier
if itemProvider.hasItemConformingToTypeIdentifier(textDataType) {

// Load the item from itemProvider
itemProvider.loadItem(forTypeIdentifier: textDataType , options: nil) { (providedText, error) in
if let error {
self.close()
return
}

if let text = providedText as? String {
// if we get here, we're good and can show the View :D
} else {
self.close()
return
}
}

} else {...}
}

Step 5: Host your SwiftUI with a UIHostingController

Now we’re ready show a SwiftUI view in a UIHostingViewController. Create a SwiftUI view you want to use as the UI for the Share Extension. This is a simple example that only shows a TextView with the passed text.

import SwiftUI

struct ShareExtensionView: View {
@State private var text: String

init(text: String) {
self.text = text
}

var body: some View {
NavigationStack{
VStack(spacing: 20){
TextField("Text", text: $text, axis: .vertical)
.lineLimit(3...6)
.textFieldStyle(.roundedBorder)

Button {
// TODO: save text
// TODO: close()
} label: {
Text("Save")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)

Spacer()
}
.padding()
.navigationTitle("Share Extension")
.toolbar {
Button("Cancel") {
// TODO: close()
}
}
}
}
}

Replace the save text comment with some code to share the data with your main application. For example, I’ve created an App Group with a CoreData instance in the App Group and write the data to CoreData from the extension, but there are many other ways. That is out of the scope of this article.

In the next step we present this view in a UIHostingController. We need to explicitly tell that we want the code responsible for showing the view to run on the main thread with DispatchQueue.main.async , because the loadItem closure can be executed on background threads.

override func viewDidLoad() {
...

// Check type identifier
let textDataType = UTType.plainText.identifier
if itemProvider.hasItemConformingToTypeIdentifier(textDataType) {

// Load the item from itemProvider
itemProvider.loadItem(forTypeIdentifier: textDataType , options: nil) { (providedText, error) in
if let error {...}

if let text = providedText as? String {
DispatchQueue.main.async {
// host the SwiftU view
let contentView = UIHostingController(rootView: ShareExtensionView(text: text))
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
}
} else {...}
}

} else {...}

}

Step 6: Close the Share Extension from SwiftUI

We can now present our custom UI and access the shared data 🥳. But we also need to let the SwiftUI view communicate with the ShareViewController.swift to trigger the close() function to dismiss the view when needed. We can do this by utilizing the NotificationCenter. In your ShareExtensionView.swift add a close() function and uncomment the calls of this function.

import SwiftUI

struct ShareExtensionView: View {
@State private var text: String

init(text: String) {...}

var body: some View {...}

func close() {
NotificationCenter.default.post(name: NSNotification.Name("close"), object: nil)
}
}

Now we need to listen for that Notification in ShareViewController.swift. At the end of viewDidLoad add and observer to the default NotificationCenter like this.

NotificationCenter.default.addObserver(forName: NSNotification.Name("close"), object: nil, queue: nil) { _ in
DispatchQueue.main.async {
self.close()
}
}

That’s it! Congrats, you’ve added a Share Extension with custom UI build in SwiftUI to your app. Here’s what we’ve build:

Full code

ShareExtensionView.swift:

import SwiftUI

struct ShareExtensionView: View {
@State private var text: String

init(text: String) {
self.text = text
}

var body: some View {
NavigationStack{
VStack(spacing: 20){
TextField("Text", text: $text, axis: .vertical)
.lineLimit(3...6)
.textFieldStyle(.roundedBorder)

Button {
// TODO: save text
close()
} label: {
Text("Save")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)

Spacer()
}
.padding()
.navigationTitle("Share Extension")
.toolbar {
Button("Cancel") {
close()
}
}
}
}

func close() {
NotificationCenter.default.post(name: NSNotification.Name("close"), object: nil)
}
}

#Preview {
ShareExtensionView(text: "Think big!")
}

ShareViewController.swift:

import SwiftUI
import UIKit
import UniformTypeIdentifiers

class ShareViewController: UIViewController {

override func viewDidLoad() {
super.viewDidLoad()

// Ensure access to extensionItem and itemProvider
guard
let extensionItem = extensionContext?.inputItems.first as? NSExtensionItem,
let itemProvider = extensionItem.attachments?.first else {
close()
return
}

// Check type identifier
let textDataType = UTType.plainText.identifier
if itemProvider.hasItemConformingToTypeIdentifier(textDataType) {

// Load the item from itemProvider
itemProvider.loadItem(forTypeIdentifier: textDataType , options: nil) { (providedText, error) in
if let error {
self.close()
return
}

if let text = providedText as? String {
DispatchQueue.main.async {
// host the SwiftU view
let contentView = UIHostingController(rootView: ShareExtensionView(text: text))
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
}
} else {
self.close()
return
}

}

} else {
close()
return
}

NotificationCenter.default.addObserver(forName: NSNotification.Name("close"), object: nil, queue: nil) { _ in
DispatchQueue.main.async {
self.close()
}
}
}

/// Close the Share Extension
func close() {
self.extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
}
}

Info.plist in the Share Extension:

<?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>NSExtension</key>
<dict>
<key>NSExtensionAttributes</key>
<dict>
<key>NSExtensionActivationRule</key>
<dict>
<key>NSExtensionActivationSupportsText</key>
<true/>
</dict>
</dict>
<key>NSExtensionPrincipalClass</key>
<string>ShareExtension.ShareViewController</string>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.share-services</string>
</dict>
</dict>
</plist>

Thanks for reading, I hope this works for you and you could save you some time! If you have a suggestion for an improvement, please let me know so that we can learn from each other :D If you’d like to see my implementation in action, you can get my app Thoughts — Inspiration Manager for free on the App Store.

All the best,
Henri

Helpful links

--

--

Henri Bredt

Indie Developer, 3x WWDC Swift Student Challenge Winner, developer of thoughts-app.com