Aleahim.comv1.78

How SwiftUI Animates, and Where It Is Not Like UIKit

UIKit and AppKit hand the render server a description of a motion and walk away. SwiftUI does not, and it does not behave the same on a Mac as on an iPhone. I measured both on a Mac, two simulators, a real iPhone and a real iPad.

How SwiftUI Animates, and Where It Is Not Like UIKit

Take one dot. Move it across a view in half a second. Do it with UIView.animate, then do it with withAnimation. On screen the two look the same. Underneath they are not the same thing, and the difference changes depending on whether you are on a Mac or on an iPhone.

I thought I knew the answer. I did not, and the first answer I was given turned out to be true on one platform and false on the other. So I stopped reading and started measuring. This post is the measurements: charts drawn from the recorded data, byte dumps of what was really in memory, and recordings of the real screens.

The answers I started with

Core Animation is data. You describe a motion once: a key path, a from value, a to value, a duration, a curve. That description goes to the render server, a separate process, and the render server runs it frame after frame. Your app does nothing more until the animation ends.

Then someone showed me a call stack from a Mac that said SwiftUI is the opposite. On each pass the system asks the app for the state at this time, the app works it out on the main thread and writes the layer. A pull model. The render server only sees the results.

And Apple says something else again. In WWDC 2023, session 10156, “Explore SwiftUI animation”, the sentence is that for built-in animatable attributes like scaleEffect, SwiftUI does the work off the main thread and without calling into your view code.

Those three cannot describe one mechanism. This is what I ended up with, drawn first so you can hold it against the evidence in the rest of the post:

flowchart LR
  subgraph A["UIKit and AppKit"]
    direction TB
    a1["you animate<br/>a view"] --> a2["one animation<br/>object is added<br/>to the layer"] --> a3["one commit to<br/>the render server"] --> a4["the render server<br/>runs it,<br/>every frame"]
  end
  subgraph B["SwiftUI on the Mac"]
    direction TB
    b1["you call<br/>withAnimation"] --> b2["SwiftUI works out<br/>the value on the<br/>main thread,<br/>every pass"] --> b3["it writes<br/>the layer"] --> b4["the render server<br/>draws what<br/>it is given"]
  end
  subgraph C["SwiftUI on iPhone and iPad"]
    direction TB
    c1["you call<br/>withAnimation"] --> c2["SwiftUI works out<br/>the value on its<br/>own thread,<br/>every frame"] --> c3["the value goes<br/>into shared<br/>memory"] --> c4["a small commit<br/>says the memory<br/>changed"] --> c5["the render server<br/>draws what<br/>it is given"]
  end
  A ~~~ B
  B ~~~ C

How I measured

I built a small probe. A SwiftUI view, a stack of two colours, hosted in a window on screen. One property driven by a switch inside withAnimation: opacity, offset, width, scale or rotation. Four kinds of animation: linear, ease in and out, a spring, and a custom animation, which is a callback that returns a value. That is 20 cases. The same properties moved with the framework’s own call, UIView.animate on iPhone and iPad and NSAnimationContext on the Mac, as the baseline.

While the motion ran I recorded three things: every call to the layer’s setters and whether it came on the main thread, every call to addAnimation:forKey:, and the model value and the presentation value of every layer, sampled every 4 milliseconds.

If the app writes the model value on many passes and adds no animation, the app is running the motion. If an animation is added and the model value is written once, the render server is. If the model value is written once, no animation is added through the public call, and the presentation value moves anyway, something I cannot see is making the motion.

I ran it on a Mac (macOS 27.0.1), on an iPhone and an iPad simulator (27.0, 60 Hz), and on a real iPhone 17 Pro Max and a real iPad Pro (27.2, 120 Hz).

What the layer received

PlatformVerdict, 20 casesDifferent model values per caseValues set on presentation modifiers per case
Mac, macOS 27.0.120 appWrites26 to 46none
iPhone simulator, iOS 27.020 presentationModifier2 to 340 to 172
iPad simulator, iPadOS 27.020 presentationModifier2 to 342 to 167
iPhone 17 Pro Max, iOS 27.220 presentationModifier2 to 342 to 171
iPad Pro M4, iPadOS 27.220 presentationModifier2 to 390 to 341

