Fundamental Differences of Compose and SwiftUI

From the Perspective of an Android Developer

Filip Wiesner
MateeDevs
10 min readMay 16, 2023

--

When I started experimenting with SwiftUI, I often heard that both SwiftUI and Jetpack Compose are quite similar. Both are declarative UI frameworks with similar primitives like vertical layouts (Column/VStack), horizontal layouts (Row/HStack), and their lazy alternatives. People often say that they work the same way and are so similar that once you know one, you can easily jump to the other and feel right at home.
After some experimentation, I’ve come to realize that while this may appear true at first glance, the true “shape” of these frameworks is drastically different. Both are tools designed to accomplish the same task, but there are some fundamental differences that shape the way you use them and think about them.

Before we dive into the nitty-gritty, I just want to mention a quick disclaimer. Although I experiment with various technologies (including web and backend), most of my experience is with Kotlin. Some of the pain points I encounter with SwiftUI might stem from my relative inexperience with Swift, UIKit, and Apple’s approach in general. However, I’ll try to remain as objective as possible and minimize any biases throughout this article.

First Bumps

When I first got my hands on SwiftUI, I immediately recognized similar patterns in working with the pre-defined components. The basic layouts — VStack, HStack, ZStack — are the same. You simply call the layouts inside the view hierarchy using a trailing closure and place other elements like Buttons and Texts inside. The alignment and spacing parameters are also similar, so everything felt familiar. But then I wondered, “How does the layout system work in SwiftUI?” And what better way to find out than by looking at the implementation of the most basic layouts? If you’ve ever used SwiftUI, you probably know where this is going, but it might still be a surprise for many Android devs — the SwiftUI framework is closed source. So when I Cmd+clicked on the definition of VStack, I only saw some sort of header file with a constructor and nothing else.

This felt like the first bump on the imaginary SwiftUI road and offered a glimpse at how different the mindset of iOS developers can be compared to what I’m used to. I’m so accustomed to looking through implementations and learning about how things work under the hood that I didn’t realize something so fundamental could be hidden from me as a developer.
The second significant difference, closely related to the closed-source nature of Apple’s frameworks, is that SwiftUI is bundled with the OS. I was already familiar with this aspect when starting with SwiftUI, and it’s not just SwiftUI that is part of the OS — the Swift runtime itself is also bundled. In contrast, from Android’s perspective, Jetpack Compose (and Kotlin) is just another library that you can update and change whenever you want. Both approaches have their advantages and disadvantages, but the important thing is that these differences fundamentally change the way we use both frameworks.

The Tree

The primary role of a UI framework is to create and, more importantly, maintain a tree (hierarchy) of UI nodes (elements). Both frameworks are declarative in nature, so they share many of the same concepts and terminology — “re-evaluation” of some part of the tree when the state changes and easy “composition” of small UI elements to form larger components. For this to work, UI frameworks must react to state changes and handle the diffing of changes in the UI tree. Every change in input data can trigger a re-evaluation of part of the tree, potentially resulting in significant changes to the UI. Both frameworks are well-equipped to handle these changes, but they take different paths to achieve this.

With the help of a compiler plugin, Compose changes the signature and contents of each view (Composable) function at compile time to ensure that each state change will update all parts of the tree that have read it. The Compose compiler plugin also generates bitmasks that help with re-evaluation skipping. The most interesting aspect for an iOS developer, though, is that each view (composable) is just a function without a return value that can hold state and contain any number of other composables. A composable is not something you can hold a reference to; there is no instance of a Composable. It’s just a function that you can call anywhere, which will execute its transformation on the state in the current call context.

SwiftUI has chosen a completely different approach by using a static type system. It utilizes Swift’s Result Builder feature that can interpret Swift expressions and combine them into a single result. This SwiftUI-specific Result Builder is called ViewBuilder, and it can have various results: EmptyView (nothing), Content (a generic type of some content), TupleView (1–10 “contents”), _ConditionalContent (true or false branch), Content? (optional content), and AnyView (any view defined at runtime).

Swift also has one more trick up its sleeve — the some keyword. Every SwiftUI component has some View as a return type. This essentially means that the return type is something that conforms to the View protocol, but it is inferred at compile time to be a specific type and cannot be changed at runtime to another type that also conforms to the View protocol. Because of this, the entire statically typed UI tree can be built at compile time and cannot be changed at runtime. This has some significant benefits for diffing, animations, and other cool features.

Ergonomics

Another crucial aspect of any framework, perhaps even the most important, is the way it’s used and how effortlessly and efficiently developers can achieve their desired outcomes. In the world of UI frameworks, customization plays a key role in creating unique user experiences. For both Jetpack Compose and SwiftUI, there are two primary ways to customize UI elements: 1. component parameters and 2. the Modifier system.

Parameters are usually used to provide the most basic configuration of a given component. For example, for Text, it is the actual string value; for Button, it is the onClick handler; and for TextField, it is the current value and some sort of onValueChange handler.
Modifiers serve a different purpose. They provide a more general API surface and can be applied to any component, e.g., visual transformations like padding, background, or interactive behavior like on-click listeners.
So, if I had to summarize, I would say that parameters are component-specific and modifiers are component-agnostic. Except that is not entirely true for SwiftUI.

