MacKuba

Kuba Suder's blog on Mac & iOS development

WWDC 19

Data Flow Through SwiftUI

Categories: SwiftUI 0 comments Watch the video

(*) – 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 framework
  • ObservableObject – 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