How to Build a Share Extension in SwiftUI
Add a Share Extension target to your Xcode project, swap the default principal class for a
UIHostingController wrapping a SwiftUI view, then load attachments from
NSExtensionContext and store them in a shared App Group container so your host app can read the data.
// ShareViewController.swift (principal class)
import UIKit
import SwiftUI
final class ShareViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
let vm = ShareViewModel(context: extensionContext!)
let host = UIHostingController(rootView: ShareView(viewModel: vm))
addChild(host)
view.addSubview(host.view)
host.view.frame = view.bounds
host.didMove(toParent: self)
}
}
Full implementation
A Share Extension is a separate app target that the OS presents as a sheet when a user taps the share button in Safari, Photos, or any other app. You own the UI — built entirely in SwiftUI — but the lifecycle entry point is still UIKit's UIViewController. The trick is a thin bridge: a UIHostingController child that hands control to your SwiftUI view, which talks back through an ObservableObject view-model. App Groups let the extension and the main app share a UserDefaults suite, so whatever the user saves here lands in your app on next launch.
// MARK: - ShareViewModel.swift (inside the Share Extension target)
import SwiftUI
import UniformTypeIdentifiers
@MainActor
final class ShareViewModel: ObservableObject {
@Published var sharedURL: URL?
@Published var sharedText: String = ""
@Published var sharedImage: UIImage?
@Published var isSaving = false
private let context: NSExtensionContext
private let appGroupID = "group.com.example.myapp"
init(context: NSExtensionContext) {
self.context = context
Task { await loadAttachments() }
}
// Load the first item from the extension context
private func loadAttachments() async {
guard let item = context.inputItems.first as? NSExtensionItem,
let provider = item.attachments?.first else { return }
if provider.hasItemConformingToTypeIdentifier(UTType.url.identifier) {
let result = try? await provider.loadItem(forTypeIdentifier: UTType.url.identifier)
sharedURL = result as? URL
} else if provider.hasItemConformingToTypeIdentifier(UTType.plainText.identifier) {
let result = try? await provider.loadItem(forTypeIdentifier: UTType.plainText.identifier)
sharedText = (result as? String) ?? ""
} else if provider.hasItemConformingToTypeIdentifier(UTType.image.identifier) {
let result = try? await provider.loadItem(forTypeIdentifier: UTType.image.identifier)
sharedImage = result as? UIImage
}
}
func save() {
isSaving = true
// Persist to shared App Group UserDefaults
if let defaults = UserDefaults(suiteName: appGroupID) {
defaults.set(sharedURL?.absoluteString ?? sharedText, forKey: "pendingShare")
}
context.completeRequest(returningItems: [], completionHandler: nil)
}
func cancel() {
context.cancelRequest(withError: CancellationError())
}
}
// MARK: - ShareView.swift
struct ShareView: View {
@ObservedObject var viewModel: ShareViewModel
var body: some View {
NavigationStack {
Form {
if let url = viewModel.sharedURL {
Section("URL") {
Text(url.absoluteString)
.foregroundStyle(.secondary)
.accessibilityLabel("Shared URL: \(url.absoluteString)")
}
} else if let img = viewModel.sharedImage {
Section("Image") {
Image(uiImage: img)
.resizable()
.scaledToFit()
.frame(maxHeight: 200)
.clipShape(RoundedRectangle(cornerRadius: 8))
.accessibilityLabel("Shared image")
}
} else {
Section("Text") {
Text(viewModel.sharedText.isEmpty ? "Nothing to share" : viewModel.sharedText)
.foregroundStyle(.secondary)
}
}
}
.navigationTitle("Save to MyApp")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { viewModel.cancel() }
}
ToolbarItem(placement: .confirmationAction) {
Button("Save") { viewModel.save() }
.disabled(viewModel.isSaving)
.accessibilityLabel("Save shared content to MyApp")
}
}
}
}
}
// MARK: - ShareViewController.swift
import UIKit
import SwiftUI
final class ShareViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
guard let context = extensionContext else { return }
let vm = ShareViewModel(context: context)
let host = UIHostingController(rootView: ShareView(viewModel: vm))
host.view.backgroundColor = .systemBackground
addChild(host)
view.addSubview(host.view)
host.view.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
host.view.topAnchor.constraint(equalTo: view.topAnchor),
host.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
host.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
host.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
host.didMove(toParent: self)
}
}
// MARK: - Preview (host-app target only, mocks the extension context)
#Preview {
ShareView(viewModel: {
// Instantiate without a real NSExtensionContext for canvas preview
class MockContext: NSExtensionContext {}
// Use a lightweight stub view-model with pre-filled data
let vm = ShareViewModel.__previewStub()
return vm
}())
}
How it works
-
ShareViewControlleras the principal class — In the extension'sInfo.plist,NSExtensionPrincipalClasspoints toShareViewController. The OS instantiates it and callsviewDidLoad, where aUIHostingControllerchild is embedded to hand off rendering to SwiftUI. -
Loading attachments with
NSItemProvider— The extension context'sinputItemscontains one or moreNSExtensionItemobjects, each withattachments— an array ofNSItemProvider. CallingloadItem(forTypeIdentifier:)with aUTTypeconstant fetches the actual data asynchronously; theasync/awaitversion avoids nested completion closures. -
App Groups for cross-process data sharing — Extensions run in a sandboxed
process isolated from the host app.
UserDefaults(suiteName:)with a shared App Group identifier (enabled in both targets' Signing & Capabilities tab) gives both processes read/write access to the same store. The host app readspendingShareon next launch or foreground. -
Completing or cancelling the request —
extensionContext?.completeRequest(returningItems:)tells the OS the extension finished successfully and dismisses the sheet. Passing an empty array signals no return data to the source app.cancelRequest(withError:)dismisses without saving and lets the source app know the user bailed. -
SwiftUI
@MainActorisolation — All@Publishedproperty updates happen on the main actor via the class-level annotation, preventing purple-highlighted runtime warnings about publishing changes from a background thread — especially important sinceloadItemdelivers on an arbitrary queue.
Variants
Accept multiple URLs in one share action
private func loadAttachments() async {
guard let item = context.inputItems.first as? NSExtensionItem else { return }
var urls: [URL] = []
for provider in item.attachments ?? [] {
guard provider.hasItemConformingToTypeIdentifier(UTType.url.identifier) else { continue }
if let result = try? await provider.loadItem(forTypeIdentifier: UTType.url.identifier),
let url = result as? URL {
urls.append(url)
}
}
self.sharedURLs = urls // @Published var sharedURLs: [URL] = []
}
Open the host app after saving
After writing to App Groups, open your host app via a deep-link URL scheme by calling
openURL(_:completionHandler:) on extensionContext itself (iOS 17+
allows extensions to call this). Encode the payload in the URL if needed:
myapp://share?url=…. Register the scheme in the host app's
Info.plist under CFBundleURLSchemes and read it in
onOpenURL in your SwiftUI scene.
Common pitfalls
-
Missing App Group entitlement on the extension target — App Groups must be
explicitly enabled in Signing & Capabilities for both the host app target and the
Share Extension target, using the exact same group ID string. Forgetting one side gives you
nilback fromUserDefaults(suiteName:)with no error. -
NSExtensionActivationRulemismatch — IfInfo.plistdeclares onlyNSExtensionActivationSupportsWebURLWithMaxCount = 1but your code also handles images, the extension simply won't appear in the share sheet for Photos. Keep the activation rule in sync with the actual UTTypes your view-model handles. -
Memory limit of ~120 MB — Share Extensions run in a tight memory sandbox.
Loading a full-resolution image with
UIImage(data:)can easily exceed this limit and cause an OOM crash the developer rarely sees locally. Downsample large images usingImageIO/kCGImageSourceCreateThumbnailAtIndexbefore storing them. -
VoiceOver labels on toolbar buttons — The default "Save" toolbar button
inherits its label from its title, but always add an explicit
.accessibilityLabelthat includes context (e.g., "Save shared content to MyApp") so VoiceOver users understand the action without reading the navigation title first.
Prompt this with Claude Code
When using Soarias or Claude Code directly to implement this:
Implement share extension in SwiftUI for iOS 17+. Use Share Extension (NSExtensionContext, NSItemProvider, UTType, App Groups). Accept URLs, plain text, and images from the share sheet. Store accepted content in UserDefaults with suiteName "group.com.example.myapp". Make it accessible (VoiceOver labels on Save and Cancel buttons). Add a #Preview with realistic sample data.
In Soarias's Build phase, paste this prompt into the implementation canvas to scaffold the Share Extension target, entitlements, and SwiftUI bridge in one shot — then iterate on the UI without leaving your flow.
Related
FAQ
Does this work on iOS 16?
Most of the code works on iOS 16, but the #Preview macro requires Xcode 15+ and
the iOS 17 SDK — swap it for a legacy PreviewProvider if you need to target iOS 16.
The async/await variant of loadItem(forTypeIdentifier:) is available
from iOS 16.0 as well, so no back-compat shims are needed for the extension logic itself.
How do I restrict the share extension to only appear for URLs from Safari?
Use an NSExtensionActivationRule predicate string instead of the simple
dictionary form. For example: SUBQUERY(extensionItems, $item, SUBQUERY($item.attachments, $attach, ANY $attach.registeredTypeIdentifiers UTI-CONFORMS-TO "public.url").@count == 1).@count == 1.
Predicate-based rules give you fine-grained control over which host bundles or UTType
hierarchies trigger the extension's appearance in the share sheet.
What is the UIKit equivalent?
In UIKit you subclass SLComposeServiceViewController for a Twitter/compose-style
sheet, or go fully custom by subclassing UIViewController directly and setting
it as the NSExtensionPrincipalClass — which is exactly what the SwiftUI bridge
above does. SLComposeServiceViewController is still available in iOS 17 but
considered legacy and offers far less UI flexibility than the full SwiftUI approach shown here.
Last reviewed: 2026-05-11 by the Soarias team.