FazBrowse GitHub Viewer | Trending |
URL:
| Home
Tools: [Download Repo ZIP]   [View Raw Code]   [Original HTTPS Page]

pythonnative/docs/guides/navigation.md at main · pythonnative/pythonnative · GitHub

Latest commit

 

History

History
651 lines (510 loc) · 24.7 KB

File metadata and controls

651 lines (510 loc) · 24.7 KB

Navigation

PythonNative navigation keeps React Navigation's model (stacks, tabs, drawers, a container at the root, and a handle for moving between screens) and expresses it in ordinary, typed Python:

  • Screens are components. A screen's parameters are its route params, so nav.push(ItemScreen(id=42)) is checked by your type checker exactly like any other call.
  • Navigators are values. You define them once at module level with [StackNavigator][pythonnative.StackNavigator], [TabNavigator][pythonnative.TabNavigator], or [DrawerNavigator][pythonnative.DrawerNavigator], and render the root one with [NavigationContainer][pythonnative.NavigationContainer].
  • Nesting is static. A navigator can be a screen of another navigator, so deep links and navigation calls reach nested screens without extra arguments.
  • State is a value. Navigation state is a plain, serializable [NavigationState][pythonnative.navigation.NavigationState], so it can be persisted, restored, and derived from a URL.

On a device every stack is native-backed. Pushing a screen pushes a real UIViewController on iOS or a Fragment on Android, at every nesting level, so you get the platform's transitions, swipe-back, and state preservation for free. Tab bars are native too. The drawer is drawn in Python on every platform. Without a native host (headless tests) the stack draws a themed header itself.

A complete example

Save a module at app/main.py that defines an App component:

import pythonnative as pn


@pn.component
def HomeScreen() -> pn.Node:
    nav = pn.use_navigation()
    return pn.Column(
        pn.Text("Home", style={"font_size": 24}),
        pn.Button("Open item 42", on_press=lambda: nav.push(ItemScreen(id=42))),
        style={"gap": 12, "padding": 16},
    )


@pn.component
def ItemScreen(id: int, tab: str = "details") -> pn.Node:
    nav = pn.use_navigation()
    pn.use_screen_options(title=f"Item {id}")
    return pn.Column(
        pn.Text(f"Item {id} ({tab})", style={"font_size": 20}),
        pn.Button("Back", on_press=nav.go_back),
        style={"gap": 12, "padding": 16},
    )


Root = pn.StackNavigator(
    pn.Screen(HomeScreen, title="Home"),
    pn.Screen(ItemScreen, path="items/{id}"),
)


@pn.component
def App() -> pn.Node:
    return pn.NavigationContainer(Root, link_prefixes=["myapp://"])

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, and myapp://items/42?tab=reviews opens ItemScreen(id=42, tab="reviews").

Screens and params

A screen is any @pn.component. Its parameters are the route's params, and parameter defaults are the defaults for params the caller leaves out:

@pn.component
def ItemScreen(id: int, tab: str = "details") -> pn.Node: ...


nav.push(ItemScreen(id=42))                 # tab == "details"
nav.push(ItemScreen(id=42, tab="reviews"))
nav.push(ItemScreen(idd=42))                # a type error, and a TypeError at run time

Calling a screen component doesn't render it. It builds an [Element][pythonnative.Element] that carries the params, and the navigator renders the screen as ItemScreen(**route.params). Because the call goes through the component's own signature, a missing or misspelled param fails at the call site. In development builds a param whose value doesn't match its annotation also produces a [diagnostics][pythonnative.diagnostics] warning.

A route stores only the params the caller passed; the screen gets its parameter defaults when it renders. So navigating back to an existing route with ItemScreen(id=6) merges id and keeps any other params it already had.

Params travel over the bridge and into saved state as JSON, so keep them to JSON-friendly values: strings, numbers, booleans, None, lists, and dicts. Pass an id and look the record up in the screen, rather than passing the record itself.

[use_route][pythonnative.use_route] returns the screen's [Route][pythonnative.navigation.Route] for the rare code that needs its identity: route.name, the raw route.params dict, and route.key, which is unique for each visit. Outside a navigator it returns a placeholder route named "__root__", so screens also render standalone in previews and tests.

