4246 字
21 分钟
[.NET,WPF] 窗体云母, 亚克力, 透明, 混合颜色, 模糊背景, 亮暗色主题全讲

众所周知, 在 Windows10 中亚克力(Acrylic)效果被添加, 在 Windows11 中云母(Mica)效果被添加. 其中亚克力效果简单来讲就是对窗体后的元素模糊再加噪点. Mica 则是对壁纸进行模糊再加噪点.

名称实现效果性能推荐用于
毛玻璃/Aero透明+模糊最差我不知道新版本操作系统怎么启用 Aero
亚克力/Acrylic透明+模糊+噪点较差临时窗口, 例如右键菜单, 工具提示等
云母/Mica壁纸采样+模糊+噪点较好最好主窗口

云母效果的性能之所以好, 是因为它不会对壁纸进行重复采样, 而是只采样一次, 生成对应云母效果贴图, 最后只需要将它应用到窗口上就可以. 可以说, 基本上没有任何消耗.

但这也带来了一些弊端, 假如你使用了某种动态壁纸软件, 那么云母效果和你的壁纸是完全不匹配的, 这稍稍影响了美观.

在阅读本文章之前, 请确保你有进行 .NET 调用非托管动态链接库函数的经验, 大致知道 WindowChrome 类的作用, 知道窗口句柄是什么东西. 阅读时请从开始到结束按顺序看, 否则可能会看漏某些细节上的东西.


背景模糊 API#

在 Windows 中, 有两个 API 可以用来实现背景模糊:

DwmSetWindowAttributeSetWindowCompositionAttribute
可以实现的功能亮暗色模式的云母与亚克力效果支持自定义混合颜色的亚克力效果, 模糊透明效果
模糊区域能够应用到整个窗口, 包括标题栏以及窗口内容只能对窗口内容进行模糊
系统要求API 需要 Windows Vista 及以上版本, 亮暗色主题, 云母和亚克力效果则只能在 Windows11 上用, 其中亮暗色需要版本 22000, 模糊背景需要版本 22621API 在 Windows 7 及以上版本可用, 亚克力与模糊背景效果则只能在 Windows10 上可用, 具体版本未知, 因为这是未在微软文档中具体写明的 API
其他要求只有在 “窗口框架” 拓展到窗口工作区内时, 拓展的厚度才会带有对应模糊效果.只有在 “窗口框架” 不拓展到窗口工作区内时有效.
优点通过设置颜色主题即可直接自动换掉云母或亚克力背景的颜色在窗口失去焦点时, 模糊仍然有效, 并且可以自定义混合颜色
缺点当窗体失去焦点的时候, 模糊效果会消失没有云母效果

两 API 在 C# 的声明分别如下:

[DllImport("DWMAPI")]
public static extern nint DwmSetWindowAttribute(nint hwnd, DwmWindowAttribute attribute, nint dataPointer, uint dataSize);
[DllImport("User32")]
public static extern bool SetWindowCompositionAttribute(nint hwnd, ref WindowCompositionAttributeData data);

它们依赖的, 用于设置 “窗口框架” 拓展厚度的 API 声明如下:

[DllImport("DWMAPI")]
public static extern nint DwmExtendFrameIntoClientArea(nint hwnd, ref Margins margins);

结构体与枚举声明如下:

DWM 属性枚举

public enum DwmWindowAttribute
{
    NCRENDERING_ENABLED,
    NCRENDERING_POLICY,
    TRANSITIONS_FORCEDISABLED,
    ALLOW_NCPAINT,
    CAPTION_BUTTON_BOUNDS,
    NONCLIENT_RTL_LAYOUT,
    FORCE_ICONIC_REPRESENTATION,
    FLIP3D_POLICY,
    EXTENDED_FRAME_BOUNDS,
    HAS_ICONIC_BITMAP,
    DISALLOW_PEEK,
    EXCLUDED_FROM_PEEK,
    CLOAK,
    CLOAKED,
    FREEZE_REPRESENTATION,
    PASSIVE_UPDATE_MODE,
    USE_HOSTBACKDROPBRUSH,

