Material Design 控件从零实现:令牌、状态机与动效契约
简介这份资源是面向Android及跨平台开发者的Material Design控件代码示例集适合希望快速上手Google设计语言、提升界面一致性与交互表现力的初中级开发者。包内共52个文件以21个xml布局、14个java源码为主辅以5张png素材、3个gradle构建脚本及properties、iml等工程配置压缩包约71KB结构紧凑可直接导入Android Studio运行调试。内容覆盖Button、FloatingActionButton、TextInputLayout、CardView、Snackbar、BottomNavigationView、BottomSheet、ProgressBar、Dialogs、Sliders、Switch、Checkbox、AppBar与Toolbar等常见控件并涉及颜色主题、尺寸与动画配置等属性用法。已有323人学习下载读者可借此理解材料、表面、投影与动画等核心概念掌握XML声明与Java动态创建两种方式快速完成原型开发与界面测试为项目增添现代感与专业性。1. Material Design 设计控件为什么你的组件库总在“形似神不似”上翻车很多团队在落地 Material Design 设计控件时第一反应是去扒一套现成的组件库把按钮、卡片、输入框的样式抄过来跑起来一看——圆角对了、阴影对了、颜色也对了但用起来就是“不对劲”。点击没有水波纹输入框聚焦时标签不会浮动列表滑动没有惯性回弹弹窗出现的位置和动画节奏总差半拍。问题不在视觉层而在于 Material Design 设计控件本质上是一套带状态机和动效契约的交互系统不是一张静态视觉稿。这个标题真正要解决的问题是如何从零搭建或深度定制一套符合 Material 规范的设计控件让它在真实业务里既保持规范一致性又能被工程化地维护和扩展。适合谁看正在做跨平台应用、后台管理系统、移动端 H5 或桌面端 Electron 的前端工程师以及需要统一多端体验的设计系统维护者。接下来我会按“规范拆解 → 最小实现 → 参数调优 → 避坑排查 → 进阶验证”的顺序把这条链路走一遍。2. 拆解 Material Design 控件的三层结构令牌、状态、动效2.1 设计令牌不是变量表而是控件的“基因序列”很多人把设计令牌理解成一组颜色变量和间距常量这是最大的误解。Material Design 3 的令牌体系分三层引用令牌、系统令牌、组件令牌。引用令牌是原始值比如md.ref.palette.primary40系统令牌是语义映射比如md.sys.color.primary组件令牌才是控件直接消费的比如md.comp.filled-button.container.color。为什么不能跳过中间层直接写死颜色因为暗色模式、动态取色、品牌换肤都依赖系统令牌这一层做重映射。如果你在按钮组件里直接写#6750A4那换主题时就得改几十个文件。常见做法是先用 JSON 定义令牌树再用构建脚本生成 CSS 变量或平台常量。{ ref: { palette: { primary40: #6750A4, primary80: #D0BCFF, neutral99: #FFFBFE } }, sys: { color: { primary: {ref.palette.primary40}, onPrimary: {ref.palette.neutral99}, primaryContainer: {ref.palette.primary80} } }, comp: { filledButton: { container: { color: {sys.color.primary} }, label: { color: {sys.color.onPrimary} } } } }这段令牌定义的关键在于引用语法{...}它让构建工具能递归解析依赖。参数说明ref层只放原始色值和尺寸sys层做语义命名comp层绑定到具体控件。改主题时只动ref或syscomp层不动。如果你用 Style Dictionary 或类似工具输出目标可以同时生成 CSS 变量、Android colors.xml、iOS Swift 常量。2.2 状态层为什么 Material 控件必须用状态机而不是 CSS 伪类Material Design 控件有明确的状态定义enabled、disabled、hovered、focused、pressed、dragged。很多实现用:hover、:active这些伪类硬怼结果在触摸设备和键盘导航下状态就乱了。正确做法是把状态作为数据层管理视觉层只负责根据状态渲染。以填充按钮为例状态优先级是disabled pressed focused hovered enabled。这意味着当按钮同时处于 focused 和 hovered 时应该显示 focused 的叠加层而不是两个叠加层都叠上去。如果你用 CSS 伪类顺序和优先级很难精确控制。// 状态优先级映射数值越大优先级越高 const STATE_PRIORITY { disabled: 100, pressed: 80, focused: 60, hovered: 40, enabled: 0 }; function resolveButtonState(states) { // states 是布尔标记对象如 { hovered: true, focused: true } let current enabled; let maxPriority -1; for (const [key, active] of Object.entries(states)) { if (active STATE_PRIORITY[key] maxPriority) { maxPriority STATE_PRIORITY[key]; current key; } } return current; } // 根据状态返回叠加层透明度 function getStateLayerOpacity(state) { const map { hovered: 0.08, focused: 0.12, pressed: 0.12, dragged: 0.16, disabled: 0 }; return map[state] ?? 0; }逻辑说明resolveButtonState接收一个状态标记对象按优先级返回当前应该呈现的状态。getStateLayerOpacity返回该状态对应的叠加层透明度这个值来自 Material 规范中的状态层定义。参数说明hovered 是 0.08focused 和 pressed 都是 0.12dragged 是 0.16。注意 pressed 和 focused 透明度相同但优先级不同pressed 会覆盖 focused 的视觉表现。2.3 动效契约时长和缓动曲线不是随便调的Material Design 对动效有明确的时长和缓动规范。短动画 100-200ms 用于小控件状态切换中动画 250-400ms 用于展开收起长动画 450-600ms 用于页面转场。缓动曲线方面标准曲线是cubic-bezier(0.2, 0, 0, 1)减速曲线是cubic-bezier(0, 0, 0, 1)加速曲线是cubic-bezier(0.3, 0, 1, 1)。我见过太多项目把按钮点击动画设成 500ms结果用户感觉“粘手”。正确做法是状态层透明度变化用 100ms 标准曲线水波纹扩散用 300ms 减速曲线弹窗入场用 250ms 减速曲线退场用 200ms 加速曲线。:root { --md-sys-motion-duration-short: 100ms; --md-sys-motion-duration-medium: 250ms; --md-sys-motion-duration-long: 500ms; --md-sys-motion-easing-standard: cubic-bezier(0.2, 0, 0, 1); --md-sys-motion-easing-decelerate: cubic-bezier(0, 0, 0, 1); --md-sys-motion-easing-accelerate: cubic-bezier(0.3, 0, 1, 1); } .md-filled-button { transition: background-color var(--md-sys-motion-duration-short) var(--md-sys-motion-easing-standard); } .md-dialog-enter { animation: dialog-in var(--md-sys-motion-duration-medium) var(--md-sys-motion-easing-decelerate); } .md-dialog-exit { animation: dialog-out 200ms var(--md-sys-motion-easing-accelerate); }参数说明--md-sys-motion-duration-short用于状态切换--md-sys-motion-duration-medium用于弹窗和展开面板--md-sys-motion-duration-long用于页面级转场。缓动曲线变量直接对应规范中的标准、减速、加速三种。注意退场动画时长通常比入场短因为用户已经决定关闭不需要再等。3. 从零实现一个符合规范的填充按钮控件3.1 结构层按钮的 DOM 应该长什么样Material 填充按钮的推荐结构是外层容器负责布局和状态层内层标签负责文字和图标。不要用button直接包文字因为状态层需要独立于内容做透明度叠加。button classmd-filled-button>.md-filled-button { position: relative; display: inline-flex; align-items: center; justify-content: center; height: 40px; padding: 0 24px; border: none; border-radius: 20px; background-color: var(--md-comp-filled-button-container-color); color: var(--md-comp-filled-button-label-color); font-family: var(--md-sys-typescale-label-large-font); font-size: 14px; font-weight: 500; letter-spacing: 0.1px; cursor: pointer; overflow: hidden; transition: box-shadow var(--md-sys-motion-duration-short) var(--md-sys-motion-easing-standard); } .md-filled-button__state-layer { position: absolute; inset: 0; background-color: var(--md-comp-filled-button-label-color); opacity: 0; pointer-events: none; transition: opacity var(--md-sys-motion-duration-short) var(--md-sys-motion-easing-standard); } .md-filled-button[data-statehovered] .md-filled-button__state-layer { opacity: 0.08; } .md-filled-button[data-statefocused] .md-filled-button__state-layer { opacity: 0.12; } .md-filled-button[data-statepressed] .md-filled-button__state-layer { opacity: 0.12; } .md-filled-button[data-statedisabled] { background-color: var(--md-sys-color-on-surface-12); color: var(--md-sys-color-on-surface-38); cursor: not-allowed; box-shadow: none; }参数说明高度 40px 是 Material 3 的默认按钮高度圆角 20px 是高度的一半形成完全圆角。--md-comp-filled-button-container-color来自组件令牌层。状态层颜色用标签色而不是容器色这是规范要求。disabled 状态下容器色用 on-surface 的 12% 透明度文字用 38% 透明度。3.3 交互层水波纹和键盘导航怎么接水波纹是 Material 控件的标志性交互但很多实现只做了鼠标点击忘了键盘回车和空格。正确做法是监听pointerdown和keydown统一触发水波纹。class MdFilledButton extends HTMLElement { connectedCallback() { this.addEventListener(pointerdown, this._onPointerDown); this.addEventListener(keydown, this._onKeyDown); this.addEventListener(pointerenter, () this._setState(hovered, true)); this.addEventListener(pointerleave, () this._setState(hovered, false)); this.addEventListener(focus, () this._setState(focused, true)); this.addEventListener(blur, () this._setState(focused, false)); } _onPointerDown(e) { if (this.disabled) return; this._setState(pressed, true); this._createRipple(e.clientX, e.clientY); const up () { this._setState(pressed, false); window.removeEventListener(pointerup, up); }; window.addEventListener(pointerup, up); } _onKeyDown(e) { if (e.key Enter || e.key ) { e.preventDefault(); this._setState(pressed, true); const rect this.getBoundingClientRect(); this._createRipple(rect.left rect.width / 2, rect.top rect.height / 2); const up () { this._setState(pressed, false); window.removeEventListener(keyup, up); }; window.addEventListener(keyup, up); } } _createRipple(x, y) { const rect this.getBoundingClientRect(); const ripple document.createElement(span); ripple.className md-ripple; const size Math.max(rect.width, rect.height) * 2; ripple.style.width ripple.style.height ${size}px; ripple.style.left ${x - rect.left - size / 2}px; ripple.style.top ${y - rect.top - size / 2}px; this.appendChild(ripple); ripple.addEventListener(animationend, () ripple.remove()); } _setState(state, active) { this._states this._states || {}; this._states[state] active; this.dataset.state resolveButtonState(this._states); } }逻辑说明_onPointerDown处理鼠标和触摸_onKeyDown处理键盘。水波纹的圆心根据点击位置或按钮中心计算尺寸取按钮最大边长的两倍确保覆盖全按钮。_setState更新状态标记后调用resolveButtonState重新计算当前状态。参数说明水波纹动画时长建议 300ms缓动用减速曲线透明度从 0.12 渐变到 0。4. 控件参数调优密度、触达区域和响应式断点4.1 密度等级怎么选默认、舒适、紧凑Material Design 支持三种密度默认0、舒适-1、紧凑-2。密度影响控件高度、内边距和字体大小。后台管理系统通常用紧凑密度移动端用默认密度触屏设备用舒适密度。密度等级按钮高度列表项高度图标尺寸适用场景舒适 -148px64px24px触屏、车载默认 040px56px24px移动端、通用紧凑 -232px48px20px后台、数据密集实现方式是在根容器上设置>[data-densitycompact] { --md-comp-filled-button-height: 32px; --md-comp-filled-button-padding: 0 16px; --md-sys-typescale-label-large-size: 13px; } [data-densitydefault] { --md-comp-filled-button-height: 40px; --md-comp-filled-button-padding: 0 24px; --md-sys-typescale-label-large-size: 14px; } [data-densitycomfortable] { --md-comp-filled-button-height: 48px; --md-comp-filled-button-padding: 0 28px; --md-sys-typescale-label-large-size: 15px; }参数说明紧凑密度下按钮高度降到 32px内边距缩到 16px字号降到 13px。注意密度变化时圆角也要跟着变通常是高度的一半。如果你用 CSS 变量做令牌密度切换只需要改根容器属性所有控件自动响应。4.2 触达区域视觉尺寸和交互尺寸要分开Material 规范要求最小触达区域是 48x48px但视觉上按钮可能只有 40px 高。很多实现直接把按钮撑到 48px结果视觉上显得臃肿。正确做法是用伪元素扩展触达区域。.md-filled-button { position: relative; } .md-filled-button::after { content: ; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); min-width: 48px; min-height: 48px; width: 100%; height: 100%; }逻辑说明::after伪元素不参与视觉渲染但会扩大点击热区。min-width和min-height确保即使按钮视觉尺寸小于 48px触达区域也不小于 48px。参数说明如果按钮本身已经大于 48px伪元素尺寸跟随按钮尺寸不会额外扩大。4.3 响应式断点控件尺寸什么时候该变Material Design 的响应式断点分五档compact0-599px、medium600-839px、expanded840-1199px、large1200-1599px、extra-large1600px。控件尺寸通常只在 compact 和 medium 之间切换expanded 以上保持 medium 尺寸。media (max-width: 599px) { :root { --md-comp-filled-button-height: 40px; --md-comp-filled-button-padding: 0 16px; } } media (min-width: 600px) { :root { --md-comp-filled-button-height: 40px; --md-comp-filled-button-padding: 0 24px; } }参数说明compact 断点下内边距缩小到 16px因为屏幕窄按钮需要更紧凑。medium 以上恢复到 24px。注意高度不变因为 40px 是触屏友好的最小高度。如果你做的是桌面端应用expanded 以上可以把高度降到 36px但触达区域仍要保持 48px。5. 避坑排查Material Design 控件落地时最容易翻车的五个点5.1 水波纹在快速点击时叠成一团现象用户快速点击按钮多次水波纹动画还没结束就叠了四五个视觉上像一团白斑。原因每次pointerdown都创建新水波纹没有清理旧动画。解决在创建新水波纹前先移除当前按钮内所有未结束的水波纹元素。_createRipple(x, y) { this.querySelectorAll(.md-ripple).forEach(r r.remove()); // ... 创建新水波纹 }5.2 暗色模式下状态层颜色反了现象暗色模式下按钮 hover 时状态层是黑色几乎看不见。原因状态层颜色用了--md-comp-filled-button-container-color而不是--md-comp-filled-button-label-color。解决状态层颜色必须用标签色因为标签色在暗色模式下是浅色叠加后才可见。5.3 键盘 Tab 聚焦时没有焦点环现象用键盘 Tab 键切换焦点按钮上没有任何视觉提示。原因只监听了focus事件更新状态但 CSS 里没有为focused状态定义轮廓或状态层。解决在focused状态下同时显示状态层和 2px 的焦点环。.md-filled-button[data-statefocused] { outline: 2px solid var(--md-sys-color-primary); outline-offset: 2px; }5.4 禁用按钮仍然响应点击事件现象按钮设了disabled属性但点击仍然触发水波纹和回调。原因pointerdown监听器没有检查disabled状态。解决在事件处理函数开头判断this.disabled或this.dataset.state disabled直接 return。5.5 令牌构建后 CSS 变量名对不上现象组件里写var(--md-comp-filled-button-container-color)但构建产物里生成的是--md-comp-filled-button-container。原因令牌命名规则不统一有的用点号分隔有的用短横线。解决在构建脚本里统一转换规则点号转短横线驼峰转短横线全部小写。function tokenToCssVar(path) { return -- path .replace(/\./g, -) .replace(/([a-z])([A-Z])/g, $1-$2) .toLowerCase(); }6. 进阶验证用视觉回归和交互测试锁住控件行为控件库最怕的是“改一个样式崩十个页面”。我一般会在令牌层和组件层之间加两道验证视觉回归测试和交互状态测试。视觉回归用截图对比交互测试用状态机断言。视觉回归的常见做法是给每个控件在每种状态下截图存为基准图后续改动后重新截图做像素对比。阈值一般设 0.1% 到 0.5%超过就报警。交互测试则直接断言状态机输出。// 交互状态测试示例 describe(MdFilledButton 状态机, () { it(hovered 和 focused 同时为真时应返回 focused, () { const state resolveButtonState({ hovered: true, focused: true }); expect(state).toBe(focused); }); it(disabled 优先级最高, () { const state resolveButtonState({ disabled: true, pressed: true, hovered: true }); expect(state).toBe(disabled); }); it(pressed 应覆盖 focused, () { const state resolveButtonState({ pressed: true, focused: true }); expect(state).toBe(pressed); }); });参数说明resolveButtonState是纯函数测试不需要渲染 DOM跑得飞快。视觉回归测试建议用 Playwright 或 Puppeteer每个控件截 5 种状态跑一轮大概 30 秒。如果你做的是跨平台控件库还要加一层平台一致性测试确保 Web、iOS、Android 三端的令牌解析结果一致。我自己的习惯是每次改令牌定义先跑交互测试再跑视觉回归最后人工抽查暗色模式和紧凑密度。这三步走完基本不会出现“改一个按钮崩一个页面”的血泪事故。希望帮到你。本文还有配套的精品资源点击获取