← Back to list

The UIHostingController Looked Like Two Lines: Migrating a Banking App from UIKit to SwiftUI…

Four bugs that live on the seam between UIKit and SwiftUI and what each one taught me about hosting one inside the other.

Chukwuebuka Ezepue · 2026-06-26 08:09 · 0 claps · 10.3 min read
#swift #ios-app-development #programming #uikit #software-development
Open on Medium ↗
Wiki topics: ECO · Economy · General 💻 · Programming 📱 · Mobile Development

The UIHostingController Looked Like Two Lines: Migrating a Banking App from UIKit to SwiftUI Without a Rewrite

Four bugs that live on the seam between UIKit and SwiftUI and what each one taught me about hosting one inside the other.

I spend my days modernizing a banking app that millions of people use to move real money. It is a large, mature codebase: years of UIKit, hundreds of screens, written long before SwiftUI was a safe bet for production. We are migrating it to SwiftUI one screen at a time, with no big-bang rewrite, because you do not stop the world to re-plumb an app people are actively paying their electricity bills with.

The tool that makes incremental migration possible is UIHostingController, the box that lets a SwiftUI view live inside a UIKit navigation stack. The first time you reach for it, it looks almost insultingly simple:

let host = UIHostingController(rootView: TransferView(viewModel: viewModel))
navigationController.pushViewController(host, animated: true)

Two lines. Your SwiftUI screen slides in, animated, sitting in the same navigation stack as every UIKit screen around it. It feels like a cheat code.

It is not a cheat code. A UIHostingController is a seam between two worlds that disagree about ownership, lifecycle, and navigation, and the interesting bugs live exactly on that seam. This is the story of four of them and how chasing them turned those two naive lines into a small, deliberate piece of infrastructure that the rest of the team now pushes every SwiftUI screen through.

The plan: change the view, not the navigation

One rule kept the migration from turning into chaos: navigation ownership never moved.

Our app uses the Coordinator pattern. A coordinator is a plain object that owns a navigation stack and decides what gets pushed, presented, or popped. View controllers never push each other directly. They ask their coordinator to do it. This matters for a migration, because it means the navigation layer does not care whether a screen is UIKit or SwiftUI. It just pushes a view controller.

Jargon check. A coordinator is an object that owns navigation flow so that screens do not need to know about each other. Screen A does not present Screen B; it tells the coordinator “I am done,” and the coordinator decides what is next. It keeps navigation logic in one place instead of smeared across every screen.

So the migration shape was to keep the coordinator, keep the navigation stack, and swap the pushed view controller from a UIKit UIViewController to a UIHostingController wrapping a SwiftUI view. The coordinator builds the screen's view model, injects itself for navigation, and pushes the host:

func openTransfer() {
    let viewModel = TransferViewModel(coordinator: self)
    let host = UIHostingController(rootView: TransferView(viewModel: viewModel))
    navigationController.pushViewController(host, animated: true)
}

This compiled, ran, and demoed beautifully. Then I started actually using the screens, and the seam started showing.

Bug #1: The screen that forgot its data

The first SwiftUI screens worked on the simulator and broke on devices, intermittently, in the worst possible way: a screen would load, show its data for a moment, and then go blank or stop responding to updates. No crash. No error. The view model had simply stopped existing.

To understand why, you have to know how SwiftUI decides who owns a view model, and there are two answers:

  • @StateObject means the view owns the object. SwiftUI creates it once and keeps it alive for the whole lifetime of the view, across re-renders.
  • @ObservedObject means someone else owns the object. The view watches it for changes but does not keep it alive. If nothing else holds a strong reference, it is deallocated.

In a pure SwiftUI app, you reach for @StateObject and never think about this. But in the coordinator bridge, the view cannot own the view model, because the coordinator has to create it first to inject the navigation dependency. By the time TransferView receives the view model, it already exists. That rules out @StateObject, so the view holds it as @ObservedObject

