AppKit vs SwiftUI on macOS: Layout, Performance, and Trade-offs

This is a technical comparison of the two primary frameworks for building native macOS user interfaces: AppKit (which uses NSView) and SwiftUI. It covers how each works, what the trade-offs are, how they interact with each other, and what the alternatives are if neither fits. It also uses a real-world image viewer application as a concrete reference point for some of the performance discussion.


The Two Frameworks

AppKit / NSView

AppKit has been the native macOS UI framework since the NeXT days. Everything in AppKit descends from NSView. A view is a rectangle on screen that is responsible for its own drawing, event handling, and layout.

AppKit is imperative. You create view objects, set properties on them, add them as subviews, and describe layout using Auto Layout constraints or manual frame math. When something changes, you update the view directly.

The framework gives you full control over the rendering pipeline. Views can be layer-backed (composited by the GPU via Core Animation) or draw directly into a graphics context using drawRect:. You can drop down to Metal if you need custom GPU rendering. You decide when things redraw, how memory is managed, and how updates are batched.

NSCollectionView and NSTableView are the primary tools for displaying large collections of items. Both implement cell reuse: a fixed pool of view objects is recycled as the user scrolls. At any given moment, only the visible rows or cells are live in memory. This is how Apple’s Photos app can display hundreds of thousands of images without running out of memory or dropping frames.

SwiftUI

SwiftUI was introduced in 2019. It is declarative: you describe what the UI should look like for a given state, and the framework figures out what to create, update, or remove. Views are value types (structs), not objects. A SwiftUI view is a description of UI, not UI itself.

When state changes, SwiftUI re-evaluates the view hierarchy and diffs it against the previous state to produce a minimal set of updates. The underlying UIKit (on iOS) or AppKit (on macOS) objects are managed by SwiftUI internally. You do not interact with them directly in most cases.

LazyVGrid and LazyHGrid provide lazy loading for grid layouts – they only create views for items near the visible area. List on macOS is backed by NSTableView internally, which is why it tends to perform better than a ScrollView + LazyVStack for long lists.


Fundamental Differences

  AppKit SwiftUI
Paradigm Imperative Declarative
View type Reference (class) Value (struct)
Layout Auto Layout / manual frames Compositional layout system
Cell reuse Explicit, manual Handled internally (Lazy variants)
State management Manual Reactive (@State, @ObservableObject)
Rendering control Full Limited
Interoperability Via NSViewRepresentable Via NSHostingView
Introduced 1989 (NeXT) 2019

How Layout Works in AppKit

AppKit uses Auto Layout: a constraint solver that computes frames from a set of linear equations. Alternatively, you set frames directly. Layout passes run top-down: a parent view calls layoutSubviews on itself, positions children, and the process recurses. This is predictable and fast when constraints are well-formed. It is slow when constraints are ambiguous or when layout passes are triggered unnecessarily.

How Layout Works in SwiftUI

SwiftUI uses a proposal-response model. A parent proposes a size to each child. The child reports back the size it actually needs. The parent uses that to position the child. This is simpler to reason about than Auto Layout for the common case but provides less direct control.

The cost is in the diffing. Every state change triggers a re-evaluation of the affected view subtree. SwiftUI tries to short-circuit this by comparing view types and their identifiers, but the process has overhead that grows with the complexity and depth of the hierarchy.


Performance Characteristics

Large Collections

AppKit’s NSCollectionView with a diffable data source is the right tool for displaying thousands of items. Cell reuse keeps memory flat. Batch updates apply animated transitions efficiently. The collection view calls your data source only for visible items.

SwiftUI’s LazyVGrid improves on Grid by not creating views outside the visible area, but it does not implement cell reuse in the same way. SwiftUI view structs are cheap to create (they are value types), but the corresponding AppKit backing views are not. Each cell that comes into view requires allocating and configuring a new NSView. At large scale – tens of thousands of items – this produces noticeable stuttering during fast scrolling that NSCollectionView does not.

For moderate collection sizes (a few hundred to a few thousand items) LazyVGrid is usually adequate.

Main Thread Constraints

Both frameworks run UI on the main thread. The difference is that AppKit, being imperative, makes it easier to control what happens on the main thread and when. In SwiftUI, the body property of a view – which may do non-trivial work – is called on the main thread every time state changes. Putting expensive work in body blocks rendering.

The correct pattern in SwiftUI is to push any work that is not pure UI construction into a background task, then publish results to state on the main actor. This requires discipline because the framework does not enforce it.

Memory

AppKit views are objects. A complex view hierarchy with many subviews consumes proportional memory, regardless of visibility. The cell reuse pattern in NSTableView and NSCollectionView exists specifically to bound memory use for large collections.

