1. 程式人生 > >[WPF自定義控制元件庫]以Button為例談談如何模仿Aero2主題

[WPF自定義控制元件庫]以Button為例談談如何模仿Aero2主題

1. 為什麼選擇Aero2

除了以外觀為賣點的控制元件庫,WPF的控制元件庫都預設使用“素顏”的外觀,然後再提供一些主題包。這樣做的最大好處是可以和原生控制元件或其它控制元件庫相容,而且對於大部分人來說模仿原生的主題也比自己設計一套好看的UI容易得多。

WPF有以下幾種原生主題:

主題檔案 桌面主題
Classic.xaml Windows XP 作業系統上的經典 Windows 外觀(Windows 95、Windows 98 和 Windows 2000)。
Luna.NormalColor.xaml Windows XP 上的預設藍色主題。
Luna.Homestead.xaml Windows XP 上的橄欖色主題。
Luna.Metallic.xaml Windows XP 上的銀色主題。
Royale.NormalColor.xaml Windows XP Media Center Edition 作業系統上的預設主題。
Aero.NormalColor.xaml Windows Vista 作業系統上的預設主題。

Win8之後WPF更新了Aero2和AeroLite兩種主題,關於Aero、Aero2、AeroLite具體可見這個網頁。再之後微軟就沒有更新WPF主題了。

如果不在程式碼中指定主題,WPF大概就是用這段程式碼確定主題,也就是說預設是Aero,如果在Win8或以上自動轉為Aero2:

_themeName = themeName.ToString();
_themeName = Path.GetFileNameWithoutExtension(_themeName);

if(String.Compare(_themeName, "aero", StringComparison.OrdinalIgnoreCase) == 0 && Utilities.IsOSWindows8OrNewer)
{
    _themeName = "Aero2";
}

由於我暫時不想相容Win7,而且我又不討厭Win8的風格,所以Kino.Toolkit.Wpf直接選擇了Aero2作為控制元件庫的主題。

2. Aero2的設計

上面分別是Aero2(左)和Aero(右)的Button在幾種狀態下的外觀,從中可以看出Aero2的設計是扁平化的風格,移除圓角、漸變等裝飾性元素,以實用為目的。這樣一來控制元件模板的結構更加簡單(如Button只有Border和ContentPresenter 兩個元素),移除裝飾性元素更節省空間,而且漸變在質量較差或陽光下很影響閱讀,圓角則是佔用更多空間而且在低解析度下表現不好。

總的來說就是以實用為目的,儘量簡單,減少裝飾性元素。

3. 以Button為例,談談Aero2中的細節:尺寸、顏色、字型、動畫

<Style x:Key="FocusVisual">
    <Setter Property="Control.Template">
        <Setter.Value>
            <ControlTemplate>
                <Rectangle Margin="2" SnapsToDevicePixels="true" Stroke="{DynamicResource {x:Static SystemColors.ControlTextBrushKey}}" StrokeThickness="1" StrokeDashArray="1 2"/>
            </ControlTemplate>
        </Setter.Value>
    </Setter>