The table is the whole result of the 20 cases, per platform. On the Mac, SwiftUI wrote the layer’s model value on 26 to 46 different values in half a second, with between 54 and 184 setter calls for a case, every one of them on the main thread, and added no animation, ever. It did not matter whether the animation was linear, ease, spring or custom, or whether the property was opacity, offset, width, scale or rotation.

On iPhone and iPad the model value was written two or three times: the start and the end. No animation was added through addAnimation:forKey:. And the presentation value, what is actually drawn, took 22 to 80 different values as it moved. The motion is real, the app does not write it on every pass, and it is not a public animation object either.

The control is the framework’s own animation. On the Mac, the six cases AppKit could express each added exactly one animation, with the model written once and the presentation taking 32 to 33 values. On iPhone and iPad, 15 UIKit cases did the same, with 26 to 106 presentation values. The probe can see a handed-off animation when there is one.

Block the main thread

The question that matters for an app is simple. If the main thread is busy, does the animation keep moving?

I put a blue square on screen and blocked the main thread for half a second in the middle of the motion. While the square is lit, the thread is blocked. I recorded the screen and read every frame for where the red dot was. On the Mac and in the simulator I read the composited display with a private call. That call returns a blank surface on a real device, so for the devices I did what QuickTime does: plug the device in by USB, switch on one CoreMediaIO setting, and each device shows up as a capture device. The recording is 60 frames a second on both devices, also on the 120 Hz iPad. The Mac capture ran at about 16 frames a second, which is why its chart has fewer, larger steps.

In every chart below, the horizontal axis is seconds from the moment the blue marker lit, sampled every 0.15 seconds, and a * after a time means the main thread was blocked then. The vertical axis is where the dot was, as a fraction of its travel. For each time the chart shows the last frame the screen had drawn by then, which is what you would have seen.

The Mac

Mac: where the dot was while the main thread was blocked
line chart with 12 labels and 2 series 0 0.2 0.4 0.6 0.8 1 AppKit, -0.45: 0.17 AppKit, -0.30: 0.23 AppKit, -0.15: 0.31 AppKit, 0.00 *: 0.38 AppKit, 0.15 *: 0.44 AppKit, 0.30 *: 0.5 AppKit, 0.45 *: 0.55 AppKit, 0.60: 0.6 AppKit, 0.75: 0.68 AppKit, 0.90: 0.73 AppKit, 1.05: 0.81 AppKit, 1.20: 0.87 SwiftUI, -0.45: 0.21 SwiftUI, -0.30: 0.28 SwiftUI, -0.15: 0.35 SwiftUI, 0.00 *: 0.41 SwiftUI, 0.15 *: 0.43 SwiftUI, 0.30 *: 0.43 SwiftUI, 0.45 *: 0.43 SwiftUI, 0.60: 0.66 SwiftUI, 0.75: 0.71 SwiftUI, 0.90: 0.78 SwiftUI, 1.05: 0.84 SwiftUI, 1.20: 0.9 -0.45 -0.30 -0.15 0.00 * 0.15 * 0.30 * 0.45 * 0.60 0.75 0.90 1.05 1.20 seconds from the marker, * the main thread was blocked the dot's place, as a fraction of its travel AppKit SwiftUI

Blue is AppKit, green is SwiftUI. The AppKit dot keeps climbing through the block. The SwiftUI dot is flat for the whole stretch, then jumps to where it should have been. In the block shown, it stayed in one place for 523 milliseconds. Over the two blocks the probe judged, the longest wait was 586 milliseconds, and in the frames of the block that were not at its edges the SwiftUI dot was in a new place in none.

The iPhone simulator

iPhone simulator: where the dot was while the main thread was blocked
line chart with 12 labels and 2 series 0 0.2 0.4 0.6 0.8 1 UIKit, -0.45: 0.2 UIKit, -0.30: 0.26 UIKit, -0.15: 0.32 UIKit, 0.00 *: 0.39 UIKit, 0.15 *: 0.46 UIKit, 0.30 *: 0.52 UIKit, 0.45 *: 0.58 UIKit, 0.60: 0.64 UIKit, 0.75: 0.7 UIKit, 0.90: 0.77 UIKit, 1.05: 0.83 UIKit, 1.20: 0.89 SwiftUI, -0.45: 0.2 SwiftUI, -0.30: 0.26 SwiftUI, -0.15: 0.32 SwiftUI, 0.00 *: 0.38 SwiftUI, 0.15 *: 0.44 SwiftUI, 0.30 *: 0.5 SwiftUI, 0.45 *: 0.56 SwiftUI, 0.60: 0.62 SwiftUI, 0.75: 0.69 SwiftUI, 0.90: 0.75 SwiftUI, 1.05: 0.82 SwiftUI, 1.20: 0.88 -0.45 -0.30 -0.15 0.00 * 0.15 * 0.30 * 0.45 * 0.60 0.75 0.90 1.05 1.20 seconds from the marker, * the main thread was blocked the dot's place, as a fraction of its travel UIKit SwiftUI

