WPF 自定义 Panel
大约 11 分钟约 3300 字
WPF 自定义 Panel
简介
WPF 自定义 Panel 允许开发者通过重写 MeasureOverride 和 ArrangeOverride 实现任意布局逻辑。理解自定义 Panel 的原理,有助于构建瀑布流、环形布局、流式布局等内置 Panel 无法满足的布局需求。在工业上位机中,自定义 Panel 常用于管道编辑器的自由布局、设备拓扑图的环形排列、仪表盘的自适应网格等场景。
WPF 布局系统概述
WPF 布局系统分为两个阶段:
Measure 阶段(测量) Arrange 阶段(排列)
┌──────────────────────┐ ┌──────────────────────┐
│ 1. Panel.MeasureOverride │ │ 1. Panel.ArrangeOverride │
│ → 计算每个子元素所需大小 │ │ → 确定每个子元素的位置 │
│ │ │ │
│ 2. Child.Measure() │ │ 2. Child.Arrange() │
│ → 子元素报告所需大小 │ │ → 子元素定位到最终位置 │
│ │ │ │
│ 3. 返回 Panel 所需总大小 │ │ 3. 返回 Panel 最终大小 │
└──────────────────────┘ └──────────────────────┘Measure 阶段:父元素询问子元素"你需要多大的空间?",子元素根据内容计算并返回 DesiredSize。父元素根据所有子元素的 DesiredSize 计算自己的所需大小。
Arrange 阶段:父元素告诉子元素"你的位置和大小是这么多",子元素在这个区域内渲染。Arrange 的 finalSize 通常等于或大于 Measure 返回的大小。
内置 Panel 回顾
| Panel | 布局方式 | 适用场景 |
|---|---|---|
| StackPanel | 水平/垂直堆叠 | 导航栏、工具栏 |
| WrapPanel | 流式换行 | 标签云、按钮组 |
| DockPanel | 停靠填充 | 主窗口布局 |
| Grid | 网格行列 | 表单、仪表盘 |
| Canvas | 绝对定位 | 图形编辑器、管道图 |
| UniformGrid | 等分网格 | 日历、数字键盘 |
当以上内置 Panel 都无法满足需求时,就需要自定义 Panel。
特点
实现
流式换行布局(WrapPanel 增强)
// ========== 带间距的流式换行布局 ==========
/// <summary>
/// FlowPanel — 带间距支持的流式换行布局
/// 比 WrapPanel 支持更多自定义属性
/// </summary>
public class FlowPanel : Panel
{
// ===== 附加属性:子元素间距 =====
public static readonly DependencyProperty ItemMarginProperty =
DependencyProperty.RegisterAttached(
"ItemMargin", typeof(Thickness), typeof(FlowPanel),
new FrameworkPropertyMetadata(new Thickness(4),
FrameworkPropertyMetadataOptions.AffectsMeasure |
FrameworkPropertyMetadataOptions.AffectsArrange));
public static Thickness GetItemMargin(DependencyObject obj)
=> (Thickness)obj.GetValue(ItemMarginProperty);
public static void SetItemMargin(DependencyObject obj, Thickness value)
=> obj.SetValue(ItemMarginProperty, value);
// ===== 依赖属性:水平间距 =====
public static readonly DependencyProperty HorizontalSpacingProperty =
DependencyProperty.Register(
"HorizontalSpacing", typeof(double), typeof(FlowPanel),
new FrameworkPropertyMetadata(4.0,
FrameworkPropertyMetadataOptions.AffectsMeasure |
FrameworkPropertyMetadataOptions.AffectsArrange));
public double HorizontalSpacing
{
get => (double)GetValue(HorizontalSpacingProperty);
set => SetValue(HorizontalSpacingProperty, value);
}
// ===== 依赖属性:垂直间距 =====
public static readonly DependencyProperty VerticalSpacingProperty =
DependencyProperty.Register(
"VerticalSpacing", typeof(double), typeof(FlowPanel),
new FrameworkPropertyMetadata(4.0,
FrameworkPropertyMetadataOptions.AffectsMeasure |
FrameworkPropertyMetadataOptions.AffectsArrange));
public double VerticalSpacing
{
get => (double)GetValue(VerticalSpacingProperty);
set => SetValue(VerticalSpacingProperty, value);
}
// ===== 依赖属性:对齐方式 =====
public static readonly DependencyProperty ItemAlignmentProperty =
DependencyProperty.Register(
"ItemAlignment", typeof(HorizontalAlignment), typeof(FlowPanel),
new FrameworkPropertyMetadata(HorizontalAlignment.Left,
FrameworkPropertyMetadataOptions.AffectsArrange));
public HorizontalAlignment ItemAlignment
{
get => (HorizontalAlignment)GetValue(ItemAlignmentProperty);
set => SetValue(ItemAlignmentProperty, value);
}
protected override Size MeasureOverride(Size availableSize)
{
double x = 0, y = 0;
double rowHeight = 0;
double maxWidth = 0;
double rowItemCount = 0;
foreach (UIElement child in InternalChildren)
{
child.Measure(availableSize);
var margin = GetItemMargin(child);
var desired = child.DesiredSize;
var totalWidth = desired.Width + margin.Left + margin.Right;
// 判断是否需要换行(第一个元素不换行)
if (x + totalWidth > availableSize.Width && x > 0)
{
// 换行
x = 0;
y += rowHeight + VerticalSpacing;
rowHeight = 0;
rowItemCount = 0;
}
rowHeight = Math.Max(rowHeight, desired.Height + margin.Top + margin.Bottom);
x += totalWidth + (rowItemCount > 0 ? HorizontalSpacing : 0);
maxWidth = Math.Max(maxWidth, x);
rowItemCount++;
}
return new Size(maxWidth, y + rowHeight);
}
protected override Size ArrangeOverride(Size finalSize)
{
double x = 0, y = 0;
double rowHeight = 0;
double rowItemCount = 0;
double rowStartX = 0;
// 先测量一行的总宽度(用于对齐计算)
foreach (UIElement child in InternalChildren)
{
var margin = GetItemMargin(child);
var totalWidth = child.DesiredSize.Width + margin.Left + margin.Right;
if (x + totalWidth > finalSize.Width && x > 0)
{
x = 0;
y += rowHeight + VerticalSpacing;
rowHeight = 0;
rowItemCount = 0;
}
rowHeight = Math.Max(rowHeight, child.DesiredSize.Height + margin.Top + margin.Bottom);
x += totalWidth + (rowItemCount > 0 ? HorizontalSpacing : 0);
rowItemCount++;
}
// 第二遍:实际排列
x = 0; y = 0; rowHeight = 0; rowItemCount = 0;
double rowTotalWidth = x; // 本行总宽度
foreach (UIElement child in InternalChildren)
{
var margin = GetItemMargin(child);
var desired = child.DesiredSize;
var totalWidth = desired.Width + margin.Left + margin.Right;
if (x + totalWidth > finalSize.Width && x > 0)
{
// 计算对齐偏移
double offsetX = ItemAlignment switch
{
HorizontalAlignment.Center => (finalSize.Width - x + HorizontalSpacing) / 2,
HorizontalAlignment.Right => finalSize.Width - x + HorizontalSpacing,
HorizontalAlignment.Stretch => 0,
_ => 0 // Left
};
x = offsetX;
y += rowHeight + VerticalSpacing;
rowHeight = 0;
rowItemCount = 0;
}
var rect = new Rect(x + margin.Left, y + margin.Top, desired.Width, desired.Height);
child.Arrange(rect);
rowHeight = Math.Max(rowHeight, desired.Height + margin.Top + margin.Bottom);
x += totalWidth + (rowItemCount > 0 ? HorizontalSpacing : 0);
rowItemCount++;
}
return finalSize;
}
}
// XAML 使用
/*
<local:FlowPanel HorizontalSpacing="8" VerticalSpacing="8" ItemAlignment="Center">
<Button Content="按钮 A" local:FlowPanel.ItemMargin="4"/>
<Button Content="按钮 B" local:FlowPanel.ItemMargin="4"/>
<Button Content="按钮 C" local:FlowPanel.ItemMargin="4"/>
</local:FlowPanel>
*/环形布局
// ========== 环形布局 ==========
/// <summary>
/// CircularPanel — 将子元素沿圆周排列
/// 适用于设备拓扑图、环形菜单等场景
/// </summary>
public class CircularPanel : Panel
{
// ===== 旋转偏移角度 =====
public static readonly DependencyProperty AngleOffsetProperty =
DependencyProperty.Register(
"AngleOffset", typeof(double), typeof(CircularPanel),
new FrameworkPropertyMetadata(0.0,
FrameworkPropertyMetadataOptions.AffectsArrange));
public double AngleOffset
{
get => (double)GetValue(AngleOffsetProperty);
set => SetValue(AngleOffsetProperty, value);
}
// ===== 起始角度 =====
public static readonly DependencyProperty StartAngleProperty =
DependencyProperty.Register(
"StartAngle", typeof(double), typeof(CircularPanel),
new FrameworkPropertyMetadata(0.0,
FrameworkPropertyMetadataOptions.AffectsArrange));
public double StartAngle
{
get => (double)GetValue(StartAngleProperty);
set => SetValue(StartAngleProperty, value);
}
// ===== 半径(0 表示自动计算)=====
public static readonly DependencyProperty RadiusProperty =
DependencyProperty.Register(
"Radius", typeof(double), typeof(CircularPanel),
new FrameworkPropertyMetadata(0.0,
FrameworkPropertyMetadataOptions.AffectsMeasure |
FrameworkPropertyMetadataOptions.AffectsArrange));
public double Radius
{
get => (double)GetValue(RadiusProperty);
set => SetValue(RadiusProperty, value);
}
// ===== 方向(顺时针/逆时针)=====
public static readonly DependencyProperty ClockwiseProperty =
DependencyProperty.Register(
"Clockwise", typeof(bool), typeof(CircularPanel),
new FrameworkPropertyMetadata(true,
FrameworkPropertyMetadataOptions.AffectsArrange));
public bool Clockwise
{
get => (bool)GetValue(ClockwiseProperty);
set => SetValue(ClockwiseProperty, value);
}
protected override Size MeasureOverride(Size availableSize)
{
double maxChildSize = 0;
foreach (UIElement child in InternalChildren)
{
child.Measure(availableSize);
maxChildSize = Math.Max(maxChildSize,
Math.Max(child.DesiredSize.Width, child.DesiredSize.Height));
}
int count = InternalChildren.Count;
if (count == 0) return new Size(0, 0);
// 自动计算半径
double radius = Radius > 0
? Radius
: maxChildSize * count / (2 * Math.PI);
var diameter = (radius + maxChildSize) * 2;
return new Size(diameter, diameter);
}
protected override Size ArrangeOverride(Size finalSize)
{
int count = InternalChildren.Count;
if (count == 0) return finalSize;
double centerX = finalSize.Width / 2;
double centerY = finalSize.Height / 2;
// 自动计算或使用指定半径
double maxChildSize = 0;
foreach (UIElement child in InternalChildren)
maxChildSize = Math.Max(maxChildSize,
Math.Max(child.DesiredSize.Width, child.DesiredSize.Height));
double radius = Radius > 0
? Radius
: Math.Min(centerX, centerY) * 0.8;
double direction = Clockwise ? 1 : -1;
double angleStep = direction * (360.0 / count);
for (int i = 0; i < count; i++)
{
var child = InternalChildren[i];
double angle = (StartAngle + AngleOffset + i * angleStep) * Math.PI / 180;
double x = centerX + radius * Math.Cos(angle) - child.DesiredSize.Width / 2;
double y = centerY + radius * Math.Sin(angle) - child.DesiredSize.Height / 2;
child.Arrange(new Rect(x, y, child.DesiredSize.Width, child.DesiredSize.Height));
}
return finalSize;
}
}
// XAML 使用
/*
<local:CircularPanel Radius="120" StartAngle="-90" AngleOffset="0">
<Border Background="Red" Width="40" Height="40" CornerRadius="20"/>
<Border Background="Green" Width="40" Height="40" CornerRadius="20"/>
<Border Background="Blue" Width="40" Height="40" CornerRadius="20"/>
<Border Background="Yellow" Width="40" Height="40" CornerRadius="20"/>
</local:CircularPanel>
*/瀑布流布局
// ========== 瀑布流布局 ==========
/// <summary>
/// WaterfallPanel — 瀑布流布局
/// 子元素自动排列到最短的列中,实现不等高元素的紧凑排列
/// </summary>
public class WaterfallPanel : Panel
{
// ===== 列数 =====
public static readonly DependencyProperty ColumnsProperty =
DependencyProperty.Register(
"Columns", typeof(int), typeof(WaterfallPanel),
new FrameworkPropertyMetadata(3,
FrameworkPropertyMetadataOptions.AffectsMeasure |
FrameworkPropertyMetadataOptions.AffectsArrange),
ValidateColumns);
private static bool ValidateColumns(object value)
{
int columns = (int)value;
return columns >= 1 && columns <= 20;
}
public int Columns
{
get => (int)GetValue(ColumnsProperty);
set => SetValue(ColumnsProperty, value);
}
// ===== 列间距 =====
public static readonly DependencyProperty ColumnSpacingProperty =
DependencyProperty.Register(
"ColumnSpacing", typeof(double), typeof(WaterfallPanel),
new FrameworkPropertyMetadata(8.0,
FrameworkPropertyMetadataOptions.AffectsMeasure |
FrameworkPropertyMetadataOptions.AffectsArrange));
public double ColumnSpacing
{
get => (double)GetValue(ColumnSpacingProperty);
set => SetValue(ColumnSpacingProperty, value);
}
// ===== 行间距 =====
public static readonly DependencyProperty RowSpacingProperty =
DependencyProperty.Register(
"RowSpacing", typeof(double), typeof(WaterfallPanel),
new FrameworkPropertyMetadata(8.0,
FrameworkPropertyMetadataOptions.AffectsMeasure |
FrameworkPropertyMetadataOptions.AffectsArrange));
public double RowSpacing
{
get => (double)GetValue(RowSpacingProperty);
set => SetValue(RowSpacingProperty, value);
}
protected override Size MeasureOverride(Size availableSize)
{
double totalSpacing = ColumnSpacing * (Columns - 1);
double columnWidth = (availableSize.Width - totalSpacing) / Columns;
var columnHeights = new double[Columns];
foreach (UIElement child in InternalChildren)
{
// 测量子元素时限制宽度为列宽
child.Measure(new Size(columnWidth, double.PositiveInfinity));
// 找到最短的列
int shortest = FindShortestColumn(columnHeights);
columnHeights[shortest] += child.DesiredSize.Height + RowSpacing;
}
return new Size(availableSize.Width, columnHeights.Max() - RowSpacing);
}
protected override Size ArrangeOverride(Size finalSize)
{
double totalSpacing = ColumnSpacing * (Columns - 1);
double columnWidth = (finalSize.Width - totalSpacing) / Columns;
var columnHeights = new double[Columns];
foreach (UIElement child in InternalChildren)
{
int shortest = FindShortestColumn(columnHeights);
double x = shortest * (columnWidth + ColumnSpacing);
double y = columnHeights[shortest];
child.Arrange(new Rect(x, y, columnWidth, child.DesiredSize.Height));
columnHeights[shortest] += child.DesiredSize.Height + RowSpacing;
}
return finalSize;
}
private static int FindShortestColumn(double[] columnHeights)
{
int shortest = 0;
double minHeight = columnHeights[0];
for (int i = 1; i < columnHeights.Length; i++)
{
if (columnHeights[i] < minHeight)
{
minHeight = columnHeights[i];
shortest = i;
}
}
return shortest;
}
}
// XAML 使用
/*
<local:WaterfallPanel Columns="3" ColumnSpacing="10" RowSpacing="10">
<Border Background="LightBlue" Height="100" CornerRadius="4"/>
<Border Background="LightGreen" Height="150" CornerRadius="4"/>
<Border Background="LightPink" Height="80" CornerRadius="4"/>
<Border Background="LightYellow" Height="120" CornerRadius="4"/>
</local:WaterfallPanel>
*/自适应网格布局
// ========== 自适应网格布局 ==========
/// <summary>
/// UniformPanel — 根据可用空间自动计算列数的等宽网格
/// 子元素自动缩放到相同大小
/// </summary>
public class UniformPanel : Panel
{
// ===== 最小子元素宽度 =====
public static readonly DependencyProperty MinItemWidthProperty =
DependencyProperty.Register(
"MinItemWidth", typeof(double), typeof(UniformPanel),
new FrameworkPropertyMetadata(100.0,
FrameworkPropertyMetadataOptions.AffectsMeasure));
public double MinItemWidth
{
get => (double)GetValue(MinItemWidthProperty);
set => SetValue(MinItemWidthProperty, value);
}
// ===== 最大子元素宽度 =====
public static readonly DependencyProperty MaxItemWidthProperty =
DependencyProperty.Register(
"MaxItemWidth", typeof(double), typeof(UniformPanel),
new FrameworkPropertyMetadata(200.0,
FrameworkPropertyMetadataOptions.AffectsMeasure));
public double MaxItemWidth
{
get => (double)GetValue(MaxItemWidthProperty);
set => SetValue(MaxItemWidthProperty, value);
}
protected override Size MeasureOverride(Size availableSize)
{
int columns = Math.Max(1, (int)(availableSize.Width / MinItemWidth));
double columnWidth = Math.Min(MaxItemWidth, availableSize.Width / columns);
// 如果宽度还有富余,增加列数
while (columns + 1 <= InternalChildren.Count &&
availableSize.Width / (columns + 1) >= MinItemWidth)
{
columns++;
columnWidth = availableSize.Width / columns;
}
columnWidth = Math.Min(columnWidth, MaxItemWidth);
int rows = (int)Math.Ceiling((double)InternalChildren.Count / columns);
double rowHeight = 0;
foreach (UIElement child in InternalChildren)
{
child.Measure(new Size(columnWidth, double.PositiveInfinity));
rowHeight = Math.Max(rowHeight, child.DesiredSize.Height);
}
return new Size(availableSize.Width, rowHeight * rows);
}
protected override Size ArrangeOverride(Size finalSize)
{
int columns = Math.Max(1, (int)(finalSize.Width / MinItemWidth));
double columnWidth = Math.Min(MaxItemWidth, finalSize.Width / columns);
while (columns + 1 <= InternalChildren.Count &&
finalSize.Width / (columns + 1) >= MinItemWidth)
{
columns++;
columnWidth = finalSize.Width / columns;
}
columnWidth = Math.Min(columnWidth, MaxItemWidth);
// 计算行高
double rowHeight = 0;
foreach (UIElement child in InternalChildren)
rowHeight = Math.Max(rowHeight, child.DesiredSize.Height);
// 计算整体偏移(居中)
double totalWidth = columnWidth * columns;
double offsetX = (finalSize.Width - totalWidth) / 2;
int index = 0;
foreach (UIElement child in InternalChildren)
{
int col = index % columns;
int row = index / columns;
double x = offsetX + col * columnWidth;
double y = row * rowHeight;
child.Arrange(new Rect(x, y, columnWidth, rowHeight));
index++;
}
return finalSize;
}
}布局性能优化
// ========== 布局性能优化策略 ==========
/// <summary>
/// 缓存布局面板 — 缓存子元素的 DesiredSize 避免重复测量
/// 适用于子元素数量多但布局不频繁变化的场景
/// </summary>
public class CachedLayoutPanel : Panel
{
private Size[]? _cachedSizes;
private int _cachedVersion = -1;
protected override Size MeasureOverride(Size availableSize)
{
int count = InternalChildren.Count;
// 检查是否需要重新计算
if (_cachedSizes == null || _cachedSizes.Length != count || IsLayoutDirty())
{
_cachedSizes = new Size[count];
for (int i = 0; i < count; i++)
{
InternalChildren[i].Measure(availableSize);
_cachedSizes[i] = InternalChildren[i].DesiredSize;
}
}
else
{
// 使用缓存的尺寸
for (int i = 0; i < count; i++)
InternalChildren[i].Measure(_cachedSizes[i]);
}
return CalculateTotalSize(_cachedSizes, availableSize);
}
private bool IsLayoutDirty()
{
// 检查子元素是否有标记需要重新测量
foreach (UIElement child in InternalChildren)
{
if (child.IsMeasureValid == false)
return true;
}
return false;
}
private Size CalculateTotalSize(Size[] sizes, Size availableSize)
{
// 根据具体布局算法计算
return new Size(availableSize.Width, sizes.Sum(s => s.Height));
}
}优点
缺点
总结
自定义 Panel 通过重写 MeasureOverride 和 ArrangeOverride 实现任意布局。流式布局、环形布局和瀑布流布局是常见应用场景。建议为子元素提供附加属性控制间距和位置,配合 AffectsMeasure / AffectsArrange 标志实现自动刷新。大数据场景下考虑配合虚拟化或分页。
关键知识点
- WPF 布局分为 Measure(测量)和 Arrange(排列)两个阶段。
- 自定义 Panel 的核心是决定每个子元素的位置和大小。
- 附加属性可以让子元素向父 Panel 传递额外的布局信息。
- AffectsMeasure 和 AffectsArrange 标志确保属性变更时自动触发重新布局。
- 布局性能直接影响渲染帧率,大数据量场景需要考虑虚拟化。
项目落地视角
- 先确定布局需求是否真的需要自定义 Panel,StackPanel + ItemsControl 的组合能解决很多问题。
- 为自定义 Panel 编写单元测试,验证不同数量子元素的布局结果。
- 提供设计时支持(DesignTimeData),方便在 XAML 设计器中预览效果。
- 注意 DPI 缩放和窗口调整时的布局响应。
常见误区
- 在 MeasureOverride 中使用实际像素而不是可用空间计算。
- 忘记在依赖属性上添加 AffectsMeasure/AffectsArrange 标志。
- 在 ArrangeOverride 中没有正确处理子元素的 Margin。
- 把业务逻辑放进 Panel 中,Panel 应只负责布局。
- Measure 和 Arrange 返回不一致的尺寸导致布局抖动。
进阶路线
- 学习 IScrollInfo 接口实现自定义滚动行为。
- 研究 VirtualizingPanel 和 IItemContainerGenerator 实现虚拟化。
- 了解 WPF 布局系统的内部实现(LayoutQueue、LayoutDirtyFlags)。
- 尝试实现支持动画过渡的布局(AnimatedPanel)。
适用场景
- 标准布局控件(StackPanel、Grid、WrapPanel)无法满足布局需求时。
- 需要环形、径向、瀑布流等特殊排列方式。
- 需要通过附加属性精细控制子元素布局参数。
落地建议
- 先在测试项目中验证布局算法的正确性。
- 添加 ClipToBounds 和 Overflow 处理,防止子元素溢出。
- 为 Panel 提供清晰的 XML 文档注释和使用示例。
- 编写单元测试验证边界情况(0 个子元素、1 个子元素、大量子元素)。
排错清单
- 检查 MeasureOverride 返回值是否正确反映所需总大小。
- 检查 ArrangeOverride 是否为每个子元素调用了 Arrange。
- 用 WPF Tree Visualizer 检查实际布局结果。
- 检查附加属性是否正确注册了 AffectsMeasure/AffectsArrange。
复盘问题
- 如果把自定义 Panel 放进你的当前项目,最先要验证的布局场景是什么?
- 自定义 Panel 在窗口大小变化时是否正确响应?你会用什么指标去确认?
- 相比使用多个内置 Panel 嵌套组合,采用自定义 Panel 最大的收益和代价分别是什么?
