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 tracks ObservableObject 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 use ObjectBinding?

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 return some View in func 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, whereas func view(for obj: Object) -> some Viewcannot.

  • 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 possible

  • Levarage @ViewBuilder

  • AnyView hides structural identiy

  • AnyView worsen performance

  • AnyView 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