Unity UI字体颜色渐变实现:从TextMeshPro顶点操作到Shader方案 1. 项目概述为什么UI字体颜色渐变如此重要在Unity里做UI尤其是做游戏或者需要视觉冲击力的应用字体颜色渐变绝对是一个能瞬间提升界面质感和动态表现力的“小魔法”。你可能见过很多游戏的标题、按钮上的高亮文字或者一些数据变化时的动态提示它们往往不是单一颜色而是从一种颜色平滑过渡到另一种颜色甚至多种颜色。这种效果我们称之为字体颜色渐变。为什么我要专门花时间研究这个因为在实际项目中我发现Unity自带的Text组件包括UGUI的Text和更现代的TextMeshPro虽然功能强大但原生并不直接支持每个字符或整段文字的逐顶点颜色渐变。你可能会说用Shader啊没错Shader是终极解决方案但对于很多UI设计师、策划或者刚入行的开发者来说Shader的门槛有点高调试也不够直观。我们需要的是一种更“接地气”、更易集成到现有UI工作流中的方法。这个项目的核心就是探索在Unity UI框架下不依赖复杂Shader编写实现灵活、高性能字体颜色渐变的几种实用方案。无论是简单的左右、上下渐变还是更复杂的径向、对角线渐变甚至是随着时间动态变化的流光效果我们都能找到对应的实现路径。这对于打造独特的品牌视觉、增强关键信息的引导性、提升用户的沉浸感都至关重要。接下来我会结合我踩过的坑和总结的经验带你从原理到实践彻底搞懂Unity UI的字体颜色渐变。2. 核心方案选型与原理拆解实现字体颜色渐变本质上是对文字网格的顶点颜色进行操作。Unity中渲染的文字无论是UGUI Text还是TextMeshPro最终都是由一个个带顶点信息的四边形对于TMP可能是更复杂的网格组成的。颜色信息就存储在顶点数据中。我们的目标就是控制这些顶点的颜色值让它们按照我们设定的规则渐变方向、颜色序列进行插值。2.1 方案一使用TextMeshPro的顶点色与材质球这是目前最推荐、最灵活的方案。TextMeshPro简称TMP是Unity官方推荐的文本解决方案它相比老旧的UGUI Text在渲染质量、功能丰富度和性能上都有巨大优势。TMP的每个字符本质上是一个独立的子网格我们可以通过脚本精确地访问和修改每个字符的顶点颜色。核心原理TMP的TMP_Text组件有一个mesh属性我们可以获取到文字的网格信息然后直接修改其顶点颜色数组colors32。通过计算每个顶点在文本矩形区域内的归一化位置比如从左到右是0到1我们可以根据这个位置值在两个或多个颜色之间进行插值Lerp然后将计算好的颜色赋值给对应的顶点。优势功能强大可以实现任意方向的线性渐变水平、垂直、对角线、径向渐变甚至每个字符独立渐变。性能可控修改顶点颜色是在CPU端完成然后上传至网格。对于静态或变化不频繁的文本性能开销很小。我们可以选择在文本内容改变时OnEnable,OnTextChanged或每帧用于动态效果进行更新。与UI系统完美融合TMP本身就是UGUI体系的一部分支持RectTransform、Canvas Renderer布局和交互行为与普通UI一致。支持富文本我们可以结合TMP的富文本标签在部分文本上应用渐变实现更复杂的设计。劣势需要导入TextMeshPro资源包Unity通常已内置或可轻松从Package Manager安装。对于超大量、频繁更新的文本每帧计算顶点颜色可能成为性能瓶颈需优化。2.2 方案二编写自定义Shader这是最底层、最自由的方案。我们可以为TMP或UGUI Text编写一个自定义的Shader在GPU端进行颜色插值计算。核心原理在顶点着色器或片元着色器中接收顶点的UV坐标或自定义的顶点数据如位置信息将其映射到渐变方向上然后根据映射值对预设的几个颜色纹理Gradient Texture进行采样或者直接在Shader中进行颜色插值计算。优势极致性能所有计算在GPU上完成效率极高非常适合大量、动态的渐变文字。效果无限可以实现非常复杂的渐变效果如基于噪声的渐变、随时间变化的流动渐变等只受限于你的Shader编程能力。一次配置多处使用创建一个材质球可以应用到所有需要相同渐变效果的文本上。劣势门槛高需要具备ShaderLab和HLSL/GLSL的编程知识。调试不便效果调整需要修改Shader代码并重新编译不如在C#脚本中调整参数直观。UI合批可能受影响使用自定义Shader可能会打断UI的自动合批如果屏幕上此类文本过多需注意Draw Call的增加。2.3 方案三多文本叠加模拟不推荐这是一种取巧的方法创建两个或多个完全相同的文本对象设置不同的纯色然后通过改变它们的Alpha通道或使用简单的混合模式叠加出渐变感。或者将文本拆分成多个部分分别着色。核心原理利用视觉欺骗。例如要实现左右渐变可以左边放一个从红到透明的文本右边放一个从透明到蓝的文本重叠在一起。优势无需编程仅靠场景搭建和动画即可实现简单效果。劣势效果粗糙渐变边缘生硬无法实现平滑过渡。维护噩梦文本内容变更时需要同步修改所有叠加的文本对象。性能浪费渲染多个文本对象增加了Overdraw和Draw Call。灵活性极差几乎无法实现复杂的渐变模式。实操心得对于绝大多数项目方案一TMP顶点色修改是平衡了效果、性能和易用性的最佳选择。方案二Shader适合有特定高性能需求或追求极致定制效果的中高级团队。方案三应尽量避免在生产环境中使用。因此下文将重点深入讲解方案一的实现细节与优化技巧。3. 基于TextMeshPro的顶点色渐变实现详解我们将实现一个通用的TextColorGradient组件可以挂载在任何TMP_Text对象上实现灵活的渐变效果。3.1 组件设计与属性定义首先我们创建一个C#脚本TextColorGradient.cs。我们需要定义一些关键属性来控制渐变。using TMPro; using UnityEngine; [RequireComponent(typeof(TMP_Text))] [ExecuteAlways] // 在编辑模式下也执行方便预览 public class TextColorGradient : MonoBehaviour { public enum GradientDirection { Horizontal, // 从左到右 Vertical, // 从上到下 Diagonal, // 从左上到右下 Radial, // 从中心向四周 } [SerializeField] private GradientDirection _direction GradientDirection.Horizontal; [SerializeField] private Gradient _colorGradient new Gradient(); // 使用Unity内置的Gradient类定义颜色序列 [SerializeField] private bool _updatePerFrame false; // 是否每帧更新用于动态效果 [SerializeField] private float _gradientScale 1.0f; // 渐变缩放影响颜色过渡的“速度” [SerializeField] private Vector2 _gradientOffset Vector2.zero; // 渐变偏移 private TMP_Text _tmpText; private bool _isDirty true; // 标记是否需要更新顶点色 }关键属性解析Gradient _colorGradient这是Unity引擎内置的Gradient类它允许我们在Inspector窗口中通过一个直观的颜色条来定义多个颜色关键帧及其插值模式线性或固定。这比我们自己定义Color[]和float[]要方便得多。ExecuteAlways属性这个属性非常重要。它让脚本在编辑模式非运行模式下也能执行Update和OnRenderObject等方法。这样我们在Scene视图或Inspector中调整渐变参数时能立即看到效果极大提升开发效率。_isDirty标志这是一个优化手段。我们不需要每帧都去计算顶点颜色。只有当文本内容改变、或者我们的渐变参数方向、颜色、偏移等被修改时才标记为“脏”在下一帧渲染前更新。这能节省大量不必要的计算。3.2 核心算法顶点颜色计算与赋值核心逻辑在UpdateVertexColors方法中。我们需要遍历TMP文本信息中的所有字符获取每个字符的四个顶点然后根据顶点位置计算其在渐变方向上的权重最后用这个权重从Gradient中取样颜色。private void UpdateVertexColors() { if (_tmpText null) return; _tmpText.ForceMeshUpdate(); // 强制TMP更新网格确保顶点数据是最新的 TMP_TextInfo textInfo _tmpText.textInfo; // 如果文本内容为空直接返回 if (textInfo.characterCount 0) return; // 获取整个文本区域的边界框用于归一化计算 Bounds bounds _tmpText.bounds; Vector3 boundsMin bounds.min; Vector3 boundsSize bounds.size; // 防止除零错误 if (boundsSize.x 0.001f) boundsSize.x 0.001f; if (boundsSize.y 0.001f) boundsSize.y 0.001f; // 遍历每个字符 for (int i 0; i textInfo.characterCount; i) { TMP_CharacterInfo charInfo textInfo.characterInfo[i]; // 跳过不可见字符如空格 if (!charInfo.isVisible) continue; // 获取这个字符的顶点索引 int vertexIndex charInfo.vertexIndex; int materialIndex charInfo.materialReferenceIndex; // 获取这个字符的四个顶点坐标局部空间 Vector3[] vertices textInfo.meshInfo[materialIndex].vertices; // 计算这个字符四个顶点的平均位置世界空间用于径向渐变等 Vector3 charCenter Vector3.zero; for (int j 0; j 4; j) { charCenter vertices[vertexIndex j]; } charCenter / 4; Vector3 worldCharCenter _tmpText.transform.TransformPoint(charCenter); // 处理这个字符的四个顶点 Color32[] vertexColors textInfo.meshInfo[materialIndex].colors32; for (int j 0; j 4; j) { int currentVertexIndex vertexIndex j; Vector3 vertexWorldPos _tmpText.transform.TransformPoint(vertices[currentVertexIndex]); // 计算该顶点在渐变方向上的权重 (0 到 1) float t CalculateGradientWeight(vertexWorldPos, worldCharCenter, boundsMin, boundsSize); // 从Gradient中根据权重取样颜色 Color32 gradientColor _colorGradient.Evaluate(t); // 赋值给顶点颜色 vertexColors[currentVertexIndex] gradientColor; } } // 将修改后的颜色数组推送回所有子网格 for (int i 0; i textInfo.meshInfo.Length; i) { Mesh mesh textInfo.meshInfo[i].mesh; mesh.colors32 textInfo.meshInfo[i].colors32; _tmpText.UpdateGeometry(textInfo.meshInfo[i].mesh, i); } _isDirty false; } private float CalculateGradientWeight(Vector3 vertexWorldPos, Vector3 charCenter, Vector3 boundsMin, Vector3 boundsSize) { Vector3 localPos vertexWorldPos - boundsMin; float normalizedX (localPos.x / boundsSize.x) * _gradientScale _gradientOffset.x; float normalizedY (localPos.y / boundsSize.y) * _gradientScale _gradientOffset.y; float t 0f; switch (_direction) { case GradientDirection.Horizontal: t Mathf.Clamp01(normalizedX); break; case GradientDirection.Vertical: t Mathf.Clamp01(normalizedY); break; case GradientDirection.Diagonal: t Mathf.Clamp01((normalizedX normalizedY) * 0.5f); break; case GradientDirection.Radial: // 计算顶点到字符中心的距离并归一化到0-1 Vector2 center new Vector2(charCenter.x, charCenter.y); Vector2 vert new Vector2(vertexWorldPos.x, vertexWorldPos.y); float distance Vector2.Distance(center, vert); // 使用字符对角线长度的一半作为最大半径进行归一化这是一个近似更精确可用boundsSize float maxRadius Mathf.Sqrt(boundsSize.x * boundsSize.x boundsSize.y * boundsSize.y) * 0.5f * _gradientScale; t Mathf.Clamp01(distance / maxRadius); // 径向渐变通常是从中心0到边缘1但Gradient是从左到右所以有时需要反转或调整颜色键 break; } return t; }算法细节与避坑指南ForceMeshUpdate的调用在修改顶点数据前必须调用_tmpText.ForceMeshUpdate()。因为TMP的网格生成可能是延迟的不调用此方法textInfo中的顶点数据可能是过时的导致你修改了“上一帧”的网格而看不到效果。顶点坐标空间TMP的textInfo.meshInfo[i].vertices中存储的顶点坐标是局部空间的相对于文本RectTransform的锚点。为了进行基于世界空间方向如固定的水平渐变的计算我们需要用TransformPoint将其转换到世界空间。如果希望渐变跟随文本旋转或缩放也可以选择在局部空间计算这取决于你的需求。边界计算我们使用_tmpText.bounds来获取文本的整体包围盒。注意这个bounds是世界空间的。对于多行文本、有换行的情况这个包围盒是准确的。归一化时使用它可以确保渐变是基于整个文本块的范围而不是画布或屏幕。径向渐变的半径径向渐变中最大半径的计算是个难点。示例代码中使用了对角线长度的一半这是一个通用性较好的近似值能确保颜色过渡到文本角落。你也可以根据需求固定一个半径值或者使用boundsSize的最大值。颜色赋值TMP使用的是Color3232位颜色每个通道0-255数组而Gradient.Evaluate返回的是Color浮点数颜色0-1。Unity会自动进行转换但直接使用Color32可以避免额外的转换开销。我们这里为了代码清晰直接赋值。3.3 驱动更新与性能优化有了核心的更新方法我们需要在合适的时机调用它。private void Awake() { _tmpText GetComponentTMP_Text(); if (_tmpText null) { Debug.LogError(TextColorGradient requires a TMP_Text component.); enabled false; return; } // 监听文本变化事件 TMPro_EventManager.TEXT_CHANGED_EVENT.Add(OnTextChanged); } private void OnDestroy() { TMPro_EventManager.TEXT_CHANGED_EVENT.Remove(OnTextChanged); } private void OnTextChanged(Object obj) { // 当任何TMP文本改变时都会触发需要检查是否是当前这个 if (obj _tmpText) { _isDirty true; } } private void OnValidate() { // 当Inspector中的属性值发生变化时编辑模式下标记为脏 _isDirty true; } private void Update() { // 如果设置为每帧更新则始终标记为脏 if (_updatePerFrame) { _isDirty true; } // 如果需要更新则执行顶点颜色更新 if (_isDirty) { UpdateVertexColors(); } } private void LateUpdate() { // 另一种策略在LateUpdate中更新确保所有布局和内容变更已完成 // if (_isDirty) UpdateVertexColors(); }更新策略解析事件驱动通过订阅TMPro_EventManager.TEXT_CHANGED_EVENT我们可以在文本内容被代码如_tmpText.text “...”修改时立即得到通知并标记更新。这是最高效的方式。编辑器实时预览OnValidate方法只在编辑模式下当脚本的序列化字段即Inspector中显示的字段被修改时调用。这让我们在Scene视图中调整颜色、方向时能实时看到反馈。按需更新 vs 每帧更新_updatePerFrame开关给了我们灵活性。对于静态的标题文字我们关闭它仅在文本改变或参数改变时更新一次。对于需要动态流动的渐变效果比如颜色随时间移动我们打开它并在Update中不断修改_gradientOffset等参数就能实现动画。更新时机选择在Update还是LateUpdate中执行UpdateVertexColors()取决于你的项目。如果文本内容在同一帧的Update中被其他逻辑修改那么在LateUpdate中更新能确保拿到最终状态。通常Update就够了。注意事项TMPro_EventManager.TEXT_CHANGED_EVENT是一个全局静态事件任何TMP文本变化都会触发。因此在OnTextChanged回调中一定要判断obj是否是当前组件所属的_tmpText否则一个场景里很多带此组件的文本一个变会导致全部重新计算造成性能浪费。另外记得在OnDestroy中取消订阅防止内存泄漏。4. 高级效果扩展与实战技巧基础的水平垂直渐变满足大部分需求但我们可以做得更多。4.1 实现动态流光效果流光效果的本质是让渐变图案沿着某个方向移动。我们只需要让_gradientOffset随时间变化即可。[Header(动态效果设置)] [SerializeField] private bool _enableFlowEffect false; [SerializeField] private GradientDirection _flowDirection GradientDirection.Horizontal; [SerializeField] private float _flowSpeed 1.0f; private void UpdateDynamicEffect() { if (!_enableFlowEffect) return; float offsetDelta Time.deltaTime * _flowSpeed; switch (_flowDirection) { case GradientDirection.Horizontal: _gradientOffset.x offsetDelta; // 使偏移量在[0, 1)区间循环实现无缝流动 _gradientOffset.x Mathf.Repeat(_gradientOffset.x, 1.0f); break; case GradientDirection.Vertical: _gradientOffset.y offsetDelta; _gradientOffset.y Mathf.Repeat(_gradientOffset.y, 1.0f); break; // 对角线流动可以同时改变x和y } _isDirty true; // 标记需要更新顶点色 } // 在Update中调用 private void Update() { UpdateDynamicEffect(); // 先更新动态参数 if (_updatePerFrame || _isDirty) { UpdateVertexColors(); } }技巧使用Mathf.Repeat而不是%取模是因为Repeat对负数也有效能确保偏移量始终在一个周期内循环实现平滑无缝的流动。4.2 与TMP富文本结合实现局部渐变有时我们不想整个文本块都是渐变而是希望其中几个关键词是渐变色。我们可以结合TMP的富文本功能。思路是在文本中插入自定义标签例如gradient#FF0000,#00FF00渐变文字/gradient。然后我们需要扩展TMP的解析流程。创建自定义标签解析器较复杂需继承TMP_TextProcessingStack并注册这涉及到TMP底层对于简单需求可能过于繁重。更实用的取巧方法使用多个TextColorGradient组件。将需要不同渐变效果的文字拆分成不同的TMP Text对象分别挂载组件并设置参数然后通过布局组Horizontal Layout Group等将它们排列在一起。虽然增加了GameObject数量但实现简单逻辑清晰。脚本控制法在代码中我们可以根据字符索引来分段应用不同的渐变逻辑。在UpdateVertexColors的循环中加入对字符索引i的判断。例如前5个字符用一种渐变5-10用另一种。这需要你在业务逻辑中维护好分段信息。// 在UpdateVertexColors的字符循环内加入分段逻辑 public ListGradientSegment gradientSegments; // 自定义类包含startIndex, endIndex, gradient for (int i 0; i textInfo.characterCount; i) { // ... 获取charInfo ... Gradient targetGradient _colorGradient; // 默认 foreach(var segment in gradientSegments) { if (i segment.startIndex i segment.endIndex) { targetGradient segment.gradient; break; } } // 使用targetGradient.Evaluate(t)来计算颜色... }4.3 性能优化关键点当屏幕上存在大量渐变文本时性能优化至关重要。减少不必要的更新这是最重要的优化。确保_updatePerFrame只对需要动态效果的文本开启。静态文本务必依赖OnTextChanged和OnValidate来驱动更新。合并更新请求如果一帧内文本被多次修改可能会触发多次OnTextChanged。可以设置一个延迟更新机制例如用Coroutine在帧末统一更新一次或者使用_isDirty标志确保一帧只计算一次。避免在Update中调用ForceMeshUpdateForceMeshUpdate()是一个比较耗时的操作它会重新生成文本网格。对于内容不变的动态渐变仅颜色变我们其实不需要每帧都调用它。我们可以缓存textInfo只更新colors32数组。但要注意如果文本内容或样式字体大小、加粗等真的变了必须调用ForceMeshUpdate。对象池与复用对于频繁创建销毁的UI元素如飘字提示考虑使用对象池复用带有TextColorGradient组件的TMP文本对象避免组件反复的Awake、OnEnable开销。Shader方案作为终极优化如果经过Profiler检测CPU端顶点色修改仍然是瓶颈例如有上百个动态渐变文本那么就应该考虑将逻辑迁移到Shader中。此时我们的C#脚本可以退化为只负责向Shader传递渐变参数方向、颜色数组、时间等所有插值计算在GPU完成。5. 常见问题排查与解决方案实录在实际使用中你肯定会遇到一些奇怪的问题。下面是我总结的几个典型坑位和填坑方法。5.1 问题渐变效果不显示或显示不全可能原因及排查步骤检查TMP组件确保挂载脚本的GameObject上确实有TMP_Text组件并且不是UnityEngine.UI.Text。脚本开头的[RequireComponent(typeof(TMP_Text))]会帮你自动添加但有时可能被误删。检查材质球TMP默认使用的材质球可能不支持顶点色。确保你的TMP文本使用的材质球Shader是支持顶点颜色的如TextMeshPro/Distance Field系列Shader都是支持的。通常没问题但如果你使用了自定义的、精简过的Shader可能需要检查。检查Alpha值你设置的Gradient颜色中的Alpha通道是否为0或者父级CanvasGroup的Alpha影响了整体透明度检查更新触发在编辑模式下确认脚本是否勾选了ExecuteAlways如果没有在编辑模式下调整参数不会刷新。在运行模式下检查_isDirty是否被正确设置为true以及UpdateVertexColors方法是否被调用。可以添加Debug.Log打印信息辅助调试。检查文本内容文本是否是空字符串或者全是空格等不可见字符charInfo.isVisible会过滤掉它们导致循环直接跳过。检查Bounds打印出_tmpText.bounds的size看是否为0或极小值。如果文本刚被实例化布局还未计算完成bounds可能不正确。可以尝试在Start或OnEnable中延迟一帧再执行首次更新用StartCoroutine(DelayUpdate())。5.2 问题渐变方向错乱或颜色不对可能原因及排查步骤坐标空间混淆这是最常见的原因。在CalculateGradientWeight方法中你计算权重时使用的是世界坐标还是局部坐标如果你的文本在一个会旋转或缩放的父节点下使用世界坐标计算水平渐变会发现渐变方向并没有跟随文本旋转。这时你需要根据需求选择想要渐变相对于屏幕方向固定使用世界坐标计算。想要渐变相对于文本自身方向固定使用局部坐标计算即直接使用vertices[currentVertexIndex]不进行TransformPoint转换并确保bounds也是局部空间的_tmpText.textBounds可能有用但通常_tmpText.bounds在世界空间。归一化计算错误检查boundsMin和boundsSize的计算是否正确。对于换行文本bounds的中心可能不在你认为的位置。可以在Scene视图中用Debug.DrawLine画出bounds的边框直观查看。Gradient颜色键设置在Inspector中双击Gradient颜色条可以编辑颜色键。确认颜色键的位置Time和颜色值是否符合预期。一个常见的错误是想要“红-黄-蓝”渐变但第一个颜色键是红色在位置0第二个蓝色在位置1中间没有黄色键那么中间段就是红到蓝的插值不会出现黄色。你需要在位置0.5处添加一个黄色的颜色键。径向渐变中心不对径向渐变以字符中心charCenter计算。如果你希望以整个文本块的中心为原点应该使用bounds.center而不是每个字符的中心。根据需求调整CalculateGradientWeight中径向渐变的计算方式。5.3 问题性能开销过大可能原因及排查步骤Profiler定位打开Unity Profiler (Window Analysis Profiler)在CPU使用率中查看UpdateVertexColors或ForceMeshUpdate的耗时。如果它们每帧都占用较高比例说明更新太频繁。关闭不必要的每帧更新检查所有TextColorGradient组件的_updatePerFrame选项确保只有需要动态效果的文本才开启。检查文本复杂度一个TMP文本包含的字符数越多网格越复杂修改顶点色的循环次数就越多。对于超长文本考虑是否真的需要全部应用渐变或许可以只用在前几个字符。合并文本如果界面上有多个静态渐变文本且颜色相同可以考虑将它们合并为一个TMP文本中间用空格或br换行这样只需要一个TextColorGradient组件处理减少了Draw Call和组件开销。升级到Shader方案如果经过上述优化仍无法满足性能要求常见于低端移动设备或超大量文本就是时候研究自定义Shader方案了。将颜色计算转移到GPUCPU端只需要传递几个参数性能提升会是数量级的。5.4 问题与描边、阴影等效果冲突TMP的描边Outline和阴影Shadow是作为额外通道Pass渲染的。我们的顶点色修改通常只影响主颜色通道。这意味着描边和阴影的颜色可能不会跟随渐变变化仍然是你在TMP组件中设置的固定颜色。解决方案接受现状很多时候描边保持固定颜色如黑色或白色反而能更好地衬托出渐变文字视觉效果不错。修改TMP Shader如果你需要描边也渐变那就必须修改TMP使用的Shader。你需要找到TMP的Distance Field Shader如TextMeshPro/Distance Field复制一份进行修改让描边Pass也采样你传入的渐变参数。这属于高级Shader技巧需要对TMP的渲染流程有深入了解。使用“面片式”描边模拟另一种取巧的方法是放弃TMP自带的描边自己用多个相同文本错位叠加来模拟描边然后给每一个叠加的文本都应用TextColorGradient。但这会显著增加渲染开销不推荐用于大量文本。最后分享一个我个人的小技巧在开发UI动效时我经常将_gradientOffset与动画系统Animation Clip或DoTween等插件联动不仅可以做线性流动还可以做出颜色呼吸通过修改Gradient的Alpha、颜色脉冲快速改变_gradientScale等丰富效果。把TextColorGradient组件看作一个强大的颜色调制器而不仅仅是实现静态渐变你的UI表现力会上一个大台阶。记住好的UI动效核心是“克制”和“服务于功能”炫酷但不干扰用户操作的渐变才是成功的渐变。