struct TransferView: View {
    @ObservedObject var viewModel: TransferViewModel
    // ...
}

And now the question “who keeps this view model alive?” has no good answer. The coordinator created it as a local let and moved on. The view only observes it. So the view model's lifetime depends on whatever the local reference graph happens to look like, which is exactly the kind of thing that works on a fast simulator and fails on a real device under memory pressure.

The fix is to make the thing that owns the screen also own the view model, because their lifetimes are identical: the view model should live exactly as long as its screen is on the navigation stack. The host controller is that thing, so I taught it to hold a strong reference:

final class RetainingHostingController<Content: View>: UIHostingController<Content> {
    private let retained: AnyObject?   // AnyObject, not Any: you can only hold a strong reference to a class

    init(rootView: Content, retaining: AnyObject? = nil) {
        self.retained = retaining
        super.init(rootView: rootView)
    }

    @available(*, unavailable)
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

The push site passes the view model in twice: once for the view to observe, once for the host to own.

let viewModel = TransferViewModel(coordinator: self)
let host = RetainingHostingController(rootView: TransferView(viewModel: viewModel),
                                      retaining: viewModel)
navigationController.pushViewController(host, animated: true)

Now the view model’s lifetime is tied to the navigation controller’s retention of the host. Push the screen, the view model lives. Pop it, both go away together. The blank screens stopped.

The lesson: in a UIKit-to-SwiftUI bridge, @StateObject is usually off the table, because your view model is born in the coordinator, not the view. The moment you fall back to @ObservedObject, ownership becomes your problem. Pin the view model's lifetime to the host's.

Bug #2: The back button that gave the migration away

With the screens staying alive, the next problem was cosmetic until you realize cosmetics are trust in a banking app. Users should never be able to tell which screens are “the new ones.” But they could, instantly, because of the back button.

Every UIKit screen in the app inherits from a BaseViewController that installs a specific back button: a custom chevron image, no "Back" text, and the previous screen's title suppressed so the bar stays clean. A freshly pushed UIHostingController ignores all of that and shows the system default: a blue chevron with the previous screen's title trailing it. Put a SwiftUI screen between two UIKit screens, and the back button visibly changed mid-flow.

The fix is to make the host install the same chrome BaseViewController does. The interesting part is the detail work, because a back button has more states than you remember until you handle them:

override func viewWillAppear(_ animated: Bool) {
    super.viewWillAppear(animated)
    installBackButtonIfNotRoot()
}

override func viewWillDisappear(_ animated: Bool) {
    super.viewWillDisappear(animated)
    // Empty title so the NEXT screen's back button shows only the chevron, no trailing label.
    navigationItem.backBarButtonItem = UIBarButtonItem(title: "", style: .plain, target: nil, action: nil)
}

private func installBackButtonIfNotRoot() {
    let isRoot = navigationController?.viewControllers.first === self
    guard !isRoot else {
        // The first screen in a stack has nowhere to go back to.
        navigationItem.hidesBackButton = false
        navigationItem.leftBarButtonItem = nil
        return
    }
    navigationItem.hidesBackButton = true
    navigationItem.leftBarButtonItem = UIBarButtonItem(image: backChevron.withRenderingMode(.alwaysOriginal),
                                                       primaryAction: UIAction { [weak self] _ in
        self?.handleBack()
    })
}

Two things in there cost me a debugging session each. The first is the root check: if the host is the first screen in the stack, it must not show a back button at all, or you get a chevron that goes nowhere. The second is the empty backBarButtonItem in viewWillDisappear, which is how you stop the next screen from rendering this screen's title beside its back chevron. That is a UIKit quirk: the back button's label is owned by the previous screen, not the one displaying it.

There is one honest caveat I left as a comment in the real code. On the newest iOS, the system back button picks up the platform’s new material automatically, and a hand-rolled leftBarButtonItem does not. The correct long-term move is to drop the custom button and use backButtonDisplayMode = .minimal, which gives you the chevron-only look natively. I am keeping the custom button only until the whole app adopts the new design language, and the comment says so, because the next engineer deserves to know it is a deliberate stopgap and not an oversight.

The lesson: to keep a migration invisible, you have to copy the old chrome down to its quirks, not just its happy path. The sharpest one here: a back button’s label belongs to the departing screen, not the arriving one. Miss a detail like that and the seam shows.

Bug #3: The lifecycle event that lied

The third bug was data going stale. A SwiftUI screen would load correctly the first time, but if you navigated forward and then came back to it, it would not refresh. The balance, the transaction list, whatever it showed was frozen at its first render.

The cause is a trap that catches almost everyone bridging the two frameworks: SwiftUI’s .onAppear and UIKit's viewWillAppear are not the same event and, inside a hosted screen, .onAppear cannot be trusted to fire when you expect.

In a pure SwiftUI navigation stack, .onAppear runs each time the view appears. But a UIHostingController pushed inside a UIKit navigation controller ties SwiftUI's appearance tracking to the host's appearance transitions, and the two do not line up on every move. The most common symptom is the one I hit: .onAppear fires on the first push, but not when you pop back to the screen. So any "refresh every time this screen becomes visible" logic silently stops running.

UIKit’s viewWillAppear, on the other hand, fires reliably on every transition, including pop-back, because that is the contract UIKit has kept since 2008. So the fix is to stop asking SwiftUI when the screen appeared and start asking the host. The view model exposes an onAppear() method, and the host calls it from the UIKit lifecycle:

final class TransferHostingController: UIHostingController<TransferView> {
    private let viewModel: TransferViewModel

