在 Web 中, 只需要一句 CSS 代码, 即可实现任何元素的背景模糊:
.some-class {
backdrop-filter: blur(10px);
}
但很遗憾, WPF 并不能这样实现. 一切, 都要我们自己手搓.
我们需要什么
要实现背景模糊, 我们首先需要得到背景, 再对其进行模糊. 假设我们有这样的一个层级关系:
根
|- 某些父节点
| |- 某些父节点之前的兄弟
| |- 某些父节点之前的兄弟
| |- 某些父节点
| | |- 某些优先绘制的兄弟
| | |- 某些优先绘制的兄弟
| | |- 自身
| | |- 后面的兄弟
| | |- 后面的兄弟...
| |- ...
|- ...
那么我们的 “背景” 来自于所有 “在自己之前” 的同级节点, 自己的父节点, 然后是自己父节点之前的同级节点, 父节点的父节点, 如此递归. 直到取到根.
要获取非父节点元素的内容是非常简单的, 因为它们和我们自身是没有关系的. 我们只需要使用 VisualBrush, 就可以获取到它们的内容.
但棘手的是父节点, 因为父节点包含我们自身, 以及我们自身后面的兄弟节点. 我们需要干净的父节点自身的内容, 这是问题的关键.
所以接下来, 我们要做的是一个会绘制自身背景的元素.
WPF 的渲染流程
我们知道, 如果要自定义一个元素的渲染, 只需要重写 OnRender 方法, 并使用传入的 DrawingContext 进行绘制即可.
通过阅读 WPF 的源代码, 我们可以发现, OnRender 的调用是在 UIElement 的 Arrange 方法中. 位于 UIElement.cs 的 928 行.
而这里的 DrawingContext 参数来自于 UIElement 的内部方法, RenderOpen 中. 它其实是创建了一个内部类 VisualDrawingContext 的实例.
在绘制完成后, 还调用了 DrawingContext 的 Close 方法, 经过一系列调用, 最后到达了 UIElement 的 RenderClose 方法.
而 RenderClose 方法本质上是将 DrawingContext 的调用指令, 存储到了 UIElement 的 _drawingContent 字段中了.
至此, 我们可以知道, UIElement 如何呈现, 关键在于 _drawingContent 的值.
取得元素绘制内容
通过反射, 我们可以轻易取到 UIElement 的 _drawingContent 字段的值
private static readonly FieldInfo _drawingContentOfUIElement = typeof(UIElement)
.GetField("_drawingContent", BindingFlags.Instance | BindingFlags.NonPublic)!;
Visual someVisual;
object drawingContent = _drawingContentOfUIElement.GetValue(someVisual);
接下来, 我们需要将其转移到一个新的 Visual 中, 然后绘制这个新的 Visual. 这里使用 DrawingVisual. 因为如果使用 UIElement, 在绘制时会调用其 OnRender 重新进行绘制, 这没意义. 而且据笔者实验, 这总是会产生一些闪烁问题.
private static readonly FieldInfo _contentOfDrawingVisual = typeof(DrawingVisual)
.GetField("_content", BindingFlags.Instance | BindingFlags.NonPublic)!;
object drawingContentOfSomeVisual;
DrawingVisual drawingVisual = new DrawingVisual();
_contentOfDrawingVisual.SetValue(drawingVisual, drawingContentOfSomeVisual);
现在, 我们就可以通过 VisualBrush 将我们所取得的内容绘制出来了.
内容的刷新
因为我们的内容来自父节点, 以及在自身之前的同级节点, 所以当它们位置变动, 也就是布局更新时, 我们需要重新绘制.
这很简单, 我们只需要订阅父节点作为 UIElement 的 LayoutUpdated 事件即可:
protected override void OnVisualParentChanged(DependencyObject oldParentObject)
{
if (oldParentObject is UIElement oldParent)
{
oldParent.LayoutUpdated -= ParentLayoutUpdated;
}
if (Parent is UIElement newParent)
{
newParent.LayoutUpdated += ParentLayoutUpdated;
}
}
private void ParentLayoutUpdated(object? sender, EventArgs e)
{
// 在这里引发重绘
}
不过, 这里需要注意. 不能使用 InvalidateVisual() 引发重绘, 因为它同样会使布局失效. 这样就造成死递归了(严格来讲会导致布局在每一帧都进行, 造成卡顿).
对内容进行裁剪
因为我们的渲染包含了背景的所有元素, 所以其大小也是和背景一致的. 它会覆盖整个窗口. 所以, 我们需要重写 GetLayoutClip 来设定呈现的范围.
protected override Geometry GetLayoutClip(Size layoutSlotSize)
{
return new RectangleGeometry(new Rect(0, 0, ActualWidth, ActualHeight));
}
结合在一起
所有的所有, 结合在一起, 我们得到了一个背景呈现元素, 再对其添加 BlurEffect 效果, 就是实时背景模糊:

