D39 五家族并行的「输入类」这一条线。四个组件全部对消息类型泛型(`M: Clone`,不碰 `app::Message`),状态一律走 `components::interaction`,精确尺寸走 `components::layout`, 零尺寸字面量(取不到的集中登记 G16~G20 在 `inputs/geometry.rs`)。 - TextField(12b §9):Filled / Outlined;56 高(无标签 40)、4 圆角、内边距 16/16、 前置图标可视边 12(文本从 48 起)、指示线/描边 静息 1dp → 聚焦与错误 2dp、 错误态线/标签/辅助文本全 Error、禁用 12%/38%(Filled 容器官方 4%,令牌只有 12% → D10)。 标签常浮起(无上浮动画,D12);A1 无 Pressed token,press 按 hover(D11)。 - Search(15 §20):SearchBar(收起)+ SearchView(展开 docked)分开实现。bar 消费 `ComponentMetrics::SEARCH_BAR`,图标可视边 = pad 12 + icon_offset 4 = 16(只做加法, 不写 16/20),聚焦 1px Secondary;view 头部 56 + 1px **Outline** 分隔 + 结果区 240。 - Slider(12b §11):自绘 `advanced::Widget`——轨道 16 full、竖条 4×44(focus/pressed 收窄到 2)、 激活 Primary / 未激活 SecondaryContainer、命中 48、离散停靠点 4(未激活轨在点两侧留 6 空隙)。 位移区间取 handle/2..W−handle/2,所以 0%/100% 竖条不越界。 - Menu / DropdownMenuItem(12b §8):SurfaceContainer + L2 阴影(Elevation 令牌)、项 48、 水平 12、上下 8、选中 SecondaryContainer(指示器内缩 4、圆角 8)、分组分隔线复用 `components::divider`。圆角取令牌表 4(12b 说本项目 12 → D18)。 - gallery/inputs.rs:四组件状态矩阵登记(五态 × 两式 + 空/有值/错误、0%/50%/100%、菜单展开态), 按 `part` 拆成四段(整段比 760 高的窗口还高)。 单文件 ≤400 行:四个组件的单测与 Slider 的纯几何分别拆到 `<name>/tests.rs` 与 `slider/track.rs`。
394 lines
14 KiB
Rust
394 lines
14 KiB
Rust
//! 文本框(`research/12b` §9):Filled / Outlined 两式,**受控组件**。
|
||
//!
|
||
//! ## 参考值(`[源]` = A1,本机 `material3-android:1.4.0` 的 `*TextFieldTokens` + `TextFieldDefaults`)
|
||
//!
|
||
//! | # | 检查项 | 参考值 | 落点 |
|
||
//! |---|---|---|---|
|
||
//! | 9.1 | 高度 | min **56**(带标签);无标签单行本项目 **40** | `FIELD_HEIGHT`(G16)/ `LM.control_height` |
|
||
//! | 9.2 | 最小宽 | **280** → **G21 未施加**(iced 的 `Length` 无 min-width,同按钮 G15) | 文档 |
|
||
//! | 9.3 | 形状 | Outlined **4**;Filled 顶圆角 4、底直角 | `Component::TextField.spec().shape` |
|
||
//! | 9.4 | 描边 / 指示线 | Outlined 静息 1dp `Outline` → hover 1dp `OnSurface` → 聚焦 **2dp `Primary`**;Filled 底指示线静息 `OnSurfaceVariant` | [`Filled`] / [`Palette`] |
|
||
//! | 9.5 | 字级 | 输入/标签 `BodyLarge 16/24`;辅助/错误 `BodySmall 12/16` | `TypeRole` |
|
||
//! | 9.6 | 图标 | 前后 **24**、`OnSurfaceVariant`;图标可视边 **12**、文本从 **48** 起 | `FieldGeometry` |
|
||
//! | 9.7 | 错误态 | 线/标签/辅助文本全 **`Error`**、线宽 **2dp** | [`Field::error`] |
|
||
//! | 9.8 | 禁用态 | Filled 容器 `OnSurface`@**4%**(→ 令牌只有 12%,**登记偏离 D10**);Outlined 描边 `OnSurface`@**12%**;内容 @**38%** | `interaction::*` |
|
||
//!
|
||
//! ## 三处口径说明
|
||
//!
|
||
//! 1. **状态差异落在指示线/描边/标签上,容器底色不叠状态层**——这是 A1 文本字段的口径
|
||
//! (`HoverActiveIndicatorColor` / `FocusActiveIndicatorColor` 等 token),与按钮家族
|
||
//! 「叠一个 8%/10% 的状态层」不同。press 在 A1 里**没有**独立 token,本实现按 hover 处理(登记 D11)。
|
||
//! 2. 标签是「**常浮起**」的两段式:有标签就固定占上面那 16px(`BodySmall`),不做上浮动画
|
||
//! (M3 的动画由 motion mission 统一接,登记 D12)。
|
||
//! 3. 缺省宽度 `Length::Fill`:文本框在页面里按列宽伸展,最小宽 280 无法在 iced 表达(G21)。
|
||
|
||
use iced::widget::text::LineHeight;
|
||
use iced::widget::{column, container, row, text, text_input};
|
||
use iced::{Alignment, Background, Border, Color, Element, Length, Padding, Pixels};
|
||
|
||
use unlucky_design::color::Role;
|
||
use unlucky_design::icons::IconKind;
|
||
use unlucky_design::spacing::Space;
|
||
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::FieldGeometry;
|
||
|
||
/// 两式容器(12b 9.3 / 9.4 的差别:Filled 有容器色 + 底部指示线;Outlined 只有描边)。
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||
pub enum FieldVariant {
|
||
/// 实心:`SurfaceContainerHighest` + 底部指示线,顶圆角 4 底直角。
|
||
Filled,
|
||
/// 描边:透明容器 + 1dp `Outline`,四角 4。
|
||
Outlined,
|
||
}
|
||
|
||
impl FieldVariant {
|
||
/// 两式全序(gallery 的行序)。
|
||
pub const ALL: [FieldVariant; 2] = [FieldVariant::Filled, FieldVariant::Outlined];
|
||
|
||
/// 稳定名字(图例 / 测试信息)。
|
||
pub const fn name(self) -> &'static str {
|
||
match self {
|
||
FieldVariant::Filled => "filled",
|
||
FieldVariant::Outlined => "outlined",
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 一个文本框的装配描述(不持有 `String`:受控组件只借值,契约 §2)。
|
||
#[derive(Debug, Clone)]
|
||
pub struct Field<'a> {
|
||
variant: FieldVariant,
|
||
value: &'a str,
|
||
label: Option<&'a str>,
|
||
placeholder: &'a str,
|
||
supporting: Option<&'a str>,
|
||
leading: Option<IconKind>,
|
||
trailing: Option<IconKind>,
|
||
error: bool,
|
||
state: Option<Interaction>,
|
||
width: Length,
|
||
}
|
||
|
||
impl<'a> Field<'a> {
|
||
/// 一个 Filled 文本框。
|
||
pub fn new(value: &'a str) -> Self {
|
||
Self {
|
||
variant: FieldVariant::Filled,
|
||
value,
|
||
label: None,
|
||
placeholder: "",
|
||
supporting: None,
|
||
leading: None,
|
||
trailing: None,
|
||
error: false,
|
||
state: None,
|
||
width: Length::Fill,
|
||
}
|
||
}
|
||
|
||
/// 选式(Filled / Outlined)。
|
||
pub fn variant(mut self, variant: FieldVariant) -> Self {
|
||
self.variant = variant;
|
||
self
|
||
}
|
||
|
||
/// 浮动标签(`BodySmall`,常浮起,见模块文档第 2 条)。
|
||
pub fn label(mut self, label: &'a str) -> Self {
|
||
self.label = Some(label);
|
||
self
|
||
}
|
||
|
||
/// 占位文案(只在值为空时可见,`OnSurfaceVariant`)。
|
||
pub fn placeholder(mut self, placeholder: &'a str) -> Self {
|
||
self.placeholder = placeholder;
|
||
self
|
||
}
|
||
|
||
/// 辅助/错误说明(字段下方 `BodySmall`)。
|
||
pub fn supporting(mut self, supporting: &'a str) -> Self {
|
||
self.supporting = Some(supporting);
|
||
self
|
||
}
|
||
|
||
/// 前置图标(24,图标可视边 12)。
|
||
pub fn leading(mut self, kind: IconKind) -> Self {
|
||
self.leading = Some(kind);
|
||
self
|
||
}
|
||
|
||
/// 后置图标(24,右侧内边距 16)。
|
||
pub fn trailing(mut self, kind: IconKind) -> Self {
|
||
self.trailing = Some(kind);
|
||
self
|
||
}
|
||
|
||
/// 错误态:线 / 标签 / 辅助文本转 `Error`,线宽 2dp(9.7)。
|
||
pub fn error(mut self) -> Self {
|
||
self.error = true;
|
||
self
|
||
}
|
||
|
||
/// 强制视觉态(gallery 状态矩阵取证;运行期不要调)。
|
||
pub fn state(mut self, state: Interaction) -> Self {
|
||
self.state = Some(state);
|
||
self
|
||
}
|
||
|
||
/// 定宽(默认 `Fill`;gallery 的矩阵格用它钉宽度)。
|
||
pub fn width(mut self, width: Length) -> Self {
|
||
self.width = width;
|
||
self
|
||
}
|
||
}
|
||
|
||
/// 一个字段在某一态下的全套颜色(9.4 / 9.7 / 9.8 的落点)。
|
||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||
struct Palette {
|
||
/// 容器底色;`None` = 透明(Outlined)。
|
||
fill: Option<Color>,
|
||
/// 描边/指示线色。
|
||
line: Color,
|
||
/// 描边/指示线宽(1 或 2)。
|
||
stroke: f32,
|
||
/// 标签(`BodySmall`)。
|
||
label: Color,
|
||
/// 输入文本。
|
||
input: Color,
|
||
/// 前后图标。
|
||
icon: Color,
|
||
/// 辅助文本。
|
||
supporting: Color,
|
||
/// 占位文本。
|
||
placeholder: Color,
|
||
}
|
||
|
||
/// 解析某一态的颜色(**唯一**做角色映射的地方)。
|
||
fn palette(ui: &Ui, variant: FieldVariant, state: Interaction, error: bool) -> Palette {
|
||
let geometry = FieldGeometry::of(ui, true, None);
|
||
|
||
if state.is_disabled() {
|
||
// 9.8:内容一律 OnSurface@38%;Filled 容器叠 12%(官方 4%,令牌无 4% 档 → D10);
|
||
// Outlined 描边正好是官方 12%。
|
||
let container = interaction::disabled_colors(ui, Role::Surface).0;
|
||
let ink = interaction::disabled_content_over_role(
|
||
ui,
|
||
Role::OnSurface,
|
||
matches!(variant, FieldVariant::Filled),
|
||
);
|
||
return Palette {
|
||
fill: match variant {
|
||
FieldVariant::Filled => Some(container),
|
||
FieldVariant::Outlined => None,
|
||
},
|
||
line: match variant {
|
||
FieldVariant::Filled => ink,
|
||
FieldVariant::Outlined => container,
|
||
},
|
||
stroke: geometry.stroke,
|
||
label: ink,
|
||
input: ink,
|
||
icon: ink,
|
||
supporting: ink,
|
||
placeholder: ink,
|
||
};
|
||
}
|
||
|
||
let focused = matches!(state, Interaction::Focused);
|
||
let hovered = matches!(state, Interaction::Hovered | Interaction::Pressed);
|
||
|
||
let (line, stroke) = if error {
|
||
(ui.color(Role::Error), geometry.focus_stroke)
|
||
} else if focused {
|
||
(ui.color(Role::Primary), geometry.focus_stroke)
|
||
} else if hovered {
|
||
(ui.color(Role::OnSurface), geometry.stroke)
|
||
} else {
|
||
(
|
||
match variant {
|
||
FieldVariant::Filled => ui.color(Role::OnSurfaceVariant),
|
||
FieldVariant::Outlined => ui.color(Role::Outline),
|
||
},
|
||
geometry.stroke,
|
||
)
|
||
};
|
||
|
||
Palette {
|
||
fill: match variant {
|
||
FieldVariant::Filled => Some(ui.color(Role::SurfaceContainerHighest)),
|
||
FieldVariant::Outlined => None,
|
||
},
|
||
line,
|
||
stroke,
|
||
label: if error {
|
||
ui.color(Role::Error)
|
||
} else if focused {
|
||
ui.color(Role::Primary)
|
||
} else {
|
||
ui.color(Role::OnSurfaceVariant)
|
||
},
|
||
input: ui.color(Role::OnSurface),
|
||
icon: if error {
|
||
ui.color(Role::Error)
|
||
} else {
|
||
ui.color(Role::OnSurfaceVariant)
|
||
},
|
||
supporting: if error {
|
||
ui.color(Role::Error)
|
||
} else {
|
||
ui.color(Role::OnSurfaceVariant)
|
||
},
|
||
placeholder: ui.color(Role::OnSurfaceVariant),
|
||
}
|
||
}
|
||
|
||
/// 一个文本框(Filled / Outlined 由 [`Field::variant`] 选)。
|
||
///
|
||
/// 受控:值由外部持有,输入经 `on_input` 回传;`state` 强制成 `Disabled` 时输入被摘掉
|
||
/// (`on_input_maybe(None)`),所以禁用字段真的点不动,不只是变灰。
|
||
pub fn text_field<'a, M: Clone + 'a>(
|
||
ui: &'a Ui,
|
||
field: Field<'a>,
|
||
on_input: impl Fn(String) -> M + 'a,
|
||
) -> Element<'a, M> {
|
||
let geometry = FieldGeometry::of(ui, field.label.is_some(), field.leading);
|
||
let state = field.state.unwrap_or(Interaction::Enabled);
|
||
let colors = palette(ui, field.variant, state, field.error);
|
||
|
||
let input_style = ui.style(TypeRole::BodyLarge);
|
||
let label_style = ui.style(TypeRole::BodySmall);
|
||
|
||
let text_input = text_input(field.placeholder, field.value)
|
||
.on_input_maybe(if state.is_disabled() {
|
||
None
|
||
} else {
|
||
Some(on_input)
|
||
})
|
||
.size(input_style.size_px)
|
||
.line_height(LineHeight::Absolute(Pixels(input_style.line_height_px)))
|
||
.font(ui.font_of(input_style))
|
||
.padding(Padding::default())
|
||
.width(Length::Fill)
|
||
.style(move |_theme, _status| text_input::Style {
|
||
background: Background::Color(Color::TRANSPARENT),
|
||
border: Border::default(),
|
||
icon: colors.icon,
|
||
placeholder: colors.placeholder,
|
||
value: colors.input,
|
||
selection: colors.line,
|
||
});
|
||
|
||
let mut content = row![].align_y(Alignment::Center).spacing(geometry.icon_gap);
|
||
if let Some(kind) = field.leading {
|
||
// Q9:图标必须经固定尺寸容器,否则会被 Fill 约束撑满(M68 的 46px 字形)
|
||
content = content.push(layout::fixed(
|
||
geometry.icon,
|
||
geometry.icon,
|
||
icons::view::<M>(kind, colors.icon),
|
||
));
|
||
}
|
||
content = content.push(text_input);
|
||
if let Some(kind) = field.trailing {
|
||
content = content.push(layout::fixed(
|
||
geometry.icon,
|
||
geometry.icon,
|
||
icons::view::<M>(kind, colors.icon),
|
||
));
|
||
}
|
||
|
||
let label = field.label.map(|label| {
|
||
text(label)
|
||
.size(label_style.size_px)
|
||
.line_height(LineHeight::Absolute(Pixels(label_style.line_height_px)))
|
||
.font(ui.font_of(label_style))
|
||
.color(colors.label)
|
||
});
|
||
|
||
// 内容块:带标签 = [标签 16 + 输入 24] 共 40,居中在内容区里;无标签 = 单行 24 居中。
|
||
// **垂直居中交给容器**(Q8 的教训:不要指望子元素自己贴边)。
|
||
let inner: Element<'a, M> = match label {
|
||
Some(label) => column![label, content].width(Length::Fill).into(),
|
||
None => content.into(),
|
||
};
|
||
let content_area = container(inner)
|
||
.width(Length::Fill)
|
||
.height(Length::Fill)
|
||
.align_y(Alignment::Center)
|
||
.padding(Padding {
|
||
top: 0.0,
|
||
right: geometry.padding.right,
|
||
bottom: 0.0,
|
||
left: geometry.padding.left,
|
||
});
|
||
|
||
// Filled:容器色 + **底部指示线**(静息 1dp / 聚焦与错误 2dp);Outlined:透明 + 描边。
|
||
// 指示线占容器底边那 1~2px(内容区因此少 1~2px,官方也是画在 padding-box 底边上的)。
|
||
let body: Element<'a, M> = match field.variant {
|
||
FieldVariant::Filled => {
|
||
let indicator = container(iced::widget::space::horizontal())
|
||
.width(Length::Fill)
|
||
.height(Length::Fixed(colors.stroke))
|
||
.style(move |_theme| container::Style {
|
||
background: Some(Background::Color(colors.line)),
|
||
..container::Style::default()
|
||
});
|
||
column![content_area, indicator].into()
|
||
}
|
||
FieldVariant::Outlined => content_area.into(),
|
||
};
|
||
|
||
let border = Border {
|
||
color: if matches!(field.variant, FieldVariant::Outlined) {
|
||
colors.line
|
||
} else {
|
||
Color::TRANSPARENT
|
||
},
|
||
width: if matches!(field.variant, FieldVariant::Outlined) {
|
||
colors.stroke
|
||
} else {
|
||
0.0
|
||
},
|
||
// Filled 是「顶圆角 4、底直角」= CornerExtraSmallTop;ShapeScale 没有「部分角」,
|
||
// 这里按官方语义手工指定上下角(登记 D13)。
|
||
radius: match field.variant {
|
||
FieldVariant::Filled => iced::border::Radius {
|
||
top_left: ui.radius_dp(geometry.shape).top_left,
|
||
top_right: ui.radius_dp(geometry.shape).top_right,
|
||
bottom_left: 0.0,
|
||
bottom_right: 0.0,
|
||
},
|
||
FieldVariant::Outlined => ui.radius_dp(geometry.shape),
|
||
},
|
||
};
|
||
|
||
let field_el: Element<'a, M> = container(body)
|
||
.width(field.width)
|
||
.height(Length::Fixed(geometry.height))
|
||
.style(move |_theme| container::Style {
|
||
background: colors.fill.map(Background::Color),
|
||
border,
|
||
..container::Style::default()
|
||
})
|
||
.into();
|
||
|
||
match field.supporting {
|
||
Some(supporting) => {
|
||
let note = text(supporting)
|
||
.size(label_style.size_px)
|
||
.line_height(LineHeight::Absolute(Pixels(label_style.line_height_px)))
|
||
.font(ui.font_of(label_style))
|
||
.color(colors.supporting);
|
||
column![field_el, layout::inset(0.0, ui.space(Space::X1), note)].into()
|
||
}
|
||
None => field_el,
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests;
|