◀ Back to NotesPublished on October 20, 2024

How I made my SwiftUI calendar app 3x faster

  DayView(day: day, weekday: SHORT_WEEKDAY_SYMBOLS[wk], weekdayNum: wkNum)
    .overlay(roundRect(radius, fill: todayColor))
+   .fixedSize()

That's it. Really, what you see in the above diff is all I had to do.

I could end the article here and it would still be useful for a bunch of people, but I'm pretty sure you'd like to know why this works, where it doesn't actually do anything, and how I reached this conclusion.

Before and after fixedSize

Below is a video demo of me bringing up the calendar app, then moving 4 months forward then backward.

You can really see just how sluggish the before video looks, it takes quite a long time between the time I pressed the forward hotkey and the time the next month renders.

The after video shows the rendering happening just a few milliseconds after the hotkey is pressed, as expected.

# How I found this

Grila is the calendar app in question. It's a read-only calendar app which can be navigated fully by keyboard.

I built it two years ago because I needed to consult my calendar a lot when trying to make plans for meeting friends and relatives. I didn't really need to add or edit things in my calendar, by I did want to view holidays, birthdays and move to specific dates in less than 2 seconds.

At the time, I complained about SwiftUI being convenient, but slow because no matter what I tried, the app felt sluggish without obvious issues in code.

In that article I also included a screenshot of an attempt at profiling the UI code using Instruments.app:

Attaching its Time Profiler to Grila showed that the heaviest stack trace was in some SwiftUI graph updating code. Look at this thing!

Click on it, I dare you.

grila heavy stack trace

Yes, that's how much I understood from it as well.

At the time, SwiftUI didn't come with debug symbols, so the stack trace was useless.

So I blindly tried people's recommendations like:

  • caching rasterized views with drawingGroup()
  • turning one-way bindings into simple var properties
  • breaking up large ObservableObject classes into smaller ones
  • removing animations
  • removing .environment dependencies

None of those made a measurable difference, even though they seem like sensible choices for lowering view draw time.


Thankfully, with macOS Sonoma's launch we also got SwiftUI symbols. So I tried my hand again at finding where most of the processing time is spent in the app.

Turns out, a huge chunk of time was spent in sizeThatFits() which was being called hundreds of times on a single render:

Unfortunately the stack trace doesn't also show what specific view causes this, but the "hundreds of times" figure led me to believe this was being called on DayView, because in the 3-month view we have more than 90 days being rendered.

From my understanding, sizeThatFits() gets called for dynamically sized views which get their inner dimensions from both their interior and exterior constraints. That's the default for all SwiftUI views.

In my case though, the DayView is a rectangle which needs to always have the same dimensions, dictated by the size of its inner text. So I could ignore the exterior constraints by adding .fixedSize() to it.

In the below screenshot (click to view the larger version), you can see the before heaviest traceback on the left which shows sizeThatFits() as the most CPU intensive call and spends more than 4 seconds on rendering the month changes.

The after traceback on the right, which adds the fixed size modifier, doesn't show sizeThatFits() at all and it spends a just little more than 1 second on the same number of renders.

On the bottom left of the image you can also see how the main queue is blocked for 629ms, and that is what causes our visible hangs. That's gone after adding fixedSize.

After making the view size fixed in a few more places where I knew I don't need dynamic sizing, the only size processing that remained as a heavy call was the text sizing. That's most likely because I use a lot of Text() elements, all with font modifiers.

I don't think I can optimise this further, but at least it feels fast enough to use daily.