# Share Extensions in Practice – Part 4: Modern UI with SwiftUI and Hosting Controllers
Table of Contents
This is part 4 of a 5-part series on building robust iOS Share Extensions:
- Part 1: When Duplication Beats Shared Frameworks - Pragmatic API architecture
- Part 2: Secure Keychain Sharing - Authentication tokens and sensitive data
- Part 3: App Groups & Core Data - Shared storage and synchronization
- Part 4: Modern UI with SwiftUI (this article) - SwiftUI integration and hosting controllers
- Part 5: Memory-Safe SwiftUI in Extensions - Memory limits, performance tradeoffs, and practical optimization patterns
When I started wiring up this Share Extension, my first thought was: “Please, not Storyboards again.”
I was not in the mood to drag UI around Interface Builder for one small extension screen, so the first thing I checked was whether SwiftUI could be bridged in.
Good news: it is almost suspiciously easy. A tiny UIViewController, one UIHostingController, a couple of constraints, and the SwiftUI view looked exactly how I wanted.
That was the moment this setup went from “ugh” to “nice.” Let’s walk through it and keep it simple.
Setting Up the SwiftUI BridgeLink to heading
The key is a minimal UIHostingController bridge between UIKit’s extension APIs and SwiftUI’s declarative UI. Here’s the setup:
import UIKitimport SwiftUI
class ShareViewController: UIViewController { // Keep a strong reference to the hosting controller while the extension is active. private var hostingController: UIHostingController<ShareBookmarkView>?
override func viewDidLoad() { super.viewDidLoad()
// Bridge extension context into SwiftUI through a ViewModel. let viewModel = ShareBookmarkViewModel(extensionContext: extensionContext) let swiftUIView = ShareBookmarkView(viewModel: viewModel)
// Wrap the SwiftUI root view in a UIKit controller. let hostingController = UIHostingController(rootView: swiftUIView)
// Proper child-controller containment. addChild(hostingController) hostingController.view.translatesAutoresizingMaskIntoConstraints = false view.addSubview(hostingController.view)
// Pin SwiftUI content to the full extension container. NSLayoutConstraint.activate([ hostingController.view.topAnchor.constraint(equalTo: view.topAnchor), hostingController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), hostingController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), hostingController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor) ])
// Complete containment lifecycle and retain controller. hostingController.didMove(toParent: self) self.hostingController = hostingController }}Key points:
- ViewModel injection: Extension context passed to SwiftUI through ViewModel
- Child controller pattern: Proper UIHostingController lifecycle management
- Clean separation: UIKit handles extension lifecycle, SwiftUI handles UI
SwiftUI View ArchitectureLink to heading
This part is intentionally simple: it works exactly like in the main app. We can keep a normal View + ViewModel setup and do not have to switch back to MVC just because we are inside an extension.
Main SwiftUI ViewLink to heading
import SwiftUI
struct ShareBookmarkView: View { // ObservableObject from outside: bridge between UI and extension logic. @ObservedObject var viewModel: ShareBookmarkViewModel // Local UI state: drives button label/disabled state while saving. @State private var isSaving = false
var body: some View { NavigationView { VStack(spacing: 16) { Text("Quick Save") .font(.title3)
// Two-way binding: user input updates viewModel.note directly. TextField("Add a note...", text: $viewModel.note)
HStack { Button("Cancel") { // View triggers action, ViewModel handles extension cancel flow. viewModel.cancelRequest() }
Button(isSaving ? "Saving..." : "Save") { Task { guard !isSaving else { return } isSaving = true defer { isSaving = false }
do { // View triggers save in ViewModel. try await viewModel.saveBookmark() viewModel.completeRequest() } catch { // Keep UI responsive; show alert/toast in real implementation. } } } // React to UI state: prevent duplicate taps while async save runs. .disabled(isSaving) } } .navigationTitle("Save Bookmark") } .task { // Initial load when the view appears. await viewModel.loadSharedContent() } }}The interaction pattern is exactly the one you already know:
- The View writes user input directly through a binding (
$viewModel.note) - Actions call ViewModel methods (
saveBookmark,cancelRequest,completeRequest) .tasktriggers the initial content load when the view appears
State Management and Data IntegrationLink to heading
Next up is the ViewModel. This is where we put the business logic, because in a Share Extension we usually want a slim architecture and no overengineering. So this uses a lightweight MVVM setup: the ViewModel handles behavior, and the UI stays focused on rendering state.
ViewModel IntegrationLink to heading
import SwiftUI
@MainActorclass ShareBookmarkViewModel: ObservableObject { @Published var note: String = "" @Published var sharedContent: SharedContent?
private let extensionContext: NSExtensionContext?
// we use here a small service layer for saving bookmarks, but it could be any data manager that integrates with Keychain/Core/API Data private let bookmarkService = BookmarkService.shared
// we inject the extension context directly into the ViewModel, // so it can handle the entire extension flow (loading content, saving, completing/canceling) init(extensionContext: NSExtensionContext?) { self.extensionContext = extensionContext }
func loadSharedContent() async { guard let item = extensionContext?.inputItems.first as? NSExtensionItem, let provider = item.attachments?.first(where: { $0.hasItemConformingToTypeIdentifier("public.url") }), let url = try? await provider.loadItem( forTypeIdentifier: "public.url", options: nil ) as? URL, let url else { return }
sharedContent = SharedContent(url: url, title: url.absoluteString) }
func saveBookmark() async throws { guard let content = sharedContent else { return }
// Service hides auth + persistence details from the view layer. try await bookmarkService.save(url: content.url, note: note) }
func completeRequest() { // Tell the system we're done and can dismiss the extension. extensionContext?.completeRequest(returningItems: nil) }
func cancelRequest() { // we create a custom error to indicate cancellation, which can be handled gracefully in the UI if needed let error = NSError( domain: "ShareExtension", code: -1, userInfo: [NSLocalizedDescriptionKey: "User cancelled"] ) extensionContext?.cancelRequest(withError: error) }}Important detail here: we pass extensionContext straight into the ViewModel and let it handle the whole extension flow.
That includes reading the shared URL, triggering save, and finishing or canceling the request. The View stays clean and only reacts to state.
This is the separation we want from the start: UI in the View, logic in the ViewModel.
In a classic Share Extension UIViewController, everything often ends up mixed together. This setup keeps concerns separated from day one.
ConclusionLink to heading
SwiftUI can provide excellent user experiences in Share Extensions when implemented thoughtfully. The key principles are:
- Minimal bridging: Use UIHostingController as a simple bridge, not a complex coordinator
- Lifecycle-aware UX: Keep interactions predictable and fast
- Integration-first: Use a small service layer that handles actual data saving, so the ViewModel can focus on UI state and extension flow
- Error resilience: Handle failures gracefully without crashing the extension
With Part 4 done, you now have:
- Pragmatic API architecture that avoids framework complexity
- Secure data sharing via Keychain Access Groups
- Robust offline storage with App Groups and Core Data
- Modern SwiftUI interface that respects extension constraints
The result is a Share Extension that feels native, performs well, and integrates seamlessly with your main app’s architecture.
Next up is Part 5: Memory-Safe SwiftUI in Extensions. That one is all about surviving tight memory limits without turning your UI into a loading-spinner festival.
This “Share Extensions in Practice” series has shown you step by step how to develop stable, secure, and user-friendly Share Extensions for iOS. Each part builds on the previous ones to create a complete, production-ready solution.