SwiftUI Was Supposed to Be Simpler and Faster. It Turned Out as Usual.
SwiftUI was supposed to be simpler. After Instruments and a Release trace in RocketTrace, I moved hora's Mac week, day and month grids to AppKit, and the iOS grids to UIKit.

In 24 seconds of a Release build, RocketTrace counted 80,630 SwiftUI updates across 310 views.
Apple just put an official Xcode plugin in Claude Code. Build, run and test. Render SwiftUI previews. Drive simulators and a real iPhone. I already build hora natively, with agents. The last excuse for shipping Electron on a Mac just died.
I expected the hard problems to show up in the web. The biggest ones showed up in a native desktop calendar.
I expected the web to be the hard part
WWDC sells SwiftUI as simplicity, and as one look and feel across Apple's platforms. I bought that. For a lot of screens, it really is simpler. You save time. Then the last stretch of a real app shows up, and you pay it back.
hora is a native calendar that works with Google Calendar. A calendar is not a water counter. A busy week is tens or hundreds of event tiles on a timeline, with weeks of blocks held in memory in both directions. Every view change, every mouse move, every toolbar action can set off an avalanche of recalculations, mostly on the main thread. Those off-screen blocks get recalculated too.
Patching this with async and concurrency works in theory. In my experience, in an interface with that many elements, SwiftUI can do hundreds of thousands of recalculations a second.
The moment that made the cost obvious was hover. A hover effect across tens or hundreds of event tiles on the axis is very expensive. I wrote about the earlier profiling work in SwiftUI Profiling with Instruments. The short version is the one I keep repeating to myself: SwiftUI can be expensive.
The 80% of time you save on SwiftUI, you give back in the last 20%. And that last 20% takes 200% more time to patch.
What the SwiftUI-era traces actually show
These figures are from the period when the production grid was still SwiftUI. They are not before-and-after numbers for the AppKit and UIKit rewrite that followed.
September, Debug, still SwiftUI
In September we recorded Debug Instruments traces while working on background render snapshots. The hang threshold was 250 ms. The notes compare one trace with the next, and they say it plainly: this is not a controlled A/B, and we do not compute a percentage improvement.
In that run4 comparison, hangs went from 22 to 16, and total hang time from 11.68 s to 6.04 s. The longest hang went from 1420 ms to 596 ms. Hitches went the other way: 214 to 271. Main-thread CPU went from 25.12 s to 19.53 s. The worst hang in the later trace was 595.676 ms. AG::Graph::UpdateStack::update() accounted for 472.7 ms of it. ForEach paths were 158.0 ms, opacity transitions 131.3 ms, and SQL about 0.5 ms. Across the whole recording, the SwiftUI graph took 10.13 s of main-thread CPU.
A later plan trace, run6, lasted 39.114 s and recorded 11 hangs totalling 4796.62 ms. The longest was 761.64 ms, in WeekDayColumn / WeekEventBlock. AG::Graph::UpdateStack::update spent 10,613.8 ms on the main thread.
In run5, the worst hang was 657.672 ms, of which the graph update was 518.1 ms. Layout View Hierarchy reported 91.22 ms in that hang, and StyledTextContentView alone cost 32.45 ms. A full export of the layout table passed 1.9 GB after about 3.38 s of recording, and we abandoned it. In run4, the SwiftUI Updates export passed 8 GB.
That matches my own experience of Instruments on a machine with 16 GB of RAM: a 50-second profile could take about an hour to save, and the cycle lasted weeks.
A 1:43 Instruments recording of hora

