Leon . Kang
Back to articles
💻 Coding 2/25/2026 8 min read

Goodbye to #if os(macOS): A Journey of 'Pain and Rebirth' in SwiftUI Multi-platform Architecture

When Apple introduced SwiftUI, the invisible slogan "Learn once, apply anywhere" became the ultimate dream for many developers. For my own project, I thought the same way at first. After all, being able to run the same code on iOS, iPadOS, and macOS sounded like the ultimate efficiency booster.

However, as the project grew wildly, this seemingly perfect "cross-platform theory" gave me a harsh lesson in practice. Today, I'm recapping how this project evolved from an initial "one-pot" mess to a state of chaos, and finally to a hard-earned refactoring towards "fine-grained separation."

1. The Honeymoon Phase: Convenience Over Everything in One Target

When the project started, to chase launch speed and development ease, I made the most intuitive decision: placing iOS, iPadOS, and macOS under the exact same Target.

My reasoning was simple and blunt: since SwiftUI is cross-platform, for macOS, I could just check Mac Catalyst or Designed for iPad and let Mac reuse the iPad's layout and interaction logic, right?

The development experience was indeed "sweet" at first. Building a color list or a detail page and having it run on all platforms felt like I had mastered the magic of efficiency. But like all "get-rich-quick" architectures, it was just the calm before the storm. As I spent more time with the macOS version, inconsistencies started cropping up everywhere:

  • Awkwardly large amounts of empty space
  • Touch-simulated scrolling interactions that felt forced
  • Full-screen Pushes instead of multi-column navigation
  • Lack of true desktop Toolbar and keyboard shortcut support...

On Mac, it didn't look like a legitimate desktop app; it felt more like a clunky "magnified phone emulator."

2. Patching and Compromise: Pulling out the Shared Layer and the Start of a Nightmare

Realizing I couldn't treat Mac desktop users so dismissively, I decided to take action.

Architecturally, I made a rational initial adjustment: I created a separate macOS Target and pulled out non-UI code like database models (Data Model) and core business logic (Service) into a Shared layer (shared component library). This is the standard approach recommended by Apple.

But—and this was the decision I regret most—to "maximize code reuse," I cut corners in the view layer and kept both platforms sharing the same UI View files.

How did I solve layout differences? Simple: add conditional compilation wherever it felt off. I started using macros everywhere:

#if os(macOS)
// Add buttons for Mac, change fonts, use desktop-specific PickerStyle
.pickerStyle(.radioGroup)
#else
// iOS style
.pickerStyle(.segmented)
#endif

It was bearable when there were only a few instances, and I even felt clever for handling cross-platform differences with just a few macros.

3. Debt Explosion: The Fear of Being Dominated by if os(macOS)

As pages multiplied and features like color extraction, professional palette editing, and dynamic camera color picking grew more complex, these "shared UI files" turned into a swamp. The code was overgrown with #if os(macOS) vines.

This highly coupled "hybrid state code" led to several disastrous consequences:

  1. Readability Hit Rock Bottom: A View that should have been 100 clear lines was bloated to over 300 by conditional compilation. Reading it felt like being a compiler—constantly switching contexts and pruning branches. The mental burden was immense.
  2. Unstable UI, Butterfly Effect Everywhere: Modifying a layout in a shared file felt like clearing a minefield. Due to subtle rendering differences between iOS (UIKit-based) and macOS (AppKit-based) in SwiftUI, fixing one thing often broke another. Today I'd fix a padding on iOS, only for the Mac view to collapse mysteriously tomorrow.
  3. Efficiency in Reverse: The goal was to write less code, but late in the project, most of my time was wasted on layout compatibility, repeated troubleshooting, and fixing bugs. I was terrified even to add a small new component.

4. Drastic Measures: Physical Separation and Embracing the Native

Enough was enough. For the future maintainability of the project, I finally decided on a major architectural surgery: completely breaking the illusion of UI-level reuse and performing a total physical isolation.

The core strategy for refactoring was clear and firm:

  • Stick to Shared Core: Data layers, state management, and various Services remained in the Shared layer. Proven over time, this business logic is indeed highly consistent across both platforms.
  • Split the Views Completely: I "evicted" all hybrid View files. iOS and iPadOS kept the original View system, while for macOS, I created a dedicated set of Views prefixed with Mac (e.g., decoupling the messy PhotoPalettePickerView into a pure iOS version and a pure desktop MacPhotoPalettePickerView).
  • Embrace the Native Experience: After the split, the Mac pages were finally unshackled. I could freely use Hover states for mouse interactions, native Sidebars, and multi-window management without being restricted by iOS gesture-based logic.

Summary: If it Needs to be Written Twice, Don't Skimp

The phrase "Write once, run anywhere" is a beautiful dream for simple utility apps or pure logic. But once you deal with complex interactions, highly customized controls, and commercial-grade applications requiring a polished experience, forced reuse will only come back to haunt you.

For SwiftUI cross-platform development, "Learn once, write anywhere" should be the true guiding principle.

Moderate UI code redundancy is not a bad thing; it's the foundation for project stability, iterative growth, and providing users with the ultimate platform-native experience. Don't be afraid to split your code. If isolation is needed, do it decisively. It's not just a responsibility to your product—it's a favor to your future hairline.