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

How to Build a Share Extension in SwiftUI

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

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

  1. ShareViewController as the principal class — In the extension's Info.plist, NSExtensionPrincipalClass points to ShareViewController. The OS instantiates it and calls viewDidLoad, where a UIHostingController child is embedded to hand off rendering to SwiftUI.
  2. Loading attachments with NSItemProvider — The extension context's inputItems contains one or more NSExtensionItem objects, each with attachments — an array of NSItemProvider. Calling loadItem(forTypeIdentifier:) with a UTType constant fetches the actual data asynchronously; the async/await version avoids nested completion closures.
  3. 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 reads pendingShare on next launch or foreground.
  4. Completing or cancelling the requestextensionContext?.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.
  5. SwiftUI @MainActor isolation — All @Published property 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 since loadItem delivers 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

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.

```