WPF 應用完全模擬 UWP 的標題欄按鈕

WPF 自定義窗口樣式有多種方式,不過基本核心實現都是在修改 Win32 窗口樣式。然而,Windows 上的應用就應該有 Windows 應用的樣子嘛,在保證自定義的同時也能與其他窗口樣式保持一致當然能最大程度保證 Windows 操作系統上的體驗一致性。

本文將分享一個我自制的標題欄按鈕樣式,使其與 UWP 原生應用一模一樣(同時支持自定義)。


WPF 使用 WindowChrome,在自定義窗口標題欄的同時最大程度保留原生窗口樣式(類似 UWP/Chrome) 一文中,我使用 WindowChrome 儘可能將 Windows 原生的窗口機制都用上了,試圖完全模擬原生窗口的樣式。不過,如果自定義了窗口的背景色,那麼標題欄那三大金剛鍵的背景就顯得很突兀。

由於 Win32 原生的方法頂多只支持修改標題欄按鈕的背景色,而不支持讓標題欄按鈕全透明,所以我們只能完全由自己來實現這三個按鈕的功能了。

標題欄的四個按鈕

一開始我說三個按鈕,是因爲大家一般都只能看得見三個。但這裏說四個按鈕,是因爲實際實現的時候我們是四個按鈕。事實上,Windows 的原生實現也是四顆按鈕。

  • 最小化
  • 還原
  • 最大化
  • 關閉

當窗口最小化時,顯示還原、最大化和關閉按鈕。當窗口普通顯示時,顯示最小化、最大化和關閉按鈕,這也是我們見的最多的情況。當窗口最大化時,顯示最小化、還原和關閉按鈕。

自繪標題欄按鈕

標題欄按鈕並不單獨存在,所以我直接做了一整個窗口樣式。使用此窗口樣式,窗口能夠模擬得跟 UWP 一模一樣。

以下是模擬的效果:

WPF 模擬版本
▲ WPF 模擬版本

UWP 原生版本
▲ UWP 原生版本(爲避免說我拿同一個應用附圖,我選了微軟商店應用對比)

爲了使用到這樣近乎原生的窗口樣式,我們需要兩個文件。一個放 XAML 樣式,一個放樣式所需的邏輯代碼。

因爲代碼很長,所以我把它們放到了最後。

如何使用我製作的原生窗口樣式

項目目錄結構

當你把我的兩份代碼文件放入到你的項目中之後,在 App.xaml 中將資源引用即可:

<Application.Resources>
    <ResourceDictionary>
        <ResourceDictionary.MergedDictionaries>
            <ResourceDictionary Source="Themes/Window.Universal.xaml" />
        </ResourceDictionary.MergedDictionaries>
    </ResourceDictionary>
</Application.Resources>

隨後,在 MainWindow 中就可以通過 Style="{StaticResource Style.Window.Universal}" 使用這份樣式。

<Window x:Class="Walterlv.Demo.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:themes="clr-namespace:Walterlv.Themes"
        Title="Walterlv.Demo.SimulateUwp" Width="800" Height="450"
        Background="#279EDA" Style="{StaticResource Style.Window.Universal}">
    <themes:UniversalWindowStyle.TitleBar>
        <themes:UniversalTitleBar ForegroundColor="White" InactiveForegroundColor="#7FFFFFFF"
                                  ButtonHoverForeground="White" ButtonHoverBackground="#3FFFFFFF"
                                  ButtonPressedForeground="#7FFFFFFF" ButtonPressedBackground="#3F000000" />
    </themes:UniversalWindowStyle.TitleBar>
    <Grid>
        <!-- 在這裏添加你的正常窗口內容 -->
    </Grid>
</Window>

當然,我額外提供了 UniversalWindowStyle.TitleBar 附加屬性,用於像 UWP 那樣定製標題欄按鈕的顏色。如果不設置,效果跟 UWP 默認情況下的效果完全一樣。

下面是這份樣式在 Whitman - Microsoft Store 應用中實際使用的效果,其中的顏色設置就是上面代碼中所指定的顏色:

Whitman

附樣式代碼文件

樣式文件 Window.Universal.xaml:

<!-- Window.Universal.xaml -->
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
                    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
                    xmlns:themes="clr-namespace:Walterlv.Themes">
    <Style x:Key="Style.Window.Universal" TargetType="Window">
        <Style.Resources>
            <SolidColorBrush x:Key="Brush.TitleBar.Foreground" Color="{Binding Path=(themes:UniversalWindowStyle.TitleBar).ForegroundColor, RelativeSource={RelativeSource TemplatedParent}, Mode=OneWay}" />
            <SolidColorBrush x:Key="Brush.TitleBar.InactiveForeground" Color="{Binding Path=(themes:UniversalWindowStyle.TitleBar).InactiveForegroundColor, RelativeSource={RelativeSource TemplatedParent}, Mode=OneWay}" />
            <SolidColorBrush x:Key="Brush.TitleBar.ButtonHoverForeground" Color="{Binding Path=(themes:UniversalWindowStyle.TitleBar).ButtonHoverForeground, RelativeSource={RelativeSource TemplatedParent}, Mode=OneWay}" />
            <SolidColorBrush x:Key="Brush.TitleBar.ButtonHoverBackground" Color="{Binding Path=(themes:UniversalWindowStyle.TitleBar).ButtonHoverBackground, RelativeSource={RelativeSource TemplatedParent}, Mode=OneWay}" />
            <SolidColorBrush x:Key="Brush.TitleBar.ButtonPressedForeground" Color="{Binding Path=(themes:UniversalWindowStyle.TitleBar).ButtonPressedForeground, RelativeSource={RelativeSource TemplatedParent}, Mode=OneWay}" />
            <SolidColorBrush x:Key="Brush.TitleBar.ButtonPressedBackground" Color="{Binding Path=(themes:UniversalWindowStyle.TitleBar).ButtonPressedBackground, RelativeSource={RelativeSource TemplatedParent}, Mode=OneWay}" />
        </Style.Resources>
        <Setter Property="themes:UniversalWindowStyle.TitleBar">
            <Setter.Value>
                <themes:UniversalTitleBar />
            </Setter.Value>
        </Setter>
        <Setter Property="WindowChrome.WindowChrome">
            <Setter.Value>
                <WindowChrome GlassFrameThickness="0 64 0 0" NonClientFrameEdges="Left,Bottom,Right" UseAeroCaptionButtons="False" />
            </Setter.Value>
        </Setter>
        <Setter Property="Template">
            <Setter.Value>
                <ControlTemplate TargetType="Window">
                    <Border Padding="4 1 4 4">
                        <Grid x:Name="RootGrid" Background="{TemplateBinding Background}">
                            <Grid x:Name="TitleBarPanel" VerticalAlignment="Top" Height="31">
                                <FrameworkElement.Resources>
                                    <Style TargetType="{x:Type Button}">
                                        <Setter Property="Width" Value="46"/>
                                        <Setter Property="BorderThickness" Value="0" />
                                        <Setter Property="Foreground" Value="{StaticResource Brush.TitleBar.Foreground}" />
                                        <Setter Property="Background" Value="Transparent"/>
                                        <Setter Property="Stylus.IsPressAndHoldEnabled" Value="False" />
                                        <Setter Property="Stylus.IsFlicksEnabled" Value="False" />
                                        <Setter Property="Stylus.IsTapFeedbackEnabled" Value="False" />
                                        <Setter Property="Stylus.IsTouchFeedbackEnabled" Value="False" />
                                        <Setter Property="WindowChrome.IsHitTestVisibleInChrome" Value="True"/>
                                        <Setter Property="Template">
                                            <Setter.Value>
                                                <ControlTemplate TargetType="Button">
                                                    <Border Name="OverBorder" BorderThickness="0 1 0 0" Background="{TemplateBinding Background}">
                                                        <TextBlock x:Name="MinimizeIcon"
                                                                   Foreground="{TemplateBinding Foreground}" Text="{TemplateBinding Content}"
                                                                   FontSize="10" FontFamily="Segoe MDL2 Assets"
                                                                   HorizontalAlignment="Center" VerticalAlignment="Center"/>
                                                    </Border>
                                                </ControlTemplate>
                                            </Setter.Value>
                                        </Setter>
                                        <Style.Triggers>
                                            <MultiTrigger>
                                                <!-- When the pointer is over the button. -->
                                                <MultiTrigger.Conditions>
                                                    <Condition Property="IsMouseOver" Value="True" />
                                                    <Condition Property="IsStylusOver" Value="False" />
                                                </MultiTrigger.Conditions>
                                                <Setter Property="Foreground" Value="{StaticResource Brush.TitleBar.ButtonHoverForeground}" />
                                                <Setter Property="Background" Value="{StaticResource Brush.TitleBar.ButtonHoverBackground}" />
                                            </MultiTrigger>
                                            <!-- When the pointer is pressed. -->
                                            <MultiTrigger>
                                                <MultiTrigger.Conditions>
                                                    <Condition Property="IsPressed" Value="True" />
                                                    <Condition Property="AreAnyTouchesOver" Value="False" />
                                                </MultiTrigger.Conditions>
                                                <Setter Property="Foreground" Value="{StaticResource Brush.TitleBar.ButtonPressedForeground}" />
                                                <Setter Property="Background" Value="{StaticResource Brush.TitleBar.ButtonPressedBackground}" />
                                            </MultiTrigger>
                                            <!-- When the touch device is pressed. -->
                                            <MultiTrigger>
                                                <MultiTrigger.Conditions>
                                                    <Condition Property="IsPressed" Value="True" />
                                                    <Condition Property="AreAnyTouchesOver" Value="True" />
                                                </MultiTrigger.Conditions>
                                                <Setter Property="Foreground" Value="{StaticResource Brush.TitleBar.ButtonPressedForeground}" />
                                                <Setter Property="Background" Value="{StaticResource Brush.TitleBar.ButtonPressedBackground}" />
                                            </MultiTrigger>
                                        </Style.Triggers>
                                    </Style>
                                    <Style x:Key="Style.Button.Close" TargetType="Button" BasedOn="{StaticResource {x:Type Button}}">
                                        <Style.Triggers>
                                            <MultiTrigger>
                                                <!-- When the pointer is over the button. -->
                                                <MultiTrigger.Conditions>
                                                    <Condition Property="IsMouseOver" Value="True" />
                                                    <Condition Property="IsStylusOver" Value="False" />
                                                </MultiTrigger.Conditions>
                                                <Setter Property="Foreground" Value="White" />
                                                <Setter Property="Background" Value="#E81123" />
                                            </MultiTrigger>
                                            <!-- When the pointer is pressed. -->
                                            <MultiTrigger>
                                                <MultiTrigger.Conditions>
                                                    <Condition Property="IsPressed" Value="True" />
                                                    <Condition Property="AreAnyTouchesOver" Value="False" />
                                                </MultiTrigger.Conditions>
                                                <Setter Property="Foreground" Value="Black" />
                                                <Setter Property="Background" Value="#F1707A" />
                                            </MultiTrigger>
                                            <!-- When the touch device is pressed. -->
                                            <MultiTrigger>
                                                <MultiTrigger.Conditions>
                                                    <Condition Property="IsPressed" Value="True" />
                                                    <Condition Property="AreAnyTouchesOver" Value="True" />
                                                </MultiTrigger.Conditions>
                                                <Setter Property="Foreground" Value="Black" />
                                                <Setter Property="Background" Value="#F1707A" />
                                            </MultiTrigger>
                                        </Style.Triggers>
                                    </Style>
                                </FrameworkElement.Resources>
                                <TextBlock x:Name="TitleTextBlock" FontSize="12" Text="{TemplateBinding Title}"
                                           Margin="12 0 156 0" VerticalAlignment="Center" Foreground="{StaticResource Brush.TitleBar.Foreground}" />
                                <StackPanel x:Name="TitleBarButtonPanel" Orientation="Horizontal"
                                            Margin="0 -1 0 0" HorizontalAlignment="Right">
                                    <Button x:Name="MinimizeButton" Content="&#xE921;" themes:UniversalWindowStyle.TitleBarButtonState="Minimized" />
                                    <Button x:Name="RestoreButton" Content="&#xE923;" themes:UniversalWindowStyle.TitleBarButtonState="Normal" />
                                    <Button x:Name="MaximizeButton" Content="&#xE922;" themes:UniversalWindowStyle.TitleBarButtonState="Maximized" />
                                    <Button x:Name="CloseButton" Content="&#xE106;" Style="{StaticResource Style.Button.Close}" themes:UniversalWindowStyle.IsTitleBarCloseButton="True" />
                                </StackPanel>
                            </Grid>
                            <AdornerDecorator>
                                <ContentPresenter />
                            </AdornerDecorator>
                        </Grid>
                    </Border>
                    <ControlTemplate.Triggers>
                        <Trigger Property="WindowState" Value="Maximized">
                            <Setter TargetName="RootGrid" Property="Margin" Value="4 7 4 4" />
                            <Setter TargetName="TitleBarPanel" Property="Height" Value="32" />
                            <Setter TargetName="MaximizeButton" Property="Visibility" Value="Collapsed" />
                        </Trigger>
                        <Trigger Property="WindowState" Value="Normal">
                            <Setter TargetName="RestoreButton" Property="Visibility" Value="Collapsed" />
                        </Trigger>
                        <Trigger Property="WindowState" Value="Minimized">
                            <Setter TargetName="MinimizeButton" Property="Visibility" Value="Collapsed" />
                        </Trigger>
                        <Trigger Property="IsActive" Value="False">
                            <Setter TargetName="TitleTextBlock" Property="Foreground" Value="{StaticResource Brush.TitleBar.InactiveForeground}" />
                            <Setter TargetName="MinimizeButton" Property="Foreground" Value="{StaticResource Brush.TitleBar.InactiveForeground}" />
                            <Setter TargetName="RestoreButton" Property="Foreground" Value="{StaticResource Brush.TitleBar.InactiveForeground}" />
                            <Setter TargetName="MaximizeButton" Property="Foreground" Value="{StaticResource Brush.TitleBar.InactiveForeground}" />
                            <Setter TargetName="CloseButton" Property="Foreground" Value="{StaticResource Brush.TitleBar.InactiveForeground}" />
                        </Trigger>
                    </ControlTemplate.Triggers>
                </ControlTemplate>
            </Setter.Value>
        </Setter>
    </Style>
