一、开发背景
从事早期桌面开发的朋友,一定对GroupBox这个控件不陌生。它的原生样式如下:
该控件有一个Header属性,可以设置GroupBox的标题。但这个标题位置默认在左上角,且不能调整。如果你尝试用设置Margin的方式让标题强行居中,那它会呈现如下效果:
这是由于Content的边框缺口是根据Header的布局宽度自动计算的,它自然会把Margin的左右间距计算进去。
二、设计思路
结合上述原理分析,设想一下怎样能够不通过设置Margin的方式让Header居中?
先贴出GroupBox的模板源码:
<ControlTemplate TargetType="{x:Type GroupBox}">
<Grid SnapsToDevicePixels="true">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="6"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="6"/>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="6"/>
</Grid.RowDefinitions>
<!-- Border for the background with the same CornerRadius as the Border with the Header
Using this because if the background is set in the Border with the Header the opacity
mask will be applied to the background as well. -->
<Border CornerRadius="4"
Grid.Row="1"
Grid.RowSpan="3"
Grid.Column="0"
Grid.ColumnSpan="4"
BorderThickness="{TemplateBinding BorderThickness}"
BorderBrush="Transparent"
Background="{TemplateBinding Background}"/>
<Border CornerRadius="4"
Grid.Row="1"
Grid.RowSpan="3"
Grid.ColumnSpan="4"
BorderThickness="{TemplateBinding BorderThickness}"
BorderBrush="White">
<Border.OpacityMask>
<MultiBinding Converter="{StaticResource BorderGapMaskConverter}"
ConverterParameter="7">
<Binding ElementName="Header"
Path="ActualWidth"/>
<Binding RelativeSource="{RelativeSource Self}"
Path="ActualWidth"/>
<Binding RelativeSource="{RelativeSource Self}"
Path="ActualHeight"/>
</MultiBinding>
</Border.OpacityMask>
<Border BorderThickness="{TemplateBinding BorderThickness}"
BorderBrush="{TemplateBinding BorderBrush}"
CornerRadius="3">
<Border BorderThickness="{TemplateBinding BorderThickness}"
BorderBrush="White"
CornerRadius="2"/>
</Border>
</Border>
<!-- ContentPresenter for the header -->
<Border x:Name="Header"
Padding="3,1,3,0"
Grid.Row="0"
Grid.RowSpan="2"
Grid.Column="1">
<ContentPresenter ContentSource="Header"
RecognizesAccessKey="True"
SnapsToDevicePixels="{TemplateBinding SnapsToDevicePixels}"/>
</Border>
<!-- Primary content for GroupBox -->
<ContentPresenter Grid.Row="2"
Grid.Column="1"
Grid.ColumnSpan="2"
Margin="{TemplateBinding Padding}"
SnapsToDevicePixels="{TemplateBinding SnapsToDevicePixels}"/>
</Grid>
</ControlTemplate>
通过阅读源码可以发现,Content的边框缺口是通过透明遮罩OpacityMask实现的,它借助多值绑定和转换器BorderGapMaskConverter,实现了对Header占据区域的边框遮挡。有兴趣的话可以研究下BorderGapMaskConverter的源码。
源码的实现思路是,从距离左边7像素(ConverterParameter设定)的位置开始,将宽度等于Header宽度、高度等于Content高度一半的矩形区域进行遮挡。
这会带来一些问题,如不能根据Header的动态位置来计算遮挡区域距离左边的长度,遮挡面积过大导致GroupBox边框变粗时会挡住一部分左侧边框。
所以应该对BorderGapMaskConverter进行改造,并且多值绑定的对象应该由Header的边框元素Border变为其内容元素ContentPresenter。
三、实现过程
1、定义附加属性类来控制Header排版
public static class GroupBoxAttach
{
public static HorizontalAlignment GetHeaderHorizontalAlignment(DependencyObject obj)
{
return (HorizontalAlignment)obj.GetValue(HeaderHorizontalAlignmentProperty);
}
public static void SetHeaderHorizontalAlignment(DependencyObject obj, HorizontalAlignment value)
{
obj.SetValue(HeaderHorizontalAlignmentProperty, value);
}
// Using a DependencyProperty as the backing store for HeaderHorizontalAlignment. This enables animation, styling, binding, etc...
public static readonly DependencyProperty HeaderHorizontalAlignmentProperty =
DependencyProperty.RegisterAttached("HeaderHorizontalAlignment", typeof(HorizontalAlignment), typeof(GroupBoxAttach), new PropertyMetadata(HorizontalAlignment.Left));
public static Thickness GetHeaderPadding(DependencyObject obj)
{
return (Thickness)obj.GetValue(HeaderPaddingProperty);
}
public static void SetHeaderPadding(DependencyObject obj, Thickness value)
{
obj.SetValue(HeaderPaddingProperty, value);
}
// Using a DependencyProperty as the backing store for HeaderPadding. This enables animation, styling, binding, etc...
public static readonly DependencyProperty HeaderPaddingProperty =
DependencyProperty.RegisterAttached("HeaderPadding", typeof(Thickness), typeof(GroupBoxAttach), new PropertyMetadata(new Thickness(5, 0, 5, 0)));
}
2、实现新的BorderGapMaskConverter
public class BorderGapMaskConverter : IMultiValueConverter
{
public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture)
{
// 第一个参数是标题元素
// 第二个参数是内容边框元素
if (values.Length < 2) return null;
var header = values[0] as FrameworkElement;
var contentBorder = values[1] as Border;
if (header is null || contentBorder is null) return null;
header.UpdateLayout();
var relativePoint = header.TranslatePoint(new Point(), contentBorder);
Point desiredPoint = new Point(relativePoint.X - header.Margin.Left, 0);
Size desiredSize = new Size(Math.Max(header.ActualWidth, header.DesiredSize.Width), contentBorder.BorderThickness.Top + 1);
var drawingBrush = new DrawingBrush()
{
Stretch = Stretch.None,
AlignmentX = AlignmentX.Left,
AlignmentY = AlignmentY.Top,
Drawing = new GeometryDrawing()
{
Brush = Brushes.Black,
Geometry = new CombinedGeometry()
{
GeometryCombineMode = GeometryCombineMode.Xor,
Geometry1 = new RectangleGeometry(new Rect(0, 0, contentBorder.ActualWidth, contentBorder.ActualHeight)),
Geometry2 = new RectangleGeometry(new Rect(desiredPoint, desiredSize))
}
}
};
return drawingBrush;
}
public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture)
{
return default;
}
}
3、对GroupBox模板进行改造
<ControlTemplate TargetType="{x:Type GroupBox}">
<Grid SnapsToDevicePixels="true">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<Border CornerRadius="4"
Grid.RowSpan="2"
Grid.Row="1"
Background="{TemplateBinding Background}"
BorderBrush="Transparent"
BorderThickness="{TemplateBinding BorderThickness}">
<ContentPresenter Grid.Row="1"
Grid.RowSpan="2"
Margin="{TemplateBinding Padding}"
SnapsToDevicePixels="{TemplateBinding SnapsToDevicePixels}"/>
</Border>
<Border CornerRadius="4"
Grid.RowSpan="2"
Grid.Row="1"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
SnapsToDevicePixels="{TemplateBinding SnapsToDevicePixels}">
<Border.OpacityMask>
<MultiBinding Converter="{StaticResource BorderGapMaskConverter}">
<Binding ElementName="HeaderContent"/>
<Binding RelativeSource="{RelativeSource Mode=Self}"/>
<Binding ElementName="HeaderContent" Path="HorizontalAlignment"/>
</MultiBinding>
</Border.OpacityMask>
</Border>
<Border x:Name="Header"
Grid.RowSpan="2"
Grid.Row="0"
Padding="{TemplateBinding ap:GroupBoxAttach.HeaderPadding}">
<ContentPresenter x:Name="HeaderContent"
ContentSource="Header"
RecognizesAccessKey="True"
SnapsToDevicePixels="{TemplateBinding SnapsToDevicePixels}"
HorizontalAlignment="{TemplateBinding ap:GroupBoxAttach.HeaderHorizontalAlignment}"
Margin="5,0"/>
</Border>
</Grid>
</ControlTemplate>
以上,即实现了可控制Header排版的GroupBox: