Skip to content

自绘控件与交互行为 ​

界面上的悬停反馈、上浮、缩放、指针光斑都不是靠控件模板里的 Storyboard 做的,而是一组附加属性 + Composition 的行为类。

为什么用附加属性而不是自定义控件 ​

自定义控件模板要自己覆写才能生效:想给一个普通 TextBlock 加落影,就得为它写一个模板;想给列表里的 ListViewItem 加上浮,也得写模板。而 ListView 里的行容器是框架生成的,你没法逐个换模板。

附加属性把行为从「控件自身的绘制」里剥出来,挂在任意元素上:

xml
<Button beh:HoverShadowBehavior.IsEnabled="True"
        beh:HoverShadowBehavior.GlowRadius="16"
        beh:HoverShadowBehavior.Carrier="{Binding ElementName=NovaShadow}" />

动画一律走 Composition(ElementCompositionPreview.GetElementVisual + StartAnimation),不在 UI 线程排版。合成线程跑动画,UI 线程继续做布局,两边不抢帧——这是整个动效体系能撑住列表滚动的前提。

动效关闭时零开销

每个行为的 Wire 开头都有同一句 if (!AppServices.Instance.Motion.Enabled) return;——不是「接线了但动画立刻结束」,而是根本不挂事件处理器。极少档下这些行为对界面的开销是零。

五个行为各自做什么 ​

HoverShadowBehavior——悬停软阴影(跟手光斑)。光是一颗会在元素内部移动的径向渐变,悬停时渐显、移出时渐隐,元素同时微微上浮 1px。半径必须按像素换算成相对值:

csharp
var radiusPx = manualRadius > 0 ? manualRadius : Math.Min(w, h) * 2.5;
brush.RadiusX = radiusPx / w;
brush.RadiusY = radiusPx / h;

直接写 0.9/0.9 的话,在又宽又扁的列表行上会拉成一颗横椭圆。

PointerLightBehavior——全局鼠标光照。把整扇窗口当成一间屋子,指针是一盏移动的灯:朝向光标那侧的 1px 内缘被点亮(边缘光),按钮底下的软阴影朝背离光标的方向平移。档位参数写死在常量里:

csharp
private const double EdgeBaseOpacity = 0.20;   // 常亮底值
private const double EdgeMaxOpacity  = 0.62;   // 最近时最亮
private const double EdgeNearDist = 150d;      // 多近开始推到最亮
private const double ActiveDist = 220d;        // 超过就不再逐帧更新
private const double ShadowActiveDist = 420d;  // 阴影跟随范围
private static double ShadowMaxShift(bool full) => full ? 3.0 : 2.2;
private const double ShadowRestY = 0.8;        // 影子永远略向下坠
private const double ShadowGain = 0.045;       // 每 px 距离位移多少 px

RowHoverBehavior——列表行。悬停上浮 2px、按下下压 1px,同时高亮覆盖层渐显(HoverLift = -2d、PressedDrop = 1d)。

CardHoverBehavior——卡片。悬停轻微放大、按下轻微缩小(HoverScale = 1.012f、PressedScale = 0.985f)。1.2% 这个量是刻意压低的:放大超过 1% 就会露出圆角矩形的边缘(卡片圆角 20,缩放后背景与圆角不再重合),读作「这块凸出来了」而不是「被点亮了」。

TextOutlineBehavior——文字落影。直接落在壁纸/毛玻璃上的文字统一改成白字 + 一层灰影(向下偏移 1px、2px 两层,透明度 0.50 / 0.22)。做法是最朴素的一种:同一个 TextBlock 的灰色副本垫在下面。之所以不用阴影/描边 API,是因为 WinUI 3 的 TextBlock 没有文字阴影或描边属性,ThemeShadow 只能深色且按元素矩形投影,合成 DropShadow 挂到 XAML 元素视觉上也不渲染。单向偏移(只往下)是关键——四正向各偏 1px 会把字形糊成重影。

指针光斑的性能三原则 ​

一、全局表只留在场元素。 容器卸载即从全局表摘除。不摘的话,静态表会一直攥着已经离开的页面,每次鼠标移动都要为它们做一遍判定——滚得越久越慢。

二、位置缓存。 每张卡片在窗口坐标系里的矩形只在接线 / 改尺寸 / 滚动(EffectiveViewportChanged)时重算:

csharp
private static bool BoundsOf(FrameworkElement element, State state, out Rect bounds)
{
    if (state.BoundsValid) { bounds = state.Bounds; return ...; }
    var transform = element.TransformToVisual(state.Root);
    state.Bounds = transform.TransformBounds(new Rect(0, 0, w, h));
    state.BoundsValid = true;
    ...
}

指针事件里只做矩形上的算术,不做坐标变换。旧实现对每张卡片各做一次 e.GetCurrentPoint(el) 换算,卡片一多时每次移动都是十几次变换。

三、零分配。 复用两个待处理队列(Leaving / Detached),不再每次事件 new List。另外距离也参与筛选:超过 ActiveDist(220px)的目标直接跳过,只在还留着旧位移时衰减一次就到位。

位移不能用 Visual.Offset

Offset 是布局属性,下一轮 Layout 会把它重置。要动就用 RenderTransform(独立动画,不触发布局)——行悬停走 TranslateTransform.Y,卡片缩放走合成层的 Visual.Scale。

顺带一个坑:不要用 Scale 做列表行的位移。行放大会溢出卡片圆角边界,视觉上是「行从卡片里鼓出来」。行用平移,卡片才用缩放。

光斑圆角必须跟着表面走 ​

光斑层是圆角矩形(geometry.CornerRadius 取自控件自己的 CornerRadius),直角矩形会把光斑在按钮四角切掉。早期版本让每个调用点自己写圆角魔数,结果出现「阴影比按钮更方、四个角溢出一圈」的错配。现在这件事收进控件模板:GlowButton 在 OnApplyTemplate 里把模板中的 Glow / EdgeLight / GlassSurface / ShadowHost 部件认下来交给行为,圆角用 {TemplateBinding CornerRadius} 与按钮表面读同一个值——结构上不可能再错配。

相关链接:外观与动效 · 3D 皮肤渲染

基于 MIT 许可证(有修改)开源