Navigation
Drift provides stack-based navigation with support for named routes, route guards, path parameters, deep linking, and tab navigation.
Setting Up Routes
Use a Navigator with route generation:
func App() core.Widget {
return navigation.Navigator{
InitialRoute: "/",
IsRoot: true, // Mark as root navigator for back button handling
OnGenerateRoute: func(settings navigation.RouteSettings) navigation.Route {
switch settings.Name {
case "/":
return navigation.NewAnimatedPageRoute(buildHome, settings)
case "/details":
return navigation.NewAnimatedPageRoute(buildDetails, settings)
case "/settings":
return navigation.NewAnimatedPageRoute(buildSettings, settings)
default:
return nil
}
},
}
}
Navigator Ownership
When using multiple navigators (e.g., with TabNavigator), set IsRoot: true on your main navigator. This registers it as the root navigator for back button handling and deep links. Tab navigators automatically register with TabNavigator and become active when their tab is selected.
Route Builders
Route builders receive BuildContext and return a widget:
func buildHome(ctx core.BuildContext) core.Widget {
return HomePage{}
}
func buildDetails(ctx core.BuildContext) core.Widget {
return DetailsPage{}
}
Navigating
Get the navigator from context and call navigation methods:
func handleTap(ctx core.BuildContext) {
nav := navigation.NavigatorOf(ctx)
if nav == nil {
return
}
// Push a named route
nav.PushNamed("/details", nil)
}
Navigation Methods
| Method | Description |
|---|---|
Push(route) | Push a route onto the stack |
PushNamed(name, args) | Push a named route with arguments |
Pop(result) | Pop the current route, optionally with a result |
CanPop() | Check if there's a route to pop |
MaybePop(result) | Pop if possible, otherwise do nothing |
PopUntil(predicate) | Pop routes until predicate returns true |
Example Navigation Flow
// Push to details
nav.PushNamed("/details", nil)
// Go back
nav.Pop(nil)
// Only go back if possible
if nav.CanPop() {
nav.Pop(nil)
}
// Pop to root
nav.PopUntil(func(route navigation.Route) bool {
return route.Settings().Name == "/"
})
Passing Data
Pass arguments when navigating:
// Navigate with arguments
nav.PushNamed("/details", map[string]any{
"id": 123,
"title": "Item Title",
})
Access arguments in the route builder via route settings:
func App() core.Widget {
return navigation.Navigator{
InitialRoute: "/",
OnGenerateRoute: func(settings navigation.RouteSettings) navigation.Route {
switch settings.Name {
case "/":
return navigation.NewAnimatedPageRoute(buildHome, settings)
case "/details":
// Access arguments from settings
return navigation.NewAnimatedPageRoute(func(ctx core.BuildContext) core.Widget {
args := settings.Arguments.(map[string]any)
id := args["id"].(int)
title := args["title"].(string)
return DetailsPage{ID: id, Title: title}
}, settings)
default:
return nil
}
},
}
}
Returning Results
Return data when popping a route:
// In the destination screen - pop with result
nav.Pop("selected_item_id")
Modal Bottom Sheets
Use ShowModalBottomSheet to present a bottom sheet and await a result.
result := <-navigation.ShowModalBottomSheet(ctx, func(ctx core.BuildContext) core.Widget {
return widgets.Padding{
Padding: layout.EdgeInsetsAll(24),
Child: widgets.Column{
MainAxisSize: widgets.MainAxisSizeMin,
Children: []core.Widget{
widgets.Text{Content: "Select an option"},
theme.ButtonOf(ctx, "Option A", func() {
widgets.BottomSheetScope{}.Of(ctx).Close("A")
}),
},
},
}
})
Snap Points and Drag Modes
navigation.ShowModalBottomSheet(
ctx,
func(ctx core.BuildContext) core.Widget { return sheetContent() },
navigation.WithSnapPoints(widgets.SnapHalf, widgets.SnapFull),
navigation.WithInitialSnapPoint(0),
navigation.WithDragMode(widgets.DragModeContentAware),
)
Scrollable Content
For scrollable content, wrap the list in BottomSheetScrollable so dragging and
scrolling are coordinated:
navigation.ShowModalBottomSheet(ctx, func(ctx core.BuildContext) core.Widget {
return widgets.BottomSheetScrollable{
Builder: func(controller *widgets.ScrollController) core.Widget {
return widgets.ListView{
Controller: controller,
Children: items,
}
},
}
})
Handle results by using a callback pattern:
// Selection screen that returns a result
type SelectionScreen struct {
OnSelect func(item string)
}
func (s SelectionScreen) Build(ctx core.BuildContext) core.Widget {
return widgets.ListView{
Children: []core.Widget{
widgets.Tap(func() {
s.OnSelect("item_1")
navigation.NavigatorOf(ctx).Pop(nil)
}, widgets.Text{Content: "Item 1"}),
// ...
},
}
}
Unknown Routes
Handle navigation to undefined routes:
navigation.Navigator{
InitialRoute: "/",
OnGenerateRoute: generateRoute,
OnUnknownRoute: func(settings navigation.RouteSettings) navigation.Route {
return navigation.NewAnimatedPageRoute(
func(ctx core.BuildContext) core.Widget {
return widgets.Column{
Children: []core.Widget{
widgets.Text{Content: "Page not found"},
widgets.Text{Content: settings.Name},
theme.ButtonOf(ctx, "Go Home", func() {
navigation.NavigatorOf(ctx).PopUntil(func(r navigation.Route) bool {
return r.Settings().Name == "/"
})
}),
},
}
},
settings,
)
},
}
Route Guards
Use the Redirect callback to implement authentication guards and route protection:
// Auth state notifier
type AuthState struct {
core.Notifier
isLoggedIn bool
}
func (a *AuthState) SetLoggedIn(loggedIn bool) {
a.isLoggedIn = loggedIn
a.Notify() // Triggers redirect re-evaluation
}
var authState = &AuthState{}
func App() core.Widget {
return navigation.Navigator{
InitialRoute: "/",
IsRoot: true,
RefreshListenable: authState, // Re-evaluate redirects when auth changes
Redirect: func(ctx navigation.RedirectContext) navigation.RedirectResult {
isProtected := strings.HasPrefix(ctx.ToPath, "/dashboard") ||
strings.HasPrefix(ctx.ToPath, "/settings")
if isProtected && !authState.isLoggedIn {
// Redirect to login, preserving the intended destination
return navigation.RedirectWithArgs("/login", map[string]any{
"returnTo": ctx.ToPath,
})
}
if ctx.ToPath == "/login" && authState.isLoggedIn {
// Already logged in, go to dashboard
return navigation.RedirectTo("/dashboard")
}
return navigation.NoRedirect()
},
OnGenerateRoute: generateRoute,
}
}
Redirect Helpers
| Function | Description |
|---|---|
NoRedirect() | Allow navigation to proceed normally |
RedirectTo(path) | Redirect to a different path (replaces current route) |
RedirectWithArgs(path, args) | Redirect with arguments preserved |
RefreshListenable
The RefreshListenable field accepts any core.Listenable. When the listenable notifies, the navigator re-evaluates whether the current route should be redirected. This is useful for:
- Auth state changes (logout redirects to login)
- Permission changes (user loses access to a route)
- Feature flags (route becomes unavailable)
Path Parameters
Extract dynamic values from URL paths using RouteSettings:
// Route settings now include Params and Query
type RouteSettings struct {
Name string
Arguments any
Params map[string]string // Path params like {"id": "123"}
Query map[string][]string // Query params
}
// Convenience methods
settings.Param("id") // Get path parameter
settings.QueryValue("search") // Get first query value
settings.QueryValues("tags") // Get all query values
Using PathPattern
For manual path matching with parameters:
pattern := navigation.NewPathPattern("/products/:id")
params, ok := pattern.Match("/products/123")
// params = {"id": "123"}, ok = true
params, ok = pattern.Match("/products/hello%20world")
// params = {"id": "hello world"}, ok = true (percent-decoded)
Wildcard Parameters
Capture the rest of the path:
pattern := navigation.NewPathPattern("/files/*path")
params, ok := pattern.Match("/files/docs/readme.md")
// params = {"path": "docs/readme.md"}, ok = true
Path Matching Options
// Case-insensitive matching
pattern := navigation.NewPathPattern("/Products/:id",
navigation.WithCaseSensitivity(navigation.CaseInsensitive),
)
// Strict trailing slash
pattern := navigation.NewPathPattern("/products/:id",
navigation.WithTrailingSlash(navigation.TrailingSlashStrict),
)
Parsing URLs
Parse full URLs with query strings:
path, query := navigation.ParsePath("/search?q=drift&page=2")
// path = "/search"
// query = {"q": ["drift"], "page": ["2"]}
Declarative Router
For larger applications, use the declarative Router API for cleaner route configuration:
func App() core.Widget {
return navigation.Router{
InitialPath: "/",
Routes: []navigation.ScreenRoute{
{
Path: "/",
Screen: navigation.ScreenOnly(buildHome),
},
{
Path: "/products",
Screen: navigation.ScreenOnly(buildProductList),
Children: []navigation.ScreenRoute{
{
Path: "/:id", // Nested: /products/:id
Screen: buildProductDetail,
},
},
},
{
Path: "/settings",
Screen: navigation.ScreenOnly(buildSettings),
},
},
ErrorBuilder: buildNotFound,
Redirect: authRedirect,
}
}
func buildProductDetail(ctx core.BuildContext, settings navigation.RouteSettings) core.Widget {
productID := settings.Param("id")
return ProductDetailPage{ID: productID}
}
Routes that use path parameters or query strings need the full
func(core.BuildContext, navigation.RouteSettings) core.Widget signature, as shown
with buildProductDetail above.
For routes that only need a BuildContext, use navigation.ScreenOnly to avoid
writing a wrapper closure:
navigation.ScreenRoute{
Path: "/settings",
Screen: navigation.ScreenOnly(buildSettings),
}
func buildSettings(ctx core.BuildContext) core.Widget {
return SettingsPage{}
}
RouterState
Access the router for path-based navigation:
func handleTap(ctx core.BuildContext) {
router := navigation.RouterOf(ctx)
if router == nil {
return
}
// Navigate to a path
router.Go("/products/123", nil)
// Replace current route
router.Replace("/settings", nil)
}
Layout Wrapping
Wrap child routes in a persistent layout (navigation bars, sidebars, etc.)
using the Wrap field:
navigation.Router{
Routes: []navigation.ScreenRoute{
{
Wrap: func(ctx core.BuildContext, child core.Widget) core.Widget {
return widgets.Column{
Children: []core.Widget{
MyNavigationBar{},
widgets.Expanded{Child: child},
},
}
},
Children: []navigation.ScreenRoute{
{Path: "/home", Screen: buildHome},
{Path: "/profile", Screen: buildProfile},
},
},
// Routes outside the wrapper
{Path: "/login", Screen: buildLogin},
},
}
Deep Linking
Handle URLs from outside your app using DeepLinkController.
Setup
type appState struct {
core.StateBase
deepLinkController *navigation.DeepLinkController
}
func (s *appState) InitState() {
// Create controller with a route mapper function
s.deepLinkController = navigation.NewDeepLinkController(
// Route mapper: converts deep links to navigation routes
func(link platform.DeepLink) (navigation.DeepLinkRoute, bool) {
switch {
case strings.HasPrefix(link.Path, "/product/"):
id := strings.TrimPrefix(link.Path, "/product/")
return navigation.DeepLinkRoute{
Name: "/product",
Args: map[string]any{"id": id},
}, true
case strings.HasPrefix(link.Path, "/user/"):
username := strings.TrimPrefix(link.Path, "/user/")
return navigation.DeepLinkRoute{
Name: "/profile",
Args: map[string]any{"username": username},
}, true
default:
return navigation.DeepLinkRoute{}, false
}
},
// Error handler
func(err error) {
log.Printf("Deep link error: %v", err)
},
)
// Cleanup when done
s.OnDispose(func() {
s.deepLinkController.Stop()
})
}
The controller automatically:
- Listens for incoming deep links from the platform
- Handles the initial deep link if the app was launched via URL
- Navigates to matching routes using
RootNavigator()
Important: Deep links require a root navigator. If your app uses TabNavigator at the top level, wrap it in a Router:
navigation.Router{
InitialPath: "/",
Routes: []navigation.ScreenRoute{
{Path: "/", Screen: buildTabNavigator},
// Deep link routes
{Path: "/product/:id", Screen: buildProduct},
{Path: "/profile/:username", Screen: buildProfile},
},
}
Tab Navigation
Use TabNavigator for bottom tab navigation with separate navigation stacks per tab:
func App() core.Widget {
return navigation.TabNavigator{
Tabs: []navigation.Tab{
navigation.NewTab(
widgets.TabItem{Label: "Home", Icon: widgets.Icon{Glyph: "🏠", Size: 24}},
buildHomeScreen,
),
navigation.NewTab(
widgets.TabItem{Label: "Search", Icon: widgets.Icon{Glyph: "🔍", Size: 24}},
buildSearchScreen,
),
navigation.NewTab(
widgets.TabItem{Label: "Profile", Icon: widgets.Icon{Glyph: "👤", Size: 24}},
buildProfileScreen,
),
},
}
}
Each screen is a regular widget (the buildProfileScreen follows the same pattern). For a stateless screen, embed StatelessBase and implement Build:
func buildSearchScreen(ctx core.BuildContext) core.Widget {
return searchScreen{}
}
type searchScreen struct {
core.StatelessBase
}
func (s searchScreen) Build(ctx core.BuildContext) core.Widget {
return widgets.Text{Content: "Search"}
}
For a stateful screen, embed StatefulBase in the widget and StateBase in a separate state struct (see Widgets guide for details):
func buildHomeScreen(ctx core.BuildContext) core.Widget {
return homeScreen{}
}
type homeScreen struct {
core.StatefulBase
}
func (homeScreen) CreateState() core.State {
return &homeScreenState{}
}
type homeScreenState struct {
core.StateBase
items []string
}
func (s *homeScreenState) InitState() {
for i := range 30 {
s.items = append(s.items, fmt.Sprintf("Item %d", i))
}
}
func (s *homeScreenState) Build(ctx core.BuildContext) core.Widget {
children := make([]core.Widget, len(s.items))
for i, item := range s.items {
children[i] = widgets.Text{Content: item}
}
return widgets.ListView{Children: children}
}
Active Navigator Tracking
TabNavigator automatically manages which tab's navigator is "active" for back button handling:
- Each tab has its own navigation stack
- When switching tabs, the new tab's navigator becomes active
- Back button pops from the active tab's stack
- Inactive tabs are excluded from the accessibility tree
Tab Controller
Control the selected tab programmatically:
type appState struct {
core.StateBase
tabController *navigation.TabController
}
func (s *appState) InitState() {
s.tabController = navigation.NewTabController(0) // Start on first tab
}
func (s *appState) Build(ctx core.BuildContext) core.Widget {
return navigation.TabNavigator{
Controller: s.tabController,
Tabs: []navigation.Tab{
// ... tabs
},
}
}
// Switch tabs programmatically
func (s *appState) goToProfile() {
s.tabController.SetIndex(2)
}
Tabs with Navigation
Each tab can have its own navigation stack:
navigation.Tab{
Item: widgets.TabItem{Label: "Home", Icon: homeIcon},
InitialRoute: "/",
OnGenerateRoute: func(settings navigation.RouteSettings) navigation.Route {
switch settings.Name {
case "/":
return navigation.NewAnimatedPageRoute(buildHome, settings)
case "/details":
return navigation.NewAnimatedPageRoute(buildDetails, settings)
}
return nil
},
}
Platform Back Button
The Navigator automatically handles the platform back button. Use navigation.HandleBackButton() for standard back button handling:
// In your platform-specific code, call HandleBackButton
// Returns true if a route was popped, false if at root
handled := navigation.HandleBackButton()
if !handled {
// At root - maybe show exit confirmation or exit app
}
Navigation from Outside the Widget Tree
For deep links and external navigation, use RootNavigator():
// Deep link handler
if nav := navigation.RootNavigator(); nav != nil {
nav.PushNamed("/product", map[string]any{"id": productID})
}
For back button handling, use HandleBackButton() which automatically handles active tab navigation:
handled := navigation.HandleBackButton()
Nested Navigators
Use multiple navigators for complex flows. Only the root navigator should have IsRoot: true:
// Main app navigator (root)
navigation.Navigator{
InitialRoute: "/",
IsRoot: true, // Only the root navigator sets this
OnGenerateRoute: func(settings navigation.RouteSettings) navigation.Route {
switch settings.Name {
case "/":
return navigation.NewAnimatedPageRoute(buildMainTabs, settings)
case "/onboarding":
// Onboarding has its own nested navigator
return navigation.NewAnimatedPageRoute(buildOnboarding, settings)
}
return nil
},
}
// Onboarding flow with its own navigator (nested, not root)
func buildOnboarding(ctx core.BuildContext) core.Widget {
return navigation.Navigator{
InitialRoute: "/welcome",
// IsRoot: false (default) - nested navigators don't register globally
OnGenerateRoute: func(settings navigation.RouteSettings) navigation.Route {
switch settings.Name {
case "/welcome":
return navigation.NewAnimatedPageRoute(buildWelcome, settings)
case "/setup":
return navigation.NewAnimatedPageRoute(buildSetup, settings)
case "/complete":
return navigation.NewAnimatedPageRoute(buildComplete, settings)
}
return nil
},
}
}
Back Button with Nested Navigators
For nested navigators outside TabNavigator, back button handling uses the root navigator by default. The nested navigator handles its own internal navigation via NavigatorOf(ctx):
func buildOnboardingStep(ctx core.BuildContext) core.Widget {
return widgets.Column{
Children: []core.Widget{
widgets.Text{Content: "Step 1"},
theme.ButtonOf(ctx, "Next", func() {
// Use NavigatorOf for internal navigation within the nested navigator
navigation.NavigatorOf(ctx).PushNamed("/setup", nil)
}),
theme.ButtonOf(ctx, "Skip", func() {
// Pop the entire onboarding flow from the root navigator
navigation.RootNavigator().Pop(nil)
}),
},
}
}
Next Steps
- Gestures - Handle touch input
- Accessibility - Make your app accessible
- API Reference - Navigation API documentation