</Style>
<SolidColorBrush x:Key="Button.Static.Background" Color="#FFDDDDDD"/>
<SolidColorBrush x:Key="Button.Static.Border" Color="#FF707070"/>
<SolidColorBrush x:Key="Button.MouseOver.Background" Color="#FFBEE6FD"/>
<SolidColorBrush x:Key="Button.MouseOver.Border" Color="#FF3C7FB1"/>
<SolidColorBrush x:Key="Button.Pressed.Background" Color="#FFC4E5F6"/>
<SolidColorBrush x:Key="Button.Pressed.Border" Color="#FF2C628B"/>
<SolidColorBrush x:Key="Button.Disabled.Background" Color="#FFF4F4F4"/>
<SolidColorBrush x:Key="Button.Disabled.Border" Color="#FFADB2B5"/>
<SolidColorBrush x:Key="Button.Disabled.Foreground" Color="#FF838383"/>
<Style TargetType="{x:Type Button}">
    <Setter Property="FocusVisualStyle" Value="{StaticResource FocusVisual}"/>
    <Setter Property="Background" Value="{StaticResource Button.Static.Background}"/>
    <Setter Property="BorderBrush" Value="{StaticResource Button.Static.Border}"/>
    <Setter Property="Foreground" Value="{DynamicResource {x:Static SystemColors.ControlTextBrushKey}}"/>
    <Setter Property="BorderThickness" Value="1"/>
    <Setter Property="HorizontalContentAlignment" Value="Center"/>
    <Setter Property="VerticalContentAlignment" Value="Center"/>
    <Setter Property="Padding" Value="1"/>
    <Setter Property="Template">
        <Setter.Value>
            <ControlTemplate TargetType="{x:Type Button}">
                <Border x:Name="border" BorderBrush="{TemplateBinding BorderBrush}" BorderThickness="{TemplateBinding BorderThickness}" Background="{TemplateBinding Background}" SnapsToDevicePixels="true">
                    <ContentPresenter x:Name="contentPresenter" Focusable="False" HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}" 
                                      Margin="{TemplateBinding Padding}" RecognizesAccessKey="True" SnapsToDevicePixels="{TemplateBinding SnapsToDevicePixels}" VerticalAlignment="{TemplateBinding VerticalContentAlignment}"/>
                </Border>
                <ControlTemplate.Triggers>
                    <Trigger Property="IsDefaulted" Value="true">
                        <Setter Property="BorderBrush" TargetName="border" Value="{DynamicResource {x:Static SystemColors.HighlightBrushKey}}"/>
                    </Trigger>
                    <Trigger Property="IsMouseOver" Value="true">
                        <Setter Property="Background" TargetName="border" Value="{StaticResource Button.MouseOver.Background}"/>
                        <Setter Property="BorderBrush" TargetName="border" Value="{StaticResource Button.MouseOver.Border}"/>
                    </Trigger>
                    <Trigger Property="IsPressed" Value="true">
                        <Setter Property="Background" TargetName="border" Value="{StaticResource Button.Pressed.Background}"/>
                        <Setter Property="BorderBrush" TargetName="border" Value="{StaticResource Button.Pressed.Border}"/>
                    </Trigger>
                    <Trigger Property="IsEnabled" Value="false">
                        <Setter Property="Background" TargetName="border" Value="{StaticResource Button.Disabled.Background}"/>
                        <Setter Property="BorderBrush" TargetName="border" Value="{StaticResource Button.Disabled.Border}"/>
                        <Setter Property="TextElement.Foreground" TargetName="contentPresenter" Value="{StaticResource Button.Disabled.Foreground}"/>
                    </Trigger>
                </ControlTemplate.Triggers>
            </ControlTemplate>
        </Setter.Value>
    </Setter>
</Style>

這是Aero2使用Blend獲取的Button控制元件模板。因為Button是最基礎最常用最具代表性的控制元件,所以以它為例談談Aero2主題中的各種細節。

3.1 尺寸

首先考慮下控制元件是否有必要有統一的尺寸。

我記得很久很久以前微軟有份文件要求桌面按鈕的高度是22畫素(有可能是23,已經不記得了)。微軟自己有沒有遵守?真是太看得起微軟了。

就以IE來說,上圖從上到下幾組按鈕的高度分別是21,28,24畫素。

這個頁面大部分按鈕都是28,只有中間那個“將所有區域重置為預設級別”是30畫素。

可以看出,微軟一直以來開放、包容、擁抱多元化的策略,在IE上可以說是完美體現。作為對比我看了看Chrome的類似按鈕,統一為32畫素,看來有很好地執行Material Design中"所有距離,尺寸都應該是8dp的整數倍"的要求(到處都是8,可以說深得中國人歡心)。

<Rectangle Height="1" Fill="Gray" />
<StackPanel Orientation="Horizontal"
            HorizontalAlignment="Center">
    <Button Content="Button" VerticalAlignment="Center" />
    <TextBox Text="TextBox" VerticalAlignment="Center" />
    <PasswordBox Password="password" VerticalAlignment="Center" />
    <ComboBox VerticalAlignment="Center">
        <ComboBoxItem Content="ComboBox" IsSelected="True"/>
    </ComboBox>
    <DatePicker VerticalAlignment="Center"/>
</StackPanel>
<Rectangle Height="1" Fill="Gray" />

