DiegoQ
DiegoQ

Reputation: 1116

Sheet with dynamic size doesn't re-size correctly

I have a sheet with a NavigationStack and I'm trying to change the height dynamically. For some reason when I change the height, the view doesn't show the content properly, however if I scrolling the sheet, the view change and show its content correctly.

struct SheetWithNavigation: View {
    @EnvironmentObject var router: Router
    
    @State private var sheetDetents: Set<PresentationDetent> = [.height(460)]
    
    var body: some View {
        NavigationStack(path: $router.navigation) {
            VStack {
                Spacer()
                Text("Sheet Presented")
                Spacer()
                
                Button("Navigate to First View") {
                    router.navigate(to: .firstNavigationView)
                }
            }
            .navigationDestination(for: Router.Destination.self) { destination in
                switch destination {
                case .firstNavigationView:
                    FirstNavigationView()
                        .navigationBarBackButtonHidden(true)
                        .onAppear {
                            sheetDetents = [.height(300)]
                        }
                }
            }
        }
        .background(.white)
        .presentationDetents(sheetDetents)
    }
}

struct FirstNavigationView: View {
    var body: some View {
        VStack {
            Spacer()
            Text("First Navigation View")
            Spacer()
            
            Button("Go to second navigation view") {}
        }
        .background(.white)
    }
}

This is a video evidence about the behavior

Upvotes: 1

Views: 316

Answers (2)

ITGuy
ITGuy

Reputation: 715

This seems to be a bug that occurs when embedding a navigation stack in a sheet presentation under iOS 17. I was no longer able to reproduce the bug with iOS 18, i.e. Apple seems to have partially fixed it.

The problematic layout behavior can be observed in detail if the views are wrapped with a custom Layout. This reveals that the FirstNavigationView is not actually aware of new view height caused by the manual programmatic change of the detent.

If you use the view hierarchy debugger, this also becomes clearly visible:

Xcode View Hierarchy Debugger

Considering that SwiftUI internally uses a UIHostingController that is embedded in a UINavigationControoler, which in turn is embedded in a presentation controller, it is almost surprising that this constellation does not cause even more problems.

My recommendation for a solution would therefore be to try to completely eliminate the NavigationStack and thus simplify the problem. In your demo code you hide the back button and maybe you don't need the features of a NavigationStack at all.

Unless you need the advanced features of a NavigationStack, this can be implemented relatively easily and leanly:

struct SheetWithNavigation: View {
    enum Destination {
        case rootView
        case firstNavigationView
    }

    @State private var destination: Destination = .rootView
    @State private var sheetDetents: Set<PresentationDetent> = [.height(460)]

    var body: some View {
        VStack {
            switch destination {
            case .rootView:
                VStack {
                    Spacer()
                    Text("Sheet Presented")
                    Spacer()

                    Button("Navigate to First View") {
                        destination = .firstNavigationView
                    }
                }

            case .firstNavigationView:
                FirstNavigationView()
                    .navigationBarBackButtonHidden(true)
                    .task {
                        sheetDetents = [.height(300)]
                    }
            }
        }
        .background(.white)
        .presentationDetents(sheetDetents)
    }
}

With this solution, the problem does not occur under iOS 17.

If you are dependent on the functions of a navigation controller, you could also try adding your own UINavigationController to your SwiftUI view hierarchy via a UIViewControllerRepresentable implementation. However, the implementation would be much more complicated.

Upvotes: 0

Sweeper
Sweeper

Reputation: 273540

I suspect that the view controller hosting the navigation destination is "not aware" of the sheet detents changing, because presenting the navigation destination and the detents are changed in the same view update.

If you change the detents first, then present the navigation destination with some delay, it works correctly.

Button("Navigate to First View") {
    sheetDetents = [.height(300)]
    Task {
        try await Task.sleep(for: .milliseconds(1))
        router.navigate(to: .firstNavigationView)
    }
}

Though, this means you have to change the detents in every button's action that will present FirstNavigationView, which would not be very convenient.

You can also set the detents after the navigation destination is presented. You just need to do it in a different view update. But, for a split second the navigation destination is still going to appear wrongly laid-out, and corrects itself soon after that.

Here is a view modifier that first shows a Spacer. Only after the Spacer appears, does it change the detents, and show the actual view you want to show.

struct NavigationDestinationDetent: ViewModifier {
    @State private var appeared = false
    @Binding var detentsBinding: Set<PresentationDetent>
    let targetDetents: Set<PresentationDetent>
    
    func body(content: Content) -> some View {
        if !appeared {
            Spacer()
                .onAppear {
                    detentsBinding = targetDetents
                    appeared = true
                }
        } else {
            content
                .onDisappear {
                    appeared = false
                }
        }
    }
}

extension View {
    func navigationDetents(_ detents: Set<PresentationDetent>, bindingTo detentsBinding: Binding<Set<PresentationDetent>>) -> some View {
        modifier(NavigationDestinationDetent(detentsBinding: detentsBinding, targetDetents: detents))
    }
}

Here is a full example using this modifier:

struct ContentView: View {
    
    var body: some View {
        List {}
            .sheet(isPresented: .constant(true)) {
                SheetWithNavigation()
            }
    }
}

struct SheetWithNavigation: View {
    @State private var sheetDetents: Set<PresentationDetent> = [.height(460)]
    @State private var path: [Int] = []
    var body: some View {
        NavigationStack(path: $path) {
            VStack {
                Spacer()
                Text("Sheet Presented")
                Spacer()
                
                Button("Navigate to First View") {
                    path.append(0)
                }
            }
            .navigationDestination(for: Int.self) { destination in
                switch destination {
                case 0:
                    FirstNavigationView(path: $path)
                        .navigationBarBackButtonHidden(true)
                        .navigationDetents([.height(300)], bindingTo: $sheetDetents)
                case 1:
                    Text("Foo")
                        .navigationDetents([.height(500)], bindingTo: $sheetDetents)
                default: EmptyView()
                }
            }
        }
        .background(.white)
        .presentationDetents(sheetDetents)
    }
}

struct FirstNavigationView: View {
    @Binding var path: [Int]
    
    var body: some View {
        VStack {
            Spacer()
            Text("First Navigation View")
            Spacer()
            
            Button("Go to second navigation view") {
                path.append(1)
                
            }
        }
        .background(.white)
    }
}

Upvotes: 1

Related Questions