SwiftUI view structs are stack-allocated value types. They are cheap to instantiate. The underlying AppKit views that SwiftUI manages internally are still objects, but SwiftUI controls their lifecycle. In practice, LazyVGrid does release backing views that scroll far out of the visible area, but the recycling is less aggressive than AppKit’s pool-based approach.


A Concrete Example: The Image Viewer

The image viewer application referenced in this post (source on GitHub) was built entirely in SwiftUI. The gallery view uses LazyVGrid with async thumbnail loading and an LRU cache. The metadata panel parses image metadata and displays it in a scrollable list.

Two specific performance situations came up during development that illustrate SwiftUI’s constraints.

Thumbnail grid at scale. For folders with several thousand images, scrolling at high speed in the LazyVGrid produces occasional frame drops. An NSCollectionView implementation would not have this problem because of cell reuse. For the expected use case – browsing a shoot of a few hundred images – LazyVGrid is fast enough and the simplicity of the SwiftUI implementation is worth it. The trade-off is acceptable at this scale and not acceptable at the scale of a full photo library.

Metadata panel blocking. The metadata panel parses ComfyUI workflow JSON embedded in image metadata. Some of that JSON is large. The initial implementation called the parser synchronously in the view’s body-derived computed property. This blocked the main thread during modal presentation, causing a visible delay before the sheet appeared.

The fix was straightforward: move the parse into a Task.detached(priority: .userInitiated) block, display a placeholder immediately, and update state when the parse completes. The modal now opens in under a frame. The point is that SwiftUI makes this kind of mistake easy to make and requires explicit effort to fix. AppKit’s imperative model makes the threading boundary more obvious because you are writing the update code yourself.


Mixing the Two

AppKit and SwiftUI interoperate. You can embed an AppKit view in a SwiftUI hierarchy using NSViewRepresentable. You can host a SwiftUI view inside an AppKit view hierarchy using NSHostingView. Both are supported and commonly used.

The image viewer application does this in several places. The zoom and pan in the full-image view uses an NSEvent local monitor for trackpad pinch gestures because SwiftUI does not expose that event directly. The window title bar updates go through NSWindow accessed via an NSViewRepresentable helper because SwiftUI has no API for setting the window title imperatively.

This is the practical reality of SwiftUI on macOS: you build most things in SwiftUI and reach into AppKit when the framework does not cover what you need.


Other Options

Catalyst

Mac Catalyst lets you compile a UIKit-based iOS app for macOS. The result runs on macOS but is not a native Mac application in the sense that AppKit applications are. The UI conventions, scrolling behavior, and window management all reflect iOS origins. It is useful for bringing an existing iOS app to the Mac without rewriting it. It is not a good starting point for a Mac-first application.

Flutter

Flutter is a cross-platform UI framework from Google that uses its own rendering engine (Skia, or Impeller on newer targets) rather than the platform’s native views. A Flutter macOS app does not use AppKit views at all – it draws everything into a Metal surface itself. This gives consistent cross-platform rendering but means the application does not integrate naturally with macOS accessibility, system themes, or platform conventions. Performance for static content is generally good. Performance for large scrolling lists depends on Flutter’s own recycling implementation, which is mature.

Qt

Qt is a C++ framework with macOS support. It has its own layout system and rendering pipeline, similar to Flutter in that it does not use native views by default (though it has options for native widget integration). Qt is a reasonable choice for cross-platform desktop applications, particularly when the team is already writing C++. It is not commonly used for macOS-first development.

Electron

Electron hosts a Chromium browser engine and a Node.js runtime. UI is built with web technologies. It is not a native macOS framework in any meaningful sense. Memory use is high relative to native alternatives. It is not relevant to this discussion except to note that VS Code, Slack, and many other widely-used desktop applications are built on it, which indicates that the performance ceiling is higher than the criticism of Electron typically implies.


Summary

AppKit gives you direct control over the rendering pipeline, explicit cell reuse for large collections, and a mature set of controls with decades of macOS-specific behavior built in. It is verbose and requires more code to do the same thing SwiftUI does declaratively.

SwiftUI is faster to write, easier to maintain, and handles state synchronization automatically. It works well for moderate-scale UIs. It has real performance limits for very large collections and requires explicit threading discipline to avoid blocking the main thread.

For a new macOS application that is not displaying a library of 100,000 items, SwiftUI is the reasonable default. For something operating at that scale – like Apple’s own Photos app – AppKit’s NSCollectionView with cell reuse and async prefetching is the right tool.

The two frameworks are not mutually exclusive. Using SwiftUI for the majority of an application and AppKit for the pieces where performance or platform integration requires it is a normal and supported approach.


AI was used to assist in the research and writing of this article. Technical claims reflect knowledge current as of August 2025 and may not account for changes introduced in later macOS or Swift releases.

Looking for a senior developer? I'm open to new opportunities (opens in a new tab) or send an email .