DEV Community

TrozWare
TrozWare

Posted on • Originally published at troz.net on

SwiftUI Data Flow 2023

SwiftUI is a reactive framework where the data drives the UI. In 2019, I wrote a post detailing how I manage the various forms of data flow through a SwiftUI app, and with the help of others in the community, I iterated over this until I had a good understanding of the concepts and which methods you should use when. In 2021, I updated the post to cover the minor changes, but there have been no major modifications since then.

At WWDC 2023, things changed a lot! With the introduction of Swift macros, the SwiftUI team was able to reduce the number of property wrappers need to send data around, and remove a lot of boilerplate code.

For this article, I have re-written my sample app as a Mac app and updated it to use the new data macros.

Table of Contents #


Observation #

In the WWDC talk on Discover Observation in SwiftUI, Apple showed the following flow chart:

img {
margin: 10px auto;
border: 0.5rem #dfe7f0 solid;
border-radius: 0.5rem;

}

.dark-theme img {
margin: 10px auto;
border: 0.5rem #4a4d50 solid;
border-radius: 0.5rem;

}

.social_footer img {
border: none;
box-shadow: none;
}

Apple’s flow chart

This is a good starting point, but I feel that it leaves out a few cases, so I’ve made my own:

My flow chart

Apart from adding some details to the decisions points, there are really only two additions to my chart:

  • If a property doesn’t need to change, it can be a let.
  • @Bindable only works for classes. The equivalent for structs or primitive data types is still @Binding.

The new macros system removes these property wrappers:

  • @ObservableObject
  • @ObservedObject
  • @EnvironmentObject
  • @Published

That’s a lot of typing we don’t have to do any more!

Back to Top


Sample app #

My sample app demonstrates the following data flow options:

Sample app options

The code is available on GitHub and I recommend you download the project and follow along. It requires macOS 14 and Xcode 15. Currently I’m using macOS 14.0 beta 2 and Xcode 15.0 beta 2, but I will update this article with any changes as we work through the beta cycle.

Because Xcode 15 now supports live previews for Mac apps, you can test all the options from inside Xcode. In the Project navigator, expand the numbered groups and preview the first file in each group to view and interact with that data flow type.

Or go to the Bookmarks navigator where I’ve used the new bookmaking system to provide quick links to each one. You can even check off each one as you finish that section.

In the code snippets below, I include the essentials, but strip out most display modifiers and some similar views to keep the code short. The project contains all the code.

Back to Top


Property #

The first option is using a static property. We get so caught up in property wrappers that it’s easy to forget that SwiftUI doesn’t always need them. Using static text from a constant may seem like a simplistic and obvious example, but separating it out allows for localization or re-usability as well as making the view body neater and easier to read.

struct Property: View {
  // static property that never changes
  let greeting = "Hello from SwiftUI!"

  var body: some View {
    // Using the property directly in a View
    Text(greeting)
  }
}

Enter fullscreen mode Exit fullscreen mode

My flow chart shows two use cases for let. This covers the first one where the view owns the property. The second is when a parent supplies the property to a view but the child view never changes the property. You’ll see an example of this later on in this article.

A parent view may have some dynamic data that it uses to set properties in a subview where the subview only needs to display the data statically. This data will still change as the parent view changes but the subview will not be able to change the data itself or in the parent view.

If a view owns a property or gets it from a parent but doesn’t need to change it, use let.

Back to Top


@State #

This is where things start to get interesting in the SwiftUI world. SwiftUI views are contained in structs, so are non-mutable. Also, views are re-created every time the data changes, so any properties are re-created then too. By marking a property with the @State property wrapper, you are telling SwiftUI that you want it to keep this data in a separate portion of memory, allow it to be mutated, and preserve the current value during view refreshes.

struct UsingState: View {
  // Using @State as this view owns and can mutate the property
  @State private var counter = 0

  var body: some View {
    // Displaying and editing @State property
    VStack {
      Text("Counter = \(counter)")

      Button(action: {
        counter += 1
      }, label: {
        Image(systemName: "plus.circle")
      })
    }
  }
}

Enter fullscreen mode Exit fullscreen mode

In this example, the counter property uses @State. The text view displays it and the Button view can change it. The button’s action changes the counter which causes the view to redraw in order to display the new value, but the value of the @State property does not get reset.

Whenever a view owns the property and that property is mutable, declare it using the @State property wrapper.

Back to Top


@Binding #