背景呈现元素的完整代码:
public class BackgroundPresenter : FrameworkElement
{
private static readonly FieldInfo _drawingContentOfUIElement = typeof(UIElement)
.GetField("_drawingContent", BindingFlags.Instance | BindingFlags.NonPublic)!;
private static readonly FieldInfo _contentOfDrawingVisual = typeof(DrawingVisual)
.GetField("_content", BindingFlags.Instance | BindingFlags.NonPublic)!;
private static readonly FieldInfo _offsetOfVisual = typeof(Visual)
.GetField("_offset", BindingFlags.Instance | BindingFlags.NonPublic)!;
private static readonly Func<UIElement, DrawingContext> _renderOpenMethod = typeof(UIElement)
.GetMethod("RenderOpen", BindingFlags.Instance | BindingFlags.NonPublic)!
.CreateDelegate<Func<UIElement, DrawingContext>>();
private static readonly Action<UIElement, DrawingContext> _onRenderMethod = typeof(UIElement)
.GetMethod("OnRender", BindingFlags.Instance | BindingFlags.NonPublic)!
.CreateDelegate<Action<UIElement, DrawingContext>>();
private static readonly GetContentBoundsDelegate _methodGetContentBounds = typeof(VisualBrush)
.GetMethod("GetContentBounds", BindingFlags.Instance | BindingFlags.NonPublic)!
.CreateDelegate<GetContentBoundsDelegate>();
private delegate void GetContentBoundsDelegate(VisualBrush visualBrush, out Rect bounds);
private readonly Stack<UIElement> _parentStack = new();
private static void ForceRender(UIElement target)
{
using DrawingContext drawingContext = _renderOpenMethod(target);
_onRenderMethod.Invoke(target, drawingContext);
}
private static void DrawVisual(DrawingContext drawingContext, Visual visual, Point relatedXY, Size renderSize)
{
var visualBrush = new VisualBrush(visual);
var visualOffset = (Vector)_offsetOfVisual.GetValue(visual)!;
_methodGetContentBounds.Invoke(visualBrush, out var contentBounds);
relatedXY -= visualOffset;
drawingContext.DrawRectangle(
visualBrush, null,
new Rect(relatedXY.X + contentBounds.X, relatedXY.Y + contentBounds.Y, contentBounds.Width, contentBounds.Height));
}
protected override Geometry GetLayoutClip(Size layoutSlotSize)
{
return new RectangleGeometry(new Rect(0, 0, ActualWidth, ActualHeight));
}
protected override void OnVisualParentChanged(DependencyObject oldParentObject)
{
if (oldParentObject is UIElement oldParent)
{
oldParent.LayoutUpdated -= ParentLayoutUpdated;
}
if (Parent is UIElement newParent)
{
newParent.LayoutUpdated += ParentLayoutUpdated;
}
}
private void ParentLayoutUpdated(object? sender, EventArgs e)
{
// cannot use 'InvalidateVisual' here, because it will cause infinite loop
ForceRender(this);
Debug.WriteLine("Parent layout updated, forcing render of BackgroundPresenter.");
}
private static void DrawBackground(
DrawingContext drawingContext, UIElement self,
Stack<UIElement> parentStackStorage,
int maxDepth,
bool throwExceptionIfParentArranging)
{
#if DEBUG
bool selfInDesignMode = DesignerProperties.GetIsInDesignMode(self);
#endif
var parent = VisualTreeHelper.GetParent(self) as UIElement;
while (
parent is { } &&
parentStackStorage.Count < maxDepth)
{
// parent not visible, no need to render
if (!parent.IsVisible)
{
parentStackStorage.Clear();
return;
}
#if DEBUG
if (selfInDesignMode &&
parent.GetType().ToString().Contains("VisualStudio"))
{
// 遍历到 VS 自身的设计器元素, 中断!
break;
}
#endif
// is parent arranging
// we cannot render it
if (parent.RenderSize.Width == 0 ||
parent.RenderSize.Height == 0)
{
parentStackStorage.Clear();
if (throwExceptionIfParentArranging)
{
throw new InvalidOperationException("Arranging");
}
// render after parent arranging finished
self.InvalidateArrange();
return;
}
parentStackStorage.Push(parent);
parent = VisualTreeHelper.GetParent(parent) as UIElement;
}
var selfRect = new Rect(0, 0, self.RenderSize.Width, self.RenderSize.Height);
while (parentStackStorage.TryPop(out var currentParent))
{
if (!parentStackStorage.TryPeek(out var breakElement))
{
breakElement = self;
}
var parentRelatedXY = currentParent.TranslatePoint(default, self);
// has render data
if (_drawingContentOfUIElement.GetValue(currentParent) is { } parentDrawingContent)
{
var drawingVisual = new DrawingVisual();
_contentOfDrawingVisual.SetValue(drawingVisual, parentDrawingContent);
DrawVisual(drawingContext, drawingVisual, parentRelatedXY, currentParent.RenderSize);
}
if (currentParent is Panel parentPanelToRender)
{
foreach (UIElement child in parentPanelToRender.Children)
{
if (child == breakElement)
{
break;
}
var childRelatedXY = child.TranslatePoint(default, self);
var childRect = new Rect(childRelatedXY, child.RenderSize);
if (!selfRect.IntersectsWith(childRect))
{
continue; // skip if not intersecting
}
if (child.IsVisible)
{
DrawVisual(drawingContext, child, childRelatedXY, child.RenderSize);
}
}
}
}
}
public static void DrawBackground(DrawingContext drawingContext, UIElement self)
{
var parentStack = new Stack<UIElement>();
DrawBackground(drawingContext, self, parentStack, int.MaxValue, true);
}
protected override void OnRender(DrawingContext drawingContext)
{
DrawBackground(drawingContext, this, _parentStack, MaxDepth, false);
}
public int MaxDepth
{
get { return (int)GetValue(MaxDepthProperty); }
set { SetValue(MaxDepthProperty, value); }
}
public static readonly DependencyProperty MaxDepthProperty =
DependencyProperty.Register("MaxDepth", typeof(int), typeof(BackgroundPresenter), new PropertyMetadata(64));
}
仓库
完整的测试代码仓库如下:
在之后, 我打算将其封装成更多易用的控件, 并添加到 WPF Suite 中. 敬请期待!
