27 · The SwiftUI screen inside a UIKit app
A UIKit account screen that hosts a SwiftUI header through UIHostingController — the everyday mid-migration setup — with a crash, a leak, stacked headers and two copies of the same number. Review it and fix it without leaving UIKit.
Shape: review this PR · Reported: a common senior-round topic; no specific company report found · Verified: the snippet and the fix typecheck against the iOS SDK in Swift 6 mode (the snippet with one warning, key 9); the view model's tests ran with Swift 6.4; the hosting wiring is checked by hand
"We're moving to SwiftUI one piece at a time. This PR puts a SwiftUI profile header at the top of our UIKit account screen. On the device: open Orders and come back, and there are two headers. Sometimes the app crashes on open. Review it."
import SwiftUI
import UIKit
@MainActor
final class ProfileViewModel: ObservableObject {
@Published private(set) var name = ""
@Published private(set) var orderCount = 0
private let api: ProfileLoading
init(api: ProfileLoading = LiveProfileAPI()) {
self.api = api
}
func load() async {
let profile = try? await api.profile()
name = profile?.name ?? ""
orderCount = profile?.orderCount ?? 0
}
}
struct ProfileHeader: View {
@ObservedObject var viewModel = ProfileViewModel()
@EnvironmentObject var session: Session
var onOpenOrders: () -> Void = {}
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text(viewModel.name).font(.title2.bold())
Text(session.email).foregroundStyle(.secondary)
Button("Your orders (\(viewModel.orderCount))") { onOpenOrders() }
}
.padding()
.onAppear {
Task { await viewModel.load() }
}
}
}
final class AccountViewController: UIViewController {
private let session: Session
private let stackView = UIStackView()
private var orderCount = 0
init(session: Session) {
self.session = session
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
override func viewDidLoad() {
super.viewDidLoad()
stackView.axis = .vertical
stackView.frame = view.bounds
stackView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
stackView.addArrangedSubview(UIButton(configuration: .plain(), primaryAction: UIAction(title: "Log out") { _ in }))
view.addSubview(stackView)
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
var header = ProfileHeader()
header.onOpenOrders = {
self.navigationController?.pushViewController(OrdersViewController(), animated: true)
}
let hosting = UIHostingController(rootView: header)
stackView.insertArrangedSubview(hosting.view, at: 0)
Task {
orderCount = try await LiveProfileAPI.shared.profile().orderCount
tabBarItem.badgeValue = "\(orderCount)"
}
}
}It compiles in Swift 6 mode with one warning, on the last Task: unstructured throwing task … is not used, which may accidentally ignore errors thrown inside the task (checked).
A hint, if you're stuck
- How many times does
viewWillAppearrun in a screen's life? What does each run add? - Where does
sessioncome from inside the SwiftUI view? - Who owns the
ProfileViewModel? What happens to it when SwiftUI rebuildsProfileHeader? - Count the places that know the order count.
The key — what I expect a senior to find
- The environment object is never provided: a crash.
ProfileHeaderreads@EnvironmentObject var session, and nothing up the tree calls.environmentObject(session)— the controller has a session and never passes it. SwiftUI stops the app with a fatal error as soon as the header readssession. Inject it on the root view. - A new header on every appearance.
viewWillAppearruns every time the screen comes back, so push Orders, pop, and a second hosting controller (with a second view model) goes into the stack above the first. That's the "two headers". Build the hosted view once, inviewDidLoad. - The hosting controller isn't a child. Only its view is added; nothing but a local variable holds the controller, and UIKit doesn't know it exists. Without
addChild(_:)anddidMove(toParent:)it isn't in the view-controller tree, so appearance calls, trait changes and safe area aren't forwarded to it the way UIKit promises a child. sizingOptionsis left at its default. In a stack view the header's height comes from its intrinsic size. By default that isn't updated when the SwiftUI content changes — the name arriving, a bigger Dynamic Type size — so the header can clip. SetsizingOptions = .intrinsicContentSize(iOS 16+).@ObservedObjectwith a default value.@ObservedObjectwatches an object it doesn't own; Apple's docs say "don't specify a default or initial value". SwiftUI can rebuild theProfileHeaderstruct at any time, and each rebuild makes a fresh, empty view model. Here the UIKit side should own it and pass it in (or the view owns it with@StateObject).- Loading too often.
.onAppearfires on every appearance of every hosting controller, the controller fetches separately inviewWillAppear, and nothing stops two loads overlapping. Load in one place, and ignore a load while one is in flight. - A retain cycle through the closure.
onOpenOrderscapturesselfstrongly. The closure lives in the root view, the root view in the hosting view, the hosting view instackView, whichselfowns. The account screen is never freed.[weak self]. - Two copies of the order count. The badge comes from the controller's own fetch, the header from the view model's. Two requests, and they can disagree — one fails, or an order is placed between them. Keep one view model, owned by the controller, and have the badge observe it.
- The throwing
Taskhides errors. Ifprofile()throws, the task ends silently and the badge is never set. The compiler warns about exactly this. LiveProfileAPI.sharedinside the controller. It bypasses the view model's injected API, so no test can control it. It goes away once the badge reads the view model.- A failed refresh blanks the screen.
try?plus?? ""means one dropped connection replaces a good name with nothing and the count with 0, and there's no error to show. Keep what you had and surface the error. - The stack ignores the safe area.
stackView.frame = view.boundsputs the header under the navigation bar and the notch. Pin it tosafeAreaLayoutGuidewith constraints. onOpenOrdersis avarset after init. Style: pass it in the initializer so a caller can't forget it. Note what's right here, too: navigating back into UIKit through a closure is the standard bridge — the SwiftUI view doesn't need to know aboutUINavigationController.
The idea behind it
A UIHostingController is a normal view controller whose view happens to be drawn by SwiftUI. So all of UIKit's rules for putting one controller inside another still apply. A container controller must call addChild(_:) before adding the child's view, and the child's didMove(toParent:) after. That's what links them: the parent then forwards appearance, size and trait changes to the child, and holds it alive. Adding only the view is like hiring someone and never telling HR — they're in the building, but nobody passes them the memos.
Inside SwiftUI, the question is always who owns the object. A SwiftUI view is a cheap struct that's thrown away and rebuilt all the time. @StateObject means "this view owns the object — create it once and keep it across rebuilds". @ObservedObject means "someone else owns it; I just watch". @EnvironmentObject means "someone above me put it in the environment" — and if nobody did, it's a crash, not a nil.
In a mixed app the clean shape is: UIKit owns the state, SwiftUI draws it. The controller creates one view model, hands it to the SwiftUI view, and observes the same object for its own needs (the tab badge). One copy of the truth, read by both sides, so they can't drift.
And viewWillAppear is the wrong place to build anything: it runs every time the screen comes back. viewDidLoad runs once per screen.
The fix
The view model — only load() and two properties change:
@Published private(set) var errorMessage: String? // key 11
private var isLoading = false // key 6
func load() async {
guard !isLoading else { return } // key 6
isLoading = true
defer { isLoading = false }
do {
let profile = try await api.profile()
name = profile.name
orderCount = profile.orderCount
errorMessage = nil
} catch {
errorMessage = String(localized: "We couldn't load your profile.") // key 11: keep what we had
}
}The header and the controller:
struct ProfileHeader: View {
@ObservedObject var viewModel: ProfileViewModel // key 5: owned by UIKit, passed in
@EnvironmentObject var session: Session
var onOpenOrders: () -> Void = {}
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text(viewModel.name).font(.title2.bold())
Text(session.email).foregroundStyle(.secondary)
Button("Your orders (\(viewModel.orderCount))") { onOpenOrders() }
}
.padding() // key 6: no .onAppear load
}
}
final class AccountViewController: UIViewController {
private let session: Session
private let viewModel: ProfileViewModel // key 5, 8: the one copy
private let stackView = UIStackView()
private var badgeSubscription: AnyCancellable?
init(session: Session, viewModel: ProfileViewModel) {
self.session = session
self.viewModel = viewModel
super.init(nibName: nil, bundle: nil)
}
// required init?(coder:) unchanged
override func viewDidLoad() {
super.viewDidLoad()
stackView.axis = .vertical
stackView.translatesAutoresizingMaskIntoConstraints = false // key 12
stackView.addArrangedSubview(UIButton(configuration: .plain(), primaryAction: UIAction(title: "Log out") { _ in }))
view.addSubview(stackView)
NSLayoutConstraint.activate([
stackView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
stackView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
stackView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
])
var header = ProfileHeader(viewModel: viewModel) // key 2: built once
header.onOpenOrders = { [weak self] in // key 7
self?.navigationController?.pushViewController(OrdersViewController(), animated: true)
}
let hosting = UIHostingController(rootView: header.environmentObject(session)) // key 1
hosting.sizingOptions = .intrinsicContentSize // key 4
addChild(hosting) // key 3
stackView.insertArrangedSubview(hosting.view, at: 0)
hosting.didMove(toParent: self) // key 3
badgeSubscription = viewModel.$orderCount.sink { [weak self] count in // key 8, 10
self?.tabBarItem.badgeValue = count > 0 ? "\(count)" : nil
}
Task { await viewModel.load() } // key 6, 9: one load, can't throw
}
// viewWillAppear is gone // key 2
}Said out loud, not coded: refresh on return with an explicit pull-to-refresh rather than every appearance; show errorMessage in the header; pass onOpenOrders through the initializer (key 13); move navigation to a coordinator; on iOS 17+, @Observable instead of ObservableObject, which lets UIKit (iOS 26 and later) track the badge without Combine; drop the default LiveProfileAPI() from the view model's init so the composition root decides.
Why each piece:
- The controller creates nothing it doesn't have to. The view model comes in through
init, so whoever builds the screen decides the API — and a test or preview can pass a fake. header.environmentObject(session)— the hosting controller's root is now a modified view. Its exact type doesn't matter becausehostingis a locallet.- The badge observes
$orderCount— Combine is already whatObservableObjectuses, so it's the smallest way for UIKit to watch the same object SwiftUI watches.
Now write the tests
"Good. What would you test — and what wouldn't you?"
What I'd test, and why
- A load publishes the count the badge listens to — the badge and the header now read one value, so the test subscribes to
$orderCountexactly as the controller does. - Two loads at once make one request — the double load. Two loads start while the first reply is still held back; only one request may go out.
- A failed reload keeps what was shown — the "blank screen on a flaky network" bug, and the error is set.
What I'd check by hand, not unit-test: the hosting wiring — child controller, sizing, safe area, no second header after push and pop, and no leak. Open Orders and come back three times: one header. Increase the text size in Settings: the header grows. Then Debug Memory Graph after closing the screen: no AccountViewController left. A unit test can't see layout, and a UI test for this costs more than it saves.
The seam. The view model already takes a ProfileLoading. The fake can hold its reply (a checked continuation — a paused await the test resumes by hand) so two loads really overlap, with no clock. The view model imports only Foundation and Combine, so this runs on the Mac.
import Combine
import Foundation
import Testing
/// A fake API: answers with `result`, and can hold its replies until the test releases them.
@MainActor
final class FakeProfileAPI: ProfileLoading {
var result: Result<Profile, Error> = .success(Profile(name: "Ada", orderCount: 3))
var holdsReplies = false
private(set) var requestCount = 0
private var waiting: [CheckedContinuation<Void, Never>] = []
func profile() async throws -> Profile {
requestCount += 1
if holdsReplies { await withCheckedContinuation { waiting.append($0) } }
return try result.get()
}
func releaseReplies() {
waiting.forEach { $0.resume() }
waiting = []
}
}
/// Gives the main actor turns until `condition` holds. Bounded by a count, not a clock.
@MainActor
func waitUntil(maxYields: Int = 1_000, _ condition: () -> Bool) async -> Bool {
for _ in 0..<maxYields {
if condition() { return true }
await Task.yield()
}
return condition()
}
@MainActor
struct ProfileViewModelTests {
@Test func loadPublishesTheCountTheBadgeListensTo() async {
let viewModel = ProfileViewModel(api: FakeProfileAPI())
var badgeCounts: [Int] = []
let subscription = viewModel.$orderCount.sink { badgeCounts.append($0) }
await viewModel.load()
#expect(viewModel.name == "Ada")
#expect(badgeCounts == [0, 3])
subscription.cancel()
}
@Test func twoLoadsAtOnceMakeOneRequest() async {
let api = FakeProfileAPI()
api.holdsReplies = true
let viewModel = ProfileViewModel(api: api)
let first = Task { await viewModel.load() }
let second = Task { await viewModel.load() }
#expect(await waitUntil { api.requestCount == 1 })
_ = await waitUntil(maxYields: 100) { false } // give the second load its turn
api.releaseReplies()
await first.value
await second.value
#expect(api.requestCount == 1)
#expect(viewModel.orderCount == 3)
}
@Test func failedReloadKeepsWhatWasShown() async {
let api = FakeProfileAPI()
let viewModel = ProfileViewModel(api: api)
await viewModel.load()
api.result = .failure(URLError(.notConnectedToInternet))
await viewModel.load()
#expect(viewModel.name == "Ada")
#expect(viewModel.orderCount == 3)
#expect(viewModel.errorMessage != nil)
}
}Ran with Swift 6.4: 3 tests, all passed.
What I'd ask next
- "
@StateObjector pass it in?" — If only the SwiftUI view uses the object,@StateObjectin the view. If UIKit also needs it (the badge here), UIKit owns it and passes it in as@ObservedObject. Never@ObservedObjectwith a default value. - "How would the hosted view tell UIKit its size if it were in a sheet or popover?" —
sizingOptions = .preferredContentSize: the hosting controller keepspreferredContentSizein step with its SwiftUI content, which a popover or a custom sheet uses. - "Why not
.taskinstead of.onAppear { Task { … } }?" —.taskis cancelled when the view disappears, so a slow load doesn't outlive the screen. It still runs on every appearance; whether that's right is a product decision. - "How do you migrate the rest of this screen?" — Screen by screen, with UIKit still doing the navigation, until a whole flow is SwiftUI; then move that flow's navigation to a
NavigationStack. Share state through view models both sides can observe. - "Would you UI-test it?" — One smoke UI test that opens the screen, pushes Orders and comes back, and asserts one header exists, is cheap insurance for key 2. Not more.