WWDC 19
Data Flow Through SwiftUI
(*) – marks APIs that were changed since the video was published
In SwiftUI, data is a first-class citizen
There are many kinds of data – some of it is just the current state of a piece of UI, and some comes from your domain models
SwiftUI has several different tools for connecting your data to the UI, depending on the kind of data
Every time you read a piece of data in the UI you create a dependency of the view to this data; every time the data changes, the view has to be updated to reflect the new value
Just like the definition of the UI itself, the definition of dependencies to data in SwiftUI is also declarative: you don't have to manually implement the code that synchronizes one with the other, you just declare which view should be dependent on which piece of data
Every piece of information appearing in the UI should have a single "source of truth", i.e. it should only be defined in one place and used from there, not in many places which have to be kept in sync
Read-only data
The simplest form of data dependency is a read-only let
property of a value type:
struct PlayerView: View { let episode: Episode var body: some View { VStack { Text(episode.title) Text(episode.showTitle) } } }
This is great when you need read-only access to a derived piece of data passed from the parent view that never changes
@State
To have a local piece of data in a view that the view depends on which can change over time, you can use the @State
property wrapper
A @State
is a (private) source of truth handled and used by this specific view
→ it’s a good practice to mark the property as private
to emphasize that it's owned and managed only by this view
struct PlayerView: View { let episode: Episode @State private var isPlaying: Bool = false var body: some View { VStack { Text(episode.title) Button(action: { self.isPlaying.toggle() }) { Image(systemName: isPlaying ? "pause.circle" : "play.circle") } } } }
When you mark a property as @State
, SwiftUI allocates separate persistent storage for the view on your behalf and tracks it as a dependency
You always need to specify a constant initial value for a @State
property
The instances of SwiftUI view structs are transient, created and discarded on the fly every time the screen needs to be updated, however any data marked with @State
is stored separately and persisted across multiple updates of the view
How view updates work in SwiftUI
Any change in the data which is a dependency of a view triggers a reload of that view
The view and its whole child view hierarchy is recomputed from scratch (new instances of view structs are created) and compared to the existing version
SwiftUI calculates the difference between the two versions and efficiently updates the view on screen, rendering again only the parts that have changed
In SwiftUI, the view is a function of state, not a result of a sequence of add/modify/delete events like in UIKit
Traditionally, you would modify views over the life of the app; in SwiftUI, you instead modify the state and data on which the UI depends
The data only flows in a single direction:
User interacts with UI ⭢ action is performed ⭢ action mutates some state ⭢ system detects that the view needs to be re-rendered and updates it
This makes the process of view updates predictable and simple to understand, resulting in more correct code and helping you manage the complexity of UI code
The framework allows you (and encourages you!) to build small views that represent a single piece of data, that can be composed together
In UIKit or AppKit, when you have multiple views that display pieces of the same data, you have to manually control and synchronize all those copies of the data through e.g. target-action mechanism, delegates, model observation etc.
This is all time-consuming and error-prone, and as the complexity of the app increases, the view controllers grow in size and complexity since a large part of the coordination happens there
Since SwiftUI handles all this data syncing for you once you correctly define all data dependencies, you don't really need a view controller anymore (!)
@Binding
The @Binding
property wrapper gives you read-write access to a source of truth that is defined somewhere higher up in the view hierarchy
It lets a view read a value and write back to it without actually owning it
You don't specify an initial value for a @Binding
property, because a binding points to an already existing property in some parent view
struct PlayButton: View { @Binding var isPlaying: Bool var body: some View { Button (action: { self.isPlaying.toggle() }) { Image(systemName: isPlaying ? "pause.circle" : "play.circle") } } }
A binding to a property is created by adding a $
sign in front of the name
When you instantiate a view that expects a binding, pass the binding in a parameter to the constructor:
struct PlayerView: View { let episode: Episode @State private var isPlaying: Bool = false var body: some View { VStack { Text(episode.title) PlayButton(isPlaying: $isPlaying) } } }
A @Binding
can be a reference to a @State
property, to an @ObservedObject
(*) or to another @Binding
Declaring a @Binding
does not create a separate copy of the value that you need to manually keep in sync – there is still only one source of truth somewhere, and all the other places are just references to it
Reacting to external events
When an external event happens that needs to update the view, the change is funneled through the same chain as user actions: the event needs to mutate some state which will cause a re-render of some parts of the view
External events use an abstraction from Combine called Publisher
The events need to be received on the main thread
→ use .receive(on:)
See more in "Introducing Combine" and "Combine in Practice"
To create a dependency on an external publisher, use the .onReceive
view modifier and update the relevant local data in a provided closure:
struct PlayerView: View { @State private var currentTime: TimeInterval = 0.0 var body: some View { VStack { Text(currentTime) // ... }.onReceive(PodcastPlayer.currentTimePublisher) { newCurrentTime in self.currentTime = newCurrentTime } } }
Bindings to external data
The ObservableObject
(*) protocol can be used to let SwiftUI views observe external reference-type models
To conform a model to ObservableObject
, provide an objectWillChange
(*) property which is a Combine publisher that sends an event whenever the data changes:
class PodcastPlayerStore: ObservableObject { var objectWillChange = PassthroughSubject<Void, Never>() func advance() { objectWillChange.send() currentEpisode = nextEpisode currentTime = 0.0 } }
You can send this event for every field that changes, and if there are multiple changes in a row, SwiftUI will make sure to only reload the view once
To create a dependency on an ObservableObject
in the view, use the @ObservedObject
(*) property wrapper:
struct MyView: View { @ObservedObject var store: PodcastPlayerStore ... }
The reference to the model needs to be passed to the view from outside when it's instantiated:
MyView(store: self.podcastStore)
ℹ️ In SwiftUI 1.0 betas, as shown in the video, the protocol was called BindableObject
, it had a property
named didChange
(which performed a send *after* the change), and the model property was marked with @ObjectBinding
Indirect dependencies through the environment
The SwiftUI Environment is a way to pass dependencies indirectly through a whole hierarchy of views
An object or value is added to the environment in a parent view and all its child and descendant views get access to that value, without having to explicitly pass it to initializers everywhere
To add an object to the environment in a parent view, use the .environmentObject()
view modifier
To make a reference (dependency) to an object from the environment in a subview, use the @EnvironmentObject
property wrapper:
struct PlayerView: View { @EnvironmentObject var player: PodcastPlayerStore }
Using an environment to pass your data through the hierarchy is not strictly required, you can just pass things explicitly through @Binding
or let
parameters – it's just a useful convenience if some data needs to be passed everywhere through whole chains of views
SwiftUI uses it for data that is commonly used across all views, like: accent colors, light/dark mode, font size, locale etc.
Summary
There are generally two groups of sources of truth in a SwiftUI app:
@State
– local data for a view, value types, managed by the frameworkObservableObject
– external objects, living outside of the view hierarchy, reference types, managed by the developer
Most of the time, views don't need to mutate the data they access, so read-only data (plain Swift properties, environment) is preferred when data does not need to be mutated
When you do need to mutate data from a view, use the @Binding
In most cases, the data you use inside views will live somewhere outside of the UI, so @State
normally has limited use
If you find yourself adding @State
, take a step back and think if this data really needs to be owned by this view
Perhaps it should instead be moved to a parent, or maybe it should be represented by an external source added as an ObservableObject
Use state for purely local pieces of data inside your views, like the press/highlight state of controls