</ResourceDictionary>

邏輯代碼文件 Window.Universal.xaml.cs(當然,名字可以隨意):

// Window.Universal.xaml.cs
using System;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;

namespace Walterlv.Themes
{
    public class UniversalWindowStyle
    {
        public static readonly DependencyProperty TitleBarProperty = DependencyProperty.RegisterAttached(
            "TitleBar", typeof(UniversalTitleBar), typeof(UniversalWindowStyle),
            new PropertyMetadata(new UniversalTitleBar(), OnTitleBarChanged));

        public static UniversalTitleBar GetTitleBar(DependencyObject element)
            => (UniversalTitleBar) element.GetValue(TitleBarProperty);

        public static void SetTitleBar(DependencyObject element, UniversalTitleBar value)
            => element.SetValue(TitleBarProperty, value);

        public static readonly DependencyProperty TitleBarButtonStateProperty = DependencyProperty.RegisterAttached(
            "TitleBarButtonState", typeof(WindowState?), typeof(UniversalWindowStyle),
            new PropertyMetadata(null, OnButtonStateChanged));

        public static WindowState? GetTitleBarButtonState(DependencyObject element)
            => (WindowState?) element.GetValue(TitleBarButtonStateProperty);

        public static void SetTitleBarButtonState(DependencyObject element, WindowState? value)
            => element.SetValue(TitleBarButtonStateProperty, value);

