```html SwiftUI: How to Build a Share Sheet App Extension (iOS 17+, 2026)

How to implement a share sheet app extension in SwiftUI

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

Add a Share Extension target in Xcode, swap its principal class for a UIHostingController that wraps a SwiftUI view, then load shared content from extensionContext?.inputItems using NSItemProvider. Call extensionContext?.completeRequest(returningItems:) or cancelRequest(withError:) to dismiss.

// ShareViewController.swift (Extension principal class)
import UIKit
import SwiftUI

final class ShareViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
        let view = ShareView(context: extensionContext!)
        let host = UIHostingController(rootView: view)
        addChild(host)
        self.view.addSubview(host.view)
        host.view.frame = self.view.bounds
        host.didMove(toParent: self)
    }
}

Full implementation

A Share Extension runs as a separate process that receives NSExtensionItem objects from the host app. The strategy here is to keep the principal class as a thin UIViewController bridge and put all UI logic in a SwiftUI view that receives the raw NSExtensionContext. An @Observable view model loads URL and text attachments asynchronously using NSItemProvider, so the SwiftUI view body stays declarative throughout.

// MARK: - ShareViewController.swift
// Set NSPrincipalClass in Info.plist to $(PRODUCT_MODULE_NAME).ShareViewController

import UIKit
import SwiftUI

final class ShareViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = .systemBackground

        guard let context = extensionContext else { return }
        let rootView = ShareView(model: ShareModel(context: context))
        let host = UIHostingController(rootView: rootView)
        addChild(host)
        host.view.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(host.view)
        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: - ShareModel.swift

import Foundation
import UIKit

@Observable
final class ShareModel {

    var sharedURL: URL?
    var sharedText: String?
    var sharedImage: UIImage?
    var isLoading = true
    var errorMessage: String?

    private let context: NSExtensionContext

    init(context: NSExtensionContext) {
        self.context = context
        Task { await loadItems() }
    }

    @MainActor
    private func loadItems() async {
        guard let items = context.inputItems as? [NSExtensionItem] else {
            isLoading = false
            return
        }
        for item in items {
            for provider in item.attachments ?? [] {
                if provider.hasItemConformingToTypeIdentifier("public.url") {
                    sharedURL = try? await loadURL(from: provider)
                }
                if provider.hasItemConformingToTypeIdentifier("public.plain-text") {
                    sharedText = try? await loadText(from: provider)
                }
                if provider.hasItemConformingToTypeIdentifier("public.image") {
                    sharedImage = try? await loadImage(from: provider)
                }
            }
        }
        isLoading = false
    }

    private func loadURL(from provider: NSItemProvider) async throws -> URL? {
        try await withCheckedThrowingContinuation { cont in
            provider.loadItem(forTypeIdentifier: "public.url") { item, error in
                if let error { cont.resume(throwing: error); return }
                cont.resume(returning: item as? URL)
            }
        }
    }

    private func loadText(from provider: NSItemProvider) async throws -> String? {
        try await withCheckedThrowingContinuation { cont in
            provider.loadItem(forTypeIdentifier: "public.plain-text") { item, error in
                if let error { cont.resume(throwing: error); return }
                cont.resume(returning: item as? String)
            }
        }
    }

    private func loadImage(from provider: NSItemProvider) async throws -> UIImage? {
        try await withCheckedThrowingContinuation { cont in
            provider.loadItem(forTypeIdentifier: "public.image") { item, error in
                if let error { cont.resume(throwing: error); return }
                if let data = item as? Data { cont.resume(returning: UIImage(data: data)) }
                else { cont.resume(returning: nil) }
            }
        }
    }

    func complete(note: String) {
        // Optionally write to an App Group shared container before completing
        if let url = sharedURL {
            saveToAppGroup(url: url, note: note)
        }
        context.completeRequest(returningItems: [], completionHandler: nil)
    }

    func cancel() {
        context.cancelRequest(withError: CancellationError())
    }

    private func saveToAppGroup(url: URL, note: String) {
        let defaults = UserDefaults(suiteName: "group.com.yourcompany.yourapp")
        var saved = defaults?.array(forKey: "sharedItems") as? [[String: String]] ?? []
        saved.append(["url": url.absoluteString, "note": note])
        defaults?.set(saved, forKey: "sharedItems")
    }
}

// MARK: - ShareView.swift

import SwiftUI

struct ShareView: View {
    @Bindable var model: ShareModel
    @State private var note = ""

    var body: some View {
        NavigationStack {
            Group {
                if model.isLoading {
                    ProgressView("Loading shared content…")
                        .frame(maxWidth: .infinity, maxHeight: .infinity)
                } else {
                    Form {
                        if let url = model.sharedURL {
                            Section("Shared URL") {
                                Text(url.absoluteString)
                                    .font(.footnote)
                                    .foregroundStyle(.secondary)
                                    .accessibilityLabel("Shared URL: \(url.absoluteString)")
                            }
                        }
                        if let text = model.sharedText {
                            Section("Shared text") {
                                Text(text).font(.body)
                            }
                        }
                        if let image = model.sharedImage {
                            Section("Shared image") {
                                Image(uiImage: image)
                                    .resizable()
                                    .scaledToFit()
                                    .accessibilityLabel("Shared image")
                            }
                        }
                        Section("Note") {
                            TextField("Add a note…", text: $note, axis: .vertical)
                                .lineLimit(3...)
                        }
                    }
                }
            }
            .navigationTitle("Save to MyApp")
            .navigationBarTitleDisplayMode(.inline)
            .toolbar {
                ToolbarItem(placement: .cancellationAction) {
                    Button("Cancel") { model.cancel() }
                }
                ToolbarItem(placement: .confirmationAction) {
                    Button("Save") { model.complete(note: note) }
                        .disabled(model.isLoading)
                        .fontWeight(.semibold)
                }
            }
        }
    }
}

#Preview {
    // In previews we can't provide a real NSExtensionContext,
    // so stub out the model state directly.
    let model = ShareModel.__preview()
    return ShareView(model: model)
}

// MARK: - ShareModel preview stub (only compiled in DEBUG)
#if DEBUG
extension ShareModel {
    static func __preview() -> ShareModel {
        // Create a throwaway context-free model for canvas previews
        class FakeContext: NSExtensionContext {
            override var inputItems: [Any] { [] }
            override func completeRequest(returningItems items: [Any]?, completionHandler: ((Bool) -> Void)?) {}
            override func cancelRequest(withError error: any Error) {}
        }
        let m = ShareModel(context: FakeContext())
        m.sharedURL = URL(string: "https://soarias.com/swiftui/")
        m.sharedText = "Check out this SwiftUI guide!"
        m.isLoading = false
        return m
    }
}
#endif

How it works

  1. Principal class bridge. Xcode's Share Extension template uses a storyboard-based UIViewController as its NSPrincipalClass. We replace the storyboard approach entirely: ShareViewController embeds a UIHostingController constrained to its own bounds, making the SwiftUI view fill the sheet naturally.
  2. Loading attachments asynchronously. NSItemProvider.loadItem(forTypeIdentifier:completionHandler:) is callback-based, so ShareModel wraps each call in withCheckedThrowingContinuation and awaits them all inside a Task. The @Observable macro means the SwiftUI view automatically re-renders once isLoading flips to false.
  3. App Group shared container. Extensions run in a separate process and cannot access the main app's sandbox. saveToAppGroup(url:note:) writes to a UserDefaults suite keyed on your App Group identifier (group.com.yourcompany.yourapp). Your main app reads from the same suite on the next launch.
  4. Completing vs. cancelling. extensionContext.completeRequest(returningItems:) signals success to the host app and dismisses the sheet. cancelRequest(withError:) signals that the user declined — iOS uses this to show the share sheet again for other destinations in some contexts.
  5. Preview stub. Because NSExtensionContext cannot be instantiated in Xcode Previews, the #if DEBUG factory method on ShareModel subclasses it with empty overrides and seeds realistic data, giving you a live canvas without a real extension host.

Variants

Restricting activation to URLs only

By default the extension activates for almost any content type. Add an NSExtensionActivationRule dictionary to your extension's Info.plist to limit activation to web URLs, keeping your extension out of irrelevant share sheets.

<!-- Extension Info.plist NSExtensionAttributes -->
<key>NSExtensionAttributes</key>
<dict>
    <key>NSExtensionActivationRule</key>
    <dict>
        <!-- Require at least one public.url attachment -->
        <key>NSExtensionActivationSupportsWebURLWithMaxCount</key>
        <integer>1</integer>
        <!-- Disable image and file activation -->
        <key>NSExtensionActivationSupportsImageWithMaxCount</key>
        <integer>0</integer>
        <key>NSExtensionActivationSupportsFileWithMaxCount</key>
        <integer>0</integer>
    </dict>
    <!-- Opt into the modern share sheet (required iOS 13+) -->
    <key>PHSupportedMediaTypes</key>
    <array/>
</dict>

Passing data to the main app via App Group + deep link

Writing to a shared UserDefaults suite is reliable, but the main app only reads it on next launch. For immediate processing, also open a custom URL scheme using open(_:options:completionHandler:) on a UIApplication reference — note that extensions can only call this via the openURL action inside extensionContext:

// Inside ShareModel.complete(note:)
// After saving to App Group:
var components = URLComponents()
components.scheme = "myapp"
components.host = "shared-item"
components.queryItems = [URLQueryItem(name: "source", value: "share-extension")]
if let deepLink = components.url {
    // Extensions cannot use UIApplication.shared.open directly.
    // Use the responder chain approach:
    var responder: UIResponder? = UIApplication.shared
    while let r = responder {
        if r.responds(to: #selector(UIApplication.open(_:options:completionHandler:))) {
            (r as? UIApplication)?.open(deepLink)
            break
        }
        responder = r.next
    }
}

Common pitfalls

Prompt this with Claude Code

When using Soarias or Claude Code directly to implement this:

Implement a share sheet app extension in SwiftUI for iOS 17+.
Use Share Extension (NSExtensionContext, NSItemProvider, NSExtensionItem).
Host the SwiftUI view via UIHostingController in the principal UIViewController.
Support shared URLs, plain text, and images via NSItemProvider.
Write shared data to an App Group UserDefaults suite.
Make it accessible (VoiceOver labels on toolbar buttons and shared content).
Add a #Preview with realistic sample data (a URL and a note string).

In Soarias's Build phase, run this prompt against your extension target after configuring the App Group entitlement — Claude Code will wire up the principal class, entitlements, and Info.plist keys in a single pass.

Related

FAQ

Does this work on iOS 16?

The @Observable macro requires iOS 17. For iOS 16 support, swap @Observable for @ObservableObject / @Published and annotate the view model with @StateObject instead of @Bindable. The #Preview macro also requires Xcode 15+ / iOS 17 minimum deployment — use PreviewProvider on older targets.

Can the extension directly write to SwiftData or Core Data?

Yes, but both the extension and main app must point to a store file inside a shared App Group container. Set the ModelContainer URL to FileManager.default.containerURL(forSecurityApplicationGroupIdentifier:) in both targets. Writes from the extension are visible to the main app on the next container open as long as you use WAL mode (the default). Avoid writing large graphs from the extension — keep it to a single record insertion to stay within the memory cap.

What is the UIKit equivalent?

In UIKit you subclass SLComposeServiceViewController (deprecated since iOS 11 but still functional) or build a plain UIViewController presented modally. The modern approach — which this guide uses — is a plain UIViewController that hosts either UIKit or SwiftUI views. SLComposeServiceViewController is not recommended for new projects because its rigid layout limits customisation and it has not received API updates since iOS 10.

Last reviewed: 2026-05-11 by the Soarias team.

```