feat(iced): 反馈类家族四件(Progress / Snackbar / Dialog / EmptyState)
D39 五家族并行的反馈类线(M78 / COMPFEED)。全部对消息类型泛型、不 import app::Message, 数值来自令牌或登记为 G17(令牌层没有反馈类的 ComponentMetrics 与骨架位)。 - Progress:linear(确定态 FillPortion 占比 + 官方 1750ms 双段不定态 keyframes)与 circular(40/4 环,弧长 = 数值×360°,不定态走官方 6000ms 弧长+旋转 keyframes)。 环形用几何 SVG(canvas feature 没开),组件不计时,相位由调用方给。 - Snackbar:48/68 两种高、上限宽 600(A1 ContainerMaxWidth)、12 外边距、贴顶/贴底、 action(InversePrimary)+ 关闭键(InverseOnSurface);**组件不计时**,超时归调用方。 iced 的 Length 没有 min-width ⇒ 344 的最小宽由宿主保证(登记 G17-d,与按钮 G15 同墙)。 - Dialog:scrim(黑@0.32 预合成实色,D27)+ R28 + SurfaceContainerHigh + pad 24 + 标题/正文/动作三区 + 无动作/单动作/双动作;官方 280..560 区间内取 480。 - EmptyState:**官方没有这个组件**,96 图标块/32 图标/HeadlineSmall 全部标 [估]。 - action.rs:Dialog/Snackbar 共用的文字/图标动作(内容角色作参数,状态层仍走 interaction)。 - gallery/feedback.rs:状态矩阵;家族内再切一段 ULL_FEEDBACK_PART(all|progress|snackbar| dialog|empty),开关本身住在 feedback/gallery_parts.rs,不动 gallery/mod.rs。 - feedback/probe.rs:对着真截图量像素(渲染层断言),四类几何各逐项钉住。 - feedback/tests.rs:几何/节奏/角色/scrim 的纯函数断言。 gates: fmt --all --check · clippy -p unlucky-app-iced --all-targets -D warnings · test -p unlucky-app-iced(144 通过 / 0 失败)
This commit is contained in:
1 parent
27f0d2a551
commit
36f58d7904
14 files changed
+2900
-39
No files matched your search
@@ -0,0 +1,235 @@
|
||||
//! Dialog(`research/12b` §7):scrim + 容器 + 标题/正文/动作三区。
|
||||
//!
|
||||
//! ## 官方值(`[源]` = A1,`DialogTokens` + `AlertDialogKt`,`javap` 核对)
|
||||
//!
|
||||
//! | # | 项 | 值 | 落点 |
|
||||
//! |---|---|---|---|
|
||||
//! | 7.1 | 圆角 | **28**(`CornerExtraLarge`) | `Component::Dialog.spec().shape` |
|
||||
//! | 7.2 | 容器 | **`SurfaceContainerHigh`**(`DialogTokens.ContainerColor`) | `Ui::layer(MaterialLayer::Overlay)` |
|
||||
//! | 7.3 | 内距 | 四边 **24**;标题下 **16**;正文下 **24** | [`DialogGeometry`](`Space` 档位) |
|
||||
//! | 7.4 | 宽 | 官方 **280..560**;本项目 **480** | [`DialogGeometry::width`] |
|
||||
//! | 7.5 | 字级 | 标题 `HeadlineSmall 24/32`、正文 `BodyMedium 14/20`、动作 `LabelLarge 14/20 w500` | 组件层取角色 |
|
||||
//! | 7.6 | 动作区 | 文本 **`Primary`**;主轴 **8** / 交叉 **12**;右对齐 | [`DialogGeometry`] |
|
||||
//! | 7.7 | 遮罩 | `scrim` = 纯黑 @ **0.32**(`SCRIM_ALPHA`) | [`scrim`] |
|
||||
//! | 7.8 | 数量 | 一屏 **1 个** modal | 页面责任(组件不计数) |
|
||||
//!
|
||||
//! ## 三种动作形态
|
||||
//!
|
||||
//! [`DialogActions::None`](纯告知)/ [`DialogActions::Single`](「知道了」)/
|
||||
//! [`DialogActions::Double`](「取消」+「删除」,右边是主操作)。双动作的 `dismiss` 在左、
|
||||
//! `confirm` 在右(M3 的阅读序;本项目**不**做「危险操作反色」——官方 Dialog 动作只有 `Primary`)。
|
||||
//!
|
||||
//! ## scrim 为什么是**不透明预合成**
|
||||
//!
|
||||
//! 官方是「纯黑 @0.32 盖在内容上」。D27 要求 CPU 档零半透明(否则 CPU/GPU 两图不一致),
|
||||
//! 所以 [`scrim`] 返回的是 `黑@0.32` **叠在 `MaterialLayer::Canvas` 上**之后的实色:
|
||||
//! 逐像素与「真半透明铺在 surface 上」等价(遮罩下通常就是底色),且两档渲染一致。
|
||||
//! 代价:遮罩下若是图片/卡片,合成目标不是 surface —— 这条登记为偏离 D-2。
|
||||
//!
|
||||
//! ## 不画阴影(偏离 D-1)
|
||||
//!
|
||||
//! 官方 L3 有阴影,但 D27(CPU 禁模糊)+ 令牌层 `ShadowSpec` 仍是 M63 占位值 ⇒ 先不画,
|
||||
//! 层级由 `SurfaceContainerHigh` + scrim 表达。
|
||||
|
||||
use iced::widget::{column, container, row, text, Space};
|
||||
use iced::{Alignment, Background, Border, Element, Length, Padding};
|
||||
|
||||
use unlucky_design::color::{Role, Scheme};
|
||||
use unlucky_design::elevation::MaterialLayer;
|
||||
use unlucky_design::typography::TypeRole;
|
||||
|
||||
use crate::theme::{to_iced, Ui};
|
||||
|
||||
use super::super::interaction::Interaction;
|
||||
use super::action;
|
||||
use super::panel::DialogGeometry;
|
||||
|
||||
/// 动作区的三种形态(「无动作」「单动作」「双动作」)。
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum DialogActions<'a, M> {
|
||||
/// 无动作:只有标题与正文(纯告知型,例如「已是最新版本」)。
|
||||
None,
|
||||
/// 单动作:确认/知道了。
|
||||
Single {
|
||||
/// 动作文案。
|
||||
label: &'a str,
|
||||
/// 按下后的消息。
|
||||
on_press: M,
|
||||
},
|
||||
/// 双动作:左侧 dismiss、右侧 confirm。
|
||||
Double {
|
||||
/// 左(次要)动作。
|
||||
dismiss: (&'a str, M),
|
||||
/// 右(主)动作。
|
||||
confirm: (&'a str, M),
|
||||
},
|
||||
}
|
||||
|
||||
impl<'a, M> DialogActions<'a, M> {
|
||||
/// 是否为空(图例/测试用)。
|
||||
pub fn is_empty(&self) -> bool {
|
||||
matches!(self, DialogActions::None)
|
||||
}
|
||||
}
|
||||
|
||||
/// 一个对话框的装配描述(**不持有任何状态所有权**)。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Dialog<'a, M> {
|
||||
title: &'a str,
|
||||
body: &'a str,
|
||||
actions: DialogActions<'a, M>,
|
||||
state: Option<Interaction>,
|
||||
}
|
||||
|
||||
impl<'a, M> Dialog<'a, M> {
|
||||
/// 标题 + 正文,默认无动作。
|
||||
pub fn new(title: &'a str, body: &'a str) -> Self {
|
||||
Self {
|
||||
title,
|
||||
body,
|
||||
actions: DialogActions::None,
|
||||
state: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 单动作。
|
||||
pub fn confirm(mut self, label: &'a str, on_press: M) -> Self {
|
||||
self.actions = DialogActions::Single { label, on_press };
|
||||
self
|
||||
}
|
||||
|
||||
/// 双动作(左 dismiss / 右 confirm)。
|
||||
pub fn double(mut self, dismiss: (&'a str, M), confirm: (&'a str, M)) -> Self {
|
||||
self.actions = DialogActions::Double { dismiss, confirm };
|
||||
self
|
||||
}
|
||||
|
||||
/// 强制动作件的视觉态(gallery 取证用;运行期不要调)。
|
||||
pub fn state(mut self, state: Interaction) -> Self {
|
||||
self.state = Some(state);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// 遮罩色:`黑 @ SCRIM_ALPHA` 叠在 `MaterialLayer::Canvas` 上之后的**实色**(D27)。
|
||||
pub fn scrim(ui: &Ui) -> iced::Color {
|
||||
let scheme = Scheme::baseline(ui.flavor());
|
||||
let canvas = scheme.get(MaterialLayer::Canvas.surface_role());
|
||||
to_iced(scheme.scrim().over(canvas))
|
||||
}
|
||||
|
||||
/// 把内容放进一块遮罩里(模态的「背后压暗」)。页面把它压在自己的内容上面即可。
|
||||
pub fn scrimmed<'a, M: 'a>(ui: &'a Ui, content: impl Into<Element<'a, M>>) -> Element<'a, M> {
|
||||
let backdrop = scrim(ui);
|
||||
container(content)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.align_x(Alignment::Center)
|
||||
.align_y(Alignment::Center)
|
||||
.style(move |_theme| container::Style {
|
||||
background: Some(Background::Color(backdrop)),
|
||||
..container::Style::default()
|
||||
})
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 画一个对话框(不含遮罩;遮罩用 [`scrimmed`])。
|
||||
pub fn dialog<'a, M: Clone + 'a>(ui: &'a Ui, spec: Dialog<'a, M>) -> Element<'a, M> {
|
||||
let geometry = DialogGeometry::of(ui);
|
||||
let surface = ui.layer(MaterialLayer::Overlay);
|
||||
let radius = iced::border::Radius::new(geometry.radius);
|
||||
|
||||
let title_style = ui.style(TypeRole::HeadlineSmall);
|
||||
let body_style = ui.style(TypeRole::BodyMedium);
|
||||
|
||||
let mut content = column![].width(Length::Fill).spacing(0.0).push(
|
||||
text(spec.title)
|
||||
.size(title_style.size_px)
|
||||
.font(ui.font_of(title_style))
|
||||
.color(ui.color(Role::OnSurface)),
|
||||
);
|
||||
content = content.push(gap(geometry.title_gap)).push(
|
||||
text(spec.body)
|
||||
.size(body_style.size_px)
|
||||
.font(ui.font_of(body_style))
|
||||
.color(ui.color(Role::OnSurfaceVariant)),
|
||||
);
|
||||
|
||||
if !spec.actions.is_empty() {
|
||||
content = content.push(gap(geometry.body_gap)).push(actions_row(
|
||||
ui,
|
||||
&geometry,
|
||||
spec.actions,
|
||||
spec.state,
|
||||
));
|
||||
}
|
||||
|
||||
container(content)
|
||||
.width(Length::Fixed(geometry.width))
|
||||
.max_width(geometry.max_width)
|
||||
.padding(Padding::from(geometry.padding))
|
||||
.style(move |_theme| container::Style {
|
||||
background: Some(Background::Color(surface)),
|
||||
border: Border {
|
||||
radius,
|
||||
..Border::default()
|
||||
},
|
||||
..container::Style::default()
|
||||
})
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 两区之间的固定留白(A1 的「标题下 16 / 正文下 24」)。
|
||||
fn gap<'a, M: 'a>(height: f32) -> Element<'a, M> {
|
||||
Space::new()
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fixed(height))
|
||||
.into()
|
||||
}
|
||||
|
||||
/// 动作区:右对齐,主轴间距 8(A1 `ButtonsMainAxisSpacing`)。
|
||||
fn actions_row<'a, M: Clone + 'a>(
|
||||
ui: &'a Ui,
|
||||
geometry: &DialogGeometry,
|
||||
actions: DialogActions<'a, M>,
|
||||
state: Option<Interaction>,
|
||||
) -> Element<'a, M> {
|
||||
let controls = match actions {
|
||||
DialogActions::None => row![],
|
||||
DialogActions::Single { label, on_press } => row![action::text_action(
|
||||
ui,
|
||||
label,
|
||||
Role::Primary,
|
||||
MaterialLayer::Overlay.surface_role(),
|
||||
Some(on_press),
|
||||
state,
|
||||
)],
|
||||
DialogActions::Double { dismiss, confirm } => row![
|
||||
action::text_action(
|
||||
ui,
|
||||
dismiss.0,
|
||||
Role::Primary,
|
||||
MaterialLayer::Overlay.surface_role(),
|
||||
Some(dismiss.1),
|
||||
state,
|
||||
),
|
||||
action::text_action(
|
||||
ui,
|
||||
confirm.0,
|
||||
Role::Primary,
|
||||
MaterialLayer::Overlay.surface_role(),
|
||||
Some(confirm.1),
|
||||
state,
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
container(
|
||||
controls
|
||||
.spacing(geometry.action_gap)
|
||||
.align_y(Alignment::Center),
|
||||
)
|
||||
.width(Length::Fill)
|
||||
.align_x(Alignment::End)
|
||||
.into()
|
||||
}
|
||||
Reference in new issue
Block a user