Swift Notes on Dataflow and SwiftUI¶
The article on iOS Architecture quoted some WWDC talks, which were Data Essentials in SwiftUI, WWDC20, Data Flow Through SwiftUI, WWDC19 and Demystifiying SwiftUI, WWDC21.
Data Essentials in SwiftUI¶
Key questions
What data does this view to do its job.
How will the view manipulate the data?
Where will the data come from? (Source of truth)
View presents editor view¶
struct EditorConfig{
var isEditorPresented = false
var note = "" // the data to edit
var progress: Double = 0. // the data to edit
mutating func present(initialProgress: Double{
progress = initialProgress
note = ""
isEditorPresented = true
}
}
}
struct BookView: View {
@State private var editConfig = EditorConfig()
func presentEditor(){ editorConfig.present(..) }
var body: some View{
...
Button(action: presentEditor){...}
...
ProgressEditor(editorConfig: $editorConfig) //
...
}
}
stuct Progresseditor: View{
@Binding var editorConfig: EditorConfig // take the state struct from the superview
...
TextEditor($editorConfig.note)
...
}
Designing your model¶
ObervableObject
exposes Data to the view, not necessary the full model. Like a facade.E.g. one
ObservableObject
shared with all views.E.g. multiple
ObervableObject
with different projection of data.
class CurrentlyReading: ObservableObject { // Exposed to view
let book: Book
// Automatically works with ObservableObject, publishes with willSet, projectedValue is Publisher
@Published var progress: ReadingProgress
...
}
struct ReadingProgress {
struct {
Entry: Identifiable{
let id: UUID
let progress: Double
let time: Data
let note: String?
}
var entries: [Entry]
}
}
@ObservedObject
tracksObservableObject
as dependency. Does not own instance.
struct BookView: View {
@ObservedObject var currentlyReading: CurrentlyReading
var body: some View {
VStack {
BookCard(currentlyReading: currentlyReading)
...
ProgressDetailsList(progress: currentlyReading.progress)
}
}
}
Bindings and ObservedObjects¶
class CurrentlyReading: ObservableObject { // Exposed to view
let book: Book
@Published var progress: ReadingProgress
@Published var isFinished: false
var currentPRogress: Double{
isFinished ? 1.0: progress.progress
}
}
struct BookView: View{
Toggle(isOn: $currentlyReading.isFinished){
Label("I'm Done", systemImage: "checkmark.circle.fill")
}
}
All other references will change state and follow up (e.g .disabled(currentlyReading.isFinished)
to enable/disable UI components).
StateObject¶
SwiftUI owns ObservableObject.
class CoverImageLoader: ObservableObject{
@Published public private(set) var image: Image? = nil
func load(_name: String){
}
func cancel(){
}
deinit() {
cancel()
}
}
struct BookCoverView: View{
@StateObject var loader = CoverImageLoader()
var coverName: String
var size: CGFloat
var body: some View{
CoverImage(loader.image, size:size)
.onApper{loader.load(coverName)}
}
}
Views are very cheap.
Make as much simple, small views as possible.
EnvironmentObject¶
When you cannot pass on objects from your high view to your low view in the view hierarchy use this to inject Object.
View modifier in parent view: `.environmentObject(ObservableObject)``
PropertyWrapper
@EnvironmentObject var model.
Wrap up¶
ObservableObject
as the data dependency surface@ObservedObject
creates a data dependency@StateObject
ties an ObservableObject to view’s life cycle@EnvironmentObject
add ergonomics to access ObservableObject
Swift UI life cycle¶
SwiftUI manages identify and lifetime
Views should be lightweight and inexpensive
UI -> Event {…} -> Mutation of Source of Truth -> new Copy of UI
Expensive Work Causes slow updates
Make view initialization cheap - no dispatching
Make body a pure function
Avoid assumptions
struct ReadingListView: View{
var body:some View{
NavigationView{
ReadingList()
Placeholder()
}
}
}
struct ReadingList: View{
@ObservedObject var store = ReadingListStore()
var body: some View{
...
}
}
Repeated heap allocation of store Object can cause a slow update
View structs to not have a defined lifetime
Better: StateObject. StateObject lets SwiftUI init the object at the right time
struct ReadingListView: View{
var body:some View{
NavigationView{
ReadingList()
Placeholder()
}
}
}
struct ReadingList: View{
@StateObject var store = ReadingListStore() // ‼️
var body: some View{
...
}
}
Event sources can be user interaction, timer, …
Data lifetime¶
Apps
Scenes
Views
SceneStorage
AppStorage
SceneStorage¶
Scene-scoped
SwiftUI managed
View-only
Behaves like
@state
.
struct ReadingListViewer: View {
@SceneStorage("selection") var selection: String?
var body: some View {
NavigationView {
ReadingList(selection: $selection)
BookDetailPlaceholder()
}
}
}
AppStorage¶
App scoped
User defaults
Usable anywhere
e.g. for settings
struct BookClubSettings: View{
@AppStorage("updateArtwork") private var updateArtwork = true
@AppStorage("syncProgress") private var syncProgress = true
var body: some View {
Form{
Toggle(isOn: $updateArtWork){
...
}
Toggle(isOn: $syncProgress){
}
}
}
}
Data Flow Through SwiftUI¶
Always
@State private
struct PlayView: View {
let episode: Episode
@State private var isPlaying: Bool = false // source of truth
var body: some View{
VStack{
Text(episode.title).foregroundColor(isPlaying ? .white: .gray)
Text(episode.showTitle).font(caption).foregroundColor(.gray)
PlayButton(isPlaying: $isPlaying)
}
}
}
struct PlayButton: View {
@Binding var isPlaying: Bool // external source of truth
var body: some View {
Button(action: {self.isPlaying.toggle()}){
Image(systemName: isPlaying ? "pause.circle" : "play.circle")
}
}
}
Animated changes¶
struct PlayButton: View {
@Binding var isPlaying: Bool // external source of truth
var body: some View {
Button(action: {
withAnimation{ self.isPlaying.toggle() }
}){
Image(systemName: isPlaying ? "pause.circle" : "play.circle")
}
}
}
Working With External Data¶
struct PlayView: View {
let episode: Episode
@State private var isPlaying: Bool = false
@State private var currentTime: TimeINterval = 0.0
var body: some View{
VStack {
Text(episode.title).foregroundColor(isPlaying ? .white: .gray)
Text(episode.showTitle).font(caption).foregroundColor(.gray)
PlayButton(isPlaying: $isPlaying)
Text("\currentTime, formatter: currentTimeFormatter")
}.onReceive(PodcastPlayer.currentTimePublisher){ newCurrentTime in
self.currentTime = newCurrentTime
}
}
}
BindableObjectProtocol
class PodcastPlayerStore {
var currentTime: TimeInterval
var isPlaying: Bool
var currentEpisode: Episode
func advance(){}
func skipForward(){}
func skipBackward(){}
}
class PodcastPlayerStore: BindableObjectProtocol {
var didChange = PassthroughSubject<Void,Never>()
func advance(){
currentEpisode = nextEpisode
currentTime = 0.0
didChange.send() // Notify subscribers that the player changed
}
}
@ObjectBinding
: automatic dependeny tracking
struct MyView: some View {
@ObjectBinding var model: MyModelObject
...
}
MyView(model: modelInstance)
Creating Dependencies Indirectly¶
Push
BindableObjects
into the Environment
struct PlayView: View {
@EnvironmentObject var player: PodCastPlayerStore
}
When to use
EnvironmentObject
, when to useObjectBinding
?
Using State Effectively¶
Limit use if possible
Use derived Binding or value
Prefer BindableObject for persistence
Example: Button highlight
Demystify SwiftUI¶
Identity
Lifetime
Dependencies
Identity¶
ViewIdentiy
Explicit Identity -> IDs and names.
Structural Identity
AnyView
to returnsome View
infunc view(for obj: Object) -> some View
is a type erasing wrapper type. When used the structural identiy is hidden.var body: some View{
can return different concrete ViewTypes, whereasfunc view(for obj: Object) -> some View
cannot.var body
is wrapped in a@ViewBuilder
property wrapper. We can use that on our func as well.
@ViewBuilder
func view(for dog: Dog) -> some View {
if dog.breed = .bulldog{
BullDogView()
}
...
}
Avoid
AnyView
whenever possibleLevarage
@ViewBuilder
AnyView
hides structural identiyAnyView
worsen performanceAnyView
worsen compile time diagnostics
Lifetime¶
@State
and@StateObject
.State lifetime = view lifetime
¶
Attributes as dependencies.
When the dependency changes, a new view is rendered
struct DogView: View {
@Binding var dog: Dog
var treat: Treat
var body: some View {
Button {
dog.reward(treat)
} label:{
PawView()
}
}
}
As all views can have own dependencies, we have a dependency graph
Stable identifiers help SwiftUI¶
enum Animal { case dog, cat }
struct Pet: Identifieable {
var name: String
var kind: Animal
var id: UUID{ UUID() } // ‼️ will create a new UUID, whenever pets: [Pet] changes. Not stable!
}
struct FavoritePets: View {
var pets: [Pet]
var body: some View {
List {
ForEach(pets){
PetView($0)
}
}
}
}
ForEach(treats, id: \.serialNumber){ treat in
TreatCell(treat)
.modifier(ExpirationModifier(date: treat.expiryDate))
}
struct ExpirationModifier: ViewModifier{
var date: Date
func body(content: Content) -> some View{
if date < .now { // ‼️ two copies of the content
content.opacity(0.3)
} else{
content
}
}
}
ForEach(treats, id: \.serialNumber){ treat in
TreatCell(treat)
.modifier(ExpirationModifier(date: treat.expiryDate))
}
struct ExpirationModifier: ViewModifier{
var date: Date
func body(content: Content) -> some View{
content.opacity(date < .now ? 0.3: 1.0)
}
}
Inert modifier
content.opacity(1.0)
are cheap, use them in conditionals.
Wrap up¶
avoid uneccessary branches
create tightly scoped dependent code
prefer inert modifiers