Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions crates/nsh-core/src/mobile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,9 +74,10 @@ pub enum TabBarEvent {
/// gives the tabs (`items`), says which is selected (`selectedIndex`), hears
/// the user's choice (`onChange`), places the bar with its `class` and
/// `style` and shows the selected tab's content; the bar's height is the
/// system's where the style leaves it open. The desktops and the web have no
/// system tab bar: there it takes no room and shows nothing, and the app
/// shows its tabs with `nsh/base`'s `Tabs`.
/// system's where the style leaves it open. The selected tab takes the
/// theme's `primary` (the other tabs keep the system's). The desktops and
/// the web have no system tab bar: there it takes no room and shows
/// nothing, and the app shows its tabs with `nsh/base`'s `Tabs`.
#[component]
pub fn TabBar(props: TabBarProps, cx: Scope<TabBarEvent>) -> impl IntoElement {
if !matches!(os(), Os::Ios | Os::Android) {
Expand All @@ -89,6 +90,12 @@ pub fn TabBar(props: TabBarProps, cx: Scope<TabBarEvent>) -> impl IntoElement {
JsonValue::from(props.selected_index.get())
})
.prop("hidden", move || JsonValue::Bool(props.hidden.get()))
// One color, for the appearance shown: sent again when either changes.
.prop("tint", || {
tokens()
.color("primary")
.map_or(JsonValue::Null, |primary| color(primary).to_hex().into())
})
.on_native_event(move |event: &NativeEvent| {
if &*event.name == "change"
&& let Some(index) = event.data.as_u64()
Expand Down
40 changes: 40 additions & 0 deletions crates/nsh-engine/tests/engine/components.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5329,6 +5329,46 @@ fn the_tab_bar_is_the_systems_component_on_ios_and_android() {
}
}

/// The bar's selected tab takes the theme's `primary`, as the platforms
/// read one color: the light value, then the dark one when the appearance
/// turns dark.
#[test]
fn the_tab_bar_takes_the_themes_primary_color() {
use nsh_core::platform::{ColorScheme, Os};

let theme = r##"{ "theme": { "cssVars": {
"light": { "primary": "#26251E" }, "dark": { "primary": "#EDECE6" } } } }"##;
let tint = |batches: &[MountBatch]| {
batches
.iter()
.flat_map(|batch| &batch.items)
.filter_map(|item| match item {
MountItem::ComponentProps { props, .. } => Some(props),
_ => None,
})
.flatten()
.rfind(|(name, _)| &**name == "tint")
.and_then(|(_, value)| value.clone())
};
for os in [Os::Ios, Os::Android] {
let environment = Environment {
os,
..Environment::default()
};
let (engine, rx) = start_with(TAB_BAR, environment, None, Some(theme));
let batches = until(&rx, |batch| batch.items.iter().any(is_tab_bar));
assert_eq!(tint(&batches), Some(serde_json::json!("#26251E")), "{os:?}");
engine.control().set_environment(Environment {
color_scheme: ColorScheme::Dark,
..environment
});
let dark = until(&rx, |batch| {
tint(std::slice::from_ref(batch)) == Some(serde_json::json!("#EDECE6"))
});
assert_eq!(tint(&dark), Some(serde_json::json!("#EDECE6")), "{os:?}");
}
}

/// Where the system has no tab bar it renders nothing: no component at all;
/// `isAvailable` tells which platforms `nsh/mobile` serves.
#[test]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ final class TabBarComponent implements NshComponent {
private final Bar bar;
private final List<Item> items = new ArrayList<>();
private int selected;
/** The theme's {@code primary} (null: none): the selected tab's icon, label and indicator. */
private Integer tint;
/** The bottom inset the bar reaches under, in px. */
private int inset;

Expand Down Expand Up @@ -78,11 +80,34 @@ public void setProp(String name, Object value) {
selected = whole ? ((Number) value).intValue() : 0;
select();
break;
case "tint":
setTint(value instanceof String ? argb((String) value) : null);
break;
default:
break;
}
}

/** The theme's {@code primary} (null: none, the system's colors; transparent is a color). */
private void setTint(Integer color) {
if (java.util.Objects.equals(color, tint)) return;
tint = color;
paint();
}

/** {@code #RRGGBB} or {@code #RRGGBBAA} (the core's hex) as ARGB; null when it is neither. */
private static Integer argb(String hex) {
String digits = hex.startsWith("#") ? hex.substring(1) : hex;
if (digits.length() != 6 && digits.length() != 8) return null;
long rgba;
try {
rgba = Long.parseLong(digits.length() == 6 ? digits + "ff" : digits, 16);
} catch (NumberFormatException e) {
return null;
}
return (int) ((rgba >>> 8) | ((rgba & 0xff) << 24));
}