A real iPad Pro

iPad Pro, a real device: where the dot was while the main thread was blocked
line chart with 12 labels and 2 series 0 0.2 0.4 0.6 0.8 1 UIKit, -0.45: 0.22 UIKit, -0.30: 0.28 UIKit, -0.15: 0.35 UIKit, 0.00 *: 0.41 UIKit, 0.15 *: 0.46 UIKit, 0.30 *: 0.53 UIKit, 0.45 *: 0.59 UIKit, 0.60: 0.65 UIKit, 0.75: 0.71 UIKit, 0.90: 0.78 UIKit, 1.05: 0.83 UIKit, 1.20: 0.9 SwiftUI, -0.45: 0.18 SwiftUI, -0.30: 0.25 SwiftUI, -0.15: 0.31 SwiftUI, 0.00 *: 0.37 SwiftUI, 0.15 *: 0.43 SwiftUI, 0.30 *: 0.49 SwiftUI, 0.45 *: 0.55 SwiftUI, 0.60: 0.61 SwiftUI, 0.75: 0.68 SwiftUI, 0.90: 0.74 SwiftUI, 1.05: 0.81 SwiftUI, 1.20: 0.87 -0.45 -0.30 -0.15 0.00 * 0.15 * 0.30 * 0.45 * 0.60 0.75 0.90 1.05 1.20 seconds from the marker, * the main thread was blocked the dot's place, as a fraction of its travel UIKit SwiftUI

A real iPhone 17 Pro Max

iPhone 17 Pro Max, a real device: where the dot was while the main thread was blocked
line chart with 12 labels and 2 series 0 0.2 0.4 0.6 0.8 1 UIKit, -0.45: 0.19 UIKit, -0.30: 0.26 UIKit, -0.15: 0.32 UIKit, 0.00 *: 0.38 UIKit, 0.15 *: 0.44 UIKit, 0.30 *: 0.5 UIKit, 0.45 *: 0.56 UIKit, 0.60: 0.62 UIKit, 0.75: 0.69 UIKit, 0.90: 0.75 UIKit, 1.05: 0.81 UIKit, 1.20: 0.88 SwiftUI, -0.45: 0.2 SwiftUI, -0.30: 0.27 SwiftUI, -0.15: 0.32 SwiftUI, 0.00 *: 0.39 SwiftUI, 0.15 *: 0.44 SwiftUI, 0.30 *: 0.51 SwiftUI, 0.45 *: 0.57 SwiftUI, 0.60: 0.63 SwiftUI, 0.75: 0.69 SwiftUI, 0.90: 0.76 SwiftUI, 1.05: 0.81 SwiftUI, 1.20: 0.88 -0.45 -0.30 -0.15 0.00 * 0.15 * 0.30 * 0.45 * 0.60 0.75 0.90 1.05 1.20 seconds from the marker, * the main thread was blocked the dot's place, as a fraction of its travel UIKit SwiftUI

On the simulator and on both real devices the two lines lie on top of each other. The SwiftUI dot kept moving through the block, at the same speed as the UIKit dot: 817 pixels a second against 816 on the iPad, 262 against 262 on the iPhone. The longest wait for a new place was one or two captured frames.

All of it in one chart

The longest time the dot stayed in one place during the block
bar chart with 5 labels and 2 series 0 200 400 600 UIKit and AppKit, Mac: 79 UIKit and AppKit, iPhone sim: 23 UIKit and AppKit, iPad Pro: 33 UIKit and AppKit, iPhone 17: 17 UIKit and AppKit, iPad control: 33 SwiftUI, Mac: 523 SwiftUI, iPhone sim: 23 SwiftUI, iPad Pro: 33 SwiftUI, iPhone 17: 33 SwiftUI, iPad control: 500 Mac iPhone sim iPad Pro iPhone 17 iPad control recording milliseconds UIKit and AppKit SwiftUI