SwiftUI leans more heavily into the modifier system and, by leveraging its typed nature, there can be component-specific modifiers. One example is the renderingMode() modifier that can be called specifically on an Image component. The catch is that other modifiers on Image return generic View, so these View-specific modifiers must be called directly on the related View.

Additionally, there are some modifiers that are specific to a component but can be called on any View, e.g., textFieldStyle() or lineLimit(). When used, these modifiers will apply to all relevant Views in a given subtree or do nothing if no relevant Views are present. These styles can be further customized by implementing style protocols like ButtonStyle, which provides a way to offer a modifier chain that will be applied to relevant views.

For the sake of transparency, I’ll step out of my unbiased bubble for a second to say that the API design of SwiftUI is the one thing I truly dislike. There is no clear API surface because anything can be either a parameter or a modifier. Why is the alignment of VStack a parameter and not a modifier? Why is the font size of Text a modifier and not a parameter? How do I change the looks and behavior of a button? Are the button-specific modifiers prefixed with “button,” or are there some hidden modifiers that I might use? How is it good API design that I can set a screen title by using the navigationTitle modifier on any View anywhere in the whole screen UI tree?! … Sorry, I got carried away a little 😅.

I must say, I can see the flexibility in this design, where you can “color” the whole subtree with a specific style modifier or something similar. I just find it hard to work with sometimes.

State

But what is the purpose of a UI if there is no way to fill it with the information we want, right? That’s where state management comes in. In the declarative world, we need a way to describe the desired output with the usage of some state, and the frameworks need to take care of updating the result on state changes.

Jetpack Compose has State interface with three main entry points: mutableStateOf(), mutableStateListOf(), and mutableStateMapOf(), which can be used to hold the state. Reads of this state during compositions (no matter how nested) are automatically subscribed to its changes, so the current scope gets re-evaluated when needed.
One nice aspect of Compose State is that it’s uncoupled from the UI part of the framework, so it can be utilized outside of the presentation layer.

SwiftUI has a similar concept with the DynamicProperty protocol, described as “An interface for a stored variable that updates an external property of a view.” However, DynamicProperty is not meant to be used directly; instead, developers should use one of many (17! 👀) property wrappers that handle specific use cases, such as @FocusState for managing focus, @GestureState for handling gestures, or EnvironmentObject for sharing state across multiple levels of the tree.

Swift Property wrapper is an object that wraps a value and provides additional logic, and its usage is signaled by the ‘@’. The Kotlin equivalent is Delegated properties.

Most of the time, you’ll use @State if your state lives inside the View, or a combination of @ObservedObject and @Published property wrappers if your state lives outside of the View. Views that have both input and output contain a special Binding parameter, which is a property wrapper that can be initialized with get and set closures. This allows you to provide the component with a value and receive a set callback when the value is supposed to change. If you have a state value created using the @State, you can automatically obtain a Binding for them using the ‘$’ prefix. In this way, the value in the state will be synchronized with the component. If you didn’t create your state using the @State property wrapper, you’ll have to provide your own Binding with implemented get and set methods.

Different usage of Binding

With property wrappers like AppStorage for using UserDefaults, FetchRequest for database access directly in UI, and convenient Binding for State created directly in UI, it seems that SwiftUI is not only pushing developers to use it as a UI framework but also as a central hub for managing all aspects of their app, including domain logic and data management.

Quick Bits

There are a few smaller aspects I wanted to mention that are quite different but not extensive enough to warrant their own sections. So, I’ve dedicated a few short paragraphs to them. Please note that I haven’t done as much research on these topics, so they might come across as slightly more biased.

Animation
Magic! I believe that SwiftUI’s typed system plays a significant role in making animations feel so magical. Only one or two lines (modifiers) are usually enough to create beautiful animations between two states. Typically, you can get away with just .animation(.default, value: someState). However, magic isn’t always a good thing. Sometimes you want more customized and complex animations, and in this regard, I find Compose’s animation API to be more consistent. It feels similarly challenging to animate one thing as it does to animate five different things simultaneously while still maintaining control over what’s happening on the screen. SwiftUI excels at quick and visually appealing animations, but Compose gains an edge when dealing with more complex animations.

Documentation
Most of the components and modifiers are really well documented with examples and descriptions, and there are a few really nice interactive tutorials for SwiftUI basics. I found myself looking through Xcode documentation quite often and liking what I saw most of the time. However, with SwiftUI, I used way more “third party” tutorials and articles than when I was learning Compose. I think this is partly due to SwiftUI being closed source, so I can’t look at how the actual base components are implemented and take inspiration there.

Tooling
Oh, Xcode! Only one thing to say about this: it’s such a good feeling when I return to Android Studio.

Interopability
I don’t have much experience with UIKit, but from what I’ve seen, the interoperability between SwiftUI and UIKit seems to be pretty good. In fact, it appears that many SwiftUI components (like NavigationView) are just wrappers around UIKit components.

Conclusion

So yeah, here we are. I think that using SwiftUI is really fundamentally different from using Compose, even though it might not be immediately obvious. The feeling from using it is just so different, which I feel is mainly due to Apple’s approach to developers, such as providing a subpar IDE and keeping SwiftUI closed source. And honestly, I wish I liked SwiftUI more because it can be a really powerful tool with some great ideas. For example, I genuinely appreciate the ease of use of the animation API and how simple it is to create great-looking skeleton placeholders. But because of (subjectively) bad API design and (objectively) atrocious IDE, I didn’t really enjoy the time working with it.

--

--