☰
WPF自定义控件实战:用ProgressBar模板打造垂直温度计
2026/9/25 18:16:17 网站建设 项目流程

简介:这份资源面向具备一定WPF基础的开发者,聚焦如何把默认水平的ProgressBar改造成垂直温度计样式的自定义控件,解决常规进度条难以满足仪表类UI需求的问题。内容围绕Orientation属性设置、ControlTemplate模板重写、Path指针与ScaleTransform动态缩放、ValueChanged动画过渡以及温度数值与单位绑定等知识点展开,适合想深入掌握WPF控件模板与数据绑定的中高级学习者。压缩包为rar格式,共41个文件,约215KB,包含12个cs源码、2个xaml界面文件、1个sln解决方案及csproj工程文件,另有exe、config、pdb等编译与配置产物,构成可直接运行的Temperature_Demo示例项目。目前已有758人学习下载。通过该示例,读者可对照完整工程理解垂直温度计的模板结构、动画实现与绑定写法,并在此基础上扩展出仪表盘、刻度尺等自定义控件,提升界面表现力。

1. 垂直温度计:一个被低估的 WPF 自定义控件练手场景

工业上位机、环境监控看板、设备状态面板里,垂直温度计出现的频率远比想象中高。它看起来只是把 ProgressBar 转 90 度,但真动手做就会发现:WPF 自带的 ProgressBar 模板是横向布局,直接旋转会把文字、边框、圆角一起转歪,刻度线也没法对齐。更麻烦的是,温度计需要双向映射——液柱高度对应温度值,刻度标注又要反过来对应像素位置,这两套坐标一旦没对齐,读数就会“看起来对、实际偏”。

这个标题真正要解决的问题,是用 WPF 的 ProgressBar 作为数值驱动核心,配合 ControlTemplate 和 ItemsControl,做出一个可复用、可绑定、带刻度标注的垂直温度计控件。它适合已经会写 WPF 基础绑定、想进阶自定义控件模板的开发者,也适合做上位机界面时被“横向进度条太丑”折磨过的工程师。下面按“先立住原理、再动手复现、最后避坑”的顺序拆开讲。

2. 为什么不能直接旋转 ProgressBar:模板结构与坐标映射

2.1 ProgressBar 的默认模板到底长什么样

WPF 的 ProgressBar 默认模板由PART_Track和PART_Indicator两个关键部件组成。PART_Track是背景轨道,PART_Indicator是随 Value 变化的填充条。默认模板里,Indicator 的宽度通过ScaleTransform的ScaleX绑定到Value / Maximum,方向是水平从左到右。

如果你直接给 ProgressBar 加LayoutTransform或RenderTransform旋转 90 度,会发生三件事:第一,文字和刻度跟着转,可读性归零;第二,ScaleX仍然按水平方向计算,旋转后视觉上变成从上到下还是从下到上,取决于旋转方向,容易搞反;第三,控件的测量尺寸和实际占位不匹配,放进 Grid 里会撑出奇怪的空白。

所以正确思路不是旋转控件,而是重写 ControlTemplate,把 Indicator 的缩放方向改成垂直方向,用ScaleY替代ScaleX。

2.2 垂直温度计的坐标映射模型

温度计的核心是两套坐标的线性映射。设控件绘图区高度为 H,温度范围为[MinTemp, MaxTemp],当前温度为 T,则液柱高度 h 满足:

h = H * (T - MinTemp) / (MaxTemp - MinTemp)

反过来,给定像素位置 y(从底部算起),对应温度:

T = MinTemp + (y / H) * (MaxTemp - MinTemp)

刻度线的位置也按同一公式计算。这里有一个容易翻车的点:WPF 的 Y 轴向下为正,而温度计液柱从底部向上生长,所以 Indicator 的RenderTransformOrigin必须设为(0.5, 1),即底部中心,再用ScaleY控制高度。如果原点设错,液柱会从顶部往下缩,看起来像“温度越高液柱越短”。

2.3 选型对比:ProgressBar 模板 vs 纯 Canvas 绘制

方案数值驱动可复用性刻度标注适用场景
旋转 ProgressBar直接绑定 Value差,旋转后布局错乱难对齐临时演示
重写 ControlTemplate绑定 Value,ScaleY 驱动好,可做成 Style需额外 ItemsControl正式项目
纯 Canvas 手绘手动计算所有位置差,每次重写灵活但代码量大一次性看板

