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

feat(navigation): add tab_bar_icon option for native system icons · pythonnative/pythonnative@baf6d58 · GitHub

Repository navigation

Commit baf6d58

Browse files
committed
feat(navigation): add tab_bar_icon option for native system icons
1 parent 1cba3ef commit baf6d58

5 files changed

Lines changed: 175 additions & 11 deletions

File tree

‎examples/hello-world/app/main.py‎

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,12 +34,47 @@
3434

3535
@pn.component
3636
def MainTabs() -> pn.Element:
37-
"""Tabbed root screen: Home, Layout, List, Settings."""
37+
"""Tabbed root screen: Home, Layout, List, Settings.
38+
39+
Each tab opts into a native system icon via ``tab_bar_icon``: an
40+
SF Symbol on iOS and an ``android.R.drawable.*`` resource on
41+
Android. The framework renders text-only if a name doesn't
42+
resolve on a given platform, so adding a new tab is safe even
43+
before its icons are picked.
44+
"""
3845
return Tab.Navigator(
39-
Tab.Screen("Home", component=HomeScreen, options={"title": "Home"}),
40-
Tab.Screen("Layout", component=LayoutScreen, options={"title": "Layout"}),
41-
Tab.Screen("List", component=ListScreen, options={"title": "List"}),
42-
Tab.Screen("Settings", component=SettingsScreen, options={"title": "Settings"}),
46+
Tab.Screen(
47+
"Home",
48+
component=HomeScreen,
49+
options={
50+
"title": "Home",
51+
"tab_bar_icon": {"ios": "house.fill", "android": "ic_menu_home"},
52+
},
53+
),
54+
Tab.Screen(
55+
"Layout",
56+
component=LayoutScreen,
57+
options={
58+
"title": "Layout",
59+
"tab_bar_icon": {"ios": "square.grid.2x2.fill", "android": "ic_menu_view"},
60+
},
61+
),
62+
Tab.Screen(
63+
"List",
64+
component=ListScreen,
65+
options={
66+
"title": "List",
67+
"tab_bar_icon": {"ios": "list.bullet", "android": "ic_menu_sort_by_size"},
68+
},
69+
),
70+
Tab.Screen(
71+
"Settings",
72+
component=SettingsScreen,
73+
options={
74+
"title": "Settings",
75+
"tab_bar_icon": {"ios": "gearshape.fill", "android": "ic_menu_preferences"},
76+
},
77+
),
4378
)
4479

4580

‎src/pythonnative/native_views/android.py‎

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1116,10 +1116,41 @@ def _set_menu(self, bnv: Any, items: list) -> None:
11161116
menu.clear()
11171117
for i, item in enumerate(items):
11181118
title = item.get("title", item.get("name", ""))
1119-
menu.add(0, i, i, str(title))
1119+
menu_item = menu.add(0, i, i, str(title))
1120+
res_id = self._resolve_icon(item.get("icon"))
1121+
if res_id:
1122+
try:
1123+
menu_item.setIcon(res_id)
1124+
except Exception:
1125+
pass
11201126
except Exception:
11211127
pass
11221128

1129+
def _resolve_icon(self, icon: Any) -> int:
1130+
"""Resolve a tab icon spec to an `android.R.drawable.*` res id.
1131+
1132+
Accepts a bare string (treated as the drawable's field name on
1133+
``android.R.drawable``) or a dict of the form
1134+
``{"ios": "...", "android": "ic_menu_home"}``. Returns ``0``
1135+
when the icon can't be resolved, which the caller treats as
1136+
"no icon".
1137+
"""
1138+
if icon is None:
1139+
return 0
1140+
name: Any = None
1141+
if isinstance(icon, str):
1142+
name = icon
1143+
elif isinstance(icon, dict):
1144+
name = icon.get("android")
1145+
if not name:
1146+
return 0
1147+
try:
1148+
RDrawable = jclass("android.R$drawable")
1149+
res_id = getattr(RDrawable, str(name), 0)
1150+
return int(res_id) if res_id else 0
1151+
except Exception:
1152+
return 0
1153+
11231154
def _set_active(self, bnv: Any, active: Any, items: list) -> None:
11241155
if active and items:
11251156
for i, item in enumerate(items):

‎src/pythonnative/native_views/ios.py‎

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2345,13 +2345,38 @@ def _apply_partial(self, tab_bar: Any, changed: Dict[str, Any]) -> None:
23452345

23462346
def _set_bar_items(self, tab_bar: Any, items: list) -> None:
23472347
UITabBarItem = ObjCClass("UITabBarItem")
2348+
UIImage = ObjCClass("UIImage")
23482349
bar_items = []
23492350
for i, item in enumerate(items):
23502351
title = item.get("title", item.get("name", ""))
2351-
bar_item = UITabBarItem.alloc().initWithTitle_image_tag_(str(title), None, i)
2352+
image = self._resolve_icon(UIImage, item.get("icon"))
2353+
bar_item = UITabBarItem.alloc().initWithTitle_image_tag_(str(title), image, i)
23522354
bar_items.append(bar_item)
23532355
tab_bar.setItems_animated_(bar_items, False)
23542356