順便拿Button與WPF的其它控制元件、及UWP的相同控制元件做橫向對比,使用相同的XAML產生的UI如上圖所示(上為UWP,下為WPF)。可以看出UWP的表單元素基本上完全統一高度,而WPF則根據內容自適應。

總結來說,WPF原生控制元件通常沒有設定具體的尺寸,所以模仿Aero2主題的自定義控制元件也不應該改變這個行為,只需控制元件要能夠清晰展示資料及容易操作就好(也就是符合基本的UI設計原則)。

我建議在實際專案中根據需要使用樣式將按鈕的高度統一為24、28、32畫素(The sizes, margins, and positions of UI elements should always be in multiples of 4 epx in your UWP apps.,因為Windows系統的縮放比例總是5/4(125%)、6/4(150%)、7/4(175%)、8/4(200%),所以尺寸最好是4的倍數,真不吉利)。

3.2 顏色

從Button的控制元件模板可以看到Button的字型顏色使用了{DynamicResource {x:Static SystemColors.ControlTextBrushKey}}。WPF為系統環境封裝了三個類,用於訪問系統環境設定:

  • SystemFonts,包含公開有關字型的系統資源的屬性。
  • SystemColors,包含與系統顯示元素相對應的系統顏色、系統畫筆和系統資源鍵。
  • SystemParameters,包含可用來查詢系統設定的屬性。

使用方式可以參考資源幫助主題。

這些設定只應用作參考,可以看到Button也只是主要使用了ControlTextBrushKey,Aero2主題有自己的顏色風格,不會跟隨系統而改變。

再次橫向比較一下,這次試用Disabled狀態作比較,可以看到每個控制元件的邊框無論在Enabled或Disabled的狀態下邊框顏色都不一樣(除了TextBox和PasswordBox,他們關係好)。

因為看不到Aero2在顏色上有什麼要求,我的建議是,如果自定義的控制元件長得像TextBox就使用TextBox的顏色設定,長得像Button的就用Button,總之儘量模仿原生控制元件,顏色也儘量使用藍色或灰色就可以了。

3.3 字型

只有Menu、StatusBar、Toolbar等有限幾個控制元件會使用SystemFonts的值,其它都可以使用繼承值。這樣可以方便地通過在根元素設定字型來統一字型的使用。

3.4 動畫

幾乎、完全、沒有。也許是為了兼顧Windows的UI,或者照顧低端配置的電腦,Aero2裡真的幾乎完全看不到動畫效果,一眼看過去所有Storyboard的Duration都是0。也好,以和Aero2統一風格作藉口我也可以不做動畫啦。

最近我發現lindexi這樣介紹我:

其實我也並不是那麼喜歡親自寫動畫,只是WPF和UWP裡連最基本的都沒提供所以我才在這方面鼓起幹勁努力了一把。

4. 提供VisualState