我一般会选第二种:ProgressBar 负责数值和动画,ControlTemplate 负责垂直液柱,刻度用 ItemsControl 单独叠一层。这样数值逻辑和视觉逻辑分离,后面改温度范围、改颜色、加报警阈值都不用动模板结构。

3. 动手写一个可绑定的垂直温度计控件

3.1 第一步:定义依赖属性与温度范围

先建一个VerticalThermometer类,继承ProgressBar,这样 Value、Maximum、Minimum 直接复用,不用重新造轮子。额外加三个依赖属性:MinTemp、MaxTemp、Unit。

public class VerticalThermometer : ProgressBar { static VerticalThermometer() { DefaultStyleKeyProperty.OverrideMetadata( typeof(VerticalThermometer), new FrameworkPropertyMetadata(typeof(VerticalThermometer))); } public static readonly DependencyProperty MinTempProperty = DependencyProperty.Register(nameof(MinTemp), typeof(double), typeof(VerticalThermometer), new PropertyMetadata(-20.0)); public static readonly DependencyProperty MaxTempProperty = DependencyProperty.Register(nameof(MaxTemp), typeof(double), typeof(VerticalThermometer), new PropertyMetadata(120.0)); public static readonly DependencyProperty UnitProperty = DependencyProperty.Register(nameof(Unit), typeof(string), typeof(VerticalThermometer), new PropertyMetadata("°C")); public double MinTemp { get => (double)GetValue(MinTempProperty); set => SetValue(MinTempProperty, value); } public double MaxTemp { get => (double)GetValue(MaxTempProperty); set => SetValue(MaxTempProperty, value); } public string Unit { get => (string)GetValue(UnitProperty); set => SetValue(UnitProperty, value); } }

这里DefaultStyleKeyProperty.OverrideMetadata是关键,它让控件去Themes/Generic.xaml里找默认样式。MinTemp和MaxTemp不直接用 ProgressBar 的 Minimum/Maximum,是因为温度范围可能和进度百分比范围不一致,分开更灵活。比如你可以让 Value 表示 0 到 100 的百分比,而 MinTemp/MaxTemp 表示实际温度区间。

3.2 第二步:重写 ControlTemplate,用 ScaleY 驱动液柱

在Themes/Generic.xaml里定义模板。核心是PART_Indicator的RenderTransform用ScaleY,并且绑定到Value相对于Maximum的比例。

<Style TargetType="{x:Type local:VerticalThermometer}"> <Setter Property="Template"> <Setter.Value> <ControlTemplate TargetType="{x:Type local:VerticalThermometer}"> <Grid> <Border x:Name="PART_Track" CornerRadius="8" Background="#1E1E1E" BorderBrush="#3A3A3A" BorderThickness="1"/> <Border x:Name="PART_Indicator" CornerRadius="6" Margin="3" RenderTransformOrigin="0.5,1"> <Border.Background> <LinearGradientBrush StartPoint="0,1" EndPoint="0,0"> <GradientStop Color="#FF4D4D" Offset="0"/> <GradientStop Color="#FFB84D" Offset="0.5"/> <GradientStop Color="#FF4D4D" Offset="1"/> </LinearGradientBrush> </Border.Background> <Border.RenderTransform> <ScaleTransform ScaleY="0"/> </Border.RenderTransform> </Border> </Grid> <ControlTemplate.Triggers> <Trigger Property="Value" Value="0"> <Setter TargetName="PART_Indicator" Property="Visibility" Value="Collapsed"/> </Trigger> </ControlTemplate.Triggers> </ControlTemplate> </Setter.Value> </Setter> </Style>

模板里PART_Indicator的ScaleY初始为 0,需要在代码里根据 Value 动态计算。RenderTransformOrigin="0.5,1"保证缩放从底部开始。Margin="3"让液柱和轨道之间留一点内边距,视觉上更像玻璃管。

3.3 第三步:在 OnValueChanged 里同步 ScaleY

重写OnValueChanged,把 Value 映射成 ScaleY。

protected override void OnValueChanged(double oldValue, double newValue) { base.OnValueChanged(oldValue, newValue); UpdateIndicatorScale(); } protected override void OnMaximumChanged(double oldValue, double newValue) { base.OnMaximumChanged(oldValue, newValue); UpdateIndicatorScale(); } private void UpdateIndicatorScale() { if (Template == null) return; var indicator = Template.FindName("PART_Indicator", this) as Border; if (indicator == null) return; double ratio = Maximum > 0 ? Value / Maximum : 0; ratio = Math.Max(0, Math.Min(1, ratio)); if (indicator.RenderTransform is ScaleTransform st) { st.ScaleY = ratio; } }

