2、自定义控件基础:继承Drawable类、重写draw()方法、处理invalidate()
好,咱们正式开始动手了。
上一章我们聊了TouchGFX的控件体系,知道了所有可见控件都继承自Drawable。那问题来了——如果官方控件库满足不了你的UI需求,怎么办?
答案很简单:自己写一个。
这一章,我就带你走一遍自定义控件最核心的三个步骤:继承Drawable、重写draw()、处理invalidate()。说白了,这就是自定义控件的"三板斧"。掌握了它们,你就能在TouchGFX里画出任何你想要的东西。
2.1 为什么非要继承Drawable?
你可能会有疑问:我直接用Container或者Image不行吗?
行,但有限制。Container本身不绘制任何内容,它只是装子控件的盒子。Image只能显示位图。如果你想要一个动态绘制的控件——比如一个实时波形图、一个自定义进度环、或者一个手写签名板——那你就必须直接继承Drawable。
核心概念:Drawable是所有可绘制对象的基类。它定义了控件的"几何存在"(位置和大小)和"绘制契约"(draw方法)。任何能在屏幕上显示的东西,最终都是Drawable。
我个人习惯把Drawable理解成一张"画布"。你继承它,就等于拿到了一张空白画布。画多大、画什么、怎么画,全由你决定。
2.2 第一步:继承Drawable并实现构造函数
先看一个最简单的例子。假设我们要做一个圆形指示灯——就是那种绿了表示正常、红了表示报警的小圆点。
#include <touchgfx/Drawable.hpp>
class LedIndicator : public touchgfx::Drawable
{
public:
LedIndicator()
: Drawable(), ledColor(touchgfx::Color::getColorFrom24BitRGB(0, 255, 0))
{
// 设置控件尺寸为40x40像素
setWidth(40);
setHeight(40);
}
virtual void draw(const touchgfx::Rect& invalidatedArea) const override;
void setColor(touchgfx::colortype color)
{
ledColor = color;
// 颜色变了,必须重绘
invalidate();
}
private:
touchgfx::colortype ledColor;
};
注意几个关键点:
- 构造函数里必须调用
setWidth()和setHeight()。不设尺寸,TouchGFX不知道这个控件占多大区域,绘制时会被裁剪掉。 - 成员变量
ledColor保存当前颜色。这是控件的"状态"——任何自定义控件都需要维护自己的状态数据。 setColor()里调用了invalidate()。这个后面细说,你先记住:状态变了,就要通知框架重绘。
小技巧:我建议你在构造函数里就把控件的默认尺寸设好。曾经有个同事忘了设宽度,结果控件画出来了但看不见,排查了半天才发现是宽度为0。嗯,这种坑踩过一次就记住了。
2.3 第二步:重写draw()方法——真正的绘制逻辑
draw()是自定义控件的灵魂。框架会在每一帧需要重绘时调用它,你在这里用TouchGFX的Canvas或PainterAPI画东西。
先看我们的圆形指示灯怎么画:
void LedIndicator::draw(const touchgfx::Rect& invalidatedArea) const
{
// 获取画布
touchgfx::Canvas canvas(this, invalidatedArea);
// 计算圆心和半径
int16_t centerX = getWidth() / 2;
int16_t centerY = getHeight() / 2;
int16_t radius = MIN(centerX, centerY) - 2; // 留2像素边距
// 画一个实心圆
canvas.fillCircle(centerX, centerY, radius, ledColor);
}
等等,这里有个参数invalidatedArea。它是什么意思?
这个参数告诉你的draw()方法:屏幕上有哪一块区域需要重绘。TouchGFX不会每次都重绘整个控件,它只重绘"脏了"的那部分。这是一种性能优化手段。
但在我们这个例子里,整个圆形都可能需要重绘,所以直接用Canvas的裁剪机制就好——它会自动把绘制限制在invalidatedArea范围内。
注意:draw()方法被声明为const。这意味着你不能在draw()里修改控件的成员变量。所有需要改变的状态,必须在draw()之前通过invalidate()触发重绘时准备好。我曾经见过新手在draw()里改颜色,结果编译报错一脸懵——嗯,const限定符就是防你这个的。
2.4 第三步:理解invalidate()——重绘的触发器
invalidate()是Drawable提供的一个方法。它的作用很简单:告诉框架"我变脏了,下一帧请重绘我"。
什么时候需要调用它?
- 控件的外观状态发生变化时(比如颜色、文本、透明度)
- 控件的尺寸或位置发生变化时
- 控件的内部数据更新后需要反映到界面上时
看一个更完整的例子——带闪烁效果的指示灯:
class BlinkingLed : public touchgfx::Drawable
{
public:
BlinkingLed()
: Drawable(), isOn(true), tickCounter(0)
{
setWidth(40);
setHeight(40);
}
void handleTickEvent() // 由框架每帧调用
{
tickCounter++;
if (tickCounter >= 30) // 30帧切换一次(约0.5秒@60fps)
{
tickCounter = 0;
isOn = !isOn;
invalidate(); // 状态变了,请求重绘
}
}
virtual void draw(const touchgfx::Rect& invalidatedArea) const override
{
touchgfx::Canvas canvas(this, invalidatedArea);
int16_t cx = getWidth() / 2;
int16_t cy = getHeight() / 2;
int16_t r = MIN(cx, cy) - 2;
if (isOn)
{
canvas.fillCircle(cx, cy, r, touchgfx::Color::getColorFrom24BitRGB(0, 255, 0));
}
else
{
// 熄灭状态画一个灰色圆
canvas.fillCircle(cx, cy, r, touchgfx::Color::getColorFrom24BitRGB(100, 100, 100));
}
}
private:
bool isOn;
int tickCounter;
};
你看,invalidate()的调用时机非常关键。调少了,界面不更新;调多了,性能浪费。我个人经验是:只在状态真正改变时才调invalidate()。比如上面的闪烁逻辑,如果isOn没变,就不调。
2.5 三者的协作流程
为了让你更直观地理解这三者的关系,我画了一张流程图:
这个流程你仔细看一遍:状态变化 → 调invalidate() → 框架标记脏区域 → 下一帧调draw() → 绘制完成。环环相扣,缺一不可。
2.6 常见踩坑与避坑指南
做自定义控件时,有几个坑我几乎每次培训都会提到:
| 常见问题 | 原因 | 解决方案 |
|---|---|---|
| 控件画了但看不见 | 忘记设宽高,或宽高为0 | 构造函数里调用setWidth()/setHeight() |
| 状态变了但界面不更新 | 忘记调invalidate() | 每次状态变化后立即调invalidate() |
| 绘制时崩溃 | draw()里访问了未初始化的成员 | 确保所有成员在draw()前已初始化 |
| 性能卡顿 | 每帧都调invalidate(),导致全屏重绘 | 只在状态真正改变时调invalidate() |
我曾经踩过的坑:有一次做仪表盘控件,指针每帧都动,我就在handleTickEvent()里无条件调invalidate()。结果整个屏幕每帧都全量重绘,帧率直接掉到20fps。后来改成只在新角度和旧角度不同时才调invalidate(),帧率立刻回到60fps。嗯,invalidate()虽好,可不要贪杯。
2.7 本章小结
来,我们捋一下这章的核心要点:
- 继承Drawable:拿到空白画布,设好宽高,定义自己的状态数据。
- 重写draw():在这里用Canvas API画东西。记住它是const方法,别想着改状态。
- 处理invalidate():状态变了就调它,让框架知道该重绘了。别多调,也别少调。
这三板斧看着简单,但它们是所有自定义控件的基石。后面章节里那些炫酷的圆形菜单、滑动选择器、自定义键盘,本质上都是在这个框架上叠加更复杂的绘制逻辑和交互处理。