The last group, the iPad control, is a test I will come back to below. First, the same recordings as moving pictures.

The same recordings, replayed

Each of these videos replays one recording at half speed. The dot is put where the camera saw it, frame by frame, nothing smoothed. The top row is the framework’s own animation, the bottom row is SwiftUI. The blue square is lit while the main thread is blocked. The red bar under a row grows while that dot stays where it is, and a full bar is 0.6 seconds.

The Mac. Watch the red bar under the SwiftUI row.

The real iPad Pro. Nothing to see: no bar grows under either row.

The real iPhone 17 Pro Max.

The real screens

This is a frame from the recording of the iPad, with the blue square lit and the SwiftUI dot still moving.

A real iPad Pro screen, recorded over USB: the blue square is lit and the main thread is blocked, and the SwiftUI dot is moving

And the full recordings, trimmed to the run. The iPad, SwiftUI with the main thread blocked:

The iPad, UIKit, the same block:

The iPhone, SwiftUI, cropped to the band the dot travels in:

The iPhone, UIKit:

So with the main thread blocked, SwiftUI on iPhone and iPad keeps animating. SwiftUI on the Mac does not. Something other than the main thread is doing the work on the phones. To find out what, I had to go lower than Objective-C.

Below Objective-C: what SwiftUI does on iPhone and iPad

I listed every method with “animation” in its name on the layer and animation classes, public and private, hooked the ones I could, and recorded the thread and call stack of each call. None of the animation methods was called. But the custom animation showed where the clock runs. On the Mac its callback runs on the main thread, every call. On iPhone and iPad the calls are on a thread named com.apple.SwiftUI.AsyncRenderer, reached from a display link of SwiftUI’s own. During the half second of blocked main thread, the callbacks went on: 24 inside the block on the simulators, 34 on the real iPhone and 48 on the real iPad, none of them on the main thread. On the Mac there were none.

Sampling the process while it animated showed the structure. On iPhone and iPad SwiftUI runs its own display link on that thread. Each tick it updates the graph, renders the display list asynchronously, and writes the values into what its symbols call an async layer. None of this exists on the Mac.

To see how those values reach the screen I attached a debugger to the simulator. SwiftUI’s AsyncLayer.setValue does not touch a layer: it queues the value. A function called commitAsyncValues takes the queued values, and it contains two groups of Core Animation calls. One builds CABasicAnimation objects and adds them. The other uses a class I had not heard of, CAPresentationModifier. I put breakpoints on both while an opacity animated. addAnimation was never called. addPresentationModifier: was called 44 times and -[CAPresentationModifier setValue:] 302 times, all on the AsyncRenderer thread, with the values rising frame by frame.

A presentation modifier is a private Core Animation class, not in the public headers. A layer that has one for a key path shows that value in its presentation without a change to the model value and without an animation. That is exactly what I had measured and could not explain: model written twice, no animation, presentation moving.

Because the class is Objective-C I could hook it on the real devices too. Every one of the 20 SwiftUI cases adds two to four presentation modifiers and sets 42 to 341 values on them, each of those calls off the main thread. The key path follows the property: opacity, bounds, position, or position and transform for scale and rotation. UIKit adds none. The Mac adds none.

Going one level further down, into QuartzCore’s C++, shows what a modifier is for. Each modifier belongs to a group, and a group owns a block of shared memory. On SwiftUI’s thread, every frame, the new value is copied into that shared memory, the group is flushed, and the flush adds a command to Core Animation’s transaction, which the same thread then commits. Here is one frame:

flowchart TB
  tick["the display link ticks"] --> evaluate
  subgraph T["SwiftUI's own thread"]
    direction TB
    evaluate["evaluates the animation<br/>at this time"] --> write["writes the value<br/>into a record"] --> send["sends two small messages,<br/>with no values in them"]
  end
  subgraph R["the render server"]
    direction TB
    swap["swaps the buffer<br/>and reads the value"] --> draw["draws the layer"]
  end
  memory["the shared memory"]
  write --> memory
  send --> swap
  memory --> swap
  note["this half is read from the render server's code,<br/>I did not watch it run"] -.-> swap

The shared memory

