Home Tech

A SwiftUI Migration Exposed One Team’s UIKit Assumptions in a Single Patch

Y
Yusuke Tanaka| Jul 16, 2026
popul.kmoonnews.com · Tech team
A SwiftUI Migration Exposed One Team’s UIKit Assumptions in a Single Patch

In early 2026, a mid-size iOS team at a logistics company rolled out what looked like a routine migration patch. The goal was straightforward: convert a handful of profile screens from UIKit to SwiftUI, reduce boilerplate, and align with Apple's long-term direction. The patch touched roughly 400 lines across 30 files. No new features. No user-facing changes. By the end of the release cycle, the team had spent three days reverting, rethinking, and rewriting chunks of the app's layout engine. The single patch that was supposed to simplify the codebase had instead exposed a decade of UIKit assumptions that SwiftUI simply does not honor.

The Single Patch That Broke a Dozen Screens

The triggering change was almost invisible in the diff: a .padding() modifier added to a List row. In UIKit, padding was handled by the cell's content view insets, and the team's layout code had relied on those insets being constant after the first layout pass. SwiftUI's layout engine, however, re-evaluates the entire view tree on any state change, and the padding modifier shifted the row's geometry in ways that cascaded outward.

Within hours of the beta build landing on test devices, bug reports came in: overlapping text, clipped buttons, a table view that refused to scroll. The team's CI dashboard lit up with failed snapshot tests. One developer described the scene as "a dozen screens that all looked like someone had shuffled their constraints in a blender." The team initially suspected a SwiftUI bug, but the real culprit was deeper: their UIKit code had never been explicit about frame ownership.

The patch had been reviewed and approved. It passed all unit tests. The UI tests, which ran on simulators with fixed screen sizes, showed no issues. On physical devices with different safe areas and dynamic type settings, the layout fell apart. The team spent the first day bisecting commits and reverting. The second day was spent understanding why a .padding() broke so much. By the third day, they had a list of six implicit assumptions that had silently governed their UIKit code for years.

The incident became a turning point. Instead of continuing the migration screen by screen, the team paused to document what they now call the "UIKit debt ledger." That ledger is the spine of this story.

Why UIKit's Coordinate System Lied to Us

UIKit's coordinate system is point-based, with origins that account for status bars, navigation bars, and tab bars through safe area insets. These insets are typically fetched once in viewDidLayoutSubviews and cached. In the team's codebase, dozens of custom views computed their frames by reading safeAreaInsets at the start of a layout pass and then storing the result. That assumption—that insets are stable between layout passes—is false in SwiftUI.

SwiftUI's layout engine is reactive. Every state change triggers a re-evaluation of the entire view body, which means safeAreaInsets can shift mid-update if a parent view's geometry changes. The old code had a method called recalculateFrames() that was called manually from viewDidLayoutSubviews and from a few KVO observers. In SwiftUI, that manual invalidation became a race condition: the SwiftUI runtime would sometimes call body before the cached frames were updated, producing stale bounds.

The .padding() modifier that started the cascade was innocent by itself. It added 8 points of horizontal padding to a row that previously relied on the cell's layoutMargins. But because the row was inside a List that computed its own row heights, the extra padding changed the row's intrinsic content size, which forced the list to recalculate all row heights, which triggered new layout passes on sibling rows that also had cached frames. The result was a chain reaction of geometry mismatches.

One developer described the debugging process as "following a trail of breadcrumbs that kept moving." The team eventually wrote a SwiftUI view modifier that logged every layout pass with the view's bounds. They found that a single row could be laid out three to four times during a single state update, each time with slightly different bounds until the engine converged. UIKit's one-shot layout pass had hidden that instability for years.

The Hidden Cost of UIKit's View Controller Lifecycle

The team's UIKit code relied heavily on the view controller lifecycle. Network calls were triggered in viewDidLoad because, as one comment in the codebase read, "by the time this runs, the view is ready." In SwiftUI, onAppear fires when the view is about to be rendered, but there is no guarantee that the view's geometry is final. The team saw cases where onAppear fired before the List had assigned frames to its rows, causing data-dependent views to render with zero width.

State restoration was another casualty. The UIKit code saved scroll positions and selection states in encodeRestorableState, which runs after the view hierarchy is fully loaded. SwiftUI's SceneStorage and AppStorage are tied to the view's identity, not the view controller's lifecycle. The team found that restoring a scroll position required knowing the list's content size, which wasn't available until after the first render pass. They had to add a .onAppear that dispatched an async task to read the content size after the run loop completed.

