feat(app-iced): M79 导航家族 —— ListItem / Tabs / NavigationDrawer / AppBar
四件组件 + gallery 状态矩阵 + 渲染层几何断言,全部只落在 `components/navigation/**` 与 `gallery/navigation.rs`(不改 mod.rs / interaction / layout)。 - ListItem(12b §4):一行/两行/三行 44/60/76(A1 56/72/88,登记偏离)、leading/trailing 槽位、 hover/focus/pressed 只叠状态层(内容色不变)、禁用是修饰器且内容角色取 A1 的 `OnSurface`@38%。 - Tabs(12b §6):Primary 3px 指示条(用 `Stack` 分层让 Fill 条拿到「该 tab 的宽」)、 Secondary 1px `SurfaceVariant` 分隔线、带图标时容器 64、横向滚动;选中色按变体分(Primary / OnSurface)。 - NavigationDrawer(15 §21 + D33):**同一个容器两个宽度** —— rail 88 ⇄ drawer 240; 药丸从容器宽派生(56×32 / 216×56,左右各缩 16 / 12,A1 `ItemPadding`); 分组标题 TitleSmall、组间 1px OutlineVariant;drawer 项高 56(G24)。 - AppBar small(12b §16):页头 56(A1 64,登记偏离)、TitleLarge emphasized + LabelMedium 副标题、 leading/trailing 槽位、滚动只换 `SurfaceContainer` + L2(A1 无 OnScroll…Height)。 - gallery:`ULL_NAV_PART=list|tabs|drawer|appbar|all` 分块出图;四块矩阵(ListItem 三行高 × 三态、 Tabs 三个选中位置 + 五态、rail/drawer 同尺度并排、AppBar 静息/滚动)。 - `navigation/probe.rs`:读真截图量像素的 4 条断言(行高 44/60/76、禁用 OnSurface@38%、 指示条宽 = 标签墨迹 + 2×16、rail 88 / drawer 240 与药丸内缩 16/12、AppBar 56), 已用两次变异(行高改 56/72/88;drawer 内缩改 16)验证能被打红。 门禁:fmt ✓ · clippy -p unlucky-app-iced --all-targets -D warnings ✓ · test -p unlucky-app-iced 195 passed ✓
This commit is contained in:
1 parent
22636668d0
commit
81e34d77da
14 files changed
+3149
-39
No files matched your search
@@ -0,0 +1,325 @@
|
||||
//! Tabs(`research/12b` §6):**Primary**(下方 3px 指示条)与 **Secondary**(1px 分隔线)。
|
||||
//!
|
||||
//! ## 参考值(`[源]` = A1,本轮 `javap` 实读本机 AAR)
|
||||
//!
|
||||
//! ```text
|
||||
//! PrimaryNavigationTabTokens: ContainerHeight 48、IconAndLabelTextContainerHeight 64、
|
||||
//! ActiveIndicatorHeight 3、ActiveIndicatorShape RoundedCornerShape(3dp)、
|
||||
//! IconSize 24、ContainerShape CornerNone、LabelTextFont TitleSmall
|
||||
//! ActiveIndicatorColor=Primary;Active*/Inactive* 各态内容色(见下表)
|
||||
//! SecondaryNavigationTabTokens: ContainerHeight 48、DividerHeight、DividerColor=SurfaceVariant、
|
||||
//! ActiveLabelTextColor=OnSurface、InactiveLabelTextColor=OnSurfaceVariant
|
||||
//! ```
|
||||
//!
|
||||
//! | 项 | Primary | Secondary |
|
||||
//! |---|---|---|
|
||||
//! | 选中内容色 | **`Primary`** | **`OnSurface`**(不是 Primary——6.5 明写) |
|
||||
//! | 未选内容色 | `OnSurfaceVariant` | `OnSurfaceVariant` |
|
||||
//! | hover / focus / pressed | 未选项提到 **`OnSurface`**,选中项保持不变 | 同左 |
|
||||
//! | 底部条 | 仅选中项:**3px `Primary`**,宽度**跟随该 tab** | 所有项:**1px `SurfaceVariant`**(连成一条线) |
|
||||
//!
|
||||
//! 尺寸/圆角/线厚全部来自 [`TabsGeometry`](**G22/G23**:`LayoutMetrics` 没有 Tabs 档)。
|
||||
//!
|
||||
//! ## 指示条怎么做到「宽度跟随选中项」
|
||||
//!
|
||||
//! iced 没有「取到渲染后文本宽度」的公开 API,所以这里用 [`stack`] 的**分层语义**:
|
||||
//!
|
||||
//! 1. `Stack` 的**底层**是 tab 自己的内容容器(`Length::Shrink` → 宽度 = 文本实测宽 + 2×16);
|
||||
//! 2. `Stack` 的**上层**是 `Length::Fill` 的指示条。`Stack::layout` 先按**底层**定尺寸,再用
|
||||
//! 「已知尺寸」的 `Limits` 布局其余层(`iced_widget-0.14.2/src/stack.rs` 的
|
||||
//! `size = limits.resolve(self.width, self.height, base.size())`)——于是 Fill 的指示条
|
||||
//! 拿到的正好是「这个 tab 的宽」,**不是**整行宽。
|
||||
//!
|
||||
//! 这也顺带避开 Q8 的另一面:把条写成 `center_x(Fill)` 会把它撑成整行。
|
||||
//!
|
||||
//! ## 横向滚动
|
||||
//!
|
||||
//! 整行外面套 `scrollable`(`Direction::Horizontal`,滚动条 `Scrollbar::hidden()`):
|
||||
//! tab 宽度因此是**内容宽**而不是「行宽 ÷ n」——官方 `ScrollableTabRow` 的口径。
|
||||
//! `TabMinWidth = 90` **本轮不施加**(iced 无 min-width,登记在 G23)。
|
||||
|
||||
use iced::widget::text::LineHeight;
|
||||
use iced::widget::{button, container, scrollable, space, text, Row, Stack};
|
||||
use iced::{Alignment, Background, Border, Color, Element, Length, Padding, Pixels, Shadow};
|
||||
|
||||
use unlucky_design::color::Role;
|
||||
use unlucky_design::icons::IconKind;
|
||||
use unlucky_design::shape::ShapeScale;
|
||||
use unlucky_design::typography::TypeRole;
|
||||
|
||||
use crate::icons;
|
||||
use crate::theme::Ui;
|
||||
|
||||
use super::super::interaction::{self, Interaction};
|
||||
use super::super::layout;
|
||||
use super::geometry::{TabsGeometry, TABS_ICON_HEIGHT};
|
||||
|
||||
/// 两种 Tabs(`research/12b` §6;M3 的 `PrimaryTabRow` / `SecondaryTabRow`)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum TabsVariant {
|
||||
/// 主标签页:选中项下方 3px `Primary` 指示条。
|
||||
Primary,
|
||||
/// 次标签页:1px `SurfaceVariant` 分隔线 + 选中项 `OnSurface` 文字。
|
||||
Secondary,
|
||||
}
|
||||
|
||||
impl TabsVariant {
|
||||
/// 两种变体(gallery 状态矩阵按这个次序出图)。
|
||||
pub const ALL: [TabsVariant; 2] = [TabsVariant::Primary, TabsVariant::Secondary];
|
||||
|
||||
/// 稳定名字(截图文件名 / 图例用)。
|
||||
pub const fn name(self) -> &'static str {
|
||||
match self {
|
||||
TabsVariant::Primary => "primary",
|
||||
TabsVariant::Secondary => "secondary",
|
||||
}
|
||||
}
|
||||
|
||||
/// 是否是 Primary(指示条 3px)。
|
||||
pub const fn is_primary(self) -> bool {
|
||||
matches!(self, TabsVariant::Primary)
|
||||
}
|
||||
}
|
||||
|
||||
/// 一个标签页。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Tab<'a, M> {
|
||||
label: &'a str,
|
||||
icon: Option<IconKind>,
|
||||
selected: bool,
|
||||
on_press: Option<M>,
|
||||
state: Option<Interaction>,
|
||||
}
|
||||
|
||||
impl<'a, M> Tab<'a, M> {
|
||||
/// 一个未选中的 tab。
|
||||
pub fn new(label: &'a str, on_press: M) -> Self {
|
||||
Self {
|
||||
label,
|
||||
icon: None,
|
||||
selected: false,
|
||||
on_press: Some(on_press),
|
||||
state: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 选中(内容色换成激活档 + 画指示条)。
|
||||
pub fn selected(mut self) -> Self {
|
||||
self.selected = true;
|
||||
self
|
||||
}
|
||||
|
||||
/// 前置图标(24,`IconSize::Toolbar`;带图标的容器高官方是 64,本轮只做文字档)。
|
||||
pub fn icon(mut self, kind: IconKind) -> Self {
|
||||
self.icon = Some(kind);
|
||||
self
|
||||
}
|
||||
|
||||
/// 强制视觉态(gallery 取证;不动状态机)。
|
||||
pub fn state(mut self, state: Interaction) -> Self {
|
||||
self.state = Some(state);
|
||||
self
|
||||
}
|
||||
|
||||
/// 转禁用:**只清消息**(修饰器,不是构造函数)。
|
||||
pub fn disabled(mut self) -> Self {
|
||||
self.on_press = None;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// 一行标签页。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Tabs<'a, M> {
|
||||
variant: TabsVariant,
|
||||
tabs: Vec<Tab<'a, M>>,
|
||||
}
|
||||
|
||||
impl<'a, M> Tabs<'a, M> {
|
||||
/// Primary tabs(3px 指示条)。
|
||||
pub fn primary() -> Self {
|
||||
Self {
|
||||
variant: TabsVariant::Primary,
|
||||
tabs: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Secondary tabs(1px 分隔线)。
|
||||
pub fn secondary() -> Self {
|
||||
Self {
|
||||
variant: TabsVariant::Secondary,
|
||||
tabs: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 追加一个 tab。
|
||||
pub fn tab(mut self, tab: Tab<'a, M>) -> Self {
|
||||
self.tabs.push(tab);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// 一个 tab 的内容色(A1 的 `Active*` / `Inactive*` / 各态表,见模块文档)。
|
||||
fn content_color(ui: &Ui, primary: bool, state: Interaction, selected: bool) -> Color {
|
||||
if state.is_disabled() {
|
||||
// A1 的 Tabs 没有 Disabled* token(实读:Primary/Secondary 两套都只有 Active/Inactive × 四态),
|
||||
// 按 ListItem 的 38% 口径处理 —— 登记 `[估]`。
|
||||
return interaction::disabled_content_over_role(ui, Role::OnSurface, false);
|
||||
}
|
||||
if selected {
|
||||
// 6.5:Primary 选中 `Primary`;Secondary 选中 `OnSurface`
|
||||
return if primary {
|
||||
ui.color(Role::Primary)
|
||||
} else {
|
||||
ui.color(Role::OnSurface)
|
||||
};
|
||||
}
|
||||
match state {
|
||||
// 未选中静息 `OnSurfaceVariant`;hover/focus/pressed 提到 `OnSurface`(A1 的 Inactive* 四态)
|
||||
Interaction::Enabled => ui.color(Role::OnSurfaceVariant),
|
||||
_ => ui.color(Role::OnSurface),
|
||||
}
|
||||
}
|
||||
|
||||
/// 指示条 / 分隔线的颜色(Primary 只给选中项;Secondary 每项都有)。
|
||||
fn bar_color(ui: &Ui, primary: bool, state: Interaction, selected: bool) -> Option<Color> {
|
||||
if primary {
|
||||
// 禁用项的指示条也降档(否则「点不动却亮着」)
|
||||
if !selected {
|
||||
return None;
|
||||
}
|
||||
if state.is_disabled() {
|
||||
return Some(interaction::disabled_content_over_role(
|
||||
ui,
|
||||
Role::OnSurface,
|
||||
false,
|
||||
));
|
||||
}
|
||||
Some(ui.color(Role::Primary))
|
||||
} else {
|
||||
// 6.6:Secondary 的分隔线 `SurfaceVariant`(不是 OutlineVariant——那是 §22 的分隔线)
|
||||
Some(ui.color(Role::SurfaceVariant))
|
||||
}
|
||||
}
|
||||
|
||||
/// 一行标签页(自带横向滚动)。
|
||||
pub fn tabs<'a, M: Clone + 'a>(ui: &'a Ui, tabs: Tabs<'a, M>) -> Element<'a, M> {
|
||||
let geometry = TabsGeometry::of(ui);
|
||||
let primary = tabs.variant.is_primary();
|
||||
// 6.1:带图标时容器高 **64**(`IconAndLabelTextContainerHeight`),纯文字 48
|
||||
let height = if tabs.tabs.iter().any(|tab| tab.icon.is_some()) {
|
||||
TABS_ICON_HEIGHT
|
||||
} else {
|
||||
geometry.height
|
||||
};
|
||||
|
||||
let mut row = Row::new()
|
||||
.height(Length::Fixed(height))
|
||||
.align_y(Alignment::Center);
|
||||
for tab in tabs.tabs {
|
||||
row = row.push(tab_cell(ui, tab, geometry, primary, height));
|
||||
}
|
||||
|
||||
scrollable(row)
|
||||
.direction(scrollable::Direction::Horizontal(
|
||||
scrollable::Scrollbar::hidden(),
|
||||
))
|
||||
.height(Length::Fixed(height))
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 一个 tab 单元(内容 + 底部条)。
|
||||
fn tab_cell<'a, M: Clone + 'a>(
|
||||
ui: &'a Ui,
|
||||
tab: Tab<'a, M>,
|
||||
geometry: TabsGeometry,
|
||||
primary: bool,
|
||||
height: f32,
|
||||
) -> Element<'a, M> {
|
||||
let forced = tab.state.or(if tab.on_press.is_none() {
|
||||
Some(Interaction::Disabled)
|
||||
} else {
|
||||
None
|
||||
});
|
||||
let state = forced.unwrap_or(Interaction::Enabled);
|
||||
let selected = tab.selected;
|
||||
let color = content_color(ui, primary, state, selected);
|
||||
let style = ui.style(TypeRole::TitleSmall);
|
||||
|
||||
let label = text(tab.label)
|
||||
.size(style.size_px)
|
||||
.line_height(LineHeight::Absolute(Pixels(style.line_height_px)))
|
||||
.font(ui.font_of(style))
|
||||
.color(color);
|
||||
|
||||
let mut content = Row::new().align_y(Alignment::Center).spacing(geometry.pad);
|
||||
if let Some(kind) = tab.icon {
|
||||
content = content.push(layout::fixed(
|
||||
geometry.icon,
|
||||
geometry.icon,
|
||||
icons::view::<M>(kind, color),
|
||||
));
|
||||
}
|
||||
content = content.push(label);
|
||||
|
||||
let bar_height = geometry.bar(primary);
|
||||
// 底层:内容自己决定宽度(Shrink),底部留出条的高度 —— 未选中的 Primary tab 也占同样的高
|
||||
let base = container(content)
|
||||
.height(Length::Fixed(height))
|
||||
.align_y(Alignment::Center)
|
||||
.padding(Padding {
|
||||
top: 0.0,
|
||||
right: geometry.pad,
|
||||
bottom: bar_height,
|
||||
left: geometry.pad,
|
||||
});
|
||||
|
||||
let bar = bar_color(ui, primary, state, selected);
|
||||
let radius = geometry.bar_radius(primary);
|
||||
let mut stack = Stack::new().push(base);
|
||||
if let Some(color) = bar {
|
||||
// 上层:Fill —— `Stack` 先按底层定尺寸,所以这里的 Fill = 这个 tab 的宽(不是整行)
|
||||
stack = stack.push(
|
||||
container(
|
||||
container(space::vertical())
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fixed(bar_height))
|
||||
.style(move |_theme| container::Style {
|
||||
background: Some(Background::Color(color)),
|
||||
border: Border {
|
||||
radius,
|
||||
..Border::default()
|
||||
},
|
||||
..container::Style::default()
|
||||
}),
|
||||
)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.align_y(Alignment::End),
|
||||
);
|
||||
}
|
||||
|
||||
let fill = interaction::overlay_color(ui, state, Role::OnSurface, Role::Surface);
|
||||
let button = button(stack)
|
||||
.height(Length::Fixed(height))
|
||||
.padding(0)
|
||||
.style(move |_theme, _status| button::Style {
|
||||
background: fill.map(Background::Color),
|
||||
text_color: color,
|
||||
// ContainerShape = CornerNone(tab 本身是直角;圆角只在指示条上)
|
||||
border: Border {
|
||||
radius: ui.radius_dp(ShapeScale::None),
|
||||
..Border::default()
|
||||
},
|
||||
shadow: Shadow::default(),
|
||||
snap: true,
|
||||
});
|
||||
|
||||
match tab.on_press {
|
||||
Some(message) => button.on_press(message).into(),
|
||||
None => button.into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
Reference in new issue
Block a user