How to move between screens in SwiftUI

SwiftUI gives you several ways to open a different screen from your first screen, and which one you use depends on what kind of navigation you need. The three main patterns are NavigationStack (for hierarchical navigation where you move forward and back), NavigationSplitView (for showing a list and detail side by side), and sheet or fullScreenCover (for modal screens that float on top). Most apps use NavigationStack because it handles the back button automatically and feels native to iOS.

The simplest approach for most cases is NavigationStack with NavigationLink. You wrap your view in a NavigationStack, then use NavigationLink to specify what screen opens when the user taps a button or other element. SwiftUI handles the animation and the back button for you.

Key Takeaways

  • NavigationStack with NavigationLink is the standard way to move between screens in a hierarchy, where each new screen has a back button.
  • Use sheet() or fullScreenCover() when you need a modal screen that appears on top of the current screen and can be dismissed by swiping down or tapping a close button.
  • NavigationSplitView shows a list on the left and detail on the right, useful for iPad layouts or apps with a sidebar.
  • The destination parameter in NavigationLink tells SwiftUI which view to open, and you can pass data between screens using @State or @ObservedObject.

Using NavigationStack and NavigationLink for forward navigation

NavigationStack is the container that manages your navigation history. You wrap your entire view hierarchy in it, and then use NavigationLink inside to create tappable elements that open new screens. Here is the basic structure:

Start with a NavigationStack at the top level of your view. Inside it, place a VStack or other layout with your content. Then add a NavigationLink with a label (the text or button the user taps) and a destination (the view that opens). The destination can be any SwiftUI view — a simple Text view for testing, or a full custom view like DetailScreen().

When the user taps the NavigationLink, SwiftUI pushes the destination view onto the stack and shows a back button in the navigation bar. The back button works automatically; you do not need to write code for it. The user can tap back to return to the first screen, and SwiftUI removes the destination view from memory.

Passing data between screens with NavigationLink

Often you need to send information from the first screen to the second. For example, if you have a list of items and the user taps one, you want the detail screen to know which item was selected. You do this by passing a value to the destination view.

Create a property in your destination view using @State or as a simple parameter. Then in the NavigationLink, pass that value when you create the destination. For example, if DetailScreen takes an id parameter, write NavigationLink(destination: DetailScreen(id: selectedId)) { Text("Open Detail") }. The value flows from the first screen to the second, and the second screen can use it to load or display the right content.

If you need two-way communication (the detail screen changes something that affects the first screen), use @State in the parent and @Binding in the child, or use @ObservedObject with a shared data model that both screens can read and write.

Modal screens with sheet and fullScreenCover

Sometimes you want a screen that appears on top of the current screen rather than replacing it. This is called a modal. Use sheet() for a screen that slides up from the bottom and can be dismissed by swiping down, or fullScreenCover() for a screen that covers the entire display.

Add sheet() as a modifier to your view. Give it an isPresented parameter (a @State boolean that controls whether the sheet is visible) and a content parameter (the view to show). When isPresented is true, the sheet appears. When the user swipes down or you set isPresented to false, the sheet closes.

fullScreenCover() works the same way but covers the entire screen with no way to swipe down. Use it for screens like login or onboarding where you want the user to make a choice before returning. Always include a button or action that sets isPresented to false, or the user will be stuck.

NavigationSplitView for list-detail layouts

If your app shows a list on the left and a detail view on the right (common on iPad or in split-screen layouts), use NavigationSplitView instead of NavigationStack. It manages two columns: the sidebar (usually a list) and the detail area.

NavigationSplitView takes a selection parameter (which item is currently selected) and a content parameter for the sidebar and a detail parameter for the detail view. When the user taps an item in the list, you update the selection, and the detail view updates to show that item's information.

On iPhone, NavigationSplitView automatically collapses to show only the list or only the detail, depending on the screen size. On iPad, it shows both side by side. You write the code once, and SwiftUI adapts the layout.

Choosing between navigation patterns

Use NavigationStack when screens form a clear path forward — like a wizard or a drill-down list. The user moves from screen to screen, and the back button lets them retrace their steps. This is the most common pattern.

Use sheet() for dialogs, forms, or secondary tasks that do not fit into the main flow. Examples include a settings panel, a confirmation dialog, or a filter menu. The user completes the task and dismisses the sheet to return to where they were.

Use fullScreenCover() for screens that demand full attention, like login, onboarding, or a camera view. The user cannot see the previous screen, and they must complete an action before moving forward.

Use NavigationSplitView if your app has a sidebar or list-detail structure, especially on iPad. It handles the layout changes automatically as the screen size changes.

Common mistakes and how to avoid them

One frequent mistake is nesting NavigationStack inside NavigationStack. SwiftUI does not handle this well — use only one NavigationStack at the top level of your app, and use NavigationLink inside it for all forward navigation. If you need a modal on top of a NavigationStack, use sheet() or fullScreenCover() as a modifier on the view inside the stack.

Another mistake is forgetting to pass data correctly. If your destination view expects a parameter, you must provide it in the NavigationLink. If you forget, the code will not compile, which is actually helpful — the compiler catches the error before you run the app.

A third mistake is leaving isPresented as true by accident. If you use sheet() but never set isPresented to false, the sheet will stay open. Always include a button or action that dismisses the sheet, or use @Environment(\.dismiss) to get a dismiss function that closes it automatically.

Frequently Asked Questions

How do I add a back button to a custom screen?

You do not need to — NavigationStack adds it automatically when you use NavigationLink. If you want to customize the back button text or appearance, use the navigationTitle() and navigationBarBackButtonHidden() modifiers. If you need a custom close button on a sheet, add a Button with an action that sets isPresented to false.

Can I pass multiple values between screens?

Yes. Create a struct or class to hold all the values you need, then pass one instance of it to the destination view. For example, create a struct Item with id, name, and description, then pass Item(id: 1, name: "Test", description: "A test item") to DetailScreen(item: item). This is cleaner than passing five separate parameters.

What is the difference between sheet and fullScreenCover?

sheet() shows a modal that slides up from the bottom and can be swiped down to dismiss. fullScreenCover() covers the entire screen and cannot be swiped away — the user must tap a button to close it. Use sheet() for optional tasks and fullScreenCover() for required ones like login.

How do I prevent a user from going back to a previous screen?

Use fullScreenCover() instead of NavigationStack. fullScreenCover() does not show a back button, so the user cannot return unless you provide a button that dismisses it. This is useful for login screens or onboarding where you want to force a choice.

Can I animate the transition between screens?

NavigationStack and sheet() have built-in animations that you cannot easily change. If you need a custom animation, use fullScreenCover() with the transition() modifier, or use a @State boolean to control a ZStack that shows different views with custom animations. This is more complex but gives you full control.