    // 表示是否使用暗色模式, 它会将窗体的模糊背景调整为暗色
    USE_IMMERSIVE_DARK_MODE = 20,
    WINDOW_CORNER_PREFERENCE = 33,
    BORDER_COLOR,
    CAPTION_COLOR,
    TEXT_COLOR,
    VISIBLE_FRAME_BORDER_THICKNESS,

    // 背景类型, 值可以是: 自动, 无, 云母, 或者亚克力
    SYSTEMBACKDROP_TYPE,
    LAST
}

背景类型枚举:

public enum WindowBackdrop
{
    Auto = 0,
    None = 1,
    MainWindow = 2,
    TransientWindow = 3,
    TabbedWindow = 4
}

设置 SetWindowCompositionAttribute 所用的数据结构

[StructLayout(LayoutKind.Sequential)]
public struct WindowCompositionAttributeData
{
    /// <summary>
    /// A flag describing which value to get or set, specified as a value of the <see cref="WindowCompositionAttribute"/> enumeration. 
    /// This parameter specifies which attribute to get or set, and the pvData member points to an object containing the attribute value.
    /// </summary>
    public WindowCompositionAttribute Attribute;

    /// <summary>
    /// When used with the GetWindowCompositionAttribute function, this member contains a pointer to a variable that will hold the value of the requested attribute when the function returns. <br/>
    /// When used with the SetWindowCompositionAttribute function, it points an object containing the attribute value to set. <br/>
    /// The type of the value set depends on the value of the Attrib member.
    /// </summary>
    public nint DataPointer;

    /// <summary>
    /// The size of the object pointed to by the pvData member, in bytes.
    /// </summary>
    public uint DataSize;
}

使用 SetWindowCompositionAttribute 设置背景模糊时所需要用到的属性枚举

public enum WindowCompositionAttribute
{
    // 省略其他未使用的字段
    WcaAccentPolicy = 19,
    // 省略其他未使用的字段
}

使用 SetWindowCompositionAttribute 设置背景模糊需要实际传入的数据

[StructLayout(LayoutKind.Sequential)]
public struct AccentPolicy
{
    public AccentState AccentState;
    public AccentFlags AccentFlags;
    public int GradientColor;
    public int AnimationId;
}

Accent state, 即在窗口上要使用到的效果:

public enum AccentState
{
    Disabled,
    EnableGradient = 1,           // 渐变 (实测没什么用)
    EnableTransparent = 2,        // 透明 (实测没什么用)
    EnableBlurBehind = 3,         // 背景模糊 (有用)
    EnableAcrylicBlurBehind = 4,  // 背景亚克力模糊 (有用)
    EnableHostBackdrop = 5,       // 没啥用
    InvalidState = 6,
}

Accent flags, 控制某些行为, 由于没有文档, 下面的值都是测试出来的:

[Flags]
public enum AccentFlags
{
    None = 0,
    ExtendSize = 0x4,      // 启用此 flag 会导致窗体大小拓展至屏幕大小
    LeftBorder = 0x20,     // 启用窗口左侧边框 (当 WindowStyle 为 None 时可以看出来)
    TopBorder = 0x40,      // 启用窗口顶部边框 (同上)
    RightBorder = 0x80,    // 启用窗口右侧边框 (同上)
    BottomBorder = 0x100,  // 启用窗口底部边框

    // 合起来, 启用窗口所有边框
    AllBorder = LeftBorder | TopBorder | RightBorder | BottomBorder,
}

设置 “窗口拓展” 需要用到的表示拓展大小的结构. 当值为 -1 时表示无穷大.

[StructLayout(LayoutKind.Sequential)]
public struct Margins
{
    public int LeftWidth;
    public int RightWidth;
    public int TopHeight;
    public int BottomHeight;
}

