Reputation: 1116
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
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:
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
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