Skip to content

Commit 2b80032

Browse files
committed
feat(navigation): add native tab bars and nested navigator forwarding
1 parent 828bbb0 commit 2b80032

File tree

9 files changed

+635
-59
lines changed

9 files changed

+635
-59
lines changed

docs/api/component-properties.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,30 @@ pn.Modal(*children, visible=show_modal, on_dismiss=handler, title="Confirm",
176176

177177
Overlay dialog shown when `visible=True`.
178178

179+
## TabBar
180+
181+
```python
182+
pn.Element("TabBar", {
183+
"items": [
184+
{"name": "Home", "title": "Home"},
185+
{"name": "Settings", "title": "Settings"},
186+
],
187+
"active_tab": "Home",
188+
"on_tab_select": handler,
189+
})
190+
```
191+
192+
Native tab bar — typically created automatically by `Tab.Navigator`.
193+
194+
| Platform | Native view |
195+
|----------|--------------------------|
196+
| Android | `BottomNavigationView` |
197+
| iOS | `UITabBar` |
198+
199+
- `items` — list of `{"name": str, "title": str}` dicts defining each tab
200+
- `active_tab` — the `name` of the currently active tab
201+
- `on_tab_select` — callback `(str) -> None` receiving the selected tab name
202+
179203
## FlatList
180204

181205
```python

docs/guides/navigation.md

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
PythonNative offers two approaches to navigation:
44

55
1. **Declarative navigators** (recommended) — component-based, inspired by React Navigation
6-
2. **Legacy push/pop** — imperative navigation via `use_navigation()`
6+
2. **Page-level push/pop** — imperative navigation via `use_navigation()` (for native page transitions)
77

88
## Declarative Navigation
99

@@ -54,7 +54,9 @@ def DetailScreen():
5454

5555
### Tab Navigator
5656

57-
A tab navigator renders a tab bar and switches between screens.
57+
A tab navigator renders a **native tab bar** and switches between screens.
58+
On Android the tab bar is a `BottomNavigationView` from Material Components;
59+
on iOS it is a `UITabBar`.
5860

5961
```python
6062
from pythonnative.navigation import create_tab_navigator
@@ -71,6 +73,13 @@ def App():
7173
)
7274
```
7375

76+
The tab bar emits a `TabBar` element that maps to platform-native views:
77+
78+
| Platform | Native view |
79+
|----------|------------------------------|
80+
| Android | `BottomNavigationView` |
81+
| iOS | `UITabBar` |
82+
7483
### Drawer Navigator
7584

7685
A drawer navigator provides a side menu for switching screens.
@@ -100,7 +109,10 @@ def HomeScreen():
100109

101110
### Nesting Navigators
102111

103-
Navigators can be nested — for example, tabs containing stacks:
112+
Navigators can be nested — for example, tabs containing stacks.
113+
When a child navigator receives a `navigate()` call for an unknown route,
114+
it automatically **forwards** the request to its parent navigator.
115+
Similarly, `go_back()` at the root of a child stack forwards to the parent.
104116

105117
```python
106118
Stack = create_stack_navigator()
@@ -123,6 +135,9 @@ def App():
123135
)
124136
```
125137

138+
Inside `FeedScreen`, calling `nav.navigate("Settings")` will forward to the
139+
parent tab navigator and switch to the Settings tab.
140+
126141
## NavigationHandle API
127142

128143
Inside any screen rendered by a navigator, `pn.use_navigation()` returns a handle with:

examples/hello-world/app/main_page.py

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import emoji
22

33
import pythonnative as pn
4+
from pythonnative.navigation import NavigationContainer, create_tab_navigator
45

56
MEDALS = [":1st_place_medal:", ":2nd_place_medal:", ":3rd_place_medal:"]
67

8+
Tab = create_tab_navigator()
79

810
styles = pn.StyleSheet.create(
911
title={"font_size": 24, "bold": True},
@@ -39,7 +41,8 @@ def counter_badge(initial: int = 0) -> pn.Element:
3941

4042

4143
@pn.component
42-
def MainPage() -> pn.Element:
44+
def HomeTab() -> pn.Element:
45+
"""Home tab — counter demo and push-navigation to other pages."""
4346
nav = pn.use_navigation()
4447
return pn.ScrollView(
4548
pn.Column(
@@ -55,3 +58,29 @@ def MainPage() -> pn.Element:
5558
style=styles["section"],
5659
)
5760
)
61+
62+
63+
@pn.component
64+
def SettingsTab() -> pn.Element:
65+
"""Settings tab — simple placeholder content."""
66+
return pn.ScrollView(
67+
pn.Column(
68+
pn.Text("Settings", style=styles["title"]),
69+
pn.Text("App version: 0.7.0", style=styles["subtitle"]),
70+
pn.Text(
71+
"This tab uses a native UITabBar on iOS " "and BottomNavigationView on Android.",
72+
style=styles["subtitle"],
73+
),
74+
style=styles["section"],
75+
)
76+
)
77+
78+
79+
@pn.component
80+
def MainPage() -> pn.Element:
81+
return NavigationContainer(
82+
Tab.Navigator(
83+
Tab.Screen("Home", component=HomeTab, options={"title": "Home"}),
84+
Tab.Screen("Settings", component=SettingsTab, options={"title": "Settings"}),
85+
)
86+
)

src/pythonnative/native_views/android.py

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -618,6 +618,148 @@ def onStopTrackingTouch(self, seekBar: Any) -> None:
618618
sb.setOnSeekBarChangeListener(SeekProxy(cb, min_val, rng))
619619

620620

621+
_android_tabbar_state: dict = {"callback": None, "items": []}
622+
623+
624+
class TabBarHandler(ViewHandler):
625+
"""Native tab bar using ``BottomNavigationView`` from Material Components.
626+
627+
Falls back to a horizontal ``LinearLayout`` with ``Button`` children
628+
when Material Components is unavailable.
629+
"""
630+
631+
_is_material: bool = True
632+
633+
def create(self, props: Dict[str, Any]) -> Any:
634+
try:
635+
bnv = jclass("com.google.android.material.bottomnavigation.BottomNavigationView")(_ctx())
636+
bnv.setBackgroundColor(parse_color_int("#FFFFFF"))
637+
ViewGroupLP = jclass("android.view.ViewGroup$LayoutParams")
638+
LayoutParams = jclass("android.widget.LinearLayout$LayoutParams")
639+
lp = LayoutParams(ViewGroupLP.MATCH_PARENT, ViewGroupLP.WRAP_CONTENT)
640+
bnv.setLayoutParams(lp)
641+
self._is_material = True
642+
self._apply_full(bnv, props)
643+
return bnv
644+
except Exception:
645+
self._is_material = False
646+
return self._create_fallback(props)
647+
648+
def _create_fallback(self, props: Dict[str, Any]) -> Any:
649+
"""Horizontal LinearLayout with Button children as a tab-bar fallback."""
650+
LinearLayout = jclass("android.widget.LinearLayout")
651+
ll = LinearLayout(_ctx())
652+
ll.setOrientation(LinearLayout.HORIZONTAL)
653+
ll.setBackgroundColor(parse_color_int("#F8F8F8"))
654+
self._apply_fallback(ll, props)
655+
return ll
656+
657+
def update(self, native_view: Any, changed: Dict[str, Any]) -> None:
658+
if self._is_material:
659+
self._apply_partial(native_view, changed)
660+
else:
661+
self._apply_fallback(native_view, changed)
662+
663+
def _apply_full(self, bnv: Any, props: Dict[str, Any]) -> None:
664+
"""Initial creation — all props are present."""
665+
items = props.get("items", [])
666+
self._set_menu(bnv, items)
667+
self._set_active(bnv, props.get("active_tab"), items)
668+
cb = props.get("on_tab_select")
669+
if cb is not None:
670+
self._set_listener(bnv, cb, items)
671+
672+
def _apply_partial(self, bnv: Any, changed: Dict[str, Any]) -> None:
673+
"""Reconciler update — only changed props are present."""
674+
prev_items = _android_tabbar_state["items"]
675+
676+
if "items" in changed:
677+
items = changed["items"]
678+
self._set_menu(bnv, items)
679+
else:
680+
items = prev_items
681+
682+
if "active_tab" in changed:
683+
self._set_active(bnv, changed["active_tab"], items)
684+
685+
if "on_tab_select" in changed:
686+
cb = changed["on_tab_select"]
687+
if cb is not None:
688+
self._set_listener(bnv, cb, items)
689+
690+
def _set_menu(self, bnv: Any, items: list) -> None:
691+
_android_tabbar_state["items"] = items
692+
try:
693+
menu = bnv.getMenu()
694+
menu.clear()
695+
for i, item in enumerate(items):
696+
title = item.get("title", item.get("name", ""))
697+
menu.add(0, i, i, str(title))
698+
except Exception:
699+
pass
700+
701+
def _set_active(self, bnv: Any, active: Any, items: list) -> None:
702+
if active and items:
703+
for i, item in enumerate(items):
704+
if item.get("name") == active:
705+
try:
706+
bnv.setSelectedItemId(i)
707+
except Exception:
708+
pass
709+
break
710+
711+
def _set_listener(self, bnv: Any, cb: Callable, items: list) -> None:
712+
_android_tabbar_state["callback"] = cb
713+
_android_tabbar_state["items"] = items
714+
try:
715+
listener_cls = jclass("com.google.android.material.navigation.NavigationBarView$OnItemSelectedListener")
716+
717+
class _TabSelectProxy(dynamic_proxy(listener_cls)):
718+
def __init__(self, callback: Callable, tab_items: list) -> None:
719+
super().__init__()
720+
self.callback = callback
721+
self.tab_items = tab_items
722+
723+
def onNavigationItemSelected(self, menu_item: Any) -> bool:
724+
idx = menu_item.getItemId()
725+
if 0 <= idx < len(self.tab_items):
726+
self.callback(self.tab_items[idx].get("name", ""))
727+
return True
728+
729+
bnv.setOnItemSelectedListener(_TabSelectProxy(cb, items))
730+
except Exception:
731+
pass
732+
733+
def _apply_fallback(self, ll: Any, props: Dict[str, Any]) -> None:
734+
items = props.get("items", [])
735+
active = props.get("active_tab")
736+
cb = props.get("on_tab_select")
737+
if "items" in props:
738+
ll.removeAllViews()
739+
for item in items:
740+
name = item.get("name", "")
741+
title = item.get("title", name)
742+
btn = jclass("android.widget.Button")(_ctx())
743+
btn.setText(str(title))
744+
btn.setEnabled(name != active)
745+
if cb is not None:
746+
tab_name = name
747+
748+
def _make_click(n: str) -> Callable[[], None]:
749+
return lambda: cb(n)
750+
751+
class _ClickProxy(dynamic_proxy(jclass("android.view.View").OnClickListener)):
752+
def __init__(self, callback: Callable[[], None]) -> None:
753+
super().__init__()
754+
self.callback = callback
755+
756+
def onClick(self, view: Any) -> None:
757+
self.callback()
758+
759+
btn.setOnClickListener(_ClickProxy(_make_click(tab_name)))
760+
ll.addView(btn)
761+
762+
621763
class PressableHandler(ViewHandler):
622764
def create(self, props: Dict[str, Any]) -> Any:
623765
fl = jclass("android.widget.FrameLayout")(_ctx())
@@ -686,4 +828,5 @@ def register_handlers(registry: Any) -> None:
686828
registry.register("SafeAreaView", SafeAreaViewHandler())
687829
registry.register("Modal", ModalHandler())
688830
registry.register("Slider", SliderHandler())
831+
registry.register("TabBar", TabBarHandler())
689832
registry.register("Pressable", PressableHandler())

0 commit comments

Comments
 (0)