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,303 @@
|
||||
//! ListItem(`research/12b` §4):一行 / 两行 / 三行,leading / trailing 槽位,选中态,五态状态层。
|
||||
//!
|
||||
//! ## 参考值(`[源]` = A1,本轮 `javap` 实读本机 AAR 的 `ListTokens` / `ListItemColors`)
|
||||
//!
|
||||
//! | # | 检查项 | 参考值 | 落点 |
|
||||
//! |---|---|---|---|
|
||||
//! | 4.1 | 一行高 | A1 **56** → 本项目 **44**(`LM.row_height`,登记偏离 −12) | [`ListItemGeometry::heights`] |
|
||||
//! | 4.2 | 两 / 三行高 | A1 **72 / 88** → 本项目 **60 / 76**(每多一行 +16,与官方同 delta) | 同上 |
|
||||
//! | 4.3 | 左右内边距 | **16 / 16** | `pad_start` / `pad_end` |
|
||||
//! | 4.4 | leading | 图标 **24** `OnSurfaceVariant` | `IconSize::Toolbar` |
|
||||
//! | 4.5 | 字级 | 主 `BodyLarge 16/24`、辅助 `BodyMedium 14/20`、overline `LabelSmall 11/16` | `TypeRole` |
|
||||
//! | 4.7 | 状态 | hover/focus/pressed **文本与图标色不变**,只叠状态层(8/10/10%) | [`interaction`] |
|
||||
//! | 4.8 | 选中 trailing | 图标 → **`Primary`** | `Role::Primary` |
|
||||
//!
|
||||
//! ## 禁用是**修饰器**,不是构造函数(照 `selection/chip.rs` 复审 P2-1 的结论)
|
||||
//!
|
||||
//! [`ListItem::disabled`] **只清消息**:图标、选中态、状态层全部保留。早期 chip 的
|
||||
//! `Chip::disabled(label)` 构造函数把图标和选中态一起丢了,图上是「另一行」,不是「这一行禁用」。
|
||||
//!
|
||||
//! ## 禁用内容角色是 **`OnSurface`**,不是默认的 `OnSurfaceVariant`
|
||||
//!
|
||||
//! A1 实读(`javap -p -c androidx.compose.material3.tokens.ListTokens`):
|
||||
//!
|
||||
//! ```text
|
||||
//! ListItemDisabledLabelTextColor = OnSurface (+ ListItemDisabledLabelTextOpacity = 0.38f)
|
||||
//! ListItemDisabledLeadingIconColor = OnSurface (+ 0.38f)
|
||||
//! ListItemDisabledTrailingIconColor = OnSurface (+ 0.38f)
|
||||
//! ```
|
||||
//!
|
||||
//! 所以这里必须走 `interaction::disabled_content_over_role(ui, OnSurface, false)`;
|
||||
//! 用默认的 `disabled_colors()`(写死 `OnSurfaceVariant`)会偏浅一档。
|
||||
//!
|
||||
//! ## 选中态:本项目口径(**偏离登记 D-M79-1**)
|
||||
//!
|
||||
//! A1 的 `ListItemColors` **没有** selected 容器色(实读字段:containerColor / headline / leadingIcon /
|
||||
//! overline / supporting / trailingIcon + 三个 disabled),选中只把 trailing 图标换成 `Primary`。
|
||||
//! mission 要求「选中态(SecondaryContainer)」,且本项目的菜单项(M77)与 drawer 项都用
|
||||
//! `SecondaryContainer` 表示「当前项」——三处保持同一语言,故 ListItem 也填 `SecondaryContainer`
|
||||
//! + `OnSecondaryContainer`。**这是实质偏离**:A1 的 ListItem 选中行不换底色。
|
||||
|
||||
use iced::widget::text::LineHeight;
|
||||
use iced::widget::{button, container, space, text};
|
||||
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 crate::icons;
|
||||
use crate::theme::Ui;
|
||||
|
||||
use super::super::interaction::{self, Interaction};
|
||||
use super::super::layout;
|
||||
use super::geometry::{
|
||||
typography_headline, typography_overline, typography_supporting, ListItemGeometry,
|
||||
};
|
||||
|
||||
/// 一个列表项(一行 / 两行 / 三行由槽位决定)。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ListItem<'a, M> {
|
||||
overline: Option<&'a str>,
|
||||
headline: &'a str,
|
||||
supporting: Option<&'a str>,
|
||||
leading: Option<IconKind>,
|
||||
trailing: Option<IconKind>,
|
||||
on_press: Option<M>,
|
||||
selected: bool,
|
||||
state: Option<Interaction>,
|
||||
}
|
||||
|
||||
impl<'a, M> ListItem<'a, M> {
|
||||
/// 一行项(主文本 + 可点消息)。
|
||||
pub fn new(headline: &'a str, on_press: M) -> Self {
|
||||
Self {
|
||||
overline: None,
|
||||
headline,
|
||||
supporting: None,
|
||||
leading: None,
|
||||
trailing: None,
|
||||
on_press: Some(on_press),
|
||||
selected: false,
|
||||
state: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 加一行辅助文本 → **两行**项(4.5:辅助 `BodyMedium`)。
|
||||
pub fn supporting(mut self, supporting: &'a str) -> Self {
|
||||
self.supporting = Some(supporting);
|
||||
self
|
||||
}
|
||||
|
||||
/// 加上方 overline → **三行**项(4.5:overline `LabelSmall`,`OnSurfaceVariant`)。
|
||||
pub fn overline(mut self, overline: &'a str) -> Self {
|
||||
self.overline = Some(overline);
|
||||
self
|
||||
}
|
||||
|
||||
/// 前置槽位(图标 24,`OnSurfaceVariant`)。
|
||||
pub fn leading(mut self, kind: IconKind) -> Self {
|
||||
self.leading = Some(kind);
|
||||
self
|
||||
}
|
||||
|
||||
/// 后置槽位(未选中 `OnSurface`,选中 `Primary`,见 4.8)。
|
||||
pub fn trailing(mut self, kind: IconKind) -> Self {
|
||||
self.trailing = Some(kind);
|
||||
self
|
||||
}
|
||||
|
||||
/// 选中态(`SecondaryContainer` + `OnSecondaryContainer`;偏离 D-M79-1 见模块文档)。
|
||||
pub fn selected(mut self) -> Self {
|
||||
self.selected = true;
|
||||
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
|
||||
}
|
||||
|
||||
/// 行数(1 + 有 overline + 有 supporting)。
|
||||
pub fn lines(&self) -> usize {
|
||||
usize::from(self.overline.is_some()) + 1 + usize::from(self.supporting.is_some())
|
||||
}
|
||||
}
|
||||
|
||||
/// 一项的颜色(按态 + 选中求值;禁用按 A1 的 `OnSurface`@38%)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
struct ItemPalette {
|
||||
fill: Option<Color>,
|
||||
headline: Color,
|
||||
supporting: Color,
|
||||
leading: Color,
|
||||
trailing: Color,
|
||||
}
|
||||
|
||||
fn palette(ui: &Ui, state: Interaction, selected: bool) -> ItemPalette {
|
||||
if state.is_disabled() {
|
||||
// A1:ListItemDisabled*Color = OnSurface × 0.38(无容器时叠在 surface 上)
|
||||
let ink = interaction::disabled_content_over_role(ui, Role::OnSurface, false);
|
||||
return ItemPalette {
|
||||
fill: None,
|
||||
headline: ink,
|
||||
supporting: ink,
|
||||
leading: ink,
|
||||
trailing: ink,
|
||||
};
|
||||
}
|
||||
|
||||
if selected {
|
||||
let fill = interaction::overlay_color(
|
||||
ui,
|
||||
state,
|
||||
Role::OnSecondaryContainer,
|
||||
Role::SecondaryContainer,
|
||||
)
|
||||
.unwrap_or_else(|| ui.color(Role::SecondaryContainer));
|
||||
return ItemPalette {
|
||||
fill: Some(fill),
|
||||
headline: ui.color(Role::OnSecondaryContainer),
|
||||
supporting: ui.color(Role::OnSecondaryContainer),
|
||||
leading: ui.color(Role::OnSecondaryContainer),
|
||||
// 4.8:选中时 trailing 图标是 `Primary`(A1 ListItemSelectedTrailingIconColor)
|
||||
trailing: ui.color(Role::Primary),
|
||||
};
|
||||
}
|
||||
|
||||
ItemPalette {
|
||||
// 状态层叠在**行自己的底**(Surface)上,得到不透明结果(D27)
|
||||
fill: interaction::overlay_color(ui, state, Role::OnSurface, Role::Surface),
|
||||
headline: ui.color(Role::OnSurface),
|
||||
supporting: ui.color(Role::OnSurfaceVariant),
|
||||
leading: ui.color(Role::OnSurfaceVariant),
|
||||
trailing: ui.color(Role::OnSurface),
|
||||
}
|
||||
}
|
||||
|
||||
/// 一行文本(显式行高:三行块的 16+24+20 必须能精确等于 60,靠的就是它)。
|
||||
fn line<'a, M: 'a>(
|
||||
ui: &'a Ui,
|
||||
value: &'a str,
|
||||
role: unlucky_design::typography::TypeRole,
|
||||
color: Color,
|
||||
) -> Element<'a, M> {
|
||||
let style = ui.style(role);
|
||||
text(value)
|
||||
.size(style.size_px)
|
||||
.line_height(LineHeight::Absolute(Pixels(style.line_height_px)))
|
||||
.font(ui.font_of(style))
|
||||
.color(color)
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 列表项(`research/12b` §4)。
|
||||
pub fn list_item<'a, M: Clone + 'a>(ui: &'a Ui, item: ListItem<'a, M>) -> Element<'a, M> {
|
||||
let geometry = ListItemGeometry::of(ui);
|
||||
let lines = item.lines();
|
||||
let height = geometry.height(lines);
|
||||
// 禁用 = 「没有消息」;`state` 是取证强制值,优先级更高(矩阵里画 hover 的禁用项也成立)
|
||||
let forced = item.state.or(if item.on_press.is_none() {
|
||||
Some(Interaction::Disabled)
|
||||
} else {
|
||||
None
|
||||
});
|
||||
let colors = palette(ui, forced.unwrap_or(Interaction::Enabled), item.selected);
|
||||
|
||||
let mut body = iced::widget::Column::new().spacing(0);
|
||||
if let Some(overline) = item.overline {
|
||||
body = body.push(line::<M>(
|
||||
ui,
|
||||
overline,
|
||||
typography_overline(),
|
||||
colors.supporting,
|
||||
));
|
||||
}
|
||||
body = body.push(line::<M>(
|
||||
ui,
|
||||
item.headline,
|
||||
typography_headline(),
|
||||
colors.headline,
|
||||
));
|
||||
if let Some(supporting) = item.supporting {
|
||||
body = body.push(line::<M>(
|
||||
ui,
|
||||
supporting,
|
||||
typography_supporting(),
|
||||
colors.supporting,
|
||||
));
|
||||
}
|
||||
|
||||
let mut content = iced::widget::Row::new()
|
||||
.align_y(Alignment::Center)
|
||||
.spacing(geometry.gap);
|
||||
if let Some(kind) = item.leading {
|
||||
// Q9:图标必须经固定尺寸容器(否则会被按钮的 Fill 约束撑满)
|
||||
content = content.push(layout::fixed(
|
||||
geometry.icon,
|
||||
geometry.icon,
|
||||
icons::view::<M>(kind, colors.leading),
|
||||
));
|
||||
}
|
||||
content = content.push(body);
|
||||
if let Some(kind) = item.trailing {
|
||||
// trailing 必须在行尾:label 与 trailing 之间插一条水平 Fill(`inputs/menu.rs` 的现行写法)
|
||||
content = content.push(space::horizontal());
|
||||
content = content.push(layout::fixed(
|
||||
geometry.icon,
|
||||
geometry.icon,
|
||||
icons::view::<M>(kind, colors.trailing),
|
||||
));
|
||||
}
|
||||
|
||||
let pad_y = geometry.pad_y(ui, lines);
|
||||
// 行必须撑满 item 宽度(Fill 空间要有宽度可撑),垂直靠 align_y 居中(`button` 默认贴左上)
|
||||
let body = container(content)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.align_y(Alignment::Center);
|
||||
|
||||
let selected = item.selected;
|
||||
let style = move |_theme: &iced::Theme, status: button::Status| {
|
||||
let state = forced.unwrap_or_else(|| Interaction::from_status(status));
|
||||
let colors = palette(ui, state, selected);
|
||||
button::Style {
|
||||
background: colors.fill.map(Background::Color),
|
||||
text_color: colors.headline,
|
||||
// 4.x:ListItem 的形状是 `CornerNone`(直角),没有圆角档
|
||||
border: Border {
|
||||
radius: ui.radius_dp(ShapeScale::None),
|
||||
..Border::default()
|
||||
},
|
||||
shadow: Shadow::default(),
|
||||
snap: true,
|
||||
}
|
||||
};
|
||||
|
||||
let button = button(body)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fixed(height))
|
||||
.padding(Padding {
|
||||
top: pad_y,
|
||||
right: geometry.pad_end,
|
||||
bottom: pad_y,
|
||||
left: geometry.pad_start,
|
||||
})
|
||||
.style(style);
|
||||
|
||||
let element: Element<'a, M> = match item.on_press {
|
||||
Some(message) => button.on_press(message).into(),
|
||||
None => button.into(),
|
||||
};
|
||||
element
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
Reference in new issue
Block a user