How to Build an Action Extension in SwiftUI
Add an Action Extension target in Xcode, set NSExtensionPointIdentifier to com.apple.ui-services in its Info.plist, and bridge to SwiftUI via UIHostingController. Pass extensionContext into your view to read input items and call completeRequest(returningItems:) when done.
// ActionViewController.swift — extension principal class
import UIKit
import SwiftUI
class ActionViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
guard let ctx = extensionContext else { return }
let hosting = UIHostingController(rootView: ActionView(context: ctx))
addChild(hosting)
view.addSubview(hosting.view)
hosting.view.frame = view.bounds
hosting.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
hosting.didMove(toParent: self)
}
}
Full implementation
An Action Extension surfaces a custom sheet from the iOS share menu, receives typed data (plain text, URLs, images) from the host app through NSExtensionItem attachments, and can optionally hand back a modified version. Because extensions run in a separate process, SwiftUI cannot be the entry point directly — you bridge with UIHostingController inside the Xcode-generated UIViewController subclass. The NSExtensionContext handle is threaded into the SwiftUI view so the view owns its own cancel and complete logic.
// ── ActionView.swift ──────────────────────────────────────────
import SwiftUI
import UniformTypeIdentifiers
struct ActionView: View {
// nil only in #Preview
let context: NSExtensionContext?
@State private var inputText = ""
@State private var outputText = ""
@State private var isLoading = true
@State private var errorMessage: String?
var body: some View {
NavigationStack {
Group {
if isLoading {
ProgressView("Loading content…")
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if let err = errorMessage {
ContentUnavailableView(err,
systemImage: "exclamationmark.triangle")
} else {
Form {
Section("Received") {
Text(inputText.isEmpty ? "(empty)" : inputText)
.foregroundStyle(.secondary)
}
Section("Return") {
TextField("Modified text", text: $outputText,
axis: .vertical)
.lineLimit(4...)
.accessibilityLabel("Output text field")
}
}
}
}
.navigationTitle("Text Action")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel", role: .cancel) { cancel() }
}
ToolbarItem(placement: .confirmationAction) {
Button("Done") { done() }
.disabled(isLoading || outputText.isEmpty)
.fontWeight(.semibold)
}
}
}
.task { await loadInput() }
}
// MARK: - Input loading
private func loadInput() async {
defer { isLoading = false }
guard let context,
let item = context.inputItems.first as? NSExtensionItem,
let provider = item.attachments?.first else {
errorMessage = "No input provided."
return
}
guard provider.hasItemConformingToTypeIdentifier(UTType.plainText.identifier) else {
errorMessage = "Unsupported content type."
return
}
do {
let value = try await provider.loadItem(
forTypeIdentifier: UTType.plainText.identifier)
if let text = value as? String {
inputText = text
outputText = text // pre-fill output
}
} catch {
errorMessage = error.localizedDescription
}
}
// MARK: - Actions
private func done() {
let result = NSExtensionItem()
let provider = NSItemProvider(object: outputText as NSString)
result.attachments = [provider]
context?.completeRequest(returningItems: [result])
}
private func cancel() {
let err = NSError(domain: NSCocoaErrorDomain,
code: NSUserCancelledError)
context?.cancelRequest(withError: err)
}
}
// ── ActionViewController.swift ────────────────────────────────
import UIKit
import SwiftUI
class ActionViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
let hosting = UIHostingController(
rootView: ActionView(context: extensionContext))
addChild(hosting)
view.addSubview(hosting.view)
hosting.view.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
hosting.view.topAnchor.constraint(equalTo: view.topAnchor),
hosting.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
hosting.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
hosting.view.bottomAnchor.constraint(equalTo: view.bottomAnchor)
])
hosting.didMove(toParent: self)
}
}
// ── Preview ───────────────────────────────────────────────────
#Preview("Action Extension UI") {
ActionView(context: nil)
}
Required Info.plist keys
Xcode scaffolds most of this when you add an Action Extension target, but double-check these keys in your extension's Info.plist:
<!-- Extension/Info.plist -->
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.ui-services</string>
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).ActionViewController</string>
<key>NSExtensionActivationRule</key>
<dict>
<!-- Accept one or more plain-text items -->
<key>NSExtensionActivationSupportsText</key>
<true/>
</dict>
</dict>
How it works
-
Extension entry point. iOS instantiates
ActionViewController(the value ofNSExtensionPrincipalClass).viewDidLoadwrapsActionViewin aUIHostingControllerusing Auto Layout anchors so the SwiftUI view fills the sheet exactly. -
Loading input items.
.task { await loadInput() }fires immediately on appear. It drills intocontext.inputItemsto find the firstNSExtensionItem, then callsprovider.loadItem(forTypeIdentifier:)with theUTType.plainTextidentifier. -
Returning modified content.
done()wraps the edited string in anNSItemProvider, attaches it to a freshNSExtensionItem, and callscontext.completeRequest(returningItems:). The host app receives the modified text if it handles the return value. -
Cancellation.
cancel()callscontext.cancelRequest(withError:)withNSUserCancelledError, which dismisses the extension sheet without side effects. -
Preview support.
ActionViewacceptscontext: NSExtensionContext?— passingnilin#Previewlets the UI render without a live extension host, whileloadInput()short-circuits to No input provided.
Variants
Accept URLs and images too
Update NSExtensionActivationRule and handle multiple UTTypes in loadInput():
// In Info.plist — accept URLs too
<key>NSExtensionActivationSupportsText</key><true/>
<key>NSExtensionActivationSupportsWebURLWithMaxCount</key><integer>1</integer>
// In loadInput() — try URL fallback
let types: [UTType] = [.plainText, .url]
for type in types {
guard provider.hasItemConformingToTypeIdentifier(type.identifier) else { continue }
let value = try await provider.loadItem(forTypeIdentifier: type.identifier)
if let text = value as? String { inputText = text; break }
if let url = value as? URL { inputText = url.absoluteString; break }
}
Predicate-based activation rule (fine-grained)
For complex filtering — e.g. "only activate when the host passes exactly one text item from a specific domain" — replace the dictionary-style rule with a string predicate:
<key>NSExtensionActivationRule</key>
<string>
SUBQUERY(
extensionItems,
$item,
SUBQUERY(
$item.attachments,
$attachment,
ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.plain-text"
).@count == 1
).@count == 1
</string>
Common pitfalls
- iOS 17 minimum, not iOS 16.
provider.loadItem(forTypeIdentifier:)withasync/awaitrequires iOS 16+, but theContentUnavailableViewused here requires iOS 17+. Set your extension target's deployment target to match your app's — a mismatch causes the extension to be silently skipped by the system. - Extension memory limit is ~120 MB. Action Extensions run in a tightly sandboxed process. Avoid importing large frameworks or loading heavy assets; the system will silently terminate the extension if it crosses the memory threshold mid-run.
- Forgetting to call complete or cancel. If the user dismisses your UI by some other means and you never call
completeRequestorcancelRequest, the host app hangs. Always hook both the Done and Cancel toolbar buttons — and any swipe-down gesture dismissal — to one of these two methods. - VoiceOver: label your output field. The
TextFieldaxis trick for multiline input does not automatically inherit a useful accessibility label. Always add.accessibilityLabel("Output text field")so VoiceOver users know what they are editing.
Prompt this with Claude Code
When using Soarias or Claude Code directly to implement this:
Implement an Action Extension in SwiftUI for iOS 17+. Use NSExtensionContext, UIHostingController, and NSItemProvider. The extension should accept plain text via NSExtensionActivationRule, display the received text in a NavigationStack with Done/Cancel toolbar buttons, allow the user to edit the text, and return it via completeRequest(returningItems:). Make it accessible (VoiceOver labels on all interactive elements). Add a #Preview with realistic sample data (context: nil fallback).
In Soarias's Build phase, paste this prompt after scaffolding your extension target so Claude Code generates the bridging controller and SwiftUI view in one pass — no back-and-forth needed.
Related
FAQ
Does this work on iOS 16?
Partially. UIHostingController bridging and loadItem(forTypeIdentifier:) with async/await both work on iOS 16, but ContentUnavailableView requires iOS 17. Replace that with a plain Text error label if you need iOS 16 support, and set your deployment target accordingly.
Can I share data between the extension and the containing app?
Yes — via an App Group. Add the App Groups capability to both the app and the extension target, then use UserDefaults(suiteName: "group.com.example.myapp") or a shared container URL from FileManager.default.containerURL(forSecurityApplicationGroupIdentifier:). Never touch the app's main sandbox path from the extension.
What is the UIKit equivalent?
In UIKit you skip the UIHostingController bridge entirely and build your UI directly inside the UIViewController subclass — the NSExtensionContext API (completeRequest, cancelRequest, inputItems) is identical regardless of UI framework.
Last reviewed: 2026-05-11 by the Soarias team.