Demystify SwiftUI
Here are some quick notes after watching this fantastic video from WWDC21 Demystify SwiftUI
*Requires Apple Account
View vs UIView
Views in SwiftUI are very different from UIKit:
- protocol based (vs Inheritance UIKit)
- value type (vs ref type UIKit)
- views as value types are ephemeral (vs mutating models in UIKit)
- as such identity may be implicit/ explicit (vs pointer identity UIKit)
- views/ view-actions are connected through a dependency graph (rather than hierarchical as in UIKit)
Overall, views are analyzed as a dependency graph of identifiable and generic elements that are rendered only when changes are detected.
View Identity
Identity is a fundamental property in order to determine changes over time. In SwiftUI, given that views are ephemeral value types, the concept of identity is not tied with any particular instance as it happens with the so called pointer identity
in UIView-UIKit. Instead in SwiftUI, Views might have
- An
implicit
identity (orstructural identity
that will be inferred by thestructural position
and theclass
- Or an
explicit
identity that may be set through theidentifiable
protocol or the.id()
method.
Structural Identity and Conditionals
When using conditional operators in SwiftUI views it’s important to keep in mind that unless a explicit identity is set, each logical path will be treated as a unique structural identity.
In the example below despite returning the same view SomeViewA
, it will be evaluated each holding a distinct identity.
@ViewBuilder
func someHelper()->some View{if a == true {
SomeViewA()}else{
SomeViewA()}}
That’s the reason why, when animating specific properties it’s better to rely on a ternary operator for short statements or View computed vars for longer evaluations. For instance, below to relative implementations, the first won’t produce a smooth transition given the structural identity change associated with the if else
This implementation will not produce a smooth opacity transition:
@ViewBuilder
func someHelper()->some View{if a == true {
SomeViewA().opacity(1.0)}else{
SomeViewA().opacity(0.0)}}
This implementation will produce a smooth opacity transition:
@ViewBuilder
func someHelper()->some View{
SomeViewA().opacity(a == true ? 1.0 : 0.0)}
Dependency Graph
Views and actions mutating the @State
or @ObserverdObjects
will cause the dependency graph to be recomputed. As views are value based, SwiftUI
can afford to compute ahead a new version to determine if any changes are present, if so, then a new view-body is evaluated potentially cascading until the graph is fully evaluated for changes (ie. stops on the links where a view skips producing a new body).
Stable Ids
Unless intentional, we typically require a stable form of identification for views. A common mistake is to unintentionally create a new Id during the view instantiation, as follows:
struct ContentView: View, Identifiable{var id = UUID() //unstable ID}
Instead an Id may be obtained from a database or contextual reference.
struct ContentView: View, Codable, Identifiable{var dbId:String
var id:String{ dbId }}
LifeCycle
Views are evaluated following a dependency graph approach, this means a view will be instantiated twice as part of the dependency graph diffing mechanism. The propagation through the graph will be stop once a no further changes are detected.
As such, when creating/ evaluating views, parameters play a very important role to determine if the current view-body need to be reevaluated. Once a change is determined, a new body is created potentially propagating changes to children subviews.
@State
properties will be persisted as long as the implicit (structural) or explicit identity for that given view stays alive.
State Properties
@State
properties are tied to the view and last only during the duration of that particular view identity lifecycle (not the view value per se, meaning the values will mutate in the view and the @State
vars will be the same
AnyView
The AnyView
struct is a type-eraser
that allows to obfuscate the inner generics within a given view hierarchy. It may be helpful in situations where no other options (that would keep the generic types intact) are available.
For instance when creating functions that may return different view types, then its better to rely in the ViewBuilder to support up to 10.
@ViewBuilder
func someHelper()->some View{if a == true {
SomeViewA()}else{
SomeViewB()}}
Switch Statements
When possible consider using switch states as syntactical sugar.
@ViewBuilder
func someHelper()->some View{switch value{
case .a:SomeViewA()case .b:SomeViewB()default:EmptyView()}}
ForEach
There are multiple ways to init the ForEach
operator
ForEeach(1...5)
With a static rangeForEach(identifiableObjects)
With an array of conformingidentifiable
objectsForEach(array, id:\.someKeyPath)
With a specific identitykeypath
source for each element.
It’s important to keep in mind that ForEach
will implicitly identify each element based on either the identifiable.id
accessor or through the specific keypath
. This may bring some insights on why dynamic ranges are not supported.