Navigators

A navigator lists its screens. Each entry is a [Screen][pythonnative.Screen], a [Group][pythonnative.Group], or a bare component or navigator, which is shorthand for pn.Screen(x):

Root = pn.StackNavigator(
    HomeScreen,                                   # same as pn.Screen(HomeScreen)
    pn.Screen(ItemScreen, title="Item", path="items/{id}"),
    pn.Group(pn.Screen(ComposeScreen), pn.Screen(FiltersScreen), presentation="modal"),
    screen_options=pn.ScreenOptions(header_large_title=True),
    initial=HomeScreen,
)
  • The route name defaults to the component's name ("ItemScreen") or, for a nested navigator, its name=. Names appear in serialized state and diagnostics; your code never passes them around. Two screens with the same name raise ValueError; give one of them pn.Screen(Component, name="Other").
  • initial selects the first screen. It takes a component, a navigator, or an element carrying params (initial=ItemScreen(id=1)), and defaults to the first entry. Parameter defaults replace React Navigation's initialParams.
  • screen_options applies [ScreenOptions][pythonnative.ScreenOptions] to every screen. pn.ScreenOptions(...) is a TypedDict, so its keys are checked statically.

Navigators are immutable. Define them at module level, after the screens they list. Calling a navigator (Root()) returns the element that renders it, which is how the container and nested navigators use it.

Stack

