VisualTFT自定义圆形进度条:Canvas绘图与嵌入式GUI开发实践
1. 项目概述与核心价值
最近在做一个工业HMI的项目,客户要求在设备启动自检的界面上,用一个圆环形的进度条来展示自检进度,而不是传统的长条状进度条。他们觉得圆环看起来更“高级”,也更符合他们产品的整体UI风格。接到这个需求,我第一反应就是去翻VisualTFT的控件库,结果发现官方自带的进度条控件只有水平(ProgressBar)和垂直(VProgressBar)两种。这可就有点意思了,官方没提供,但需求又很明确,那就得自己动手“造轮子”了。
这个“自定义圆形进度条”的需求,在工控、智能家居中控屏、医疗仪器面板等嵌入式GUI开发里其实挺常见的。它不仅仅是把形状从矩形变成圆形那么简单,背后涉及到图形绘制、数值映射、动画平滑度、以及如何与底层硬件(比如单片机)高效交互等一系列问题。VisualTFT作为一款优秀的嵌入式UI设计工具,其强大的自定义控件和脚本功能,恰恰给了我们实现这种个性化需求的舞台。今天,我就把自己从零开始,在VisualTFT里实现一个美观、实用、可复用的圆形进度条的全过程,包括设计思路、关键脚本编写、属性封装以及实际调试中遇到的坑,毫无保留地分享出来。无论你是刚接触VisualTFT的新手,还是想深化控件自定义能力的老鸟,相信这篇内容都能给你带来直接的参考价值。
2. 整体设计思路与方案选型
在VisualTFT里实现一个控件,尤其是这种图形化控件,通常有几条路可以走。我们先来拆解一下,并说说我为什么选择了最终这个方案。
2.1 可行性路径分析
纯位图叠加方案:准备0%-100%共101张圆环进度图片,通过脚本控制显示哪一张。这种方法最简单粗暴,效果也稳定,因为图片是美工做好的。但缺点极其明显:资源占用巨大(101张图片),进度不连续(只能以1%为步进),修改样式(比如颜色、粗细)需要重做所有图片,完全不灵活。PASS。
使用“仪表”控件模拟:VisualTFT有一个
Meter(仪表)控件,本身就是一个圆环或扇形。我们可以将其刻度隐藏,指针改成一个不显示的标记,然后通过设置其值来改变填充区域。这个方法比方案1好,但Meter控件的重点在于模拟仪表盘,其API和属性对于“进度条”这个应用场景来说并不直观,定制填充样式(如渐变色)也比较麻烦。使用“画布”控件动态绘制:这是最灵活、最专业,也是我最终采用的方案。VisualTFT提供了
Canvas(画布)控件,它就像一块空白的画布,我们可以通过Lua脚本,使用其提供的绘图API(如画弧、画线、填充)在上面动态地绘制出我们想要的任何图形。圆形进度条本质上就是一个不断变长的圆弧,用Canvas来实现再合适不过。
2.2 为什么选择“Canvas动态绘制”方案
选择这个方案,是基于以下几个核心考量:
- 极致灵活:进度条的宽度、颜色(包括静态色和渐变色)、起始角度、绘制方向(顺时针/逆时针)、是否显示中心文本等,全部可以通过属性或脚本参数控制,无需修改资源。
- 资源占用极小:只需要一个
Canvas控件,几乎不占用额外的Flash存储空间(图片资源),特别适合资源紧张的嵌入式平台。 - 平滑连续:由于是实时计算并绘制图形,进度可以非常平滑地变化,甚至可以配合定时器实现动画过渡效果。
- 技能复用:掌握
Canvas绘图,就等于掌握了在VisualTFT中创建任何不规则图形控件的能力,价值远超实现一个进度条本身。
这个方案的核心在于编写正确的绘图逻辑。接下来,我们就深入到Canvas控件的脚本中,看看如何用代码“画”出这个圆环。
3. 核心实现:Canvas绘图脚本详解
假设我们在VisualTFT的窗体上放置了一个Canvas控件,命名为CanvasProgress。我们所有的魔法都将发生在它的onPaint事件回调函数里。这个函数会在控件需要重绘时(如初始显示、值改变后)被自动调用。
3.1 绘图坐标与参数计算
在屏幕上绘图,首先要建立坐标系和理解关键参数。Canvas控件的左上角是坐标原点(0,0),向右为x轴正方向,向下为y轴正方向。
绘制一个圆环进度条,我们需要以下几个核心参数:
centerX, centerY: 圆环的中心点坐标。通常就是Canvas宽度和高度的一半。radius: 圆环的半径。lineWidth: 圆环的粗细(宽度)。startAngle: 进度条开始的弧度角。数学上,0弧度指向正右方(3点钟方向)。currentAngle: 当前进度对应的弧度角。这由当前进度值currentValue、最大值maxValue和最小值minValue计算得出。colorStart,colorEnd: 如果使用渐变色,这是起始和结束颜色。
让我们在onPaint函数中实现它。首先,我们需要获取或定义这些参数。一种好的实践是将可配置的参数放在脚本的开头,或者通过控件的自定义属性来设置(后面会讲)。这里我们先在脚本内定义。
function CanvasProgress.onPaint(sender, vtx, paintParam) -- 1. 定义配置参数(后续可改为从属性读取) local minValue = 0 local maxValue = 100 local currentValue = 75 -- 示例:当前进度75% local centerX = sender.Width / 2 local centerY = sender.Height / 2 local radius = math.min(centerX, centerY) * 0.8 -- 半径为Canvas大小的80%,留出边距 local lineWidth = radius * 0.2 -- 圆环宽度为半径的20% local startAngle = -math.pi / 2 -- 从顶部(-90度,即12点钟方向)开始,更符合视觉习惯 local endAngle = startAngle + (currentValue - minValue) / (maxValue - minValue) * (2 * math.pi) -- 计算结束弧度 local colorBack = 0xCCCCCC -- 背景圆环颜色(灰色) local colorFore = 0x007ACC -- 前景进度颜色(蓝色) -- 2. 绘制底层背景圆环 vtx:BeginPath() vtx:Arc(centerX, centerY, radius, 0, 2 * math.pi, false) -- 绘制一个完整的圆 vtx:SetLineWidth(lineWidth) vtx:SetStrokeColor(colorBack) vtx:Stroke() -- 3. 绘制上层进度圆环 vtx:BeginPath() -- 开始新路径 -- 绘制圆弧。参数:中心x, 中心y, 半径, 起始角, 结束角, 是否逆时针(false为顺时针) vtx:Arc(centerX, centerY, radius, startAngle, endAngle, false) vtx:SetLineWidth(lineWidth) vtx:SetStrokeColor(colorFore) vtx:Stroke() -- 4. (可选)绘制中心文本 local text = string.format("%d%%", currentValue) vtx:SetFont(sender.FontName, sender.FontSize, vtx.FONT_BOLD) -- 使用控件字体或自定义 vtx:SetTextAlign(vtx.TEXT_ALIGN_CENTER, vtx.TEXT_ALIGN_MIDDLE) vtx:SetFillColor(0x000000) -- 文本颜色黑色 vtx:FillText(text, centerX, centerY) end关键点解析:
vtx:Arc()是绘图的核心。注意角度的单位是弧度,不是角度。2 * math.pi就是一个完整的圆。- 我们绘制了两次:第一次画一个完整的灰色圆环作为背景,第二次根据进度画一个蓝色的圆弧作为前景。它们半径和宽度相同,所以前景会覆盖背景,形成进度效果。
vtx:BeginPath()非常重要。它表示开始一条新的绘制路径。如果不调用,第二次Arc会和第一次的路径连在一起,导致绘制错误。- 进度计算:
(currentValue - minValue) / (maxValue - minValue)得到进度比例,再乘以2 * math.pi(整个圆的弧度),得到进度对应的弧度跨度。
3.2 实现渐变色与圆角端点
基础的圆环有了,但产品经理可能想要更炫酷的效果,比如渐变色和圆润的线条端点。VisualTFT的Canvas绘图上下文也支持这些。
添加渐变色: 渐变色需要先创建一个线性或径向渐变对象,然后将其设置为描边或填充样式。
-- 在绘制前景进度圆环的部分替换掉单色设置 -- 创建线性渐变(从进度起点到终点) local gradient = vtx:CreateLinearGradient( centerX + radius * math.cos(startAngle), -- 起点x centerY + radius * math.sin(startAngle), -- 起点y centerX + radius * math.cos(endAngle), -- 终点x centerY + radius * math.sin(endAngle) -- 终点y ) gradient:AddColorStop(0.0, 0xFF0000) -- 0%位置为红色 gradient:AddColorStop(1.0, 0x0000FF) -- 100%位置为蓝色 vtx:BeginPath() vtx:Arc(centerX, centerY, radius, startAngle, endAngle, false) vtx:SetLineWidth(lineWidth) vtx:SetStrokeStyle(gradient) -- 使用渐变样式替代单一颜色 vtx:Stroke()设置圆角线帽: 默认的线条端点是方形的(vtx.LINE_CAP_BUTT)。要让圆环的末端看起来圆润,可以设置线帽为圆形。
vtx:SetLineCap(vtx.LINE_CAP_ROUND) -- 在调用Stroke之前设置 vtx:Stroke()这样,进度条的头部(当前进度端点)就会呈现一个半圆形,视觉效果更加柔和。
3.3 封装为可复用的自定义控件
把上面的代码直接写在onPaint里可以工作,但不好复用。最佳实践是创建一个自定义控件。这样我们可以像使用标准控件一样,拖拽它到界面上,然后在属性窗口里设置最小值、最大值、当前值、颜色等,甚至可以在其他项目中直接导入使用。
- 创建自定义控件:在VisualTFT的“资源”窗口,右键“自定义控件”->“新建”。命名为
CircleProgressBar。 - 添加自定义属性:在自定义控件的属性编辑器中,添加我们需要的属性,例如:
MinValue(整数, 默认0)MaxValue(整数, 默认100)CurrentValue(整数, 默认0)LineWidth(整数, 默认10)StartAngle(浮点数, 默认-90, 单位度, 方便理解)ColorBackground(颜色)ColorForeground(颜色)ShowText(布尔, 是否显示中间百分比文本)
- 编写控件的绘制脚本:在自定义控件的
onPaint事件中,编写与我们之前类似的脚本,但关键参数不再写死,而是从self(控件对象)的属性中读取。
function self.onPaint(sender, vtx, paintParam) -- 从自定义属性读取值 local minValue = self.MinValue or 0 local maxValue = self.MaxValue or 100 local currentValue = self.CurrentValue or 0 -- 确保当前值在范围内 currentValue = math.max(minValue, math.min(maxValue, currentValue)) local centerX = sender.Width / 2 local centerY = sender.Height / 2 local radius = math.min(centerX, centerY) - (self.LineWidth or 10) / 2 -- 将角度从度转换为弧度 local startAngleRad = math.rad(self.StartAngle or -90) local endAngleRad = startAngleRad + (currentValue - minValue) / (maxValue - minValue) * (2 * math.pi) -- ... 后续绘制代码与之前类似,但颜色等使用 self.ColorBackground, self.ColorForeground ... -- 文本显示根据 self.ShowText 属性判断 end- 提供设置进度的方法:我们还需要一个接口,让外部脚本(比如定时器或数据解析回调)能更新进度。可以在自定义控件中添加一个Lua函数。
-- 在自定义控件的脚本中,可以定义全局函数 function SetProgress(newValue) self.CurrentValue = newValue self:Invalidate() -- 标记控件需要重绘,触发onPaint end这样,在其他地方就可以用CircleProgressBar.SetProgress(50)来更新进度了。
4. 高级功能与性能优化
一个基本的圆形进度条已经完成。但在实际工业项目中,我们往往需要更多功能和考虑性能。
4.1 动画平滑过渡
直接从0%跳到100%会很生硬。我们可以实现一个平滑的动画过渡。
思路:使用一个Timer(定时器)控件。当目标进度改变时,启动定时器。在定时器的onTimer事件中,让CurrentValue逐步逼近TargetValue,每次逼近都调用Invalidate()重绘。
-- 在自定义控件或窗体脚本中 local targetValue = 0 local animationSpeed = 2 -- 每帧变化的单位值 function StartAnimationTo(newTarget) targetValue = newTarget -- 启动一个间隔50ms的定时器 TimerAnimation.Enabled = true end function TimerAnimation.onTimer(sender) local current = CircleProgressBar.CurrentValue if math.abs(current - targetValue) < 0.5 then -- 接近目标,停止动画 sender.Enabled = false CircleProgressBar.CurrentValue = targetValue CircleProgressBar:Invalidate() else -- 向目标值移动 if current < targetValue then CircleProgressBar.CurrentValue = current + animationSpeed else CircleProgressBar.CurrentValue = current - animationSpeed end CircleProgressBar:Invalidate() end end注意:动画会频繁触发重绘,对性能有影响。在低性能MCU上需谨慎使用,或降低动画帧率(增大定时器间隔)。
4.2 多段颜色与阈值警示
在工业场景,进度可能代表温度、压力等。我们常常需要根据不同的值域显示不同颜色(如正常蓝色、警告黄色、危险红色)。
实现方法:在onPaint绘制前景圆弧前,根据currentValue判断所在区间,动态决定使用的颜色。
local function getColorByValue(val) if val < 60 then return 0x00CC00 -- 绿色,安全 elseif val < 85 then return 0xFFAA00 -- 黄色,警告 else return 0xFF0000 -- 红色,危险 end end local progressColor = getColorByValue(currentValue) vtx:SetStrokeColor(progressColor)更复杂的可以配置一个颜色阈值表,实现高度可配置化。
4.3 性能优化要点
在资源受限的嵌入式设备上,GUI绘制是性能热点。
- 减少不必要的重绘:只在
CurrentValue真正改变时调用Invalidate()。避免在循环或高频定时器中无条件重绘。 - 简化绘图操作:如果不需要背景圆环,就不画。如果文本不常变,可以考虑将其画在另一个
Label控件上,而不是每次在Canvas里绘制。 - 固定大小与坐标:如果
Canvas控件大小和位置不变,避免在onPaint中进行复杂的布局计算。可以将centerX,centerY,radius等计算一次后缓存起来(注意,当控件大小改变时需要重新计算,可监听onResize事件)。 - 慎用渐变和透明度:渐变色计算和Alpha混合(透明度)会消耗更多CPU资源。如果性能吃紧,优先使用纯色。
5. 常见问题与调试技巧
在实际开发中,你可能会遇到下面这些问题。
5.1 圆环绘制不完整或位置不对
- 现象:圆环只显示一部分,或者偏离中心。
- 排查:
- 检查坐标和半径:确保
centerX, centerY是sender.Width/2和sender.Height/2。radius不能大于min(centerX, centerY) - lineWidth/2,否则部分圆弧会画到控件区域外被裁剪。 - 检查角度:确认
startAngle和endAngle的单位是弧度。一个常见错误是直接用了角度值。使用math.rad(角度)进行转换。 - 验证Canvas大小:在界面上检查
Canvas控件是否被其他控件遮挡,或其Width和Height属性是否设置正确。
- 检查坐标和半径:确保
5.2 进度更新后界面无变化
- 现象:修改了
CurrentValue,但屏幕上进度条没动。 - 排查:
- 是否调用了Invalidate():修改属性后,必须调用控件的
Invalidate()方法来请求重绘。直接改属性值是不会刷新屏幕的。 - 作用域问题:确保你修改的是正确的控件对象。在Lua脚本中,使用控件的准确名称(如
CanvasProgress.CurrentValue = 50)。 - 数值范围:检查
CurrentValue是否在MinValue和MaxValue之间。我们的绘图计算依赖这个范围。
- 是否调用了Invalidate():修改属性后,必须调用控件的
5.3 自定义控件属性不生效
- 现象:在属性窗口改了自定义控件的颜色、宽度,但运行时没变化。
- 排查:
- 属性读取:在
onPaint脚本中,你是否通过self.PropertyName正确读取了自定义属性?属性名必须完全匹配。 - 属性默认值:在自定义控件编辑器中,检查属性是否设置了有效的默认值。
- 运行时与设计时:有时设计时属性面板的更改需要重新编译或下载工程到模拟器/实机才能生效。确保你执行了完整的“生成代码”->“下载”流程。
- 属性读取:在
5.4 实机运行闪烁或卡顿
- 现象:在PC模拟器上流畅,下载到真机(如STM32)上画面闪烁或更新很慢。
- 排查:
- 帧率过高:检查动画定时器的间隔是否太短。对于许多工控MCU,50ms(20FPS)已经是比较高的要求了,可以尝试调整到100ms甚至200ms。
- 绘图复杂度:关闭渐变、圆角等高级效果,看是否改善。如果改善明显,说明需要优化绘图指令或降低效果。
- MCU性能与内存:确认目标MCU的Flash和RAM资源是否充足。使用VisualTFT的性能分析工具(如果有)或查看编译报告,了解资源占用情况。
- 双缓冲:检查VisualTFT工程或底层GUI库是否开启了双缓冲。双缓冲可以极大减少闪烁。通常这是在工程配置或底层驱动中设置的。
调试技巧:
- 善用“输出窗口”:在关键位置使用
print(“当前值:”, currentValue)将变量打印出来,这是最直接的调试方法。 - 分步绘制:在
onPaint函数中,先注释掉绘制前景或背景的代码,只画一样,看是否正确。逐步增加功能,定位问题代码。 - 模拟器优先:绝大部分逻辑和显示问题都在PC模拟器上解决,再下载到真机调试硬件相关问题,能大大提高效率。
最后,这个自定义圆形进度条控件完成后,它的价值不仅仅在于完成了一个任务。它更是一个模板,你可以基于它轻松修改出“扇形进度条”、“仪表盘指针”、“速度表”等各种各样的自定义图形控件。掌握Canvas绘图和自定义控件封装,你在VisualTFT上的开发能力就上了一个大台阶,面对各种奇葩的UI需求时,心里都会更有底气。