        public static readonly DependencyProperty IsTitleBarCloseButtonProperty = DependencyProperty.RegisterAttached(
            "IsTitleBarCloseButton", typeof(bool), typeof(UniversalWindowStyle),
            new PropertyMetadata(false, OnIsCloseButtonChanged));

        public static bool GetIsTitleBarCloseButton(DependencyObject element)
            => (bool) element.GetValue(IsTitleBarCloseButtonProperty);

        public static void SetIsTitleBarCloseButton(DependencyObject element, bool value)
            => element.SetValue(IsTitleBarCloseButtonProperty, value);

        private static void OnTitleBarChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
        {
            if (e.NewValue is null) throw new NotSupportedException("TitleBar property should not be null.");
        }

        private static void OnButtonStateChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
        {
            var button = (Button) d;

            if (e.OldValue is WindowState)
            {
                button.Click -= StateButton_Click;
            }

            if (e.NewValue is WindowState)
            {
                button.Click -= StateButton_Click;
                button.Click += StateButton_Click;
            }
        }

        private static void OnIsCloseButtonChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
        {
            var button = (Button) d;

            if (e.OldValue is true)
            {
                button.Click -= CloseButton_Click;
            }

            if (e.NewValue is true)
            {
                button.Click -= CloseButton_Click;
                button.Click += CloseButton_Click;
            }
        }