    init(viewModel: TransferViewModel) {
        self.viewModel = viewModel
        super.init(rootView: TransferView(viewModel: viewModel))
    }

    @available(*, unavailable)
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        // Reliable on every push, pop-back, and tab reselection. Unlike .onAppear.
        viewModel.onAppear()
    }
}

One honest simplification: I am presenting these as separate host classes to keep each bug in focus, but in the real codebase they are one. The retention from Bug #1, this lifecycle hook, and the back button from Bug #2 all live on a single RetainingHostingController. Exposed as a reusable hook on that shared host, the lifecycle bridge looks like this:

var onAppearHook: (() -> Void)?

override func viewDidAppear(_ animated: Bool) {
    super.viewDidAppear(animated)
    onAppearHook?()
}

The lesson: .onAppear is a SwiftUI convenience, not a lifecycle guarantee. When a SwiftUI view is hosted in UIKit, the host's viewWillAppear and viewDidAppear are the source of truth for "the screen is on screen now." Drive anything important, a refresh, an analytics event, an auto-navigation check, from there.

Bug #4: The double-pop

The last one is my favorite, because it never reached a user. A reviewer caught it, and it taught me the most.

By this point the bridge had grown a helper so nobody had to assemble a host by hand.

func pushSwiftUIView<T: View>(_ view: T,
                             retaining: AnyObject? = nil,
                             onBack: (() -> Void)? = nil,
                             animated: Bool = true) {
    let host = RetainingHostingController(rootView: view, retaining: retaining, onBack: onBack)
    pushViewController(host, animated: animated)
}

The onBack closure was meant for side effects: log an analytics event, clean up some state, and then let the host run the normal pop. So the host did this:

private func handleBack() {
    onBack?()                                       // side effects
    navigationController?.popViewController(animated: true)   // then pop
}

Reasonable. Except some screens did not want the normal pop. A few flows needed to jump several screens back at once, so their coordinators used onBack to do the navigation themselves:

navigationController.pushSwiftUIView(offerView,
                                     onBack: { [weak coordinator] in
    coordinator?.popToRoot()   // custom navigation
})

