Published on

SwiftUI for UIKit developers - Part 1

Authors

You're an experienced UIKit/AppKit developer trying to make the first steps towards SwiftUI. You already master the iOS/macOS platform and the docs explain the API well enough, but:

  1. You're having trouble understanding how SwiftUI works behind the scene.
  2. You can't always tell why things don't behave as you'd expect.
  3. There are many new ways you can solve a problem but you're lacking the intuition to pick the one that's fitting best the SwiftUI paradigm.
  4. Some things like navigation and dependency injection look weird and trigger your dev senses.
  5. You feel like most of the SwiftUI sample projects out there don't scale to real-life scenarios.

If this sounds familiar, you're in the right place. We're going to talk about all these points in an eight-part article here at swiftcraft.io while reverse-engineering SwiftUI and learning its inner workings. So, buckle up, and let's jump right in.

Subscribe and get notified when I publish the next part!

Here's the plan for this introduction, we'll start with:

  1. Swift language features that make SwiftUI possible

Then we're going to talk about:

  1. SwiftUI as a declarative DSL
  2. State update propagation

And then explore the central concept of SwiftUI:

  1. Views as a function of state

Finally, some opinionated advice:

  1. Small advice on learning SwiftUI

Swift language features that make SwiftUI possible

There are 3 relatively novel (and rarely used in UIKit), features that make SwiftUI possible:

Result builders

This is what gives SwiftUI its hierarchical structure looks:

let group = Group {
  Text("Hello")
  Text("World")
}

This resembles a tree-like structure: a parent and two children. It might not make a lot of sense tho, might not even look like Swift, but let's break it down:

Given the Group initializer:

struct Group {
  init<V>(@ViewBuilder content: () -> V) where V: View {
    // ...
  }
}
  1. Group and Text are both structs
  2. Group's init takes a @ViewBuilder closure returning a view
  3. We use the trailing closure syntax (Group { ... }) to initialize the group
  4. Weirdly we don't explicitly return anything, but initialize two Text instances inside the closure. That's because @ViewBuilder is a result builder and a result builder's job is to take the statements between the closure brackets and feed them to a reducer function that will turn them into a single component, in this case of the same type, View. The above de-sugars to:
let group = Group(viewBuilder: {
  return ViewBuilder.buildBlock(Text("Hello"), Text("World"))
})

Where the reducer is buildBlock, and its return type is a _TupleView (more about these later). Standard Swift code.

You can find the full result builder proposal here.

Property wrappers

A property wrapper is a structure that encapsulates the read and write access to a property, allowing additional behavior on any of these two operations. You can think of it as a decorator: each time you get or set the value of a wrapped property, you're calling the decorator, which in turn gets/sets its internal storage and adds additional behavior as needed.

In SwiftUI we mark the stateful dependencies using property wrappers, e.g. @State, @StateObject, @Environment, and so on. This is because the actual state of a view is externally stored (remember, views are immutable) and we need the property wrapper to manage that external state for us.

Property wrappers are discussed in detail here.

Opaque return types

I'd normally consider this a feature small enough to omit, however, it's crucial in understanding how SwiftUI preserves the type of each view in the view tree.

Opaque types (prefixed with some) are often confused with existential types (prefixed with any from Swift 6 on). They are both used with protocols to hide the underlying type, however, unlike existential types which hide type information from the compiler as well (forcing a dynamic dispatch at runtime), opaque types are resolved and only hide the underlying type from the caller. For this reason, opaque types can only be used with return types, since the compiler needs to infer the type from the return value.

In other words, the compiler always knows the return concrete type of an opaque type, it just doesn't share it with the caller, forcing the caller to rely on the protocol for further action and not the actual concrete type (which is seen as an implementation detail).

You can find the proposal for opaque return types here.

SwiftUI as a declarative DSL

There's a reason why HTML, the most successful UI-oriented (markup) language of all time, is declarative. It feels natural to abstract a user interface as a set of rules persisting over state updates.

The best way to visualize this as an UIKit developer is by comparing it to auto layout. Before auto layout, you'd have to keep track of all the state changes that might impact the layout and manually invalidate it.

In contrast, with auto layout you define a set of rules that cover all possible ways your view can adapt, then let the auto layout engine take control. SwiftUI is similar, except, you're defining rules for everything: views, gestures, animations, and so on, not just the layout. Also, most of these rules are enforced at compile time. With sensible defaults, any compiling UI is a working UI (at least to a large extent, we'll discuss some exceptions later).

An important thing to notice is how SwiftUI works only with descriptions. Low-cost abstractions of things that live elsewhere and are inaccessible to us. Creating a view doesn't allocate any drawing buffer, doesn't render anything, it's just an immutable description of how a view should behave. Same with animations, colors, gestures, and so on. Similarly, the layout is computed at a later time, putting two views in a horizontal stack only describes how those views should be aligned, it does not calculate their actual position on the screen.

State update propagation

On top of invalidating the layout, in UIKit you also have to make sure the view gets updated each time the model or application state changes, otherwise, the elements on screen become stale. The typical flow is: you fetch some data, update your model, then call something like view.update(with:) to display the new data.

SwiftUI does all that for you: it watches the state and optimally updates views when things change. We'll see exactly how it does so, but spoiler, most of the complexity goes into the optimally part.

Views as a function of state

It's mind-blowing that it took so many years for this concept to become mainstream since Elm first popularized it. Especially because it's the most natural way of modeling a view. But here we finally are and it's a great day to be an app developer.

If you think about it, the view has always been a function of state. That's why they're called "views", they are representations, or a way of seeing (aka point of view), the underlying state.

We could recreate all views each time the state changes, making the information flow unidirectional, from the state to the view. But, without the abstractions SwiftUI offers, it would be too expensive. Instead, we'll be recreating the views' descriptions and let SwiftUI figure out the rest. And so, we can start thinking about views as functions of state, instead of service-like entities that need constant bi-directional communication with the rest of the app.

Small advice on learning SwiftUI

In my experience, things started to move smoothly when I fully embraced SwiftUI's paradigm and stopped providing UIKit solutions to SwiftUI problems. Don't linger on the tools, architectures, and techniques you used in UIKit. It will take a while, and that's part of the reason I'm writing this series, but once you embrace it, SwiftUI is a powerful tool that's only going to get stronger with each new iOS release.

What we'll learn next

Next time we'll be focusing on the main protocol in SwiftUI, the View. Then, we'll talk about identity, lifetime, and dependencies.