自绘控件与交互行为
界面上的悬停反馈、上浮、缩放、指针光斑都不是靠控件模板里的 Storyboard 做的,而是一组附加属性 + Composition 的行为类。
为什么用附加属性而不是自定义控件
自定义控件模板要自己覆写才能生效:想给一个普通 TextBlock 加落影,就得为它写一个模板;想给列表里的 ListViewItem 加上浮,也得写模板。而 ListView 里的行容器是框架生成的,你没法逐个换模板。
附加属性把行为从「控件自身的绘制」里剥出来,挂在任意元素上:
<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。半径必须按像素换算成相对值:
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 内缘被点亮(边缘光),按钮底下的软阴影朝背离光标的方向平移。档位参数写死在常量里:
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 距离位移多少 pxRowHoverBehavior——列表行。悬停上浮 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)时重算:
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} 与按钮表面读同一个值——结构上不可能再错配。