```html SwiftUI: How to Build an Action Extension (iOS 17+, 2026)

How to Build an Action Extension in SwiftUI

iOS 17+ Xcode 16+ Advanced APIs: Action Extension, NSExtensionContext, UIHostingController Updated: May 11, 2026
TL;DR

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

  1. Extension entry point. iOS instantiates ActionViewController (the value of NSExtensionPrincipalClass). viewDidLoad wraps ActionView in a UIHostingController using Auto Layout anchors so the SwiftUI view fills the sheet exactly.
  2. Loading input items. .task { await loadInput() } fires immediately on appear. It drills into context.inputItems to find the first NSExtensionItem, then calls provider.loadItem(forTypeIdentifier:) with the UTType.plainText identifier.
  3. Returning modified content. done() wraps the edited string in an NSItemProvider, attaches it to a fresh NSExtensionItem, and calls context.completeRequest(returningItems:). The host app receives the modified text if it handles the return value.
  4. Cancellation. cancel() calls context.cancelRequest(withError:) with NSUserCancelledError, which dismisses the extension sheet without side effects.
  5. Preview support. ActionView accepts context: NSExtensionContext? — passing nil in #Preview lets the UI render without a live extension host, while loadInput() 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

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.

```