<ControlTemplate TargetType="local:KinoButton">
    <Border x:Name="border"
            BorderBrush="{TemplateBinding BorderBrush}"
            BorderThickness="{TemplateBinding BorderThickness}"
            Background="{TemplateBinding Background}"
            SnapsToDevicePixels="true">
        <VisualStateManager.VisualStateGroups>
            <VisualStateGroup x:Name="CommonStates">
                <VisualState x:Name="Normal" />
                <VisualState x:Name="MouseOver">
                    <Storyboard>
                        <ObjectAnimationUsingKeyFrames Storyboard.TargetProperty="(Panel.Background)"
                                                       Storyboard.TargetName="border">
                            <DiscreteObjectKeyFrame KeyTime="0"
                                                    Value="{StaticResource Button.MouseOver.Background}" />
                        </ObjectAnimationUsingKeyFrames>
                        <ObjectAnimationUsingKeyFrames Storyboard.TargetProperty="(Border.BorderBrush)"
                                                       Storyboard.TargetName="border">
                            <DiscreteObjectKeyFrame KeyTime="0"
                                                    Value="{StaticResource Button.MouseOver.Border}" />
                        </ObjectAnimationUsingKeyFrames>
                    </Storyboard>
                </VisualState>
                <VisualState x:Name="Pressed">
                    <Storyboard>
                        <ObjectAnimationUsingKeyFrames Storyboard.TargetProperty="(Border.BorderBrush)"
                                                       Storyboard.TargetName="border">
                            <DiscreteObjectKeyFrame KeyTime="0"
                                                    Value="{StaticResource Button.Pressed.Border}" />
                        </ObjectAnimationUsingKeyFrames>
                        <ObjectAnimationUsingKeyFrames Storyboard.TargetProperty="(Panel.Background)"
                                                       Storyboard.TargetName="border">
                            <DiscreteObjectKeyFrame KeyTime="0"
                                                    Value="{StaticResource Button.Pressed.Background}" />
                        </ObjectAnimationUsingKeyFrames>
                    </Storyboard>
                </VisualState>
                <VisualState x:Name="Disabled">
                    <Storyboard>
                        <ObjectAnimationUsingKeyFrames Storyboard.TargetProperty="(TextElement.Foreground)"
                                                       Storyboard.TargetName="contentPresenter">
                            <DiscreteObjectKeyFrame KeyTime="0"
                                                    Value="{StaticResource Button.Disabled.Foreground}" />
                        </ObjectAnimationUsingKeyFrames>
                        <ObjectAnimationUsingKeyFrames Storyboard.TargetProperty="(Panel.Background)"
                                                       Storyboard.TargetName="border">
                            <DiscreteObjectKeyFrame KeyTime="0"
                                                    Value="{StaticResource Button.Disabled.Background}" />
                        </ObjectAnimationUsingKeyFrames>
                        <ObjectAnimationUsingKeyFrames Storyboard.TargetProperty="(Border.BorderBrush)"
                                                       Storyboard.TargetName="border">
                            <DiscreteObjectKeyFrame KeyTime="0"
                                                    Value="{StaticResource Button.Disabled.Border}" />
                        </ObjectAnimationUsingKeyFrames>
                    </Storyboard>
                </VisualState>
            </VisualStateGroup>
        </VisualStateManager.VisualStateGroups>
        <Grid   HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"
                VerticalAlignment="{TemplateBinding VerticalContentAlignment}"
                Margin="{TemplateBinding Padding}">
            <Grid.ColumnDefinitions>
                <ColumnDefinition Width="Auto" />
                <ColumnDefinition />
            </Grid.ColumnDefinitions>
            <!--comecode-->

            <ContentPresenter x:Name="contentPresenter"
                              Grid.Column="1"
                              Focusable="False"
                              RecognizesAccessKey="True"
                              VerticalAlignment="{TemplateBinding VerticalContentAlignment}"
                              SnapsToDevicePixels="{TemplateBinding SnapsToDevicePixels}" />
        </Grid>
    </Border>
    <ControlTemplate.Triggers>
        <Trigger Property="IsDefaulted"
                 Value="true">
            <Setter Property="BorderBrush"
                    TargetName="border"
                    Value="{DynamicResource {x:Static SystemColors.HighlightBrushKey}}" />
        </Trigger>
    </ControlTemplate.Triggers>
</ControlTemplate>

出於好玩,我把KinoButton(主要是在Button的基礎上添加了Icon的功能)的控制元件模板從使用Trigger改為儘量使用VisualState,這樣做沒什麼實際意義,真的只是好玩而已,而且XAML的行數還增加了不少。

不過在實現其它自定義控制元件的時候我也比較傾向提供VisualState,因為這樣可以明確指出控制元件外觀有幾種狀態,避免了混輪,而且提供了VisualState可以更方便擴充套件。這點WPF原生控制元件也是一樣的,它們很多都沒有宣告TemplateVisualState,而且ControlTemplate也沒有使用VisualState,但使用Blend編輯控制元件模板還是可以在“狀態”面板看到它的TemplateVisualState(其中FocusStates和ValidationStates可以不使用,如果修改了這兩組狀態也就是讓控制元件外觀更個性化而已)。對終端使用者來說多一個選擇並不是壞事。

5. 結語

通過這篇文章讀者應該對Aero2的風格有了一定程度的瞭解。更多Aero和Aero2的相關資訊可以看這個Github專案。

很多控制元件庫都會提供額外的主題包,這點可以放到後面再考慮。

6. 參考

Control樣式和模板
資源幫助主題
PresentationTheme.Aero