Reflecting changing state in SwiftUI Lists and List items
19 Feb 2020 17:21
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 TaskView
s 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 TaskView
s.
So, next I tried to use .disabled()
on the TaskView
s 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 TaskView
s 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.