应用云母材质#

要对 WPF 的窗口进行操作, 我们需要拿到窗口的句柄, 然后调用刚刚提到的 API 进行操作.

不过在这之前, 我们还需要知道一件事. WPF 要应用背景透明模糊效果, 还需要设置窗口对应 HwndSourceCompositionTargetBackgroundColorTransparent 才能正确的看到透明背景. 除此之外, 你也需要确认窗口的 Background 也是 Transparent, 否则窗口颜色遮挡背景, 你就看不到了.

由于在 WPF 程序中, 一切都抽象成了 WPF 的对象, 而对于窗口这一类型来讲, 在它被调用 Show 方法之前, 是不会创建 Win32 窗口的. 要保证我们能够持有窗口句柄, 有两种方案:

  • 使用 WindowsInteropHelperEnsureHandle 方法, 确保窗口对象创建了 Win32 窗口并可以使用句柄.
  • 订阅窗口的 SourceInitialized 事件, 该事件会在窗口的 Win32 窗口被创建之后引发.

下面我们以 SourceInitialized 事件的事件处理器中编写逻辑举例, 讲述如何正确的在 WPF 中应用云母效果.

首先, 确保我们的窗口背景颜色为 Transparent, 你的 XAML 代码应该像这样:

<Window ...
        Background="Transparent">
    ...
</Window>

如果你的设置无误, 那么你的窗口现在的 “透明” 看起来应该是 “黑色”:

然后, 实现 HwndSourceCompositionTarget 设置和 “窗口框架”, 最简单的方式是直接为窗口设置 WindowChrome, 通过对 WindowChrome 类中名为 WindowChrome 的附加属性进行赋值即可, 最后只需要设置 GlassFrameThickness 为 -1 就完成了, 在 XAML 中这样编写:

<Window ...
        Background="Transparent">
    <WindowChrome.WindowChrome>
        <WindowChrome GlassFrameThickness="-1"/>
    </WindowChrome.WindowChrome>
    ...
</Window>

窗口边框拓展大小 控制了窗口设置的模糊背景延伸到窗口内部的大小. 也就是说, 如果你将 GlassFrameThickness 设置为 10, 那么窗口边缘的 10 像素会应用上指定的背景. 而 -1 在这里则表示无限大.

这样操作之后, WindowChrome 会帮我们设置好所需的属性, 如果你的设置无误, 现在窗口应该会恢复到原来的 “白色”, 并且窗口的标题栏会 “消失”, 只留下三个窗口按钮:

最后, 我们订阅窗口的 SourceInitialized 事件:

<Window ...
        Background="Transparent"
        SourceInitialized="Window_SourceInitialized">
    <WindowChrome.WindowChrome>
        <WindowChrome GlassFrameThickness="-1"/>
    </WindowChrome.WindowChrome>
    ...
</Window>

并添加以下处理:

private unsafe void Window_SourceInitialized(object sender, EventArgs e)
{
    var hwndSource = (HwndSource)PresentationSource.FromVisual(this);
    var backdropType = (int)2;   // 0 到 4 分别是 '自动', '无', '云母(Mica)效果', '亚克力(Acrylic)效果', '云母备选(Mica Alt)效果'

    DwmSetWindowAttribute(hwndSource.Handle, DwmWindowAttribute.SYSTEMBACKDROP_TYPE, (nint)(void*)&backdropType, sizeof(int));
}

注意, 在这里使用到了不安全代码块, 你需要在你的项目文件中启用 “允许不安全代码块” 才能使用此功能.

最后启动窗口, 操作无误的话, 你最终的窗口, 已经能够呈现出云母效果了.


应用亚克力材质#

在上面, 我们已经成功将云母材质应用到了窗口上, 如果要换成亚克力效果, 也非常简单, 只需要将表示云母背景的 “2” 换成表示亚克力的 “3” 即可, 代码如下:

