Passing values down the view hierarchy in SwiftUI

Introduction

So recently I have been trying to get into iOS App Development with Swift and SwiftUI, not because I had some genius app idea thats going to make me rich, much more because at work I exclusively develop internal stuff, even if my colleagues are very happy about it, it would be nice to create something that I can publish on the App Store and that might end up on someones iPhone or iPad and helps them in some way.

As my first simple project I chose an unofficial client for the Databricks platform. Databricks has a great web app however in my daily life at work I am responsible not only for developing these ETL-workflows but to also make sure they run successfully everyday and to intervene if something goes wrong, so naturally often I wish I could just check if everything is alright on the go and maybe even start a job again or restart a cluster quickly from my phone without needed to log in to the web app.
Simply enough, Databricks offers a great set of API endpoints that should make this a great first project to start on and learn the fundamentals of Swift and SwiftUI.

Before we get into Jobs (or Workflows, how they are called now), let us start with Clusters which should be a bit simpler. For those of you who do not use databricks, a cluster is just a collection of VMs from the cloud provider of your choice (Azure in our case) that runs pyspark, scala or SQL code.

So in my app I would like to see and overview of all my clusters in the workspace and also be able to select a single cluster to learn more about it. The basic view structure I came up with looks like the following:

ContentView -> ClusterView -> ClusterInfoView.

The important thing here is, that I want to be able to update the values from every view, even the child views, so the user always has current data without going all the way back to the overview. Either that happens automatically, or with a pull-to-refresh gesture. For this post I just want to talk about how to get the child views to update, I will talk into how to call the refresh from the child views in another post, for now just keep in mind we want to be able to refresh the cluster data even if we are currently viewing a cluster in a ClusterInfoView.

Another thing to keep in mind is that the Cluster object in this example needs a stable identity. I solved this with the Identifiable protocol and using a unique ID provided by the API. Using a UUID will not work because that will change every time a new cluster response is downloaded from the API and a "new" Cluster() instance is created. This will lead to all the child views being fully destroyed and redrawn on refresh, which is not what we want. So instead we use cluster.cluster_id.

Creating the parent view

After going though Apples excellent fundamentals course, I thought I had understood how to pass data around my view hierarchy. Let us ignore how I get the data from the API for now and just look at how to pass the data through my views. As a first step I need a view that takes the clusters and displays all of them in a list for the overview page.

struct ClusterView: View {
    var clusters = [Cluster]()

    var body: some View {
        VStack {
            Text("Databricks Clusters").font(.headline)
            List(self.clusters) { cluster in
                HStack {
                    Text(cluster.cluster_name)
                }
            }.task {
                // load clusters from API
            }
        }
    }
}

We are not passing any data yet and SwiftUI is not watching the clusters for any changes. Even if the clusters variable changes, the names in the displayed view would remain the same. So if we want to tell SwiftUI to watch this value, we use the @State wrapper.

@State var clusters = [Cluster]()

Now should we reload our cluster data, e.g. if we call the API in refreshable{} on the list, we can get the names in the list to update simply by performing a pull-to-refresh gesture.

Creating the child view

Now it is time to create our child view ClusterInfoView and get it to open as a second screen. For this we can use NavigationView and NavigationLink. In my ContentView I wrap the call to ClusterView in NavigationView{} and now I can use NavigationLink inside of it.

struct ContentView: View {
    var body: some View {
        NavigationView {
            ClusterView()
        }
    }
}

struct ClusterView: View {
    @State var clusters = [Cluster]()

    var body: some View {
        VStack {
            Text("Databricks Clusters").font(.headline)
            List(self.clusters) { cluster in
                NavigationLink(destination: ClusterInfoView(cluster: cluster)) {
                    HStack {
                        Text(cluster.cluster_name)
                    }
                }
            }
        }
    }
}

Every list item is wrapped in a NavigationLink so that it can be tapped and a new page is opened, in this case ClusterInfoView. Because I want to show the details of one particular cluster, I have to pass that as a parameter and expect said parameter in my ClusterInfoView.

struct ClusterInfoView: View {
    @State var cluster: Cluster

    var body: some View {
        List {
            HStack {
                Label(self.label, systemImage: self.icon)
                Spacer()
                Text(self.value)
            }
        }
    }
}

And here I met my first problem. I ran the code on my phone and to my immense disappointment, the ClusterInfoView is not actually updating, it keeps the values it has when it is first rendered.
This is because by applying the @State wrapper to ClusterInfoViews cluster I established a second source of information or "truth" how Apple likes to call it.

So away with the @State:

var cluster: Cluster

Actually I have to intention of altering the cluster inside of my child view so why even make it mutable?

let cluster: Cluster

Fully expecting everything to work now I start the App on my iPhone and find: Still nothing. I must have spend hours that felt like days figuring out what went wrong and if I still fundamentally misunderstood something about SwiftUI.

Alternatively I tried @Binding in the child view and that worked! The child views values are updating! But as I understand, Binding is really only designed for when the ChildView needs to update its parent, which might be needed in the future when I make the cluster editable. But for now this should work without Bindings!
So why is the ClusterDetailView not updating?

I explained the problem to my plush Bert from Sesame Street sitting on my desk and while he did not offer any insights - he rarely does - I had one more idea: When I implemented my Codable model of Cluster to translate the API responses into struct values, I did not just use Identifiable but also Equatable. I did not really need it back then yet but it seemed like a good idea to be able to equate my Clusters with their stable identity. However it seems like SwiftUI is using Equatable to determine if a value changed, while ignoring all other possible changes of other values. Of course my API-provided cluster "id" never changed, so SwiftUI never thought to redraw the Views.

This was the Equatable definition that I came up with.

extension Cluster: Equatable {
    static func == (lhs: Cluster, rhs: Cluster) -> Bool {
        return lhs.id == rhs.id
    }
}

I removed it from the model and I audibly cheer as my child view is now updating automatically when the clusters change.


You'll only receive email when they publish something new.

More from restlessmodem
All posts