| FazBrowse GitHub Viewer | Trending | | Home |
| Tools: [Download Repo ZIP] [Original HTTPS Page] |
1 parent 1cba3ef commit baf6d58
5 files changed
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -34,12 +34,47 @@ | |||
| 34 | 34 | ||
| 35 | 35 | @pn.component | |
| 36 | 36 | 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 | + """ | ||
| 38 | 45 | 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 | + ), | ||
| 43 | 78 | ) | |
| 44 | 79 | ||
| 45 | 80 | ||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -1116,10 +1116,41 @@ def _set_menu(self, bnv: Any, items: list) -> None: | |||
| 1116 | 1116 | menu.clear() | |
| 1117 | 1117 | for i, item in enumerate(items): | |
| 1118 | 1118 | 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 | ||
| 1120 | 1126 | except Exception: | |
| 1121 | 1127 | pass | |
| 1122 | 1128 | ||
| 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 | + | ||
| 1123 | 1154 | def _set_active(self, bnv: Any, active: Any, items: list) -> None: | |
| 1124 | 1155 | if active and items: | |
| 1125 | 1156 | for i, item in enumerate(items): | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -2345,13 +2345,38 @@ def _apply_partial(self, tab_bar: Any, changed: Dict[str, Any]) -> None: | |||
| 2345 | 2345 | ||
| 2346 | 2346 | def _set_bar_items(self, tab_bar: Any, items: list) -> None: | |
| 2347 | 2347 | UITabBarItem = ObjCClass("UITabBarItem") | |
| 2348 | + UIImage = ObjCClass("UIImage") | ||
| 2348 | 2349 | bar_items = [] | |
| 2349 | 2350 | for i, item in enumerate(items): | |
| 2350 | 2351 | 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) | ||
| 2352 | 2354 | bar_items.append(bar_item) | |
| 2353 | 2355 | tab_bar.setItems_animated_(bar_items, False) | |
| 2354 | 2356 | ||
| 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 | + | ||
| 2355 | 2380 | def _set_active(self, tab_bar: Any, active: Any, items: list) -> None: | |
| 2356 | 2381 | if not active or not items: | |
| 2357 | 2382 | return | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -570,10 +570,14 @@ def get_stack() -> List[_RouteEntry]: | |||
| 570 | 570 | if screen_def is None: | |
| 571 | 571 | screen_def = screen_map[screen_list[0].name] | |
| 572 | 572 | ||
| 573 | - tab_items: List[Dict[str, str]] = [] | ||
| 573 | + tab_items: List[Dict[str, Any]] = [] | ||
| 574 | 574 | for s in screen_list: | |
| 575 | 575 | 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) | ||
| 577 | 581 | ||
| 578 | 582 | def on_tab_select(name: str) -> None: | |
| 579 | 583 | switch_tab(name) | |
@@ -639,8 +643,17 @@ def Screen(name: str, *, component: Any, options: Optional[Dict[str, Any]] = Non | |||
| 639 | 643 | name: Route name and default tab title. | |
| 640 | 644 | component: A `@component` function rendered when this | |
| 641 | 645 | 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. | ||
| 644 | 657 | ||
| 645 | 658 | Returns: | |
| 646 | 659 | A `_ScreenDef` consumed by `Navigator(...)`. | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -629,6 +629,66 @@ def find_tab_bar(view: MockView) -> Any: | |||
| 629 | 629 | assert callable(tab_bar.props["on_tab_select"]) | |
| 630 | 630 | ||
| 631 | 631 | ||
| 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 | + | ||
| 632 | 692 | def test_tab_navigator_empty_screens() -> None: | |
| 633 | 693 | Tab = create_tab_navigator() | |
| 634 | 694 | ||
| Back | FazBrowse Home | New Git URL |
0 commit comments