SwiftUI Picker: Unreasonable tricks for reasonable behavior

Dye is a very simple app, with a very simple UI. It lists installed applications, each with a pop-up menu for its theme color. That’s about it.

Screenshot of macOS app Dye. The window is mostly just a list, each row representing an app—Finder, Mail, Music, etc—and offering a theme color picker. The various rows are set to explicit colors—yellow, green, etc—or sometimes to special values, Automatic or App default. The toolbar contains a search field in its toolbar, and the apps are split into two sections: Open Apps, and All Apps.

Dye: just a list.

Despite this simplicity, the first implementation of that list was shockingly badly-behaved. It was:

  1. Extremely slow (scrolling would cause multi-second freezes!)
  2. Visually broken in multiple ways (you’ll see)

The implementation was naive but reasonable. The output, anything but. How come?

Using images in Picker options

Look, I like colors. So if my app has a whole menu of them, I want to show cute little swatches:

A pop-up menu. It lists colors—from Graphite to Pink—preceded by two special entries, Automatic and App default. Each item prefixes its title with a circle of the represented color.

That didn’t work at first, though. For its options Picker, only accepts some text, plus an image; anything else seems to get ignored. So this sensible-looking piece of code plain doesn’t work:

struct AccentColorPicker: View {
	@Binding var selection: AccentColor
	
	var body: some View {
		Picker("Accent color", selection: $selection) {
			ForEach(AccentColor.allCases) { accentColor in
				HStack {
					Circle()
						.fill(accentColor.color)
						.strokeBorder(.tertiary)
					
					Text(accentColor.name)
				}
			}
		}
	}
}
The same menu as above, except there are no color circles at all.

SwiftUI ignores the Circle views.

Well, if an image is what it takes, we can make one. Dye’s solution is to make use of SwiftUI’s ImageRenderer, a class for rendering a view into an image. We create our own Rasterize view, which draws into content view into an image; then we wrap the circle with it. This works!

// Defining Rasterize
struct Rasterize<Content: View>: View {
	@ViewBuilder var content: Content
	
	var body: some View {
		if let nsImage = ImageRenderer(content: content).nsImage {
			Image(nsImage: nsImage)
		}
	}
}

// Using it
struct AccentColorPicker: View {
	@Binding var selection: AccentColor
	
	var body: some View {
		Picker("Accent color", selection: $selection) {
			ForEach(AccentColor.allCases) { accentColor in
				HStack {
					Rasterize { // here!
						Circle()
							.fill(accentColor.color)
							.strokeBorder(.tertiary)
					}
					
					Text(accentColor.name)
				}
			}
		}
	}
}
The same menu as before, except the options now have their color circles. However, in the closed menu’s button, the swatch-to-text spacing is different than that from the items’.

The main drawback here is that the ImageRenderer’s view tree is disconnected from the main view tree; as a result, the environment must be bridged over explicitly (as seen in the full source to Rasterize), which breaks dependency tracking, making the user responsible for keeping the environment up-to-date. It works, though!

Fixing the spacing inconsistency

Look at the screenshot above again, though. There’s something wrong with the space between swatches and text: it’s inconsistent between in the popup button at the very top, and the menu items below! Yikes.

I tried a few variations—using a Label instead of an HStack, for instance—but the output was always the same. It looks like that’s just how Picker looks on macOS.

So let’s roll up our sleeves, and reimplement the popup button’s appearance. It’s not that much code, and the double arrow is conveniently available as a SF symbol. What you see in the app, then, is a totally fake button; but overlaid on top is the real Picker button, set to near-zero opacity.

That’s an absurd amount of work for a basic piece of UI, but it works!

The same menu still, but with color circles, all correctly spaced.

Making Picker fast (in two parts)

Okay, the Picker looks good, now. But, surprise surprise, scrolling the list is extremely choppy. Like stop motion, without the motion. What gives?

Turns out, computing the width of the popup button is very slow. That requires measuring the width of every option in the menu, to make the button fit the widest one1. It seems that doing these measurements in SwiftUI is very slow; this was the case both for the native Picker view, and our own fake recreation, which goes through the same process2. What to do?

Part I: No hover, no picker

If Picker is slow, let’s avoid rendering it.

Now, when you scroll through the list in Dye, no picker is actually there; just the fake button, the appearance. Only once you hover the button does the Picker get created, before you have a chance to click it. In practice, the interaction works exactly the same, but scroll performance is dramatically improved, since we skip the repeated measurements. Simple and effective.