private void setItems(List<?> tabs) {
bar.removeAllViews();
items.clear();
Expand Down Expand Up @@ -251,12 +276,16 @@ public void setSelected(boolean on) {

void paint(boolean dark) {
boolean on = isSelected();
pill.setColor(color(dark ? 0xff4a4458 : 0xffe8def8, dark ? android.R.color.system_accent2_700 : android.R.color.system_accent2_100));
// The theme's primary: the selected icon and label in it, over
// an indicator of it at Material's state-layer opacity (12%).
pill.setColor(tint != null
? (tint & 0x00ffffff) | (((tint >>> 24) * 0x1f / 0xff) << 24)
: color(dark ? 0xff4a4458 : 0xffe8def8, dark ? android.R.color.system_accent2_700 : android.R.color.system_accent2_100));
int iconColor = on
? color(dark ? 0xffe8def8 : 0xff1d192b, dark ? android.R.color.system_accent2_100 : android.R.color.system_accent2_900)
? (tint != null ? tint : color(dark ? 0xffe8def8 : 0xff1d192b, dark ? android.R.color.system_accent2_100 : android.R.color.system_accent2_900))
: color(dark ? 0xffcac4d0 : 0xff49454f, dark ? android.R.color.system_neutral2_200 : android.R.color.system_neutral2_700);
int labelColor = on
? color(dark ? 0xffe6e0e9 : 0xff1d1b20, dark ? android.R.color.system_neutral1_100 : android.R.color.system_neutral1_900)
? (tint != null ? tint : color(dark ? 0xffe6e0e9 : 0xff1d1b20, dark ? android.R.color.system_neutral1_100 : android.R.color.system_neutral1_900))
: iconColor;
icon.setImageTintList(ColorStateList.valueOf(iconColor));
label.setTextColor(labelColor);
Expand Down
41 changes: 39 additions & 2 deletions crates/nsh-platform-ios/src/system_tab_bar.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ use objc2::{
};
use objc2_core_foundation::{CGPoint, CGRect, CGSize};
use objc2_foundation::{NSArray, NSObject, NSObjectProtocol, NSString};
use objc2_ui_kit::{UIImage, UIResponder, UITabBar, UITabBarDelegate, UITabBarItem, UIView};
use objc2_ui_kit::{
UIColor, UIImage, UIResponder, UITabBar, UITabBarDelegate, UITabBarItem, UIView,
};

use crate::component::{ComponentContext, ComponentFactory, NativeComponent};

Expand Down Expand Up @@ -211,6 +213,15 @@ fn parse_items(items: &[JsonValue]) -> Vec<Tab> {
.collect()
}

/// The `tint` prop: the theme's `primary` as the core's hex (`None`: the
/// system's).
fn parse_tint(value: Option<&JsonValue>) -> Option<nsh_core::Color> {
let hex = value?.as_str()?;
nsh_core::Hsla::parse_hex(hex)
.ok()
.map(nsh_core::Color::from)
}

/// Shows `items` on `bar`, with `selected` chosen.
fn show(bar: &UITabBar, items: &[Retained<UITabBarItem>], selected: usize) {
if items.is_empty() {
Expand Down Expand Up @@ -327,6 +338,16 @@ impl NativeComponent for SystemTabBar {
self.bar
.setSelectedItem(items.get(selected).map(|item| &**item));
}
("tint", value) => {
// The bar draws its selected item in its `tintColor`.
let tint = parse_tint(value).map(|c| {
let (r, g, b, a) = c.to_f64();
UIColor::colorWithRed_green_blue_alpha(r, g, b, a)
});
// SAFETY: a plain setter on the main thread; `None` restores
// the inherited tint.
unsafe { self.bar.setTintColor(tint.as_deref()) };
}
_ => {}
}
}
Expand Down Expand Up @@ -355,7 +376,7 @@ pub(crate) fn factory() -> ComponentFactory {
mod tests {
use serde_json::json;

use super::{Tab, parse_items};
use super::{Tab, parse_items, parse_tint};

/// Each tab carries its own title and icon; a tab without an icon has
/// none.
Expand All @@ -379,4 +400,20 @@ mod tests {
]
);
}

/// The core's hex is the tint; anything else is none (the system's).
#[test]
fn the_tint_is_the_cores_hex_color() {
assert_eq!(
parse_tint(Some(&json!("#26251E"))),
Some(nsh_core::Color(0x26251eff))
);
assert_eq!(
parse_tint(Some(&json!("#EDECE600"))),
Some(nsh_core::Color(0xedece600))
);
assert_eq!(parse_tint(Some(&json!(null))), None);
assert_eq!(parse_tint(Some(&json!("primary"))), None);
assert_eq!(parse_tint(None), None);
}
}
8 changes: 5 additions & 3 deletions website/modules/nsh-mobile.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ In Rust, `tab_bar()` makes one, with a setter per prop (`.items(..)`, `.selected
| `items` | `.items(..)` | `TabBarItem[]` | `[]` | The tabs, in order. |
| `selectedIndex` | `.selected_index(..)` | `number` | `0` | The selected tab, by index; an index past the tabs selects none. |
| `hidden` | `.hidden(..)` | `boolean` | `false` | iOS: hides the bar without a transition. The app owns this value across pushes and pops. The bar shows when both this prop and the visible stack’s `hidesBottomBarWhenPushed` request are false. |
| `class`, `style` | style methods | `string`, `StyleProp<ViewStyle>` | none | Where the bar is: its position and size. Its look is the system's. |
| `class`, `style` | style methods | `string`, `StyleProp<ViewStyle>` | none | Where the bar is: its position and size. Its look is the system's, with the selected tab in the theme's `primary`. |
| `testID` | `.test_id(..)` | `string` | none | Finds the bar in tests and `nsh test --trace`. |

#### TabBarItem
Expand All @@ -132,11 +132,13 @@ In Rust, `tab_bar()` makes one, with a setter per prop (`.items(..)`, `.selected
### Platform notes

- **iOS and Mac Catalyst**: `UITabBar`, with the system's appearance for the iOS version: the floating Liquid Glass
bar and its selection on iOS 26. Icons are SF Symbols.
bar and its selection on iOS 26. Icons are SF Symbols. The theme's `primary` is the bar's `tintColor`, the selected
tab's color; the other tabs keep the system's.
- **Android**: Material 3's navigation bar, made of the platform's views: an icon in its active indicator over each
label, a ripple on press, the system's dynamic colors on Android 12 and later and Material's baseline colors before,
light or dark as the app's appearance. At the bottom of the window it reaches under the navigation bar, taller by
its inset, as Material's does. Icons are drawables of the app (tinted by the bar).
its inset, as Material's does. Icons are drawables of the app (tinted by the bar). The selected tab's icon and label
take the theme's `primary`, over an active indicator of it at 12% opacity; the other tabs keep the system's.
- **Desktops (macOS, Windows, Linux, GPUI) and the web**: the sections show with [Tabs](../elements/tabs.md), in the app's
look. `TabBar` takes no room there, so one layout holds both, picked by `platform.os`
([`nsh`'s device API](./nsh.md#device)).
Expand Down
6 changes: 3 additions & 3 deletions website/zh-CN/modules/nsh-mobile.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ mount(App);
| `items` | `.items(..)` | `TabBarItem[]` | `[]` | 各个标签,按顺序。 |
| `selectedIndex` | `.selected_index(..)` | `number` | `0` | 选中的标签的索引;超出标签数量的索引不选中任何标签。 |
| `hidden` | `.hidden(..)` | `boolean` | `false` | iOS:立即隐藏标签栏。应用在页面推入和弹出期间持有这个值;当它和当前可见导航栈的 `hidesBottomBarWhenPushed` 请求都为 false 时,标签栏显示。 |
| `class`、`style` | 样式方法 | `string`、`StyleProp<ViewStyle>` | 无 | 标签栏的位置和尺寸。外观是系统的。 |
| `class`、`style` | 样式方法 | `string`、`StyleProp<ViewStyle>` | 无 | 标签栏的位置和尺寸。外观是系统的,选中的标签使用主题的 `primary`。 |
| `testID` | `.test_id(..)` | `string` | 无 | 在测试和 `nsh test --trace` 中找到标签栏。 |

#### TabBarItem
Expand All @@ -116,8 +116,8 @@ mount(App);

### 平台说明

- **iOS 和 Mac Catalyst**:`UITabBar`,采用当前 iOS 版本的系统外观:iOS 26 上是悬浮的 Liquid Glass 标签栏及其选中效果。图标是 SF Symbols。
- **Android**:Material 3 的导航栏,由平台的 View 组成:每个标签的文字上方是放在激活指示器中的图标,按压时有水波纹;Android 12 及以上使用系统的动态颜色,更早的版本使用 Material 的基准颜色;浅色或深色跟随应用的外观。位于窗口底部时,它延伸到导航栏下方,高度增加导航栏的 inset,与 Material 的一致。图标是应用的 drawable(由标签栏着色)。
- **iOS 和 Mac Catalyst**:`UITabBar`,采用当前 iOS 版本的系统外观:iOS 26 上是悬浮的 Liquid Glass 标签栏及其选中效果。图标是 SF Symbols。主题的 `primary` 是标签栏的 `tintColor`,即选中标签的颜色;其他标签保持系统的颜色。
- **Android**:Material 3 的导航栏,由平台的 View 组成:每个标签的文字上方是放在激活指示器中的图标,按压时有水波纹;Android 12 及以上使用系统的动态颜色,更早的版本使用 Material 的基准颜色;浅色或深色跟随应用的外观。位于窗口底部时,它延伸到导航栏下方,高度增加导航栏的 inset,与 Material 的一致。图标是应用的 drawable(由标签栏着色)。选中标签的图标和文字使用主题的 `primary`,激活指示器是这个颜色的 12% 不透明度;其他标签保持系统的颜色。
- **桌面(macOS、Windows、Linux、GPUI)和 Web**:各个部分用 [Tabs](../elements/tabs.md) 显示,外观由应用决定。`TabBar` 在这些平台上不占空间,所以一个布局可以同时包含两者,用 `platform.os`([`nsh` 的设备 API](./nsh.md#device))来选择。

## InputAccessoryView
Expand Down
Loading