SwiftUI: Transform Your Developer Mindset
For many years, we have leaned on UIKit to design user interfaces, but SwiftUI is now gaining momentum as a powerful alternative. SwiftUI not only reduces the amount of code needed to create the same UI, but it also saves developers from the trouble of debugging auto layout problems. To fully understand and adopt this shift, we need to adjust our mindset accordingly.
Event-Driven Approach
UIKit offers an imperative, event-driven user interface where views are built through a series of events that perform various operations and eventually combine to create what appears on the screen. This means you need to manage all state changes during events such as when view is loaded or button is pressed. The main disadvantage of this approach is the difficulty of maintaining synchronization between the user interface and its state. Each time the state changes, you have to manually adjust views—adding, showing, or hiding them—to keep everything aligned with the current state.
Let’s say we have a list that retrieves its data from a backend service. The user can press a button to get the most updated list. Let’s take a look at how this would be represented in UIKit.
To sync your UI with the current data, you must call
tableView.reloadData()
. If we don’t make this call, the prior work becomes meaningless.
This process shows that the view controller handles many tasks. In a more complex app, this can make the view controller very large and hard to manage. You’ll need to write code and set up logic for how the view controller, view, and events interact. Since you are responsible for the entire process, you must also think about performance.
Data-Driven Aproach
SwiftUI is a declarative, data-driven UI framework. But what does declarative
really mean? In an imperative world, you’re responsible for all aspects, such as layout, behavior, and data binding.
In contrast, a declarative world lets you specify what you want, and the framework manages the details. It’s like assembling furniture yourself (imperative) versus buying a pre-assembled piece from a store (declarative).
With a data-driven framework, the view controller is no longer needed. The state, directly controls what is displayed to the user. Let’s see the same example in SwiftUI.
The list data is tied to the view, so any changes to the list automatically update the UI. When user tap the button, we just update the state, and the view refreshes with the new list—no controller needed. This is what makes it "data-driven”.
UI = f(state)
In SwiftUI, the UI is a function of the model’s state. To update the UI, you no longer directly manipulate the individual UI components. Instead, you bind the UI elements to underlying models.
It’s easy to grasp the basics of SwiftUI, but what happens behind the scenes is less clear. To minimize this lack of clarity and write effective code, there are key concepts we need to understand.
View Identity
In UIKit, views are class type, and thanks to nature of reference type, each view has its own pointer for identification.
In SwiftUI, views are structs and do not use pointers. To keep things efficient, SwiftUI needs to know if views are the same or distinct.
This helps the framework handle transitions smoothly and render views correctly when their values are updated. A view is a struct that conforms to the view
protocol, unlike a class that inherits from a base class like UIView
.
This means that your view doesn’t inherit any stored properties. It is allocated on the stack and passed by value.
View identity is crucial for understanding SwiftUI's rendering process. SwiftUI uses two methods to identify views: explicit identity and structural identity. Let’s explore both approaches.
Explicit Identity
Views can be identified through custom identifiers.This is something you might notice when iterating over views in a ForEach loop.
Explicit identity can be set using .id(...)
, which ties a view’s identity to a specific value.
_10func id<ID>(_ id: ID) -> some View where ID : Hashable
Let’s take a look an example:
_14struct OlympicsMedalList: View {_14 let Countires = [Country(name: “Türkiye”, capital: “Ankara”, medalCount: 10),_14 Country(name: “Spain”, capital: “Madrid”, medalCount: 15),_14 Country(name: “Italy”, capital: “Rome”, medalCount: 20)_14]_14 _14 var body: some View {_14 ScrollView {_14 ForEach(Countires, id: \.name) { country in_14 RowView(country: country)_14 }_14 }_14 }_14}
RowView will be identified by the name, as each country name is intended to be unique.
Tips for Using Explicit Identity:
- Use unique and stable identifiers to avoid bugs.
- Only apply explicit identity when necessary, as overuse can impact performance.
- Ensure identifiers remain consistent, especially when data is updated from external sources.
Structural Identity
All SwiftUI views need an identity. If a view doesn’t have an explicit identity, it will have a structural identity. Structural identity is determined by the view’s type and its position in the hierarchy. SwiftUI uses this hierarchy to provide an implicit identity for the views.
_14struct LogoView: View {_14 @state var isLoading: Bool = true_14_14 var body: some View {_14 if isLoading {_14 progressView()_14 } else {_14 LogoImage()_14 }_14 }_14}_14_14print(Mirror(reflecting: LogoView(user: nil).body))_14// Mirror for _ConditionalContent<progressView, LogoImage>
This approach results in two entirely distinct views depending on the boolean state. In reality, SwiftUI creates an instance of ConditionalContent behind the scenes. This ConditionalContent view manages the display of one view or another based on the condition. Even if these views are not distinct but rather instances of the same view with different parameters, they will still have different identities.
_13struct LogoView: View {_13 @state var isLoading: Bool = true_13_13 var body: some View {_13 if isLoading {_13 LogoImage_13 .mode(.loading)_13 } else {_13 LogoImage_13 .mode(.success(data))_13 }_13 }_13}
By using an if statement in this example, we conditionally enable or disable the view. SwiftUI will handle the creation and destruction of views according to the state of the condition. The process may look accurate, but it leads to losing the state of the ComplexView instance during condition changes because SwiftUI regenerates the view within the if statement branches. Additionally, it may cause unwanted animations during the view transitions. Apple recommends keeping the view's identity by using conditionals in view modifiers, rather than using if/else statements.
_10struct LogoView: View {_10 @state var isLoading: Bool = true_10_10 var body: some View {_10 LogoImage_10 .mode(isLoading ? .loading : .success(data))_10 }_10}
Effective use of structural identity is crucial for optimizing your app and decreasing the number of bugs.
View Hierarchy
The fundamental UI element in SwiftUI is a View. How well we define and manage its state significantly influences the app’s performance and visual quality.
_10public protocol View {_10 associatedtype Body : View_10 _10 @ViewBuilder var body: Self.Body { get }_10}
A computed body property defines the content of the view. SwiftUI builds a hierarchy of views. When SwiftUI renders a view, it retrieves the view's body. If the view includes custom subviews, SwiftUI will also need to fetch the body of each nested custom view to display everything properly. Here’s an example:
SwiftUI can't keep asking for view bodies endlessly, it needs to break recursion at some point.
It needs a core set of primitive views that can be rendered directly without requiring their body.
In this example, recursion terminates when SwiftUI requests the body of Text
, Image
or Spacer
.
SwiftUI’s primitive components does not require further body requests for rendering. This is managed by the Never
type:
The type Never is an object type with no values and cannot be constructed.
It is primarily used in Swift functions that terminate execution.
Additionally, since the compiler needs to handle every object as a view, Never
is considered a View
.
_10extension Never: View {_10 public typealias Body = Never_10 public var body: Never { get }_10}
When SwiftUI faced with a view that has a Never body, it knows exactly when to halt, effectively breaking the recursion.
Lifetime
When a view's identity changes, it marks the end of its lifetime. The lifetime of a view is closely linked to its identity, so to maintain consistent state, you must ensure that its identity is properly assigned. However, when a view's property changes, the view itself remains the same. The identity of the view stays constant; only the state of the view has been updated. When a view appears on the screen (after onAppear is called), any updates to the view generate new values, but SwiftUI still considers it the same view. This means the view's identity and lifetime remain unchanged. The view's lifetime ends when its identity changes or when it is removed from the screen (after onDisappear is called).
Key points:
- A view remains active for as long as its identity is valid.
- You manage the lifetime of a view by controlling its identity.
- Always assign a stable identity to your data.
- Every pixel you see on the screen is defined by a view. What makes SwiftUI views special is their declarative, compositional, and state-driven nature.
- Refactoring your SwiftUI code is fine because extracting subviews has almost no performance cost. SwiftUI views and traditional UI views both create parts of the interface. In SwiftUI, a view requires just one property: body, which is itself a view.
Conclusion
In summary, SwiftUI is more than just a new tool for building apps; it’s a new way of thinking about app development. SwiftUI helps you write cleaner code, manage app state more easily, and smooth integration across Apple’s ecosystem. SwiftUI is not write once run everywhere, it is learn once run everywhere. Based on my experience with SwiftUI, I can tell you it’s a powerful tool that redefines how you build apps. Dive into SwiftUI and discover how it can simplify your development process and improve your user experiences.