1. 前言 做了WPF開發多年,一直未曾自己實現一個自定義Window Style,無論是《WPF編程寶典》或是各種博客都建議使用WindowStyle="None" 和 AllowsTransparency="True",於是想當然以為這樣就可以了。最近來了興緻想自己實現一個,才知道WindowS ...
1. 前言
做了WPF開發多年,一直未曾自己實現一個自定義Window Style,無論是《WPF編程寶典》或是各種博客都建議使用WindowStyle="None"
和 AllowsTransparency="True"
,於是想當然以為這樣就可以了。最近來了興緻想自己實現一個,才知道WindowStyle="None"
的方式根本不好用,原因有幾點:
- 如果Window沒有陰影會很難看,但自己添加DropShadowEffect又十分影響性能。
- 需要自定義彈出、關閉、最大化、最小化動畫,而自己做肯定不如Windows自帶動畫高效。
- 需要實現Resize功能。
- 其它BUG。
光是性能問題就足以放棄WindowStyle="None"
的實現方式,幸好還有使用WindowChrome的實現方式,但一時之間也找不到理想的實現,連MSDN上的文檔( WindowChrome Class )都太過時,.NET 4.5也沒有SystemParameters2這個類,只好參考一些開源項目(如 Modern UI for WPF )自己實現了。
2. Window基本功能
Window的基本功能如上圖所示。註意除了標準的“最小化”、“最大化/還原”、"關閉"按鈕外,Icon上單擊還應該能打開窗體的系統菜單,雙擊則直接關閉窗體。
我想實現類似Office 2016的Window效果:陰影、自定義窗體顏色。陰影、動畫效果保留系統預設的就可以了,基本上會很耐看。
大多數自定義Window都有圓角,但我並不喜歡,低DPI的情況下只有幾個像素組成的圓角通常都不會很圓滑(如下圖),所以保留直角。
另外,激活、非激活狀態下標題欄顏色變更:
最終效果如下:
3. 實現
3.1 定義CustomWindow控制項
首先,為了方便以後的擴展,我定義了一個名為CustomWindow的模板化控制項派生自Window。
public class CustomWindow : Window
{
public CustomWindow()
{
DefaultStyleKey = typeof(CustomWindow);
CommandBindings.Add(new CommandBinding(SystemCommands.CloseWindowCommand, CloseWindow));
CommandBindings.Add(new CommandBinding(SystemCommands.MaximizeWindowCommand, MaximizeWindow, CanResizeWindow));
CommandBindings.Add(new CommandBinding(SystemCommands.MinimizeWindowCommand, MinimizeWindow, CanMinimizeWindow));
CommandBindings.Add(new CommandBinding(SystemCommands.RestoreWindowCommand, RestoreWindow, CanResizeWindow));
CommandBindings.Add(new CommandBinding(SystemCommands.ShowSystemMenuCommand, ShowSystemMenu));
}
protected override void OnMouseLeftButtonDown(MouseButtonEventArgs e)
{
base.OnMouseLeftButtonDown(e);
if (e.ButtonState == MouseButtonState.Pressed)
DragMove();
}
protected override void OnContentRendered(EventArgs e)
{
base.OnContentRendered(e);
if (SizeToContent == SizeToContent.WidthAndHeight)
InvalidateMeasure();
}
#region Window Commands
private void CanResizeWindow(object sender, CanExecuteRoutedEventArgs e)
{
e.CanExecute = ResizeMode == ResizeMode.CanResize || ResizeMode == ResizeMode.CanResizeWithGrip;
}
private void CanMinimizeWindow(object sender, CanExecuteRoutedEventArgs e)
{
e.CanExecute = ResizeMode != ResizeMode.NoResize;
}
private void CloseWindow(object sender, ExecutedRoutedEventArgs e)
{
this.Close();
//SystemCommands.CloseWindow(this);
}
private void MaximizeWindow(object sender, ExecutedRoutedEventArgs e)
{
SystemCommands.MaximizeWindow(this);
}
private void MinimizeWindow(object sender, ExecutedRoutedEventArgs e)
{
SystemCommands.MinimizeWindow(this);
}
private void RestoreWindow(object sender, ExecutedRoutedEventArgs e)
{
SystemCommands.RestoreWindow(this);
}
private void ShowSystemMenu(object sender, ExecutedRoutedEventArgs e)
{
var element = e.OriginalSource as FrameworkElement;
if (element == null)
return;
var point = WindowState == WindowState.Maximized ? new Point(0, element.ActualHeight)
: new Point(Left + BorderThickness.Left, element.ActualHeight + Top + BorderThickness.Top);
point = element.TransformToAncestor(this).Transform(point);
SystemCommands.ShowSystemMenu(this, point);
}
#endregion
}
主要是添加了幾個CommandBindings,用於給標題欄上的按鈕綁定。
3.2 使用WindowChrome
對於WindowChrome,MSDN是這樣描述的:
若要自定義視窗,同時保留其標準功能,可以使用WindowChrome類。 WindowChrome類視窗框架的功能分離開來視覺對象,並允許您控制的客戶端和應用程式視窗的非工作區之間的邊界。
在CustomWindow的DefaultStyle中添加如下Setting:
<Setter Property="WindowChrome.WindowChrome">
<Setter.Value>
<WindowChrome CornerRadius="0"
GlassFrameThickness="1"
UseAeroCaptionButtons="False"
NonClientFrameEdges="None" />
</Setter.Value>
</Setter>
這樣除了包含陰影的邊框,整個Window的內容就可以由用戶定義了。
3.3 Window基本佈局
<Border BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
x:Name="WindowBorder">
<Grid x:Name="LayoutRoot"
Background="{TemplateBinding Background}">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<Grid x:Name="PART_WindowTitleGrid"
Grid.Row="0"
Height="26.4"
Background="{TemplateBinding BorderBrush}">
....
</Grid>
<AdornerDecorator Grid.Row="1" KeyboardNavigation.IsTabStop="False">
<ContentPresenter x:Name="MainContentPresenter"
KeyboardNavigation.TabNavigation="Cycle" />
</AdornerDecorator>
<ResizeGrip x:Name="ResizeGrip"
HorizontalAlignment="Right"
VerticalAlignment="Bottom"
Grid.Row="1"
IsTabStop="False"
Visibility="Hidden"
WindowChrome.ResizeGripDirection="BottomRight" />
</Grid>
</Border>
Window的標準佈局很簡單,大致上就是標題欄和內容。
PART_WindowTitleGrid是標題欄,具體內容下一節再討論。
ContentPresenter的內容即Window的Client Area的範圍。
ResizeGrip是當ResizeMode = ResizeMode.CanResizeWithGrip;
時出現的Window右下角的大小調整手柄,基本上用於提示視窗可以通過拖動邊框改調整小。
AdornerDecorator 為可視化樹中的子元素提供 AdornerLayer,如果沒有它的話一些裝飾效果不能顯示(例如下圖Button控制項的Focus效果),Window的 ContentPresenter 外面套個 AdornerDecorator 是 必不能忘的。
3.4 佈局標題欄
<Button x:Name="Minimize"
ToolTip="Minimize"
WindowChrome.IsHitTestVisibleInChrome="True"
Command="{Binding Source={x:Static SystemCommands.MinimizeWindowCommand}}"
ContentTemplate="{StaticResource MinimizeWhite}"
Style="{StaticResource TitleBarButtonStyle}"
IsTabStop="False" />
標題欄上的按鈕實現如上,將Command綁定到SystemCommands,並且設置WindowChrome.IsHitTestVisibleInChrome="True"
,標題欄上的內容要設置這個附加屬性才能響應滑鼠操作。
<Button VerticalAlignment="Center"
Margin="7,0,5,0"
Content="{TemplateBinding Icon}"
Height="{x:Static SystemParameters.SmallIconHeight}"
Width="{x:Static SystemParameters.SmallIconWidth}"
WindowChrome.IsHitTestVisibleInChrome="True"
IsTabStop="False">
<Button.Template>
<ControlTemplate TargetType="{x:Type Button}">
<Image Source="{TemplateBinding Content}" />
</ControlTemplate>
</Button.Template>
<i:Interaction.Triggers>
<i:EventTrigger EventName="Click">
<i:InvokeCommandAction Command="{x:Static SystemCommands.ShowSystemMenuCommand}" />
</i:EventTrigger>
<i:EventTrigger EventName="MouseDoubleClick">
<i:InvokeCommandAction Command="{x:Static SystemCommands.CloseWindowCommand}" />
</i:EventTrigger>
</i:Interaction.Triggers>
</Button>
標題欄上的Icon也是一個按鈕,單機打開SystemMenu,雙擊關閉Window。Height和Widht的值分別使用了SystemParameters.SmallIconHeight
和SystemParameters.SmallIconWidth
,SystemParameters包含可用來查詢系統設置的屬性,能使用SystemParameters的地方儘量使用總是沒錯的。
按鈕的樣式沒實現得很好,這點暫時將就一下,以後改進吧。
3.5 處理Triggers
<ControlTemplate.Triggers>
<Trigger Property="IsActive"
Value="False">
<Setter Property="BorderBrush"
Value="#FF6F7785" />
</Trigger>
<Trigger Property="WindowState"
Value="Maximized">
<Setter TargetName="Maximize"
Property="Visibility"
Value="Collapsed" />
<Setter TargetName="Restore"
Property="Visibility"
Value="Visible" />
<Setter TargetName="LayoutRoot"
Property="Margin"
Value="7" />
</Trigger>
<Trigger Property="WindowState"
Value="Normal">
<Setter TargetName="Maximize"
Property="Visibility"
Value="Visible" />
<Setter TargetName="Restore"
Property="Visibility"
Value="Collapsed" />
</Trigger>
<Trigger Property="ResizeMode"
Value="NoResize">
<Setter TargetName="Minimize"
Property="Visibility"
Value="Collapsed" />
<Setter TargetName="Maximize"
Property="Visibility"
Value="Collapsed" />
<Setter TargetName="Restore"
Property="Visibility"
Value="Collapsed" />
</Trigger>
<MultiTrigger>
<MultiTrigger.Conditions>
<Condition Property="ResizeMode"
Value="CanResizeWithGrip" />
<Condition Property="WindowState"
Value="Normal" />
</MultiTrigger.Conditions>
<Setter TargetName="ResizeGrip"
Property="Visibility"
Value="Visible" />
</MultiTrigger>
</ControlTemplate.Triggers>
雖然我平時喜歡用VisualState的方式實現模板化控制項UI再狀態之間的轉變,但有時還是Trigger方便快捷,尤其是不需要做動畫的時候。
註意當WindowState=Maximized時要將LayoutRoot的Margin設置成7,如果不這樣做在最大化時Window邊緣部分會被遮蔽,很多使用WindowChrome自定義Window的方案都沒有處理這點。
3.6 處理導航
另一點需要註意的是鍵盤導航。一般來說Window中按Tab鍵,焦點會在Window的內容間迴圈,不要讓標題欄的按鈕獲得焦點,也不要讓ContentPresenter 的各個父元素獲得焦點,所以在ContentPresenter 上設置KeyboardNavigation.TabNavigation="Cycle"
。為了不讓標題欄上的各個按鈕獲得焦點,在各個按鈕上還設置了IsTabStop="False"
,
3.7 DragMove
有些人喜歡不止標題欄,按住Window的任何空白部分都可以拖動Window,只需要在代碼中添加DragMove即可:
protected override void OnMouseLeftButtonDown(MouseButtonEventArgs e)
{
base.OnMouseLeftButtonDown(e);
if (e.ButtonState == MouseButtonState.Pressed)
DragMove();
}
3.8 移植TransitioningContentControl
索性讓Window打開時內容也添加一些動畫。我將Silverlight Toolkit的TransitioningContentControl
複製過來,只改了一點動畫,並且在OnApplyTemplate()
最後添加了這句:VisualStateManager.GoToState(this, Transition, true);
。最後將Window中的ContentPresenter 替換成這個控制項,效果還不錯(實際效果挺流暢的,可是GIF看起來不怎麼樣):
3.9 SizeToContent問題
有個比較麻煩的問題,當設置SizeToContent="WidthAndHeight"
,打開Window會出現以下錯誤。
看上去是內容的Size和Window的Size計算錯誤,目前的解決方法是在CustomWindow中添加以下代碼,簡單粗暴,但可能引發其它問題:
protected override void OnContentRendered(EventArgs e)
{
base.OnContentRendered(e);
if (SizeToContent == SizeToContent.WidthAndHeight)
InvalidateMeasure();
}
5. 結語
第一次寫Window樣式,想不到遇到這麼多需要註意的地方。
目前只是個很簡單的Demo,沒有添加額外的功能,希望對他人有幫助吧。
編碼在Window10上完成,只在Windows7上稍微測試了一下,不敢保證相容性。
如有錯漏請指出。
6. 參考
Window Styles and Templates
WindowChrome 類
SystemParameters 類
mahapps.metro
Modern UI for WPF