The Apple flow chart mentioned @Bindable but that only works with observable classes. For connecting structures or primitive data types to subviews, use @Binding. Apple does this internally for controls like Toggle and TextField.

A problem with building SwiftUI views is that it is very easy to end up with a gigantic Pyramid of Doom as you embed views within views within views. Extracting subviews is a great solution, but then you need a way to pass the data to a subview in a way that allows the subview to edit that property and have the edits flow back to the parent.

This is where @Binding and @Bindable come into play.

The Binding 1 example uses a primitive data type - in this case an Int. The parent Numbers view declares it using @State because it owns the property, and it displays the value. The Numbers view passes the property to the NumberChooser subview as an @Binding property by using the $ prefix.

struct Numbers: View {
  // Using @State as this view owns and can mutate the property
  @State private var stepperValue = 0

  var body: some View {
    VStack {
      // Display @State property
      Text("Parent view value = \(stepperValue)")

      // Pass to subview as a Binding ($) for two-way editing
      NumberChooser(stepperValue: $stepperValue)
    }
  }
}

Enter fullscreen mode Exit fullscreen mode

The NumberChooser subview has this property marked with @Binding. It has no initial value as this view expects to receive the value from its parent view. NumberChooser has a Stepper for changing the value of the property. It displays the value after each change and you can see that the value displayed in the parent view also changes as the change flows back.

struct NumberChooser: View {
  // Using value from parent with 2-way binding
  @Binding var stepperValue: Int

  var body: some View {
    VStack {
      // Using bound state from parent with 2-way binding
      Stepper(value: $stepperValue, in: 0...20) {
        Text("Value in child = \(stepperValue)")
      }

      // Using bound state from parent as property
      // this view cannot change the value
      NumberBlock(stepperValue: stepperValue)
    }
  }
}

Enter fullscreen mode Exit fullscreen mode

NumberChooser has its own subview - NumberBlock. This is purely a display view that never edits the value. NumberChooser passes it the value and it stores it as a let. Whenever the value changes, SwiftUI redraws the view with the new value, and the display updates. This demonstrates the second use of let that I talked about in the property section. There is no need to make this a var because it doesn’t change - it gets re-created with every update.

struct NumberBlock: View {
  // As this view never changes the value, there is no need to bind it
  let stepperValue: Int

  var body: some View {
    Image(systemName: "\(stepperValue).square")
  }
}

Enter fullscreen mode Exit fullscreen mode

Back to Top


@Binding for a Structure #

The previous example showed how to use @Binding for a primitive data type like Int, String etc. But bindings also work for any value type, including structures and that’s what you can see in Binding 2.

While this is not very common, I enjoyed writing this example for a couple of reasons. Firstly, it shows how to draw the UI based on the different cases in an enum. Check out Pizza.swift which has three enums. Each of the picker views loops through one of these enums to draw the view.

Secondly, it demonstrates how you can pass portions of the data model to a subview, instead of sending everything everywhere.

PizzaView has a Pizza property declared using @State because this view owns the data. It draws a Form containing three different pickers. Each picker gets a binding to one of the properties of the pizza. A Text view shows the assembled data to confirm that the parent knows about any changes.

struct PizzaView: View {
  // Using @State for a struct
  @State private var pizza = Pizza()

  var body: some View {
    VStack {
      Form {
        // Using 2-way binding but each component
        // only needs 1 property from the struct
        PizzaNamePicker(selectedPizzaName: $pizza.name)
        PizzaSizePicker(selectedPizzaSize: $pizza.size)
        PizzaCrustPicker(selectedPizzaCrust: $pizza.crust)
      }

      // Text representation to prove that the
      // subviews are modifying the parent struct
      Text(pizza.pizzaSelection)
    }
  }
}

Enter fullscreen mode Exit fullscreen mode

I won’t show the code for all of the pickers, but here’s the one to select the size:

struct PizzaSizePicker: View {
  // A single property of the Pizza struct is passed hare.
  // It's a Binding so data flows 2 ways.
  @Binding var selectedPizzaSize: PizzaSize

  var body: some View {
    Section(header: Text("Select your size:").font(.headline)) {
      Picker("", selection: $selectedPizzaSize) {
        ForEach(PizzaSize.allCases, id: \.self) { pizzaSize in
          Text(pizzaSize.rawValue.capitalized)
        }
      }
      .pickerStyle(.segmented)
    }
  }
}

Enter fullscreen mode Exit fullscreen mode

If you’re passing a primitive data type or a struct to a subview, and the subview can change the value of this property, use @Binding in the subview and pass the property with a $ prefix.

