Skip to content
C.W.K.
Stream
Lesson 01 of 06 · published

Four Ways to Hold a Mac App Together

~15 min · appkit-swiftui, architecture, swiftui, appkit, menu-bar

Level 0Bundle Opener
0 XP0/81 lessons0/17 achievements
0/100 XP to next level100 XP to go0% complete
"SwiftUI or AppKit is the wrong question. Which one owns the window, and which one draws the leaves?"

The Family's Mac Apps Use All Four

Tutorials present SwiftUI and AppKit as a choice. Real Mac apps combine them, and the useful question is where the boundary sits. The family's Mac apps land on four distinct shapes:

  1. AppKit only. A dual-pane file workbench and a terminal are AppKit from top to bottom: NSTableView and NSCollectionView, NSSplitViewController, a custom tab strip, fine-grained keyboard handling. Nothing in them needed SwiftUI, and several things needed control SwiftUI does not expose.
  2. AppKit with SwiftUI leaves. A prose editor built on the AppKit text system, and several menu-bar utilities, keep AppKit as the application skeleton and host SwiftUI for the parts SwiftUI is great at — settings panes, forms, small panels — through NSHostingView or NSHostingController.
  3. SwiftUI app with AppKit hosts. A video player, a creative sidecar and an agent client use a SwiftUI App and windows, and wrap one heavy native view each — a video layer, a web view — in NSViewRepresentable.
  4. Pure SwiftUI. A small launcher is a MenuBarExtra, a Window and a thin app-delegate adaptor. For a surface that small, SwiftUI is simply less code.

How to Choose

  • Text is the product (an editor, a terminal) → AppKit owns the view. The text system's input methods, marked-text composition and undo are where SwiftUI's abstractions leak first.
  • Custom window chrome — tinted title bars, your own tab strip, nested split views with remembered sizes → AppKit owns the window.
  • Mostly standard windows around one special view → SwiftUI app, AppKit host for that view.
  • Forms, settings, lists of simple rows → SwiftUI, wherever it is hosted.
  • A menu-bar utility with a popover and a settings window → either pure SwiftUI with MenuBarExtra, or AppKit NSStatusItem when you need precise control over the item, global hotkeys and panels.

The shapes are not a ladder of maturity. The family's most complex app is AppKit-only and its smallest is pure SwiftUI; both are correct. What goes wrong is drifting between shapes without deciding: a SwiftUI view that owns an AppKit object's lifetime without anyone noticing, or an AppKit app that grows ad-hoc SwiftUI islands with their own copies of state. The rest of this track is about the seams.

Code

Shape 4 — a pure SwiftUI menu-bar launcher with one window·swift
import SwiftUI
import AppKit

@main
struct SparkLauncher: App {
    @NSApplicationDelegateAdaptor(LauncherDelegate.self) private var delegate
    @State private var model = LauncherModel()

    var body: some Scene {
        MenuBarExtra("Spark", systemImage: "flame") {
            LauncherMenu()
        }
        Window("Capture", id: "composer") {
            ComposerView(model: model)
        }
    }
}

struct LauncherMenu: View {
    @Environment(\.openWindow) private var openWindow

    var body: some View {
        Button("New Capture…") {
            openWindow(id: "composer")
            NSApp.activate()                         // an accessory app never comes forward on its own
        }
        .keyboardShortcut("n")
        Divider()
        Button("Quit Spark") { NSApplication.shared.terminate(nil) }
    }
}

@MainActor
@Observable
final class LauncherModel {
    var draft = ""
}

struct ComposerView: View {
    @Bindable var model: LauncherModel
    var body: some View {
        TextField("What happened?", text: $model.draft, axis: .vertical).padding()
    }
}

final class LauncherDelegate: NSObject, NSApplicationDelegate {
    func applicationDidFinishLaunching(_ notification: Notification) {
        NSApp.setActivationPolicy(.accessory)      // no Dock icon
    }
}

External links

Exercise

Write down which of the four shapes Spark's Mac app should take, and defend it in three sentences: what the app mostly shows, whether it needs custom window chrome or text editing, and whether it lives in the menu bar. Then build the pure SwiftUI launcher from the code block as a new executable target and run it — confirm there is no Dock icon and the menu-bar item opens the composer window.
Hint
The menu is its own view so it can read @Environment(\.openWindow); the App struct cannot. An accessory app is never frontmost by itself, so without NSApp.activate() the window opens behind whatever you were using. If a Dock icon appears, check that the activation policy is set to .accessory at launch.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.