I could reach the group’s shared memory from inside the app, and the disassembly of the render server’s flush function told me where its size and its data pointer sit. So I read it every 4 milliseconds while an opacity animated, on the simulator and on both real devices. It is 114,688 bytes. The first 112 bytes of one reading, from the iPhone 17 Pro Max, are in this picture, coloured by field:

The first 112 bytes of one reading of the shared memory, as captured, coloured by field

BytesColourWhat it is
0 to 3violetA flags word. Bit 0 flips between readings, as the render server’s flush function flips it.
4 to 7lilacThe flip flag, equal to that bit.
8 to 11amberA sequence number. It only ever rises.
12 to 15greyA count: 2, the two modifiers.
16 to 39pale yellowThree times, as doubles.
40 to 47tealThe flags of record 0: 0x1.
48 to 55coralThe value of record 0, a double. It is the animated opacity.
56 to 63darkA second double, 0 in every reading.
64 to 111the sameRecords 1 and 2 have the same three fields. Record 2 has flags 0x10001.

The values in the records are the animation. Here are two of the records over the first frames of an opacity animation from 0.2 up to 1 over 0.8 seconds, on the iPhone. The record with flags 0x10001 takes a new value on every frame. The one with flags 0x1 takes a new value on every other frame, and trails by one frame:

iPhone 17 Pro Max: two records of the shared memory over the first 12 frames
line chart with 12 labels and 2 series 0 0.2 0.4 0.6 updated every frame, 1: 0.37 updated every frame, 2: 0.38 updated every frame, 3: 0.4 updated every frame, 4: 0.42 updated every frame, 5: 0.43 updated every frame, 6: 0.45 updated every frame, 7: 0.47 updated every frame, 8: 0.48 updated every frame, 9: 0.5 updated every frame, 10: 0.52 updated every frame, 11: 0.53 updated every frame, 12: 0.55 updated every other frame, 1: 0.37 updated every other frame, 2: 0.4 updated every other frame, 3: 0.4 updated every other frame, 4: 0.43 updated every other frame, 5: 0.43 updated every other frame, 6: 0.47 updated every other frame, 7: 0.47 updated every other frame, 8: 0.5 updated every other frame, 9: 0.5 updated every other frame, 10: 0.53 updated every other frame, 11: 0.53 updated every other frame, 12: 0.57 1 2 3 4 5 6 7 8 9 10 11 12 frame the record's value, an opacity updated every frame updated every other frame

And on the iPad Pro at 120 Hz, the same, with twice as many frames in the same time:

iPad Pro, at 120 Hz: the same two records over the first 12 frames
line chart with 12 labels and 2 series 0 0.1 0.2 0.3 0.4 0.5 updated every frame, 1: 0.39 updated every frame, 2: 0.4 updated every frame, 3: 0.41 updated every frame, 4: 0.42 updated every frame, 5: 0.43 updated every frame, 6: 0.43 updated every frame, 7: 0.44 updated every frame, 8: 0.45 updated every frame, 9: 0.46 updated every frame, 10: 0.47 updated every frame, 11: 0.48 updated every frame, 12: 0.48 updated every other frame, 1: 0.4 updated every other frame, 2: 0.4 updated every other frame, 3: 0.42 updated every other frame, 4: 0.42 updated every other frame, 5: 0.43 updated every other frame, 6: 0.43 updated every other frame, 7: 0.45 updated every other frame, 8: 0.45 updated every other frame, 9: 0.47 updated every other frame, 10: 0.47 updated every other frame, 11: 0.48 updated every other frame, 12: 0.48 1 2 3 4 5 6 7 8 9 10 11 12 frame the record's value, an opacity updated every frame updated every other frame

So the rate of the values in the memory follows the display’s refresh rate, on every platform. Why a modifier has three records, and what the two flag values mean, I inferred from their rates; I did not read it from code.

The messages

If the values are in shared memory, what does SwiftUI’s thread send the render server on every frame? I put a breakpoint on mach_msg and captured them. Every frame there are two small messages, of 72 and 84 bytes, and every 84 byte message had a 72 byte one within 4 milliseconds. I had guessed the 72 byte one carried a layer’s properties. It does not. Both are the same Core Animation transaction record. Here are the bytes of a real one of each, coloured by field.

The 72 byte message is a transaction with no commands:

A real captured 72 byte message, coloured by field

