Posted on Wed 19 February 2020

Reflecting changing state in SwiftUI Lists and List items

I was struggling with something recently in SwiftUI and thought I'd share the solution I came up with. Here's a simple example to demonstrate what I was stuck on:

struct ContentView: View {
  var tasks: [Task]

  var body: some View {
    List(self.tasks) { task in
      TaskView(task: task)
    }
  }
}

I have a List showing TaskViews made from Task models. Each TaskView includes a button which will set the task state to completed. When the user taps the button, a network call is kicked off, which changes the task's state on the server. When the network call completes (let's assume it succeeds), it updates my local database.

I'm using GRDB and GRDBCombine to get live reloading of my List when the local database changes, so my List will redraw as soon as the network call succeeds and the databased gets updated. I won't include the database setup in these examples, just to keep things simple.

Here's where I was having trouble: I wanted to make the List, or at least, the other items in the list, not allow any user interaction while the network call was running.

The TaskView shows a spinner where the button was after the user taps the button, so I'm trying to update both the List and a view inside the list at the same time, so they'll both reflect the status of the network call in different ways.

Showing the spinner was easy enough, since the list item itself triggers the network call, so it knows when to replace the button with the spinner. But getting the information bubbled up to the List that it needs to disable user interaction on other items in the list was the tricky part. Here's how I swapped out the button for the spinner (Spinner is a UIViewRepresentable wrapper around UIActivityIndicator):

struct TaskView: View {
  let task: Task
  @State var isCompleting: Bool = false

  var body: some View {
    HStack {
      Text(self.task.title)

      // If we're already busy, show a spinner,
      // otherwise show a checkmark
      if self.isCompleting {
        Spinner()
      } else {
        Image(systemName: "checkmark")
      }
      // Instead of a Button I'm using a view with an .onTapGesture closure
      // because Buttons behave weirdly inside Lists
      .onTapGesture {
        self.isCompleting = true
        complete(self.task)
      }
    }
  }
}

I started by adding a @State var to the main ContentView, so I could keep track of whether any of the list items had kicked off a network call and reflect that inside the List:

@State private var isBusy: Bool = false

I tried using this with the handy .disabled() modifier, which takes a Bool, to set the entire list to disabled:

List(self.tasks) { task in
  TaskView(task: task)
}
.disabled(self.isBusy)

But, just like with UITableView, disabling the entire List meant the user couldn't scroll either, which is a bad experience. I really just wanted them to not be able to tap the checkmark button in any of the other TaskViews.

So, next I tried to use .disabled() on the TaskViews inside the list:

List(self.tasks) { task in
  TaskView(task: task)
    .disabled(self.isBusy)
}

But for some reason (tweet at me (@bellebcooper) if you know why!), disabling the TaskView meant the spinner never showed up when isCompleting changed to true. It seemed like the TaskView body code wasn't being called anymore. Maybe .disabled() stops any redraws as well? I don't know, but that was obviously no good, since the user had no indication their button tap had done anything.

So I decided I would instead have to pass the isBusy Bool from the main ContentView through to each TaskView inside the List so it could decide whether to enable its button or not. I added a property for the Bool to the TaskView:

@Binding var isBusy: Bool

And instead of disabling anything, I just used a guard to decide whether to handle a tap or not:

.onTapGesture {
  guard !self.isBusy else { return }
  complete(self.task)
}

I also changed the colour of the checkmark button depending on the isBusy Bool, so it would look greyed-out if a network call was already running:

.foregroundColor(self.isBusy ? Styles.Colours.Grey.medium : Styles.Colours.Theme.accent)

I also created a view model for my TaskView, which handles the network call, and subscribes to the result of the call. The view model can update the isBusy Bool based on starting/completing a network call, which tells all the TaskViews to not accept taps. I also moved the individual TaskView's isCompleting property into the view model, which tells this TaskView to show a spinner instead of a button. Now the TaskView can check the isCompleting property on its view model, and the view model can update that property based on the state of its network call.

struct TaskViewModel: View {
  @Binding var isBusy: Bool // Binding to the ContentView's isBusy Bool
  @Published var isCompleting: Bool = false
  private var cancellables = [AnyCancellable]()

  init(isBusy: Binding<Bool>) {
    self._isBusy = isBusy
  }

// This func is called by the TaskView when the user taps the button
  func complete(_ task: Task) {
    self.isAppending = true // make the TaskView swap the button for a spinner
    self.isBusy = true // make all other TaskViews not accept taps and show their buttons as greyed-out

    NetworkService.complete(_ task: Task)
      .receive(on: DispatchQueue.main)
      .sink(receiveCompletion: { completion in
        //...
      }) { _ in
      // Regardless of the returned value, we don't want to keep showing a spinner, so isCompleting should be false now.
      // The TaskView will show a button if the network call failed, or
      // nothing if the call succeeded, based on info from the local db
        self.isAppending = false
        self.isBusy = false // and tell other TaskViews they can receive taps now because we're done with the network call
      }
      // Keep a reference or else our Subscriber will drop out of memory
      .store(in: &self.cancellables)
    }
}

I'm not sure if there's a better way to handle this, but I've run into the same trouble in UIKit before, where I want to update both an individual UITableViewCell and the UITableView its in, based on a network call, and I've never found a better way to manage it than sharing some state between the two. If you have a better idea, feel free to share it with me on Twitter at @bellebcooper.


P.S. I make some stuff you might like: Exist, a personal analytics app to help you understand your life, Larder, a bookmarking app for developers, and Changemap, a roadmap and changelog for transparent teams.

© Belle B. Cooper. Built using Pelican. Theme by Giulio Fidente on github, edited by Belle B. Cooper. Theme inspiration from Jordan Smith and DuoTone snow theme.