A stack keeps a history. navigate goes to a screen (returning to it if it's already in the history), push always adds a new instance, and pop and go_back return.

On a device the stack pushes native screens whether it sits at the root or inside a tab. The native container draws the header: the title, the back button, and the header_left and 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.

Tabs = pn.TabNavigator(
    pn.Screen(HomeScreen, title="Home", tab_bar_icon="house"),
    pn.Screen(InboxScreen, tab_bar_label="Inbox", tab_bar_badge=3),
    pn.Screen(SettingsScreen, lazy=False),
    pn.Screen(CameraScreen, unmount_on_blur=True),
    pn.Screen(FeedScreen, freeze_on_blur=True),
    tab_bar_style=pn.TabBarStyle(active_tint_color="#FF2D55", show_labels=False),
)
  • Every tab is created when the navigator mounts, so every tab except the initial one must be callable without arguments. Give tab parameters defaults; the navigator raises TypeError when it's built otherwise.
  • tab_bar_icon takes a Lucide icon name (the same names as [Icon][pythonnative.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.
  • freeze_on_blur=True holds back new params for an unfocused tab until it regains focus. Navigators already skip an unfocused tab whose params didn't change, and a state change inside the tab still re-renders it.
  • tab_bar_visible=False hides the tab bar while that tab is focused. Set it on a tab whose nested stack pushes detail screens, or toggle it at run time with nav.get_parent().set_options(tab_bar_visible=False).
  • tab_bar_style is a [TabBarStyle][pythonnative.TabBarStyle] with background_color, active_tint_color, inactive_tint_color, translucent (iOS only), and show_labels. Unset keys fall back to the theme.

Inside a tab screen, use_navigation() returns a [TabNavigation][pythonnative.navigation.TabNavigation], which adds jump_to(target). Most screens don't need it: nav.navigate(...) switches tabs too.

Drawer

A drawer is like tabs with a slide-in menu instead of a tab bar. Like tabs, drawer screens other than initial must be callable without arguments.

from pythonnative.navigation import DrawerNavigation


@pn.component
def FeedScreen() -> pn.Node:
    nav = pn.use_navigation()
    assert isinstance(nav, DrawerNavigation)
    return pn.Column(
        pn.Button("Menu", on_press=nav.open_drawer),
        pn.Text("Feed"),
    )


Drawer = pn.DrawerNavigator(
    pn.Screen(FeedScreen, title="My feed"),
    pn.Screen(ProfileScreen, title="Profile"),
    drawer_width=280,
)

[DrawerNavigation][pythonnative.navigation.DrawerNavigation] adds open_drawer(), close_drawer(), toggle_drawer(), is_drawer_open(), and jump_to(target), which also closes the menu. use_navigation() is typed as the base [Navigation][pythonnative.Navigation], so narrow it with isinstance before calling the drawer methods. The system back action closes an open drawer before it does anything else.

Screen options

There's one way to set options statically and one way to set them from a screen's render.

Statically, pass [ScreenOptions][pythonnative.ScreenOptions] keywords to pn.Screen(...), to pn.Group(...), or as the navigator's screen_options=. A Group layers options over a subset of screens without changing the route list; it has no state of its own. Every navigator (stack, tab, and drawer) accepts all three.

Dynamically, call [use_screen_options][pythonnative.use_screen_options] from the screen's render. Options computed there follow the screen's props and state, so a dynamic title or a header button lives next to the code it depends on:

@pn.component
def ComposeScreen(draft_id: int | None = None) -> pn.Node:
    nav = pn.use_navigation()
    text, set_text = pn.use_state("")
    pn.use_screen_options(
        title="New message" if draft_id is None else "Edit draft",
        header_right=pn.Button("Send", on_press=nav.go_back, disabled=not text),
    )
    return pn.TextInput(value=text, on_change=set_text, placeholder="Message")

use_screen_options runs as a layout effect and updates the navigator only when the options actually change. The navigator reuses the screen's element when it re-renders for an option change, so setting options doesn't re-render the screen and can't loop. header_left and header_right take an element. To change another screen's options, call set_options(...) on its handle, for example nav.get_parent().set_options(tab_bar_visible=False).

Options resolve in this order, later entries winning: the navigator's screen_options, each enclosing Group, the Screen itself, then options set at run time. An unknown option key raises TypeError.

The Navigation handle

[use_navigation][pythonnative.use_navigation] returns the [Navigation][pythonnative.Navigation] handle for the current screen. Destinations are screen elements, built by calling the screen component with its params:

nav.navigate(ItemScreen(id=42))          # go to it (back to it if it's in the history)
nav.push(ItemScreen(id=43))              # always push a new instance
nav.replace(LoginScreen())               # swap the current screen
nav.pop()                                # back one screen (also nav.go_back())
nav.pop(2)                               # back two
nav.pop_to(HomeScreen)                   # back to the most recent HomeScreen
nav.pop_to(ItemScreen(id=1))             # ... merging new params into it
nav.pop_to_top()                         # back to the first screen
nav.reset(HomeScreen(), ItemScreen(id=1))  # replace the whole history
nav.set_params(id=44)                    # re-render this screen with new arguments
nav.set_options(title="Edited")          # change ScreenOptions at run time

pop_to and jump_to also accept a bare component or navigator when there are no params to merge. pop_to on a screen that isn't in the history replaces the active screen, as React Navigation does; on tab and drawer navigators it falls back to navigate. set_params checks the merged params against the screen's signature, so a misspelled name raises TypeError. get_state() is truthful right after an action, because the navigator commits its state eagerly.

Introspection: nav.route, nav.get_options(), nav.get_state(), nav.get_parent(), nav.can_go_back(), nav.is_focused(), and nav.kind ("stack", "tab", or "drawer").

How targets are found

A target is matched by component identity. The handle searches its own navigator first, then the navigators statically nested in it, then each enclosing navigator in turn. A screen can therefore navigate anywhere in the tree without knowing its shape: pushing a detail screen from inside a tab reaches the root stack, and navigating to a screen inside a nested navigator opens that navigator on the screen. A target that no navigator in the tree renders raises ValueError.

Navigating from outside the tree

Push-notification handlers, deep-link code, and services have no component in scope. Create a [NavigationRef][pythonnative.NavigationRef] at module level and bind it with NavigationContainer(ref=...):

nav_ref = pn.NavigationRef()


@pn.component
def App() -> pn.Node:
    return pn.NavigationContainer(Root, ref=nav_ref)


def on_push_notification(payload: dict[str, str]) -> None:
    if nav_ref.is_ready():
        nav_ref.navigate(ThreadScreen(id=payload["thread"]))

The ref proxies navigate, push, replace, pop, go_back, pop_to, pop_to_top, reset, and get_state to the root navigator, and exposes the root [Navigation][pythonnative.Navigation] handle as nav_ref.current. Because the root navigator reaches every statically nested screen, the ref can open any screen in the app. Every method raises RuntimeError while no container is mounted, so check is_ready() from code that may run before the UI is up.

Listeners

from collections.abc import Callable

from pythonnative.navigation import NavigationEvent


@pn.component
def EditScreen() -> pn.Node:
    nav = pn.use_navigation()
    dirty, set_dirty = pn.use_state(False)

    def guard() -> Callable[[], None]:
        def on_before_remove(event: NavigationEvent) -> None:
            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, [nav, dirty])
    return pn.TextInput(on_change=lambda _: set_dirty(True))

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.

before_remove fires for every removal of the route: pop, pop_to, navigate back to an existing route, replace, reset, the Android back action, and the iOS swipe-back or sheet pull-down; event.data["action"] names the cause. While a route has a before_remove listener the stack marks its native screen as guarded, so UIKit refuses the pop synchronously and asks Python first. A vetoed back therefore doesn't animate the screen out and back in, and if the race is lost the stack restores the native screens to match Python's state. gesture_enabled=False (iOS only) disables the swipe gesture outright instead.

Focus

[use_is_focused][pythonnative.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][pythonnative.use_focus_effect] runs an effect while focused and runs its cleanup on blur:

from collections.abc import Callable


@pn.component
def FeedScreen() -> pn.Node:
    def start_polling() -> Callable[[], None]:
        timer = schedule_refresh()
        return timer.cancel

    pn.use_focus_effect(start_polling, [])
    return FeedList()

Nesting

A navigator is a valid screen, so nesting is part of the static definition:

Tabs = pn.TabNavigator(
    pn.Screen(FeedScreen, title="Feed", tab_bar_icon="house"),
    pn.Screen(ProfileScreen, title="Me", tab_bar_icon="user"),
    name="Main",
)

Root = pn.StackNavigator(
    pn.Screen(Tabs, header_shown=False),
    pn.Screen(ItemScreen),
)

A nested navigator needs a route name: pass name= to the navigator or pn.Screen(Tabs, name="Main"). From any screen:

  • nav.push(ItemScreen(id=7)) from inside the feed tab pushes onto the root stack, above the tab bar.
  • nav.navigate(ProfileScreen(user="ada")) from ItemScreen returns to the tabs and selects the profile tab with user="ada". No screen= argument is needed: the root stack finds ProfileScreen inside Tabs and seeds the nested state.
  • go_back() at the bottom of a nested stack pops the outer one.

Because nesting is static, a navigator rendered from inside a component body (instead of listed as a screen) is invisible to target resolution and deep links. List navigators as screens.

State and persistence

The container reports every state change and accepts an initial state. To restore navigation across launches, load the saved state before the container mounts, because initial_state is read once:

from typing import Any

import pythonnative as pn


@pn.component
async def RestoredNavigation() -> pn.Node:
    saved: dict[str, Any] | None = await pn.use_resource(lambda: pn.AsyncStorage.get_json("nav"), [])

    def save(state: pn.NavigationState) -> None:
        pn.run_async(pn.AsyncStorage.set_json("nav", state.to_dict()))

    return pn.NavigationContainer(Root, initial_state=saved, on_state_change=save)


@pn.component
def App() -> pn.Node:
    return pn.Suspense(RestoredNavigation(), fallback=pn.ActivityIndicator())

initial_state accepts a NavigationState or its to_dict() form. A saved state that no longer matches the navigators (a screen was renamed or gained a required parameter) is ignored. State restored by the native host (a pushed native screen re-entering Python) takes precedence, then initial_state, then the launch URL.

Deep links

A screen's path is its deep link. Paths use Python format-string placeholders, and a nested navigator's path prefixes the paths of its screens. Pass the URL prefixes the app answers to as NavigationContainer(link_prefixes=...):

import enum


class Tab(enum.Enum):
    DETAILS = "details"
    REVIEWS = "reviews"


@pn.component
def ItemScreen(id: int, tab: Tab = Tab.DETAILS, preview: bool = False) -> pn.Node: ...


Tabs = pn.TabNavigator(
    pn.Screen(FeedScreen, path="feed"),
    pn.Screen(ProfileScreen, path="u/{user}"),
    name="Main",
)

Root = pn.StackNavigator(
    Tabs,
    pn.Screen(ItemScreen, path="items/{id}"),
)


@pn.component
def App() -> pn.Node:
    return pn.NavigationContainer(Root, link_prefixes=["myapp://", "https://example.com"])
  • myapp://items/42?tab=reviews opens ItemScreen(id=42, tab=Tab.REVIEWS), and https://example.com/u/ada opens the profile tab with user="ada".
  • Path placeholders and query parameters become the screen component's arguments, converted with its annotations: str, int, float, bool (1, true, yes, on, and their negatives), Enum subclasses (by value, then by name), Literal values, and Optional of any of those.
  • Query parameters the component doesn't accept are ignored. A URL whose values don't convert, or that lacks a required parameter, matches nothing instead of passing a string through.
  • A nested navigator without a path adds no prefix, so Tabs above contributes feed and u/{user} at the top level.
  • A link into a stack keeps the stack's initial screen beneath the target, so back returns there.
  • The URL that launched the app seeds the initial state, and URLs that arrive while the app is running navigate.

There's no separate route tree to keep in sync: the container derives its pattern table from the navigators once. To build a URL for sharing, or to test your paths, use [LinkTable][pythonnative.navigation.LinkTable] directly:

from pythonnative.navigation import LinkTable

links = LinkTable(Root, ["myapp://"])
state = links.state_from_url("myapp://items/42")
assert state is not None and links.url_from_state(state) == "myapp://items/42"

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, stores, 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.

The header_left and header_right elements render within their route's providers and navigation context. Their native views are installed in UIKit navigation items or the Android toolbar.

Presentation and transitions

presentation is "card" (default, a push) or one of the modal styles: "modal", "full_screen_modal", "form_sheet", or "transparent_modal". On iOS these map to pageSheet, fullScreen, formSheet, and overFullScreen; a sheet contains its own stack, so cards pushed from a modal screen push within the sheet. Android presents every modal style as a full-screen screen with a slide-from-bottom transition, which is what React Navigation's native stack does there.

animation picks the push transition on both platforms: "default", "none", "fade", "slide_from_right", or "slide_from_bottom". Android pushes and pops slide by default, draw a back arrow in an inset-aware toolbar, and render header_large_title as an expanded toolbar with a larger title (iOS uses the system large title).

Two options are iOS-only and ignored on Android: header_back_title (the label of the back button on the next screen; Android shows a bare arrow) and gesture_enabled (whether the interactive swipe-back can pop the screen; Android's system back is always available). Predictive back on Android is enabled at the activity level: the system back preview appears once Python reports that the stack can't pop; screens themselves don't animate with the gesture.

Theming

Navigators draw their chrome from the app's [Theme][pythonnative.Theme], the same one your components read with [use_theme][pythonnative.use_theme]. There's no separate navigation theme:

Token Used for
colors.primary Back button, header buttons, the selected tab, and the active drawer row
colors.surface Header, tab bar, and drawer panel backgrounds
colors.background Screen backgrounds
colors.text Header titles and drawer labels
colors.border Hairlines under the header and above the tab bar
colors.error Tab badges

Without a provider, navigators follow the system appearance with [LIGHT_THEME][pythonnative.LIGHT_THEME] and [DARK_THEME][pythonnative.DARK_THEME]. To brand them, wrap the container in a [ThemeProvider][pythonnative.ThemeProvider]:

from dataclasses import replace

LIGHT = replace(pn.LIGHT_THEME, colors=replace(pn.LIGHT_THEME.colors, primary="#FF2D55"))
DARK = replace(pn.DARK_THEME, colors=replace(pn.DARK_THEME.colors, primary="#FF375F"))


@pn.component
def App() -> pn.Node:
    return pn.ThemeProvider(pn.NavigationContainer(Root), light=LIGHT, dark=DARK)

Explicit header_tint_color, header_style, header_title_style, and tab_bar_style keys win over the theme. See the Styling guide for defining themes and adding tokens.

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_item() -> None:
    result = render(App())
    result.press(result.get_by_text("Open item 42"))
    assert result.get_by_text("Item 42 (details)")
    assert result.back()
    assert result.get_by_text("Open item 42")

Next steps


Back | FazBrowse Home | New Git URL