BytesColourWhat it is
0 to 31greyThe Mach message header (24 bytes) and two words.
32 to 35violetThe context the message is for.
36 to 43amberA time, a double.
44coralThe marker 0x21: a transaction record follows.
45 to 48light coralA commit counter.
49greyA flags byte, 0 in every message.
50 to 58pale yellowThe marker 0x1e and a second time.
59 to 67lilacThe marker 0x1f and a last double, of a few microseconds.
68 to 71darkZeros.

The 84 byte message is the same record with the reference to the shared memory in front of it:

A real captured 84 byte message, coloured by field

BytesColourWhat it is
0 to 43grey, violet, amberThe same header, context and time as above.
44 to 57tealThe marker 0x24, a 9 byte key, an index byte and three zeros: the reference to the shared memory.
58coralThe marker 0x21.
59 to 82the sameThe commit counter, flags, the two times and the last double, as above, then two bytes of zeros.

I found which function writes each marker byte by scanning QuartzCore’s code for the instruction that stores it. 0x21, 0x1e and 0x1f are all written in CA::Context::commit_transaction, followed by the call that sends the message. 0x24 is written by CA::Context::commit_command.

Nothing in the per-frame traffic is a property value. The values are in the shared memory, and the messages only say that it was updated. That the render server then swaps the buffer and reads the values each frame I know from reading its code. I could not watch it: a debugger is refused on the render server even in the simulator.

The control: who runs it

Two more tests ask what the render server does by itself. The first one stops SwiftUI’s own render thread for half a second instead of blocking the main thread. If the render server ran a SwiftUI animation on its own, the dot would not care. Here is the screen, in the same chart as before, on the real iPad:

Control, iPad Pro: where the dot was while SwiftUI's own thread was stopped
line chart with 12 labels and 2 series 0 0.2 0.4 0.6 0.8 1 UIKit, -0.45: 0.2 UIKit, -0.30: 0.27 UIKit, -0.15: 0.33 UIKit, 0.00 *: 0.4 UIKit, 0.15 *: 0.45 UIKit, 0.30 *: 0.52 UIKit, 0.45 *: 0.58 UIKit, 0.60: 0.64 UIKit, 0.75: 0.7 UIKit, 0.90: 0.76 UIKit, 1.05: 0.83 UIKit, 1.20: 0.88 SwiftUI, -0.45: 0.21 SwiftUI, -0.30: 0.27 SwiftUI, -0.15: 0.34 SwiftUI, 0.00 *: 0.4 SwiftUI, 0.15 *: 0.43 SwiftUI, 0.30 *: 0.43 SwiftUI, 0.45 *: 0.43 SwiftUI, 0.60: 0.65 SwiftUI, 0.75: 0.71 SwiftUI, 0.90: 0.77 SwiftUI, 1.05: 0.84 SwiftUI, 1.20: 0.89 -0.45 -0.30 -0.15 0.00 * 0.15 * 0.30 * 0.45 * 0.60 0.75 0.90 1.05 1.20 seconds from the marker, * the main thread was blocked the dot's place, as a fraction of its travel UIKit SwiftUI

The UIKit dot ignores the stop. The SwiftUI dot stays in one place for 500 milliseconds, then jumps. The replay shows it, with the red bar:

And the real recordings, on the iPad and the iPhone:

The second test is the render server’s own frame counter, which I read from another thread through a private C function. Blocking the main thread changes nothing for either framework:

Frames the render server drew each second, main thread blocked for 0.5 s
bar chart with 4 labels and 2 series 0 50 100 150 Before, iPhone simulator UIKit: 58.7 Before, iPhone simulator SwiftUI: 59.1 Before, iPad Pro UIKit: 119.6 Before, iPad Pro SwiftUI: 119.8 During, iPhone simulator UIKit: 58.6 During, iPhone simulator SwiftUI: 60.5 During, iPad Pro UIKit: 120.7 During, iPad Pro SwiftUI: 119.4 iPhone simulator UIKit iPhone simulator SwiftUI iPad Pro UIKit iPad Pro SwiftUI recording frames a second Before During

Stopping SwiftUI’s thread takes the render server’s frames away, for SwiftUI only:

