Understanding Data Flow in SwifUI

You,Swift

As we all agree data is the essential and most complex part of apps. In the imperative frameworks like UIKit, delegates, closures and notifications centers has been used as a state management pattern to simplify the process of data flow. However, the new declarative framework (SwiftUI) makes this whole process of state management way easier to play with. There are few property wrappers introduced to us when SwiftUI was first released. These property wrappers simply let us declare how our data is observed, and gets mutated through out the life cycle of the app. Let’s go a little deeper and take a closer look at these property wrappers to understand how they work.

@State

The @State property acts as a single source of truth for a given view. When the value of the state changes, the view gets discarded and redrawn again to sync with the associated data. In other words, your view automatically responds to any changes made to @State property. Let’s look at this concept in code for better understanding.

struct ContentView: View {
    @State private var name = ""
    
    var body: some View {
        VStack(alignment: .center) {
            Text("My name is: \(name)")
            Text("The text above gets updated as you type")
                .font(.caption)
            
            TextField("Name", text: $name)
        }
    }
}

By marking the name property as @State, we are associating it with our view. The value of our property gets stored internally by SwiftUI and persisted through out. Every time the value changes, the view re-renders itself to sync with the data. As you can see in the example above, whenever you type your name in the text field, the label is updated automatically, meaning it's redrawn each time you type in the text field.

Since, @State is used internally to represent the state of a given View, we should always make it private, so that no external source can modify it. We should use @State property wrapper when our view needs to respond to changes immediately or we need to keep our view in sync with data.

@Binding

Sometimes our view can be composed of multiple child views, and we would like to respond to changes made by external sources like parent views. In that case, @Binding comes in hand to allow us pass state from parent view to the child view. When the child view changes its state, the parent view immediately gets the updated change to the property and re-renders itself. Lets look at example:

struct ParentView: View {
    @State private var name = ""
    
    var body: some View {
        VStack(alignment: .center) {
            ChildView(name: $name)
            Text("The text above gets updated as you type")
                .font(.caption)
            
            TextField("Name", text: $name)
        }
    }
}

struct ChildView: View {
    @Binding var name: String
    
    var body: some View {
        VStack(alignment: .center) {
            Text("My name is: \(name)")
        }
    }
}

In the example above, we are passing in a state property to the child view with the $ sign prefix. By doing so we are providing the child view reference to the state property, and whenever that state changes the child view redraws itself to reflect the change. When we mark our property as @Binding, SwiftUI doesn’t store the value when the view gets discarded like how @State stores the value, because @Binding is always passed by external source.

The main difference between the @State and @Binding is the ownership. A view with a property marked as @State has the ownership of that property, whereas A view with a property marked as @Binding has NO ownership, but has read/write access the property.

@StateObject

The @StateObject property is similar to @State, except its applied to reference type objects that conform to ObservableObject protocol. @StateObject instantiates a class that conforms ObservableObject and stores it internally. Lets look at example:

class DataSource: ObservableObject {
  @Published var counter = 0
}

The only property in DataSource class is the counter, which is marked as @Published, it basically means the property acts as a publisher and all the subscribers will be notified when the value of the counter changes. If you would like your view to get updates whenever the counter value changes, then you should instantiate DataSource class inside a given view as @StateObject.

struct CounterView: View {
  @StateObject var dataSource = DataSource()

  var body: some View {
    VStack {
      Button("Increment counter") {
        self.dataSource.counter += 1
      }

      Text("Count is \(dataSource.counter)")
    }
  }
}

Notice the CounterView owns the DataSource, whenever the value of DataSource.counter gets updated the CounterView will redraw itself to reflect the change. When we mark dataSource property as @StateObject we are making sure the DataSource() instance does not get destroyed when the view gets redrawn. In other words, SwiftUI will keep the initially created instance of DataSource for as long as the view is needed.

@ObservedObject

The @ObservedObject property is similar to @Binding, its applied to ObservableObject instances that’s owned by external source and passed down to a reusable view/child view. SwiftUI does not store an @ObservedObject when the view that holds the property gets discarded, as the property value is passed down by a parent view. Lets look at simple example to understand this:

struct ParentView: View {
  @StateObject private var dataSource = DataSource()

  var body: some View {
    VStack {
      Text("Counter: \(dataSource.counter)")

      SomeReusableView(dataSource: dataSource)
    }
  }
}

struct SomeReusableView: View {
  @ObservedObject var dataSource: DataSource

  var body: some View {
    // Some other code`
  }
}

As you can see in the example, the ParentView owns the dataSource property marked as @StateObject and passes down to SomeReusableView, where it is used as @ObservedObject. Since the @ObservedObject is being passed down from the ParentView, SwiftUI will not store its value when the view gets redrawn.

@EnvironmentObject

Finally, let’s look at @EnvironmentObject, how it can be accessed through an entire app. You can think of @EnvironmentObject as a shared data that is available across an entire app, this allows us to keep our views in sync when that data changes. For instance, instead of creating an object and passing it down to child views when we initialise these views, we can inject environment object from a parent view so that it can be accessed by all the child views without the need to pass to each of them. The key point here is that we need to inject the environment object from a parent view, otherwise if SwiftUI can’t find it, our app would just crash. Lets look at example:

class DataSource: ObservableObject {
  @Published var counter = 0
}

struct MyApp: App {
  @StateObject var dataSource = DataSource()

  var body: some Scene {
    WindowGroup {
      DemoView()
        .environmentObject(dataSource)
    }
  }
}

As you can see in the code above, we are injecting environment object into DemoView. And in the code below, the property dataSource is marked as @EnvironmentObject which must conform to ObservableObject.

struct DemoView: View {
  @EnvironmentObject var dataSource: DataSource

  var body: some View {
    AnotherView()
  }
}

struct AnotherView: View {
    @EnvironmentObject var dataSource: DataSource

    var body: some View {
        Text("\(dataSource.counter)")
    }
}

So once the @EnvironmentObject property changes, the child views will be redrawn to reflect the changes. @ObservedObject and @EnvironmentObject share a lot of similarities, the main difference is that, @EnvironmentObject maintains a global state, which can be accessed by all the child views.

© Ezaden Seraj.