What is not so great about SwiftUI

There's a trillion blog posts about everything that is so great with SwiftUI. Now that we all know what is so great about it, I thought I'd write something about what is not so great. This post focuses on macOS but should apply to iOS as well.

The Layout System

This is a SwiftUI app, no AppKit bridging, only using fully native types: VStack, HStack, Text, Picker, Toggle, DatePicker.

As you probably notice there's a lot of things not looking right: the popup button is too wide, the text field and the checkbox label are truncated, the date pickers are too wide. When I originally saw this in the first Catalina betas, I filed a bug report and assumed this was going to be fixed, but two years have since passed and this is still the layout that SwiftUI produces. I can make things better by attaching the .fixedSize() modifier everywhere but I would think this is not how SwiftUI is supposed to be used, this modifier also prevents views from truncating when they actually should and it sizes the popup button incorrectly (it sizes to fit the current selection instead of sizing to fit the largest item in the list).

What seems to still be missing from SwiftUI is the notion of the intrinsic content size: the natural size that a view really wants to be unless it is forced to be something else. This is the size that views should adopt by default. Working in-hand with this would be the ability to define compression resistance priorities and content hugging priorities, something that SwiftUI also lacks. These are important features to describe how views should behave relative to each others when they are forced to deviate from their natual size.

This would allow to build simple layouts that we could easily expressed in auto-layout but can't anymore with SwiftUI. For example I had a need to force a stack view width to match one of its child view but not the others, meaning the other child views would compress and truncate as appropriate. There is seemingly no easy way to achieve something like this in SwiftUI.

Another piece that is missing is the ability to describe whether views should be able to push the window edges to match their natural sizes. This was easily achieved in auto-layout by setting the compression resistance and content hugging priorities below or above the .windowSizeStayPut priority.

Building Custom Views

Sizing the view

The first problem is how to consult the size proposed by the parent view. It seems the only way to access it is via a GeometryReader, however the geometry reader is itself a view that wraps your custom view and has a layout of its own, filling all the available space provided by the parent view. You heard that right: merely looking at the size proposed by the parent will change the layout in unwanted ways.

Secondly there is no obvious way to declare an intrinsic content size that the view really wants to be unless forced otherwise. I originally thought the .frame(idealWidth:idealHeight:) modifier would do this but it doesn't, it does something else which is not exactly obvious (if I understand correctly this 'ideal' size is the size that is enforced when the parent uses the .fixedSize() modifier on the view).

Container views

I wanted to build a simple VStack-like view that adds separators in-between views. This is a bit tricky because I couldn't merely add a border around the views, I needed a one-line separator in-between the views, not around, not on top of the first view and not below the last view. I added a @ViewBuilder input to my view so I could call it from the body. But then what? This returns an opaque View which cannot be used to enumerate the child views and place decorations. Again there is seemingly no easy way to build this simple view in SwiftUI (one solution would be declare my own function builder type to produce an array of views, but it doesn't seem very right to use something other than @ViewBuilder). It is bothering that third-party developers are not able to write the same kind of views that Apple provides in SwiftUI.

Working with AppKit/UIKit

Messaging the underlying view from SwiftUI

AppKit/UIKit can be used from SwiftUI using NSViewRepresentable which is great to address the shortcomings in SwiftUI. SwiftUI still very new and lacks some important features, there are no table views for example. However the integration can be quite difficult, these are two very different systems that cannot always be made to talk to each other. For example I have a SwiftUI wrapper for SFAuthorizationView (which provides a simple lock/unlock button such as the ones you can find on macOS in System Preferences) and I needed to manually authorize the lock when the user clicks a button, which you would do by calling its authorize() function. But the SwiftUI code only builds a description of the wrapper view, there is no function that can directly be called to talk to the underlying AppKit view. The natural SwiftUI way of doing this would be to add a new state variable such as @State var isAuthorized: Bool and pass it along to the view representable but alas this really doesn't work well as the SFAuthorizationView owns the state already (the authorization reference). There is no obvious way that I know to address this sort of discrepancies.

Localization

SwiftUI views usually accept a LocalizedKey type to allow for localization. But AppKit/UIKit views don't. Unfortunately there is no way to retrieve the underlying localization key nor the localized string from LocalizedKey objects making it impossible to write a proper interface for views wrapping Appkit/UIKit views.

State

SwiftUI provide @State and @StateObject property wrappers to handle state tied to a view appearing on screen. Sometimes the state initial value needs to be derived from the view inputs rather than being hardcoded. There is no really obvious way to do this. The state cannot be assigned during the view initialization, assigning it via self._property = State(initialValue: ...) only works when first called and not when the view is rebuilt while still on screen. The only solution I have found is to use the .onAppear modifier to initialize state and the .onChange modifier to listen to changes on the inputs in order to be able to reset the state. This is cumbersome and has some issues: onAppear only gets called after things have been put on screen (there is no willAppear modifier) and onChange is only available on macOS 11+ systems.

2020, November 27

@tclementdev