This is hora in Instruments, Run 3, recorded for 1:43, with Time Profiler, Hangs, Hitches and os_signpost on the track list. The call tree's total weight is 53.93 s, spread across many hora threads. NSURLConnectionLoader is 5.21 s. NSEventThread is 3.64 s. The main thread is 2.16 s. io.sentry.app-hang-tracker is 2.02 s. Hitches show "No Data" here, so I am not reading hitch counts out of it.
A Release trace, the morning the grids still belonged to SwiftUI
RocketTrace is the other half of that loop. In my experience it was about twice as fast as Instruments. This overview is why I kept using it.
On 2 November 2026 at 9:45, RocketTrace analyzed 24.05 s of hora Calendar 2.0.3, Build 1, a Release build, on a MacBook Pro. That is the same day the grids later left SwiftUI, and several hours before that work landed. The production app in this trace is still the SwiftUI-era grid.
In those 24.05 s it counted 12 hangs, the worst 465 ms, and 285 ms of app CPU. It counted 80,630 SwiftUI updates across 310 views, and 332 concurrency items with 677 suspensions. App hitches in this overview are unverified, with a summary that exported zero rows, so I am not using a hitch number from it.
The top findings all say the same thing: the main thread was CPU-bound by SwiftUI updates and layout. One hang lasted 438 ms, with 99% main-thread coverage. Another lasted 355 ms, with 100% coverage. Four hangs with the same measured cause lasted 1.22 s combined, from 266 ms to 334 ms. Three more lasted 1.08 s combined, from 304 ms to 443 ms, in FocusLink / connected accounts.
What left SwiftUI, and what stayed
On 2 November 2026 the Mac week, day and month grids left SwiftUI. That work shipped in hora Calendar 3.0, in a pull request merged on 5 November.
Tiles are no longer one SwiftUI view per event. A shared CoreGraphics GridPainter draws them in a few NSViews: WeekTimedGridView for the timed week and day grid, MonthWeekRowView for a week row in month view. The painter lives in a platform-neutral core: 16 files, 3,408 lines, using CoreGraphics, CoreText and Foundation. There is no AppKit or UIKit in that core. Each platform only hosts it.
On iOS it is one UIView per grid, PhoneGridView, drawn in draw(_:), and redrawn only where the scene changed. It is not a UICollectionView, and there is no cell reuse. Scrolling is still SwiftUI on both platforms: ScrollView on the Mac, and ScrollView, LazyHStack and TabView on iOS.
Hover left SwiftUI too. It used to be .onHover and .onContinuousHover plus @State, which re-evaluated the column when the pointer left a tile. It is now an NSTrackingArea and mouseMoved.
Popover content stayed SwiftUI. It now lives inside an NSPopover, hosted in an NSHostingController. The SwiftUI .popover on an offset view had opened in the wrong place, or not at all.
The main-window toolbar moved to a native NSToolbar on 9 November. The change record says Sentry's largest App Hang family, about 75 issues and about 4,200 events, was the toolbar: every window layout pass re-rendered the SwiftUI views hosted in NSToolbarItemViewer. I have not re-queried Sentry for this article, so that count is the one from the change itself.
Settings, onboarding, the menu bar, the sidebar and the inspector stayed SwiftUI, along with event and task editors.
In my experience that is about 80% of the Mac UI. What I can stand behind without that estimate is simpler: the grids, the hover and the toolbar left SwiftUI. The chrome around them did not.
From a hover modifier to a tracking area
Before, each column watched the pointer in SwiftUI, and every tile had two clear 6 pt resize strips with their own .onHover. A comment in that file is blunt: MultiViewResponder.containsGlobalPoints was the single hottest hora-side frame in a user's sample.
.coordinateSpace(name: "weekDayColumn")
.onContinuousHover { phase in
switch phase {
case .active(let location):
updateHoveredTimedEvent(at: location, width: columnWidth)
case .ended:
hoveredTimedEventID = nil // @State → re-evaluates the column
}
}
// …and on every tile, two 6 pt resize strips:
Rectangle().fill(Color.clear).frame(height: 6)
.contentShape(Rectangle())
.onHover { hovering in
if hovering { NSCursor.resizeUpDown.push() } else { NSCursor.pop() }
}After, one NSView owns a tracking area and updates hover in mouseMoved. The resize cursor is a hit test against the scene, not a view tree of invisible rectangles.
override func updateTrackingAreas() {
super.updateTrackingAreas()
trackingAreas.forEach(removeTrackingArea)
addTrackingArea(NSTrackingArea(rect: bounds,
options: [.mouseMoved, .mouseEnteredAndExited, .cursorUpdate, .activeInActiveApp, .inVisibleRect],
owner: self, userInfo: nil))
}
override func mouseMoved(with event: NSEvent) { updateHover(convert(event.locationInWindow, from: nil)) }
private func updateHover(_ point: CGPoint) {
if let element = hit(at: point), isResizable(element),
WeekGridLogic.resizeEdge(at: point, in: element.frame) != nil {
NSCursor.resizeUpDown.set()
} else { NSCursor.arrow.set() }
}From a view per tile to one painter
The first cut was hybrid. Plain timed tiles were already drawn by one NSView, and the rest of the column was still one SwiftUI tile each, inside a TimelineView and a GeometryReader.
TimelineView(DayColumnClockSchedule(ticks: ticksClock)) { context in
GeometryReader { geo in
TimedTileLayer(input: split.input) { … } // plain tiles: one NSView
ForEach(split.swiftUI) { entry in // the rest: one SwiftUI tile each
positionedTimedEvent(entry: entry, containerWidth: eventLayoutWidth,
now: now, geo: geo)
}
ForEach(layout.tasks, id: \.chip.id) { placed in
positionedTimedTask(placed, containerWidth: eventLayoutWidth, geo: geo)
}
}
}The notes from that step say Instruments put about 85% of the week and day switch hangs inside SwiftUI building a view tree per event tile. After the change, hang time per second of recording was down about 45%. A parallel month step said month switches spent their hang time in SwiftUI laying out about 300 event rows.
That 45% is not a measurement of the final AppKit and UIKit grid. It belongs to the hybrid step only, and it is approximate. I am not going to pretend it is the after of the rewrite that shipped a few days later.
The final Mac grid is quieter than that hybrid column. A flipped NSView asks the shared painter to draw the scene, and the painter skips anything outside the dirty rect.
override var isFlipped: Bool { true }
override func draw(_ dirtyRect: NSRect) {
guard let ctx = NSGraphicsContext.current?.cgContext, let painter else { return }
draw(scene, with: painter, in: ctx, dirty: dirtyRect)
}
public func draw(_ scene: GridScene, in ctx: CGContext, dirty: CGRect? = nil) {
for element in scene.elements {
if let dirty, !element.frame.insetBy(dx: -4, dy: -4).intersects(dirty) { continue }
draw(element, in: ctx)
}
}I am not claiming this work moved off the main thread. Scene builds are still synchronous, on the main thread. The earlier move of data reads off main is older. It shipped in September, while the production calendar was still SwiftUI.
SwiftUI vs UIKit, on iPhone
The iOS event, timeline and month grids made the same change, against the same core. If you are comparing SwiftUI vs UIKit for a dense calendar, this is the part that matters: the tiles are no longer a ForEach of SwiftUI blocks. They are one UIView that paints a scene.
func apply(_ new: GridScene, painter: GridPainter) {
let old = scene
let repaintAll = self.painter !== painter || old.size != new.size
|| old.elements.count != new.elements.count
self.painter = painter
scene = new
guard !repaintAll else { setNeedsDisplay(); sceneDidChange(); return }
var dirty = CGRect.null
for index in new.elements.indices where old.elements[index] != new.elements[index] {
dirty = dirty.union(old.elements[index].frame).union(new.elements[index].frame)
}
if !dirty.isNull { setNeedsDisplay(dirty.insetBy(dx: -4, dy: -4)) }
}The scroll container around that view is still SwiftUI. Gestures on the timed grid are three UILongPressGestureRecognizers: lift, grab and create. I have no iOS performance numbers for this, before or after, so I am not going to invent a speedup on iPhone.
Pixel parity, and what the diff looks like
We did not want the calendar to look like a rewrite. The shared core was tested against SwiftUI reference PNGs. The tolerance was at most 1.5% of pixels differing by more than 32, and a mean difference of at most 1.2. The measured maximum was 0.96% of pixels, with a mean of 0.99, for tiles and pills. App-side views came in at 1.23% and 0.96.
HoraUI tests went from 83 at the shared-core step to 105 when 3.0 shipped.
The app-code diff for the phase that replaced the grids, tests excluded, was 44 files, +4,783 and −5,740. A 1,453-line week column file went away. Month view went from 1,127 lines to 274. The iPhone timeline view went from 2,439 to 1,572. The goal was not a smaller codebase for its own sake. It was a calendar that just runs smoothly, and still looks like hora.
What we haven't measured yet
This is the part I would rather say than skip.
We do not have FPS, CPU or memory numbers for the final AppKit and UIKit grid. We do not have Release hang data after that migration. The Release trace above is 2.0.3, from the morning of 2 November, before the grids left SwiftUI. We do not have iOS performance data, before or after.
The September Debug traces are not a controlled before-and-after of the final grid, and they say so themselves. The only post-migration figure I have is the hybrid step's "about 45%," and that is not the final grid. I am also not claiming the new grid moved work off the main thread. Drawing happens in draw(_:). Scenes are built on the main thread.
The 3.0 release notes called it a faster grid, with no figure attached. That is the honest state of the evidence. The architecture changed. The after-trace is still on my list.
Let's take shortcuts, but smart ones
Be skeptical of the buzz. SwiftUI is the best. Flutter is the future. Electron is dying. Everything should be in Rust, on the GPU. I have heard all of it.
What actually holds up is good code, and a low cost in resources. Every app is a system that turns lights on and off. Some of those lights are cheap. Some are not. SwiftUI is a shortcut. AppKit is a shortcut too, just an older one. I still take them.
The loop with an agent is simple enough. Find the bug. Understand it. Fix it with the agent. Check the result. Sentry and PostHog can catch what I miss. The human part does not go away. You still have to think. Let's take shortcuts, but smart ones.
If you have been down this path, I would like to hear what you rewrote, and why.
AppKit vs SwiftUI FAQ
Is SwiftUI slower than AppKit?
Not always. For many screens it is fine, and it is faster to write. In hora, a hover effect across tens or hundreds of event tiles was very expensive, and a 24-second Release trace counted 80,630 SwiftUI updates across 310 views, with the main thread CPU-bound by SwiftUI updates and layout. That is why the Mac week, day and month grids, the hover path and later the toolbar left SwiftUI.
Should a new Mac app start in SwiftUI or AppKit?
I started in SwiftUI and rewrote the heavy parts later. That is a reasonable path if you know where you will stop. A calendar, a timeline, or any UI with a large number of on-screen elements is where I would reach for AppKit, or UIKit on iOS, sooner. I would still use SwiftUI for settings, onboarding, popovers and the chrome around the grid.
What is the difference between hora's iOS grid and a UICollectionView?
There isn't a UICollectionView in this grid. Each calendar grid is one UIView that draws a scene in draw(_:), with dirty-rect updates, using the same CoreGraphics painter as the Mac app. Scrolling around it is still SwiftUI. There is no cell reuse pool.
Why not Electron, if agents can now drive Xcode?
Apple added an official Xcode plugin to Claude Code: build, test, SwiftUI previews, simulators, devices. Building natively with agents got easier, not harder. Electron is still a web runtime on a Mac. I want a calendar that feels like it belongs there.
If Google Calendar is where your week lives, try hora free for 14 days.
Small print: the 14-day trial is for the Direct version and needs no card. hora is an independent app and is not affiliated with Google.
Sources
September Debug Instruments notes shipped with the 1.1.6 work in PR #325. The run4 notes say the comparison is not a controlled A/B. The hybrid step's "about 45%" is not a measurement of the final grid. The Mac week, day and month grids left SwiftUI on 2 November 2026 and shipped in PR #339, merged 5 November. The toolbar moved on 9 November.
The official Xcode plugin for Claude Code is documented in Apple's Giving external agents access to Xcode, and in Anthropic's official plugin marketplace, authored by Apple Inc.