The team also discovered that SwiftUI's lazy loading of views—particularly in List and LazyVStack—meant that views that were offscreen never called onAppear. In UIKit, all cells were instantiated when the table view loaded, even if they were not visible. The old code used that behavior to pre-fetch data for all rows. In SwiftUI, the team had to move data fetching to the view model and trigger it based on the row's index, not its appearance.

One senior engineer on the team summed it up: "We thought we were just rewriting views. We ended up rewriting the entire data flow." The migration forced them to separate data loading from view presentation, which was a net improvement, but it came at the cost of a full sprint's worth of refactoring.

What the Patch Actually Changed (and Didn't)

The final patch that shipped after the three-day revert was smaller than the original. It replaced manual frame calculations with SwiftUI's layout priorities—.layoutPriority(1) on the primary label, .fixedSize() on the button. It removed all calls to updateConstraints() and setNeedsLayout(). The team kept UIKit for one complex table that displayed real-time tracking data, because SwiftUI's List performance degraded when rows contained multiple TimelineView updates.

The patch size was still roughly 400 lines, but those lines were concentrated in fewer files. The team had learned to keep SwiftUI views small and composable. They extracted a ProfileRow view that used @ViewBuilder to compose optional elements. They replaced the old UITableViewDataSource with a @Observable view model that published an array of row configurations. The diff was cleaner, but the real change was invisible: the team had stopped fighting the framework.

Users saw no difference. The screens looked identical. The team measured rendering time using Xcode's Instruments and found that SwiftUI views were, on average, 12% slower on initial render but faster on subsequent updates because the diffing engine skipped unchanged views. The trade-off was acceptable, but the team noted that the performance profile depended heavily on view complexity. Simple lists benefited; complex forms with many @State variables did not.

The team also kept UIKit for the app's onboarding flow, which used a UIPageViewController. SwiftUI's TabView with .tabViewStyle(.page) worked, but the team could not replicate the exact transition timing. They decided that the migration's goal was not purity but maintainability. As the lead iOS developer put it, "We're not rewriting the app. We're replacing the parts that hurt."

Three Assumptions That Survived the Migration

Even after the patch shipped, three assumptions from UIKit persisted in the team's mental model. The first was that views own their data. In UIKit, a UITableViewCell holds a reference to a model object, and the cell configures itself from that model. SwiftUI encourages a unidirectional data flow where the view is a function of state, not an owner of data. The team's old habit of passing a model object directly to a view and letting it mutate internal state caused bugs when SwiftUI recreated the view and lost the mutation.

The second assumption was that layout is a one-shot pass. UIKit calls layoutSubviews once per run loop iteration, and unless you call setNeedsLayout, the frames stay put. SwiftUI re-renders on every state change, which means layout is continuous. The team saw this most acutely with animations: a state change that toggled a view's visibility would animate the transition by default, even when the team had not written any animation code. They had to add .animation(nil, value: condition) to suppress unwanted motion.

The third assumption was that animations are opt-in. In UIKit, UIView.animate is explicit. In SwiftUI, almost every state change is implicitly animated unless you wrap it in .transaction or disable animations. The team's first SwiftUI screen had a button that toggled a flag, and the entire screen's layout slid into place with a spring animation. The effect was disorienting. They had to audit every @State and @Binding to decide which changes should be animated and which should be instant.

The team documented these three assumptions as onboarding warnings for new hires. The document, titled "UIKit to SwiftUI: What You Need to Unlearn," became required reading for the iOS chapter. One junior developer later said it saved them from making the same mistakes. The document is now shared with other teams in the company that are considering migration.

What 2026 iOS Teams Should Learn from This Patch

The team's experience offers concrete advice for iOS teams in 2026. First, start migration with a single screen, not a feature module. A screen is a self-contained unit with its own data flow. A feature module often spans multiple screens and shared state, making it harder to isolate SwiftUI-specific bugs. The team's mistake was picking a feature module that included a list, a detail view, and a modal sheet. The interactions between those screens amplified the layout issues.

Second, instrument SwiftUI body recomputation counts in production. The team added a custom log that recorded how many times each view's body was called per second. They found that some views recomputed dozens of times per interaction, often because a parent view's @State was changing too broadly. They used .equatable() and extracted sub-views to reduce the recomputation rate. Without instrumentation, they would have shipped a sluggish app.

Third, write unit tests for layout, not just logic. The team wrote snapshot tests for every migrated screen at multiple dynamic type sizes and orientations. They also added tests that verified that views did not exceed a certain recomputation threshold. These tests caught regressions early and gave the team confidence to iterate quickly. They now run these tests on every pull request.

Fourth, expect to keep UIKit for performance-critical lists. SwiftUI's List and LazyVStack are fast for typical cases, but the team found that rows with multiple TimelineView updates or complex GeometryReader usage caused frame drops. They kept a single UITableView subclass for the tracking screen and wrapped it in UIViewRepresentable. The interop code was roughly 50 lines and gave them full control over cell reuse and prefetching.

Finally, budget 20% extra time for unexpected behavior differences. The team estimated the migration at two sprints. It took three. The extra sprint was spent on the layout cascade, state restoration, and animation audits. The team now adds a "SwiftUI learning tax" to any migration estimate. The tax is not a sign of failure—it is a recognition that SwiftUI is not a declarative wrapper on UIKit. It is a different runtime with its own rules.

The patch that exposed all of this was small. The lessons are not.

Trade-Offs and Counter-Arguments: When SwiftUI Wins

Despite the difficulties, the team acknowledged that SwiftUI brought real benefits. The most obvious was code reduction. The profile screens that were migrated shrank by roughly 40% in line count, from an average of around 200 lines per view controller to about 120 lines per SwiftUI view. The team attributed this to SwiftUI's composable modifiers and the elimination of delegate methods. However, they noted that the line count savings were offset by the need for additional view models and state management code. In the end, the total lines of code for the migrated feature module actually increased by about 10% because of the new @Observable classes and the UIViewRepresentable wrapper for the tracking table.

The team also found that SwiftUI's previews were a double-edged sword. On one hand, they enabled rapid iteration on individual views without running the full app. On the other hand, the previews often diverged from the runtime behavior. For example, a view that looked correct in the preview would render with incorrect spacing on a device because the preview didn't simulate dynamic type changes or safe area insets accurately. The team learned to treat previews as a rough guide, not a guarantee, and they always verified layouts on physical devices before merging.

Another counter-argument came from the team's Android counterparts, who had been using Jetpack Compose for over a year. They warned that the team might be over-correcting: just because SwiftUI is different doesn't mean it's worse. The Android team had faced similar issues with Compose's recomposition model, but they had learned to embrace it. The iOS team eventually agreed that the key was to stop thinking of SwiftUI as a UIKit replacement and start thinking of it as a separate paradigm with its own idioms. One developer said, "The moment we stopped asking 'how do we do this in SwiftUI the UIKit way?' and started asking 'what is the SwiftUI way?' the migration got easier."

The team also debated whether to migrate the entire app or leave UIKit islands. The pragmatic decision was to keep UIKit for screens that were rarely modified and had complex custom animations. For example, the app's map-based delivery tracking screen used a custom MKMapView subclass with animated annotations. The team estimated that rewriting it in SwiftUI would take at least a month and would likely introduce new bugs. They decided to leave it in UIKit indefinitely. The lesson: not every screen needs to be migrated. The best migration strategy is to convert screens that benefit from SwiftUI's data flow and leave the rest alone.

Finally, the team considered the long-term maintenance cost. SwiftUI's rapid evolution means that code written today may break with the next OS update. The team had already experienced a minor issue when iOS 18 changed the default behavior of List separators. In contrast, UIKit had been stable for years. The team decided to mitigate this by pinning their minimum deployment target to the latest major OS version and by avoiding experimental APIs. They also set up a quarterly review of SwiftUI usage to catch deprecations early. The trade-off was acceptable, but it added ongoing overhead that the team had not anticipated.

How do you feel about this?
Happy
Happy
39%
Love
Love
24%
Excited
Excited
29%
Sad
Sad
8%
Angry
Angry
0%
Feedback

Found a problem or have a suggestion? Let us know. You can leave your email for a follow-up.

Tech

One Inference Cluster's GPU Memory Layout Forced a Training Rewind on Every Resharding Event

One Inference Cluster's GPU Memory Layout Forced a Training Rewind on Every Resharding Event

An inference cluster's static GPU memory layout triggered a full training rewind on every resharding event. Here's how one team diagnosed the problem and built a tiered memory fix with lazy migration.

Insurance

An MGA’s Single Homeowners Parametric Paid Before the Adjuster Inspected

An MGA’s Single Homeowners Parametric Paid Before the Adjuster Inspected

A homeowners parametric policy paid out before an adjuster ever saw the property. This article traces the premium flow, reinsurance tower, and balance-sheet realities behind the MGA model.

Copyright 2019 - 2026 popul.kmoonnews.com