资讯动态

WPF垂直温度计开发实战:基于ProgressBar的模板重写与动画封装

发布时间:2026/9/16 13:02:27 来源:尧图企业网站定制
简介一份面向 WPF 初中级开发者的自定义控件示例项目围绕 ProgressBar 的垂直布局、ControlTemplate 重绘与 RenderTransform 绑定实现可复用的温度计样式。资源共 41 个文件包含 12 个 C# 源码文件、2 个 XAML 界面文件以及项目解决方案、资源配置与可直接运行的编译输出压缩包整体约 215KB结构紧凑适合快速参考。示例具体展示了将 Orientation 设为 Vertical 后的纵向填充、用 Path 与 ScaleTransform 随 Value 伸缩的指针效果并加入 DoubleAnimation 让温度变化平滑过渡此外还通过 TextBlock 绑定温度值与单位标签兼顾了数值显示与视觉刻度。包内工程文件完整可直接打开编译调整 Maximum 与 Value 即可模拟不同温度区间适合集成到上位机监控或实验实训界面中。已有 756 人学习查看对希望掌握 WPF 控件模板、动画绑定及自定义外观的开发者有直接借鉴价值可据此扩展出气压计、液位计等类似仪表盘组件。1. 为什么是ProgressBar而不是自定义控件第一次把进度条竖起来做成温度计是在一个温度监控看板项目里。当时产品经理给了一个水温计样式的参考图我的第一反应是画一个自定义 UserControl再写一堆 DrawingBrush 去描玻璃管和液柱。后来仔细想了一下温度计和进度条本质上是同一个问题一个值在两个边界之间占的比例。ProgressBar 已经把这个区间计算、自动化树和视觉状态都做好了直接拿它当温度计的骨架比从头画省一半以上工作量。这篇文章从一个可运行的 Demo 出发讲清楚怎么基于 ProgressBar 做垂直温度计从模板重写、刻度排布、值映射到动画封装最后收进一个可复用的 WPF 控件库。适合在 vs2022 里用 C# 写 WPF 界面、又不想把 UI 细节塞进业务层的开发者。2. 先把ProgressBar竖起来旋转还是重写模板要动手前得先解决一个基础问题WPF 的 ProgressBar 默认是水平走向的怎么让它竖直显示网上很多教程会告诉你加一个OrientationVertical但这个写法在 WPF 里并不成立。下面两条路都能走通但代价完全不同。2.1 为什么OrientationVertical在WPF里是行不通的从 UWP 或 WinUI 转过来的同学会很自然地写下OrientationVerticalUWP 的 ProgressBar 确实有这个属性但 WPF 没有。WPF ProgressBar 只有Minimum、Maximum、Value、IsIndeterminate这些基础属性XAML 编译器看到Orientation会直接报错不是静默忽略。原因在 WPF 的控件实现历史上ProgressBar 的内部更新逻辑主要围绕水平方向设计默认模板用PART_Indicator的ScaleTransform.ScaleX来控制填充宽度垂直方向没有被纳入布局契约。官方一直没补这个属性大概率是为了不改动默认模板避免破坏现存应用的视觉一致性。所以在 vs2022 里新建的 WPF 工程中属性窗口不会有这个选项。把这个坑说清楚是因为搜索引擎里大量答案是从 UWP 文档直接抄过来的照着做会卡在编译阶段。2.2 方案ALayoutTransform偷懒做法如果只是临时看板、调试窗口不建议直接重写模板用LayoutTransform旋转最省事。ProgressBar Width20 Height180 Minimum0 Maximum100 Value60 Background#E0E0E0 Foreground#D32F2F ProgressBar.LayoutTransform RotateTransform Angle-90 / /ProgressBar.LayoutTransform /ProgressBarLayoutTransform会在布局阶段先对元素做旋转再参与父级容器排版。Angle-90将水平从左到右的填充方向变成从下到上正好符合温度计液柱上升的感觉。Value60表示当前温度对应 60% 的位置。这个方案不需要写转换器ProgressBar 自带的 Value 更新逻辑和默认动画都还能用适合快速验证效果。缺点是刻度文本、鼠标命中也一起旋转要在旁边加温度单位文本时文本也得反向旋转布局代码会越写越绕。另外旋转后的实际占用空间是宽 180、高 20外层 Grid 的行高会被撑大初次看到会误以为布局异常这是 LayoutTransform 参与布局计算的正常结果。2.3 方案B重写ControlTemplate从根上换成垂直要做成真正的产品级效果我一般直接重写ControlTemplate把 ProgressBar 的展示层完全拿过来。模板里的汞柱不再用ScaleX而是用ScaleY从底部往上缩放。Window.Resources local:ValueToRatioConverter x:KeyValueToRatioConverter/ Style x:KeyVerticalTemperatureStyle TargetTypeProgressBar Setter PropertyWidth Value28/ Setter PropertyHeight Value220/ Setter PropertyBackground Value#E0E0E0/ Setter PropertyForeground Value#E53935/ Setter PropertyTemplate Setter.Value ControlTemplate TargetTypeProgressBar Grid Border x:NamePART_Track Background{TemplateBinding Background} BorderBrush#BDBDBD BorderThickness1 CornerRadius14/ Border x:NamePART_Indicator Background{TemplateBinding Foreground} CornerRadius14 VerticalAlignmentBottom HorizontalAlignmentStretch RenderTransformOrigin0,1 Border.RenderTransform ScaleTransform ScaleY{Binding Value, RelativeSource{RelativeSource TemplatedParent}, Converter{StaticResource ValueToRatioConverter}, ConverterParameter-20/50}/ /Border.RenderTransform /Border /Grid /ControlTemplate /Setter.Value /Setter /Style /Window.ResourcesPART_Track和PART_Indicator是 ProgressBar 控件模板里的约定命名保留它们有利于后续接入自动化测试和视觉状态管理。VerticalAlignmentBottom让汞柱容器默认贴住底部RenderTransformOrigin0,1把缩放中心设到左下角这样ScaleY从 0 到 1 时元素会从底部向上生长而不是从中心向两边扩张。ConverterParameter-20/50表示温度量程是零下 20 到 50 度具体转换逻辑下一章讲。这里的 Indicator 没有写死 Height而是由外层 Grid 拉伸决定高度窗口尺寸变化时汞柱会自动跟随轨道长度这是固定高度写法做不到的。2.4 两种方案的取舍维度LayoutTransform 方案ControlTemplate 方案实现成本低三行 XAML中需要 Style 和转换器刻度对齐刻度随控件的坐标系一起旋转刻度、文本、液柱可独立布局值驱动完全依赖 ProgressBar 默认逻辑需要额外处理 Value 到比例映射动画可以直接用自带动画需要自己接管动画适用范围原型、临时看板正式项目、可复用控件库表格里的差异在实际项目里会明显放大。温度计不是一根光秃秃的进度条它外面要有玻璃管、刻度、数值单位球泡位置也要单独处理。LayoutTransform 把整个坐标系转 90 度文本和刻度线要反向转回来布局代码非常绕模板重写相当于把每个视觉元素都放在独立的 Grid 单元格里想调整哪块就调整哪块。从 WPF 界面设计的角度模板重写虽然起步慢但后续控件的可定制程度完全不同。3. 让温度计可读刻度、数值和范围映射垂直模板立起来以后下一个问题是Value 和 ScaleY 之间不是直接相等关系。ProgressBar 的 Value 可以是 0 到 100也可以是负数温度区间但 ScaleY 只接受 0 到 1。这段映射关系必须用一个转换器接在绑定链上。3.1 值域映射负数温度怎么进ProgressBar先看转换器实现。温度计的量程通常是-20到50ProgressBar 的Minimum、Maximum会保持和它们一致但ScaleY必须归一化。using System.Globalization; using System.Windows.Data; public sealed class ValueToRatioConverter : IValueConverter { public object Convert(object value, Type targetType, object parameter, CultureInfo culture) { double current value is double d ? d : 0d; double min 0d; double max 100d; if (parameter is string range range.Contains(/)) { var parts range.Split(/); min double.Parse(parts[0], CultureInfo.InvariantCulture); max double.Parse(parts[1], CultureInfo.InvariantCulture); } if (Math.Abs(max - min) double.Epsilon) { return 0d; } double ratio (current - min) / (max - min); return Math.Max(0d, Math.Min(1d, ratio)); } public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) { throw new NotSupportedException(); } }parameter接收 XAML 里写的-20/50按/拆成下限和上限。double.Parse使用InvariantCulture避免德语、法语系统上小数点解析出错。Math.Max(0d, Math.Min(1d, ratio))做了钳制防止温度超出量程时液柱溢到玻璃管外面。ConvertBack不实现因为温度值倒推回 ProgressBar 的 Value 没有实际场景也不需要支持双向绑定。3.2 把Minimum和Maximum接到转换器上更新上一章的 Style 使用方式让控件属性和转换器参数保持一致。ProgressBar Style{StaticResource VerticalTemperatureStyle} Minimum-20 Maximum50 Value{Binding Temperature}/这里Temperature来自 ViewModel类型是double。Minimum和Maximum告诉 ProgressBar 当前温度边界ConverterParameter里的-20/50则告诉转换器用同一个边界做归一化。两个地方的参数一旦不一致液柱位置就会错乱这是最常见的调试入口。比如把Minimum改成 0 但转换器参数还写-20/50零下温度虽然被允许但液柱永远到不了最底部。我习惯把量程收敛为一个 ViewModel 属性统一生成避免人工维护两份数字。3.3 刻度线和标签别用图片用ItemsControl温度计的刻度不能贴一张固定 PNG温度和容器尺寸一变就糊。用ItemsControl生成刻度可以保持矢量化。ItemsControl ItemsSource{Binding Ticks} HorizontalAlignmentRight ItemsControl.ItemsPanel ItemsPanelTemplate StackPanel OrientationVertical/ /ItemsPanelTemplate /ItemsControl.ItemsPanel ItemsControl.ItemTemplate DataTemplate Grid Height20 Line X10 X210 Y10 Y20 Stroke#888888 StrokeThickness1/ TextBlock Text{Binding} FontSize10 Margin12,-6,0,0/ /Grid /DataTemplate /ItemsControl.ItemTemplate /ItemsControlTicks是 ViewModel 暴露的集合元素是带数值的字符串比如0、10、20。Line的X1、X2画出一段 10 像素的短线TextBlock放在短线右侧。Margin12,-6,0,0里的-6是半个 FontSize 的近似值让文本垂直中心对齐刻度线这是 XAML 排版的常见微调技巧。注意ItemsControl不指定VerticalAlignment让它在父级 Grid 里拉伸刻度才能均匀分布在整个温度计高度上。3.4 数值和单位绑定温度计的底部放当前温度和单位文本绑定到同一个Temperature。StackPanel OrientationHorizontal HorizontalAlignmentCenter Margin0,8,0,0 TextBlock Text{Binding Temperature, StringFormat{}{0:F1}} FontSize18 FontWeightSemiBold/ TextBlock Text °C FontSize14 Foreground#757575 Margin2,0,0,0/ /StackPanelStringFormat里的{}是 XAML 转义符防止解析器把大括号误判为标记扩展。{0:F1}保留一位小数显示23.5而不是冗长的23.5000。在 wpf mvvm 项目中这个TextBlock只需要和Temperature建立单向绑定ViewModel 实现INotifyPropertyChanged即可不需要额外命令。3.5 组合后的完整XAML骨架把温度计主体和右侧刻度放在同一个 Grid 单元格里下面放数值文本。Grid Grid.RowDefinitions RowDefinition HeightAuto/ RowDefinition HeightAuto/ /Grid.RowDefinitions Grid Grid.Row0 Width60 Height220 ProgressBar Style{StaticResource VerticalTemperatureStyle} Minimum-20 Maximum50 Value{Binding Temperature} HorizontalAlignmentLeft/ ItemsControl ItemsSource{Binding Ticks} HorizontalAlignmentRight VerticalAlignmentStretch/ /Grid StackPanel Grid.Row1 ... / /GridProgressBar 宽度 28左侧靠边刻度区域宽 60右侧靠边。这样液柱和刻度之间有固定间距不会重叠。Grid.Row0内部不设置 Grid 的列所有子元素叠在同一个单元格里用各自的对齐方式错开。这个骨架既能跑 Demo也能直接拷进正式项目。属性/参数示例值作用Minimum-20温度下限决定液柱最底部位置Maximum50温度上限决定液柱最顶部位置ConverterParameter-20/50转换器归一化范围必须与 Min、Max 一致RenderTransformOrigin0,1缩放中心在底部液柱向上生长4. 动画和交互不要让液柱当场去世温度计最忌讳温度更新时液柱直接跳到新位置看起来像离散开关不像连续物理量。这里要介入动画。4.1 直接改Value为什么会跳如果按钮点击后直接给 ViewModel 的Temperature赋值绑定会立即把新值推给 ProgressBar液柱从当前高度瞬间跳到目标高度。在真实仪表盘里这非常假。常见做法是用DoubleAnimation在旧值和新值之间补帧。DoubleAnimation animation new DoubleAnimation { From progressBar.Value, To newTemperature, Duration TimeSpan.FromMilliseconds(900), EasingFunction new CubicEase { EasingMode EasingMode.EaseInOut } }; progressBar.BeginAnimation(ProgressBar.ValueProperty, animation);From取当前进度条值To是目标温度。CubicEase配合EaseInOut让液柱慢起慢停模拟液体流动的惯性。注意BeginAnimation会用一个独立的动画时钟去控制依赖属性它的优先级高于本地绑定值。动画结束后如果没有释放后续 Widget 上的其他绑定可能会受到影响。解决方法是动画完成事件里调用progressBar.BeginAnimation(ProgressBar.ValueProperty, null)释放动画时钟把控制权交还给绑定。4.2 用Behavior把动画从ViewModel里拿掉在 wpf mvvm 或 wpf prism 项目里ViewModel 不应该直接持有 ProgressBar 实例。把动画逻辑封装成行为View 层声明绑定即可。using Microsoft.Xaml.Behaviors; public class SmoothProgressBehavior : BehaviorProgressBar { private bool _hasLoaded; public static readonly DependencyProperty ValueProperty DependencyProperty.Register(nameof(Value), typeof(double), typeof(SmoothProgressBehavior), new PropertyMetadata(0d, OnValueChanged)); public double Value { get (double)GetValue(ValueProperty); set SetValue(ValueProperty, value); } private static void OnValueChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) { SmoothProgressBehavior behavior (SmoothProgressBehavior)d; behavior.PlayAnimation((double)e.OldValue, (double)e.NewValue); } private void PlayAnimation(double oldValue, double newValue) { if (!_hasLoaded) { _hasLoaded true; AssociatedObject.Value newValue; return; } DoubleAnimation animation new DoubleAnimation(oldValue, newValue, TimeSpan.FromMilliseconds(600)) { EasingFunction new QuadraticEase { EasingMode EasingMode.EaseOut } }; AssociatedObject.BeginAnimation(ProgressBar.ValueProperty, animation); } }_hasLoaded标志用来跳过首次绑定。页面初始化时Behavior 的Value从0绑到当前温度如果直接播放动画液柱会从底部升到初始温度看起来像启动表演。首次赋值只同步不播放后续温度变化才带动画。这个细节在实际项目里很关键很多页面一打开就滚动的 bug 就来自这里。XAML 侧把行为挂在 ProgressBar 上ProgressBar Style{StaticResource VerticalTemperatureStyle} Minimum-20 Maximum50 i:Interaction.Behaviors local:SmoothProgressBehavior Value{Binding Temperature}/ /i:Interaction.Behaviors /ProgressBarMicrosoft.Xaml.Behaviors.Wpf是标准的 WPF 行为扩展库prism 项目同样兼容。行为里只关心 ProgressBar 和温度值不关心温度从哪里来ViewModel 保持纯净。4.3 调试温度刻度对不上先查这三个地方症状原因对策液柱只停在底部一小截转换器参数和 Minimum/Maximum 不一致核对ConverterParameter与控件属性动画完全不生效IsIndeterminateTrue时 Value 被忽略确认处于确定模式液柱超出玻璃管归一化没有钳制转换器里加Math.Max/Math.Min液柱方向反了用了 Angular 矩阵或角度选错检查 RenderTransform 与正负方向还有一个隐蔽问题刻度ItemsControl里每个 Item 高度固定为 20 像素但温度量程跨 70 度220 像素高度平均每度约 3.14 像素固定间距必然累积误差。要精确对齐刻度应该按ActualHeight动态计算而不是用固定 Item 高度。这个思路在下一章封装控件时一起处理。5. 从Demo到控件库把温度计做成可复用的WPF控件Demo 里的 Style 和转换器放在Window.Resources里复制到下一个项目就散架。温度计这类视觉元件应该收进独立的 WPF 类库做成真正可复用的控件。5.1 为什么我推荐继承ProgressBar而不是UserControl有人会倾向做UserControl内部放一个 ProgressBar再暴露依赖属性转发。这样做不是不行但转发代码特别多Value、Minimum、Maximum、Foreground每转一个都要包一层。继承 ProgressBar 可以直接沿用这组原生属性模板里的TemplateBinding依然有效自动化测试也认识这个控件。继承后只需要额外加两个属性温度量程的下限和上限。5.2 封装一个Thermometer控件在类库项目里新建Themes/Generic.xaml把温度计模板放进去然后在类库代码里实现控件逻辑。public class Thermometer : ProgressBar { private FrameworkElement _indicator; public static readonly DependencyProperty ScaleMinProperty DependencyProperty.Register(nameof(ScaleMin), typeof(double), typeof(Thermometer), new PropertyMetadata(-20d, OnScaleChanged)); public static readonly DependencyProperty ScaleMaxProperty DependencyProperty.Register(nameof(ScaleMax), typeof(double), typeof(Thermometer), new PropertyMetadata(50d, OnScaleChanged)); public double ScaleMin { get (double)GetValue(ScaleMinProperty); set SetValue(ScaleMinProperty, value); } public double ScaleMax { get (double)GetValue(ScaleMaxProperty); set SetValue(ScaleMaxProperty, value); } public override void OnApplyTemplate() { base.OnApplyTemplate(); _indicator GetTemplateChild(PART_Indicator) as FrameworkElement; UpdateIndicator(); } protected override void OnValueChanged(double oldValue, double newValue) { base.OnValueChanged(oldValue, newValue); UpdateIndicator(); } private static void OnScaleChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) { if (d is Thermometer thermometer) { thermometer.UpdateIndicator(); } } private void UpdateIndicator() { if (_indicator null) { return; } double range ScaleMax - ScaleMin; if (Math.Abs(range) double.Epsilon) { return; } double ratio Math.Max(0d, Math.Min(1d, (Value - ScaleMin) / range)); _indicator.RenderTransformOrigin new Point(0, 1); _indicator.RenderTransform new ScaleTransform(1, ratio); } }ScaleMin和ScaleMax表示温度的物理量程和 ProgressBar 自带的Minimum/Maximum无关。这里有意保留这个分离让 Value 仍然按 0 到 100 的默认区间走温度范围和进度条数值互不干扰。OnValueChanged和OnScaleChanged都会触发UpdateIndicator直接设置PART_Indicator的RenderTransform不再依赖绑定转换器。这样做的好处是动画驱动 Value 时每次变化都会重算液柱比例且不需要 ConverterParameter 反复传参。Generic.xaml 里的模板和普通 Style 类似只需要保证PART_Indicator存在Style TargetTypecontrols:Thermometer Setter PropertyTemplate Setter.Value ControlTemplate TargetTypecontrols:Thermometer Grid Border x:NamePART_Track Background#E0E0E0 CornerRadius14/ Border x:NamePART_Indicator Background#E53935 CornerRadius14 VerticalAlignmentBottom HorizontalAlignmentStretch/ /Grid /ControlTemplate /Setter.Value /Setter /Style这段模板里 Indicator 没有固定 Height由 Grid 拉伸决定。UpdateIndicator里直接操作RenderTransformXAML 侧不需要任何 Converter。于是温度计控件变成了自包含的成品外部只需要设置ScaleMin、ScaleMax和Value。5.3 别忘了窗口缩放模板里不写固定 Height是为了让温度计跟着布局走。但随之而来的问题是容器改变大小时ActualHeight会变刻度间距和液柱比例都需要重新计算。我的做法是给Thermometer挂一个SizeChanged事件在事件里重新调用UpdateIndicator同时让刻度集合里的每个刻度点按ActualHeight / (ScaleMax - ScaleMin)计算 Y 坐标。这样温度计无论被放在工具栏、看板卡片还是弹窗里液柱底部始终是量程下限顶部始终是量程上限不会因为窗口 Resize 出现刻度对不上的错位。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价