Frames the render server drew each second, SwiftUI's thread stopped for 0.5 s
bar chart with 4 labels and 2 series 0 50 100 150 Before, iPhone simulator UIKit: 58.8 Before, iPhone simulator SwiftUI: 59.9 Before, iPad Pro UIKit: 120.5 Before, iPad Pro SwiftUI: 120.4 During, iPhone simulator UIKit: 58.8 During, iPhone simulator SwiftUI: 0 During, iPad Pro UIKit: 119 During, iPad Pro SwiftUI: 5.4 iPhone simulator UIKit iPhone simulator SwiftUI iPad Pro UIKit iPad Pro SwiftUI recording frames a second Before During

On the simulator the server went from 59.9 frames a second to none while SwiftUI’s thread was stopped, and on the iPad Pro from 120.4 to 5.4. The UIKit animation was untouched in both. So the render server does not animate a SwiftUI animation by itself. It draws what SwiftUI’s thread sends it, and when that thread stops it has nothing to draw. The iPhone 17 Pro Max’s counter drew 60 frames a second in every run, so it is not an instrument there, and I left it out.

So which is it

Both are true, on different platforms.

MaciPhone and iPad
UIView.animate, animator()data, the render server runs itdata, the render server runs it
SwiftUI built-in animationthe app writes every pass, on the main threadpresentation modifiers set from SwiftUI’s own thread, model written twice, no animation
SwiftUI custom animation callbackon the main threadoff the main thread
Main thread blockedthe SwiftUI animation stops, the dot waitsit keeps going at the same speed as UIKit
SwiftUI’s own thread stoppednot applicablethe SwiftUI animation stops, the UIKit one does not

The popular description of SwiftUI as a pull model driven from the main thread is a description of the Mac. And Apple’s sentence from WWDC 2023, that built-in attributes run off the main thread, fits iPhone and iPad and does not fit the Mac.

What it means for your code

Three things I would act on, and one I would not.

  1. On the Mac, SwiftUI animations share the main thread with everything else. A slow body, a long layout, a synchronous call, and the animation stutters, where a Core Animation animation would have kept moving. For motion that must not stutter on the Mac, the framework’s own animation is the safer choice.

  2. A custom animation’s animate is not on the main thread on iPhone and iPad. If it touches main-actor state, that is a data race waiting to happen. I measured it with a callback that only counts, so I know where it runs, not what breaks.

  3. Do not generalize from one platform. Whatever you read about how SwiftUI animates, check which platform it was measured on. I nearly did not.

And the one I would not: do not build on the private classes in this post. I used them to look, nothing else.

What I got wrong on the way

This is the part worth reading twice, because the mistakes are easy.

My first iOS runs read model values only and showed almost nothing happening. The motion only appeared when I read presentation values too.

Then I counted 54 setter calls for an opacity animation on the iPhone where the app made 4. Reading layer.presentation() builds a copy of the layer, and building the copy calls the setters I was hooking. I was counting my own probe. The hooks now stand down while the probe reads.

I first tested the blocked main thread by reading presentation values from a background thread. It said UIKit’s own animation had stalled too, which I knew was false. A measurement that fails on the case whose answer you know does not measure what you wanted, so I dropped it and counted callbacks and then screens instead.

I moved a threshold after seeing the data, from 20 different values to 10, because two iPhone cases fell at 19 and 20 on a 120 Hz screen. It changes those two verdicts only.

My first colour thresholds for finding the red dot sat on the edge of the iPhone’s red and found it in only some frames, which showed as impossible speeds. I widened them and recorded all eight runs again.

And I guessed that the 72 byte message carried a layer’s properties. Decoding it showed it does not.

What I do not know

  • Where the last number in each frame’s record comes from (a few microseconds), what the command is called, and why each modifier has three records in the shared memory.

  • Stalls shorter than about 35 milliseconds on a real device: the recording I could take is 60 frames a second, also on the 120 Hz iPad.

  • scaleEffect and rotationEffect with the main thread blocked, on both platforms. The blocked test only covers the custom animation.

  • How many messages SwiftUI sends per frame on the Mac. I counted them on the iPhone simulator, not on the Mac.

  • Transitions, matched geometry, keyframe and phase animators, Canvas and TimelineView. I probed five properties and four kinds of animation, in a hosted view with a fixed layout.

  • Whether the Mac differs on a newer build than 27.0.1.

The probe, the recordings and the decoded bytes are in a private repository for now. Every number in this post is in the charts and tables above, taken from those recordings.

Apple UI Insider

A free newsletter on how Apple frameworks really work, from Core Graphics and Core Animation to SwiftUI and motion systems.

Powered by Buttondown.