struct FakePicker<FakePickerContent: View, PickerContent: View>: View {
	@State private var shouldRenderPicker = false
	[...]
	
	var body: some View {
		fakePickerButton
			.onHover { isHovering in
				guard isHovering else { return }
				shouldRenderPicker = true
			}
			.overlay(alignment: .trailing) {
				if shouldRenderPicker {
					actualPicker
						.labelsHidden()
						.opacity(0.1)
						.opacity(0.1)
						.opacity(0.1)
				}
			}
			.accessibilityRepresentation {
				actualPicker
			}
	}
}

Part II: Designated sizer

Now, for the final trick.

Scrolling is still slow, because we still create our fake popup button for every rendered row, which means we’re repeatedly measuring all ten options. There’s no way around rendering that fake button, but! Its width is the same in every row; why do we keep recomputing it?

There’s plenty of ways of computing that width once, then using the cached result in every fake picker. You could even make the argument for a hardcoded value, since macOS doesn’t have a system-wide font size setting, and Dye is currently only available in a single language. Feel free to imagine your own, reasonable solution; or keep reading for an overkill one.

The idea here is that the fake pickers’ content views are still responsible for computing their width, except one of them is randomly selected to make the measurement, sharing the result with everyone else. To achieve this, there’s a new .minSizeWithSharedCache modifier on the fake picker content, and a .sharedMinSizeCache modifier at the root of the app. They look like this in practice:

// Using .sharedMinSizeCache: it coordinates everything
struct ContentView: View {
	var body: some View {
		[...]
		.sharedMinSizeCache()
	}
}

// Using .minSizeWithSharedCache: it communicates with the root cache
struct AccentColorPicker: View {
	@Binding var selection: AccentColor
	
	var body: some View {
		FakePicker {
			// The fake, visible picker button
			pickerOption(for: $selection.wrappedValue)
				.minSizeWithSharedCache(alignment: .leading) {
					ForEach(AccentColor.allCases) { accentColor in
						pickerOption(for: accentColor)
					}
				}
		} picker: {
			// The actual picker (invisible, but clickable)
			Picker("Accent color", selection: $selection) {
				[...]
			}
		}
	}
}

How do these work? It’s an easy 5-step process of mad back-and-forth.

  1. Each .minSizeWithSharedCache chooses a random UUID for itself, and propagates it up through a preference.
  2. The root .sharedMinSizeCache receives all the UIIDs, and picks one arbitrarily. It propagates the chosen UIID back down the tree through the environment.
  3. The .minSizeWithSharedCache that got chosen computes the minimum size, by measuring its content view. It then sends that size back up the three through a preference.
  4. At the root, .sharedMinSizeCache receives the measured size, caches it in its state, and sends it back down through the environment.
  5. Finally, all .minSizeWithSharedCache read that value, and use it as their minimum size. Goal achieved: the size was computed just once, but enforced in every row.

This was honestly very entertaining code to write! I think it’s especially fascinating how it’s essentially imperative code, expressed through declarative modifiers. It’s an absolute mess to read, because on top of that mismatch between behavior and expression, respecting view tree constraints forced an unnatural order for everything. Glorious, and dismal.

In conclusion

I’m not sure how to convey this through words without sounding sarcastic. Doing all this work—reimplementing the picker appearance, creating a Rasterize view, lazy-rendering the actual control, and creating an over-the-top cache network—was a ton of fun! Engrossing engineering work, solving puzzles with unusual tools.

And at the same time, it’s a maddening amount of work for such a basic user interface. A naive AppKit implementation would have achieved excellent performance with little effort. SwiftUI, at the ripe old age of 6, just shouldn’t struggle with such basics. To me, the framework is built on remarkable foundations3, but is as a whole deeply immature, somehow starved for development resources. It’s delicious jam spread too thin.

Oh well; there’s always next WWDC.


  1. “Measuring text is slow” sure is recurring theme. Last time was about thousands of lines, though, not eleven words. ↩︎

  2. Jason Gregori points out on Mastodon that most of what makes the FakePicker slow to measure is our Rasterize view. This is in strange contrast to the native Picker, which seems slow even when only containing text. ↩︎

  3. SwiftUI’s core concepts are both excellently designed, and largely misunderstood. I happen to teach their secrets, though! ↩︎