这里有个时序问题:OnValueChanged可能在模板应用之前触发,此时Template.FindName返回 null。所以还要重写OnApplyTemplate,在里面再调一次UpdateIndicatorScale。

public override void OnApplyTemplate() { base.OnApplyTemplate(); UpdateIndicatorScale(); }

参数说明:Maximum是 ProgressBar 自带的,表示满量程对应的值;Value是当前值。ratio做了 0 到 1 的钳制,防止 Value 超出 Maximum 时液柱溢出轨道。如果你希望温度计支持负温度,比如 -20°C 到 120°C,那 Value 应该先归一化到 0 到 100,再绑定给 ProgressBar,或者直接在UpdateIndicatorScale里用MinTemp和MaxTemp重新计算 ratio。

3.4 第四步:用 ItemsControl 叠加刻度线

刻度线不适合塞进 ControlTemplate,因为刻度数量随温度范围变化。用 ItemsControl 叠在温度计旁边,数据源是一组刻度模型。

public class TickMark { public double Temperature { get; set; } public double Position { get; set; } // 0 到 1,从底部算起 public string Label { get; set; } }

在 ViewModel 里生成刻度:

public List<TickMark> Ticks { get; } = new List<TickMark>(); public void BuildTicks(double minTemp, double maxTemp, double step) { Ticks.Clear(); for (double t = minTemp; t <= maxTemp; t += step) { double pos = (t - minTemp) / (maxTemp - minTemp); Ticks.Add(new TickMark { Temperature = t, Position = pos, Label = t.ToString("F0") }); } }

XAML 里用 ItemsControl 的ItemContainerStyle把Position映射成Canvas.Top或Margin。因为 WPF 的 Y 轴向下,Position从底部算起,所以实际Canvas.Top应该是(1 - Position) * ActualHeight。这一步建议用MultiBinding绑定Position和ActualHeight,避免硬编码高度。

<ItemsControl ItemsSource="{Binding Ticks}"> <ItemsControl.ItemsPanel> <ItemsPanelTemplate> <Canvas/> </ItemsPanelTemplate> </ItemsControl.ItemsPanel> <ItemsControl.ItemContainerStyle> <Style TargetType="ContentPresenter"> <Setter Property="Canvas.Top"> <Setter.Value> <MultiBinding Converter="{StaticResource PosToTopConverter}"> <Binding Path="Position"/> <Binding Path="ActualHeight" RelativeSource="{RelativeSource AncestorType=Canvas}"/> </MultiBinding> </Setter.Value> </Setter> </Style> </ItemsControl.ItemContainerStyle> <ItemsControl.ItemTemplate> <DataTemplate> <StackPanel Orientation="Horizontal"> <Rectangle Width="8" Height="1" Fill="#888"/> <TextBlock Text="{Binding Label}" Margin="4,0,0,0" Foreground="#CCC" FontSize="11"/> </StackPanel> </DataTemplate> </ItemsControl.ItemTemplate> </ItemsControl>

PosToTopConverter的实现就是(1 - position) * height。注意ActualHeight在布局完成前是 0,所以刻度线可能在窗口刚加载时挤在顶部,等布局更新后自动散开。如果介意这个闪烁,可以在Loaded事件里再触发一次绑定更新。

4. 避坑:垂直温度计最容易翻车的 5 个地方

4.1 液柱从顶部往下缩

现象:Value 增大时,液柱反而从顶部往下缩短。原因:RenderTransformOrigin设成了(0.5, 0)或默认值(0, 0),缩放原点在顶部。解决:明确设RenderTransformOrigin="0.5,1",让缩放从底部开始。

4.2 模板里找不到 PART_Indicator

现象:Template.FindName("PART_Indicator", this)返回 null,液柱不动。原因:OnValueChanged在模板应用之前触发,或者模板里名字拼错。解决:重写OnApplyTemplate并在里面调用更新方法;同时检查 XAML 里x:Name是否和FindName参数一致。

4.3 刻度线和液柱对不上

现象:液柱显示 50°C,但刻度线标注的 50°C 在更高或更低的位置。原因:刻度线的 Position 计算用了MinTemp到MaxTemp,而液柱的 ScaleY 用了Value / Maximum,两套映射不一致。解决:统一映射基准。要么把 Value 归一化到[MinTemp, MaxTemp],要么刻度线也按Value / Maximum的比例来算。我一般会在 ViewModel 里统一做归一化,控件只认 0 到 1。

4.4 动画卡顿或跳变

现象:温度快速变化时,液柱动画不流畅,甚至直接跳变。原因:直接设ScaleY没有过渡,或者Value绑定更新频率太高。解决:在ScaleTransform上加DoubleAnimation,持续时间 200 到 300 毫秒,缓动函数用QuadraticEase。如果数据源每秒更新多次,可以在 ViewModel 里做节流,比如 100 毫秒合并一次。

4.5 负温度范围下液柱消失

现象:MinTemp 设为 -20,Value 为 -10 时液柱不显示。原因:ProgressBar 的 Value 默认不能小于 Minimum(默认 0),负值被钳制。解决:把 ProgressBar 的Minimum设为MinTemp,Maximum设为MaxTemp,然后UpdateIndicatorScale里用(Value - Minimum) / (Maximum - Minimum)计算 ratio。这样负温度也能正确映射。

5. 进阶:让温度计支持报警阈值与平滑动画

5.1 用多段渐变实现报警色

温度计在超过阈值时变红,是工业看板的常见需求。可以在PART_Indicator的Background上用LinearGradientBrush,但渐变是静态的,不会随温度变。更灵活的做法是叠加一个Border作为报警层,用DataTrigger控制可见性。

<Border x:Name="AlarmLayer" CornerRadius="6" Margin="3" Background="#FF0000" Opacity="0" RenderTransformOrigin="0.5,1"> <Border.RenderTransform> <ScaleTransform ScaleY="0"/> </Border.RenderTransform> </Border>

在代码里,当 Value 超过AlarmThreshold时,把 AlarmLayer 的 ScaleY 设为(Value - AlarmThreshold) / (Maximum - AlarmThreshold),并让 Opacity 在 0.6 到 0.8 之间。这样报警段只覆盖超过阈值的部分,视觉上更直观。

5.2 平滑动画的 DoubleAnimation 写法

在UpdateIndicatorScale里不要直接赋值,而是用BeginAnimation。

private void AnimateIndicator(double targetRatio) { if (Template == null) return; var indicator = Template.FindName("PART_Indicator", this) as Border; if (indicator?.RenderTransform is not ScaleTransform st) return; var anim = new DoubleAnimation { To = targetRatio, Duration = TimeSpan.FromMilliseconds(250), EasingFunction = new QuadraticEase { EasingMode = EasingMode.EaseOut } }; st.BeginAnimation(ScaleTransform.ScaleYProperty, anim); }

注意:BeginAnimation会覆盖当前值,如果后续要读取st.ScaleY,读到的是动画目标值而不是当前插值。如果需要精确读取,可以用st.ScaleY的GetAnimationBaseValue或者改用Storyboard。我一般只在视觉层用动画,数值逻辑仍然走 Value,互不干扰。

5.3 验证方法:用三个温度点做回归

做完之后,用三个点验证映射是否正确:MinTemp、中间值、MaxTemp。比如 -20、50、120。检查液柱高度比例是否分别是 0、0.5、1,刻度线标注是否和液柱顶部对齐。如果中间值偏了,多半是归一化公式写错;如果两端偏了,检查Minimum和Maximum是否设对。

验证点温度值期望液柱比例检查项
下限-200液柱隐藏,刻度在底部
中点500.5液柱到一半,刻度对齐
上限1201液柱满格,刻度在顶部

5.4 一个我踩过的坑:别在 ControlTemplate 里绑 ActualHeight

早期版本我把刻度线的Canvas.Top直接绑到ActualHeight,结果窗口缩放时刻度线不更新,因为ActualHeight在ControlTemplate内部绑定不会触发SizeChanged。后来改成在控件的SizeChanged事件里手动刷新刻度容器的ItemsPanel,或者用RelativeSource绑到外层 Grid 的ActualHeight,才解决。血泪经验:WPF 的布局绑定在模板内部和外部行为不一样,涉及尺寸的绑定尽量放在用户控件层,别塞进模板。

5.5 复用建议:做成 Style 而不是 UserControl

如果你只是在一个窗口里用,UserControl 最快。但如果要在多个看板复用,建议做成Style+ 自定义控件,因为 UserControl 的模板不容易被外部覆盖,而自定义控件可以通过BasedOn继承样式,改颜色、改圆角、改刻度密度都不用改代码。我现在的习惯是:先写 UserControl 验证效果,稳定后抽成自定义控件,把可配置项全部做成依赖属性。这样下次做压力表、液位计,直接换模板就行。

希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询