# 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:


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 UIKit
import 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)
  • .task triggers 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
@MainActor
class 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:

  1. Pragmatic API architecture that avoids framework complexity
  2. Secure data sharing via Keychain Access Groups
  3. Robust offline storage with App Groups and Core Data
  4. 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.

My avatar

Thanks for reading my blog post! Feel free to check out my other posts or contact me via the social links in the footer.


More Posts