How to Pass Data Up & Down in SwiftUI

Updated May 27, 2023#how-to#swiftui#swift#ios

As always you can pass data up & down in Swift using normal properties and delegates. In SwiftUI, we focus more on techniques that automatically update views using state management property wrappers.

You typically use @State and @StateObject to hold data as single source of truth, then passing around using following patterns:

In this tutorial, we’ll focus on passing data using @Binding, custom environment values, and custom preferences.

Pass data two-way using @Binding

Data binding is a technique that links the data of your application with the UI components. It enables you to establish a two-way connection between your model data and the views that display it. Any changes to the data automatically update the views, and any changes to the views automatically update the data.

Data binding eliminates the need for manual synchronization between the data and the UI, resulting in less boilerplate code and fewer bugs.

@Binding is a property wrapper that creates a binding between a parent view and its child view. When you use @Binding in a child view, you’re telling SwiftUI that the value of the variable should be determined by the parent view. Any changes made to the binding within the child view propagate up to the parent view.

struct ContentView: View {
  @State var isOn = false

  var body: some View {
    VStack {
      Text(isOn ? "Switch is on" : "Switch is off")
      Toggle("Toggle switch", isOn: $isOn)
      ChildView(isOn: $isOn)
    }
  }
}

struct ChildView: View {
  @Binding var isOn: Bool

  var body: some View {
    VStack {
      Text(isOn ? "Child switch is on" : "Child switch is off")
      Button("Toggle child switch") {
        self.isOn.toggle()
      }
    }
  }
}

Pass data up using preferences

Preferences allows for a child view to communicate with a parent, similar to how an Environment allows for data to be sent down the view hierarchy. An excellent example of view preferences in action is NavigationView and navigationTitle(_:). The navigationTitle(_:) does not modify the navigation view directly, but rather it uses view preferences and the navigation view has a closure that is called when the title is updated.

While it is possible to achieve basic communication up the view hierarchy using a @Binding, this can produce unintended effects as a result of modifying state during a view update. In these scenarios, it may be better to use view preferences.

PreferenceKey is a protocol in SwiftUI that lets you define a key for storing a view’s preferences, requires a defaultValue and a reduce(value:nextValue:), which defines the logic used to combine multiple outputted values into one.

Unlike data that flows down a view hierarchy from one container to many subviews, a single container needs to reconcile potentially conflicting preferences flowing up from its many subviews. When you use the PreferenceKey protocol to define a custom preference, you indicate how to merge preferences from multiple subviews.

For example, to set a preference key from a view and use it to change a state:

struct ExampleView: View {
  @State private var customPreferenceKey: String = ""

  var body: some View {
    VStack {
      Text("View that sets a preference key when loaded")
        .preference(key: CustomPreferenceKey.self, value: "New value! 🤓")
    }
    .onPreferenceChange(CustomPreferenceKey.self) { (value: CustomPreferenceKey.Value) in
      customPreferenceKey = value
      print(customPreferenceKey)  // Prints: "New value! 🤓"
    }
  }
}

struct CustomPreferenceKey: PreferenceKey {
  static var defaultValue: String = ""

  static func reduce(value: inout String, nextValue: () -> String) {
    value = nextValue()
  }
}

Pass data down using custom environment values

Create custom environment values by defining a type that conforms to the EnvironmentKey protocol, and then extending the EnvironmentValues structure with a new property. You set custom value with environment(_:_:) view modifier, then read that value in child views using @Environment property wrapper.

import SwiftUI

struct Account {
  var name: String
  var token: String?
}

struct AccountEnvironmentKey: EnvironmentKey {
  static let defaultValue: Account = .init(name: "Anonymous")
}

extension EnvironmentValues {
  var currentAccount: Account {
    get { self[AccountEnvironmentKey.self] }
    set { self[AccountEnvironmentKey.self] = newValue }
  }
}

struct ContentView: View {
  @State private var currentAccount: Account = .init(name: "byby", token: "foo")

  var body: some View {
    ScrollView {
      ButtonsView()
    }
    .environment(\.currentAccount, currentAccount)
  }
}

struct ButtonsView: View {
  @State private var showingSheet = false
  @Environment(\.currentAccount) private var currentAccount

  var body: some View {
    VStack {
      Button("Show logged in account profile") {
        showingSheet = true
      }
      .buttonStyle(.borderedProminent)
    }
    .navigationTitle(currentAccount.name)
    .sheet(isPresented: $showingSheet) {
      showingSheet = false
    } content: {
      Text("Logged in with \(currentAccount.name), token: \(currentAccount.token ?? "")")
        .onTapGesture {
          showingSheet = false
        }
    }
  }
}

You can also provide a dedicated modifier to the value, then access the value in the usual way, reading it with the @Environment property wrapper, and setting it with that custom view modifier.

extension View {
  func currentAccount(_ account: Account) -> some View {
    environment(\.currentAccount, account)
  }
}