Read what happens on a back tap. onBack runs and pops the stack all the way to the root. Then handleBack continues to its next line and calls popViewController again, on a stack that no longer looks anything like it did a millisecond ago. That is a double-pop: one navigation action firing two pops against a mutated stack. In review, my reviewer flagged it with one sentence: "who owns the pop here?"

That question is the whole bug. The original design let onBack perform navigation but also ran the default pop, so two parties were trying to own the same back action. The fix is to make ownership explicit by splitting one ambiguous hook into two clear ones:

private func handleBack() {
    if let onCustomBack {
        onCustomBack()      // the caller fully owns navigation; we do NOT pop
        return
    }
    onBack?()               // side effects only; the default pop still runs
    navigationController?.popViewController(animated: true)
}

onBack is now strictly for side effects, and the host always does the pop. onCustomBack means the caller takes the wheel, and the host does nothing afterward. If a caller sets both, onCustomBack wins, so there is no configuration that can pop twice. The screens doing custom navigation moved to onCustomBack, and the double-pop became impossible to express.

The lesson: any time a callback is allowed to perform navigation, exactly one piece of code must own the pop. If a hook can either trigger a side effect or replace the navigation, those are two different hooks, not one with a comment hoping callers behave.

The bigger picture: a host is two lifecycles, not one

Every one of these bugs is the same bug wearing a different hat. A UIHostingController looks like a single object, but it is really a negotiated truce between two frameworks:

  • UIKit owns memory and lifecycle. That is why the view model has to be retained on the UIKit side, and why viewWillAppear is the reliable appearance signal.
  • SwiftUI owns rendering and state. That is why @ObservedObject works for updates but not for ownership.
  • Navigation is shared, which is why the back button and the pop need an explicit owner.

Once you see the seam that way, the bugs stop being surprises and start being a checklist.

The checklist I wish someone had handed me

If you are about to host SwiftUI inside a UIKit app, here is the distilled version:

  1. Keep navigation ownership where it is. If you use coordinators, keep using them. The view layer can change without the navigation layer noticing.
  2. Own your view model’s lifetime explicitly. When the coordinator creates the view model, @StateObject will not save you. Retain it on the host so its life matches the screen's.
  3. Mirror your existing navigation chrome. Users should not be able to tell which screens are SwiftUI. Match the back button, including the root case and the previous-screen title.
  4. Do not trust .onAppear in a hosted view. Drive refreshes, analytics, and lifecycle checks from the host's viewWillAppear or viewDidAppear.
  5. Give the back action one owner. Side-effect hooks and navigation-replacing hooks are different things. Never let two code paths pop the same screen.
  6. Leave notes for the platform’s future. When you hand-roll something the OS will eventually do better, say so in a comment so the stopgap does not calcify into folklore.

None of this is in the UIHostingController documentation, because none of it is wrong with UIHostingController. It is what happens when two frameworks with different rules have to share one screen, and you are the one standing on the seam. Migrate incrementally, respect both sets of rules, and your users will never know there was a seam at all. Which, in an app people trust with their money, is exactly the point.

Thanks for reading. If you have migrated a UIKit app to SwiftUI and hit a seam I did not cover, I would love to hear about it in the comments.


메타데이터
post_id
e9ec0bc1bdfc
slug
the-uihostingcontroller-looked-like-two-lines-migrating-a-banking-app-from-uikit-to-swiftui-e9ec0bc1bdfc
url
https://medium.com/@ebukaezepue/the-uihostingcontroller-looked-like-two-lines-migrating-a-banking-app-from-uikit-to-swiftui-e9ec0bc1bdfc
canonical_url
https://medium.com/@ebukaezepue/the-uihostingcontroller-looked-like-two-lines-migrating-a-banking-app-from-uikit-to-swiftui-e9ec0bc1bdfc
author_url
https://medium.com/@ebukaezepue
status
ok
fetched_at
2026-07-09 17:12:49