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.

SwiftUI: Sheets and Bindings
A List and Edit Detail View in a Sheet Presentation (SwiftUI)

One of the ways for presenting a sheet in SwiftUI is the
sheet(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.