Search code examples
swiftuiasync-awaitbottom-sheetios17

Bottom sheet detent update in async Task


I'm facing an issue with updating the detent size of a bottom sheet view (PlannerView) after an asynchronous network call.

Here's the setup:

In my ParentView, there's a button that triggers the presentation of PlannerView as a bottom sheet. The detent size of this bottom sheet, controlled by bottomSheetDetent, is set to .medium by default. However, based on certain conditions in my ViewModel (viewModel.routePath.isEmpty or viewModel.locations.isEmpty), the detent size should change to .large. If these conditions are not met, it should remain .medium.

@main
struct MyApp: App {
    @StateObject private var viewModel = ViewModel()
    var body: some Scene {
        WindowGroup {
            ParentView()
                .environmentObject(viewModel)
        }
    }
}

struct ParentView: View {
    @EnvironmentObject var viewModel: ViewModel
    @State private var bottomSheetDetent: PresentationDetent = .large
    @State private var showingPlannerView = false
    
    var body: some View {
        Button("Show Planner") {
            showingPlannerView = true
        }
        .sheet(isPresented: $showingPlannerView) {
            PlannerView(
                bottomSheetDetent: $bottomSheetDetent,
                showingPlannerView: $showingPlannerView
            )
            .presentationDetents(
                ((viewModel.routePath.isEmpty || viewModel.chargerLocations.isEmpty) ? [.large, .medium] : [.medium]),
                selection: $bottomSheetDetent
            )
            .presentationContentInteraction(.resizes)
            .presentationBackgroundInteraction(.enabled(upThrough: .medium))
            .presentationDragIndicator(.visible)
        }
    }
}

The PlannerView is responsible for making an asynchronous network call. My intention is for the PlannerView to update its detent size to .medium upon the successful completion of this network call. This is crucial for allowing users to interact with the underlying ParentView, which is an iOS 17 Map view in my app.

However, I'm encountering a problem: after the network call in PlannerView completes, the detent size doesn't update to .medium as I expect. I'm looking for advice on how to ensure that the PlannerView updates its detent size to .medium post the network call, enabling proper interaction with the ParentView. Any suggestions or insights on how to resolve this issue would be greatly appreciated.

struct PlannerView: View {
   @EnvironmentObject var viewModel: ViewModel
   @Binding var bottomSheetDetent: PresentationDetent
   @Binding var showingPlannerView: Bool
    
    var body: some View {
        VStack {
            if viewModel.isLoading {
                Text("Loading route data...")
            } else {
                Text("Route Data Ready")
                // Display route data here
            }
            Button("Fetch Route") {
                DispatchQueue.main.asyncAfter(deadline: .now() + 3) { // ?????
                    self.bottomSheetDetent = .medium // ?????
                }
                Task {
                    await viewModel.fetchRouteData(
                        carModel: "CarModel",
                        from: CLLocationCoordinate2D(latitude: 0.0, longitude: 0.0),
                        to: CLLocationCoordinate2D(latitude: 1.0, longitude: 1.0),
                        initialSocPerc: 50
                    )
                }
            }
        }
    }
}

And finally the ViewModel code which makes the related API call and retrieving data:

struct Dest {
    let lat: Double
    let lon: Double
    let address: String?
}

struct Planner {
    struct Route {
        var steps: [Step]
    }
    
    struct Step {
        var lat: Double?
        var lon: Double?
        var isCharger: Bool?
        var Duration: Int?
        var path: [[Double]]?
        // ... other properties ...
    }
    
    var result: Route?
    var planURL: String?
}

// ViewModel to handle route data
class ViewModel: ObservableObject {
    @Published var isLoading = false
    @Published var hasError = false
    @Published var routePath: [CLLocationCoordinate2D] = []
    
    // Fetch route data
    @MainActor
    func fetchRouteData(
        carModel: String,
        from startLocation: CLLocationCoordinate2D,
        to endLocation: CLLocationCoordinate2D,
        initialSocPerc: Int
    ) async {
        isLoading = true
        defer { isLoading = false }
        
        // Mocking a network response after a delay
        DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
            let mockData = Planner(result: Planner.Route(steps: [
                // Mock steps data
            ]))
            self.handlePlannerData(mockData)
        }
    }
    
  // Handle the planner data
  @MainActor
  func handlePlannerData(_ data: Planner) {
        guard let route = data.result?.steps.first else { return }
        
        var routePathCoordinates = [CLLocationCoordinate2D]()
        
        if let stepPath = route.path {
            let stepCoordinates = stepPath.map { CLLocationCoordinate2D(latitude: $0[0], longitude: $0[1]) }
            routePathCoordinates.append(contentsOf: stepCoordinates)
        }
        self.routePath = routePathCoordinates
        // ... additional processing ...
    }
}

Solution

  • Ok, I've checked your code. I dont'get where your difficulties lie, maybe I'm missing something or, maybe you are misinterpreting the async/await behavior. I've used this simple approach. Just change the Fetch Route button's action to this:

    Button("Fetch Route") {
                Task {
                    await viewModel.fetchRouteData(
                        carModel: "CarModel",
                        from: CLLocationCoordinate2D(latitude: 0.0, longitude: 0.0),
                        to: CLLocationCoordinate2D(latitude: 1.0, longitude: 1.0),
                        initialSocPerc: 50
                    )
                    
                    /// Add this line here
                    self.bottomSheetDetent = viewModel.detentCondition ? .large : .medium
                }
            }
    

    so that the detent gets updated only after the the async action completes (when using await the code after that does not get executed) so you don't use a DispatchQueue that is maybe executed before the task completes. Then I added a computed variable in the ViewModel to check your condition:

    var detentCondition: Bool {
        /// ViewModel lacks locations property
        routePath.isEmpty || /*viewModel.locations.isEmpty*/ Bool.random()
    }
    

    Now, I hope i did not miss something that was causing you the issue. Let me know if this works for you!