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

Text, Splits and Windows: Where AppKit Still Wins

~17 min · appkit-swiftui, nstextview, nssplitview, window-chrome, drag-and-drop

Level 0Bundle Opener
0 XP0/81 lessons0/17 achievements
0/100 XP to next level100 XP to go0% complete
"Every click worked. Only drags were dead, and the window moved under the pointer."

Why the Big Mac Apps Stay in AppKit

The family's editor, terminal and file workbench are AppKit-owned for concrete reasons, not nostalgia. The text system (NSTextView and TextKit) carries input methods, marked-text composition, spell checking, services and accessibility that a from-scratch text surface does not. Split views, custom tab strips and title-bar accessories need control over layout and mouse tracking that SwiftUI does not expose. And drag and drop between apps is decided by flags on AppKit objects. The price is that AppKit's power comes with its own sharp edges. These are the ones that cost the family real sessions.

Mouse Tracking

  • A view in the title bar never sees mouseDragged. A tab strip placed in a title-bar accessory handled clicks, double-clicks and right-clicks, but drag-to-reorder never fired — the whole window slid instead. An NSView answers mouseDownCanMoveWindow with !isOpaque, so a plain transparent view says yes and the window move takes the event stream. Override it to return false, and call window.performDrag(with:) yourself on empty strip space to keep the title-bar feel. (Buttons never show this: NSControl already returns false.)
  • Rebuilding views mid-drag ends the drag. Reordering tabs by notifying the model on every swap made the parent rebuild the strip's arranged subviews, which removed the tracked view and silently ended its mouseDragged and mouseUp stream. Mutate only the strip's own order during the drag, and commit one model change on mouse-up.
  • Dragging files to other apps needs a non-local mask. Rows could be dropped inside the workbench but were dead over Finder and other apps. Table and collection views default to an empty source operation mask for non-local drags; one call per view fixes it.

Split Views

Nested NSSplitViews were the single largest source of layout bugs. A split whose parent sets its frame by hand does not get the autolayout-driven resize a normal split does, and a one-shot setPosition at the first non-zero height left one half filling the panel and the other invisible. Place the divider in layout() whenever the tracked height changes, record the user's fraction when they drag, and guard the programmatic call so it does not feed back. Give arranged subviews non-zero frames before activating constraints, verify during a real window resize rather than at launch, and check the unified log: a recovered constraint conflict is invisible on screen.

Windows and Tabs

Native window tabs make every tab its own NSWindow, cannot be coloured per tab, and stretch a tab accessory view to full width unless it pins its own size constraints. The terminal disallowed native tabbing and drew its own strip. A tab added to a group must join it (addTabbedWindow(_:ordered:)) before any code snapshots windows by tab group, or it is saved as a separate window.

Code

Title-bar drags, a hand-laid-out split, and drag-out to other apps·swift
import AppKit

final class TitleBarTabStrip: NSView {
    override var mouseDownCanMoveWindow: Bool { false }        // or the window steals the drag
    override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true }

    override func mouseDown(with event: NSEvent) {
        if tab(at: convert(event.locationInWindow, from: nil)) == nil {
            window?.performDrag(with: event)                   // empty strip still drags the window
        } else {
            beginTabDrag(event)
        }
    }
    private func tab(at point: NSPoint) -> Int? { nil }
    private func beginTabDrag(_ event: NSEvent) {}
}

final class SidePanelView: NSView, NSSplitViewDelegate {
    private let split = NSSplitView()
    private var upperFraction: CGFloat = 0.5
    private var lastHeight: CGFloat = 0
    private var applying = false

    override init(frame: NSRect) {
        super.init(frame: frame)
        split.isVertical = false
        split.delegate = self
        split.addArrangedSubview(NSView(frame: NSRect(x: 0, y: 0, width: 100, height: 100)))
        split.addArrangedSubview(NSView(frame: NSRect(x: 0, y: 0, width: 100, height: 100)))
        addSubview(split)
    }
    required init?(coder: NSCoder) { fatalError("init(coder:) is not used") }

    override func layout() {
        super.layout()
        split.frame = bounds
        guard abs(bounds.height - lastHeight) > 0.5, bounds.height > 0 else { return }
        lastHeight = bounds.height
        applying = true
        split.setPosition(bounds.height * upperFraction, ofDividerAt: 0)   // on EVERY height change
        applying = false
    }

    func splitViewDidResizeSubviews(_ notification: Notification) {
        guard !applying, bounds.height > 0, let upper = split.arrangedSubviews.first else { return }
        upperFraction = upper.frame.height / bounds.height                // a user drag moves the fraction
    }
}

@MainActor
func enableDragOut(from table: NSTableView) {
    table.setDraggingSourceOperationMask([.copy, .generic], forLocal: false)
}

External links

Exercise

Add a title-bar accessory to a Spark window containing a TitleBarTabStrip with two fake tabs drawn as rectangles. First leave out the mouseDownCanMoveWindow override and try dragging a tab; then add it and implement drag-to-reorder with a single model commit on mouse-up. Separately, embed SidePanelView in a window and resize the window slowly from very small to large — confirm both halves stay visible and a dragged divider keeps its proportion.
Hint
Use NSTitlebarAccessoryViewController with layoutAttribute = .bottom to place the strip. Watch Console.app (or log stream --predicate 'subsystem == "com.apple.AppKit"') for constraint warnings while resizing; a layout that looks right can still be recovering from a conflict.

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.