2357+
def _resolve_icon(self, UIImage: Any, icon: Any) -> Any:
2358+
"""Resolve a tab icon spec to a UIImage, or return None.
2359+
2360+
Accepts a bare string (treated as an SF Symbol name) or a dict
2361+
of the form ``{"ios": "house.fill", "android": "..."}``. SF
2362+
Symbols are looked up via ``UIImage.systemImageNamed:``; names
2363+
that don't resolve produce a text-only tab.
2364+
"""
2365+
if icon is None:
2366+
return None
2367+
name: Any = None
2368+
if isinstance(icon, str):
2369+
name = icon
2370+
elif isinstance(icon, dict):
2371+
name = icon.get("ios")
2372+
if not name:
2373+
return None
2374+
try:
2375+
image = UIImage.systemImageNamed_(str(name))
2376+
return image if image else None
2377+
except Exception:
2378+
return None
2379+
23552380
def _set_active(self, tab_bar: Any, active: Any, items: list) -> None:
23562381
if not active or not items:
23572382
return

‎src/pythonnative/navigation.py‎

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -570,10 +570,14 @@ def get_stack() -> List[_RouteEntry]:
570570
if screen_def is None:
571571
screen_def = screen_map[screen_list[0].name]
572572

573-
tab_items: List[Dict[str, str]] = []
573+
tab_items: List[Dict[str, Any]] = []
574574
for s in screen_list:
575575
if isinstance(s, _ScreenDef):
576-
tab_items.append({"name": s.name, "title": s.options.get("title", s.name)})
576+
item: Dict[str, Any] = {"name": s.name, "title": s.options.get("title", s.name)}
577+
icon = s.options.get("tab_bar_icon")
578+
if icon is not None:
579+
item["icon"] = icon
580+
tab_items.append(item)
577581

578582
def on_tab_select(name: str) -> None:
579583
switch_tab(name)
@@ -639,8 +643,17 @@ def Screen(name: str, *, component: Any, options: Optional[Dict[str, Any]] = Non
639643
name: Route name and default tab title.
640644
component: A `@component` function rendered when this
641645
tab is active.
642-
options: Optional per-screen settings (e.g.,
643-
`{"title": "..."}`).
646+
options: Optional per-screen settings. Recognized keys:
647+
648+
- `title` (str): Tab label.
649+
- `tab_bar_icon` (str | dict): Native system icon
650+
identifier. A string is used on every platform; a
651+
dict like `{"ios": "house.fill", "android":
652+
"ic_menu_home"}` selects per platform. iOS values
653+
are resolved via SF Symbols
654+
(`UIImage.systemImageNamed_`); Android values are
655+
resolved against `android.R.drawable.<name>`.
656+
Names that don't resolve fall back to text-only.
644657
645658
Returns:
646659
A `_ScreenDef` consumed by `Navigator(...)`.

‎tests/test_navigation.py‎

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -629,6 +629,66 @@ def find_tab_bar(view: MockView) -> Any:
629629
assert callable(tab_bar.props["on_tab_select"])
630630

631631

632+
def test_tab_navigator_forwards_tab_bar_icon() -> None:
633+
"""`tab_bar_icon` (string or per-platform dict) reaches the TabBar element."""
634+
Tab = create_tab_navigator()
635+
636+
@component
637+
def ScreenA() -> Element:
638+
return Element("Text", {"text": "a"}, [])
639+
640+
@component
641+
def ScreenB() -> Element:
642+
return Element("Text", {"text": "b"}, [])
643+
644+
@component
645+
def ScreenC() -> Element:
646+
return Element("Text", {"text": "c"}, [])
647+
648+
backend = MockBackend()
649+
rec = Reconciler(backend)
650+
rec._screen_re_render = lambda: None
651+
652+
el = Tab.Navigator(
653+
Tab.Screen(
654+
"TabA",
655+
component=ScreenA,
656+
options={"title": "Tab A", "tab_bar_icon": "house.fill"},
657+
),
658+
Tab.Screen(
659+
"TabB",
660+
component=ScreenB,
661+
options={
662+
"title": "Tab B",
663+
"tab_bar_icon": {"ios": "gearshape.fill", "android": "ic_menu_preferences"},
664+
},
665+
),
666+
Tab.Screen("TabC", component=ScreenC, options={"title": "Tab C"}),
667+
)
668+
root = rec.mount(el)
669+
670+
def find_tab_bar(view: MockView) -> Any:
671+
if view.type_name == "TabBar":
672+
return view
673+
for c in view.children:
674+
r = find_tab_bar(c)
675+
if r is not None:
676+
return r
677+
return None
678+
679+
tab_bar = find_tab_bar(root)
680+
assert tab_bar is not None
681+
assert tab_bar.props["items"] == [
682+
{"name": "TabA", "title": "Tab A", "icon": "house.fill"},
683+
{
684+
"name": "TabB",
685+
"title": "Tab B",
686+
"icon": {"ios": "gearshape.fill", "android": "ic_menu_preferences"},
687+
},
688+
{"name": "TabC", "title": "Tab C"},
689+
]
690+
691+
632692
def test_tab_navigator_empty_screens() -> None:
633693
Tab = create_tab_navigator()
634694

0 commit comments

Comments
 (0)

Back | FazBrowse Home | New Git URL