        private static void StateButton_Click(object sender, RoutedEventArgs e)
        {
            var button = (DependencyObject) sender;
            var window = Window.GetWindow(button);
            var state = GetTitleBarButtonState(button);
            if (window != null && state != null)
            {
                window.WindowState = state.Value;
            }
        }

        private static void CloseButton_Click(object sender, RoutedEventArgs e)
            => Window.GetWindow((DependencyObject) sender)?.Close();
    }

    public class UniversalTitleBar
    {
        public Color ForegroundColor { get; set; } = Colors.Black;
        public Color InactiveForegroundColor { get; set; } = Color.FromRgb(0x99, 0x99, 0x99);
        public Color ButtonHoverForeground { get; set; } = Colors.Black;
        public Color ButtonHoverBackground { get; set; } = Color.FromRgb(0xE6, 0xE6, 0xE6);
        public Color ButtonPressedForeground { get; set; } = Colors.Black;
        public Color ButtonPressedBackground { get; set; } = Color.FromRgb(0xCC, 0xCC, 0xCC);
    }
}

兼容 Windows 10 之前的系統

上面的樣式中我們使用了 Segoe MDL2 Assets 字體,而這款字體僅 Windows 10 上纔有。於是如果我們的應用還要兼容 Windows 10 之前的系統怎麼辦呢?

需要改動兩個地方:

  • 按鈕模板中圖標的顯示方式(從 TextBlock 改成 Path
  • 按鈕圖標的指定方式(從字符串改成 StreamGeometry)。
<ControlTemplate TargetType="Button">
    <Border Name="OverBorder" BorderThickness="0 1 0 0" Background="{TemplateBinding Background}">
        <Path x:Name="MinimizeIcon"
                Fill="{TemplateBinding Foreground}" Data="{TemplateBinding Content}"
                Width="16" Height="16" SnapsToDevicePixels="True"
                HorizontalAlignment="Center" VerticalAlignment="Center"/>
    </Border>
</ControlTemplate>
<Button x:Name="MinimizeButton" themes:UniversalWindowStyle.TitleBarButtonState="Minimized">
    <StreamGeometry>M 3,8 L 3,9 L 13,9 L 13,8 Z</StreamGeometry>
</Button>
<Button x:Name="RestoreButton" themes:UniversalWindowStyle.TitleBarButtonState="Normal">
    <StreamGeometry>M 3,3 L 3,4 L 13,4 L 13,3 Z M 3,12 L 3,13 L 13,13 L 13,12 Z M 3,4 L 3,12 L 4,12 L 4,4 Z M 12,4 L 12,12 L 13,12 L 13,4 Z</StreamGeometry>
</Button>
<Button x:Name="MaximizeButton" themes:UniversalWindowStyle.TitleBarButtonState="Maximized">
    <StreamGeometry>M 3,3 L 3,4 L 13,4 L 13,3 Z M 3,12 L 3,13 L 13,13 L 13,12 Z M 3,4 L 3,12 L 4,12 L 4,4 Z M 12,4 L 12,12 L 13,12 L 13,4 Z</StreamGeometry>
</Button>
<Button x:Name="CloseButton" Style="{StaticResource Style.Button.Close}" themes:UniversalWindowStyle.IsTitleBarCloseButton="True">
    <StreamGeometry>M 3,3 L 3,4 L 4,4 L 4,3 Z M 5,5 L 5,6 L 6,6 L 6,5 Z M 7,7 L 7,9 L 9,9 L 9,7 Z M 9,9 L 9,10 L 10,10 L 10,9 Z M 11,11 L 11,12 L 12,12 L 12,11 Z M 4,4 L 4,5 L 5,5 L 5,4 Z M 6,6 L 6,7 L 7,7 L 7,6 Z M 12,3 L 12,4 L 13,4 L 13,3 Z M 10,10 L 10,11 L 11,11 L 11,10 Z M 12,12 L 12,13 L 13,13 L 13,12 Z M 11,4 L 11,5 L 12,5 L 12,4 Z M 10,5 L 10,6 L 11,6 L 11,5 Z M 9,6 L 9,7 L 10,7 L 10,6 Z M 6,9 L 6,10 L 7,10 L 7,9 Z M 5,10 L 5,11 L 6,11 L 6,10 Z M 4,11 L 4,12 L 5,12 L 5,11 Z M 3,12 L 3,13 L 4,13 L 4,12 Z</StreamGeometry>
</Button>
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章