Navigation¶
PythonNative navigation follows React Navigation's shape, so the mental model carries over directly:
- You describe screens once as a
Stack.Navigator(orTab/Drawer) tree inside aNavigationContainer. - Screens call
use_navigationfor an imperativeNavigationhandle anduse_routefor their params. - Navigation state is a plain, serializable value
(
NavigationState), so it can be persisted, restored, and deep-linked.
The one thing React Navigation can't do: at the root of the app, the
stack is native-backed. Pushing a screen pushes a real
UIViewController on iOS or a Fragment on Android, so you get the
platform's transitions, swipe-back, and state preservation for free.
Nested navigators (tabs inside a stack, stacks inside tabs) are drawn
in Python and keep their screens mounted between switches.
A complete example¶
Save a module at app/main.py that defines an App component:
import pythonnative as pn
Stack = pn.create_stack_navigator()
@pn.component
def HomeScreen():
nav = pn.use_navigation()
return pn.Column(
pn.Text("Home", style={"font_size": 24}),
pn.Button("Open item 42", on_press=lambda: nav.navigate("Detail", id=42)),
style={"spacing": 12, "padding": 16},
)
@pn.component
def DetailScreen():
nav = pn.use_navigation()
route = pn.use_route()
return pn.Column(
pn.Text(f"Detail #{route.params['id']}", style={"font_size": 20}),
pn.Button("Back", on_press=nav.go_back),
style={"spacing": 12, "padding": 16},
)
@pn.component
def App():
return pn.NavigationContainer(
Stack.Navigator(
Stack.Screen("Home", HomeScreen, title="Home"),
Stack.Screen(
"Detail",
DetailScreen,
options=lambda route: {"title": f"Item {route.params['id']}"},
),
)
)
The native templates (Android ScreenFragment, iOS ViewController)
import app.main and look up its top-level App, so no other wiring
is required. title propagates to the native navigation bar.
Screen(name, component, ...) accepts every key of
ScreenOptions as a keyword, an
options= dict, or an options=lambda route: {...} callable for
options that depend on params. Keywords merge on top of options.
Navigators¶
Stack¶
A stack keeps a history. navigate goes to a screen (switching back to
it if it's already in the history), push always adds a new instance,
and pop / go_back return.
Stack = pn.create_stack_navigator()
Stack.Navigator(
Stack.Screen("Home", HomeScreen, title="Home"),
Stack.Screen("Detail", DetailScreen, initial_params={"id": 0}),
Stack.Screen("Settings", SettingsScreen, presentation="modal"),
initial_route="Home",
)
At the root of a native host the stack pushes native screens; nested
stacks are drawn in Python with a header (title, back button, and the
header_left / header_right slots). Screens beneath the top stay
mounted with their state, so popping back restores scroll position and
inputs.
Tabs¶
Tabs render a native tab bar (UITabBar on iOS, Material
BottomNavigationView on Android). Visited tabs stay mounted and
hidden, so switching back is instant and keeps state.
Tab = pn.create_tab_navigator()
Tab.Navigator(
Tab.Screen("Home", HomeScreen, title="Home", tab_bar_icon="house"),
Tab.Screen("Inbox", InboxScreen, tab_bar_label="Inbox", tab_bar_badge=3),
Tab.Screen("Settings", SettingsScreen, lazy=False),
Tab.Screen("Camera", CameraScreen, unmount_on_blur=True),
)
tab_bar_icontakes a Lucide icon name (the same names asIcon) or a bundled image viapn.asset(...). Both render identically on iOS and Android and tint with the tab bar.lazy(defaultTrue) mounts a tab the first time it's focused.lazy=Falsemounts it with the navigator.unmount_on_blur=Truetears a tab down when it loses focus, for screens that hold expensive resources.
Inside a tab screen, use_navigation() returns a
TabNavigation with
jump_to(name, **params).
Drawer¶
A drawer is like tabs with a slide-in menu instead of a tab bar.
Drawer = pn.create_drawer_navigator()
Drawer.Navigator(
Drawer.Screen("Feed", FeedScreen, title="My Feed"),
Drawer.Screen("Profile", ProfileScreen, title="Profile"),
drawer_width=280,
)
@pn.component
def FeedScreen():
nav = pn.use_navigation() # a DrawerNavigation
return pn.Column(
pn.Button("Menu", on_press=nav.open_drawer),
pn.Text("Feed"),
)
DrawerNavigation adds
open_drawer(), close_drawer(), toggle_drawer(), and
is_drawer_open(). The system back action closes an open drawer
before it does anything else.
The Navigation handle¶
use_navigation returns the
Navigation handle for the current
screen. Route names come first; params are keyword arguments.
nav.navigate("Detail", id=42) # go to Detail (back to it if it's in the history)
nav.push("Detail", id=43) # always push a new Detail
nav.replace("Login") # swap the current screen
nav.pop() # back one screen (also nav.go_back())
nav.pop(2) # back two
nav.pop_to_top() # back to the first screen
nav.reset("Home") # replace the whole history
nav.reset(pn.Route("Home"), pn.Route("Detail", {"id": 1}))
nav.set_params(id=44) # merge params into this screen's route
nav.set_options(title="Edited") # change ScreenOptions at runtime
Introspection: nav.route, nav.get_params(), nav.get_options(),
nav.get_state(), nav.get_parent(), nav.can_go_back(),
nav.is_focused(), and nav.kind ("stack", "tab", or
"drawer").
Listeners¶
@pn.component
def EditScreen():
nav = pn.use_navigation()
dirty, set_dirty = pn.use_state(False)
def guard():
def on_before_remove(event):
if dirty:
event.prevent_default()
pn.Alert.show("Discard changes?", "You have unsaved edits.")
return nav.add_listener("before_remove", on_before_remove)
pn.use_effect(guard, [dirty])
...
Events are "focus", "blur", "before_remove" (call
event.prevent_default() to keep the screen), and "state" (fired
with the navigator's new state). add_listener returns an unsubscribe
callable, so it slots straight into use_effect.
Native back gestures on iOS can't be intercepted by before_remove;
set gesture_enabled=False on screens that need a guard.
Route params¶
use_route returns the current
Route: route.name,
route.params, and a stable route.key for this visit.
@pn.component
def DetailScreen():
route = pn.use_route()
return pn.Text(f"Item #{route.params.get('id', 0)}")
initial_params on Screen(...) fill in defaults; navigate /
push params merge on top.
Typed params¶
Declare a screen's params as a TypedDict and pass it to use_route.
route.params is then typed for your editor and type checker, and the
hook verifies at the screen's first render that every required key is
present, naming the missing ones instead of failing later with a
KeyError:
from typing import NotRequired, TypedDict
class DetailParams(TypedDict):
id: int
title: NotRequired[str]
@pn.component
def DetailScreen():
route = pn.use_route(DetailParams)
return pn.Text(f"Item #{route.params['id']}") # id: int
Params travel over the bridge as JSON, so keep them to JSON-friendly values (strings, numbers, booleans, lists, and dicts).
Focus¶
use_is_focused is True only for the
visible screen: inactive tabs, screens beneath the top of a stack, and
screens covered by a pushed native screen all read False.
use_focus_effect runs an effect
while focused and runs its cleanup on blur:
@pn.component
def Feed():
def start_polling():
timer = schedule_refresh()
return timer.cancel
pn.use_focus_effect(start_polling, [])
...
Nesting¶
Navigators nest freely. A request the current navigator can't satisfy
bubbles to its parent: navigate("Settings") from deep inside one tab
switches tabs, and go_back() at the bottom of a nested stack pops the
outer one.
Root = pn.create_stack_navigator()
Tabs = pn.create_tab_navigator()
@pn.component
def MainTabs():
return Tabs.Navigator(
Tabs.Screen("Home", HomeScreen, title="Home"),
Tabs.Screen("Profile", ProfileScreen, title="Profile"),
)
@pn.component
def App():
return pn.NavigationContainer(
Root.Navigator(
Root.Screen("Tabs", MainTabs, header_shown=False),
Root.Screen("Detail", DetailScreen),
)
)
To land on a specific screen inside a nested navigator, pass
screen=; the remaining params go to that screen:
State, persistence, and deep links¶
The container reports every state change and accepts an initial state, so persisting navigation is a few lines:
@pn.component
def App():
saved, set_saved = pn.use_persisted_state("nav", None)
return pn.NavigationContainer(
Root.Navigator(...),
initial_state=saved,
on_state_change=lambda state: set_saved(state.to_dict()),
)
initial_state accepts a NavigationState or its to_dict() form.
State restored by the native host (a pushed native screen re-entering
Python) takes precedence, then initial_state, then the launch URL.
Deep links map URLs to states with
LinkingConfig:
linking = pn.LinkingConfig(
prefixes=["myapp://", "https://example.com"],
screens={
"Tabs": {
"path": "",
"screens": {"Home": "home", "Profile": "u/:user"},
},
"Detail": {"path": "item/:id", "parse": {"id": int}},
},
)
pn.NavigationContainer(Root.Navigator(...), linking=linking)
myapp://item/42?ref=mail opens Detail with
{"id": 42, "ref": "mail"}; https://example.com/u/ada opens the
Profile tab. The URL that launched the app seeds the initial state,
and URLs that arrive while running dispatch as navigate calls.
linking.url_from_state(state) goes the other way, for sharing.
Native screens¶
A root stack renders keyed logical Screen children within one application
reconciler. UIKit navigation controllers and Android fragments present those
existing roots. Pushing a screen preserves providers, repositories, and state
above the navigator. Covered screens remain mounted until they leave the stack.
Native containers own the content rectangle below their navigation bars.
Navigation changes are cached on the native host before lifecycle state-saving
callbacks. Back requests return to Python asynchronously so before_remove
listeners and back handlers can decide whether to remove a route.
Header factories (header_left and header_right) render ordinary Python
components within their route's providers and navigation context. Their native
views are installed in UIKit navigation items or the Android toolbar.
On iOS, presentation="modal" starts a native sheet containing its own stack;
subsequent cards push within that sheet. Android presents these routes through
its full-screen fragment stack. gesture_enabled=False prevents interactive
iOS dismissal, and rejected native back requests restore the existing route.
Testing¶
Use render, press, and back to exercise complete navigation flows in one
tree. FakeHost supplies lifecycle focus and restoration state when needed.
from pythonnative.testing import render
def test_home_opens_detail():
result = render(App())
result.press(result.get_by_text("Open item 42"))
assert result.get_by_text("Item 42")
assert result.back()
assert result.get_by_text("Open item 42")
Next steps¶
- See worked navigation examples: Examples/Navigation.
- Browse the API: Navigation.
- Learn how focus interacts with effects: Lifecycle.