SwiftUI - Passing bindings to sheet view

Viewed 72

I need to pass some bindings to a sheet that can be written to. What I've come up with works but seems a really inefficient way of doing it.

I have recreated a very simplified version of my code to use as an example.

I have a custom LocationStruct...

struct LocationStruct {
    var id = UUID()
    var name: String
    var location: CLLocationCoordinate2D?
    var haveVisited = false
}

I then have the parent view that displays a number of the LocationStruct's - the origin, an array of waypoints and the destination...

struct ContentView: View {

    @State var origin = LocationStruct(name: "Paris")
    @State var waypoints = [LocationStruct(name: "Berlin"), LocationStruct(name: "Milan")]
    @State var destination = LocationStruct(name: "Venice")
    
    @State var selectedLocation: Int?
    @State var showSheet = false
    
    var body: some View {
        
        VStack{
            
            HStack{
                Text("Origin:")
                Spacer()
                Text(origin.name)
            }
            .onTapGesture{
                selectedLocation = 1000
                showSheet = true
            }
            
            ForEach(waypoints.indices, id:\.self){ i in
                HStack{
                    Text("Waypoint \(i + 1):")
                    Spacer()
                    Text(waypoints[i].name)
                }
                .onTapGesture{
                    selectedLocation = i
                    showSheet = true
                }
            }
            
            HStack{
                Text("Destination:")
                Spacer()
                Text(destination.name)
            }
            .onTapGesture{
                selectedLocation = 2000
                showSheet = true
            }
        }
        .padding()
        
        .sheet(isPresented: $showSheet){
            LocationSheet(origin: $origin, waypoints: $waypoints, destination: $destination, selectedLocation: $selectedLocation)
        }
    }
    
}

I then need to read and write to the location object that was tapped on the ContentView. I'm setting selectedLocation value to 1000 or 2000 for origin and destination otherwise its set to the waypoint array index (waypoints are limited in number so will not reach 1000).

I'm having to repeating "if let selectedLocation = ..." in quite a few places. Is there a better way of doing this, maybe some sort of computed binding or something?

struct LocationSheet: View {

    @Binding var origin: LocationStruct
    @Binding var waypoints: [LocationStruct]
    @Binding var destination: LocationStruct
    
    @Binding var selectedLocation: Int?
        
    var body: some View {
        
        VStack{
            if let selectedLocation = selectedLocation {
                switch selectedLocation {
                case 1000:
                    TextField("", text: $origin.name).textFieldStyle(.roundedBorder)
                case 2000:
                    TextField("", text: $destination.name).textFieldStyle(.roundedBorder)
                default:
                    TextField("", text: $waypoints[selectedLocation].name).textFieldStyle(.roundedBorder)
                }
            }
            
            Button(action: { markAsVisted() }){
                Text("Visited")
            }
        }
        .padding()
        
    }
    
    func markAsVisted(){
        if let selectedLocation = selectedLocation {
            switch selectedLocation {
            case 1000:
                origin.haveVisited = true
            case 2000:
                destination.haveVisited = true
            default:
                waypoints[selectedLocation].haveVisited = true
            }
        }
    }
    
}

Thanks in advance

2 Answers

For your question, instead of binding a lots variables like that you can create an EnvironmentObject to store and just pass screen with only that EnvironmentObject will make your view be simpler.

Making an enum to store location type where you tap instead of making a number const like 1000, 2000 like that - this will make you code not clean and not easily extendable.

Code of enum and ObservableObject will be like this

enum LocationType {
    case origin
    case waypoint
    case destination
}

struct LocationStruct {
    var id = UUID()
    var name: String
    var location: CLLocationCoordinate2D?
    var haveVisited = false
}

class Location : ObservableObject {
    @Published var origin = LocationStruct(name: "Paris")
    @Published var waypoints = [LocationStruct(name: "Berlin"), LocationStruct(name: "Milan")]
    @Published var destination = LocationStruct(name: "Venice")

    @Published var tapLocationType : LocationType = .origin
    @Published var tapIndex : Int?

    func didTapLocaiton(type: LocationType, tapIndex: Int? = nil) {
        self.tapLocationType = type
        self.tapIndex = tapIndex
    }

    func didVisit() {
        switch tapLocationType {
        case .origin:
            origin.haveVisited = true
        case .waypoint:
            waypoints[tapIndex ?? 0].haveVisited = true
        case .destination:
            destination.haveVisited = true
        }
    }

    func getName() -> String {
        switch tapLocationType {
        case .origin:
            return origin.name
        case .waypoint:
            return waypoints[tapIndex ?? 0].name
        case .destination:
            return destination.name
        }
    }
}

And then, come to your view just take the variable from Location and appear and when you need to change any variables you should call from Location object. Will make your code easier to handle

struct LocationSheet: View {
    @ObservedObject var location : Location

    var body: some View {
        VStack{
            Text(location.getName()).textFieldStyle(.roundedBorder)
            Button(action: { markAsVisted() }){
                Text("Visited")
            }
        }
        .padding()
    }

    func markAsVisted(){
        location.didVisit()
    }
}

struct ContentView: View {
    // make location state for storing
    @StateObject var location = Location()
    @State var showSheet = false

    var body: some View {
        VStack{
            HStack{
                Text("Origin:")
                Spacer()
                Text(location.origin.name)
                Spacer()
                Text(verbatim: "Visit: \(location.origin.haveVisited)")
            }
            .onTapGesture{
                location.didTapLocaiton(type: .origin)
                showSheet = true
            }

            ForEach(location.waypoints.indices, id:\.self){ i in
                HStack{
                    Text("Waypoint \(i + 1):")
                    Spacer()
                    Text(location.waypoints[i].name)
                    Spacer()
                    Text(verbatim: "Visit: \(location.waypoints[i].haveVisited)")
                }
                .onTapGesture{
                    location.didTapLocaiton(type: .waypoint, tapIndex: i)
                    showSheet = true
                }
            }

            HStack{
                Text("Destination:")
                Spacer()
                Text(location.destination.name)
                Spacer()
                Text(verbatim: "Visit: \(location.destination.haveVisited)")
            }
            .onTapGesture{
                location.didTapLocaiton(type: .destination)
                showSheet = true
            }
        }
        .padding()
        .sheet(isPresented: $showSheet){
            LocationSheet(location: location)
        }
    }
}

The result Result

The trick is to use a non-optional custom state struct that holds both isPresented and the data the sheet needs, which is only valid when the sheet is showing.

Apple provide an example of this in Data Essentials in SwiftUI WWDC 2020 at 4:18.

EditorConfig can maintain invariants on its properties and be tested independently. And because EditorConfig is a value type, any change to a property of EditorConfig, like its progress, is visible as a change to EditorConfig itself.

In your case it would be done as follows:

struct LocationSheetConfig {
    var selectedLocation: Int = 0 // this is usually a struct, e.g. Location, or an id, e.g. UUID.

    var showSheet = false
 
    mutating func selectLocation(id: Int){
        selectedLocation = id
        showSheet = true
    }

    // usually there is another mutating func for closing the sheet.
}

@State var config = LocationSheetConfig()

.onTapGesture{
    config.selectLocation(id: 1000)
}

.sheet(isPresented: $config.showSheet){
    LocationSheet(origin: $origin, waypoints: $waypoints, destination: $destination, config: $config)
}

Also I noticed your Location struct is missing Identifiable protocol conformance. And you must not use give indices to the ForEach View, it has to be an array of Identifiable structs, otherwise it'll crash when there is a change (because it cannot track indices, only IDs).

Related