private unsafe void Window_SourceInitialized(object sender, EventArgs e)
{
    var hwndSource = (HwndSource)PresentationSource.FromVisual(this);
    var backdropType = (int)3;   // 0 到 4 分别是 '自动', '无', '云母(Mica)效果', '亚克力(Acrylic)效果', '云母备选(Mica Alt)效果'

    DwmSetWindowAttribute(hwndSource.Handle, DwmWindowAttribute.SYSTEMBACKDROP_TYPE, (nint)(void*)&backdropType, sizeof(int));
}

那么, 最终的效果是这样, 可以看到窗口的背景是参考了其后方窗口内容的:


切换窗口亮暗色主题#

在 Windows11 的 22000 版本中, DwmSetWindowAttribute 支持了表示窗口是否使用暗色模式的 DWMWA_USE_IMMERSIVE_DARK_MODE 属性.

当我们使用该 API 设置该属性为 1 的时候, 标题栏, 以及云母与亚克力材料会切换到对应的暗色. 下面, 我们向代码中添加将窗口设置为暗色模式的逻辑:

private unsafe void Window_SourceInitialized(object sender, EventArgs e)
{
    var hwndSource = (HwndSource)PresentationSource.FromVisual(this);
    var backdropType = (int)3;   // 0 到 3 分别是 '自动', '无', '云母(Mica)效果', '亚克力(Acrylic)效果'
    var isDarkMode = (int)1;

    DwmSetWindowAttribute(hwndSource.Handle, DwmWindowAttribute.SYSTEMBACKDROP_TYPE, (nint)(void*)&backdropType, sizeof(int));
    DwmSetWindowAttribute(hwndSource.Handle, DwmWindowAttribute.USE_IMMERSIVE_DARK_MODE, (nint)(void*)&isDarkMode, sizeof(int));
}

现在我们的亚克力窗口背景看起来比以前暗了许多.

如果它的背景更暗一些, 窗口看起来也会更暗:

我们再将亚克力材质换成云母, 它的暗色模式看起来是这样的, 因为它不参考窗口后的内容, 只参考壁纸, 所以无论窗口在哪, 它都是这副模样:

在调整窗口内容元素前景与背景色到大致合适的值之后, 它看起来是这样的:


自定义混合颜色亚克力#

在上面, 我们使用 DwmSetWindowAttribute 实现了窗口的云母与亚克力背景, 而它们具体的颜色, 则是通过设置窗口是否是暗色实现来切换的. 如果我们需要自定义叠加的混合颜色, 那这个 API 就不能实现了.

我们需要使用 SetWindowCompositionAttribute 这个 API 来实现了. 当然, 在最开始我们也了解过这个 API 的弊端了, 所以, 除非你真的有这方面的需求, 否则尽量使用新的 API 比较好.

同样, 在调用 API 之前, 需要确保我们的窗口设置了正确的属性, 第一个是背景颜色需要设为透明:

<Window ...
        Background="Transparent">
    ...
</Window>

然后, 需要确保 “窗口框架” 的拓展大小是 0, 也就是说, Window 必须不设置 WindowChrome, 或者 WindowChromeGlassFrameThickness 值需要为 0. 以下两种设置方式均可:

<Window ...
        Background="Transparent">
    <!-- 这里没有设置窗口的 WindowChrome -->
    ...
</Window>

或者设置 WindowChrome, 但是 GlassFrameThickness 为 0:

<Window ...
        Background="Transparent">
    <WindowChrome.WindowChrome>
        <WindowChrome GlassFrameThickness="0"/>
    </WindowChrome.WindowChrome>
    ...
</Window>

最后, 确保 HwndSource.CompositionTarget.BackgroundColor 为透明, 最暴力的方式是直接将窗口的 AllowsTransparency 设为 true. 又因为只有 WindowStyleNone 是, 窗口才能设置 AllowsTransparencytrue, 所以最终你的 XAML 像是这样:

<Window ...
        Background="Transparent"
        WindowStyle="None"
        AllowsTransparency="True"
        SourceInitialized="Window_SourceInitialized">
    <WindowChrome.WindowChrome>
        <WindowChrome GlassFrameThickness="0"/>
    </WindowChrome.WindowChrome>
    ...
</Window>

就此, 我们的准备阶段完毕, 最后将 SourceInitialized 事件处理逻辑更改为以下代码:

private unsafe void Window_SourceInitialized(object sender, EventArgs e)
{
    var hwndSource = (HwndSource)PresentationSource.FromVisual(this);

    var accentPolicy = new AccentPolicy()
    {
        AccentState = AccentState.EnableAcrylicBlurBehind,
        AccentFlags = AccentFlags.None,

        // 注意, 这里从高位到低位分别是 Alpha, Blue, Green, Red
        // 每一个字节一个通道颜色值
        GradientColor = 0x338888FF,
    };

    var data = new WindowCompositionAttributeData()
    {
        Attribute = WindowCompositionAttribute.WcaAccentPolicy,
        DataPointer = (nint)(void*)&accentPolicy,
        DataSize = (uint)sizeof(AccentPolicy),
    };

    SetWindowCompositionAttribute(hwndSource.Handle, ref data);
}

运行后, 它的效果是偏红色的亚克力窗口.

Pasted image 20240530144501

相信你也一定发现了一些问题, 经过一番设置之后, 我们的窗口:

  • 由于设置了 AllowsTransparencyTrue, 窗口中的 “透明” 部分, 鼠标事件会穿透它, 从而点击到窗体后方的内容.
  • 由于设置了 WindowStyleNone, 窗口标题栏没有了, 按钮没有了, 圆角没有了, 最小化最大化与关闭的动画没有了.

关于第一点, 可以通过一种简单暴力的方式来解决, 只需要给窗口的不透明度一个很小的值, 即可:

<Window ...
        Background="#01000000"
        WindowStyle="None"
        AllowsTransparency="True"
        SourceInitialized="Window_SourceInitialized">
    <WindowChrome.WindowChrome>
        <WindowChrome GlassFrameThickness="0"/>
    </WindowChrome.WindowChrome>
    ...
</Window>

而第二点, 本质上是因为我们通过设置 WindowStyle 以满足调用条件导致的, 只需要我们不去设置 WindowStyle, 从底层上自己满足调用条件, 就可以解决. 具体的操作方式, 请继续往下看.


从底层上满足调用条件#

在上面的代码中, 为了满足 DwmSetWindowAttributeSetWindowCompositionAttribute 的调用条件, 我们使用了 WindowChrome.WindowChrome, WindowStyle, AllowsTransparency 属性. 而它们也都有自己的副作用:

  • WindowChrome 会去除窗口自带的标题栏, 你还需要手动用 WPF 自己做一个标题栏
  • WindowStyle 设为 None 会导致窗口的边框消失, 窗口操作按钮消失, 动画消失, 圆角消失.
  • AllowsTransparency 设为 True 会导致透明部分穿透鼠标事件

实际上, 最终我们需要满足的条件也只有:

  • 窗口的背景设为透明, 以能够查看到设置的窗口背景样式
  • HwndSource.CompositionTarget.BackgroundColor 设为透明
  • 当使用 DwmSetWindowAttribute 时, “窗口框架” 的拓展厚度设置到一个足够大的值, 当使用 SetWindowCompositionAttribute 时, “窗口框架” 的拓展厚度需要设置为 0

那么现在, 我们将多余的属性删去, 仅保留窗体的以下属性:

<Window ...
        Background="Transparent"
        SourceInitialized="Window_SourceInitialized">
    ...
</Window>

然后在 SourceInitialized 改为以下逻辑, 来实现手动满足条件的云母效果:

