Skip to content

Navigation

PythonNative navigation follows React Navigation's shape, so the mental model carries over directly:

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.

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_icon takes a Lucide icon name (the same names as Icon) or a bundled image via pn.asset(...). Both render identically on iOS and Android and tint with the tab bar.
  • lazy (default True) mounts a tab the first time it's focused. lazy=False mounts it with the navigator.
  • unmount_on_blur=True tears 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:

nav.navigate("Tabs", screen="Profile", user="ada")

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