SwiftUI: Sheets and Bindings
SwiftUI's `sheet(item:onDismiss:content:)` seems like the perfect way to display an editor view in a sheet presentation. I ran into some issues using this method, and eventually found a nice solution.
One of the ways for presenting a sheet in SwiftUI is thesheet(item:onDismiss:content:)
method.
From the docs:
Presents a sheet using the given item as a data source for the sheet’s content.
item - A binding to an optional source of truth for the sheet. When item is non-nil, the system passes the item’s content to the modifier’s closure. You display this content in a sheet that you create that the system displays to the user. If item changes, the system dismisses the sheet and replaces it with a new one using the same process.
This sounds pretty perfect for the common use case of displaying an editor view in a sheet presentation.
In practice I ran into some issues using this method, and eventually found a nice solution. Apple's documentation seems to be lacking in this regard, hence this blog post.
First attempt:
struct PlayersView: View {
@State private var players = [
Player(name: "Xen", rating: 9),
Player(name: "Jonny Zero", rating: 8),
]
@State private var playerToEdit: Player?
var body: some View {
VStack {
List($players) { $player in
VStack(alignment: .leading) {
Text(player.name)
Text("Rating: \(player.rating)")
}
.onTapGesture {
playerToEdit = player
}
}
}
.sheet(item: $playerToEdit) { player in
// ⚠️ Wont compile...
PlayerEditor(player: player)
// Error:
// "Cannot convert value of type 'Player' to expected argument type 'Binding<Player>'"
// ⚠️ Also wont compile...
PlayerEditor(player: $player)
// Error:
// "Cannot find '$player' in scope"
// 🤔 What do we do?
}
}
}
Here, we want to be able to use the closure argument player
and edit it using an editor view (PlayerEditor
in this case).
Like most Editor views, PlayerEditor
needs a binding to a type to edit:
struct PlayerEditor: View {
@Binding var player: Player
var body: some View {
VStack {
TextField("Name", text: $player.name)
}
}
}
So we somehow need to get a Binding for the player
closure argument.
One solution is to have a method that searches the appropriate data store and returns a binding to the type to be edited. In our toy example, this would look like this:
func binding(for playerID: Player.ID) -> Binding<Player>? {
guard let index = players.firstIndex(where: { $0.id == playerID }) else {
return nil
}
return Binding(get: { players[index] },
set: { value in players[index] = value })
}
Subsequently our sheet code ends up looking like:
.sheet(item: $playerToEdit) { player in
if let playerBinding = binding(for: player.id) {
PlayerEditor(player: playerBinding)
} else {
Text("Error condition")
}
}
Now, this works, and Apple's own sample code uses this approach in places. And I have used this approach in some projects. But... I have always really disliked the fact we have to do a lookup to get the binding to the type in the data store.
"There has to be a better way!" I often thought. And, yes, there is. After some research, I found an elegant solution by Dave Meehan, who shared his approach to this problem in a Github gist.
The broad approach is as follows. Instead of keeping a @State variable of optional Player type (Player?
):
@State private var playerToEdit: Player?
...we explicitly set the type to that of an optional Binding:
@State private var playerToEdit: Binding<Player>?
This means, the argument to our .sheet closure ends up being of type Binding
.
Our code ends up being:
struct PlayersView: View {
@State private var players = [
Player(name: "Xen", rating: 9),
Player(name: "Jonny Zero", rating: 8),
]
@State private var playerToEdit: Binding<Player>?
var body: some View {
VStack {
List($players) { $player in
VStack(alignment: .leading) {
Text(player.name)
Text("Rating: \(player.rating)")
}
.onTapGesture {
playerToEdit = $player
}
}
}
.sheet(item: $playerToEdit) { playerBinding in
PlayerEditor(player: playerBinding)
}
}
}
I think this is a really elegant solution, and I am surprised Apple does not use it in their sample code, or have something similar as the example code for the sheet(item:onDismiss:content:)
method.
Dave Meehan's gist also shows an approach to support non-destructive edits (ie you can cancel out of the editor view). Definitely worth checking out.
Perhaps I'm missing something here and there is a better way to handle editing items in a sheet view. But so far, I think this is the best approach I have seen.