Back to Top


@Observable and @Bindable #

So far, the examples have been largely the same as before. Now the new property wrappers appear. The first big change is in the model class: ColorSet.

import SwiftUI
import Observation

@Observable class ColorSet {
  // Observable class
  // The color components are not private so any changes
  // get published to the views that are observing

  var foregroundRed = 0.0
  var foregroundGreen = 0.0
  var foregroundBlue = 0.0

  // Computed variables to create the RGB colors from the components
  var foregroundColor: Color {
    return Color(red: foregroundRed, green: foregroundGreen, blue: foregroundBlue)
  }
}

Enter fullscreen mode Exit fullscreen mode

The first thing to notice is the new import: Observation. This is the library that supports the new @Observable macro. Previously, ColorSet conformed to the ObservableObject protocol and the properties that needed to be observed were marked with the @Published property wrapper.

Now the class uses the @Observable macro and any property that isn’t private, is automatically published. This is only available for classes, not structures. The properties must all have an initial value - setting them in an init is not sufficient and will not build.

If you want a look at what’s happening inside the macro, right-click on @Observable and select Expand Macro. When you’ve finished, right -click again and choose Hide Macro Expansion.

Expand the macro

While this is not something I can read comfortably, it shows how macros are regular Swift code that saves a lot of typing, but are not doing magical things invisibly in the background.

Now that the model uses the new macro, how do we use it?

Again, the owning view declares it using @State.

struct ColorSetView: View {
  // Using an Observable for reference-based data (classes)
  @State private var colorSet = ColorSet()

  // ...
}

Enter fullscreen mode Exit fullscreen mode

The ColorSetView uses the data to add color modifiers to the view background and to a system image of a cat.

But the next change is when sending the data to the ColorChooser view. This needs two-way binding so the current values can be passed to the chooser and any changes can be passed back. But with the new Observation methods, this uses @Bindable and not @Binding.

First, the ColorChooser gets the colorSet property, but this doesn’t need the $ prefix.

ColorChooser(colorSet: colorSet)

Enter fullscreen mode Exit fullscreen mode

The ColorChooser view receives it as @Bindable and can in turn, pass it down to its subviews, again using the @Bindable technique which requires no $ prefixes.

struct ColorChooser: View {
  @Bindable var colorSet: ColorSet

  var body: some View {
    // The chooser subview also get passed the Observed
    ForeColorChooser(colorSet: colorSet)
  }
}

Enter fullscreen mode Exit fullscreen mode

Color chooser

There is one oddity to beware of. When declaring the original colorSet property, I use @State but if I declare it as a simple var, the app still works. Don’t do this!

If you remember the older code style, the owning view initialized an ObservableObject using @StateObject. Subsequent views declared the same property using @ObservedObject but it was very important for the owning view to use @StateObject to avoid strange and difficult to trace bugs. The app worked if you used @ObservedObject instead of @StateObject for the original declaration, but then odd things could happen.

I think this is the same, and it’s just as important for the owning view to declare the original property using @State.

Back to Top


@Observable and @Bindable List #

In the original post, this was the section that gave me the most trouble - having a list of data objects where each one could be edited and updating the original list to show the edits.

This is now a lot easier, although it took me some time to realise this.

I have a sample data file and a Person model class using @Observable. The PersonListModel class holds an array of these Person objects and it also uses @Observable.

I wanted to make the selection of the list point directly to a Person instead of going through an id, so I added this extension to Person:

extension Person: Hashable, Equatable {
  func hash(into hasher: inout Hasher) {
    hasher.combine(id)
  }

  static func ==(lhs: Person, rhs: Person) -> Bool {
    lhs.id == rhs.id
  }
}

Enter fullscreen mode Exit fullscreen mode

Then I was able to set up a List to display the names of the people, with the list’s selection bound to an optional Person. I had to add a tag to the person, I presume the tag is set to the id by default.

List(selection: $selectedPerson) {
  ForEach($personList.persons) { $person in
    Text(person.fullName)
      .tag(person)
  }
}

Enter fullscreen mode Exit fullscreen mode

Two important things to see in this code snippet:

  • The contents of the ForEach is a binding as you can see from the $ prefix
  • The argument passed in to each entry in the list is also a binding.

These two bindings make the list respond to edits.

With this in place, it was time to think about an editor and I decided to try out the new inspector. Unfortunately, unlike with sheets, you can only trigger an inspector with a Boolean so I added an event listener to set the inspector trigger:

.onChange(of: selectedPerson, { oldValue, newValue in
  shouldShowInspector = newValue != nil
})

Enter fullscreen mode Exit fullscreen mode

After that, I added this inspector modifier:

.inspector(isPresented: $shouldShowInspector) {
  if let selectedPerson {
    PersonEditView(person: selectedPerson)
  }
}

Enter fullscreen mode Exit fullscreen mode

Even though shouldShowInspector is only true when there is a valid selectedPerson, force unwrapping causes a crash as it appears that the inspector tries to draw itself off-screen on launch.

The PersonEditView in the inspector receives the person as @Bindable and populates the fields on a form. And editing the data flows right back to the list.

Editing a person

This took a lot less code than the previous version. Part of the improvement was the ability to use bindings in a list, which we got last year, but another big part was using the Person directly as the selection. This needs the Hashable and Equatable conformance, which I think should be a standard part of Identifiable, but once that’s in place, all the data flows smoothly.

If I was using a sheet for the editor, it would be even neater as I could tie the sheet’s appearance to selectedPerson.

When you have a model class and you want changes to be published, declare the class with the @Observable macro. The owning view declares the model using @State and can pass it to editing subviews using @Bindable or to display subviews as let.

Back to Top


@Environment #

The last section is Environment, which again, has changed a lot. The benefit of using Environment is that the data flow doesn’t have to be unbroken. In the sample app, the parent view (NestedViews) uses the Environment property and so does the GrandChildView, but the ChildView in the middle doesn’t.

Setting up a property for Environment is the same as for Observable.

import Observation

// An Environment class is just like any Observable class
@Observable class UserSettings {
  var isLoggedIn: Bool = false
}

Enter fullscreen mode Exit fullscreen mode

The difference is in the way that you use it. When creating the first view that needs this environment value, use an environment modifier to inject the initial value. In the sample app, this happens in DataLink.swift which displays the view for each example.

NestedViews()
  .environment(UserSettings())

Enter fullscreen mode Exit fullscreen mode

To access this property, declare an Environment property with a key path, which is its type, in this case UserSettings.self.

struct NestedViews: View {
  // The injected environment property is accessed by type and assigned a name
  @Environment(UserSettings.self) private var userSettings

  var body: some View {
    // ...
  }
}

Enter fullscreen mode Exit fullscreen mode

This is similar the way we use built-in environment values like colorScheme, openWindow and so on. Use this same declaration in any view that needs access.

Previews need to get access too, like this:

#Preview {
  // The environment object must be injected into the preview too.
  NestedViews()
    .environment(UserSettings())
}

Enter fullscreen mode Exit fullscreen mode

And even though ChildView doesn’t use the environment object, because it contains GrandChildView that does, it also needs the environment modifier in its preview, but not in the main view code.

In the sample app, the various nested views are brightly coloured to show which is which. Using the Log In or Log Out buttons works on either the outer view or the inner view with the data being synced between them.

Environment

If you have a class that is global to your app, like UserSettings in this example, declare the class with the @Observable macro. Inject it into your view hierarchy using .environment and then use @Enviroment to access it.

Back to Top


Wrapping Up #

There are two issues I want to discuss before ending.

First is the use of private for view properties. Apple recommends marking every @State property as private. It seems a bit strange as you then pass them around and sometimes the subviews change the data, but it means that no object can edit the value unless the view has expressly given permission. I presume there are security and performance benefits to this.

Similarly, @Enviroment properties can (and I presume, should) be private. The two binding property wrappers do not allow their properties to be private.

And lastly, in @Observable classes, everything that is NOT private is published. This is the opposite to what we had before where you had to explicItly state which properties were published. So mark properties as private if they should not be published.

The last issue is to do with using var on its own i.e. without any property wrapper. The Apple flow chart suggests doing this if you get to the end of the flow without making any other decision, but I think you should use let in this case. The only time I use a plain var in a view is for computed properties, otherwise I use let.

In my flow chart, I left the appropriate end point showing var or let , to better match Apple’s, but I recommend using let and only changing to var if Xcode complains. This is a good rule for any Swift code, not just SwiftUI.


This article may get several updates before we exit this years beta cycle, but I thought other people might find it useful. Writing it certainly helped clarify the new options in my head.

If you have any suggestions, ideas or corrections, please contact me using one of the links below or through the Contact page. And if you found this article useful, I’d love you to buy me a coffee.

Back to Top

Top comments (0)