private unsafe void Window_SourceInitialized(object sender, EventArgs e)
{
    // 取得窗口句柄
    var hwndSource = (HwndSource)PresentationSource.FromVisual(this);

    // 设置 HwndSource.CompositionTarget.BackgroundColor 为透明
    hwndSource.CompositionTarget.BackgroundColor = Colors.Transparent;

    // 设置边框
    var margins = new Margins()
    {
        LeftWidth = -1,
        TopHeight = -1,
        RightWidth = -1,
        BottomHeight = -1
    };

    DwmExtendFrameIntoClientArea(hwndSource.Handle, ref margins);

    // 设置背景
    var backdrop = (int)2;

    DwmSetWindowAttribute(hwndSource.Handle, DwmWindowAttribute.SYSTEMBACKDROP_TYPE, (nint)(void*)&backdrop, sizeof(int));
}

最后我们就得到了一个, 带有窗口正常标题栏与云母背景的窗口:

同理, 我们手动处理 SetWindowCompositionAttribute 的调用条件, XAML 不需要更改, 只需要将逻辑改为下面的代码:

private unsafe void Window_SourceInitialized(object sender, EventArgs e)
{
    // 取得窗口句柄
    var hwndSource = (HwndSource)PresentationSource.FromVisual(this);

    // 设置 HwndSource.CompositionTarget.BackgroundColor 为透明
    hwndSource.CompositionTarget.BackgroundColor = Colors.Transparent;

    // 设置边框
    var margins = new Margins()
    {
        LeftWidth = 0,
        TopHeight = 0,
        RightWidth = 0,
        BottomHeight = 0
    };

    DwmExtendFrameIntoClientArea(hwndSource.Handle, ref margins);

    // 设置背景
    var accentPolicy = new AccentPolicy()
    {
        AccentState = AccentState.EnableAcrylicBlurBehind,
        AccentFlags = AccentFlags.None,

        // 注意, 这里从高位到低位分别是 Alpha, Blue, Green, Red
        // 每一个字节一个通道颜色值
        GradientColor = 0x338888FF,
    };

    var data = new WindowCompositionAttributeData()
    {
        Attribute = WindowCompositionAttribute.WcaAccentPolicy,
        DataPointer = (nint)(void*)&accentPolicy,
        DataSize = (uint)sizeof(AccentPolicy),
    };

    SetWindowCompositionAttribute(hwndSource.Handle, ref data);
}

于是我们得到了一个带有不透明标题栏与自定义混合颜色亚克力背景的窗口:


错误参考#

下面是一些属性没有正确设置会导致的结果:

窗口颜色过曝, 没有将 HwndSource.CompositionTarget.BackgroundColor 设置为透明导致.

窗口黑了, “窗口边框” 拓展大小于 API 所需大小不匹配导致.


总结#

可以使用 DwmSetWindowAttributeSetWindowCompositionAttribute 设置窗口的背景, 其中前者只能用于 Windows11 上设置亮暗的云母与亚克力材质, 后者能够设置自定义混合颜色的亚克力材质. 前者能够将材质应用到标题栏与内容, 而后者只能将材料应用到内容中.

这两个 API 均有自己的 “条件”, 只有条件满足, 所指定的背景才能够被用户看到. 否则要么无作用, 要么窗口的内容变得很奇怪.

使用 WindowChrome, WindowStyleAllowsTransparency 的根本目的是满足显示指定背景, 我们完全可以自己完整这一操作. 这样就不需要承担那三个属性会导致的副作用.


引用#

文章参考:

代码参考:

注: 上面提到的文章参考可能只介绍到了 “某一部分” 内容, 可能会造成误导, 注意看完本篇文章后自行验证

[.NET,WPF] 窗体云母, 亚克力, 透明, 混合颜色, 模糊背景, 亮暗色主题全讲
https://slimenull.com/posts/20240530104846/
作者
SlimeNull
发布于
2024-05-30
许可协议
CC BY-NC-SA 4.0