综述
本文将完成以下任务
使用触摸屏功能,实现一个简单的绘图应用,用户可以用手指在屏幕上绘制线条。
我们将实现一个画面,具有绘制区域,预设颜色按钮、清屏按钮、调色盘等功能。画布中使用Line类来模拟绘制的曲线;颜色按钮和调色盘用于设置画笔颜色,调色盘通过调节R,G,B的值调节画笔颜色,并且实时将颜色显示在调色盘按钮上;Clear按钮用于清空画布内容。
开发工具
TouchGFX Designer 4.26.0,STM32CubeIDE 2.0.0,STM32CubeMX
GUI绘制
- 在新建或者复制项目文件后,先新建一个Screen(我在上一个项目的基础上编辑),修改名称为SketchPad;如果有其他画面存在的话,先设置为Startup Screen,方便后续调试;勾选 Canvas Buffer 并设置大一些,这个大小将决定画布能画多少条线。

- 点击Containers添加并绘制自定义的控件,用于画布的实现,负责实际“绘制”逻辑,不与Screen混合,结构更清晰,也可以避免很多问题:

将新建后的Container重命名为SketchContainer,调整大小为W240×H260;

- 返回Screens,添加自定义的SketchContainer组件,并且勾选Mixins中的ClickListener,确保能响应绘制时的点击事件;

- 添加FlexButton控件,调整大小为W30×H30,设置Box With Border元素的Border Size 边框尺寸为3,颜色设置为对应按钮颜色,复制并放置红、橙、黄、绿、蓝、紫、黑和一个预留的调色盘按钮。

复制多个按钮,因为按钮较多,为了方便辨认,最好给每个按钮都命名。修改按钮颜色、文本内容并排版后的样式如下:
其中调色盘按钮(ColorChooseBtn)的Visual Elements中加入文本元素"...",清屏按钮(ClearBtn)加入文本元素"Clear"。

- 接下来绘制调色盘的弹窗,首先添加一个 ModalWindow,底图选择较大的240×270的边框。

)
- 绘制滑块控件,在Images中导入绘制滑动控件用的三个颜色的长条和小球,如图所示。

添加一个Slider,不需要选择预设,直接进行Image的替换:
原本的track_medium.png替换成我们自己的track,filler_medium.png替换成我们自己的filler,同样的,滑块也替换成自己的小球。

设置Indicator Position(小球的位置)的最大值为160,因为长条的最大值只有160,否则小球能够划出画布范围;同时设置Values数值范围为0到255,表示8位颜色。

复制三个Slider并分别设置控件为对应颜色的图片,记得将三个控件拖到弹窗ColorSetmodalWindow里面,并分别修改名称,调整位置后的布局如下:

- 接下来在3个滑块的后面加入一个文本,使拉动滑块的同时会显示对应的数值:
添加一个Text Area,在Translation下面有一个wildcard的"➕",点击加号添加一个通配符;
输入Wildcard1 的文本ID、初始值,并勾选Use wildcard buffer,buffer的大小使用默认的10即可。在后续交互中会实现Slider和Text Area的绑定。

同样的,将Text Area复制三份,并在控件树中拖到弹窗ColorSetmodalWindow里面,分别修改名称为GreenValue,BlueValue,RedValue;

同时,注意GreenValue、BlueValue的Wildcard ID需要删除并重新设置,否则后面无法正常显示。

⚠在Texts -> Typographies里面修改Wildcard对应的字体预设,在Wildcard Characters中添加数字字符,才能确保动态显示的数字不会变成???

- 在modalWindow容器中添加一个FlexButton按钮控件,修改名称为ColorConfirmBtn,即颜色设置的确认按键,将按钮的边框大小调为0,只保留填充颜色。
!
交互设计
因为TouchGFX没有提供原生的小画板控件,我们必须通过额外的代码来实现绘制线条的功能。
CubeIDE编写代码
找到项目文件夹中的<项目名>/STM32CubeIDE,双击.project或者.cproject打开CubeIDE项目文件。

在项目中的Application/User/gui中有两个和SketchPad这个窗口有关的文件,分别是SketchPadPresenter.cpp和SketchPadView.cpp;以及一个SketchContainer.cpp的自定义组件的源文件,双击打开它们。

SketchContainer作为自定义控件,响应在Container内触发的点击和拖拽事件,并直接完成线段绘制;
SketchPadView和SketchPadViewBase作为UI层,实现了可视化控件的绘制和交互事件处理,sketchContainer1在SketchPadViewBase.hpp中声明,可以通过SketchPadView的继承,在SketchPadView中调用;
同时SketchPadPresenter作为逻辑层,虽然不能直接使用SketchPadViewBase中声明的变量和函数,但是可以通过SketchPadView& view这一私有变量的声明,实现对Container的接口的调用;同时presenter又在父类View.hpp中作为保护段变量被声明,这一设计使得SketchPadPresenter中实现的接口可以在SketchPadViewBase 自动生成回调函数中中通过presenter -> <函数名> 被调用。

SketchContianer
首先修改SketchContianer的源文件和头文件,按住Ctrl并单击.cpp上include的头文件可以直接打开SketchContianer.hpp。

SketchContianer.hpp的内容如下,主要是实现了点击和拖拽响应以及线条绘制、同时提供了清屏、修改颜色的接口。代码中已经加以注释,以供参考:
#ifndef SKETCHCONTAINER_HPP
#define SKETCHCONTAINER_HPP
#include <gui_generated/containers/SketchContainerBase.hpp>
#include <touchgfx/widgets/canvas/Line.hpp> // Canvas 模式的直线控件
#include <touchgfx/widgets/canvas/PainterRGB565.hpp> // RGB565 画笔,用于为 Line 上色
/* 每一条用户绘制的线段都使用一个 LineNode 保存:
// 包含 Line 控件对象、画笔对象、起点与终点像素坐标。
TouchGFX 的 Line 实际上只会画一条直线,曲线效果靠多条直线逼近。*/
struct LineNode
{
touchgfx::Line line; // TouchGFX 的可绘制直线控件
touchgfx::PainterRGB565 painter; // 为 Line 提供颜色、位深格式的画笔
int16_t sx, sy; // 当前线段的起点(屏幕坐标)
int16_t ex, ey; // 当前线段的终点(屏幕坐标)
};
/* SketchContainer内部有白色画布与所有的线段控件。*/
class SketchContainer : public SketchContainerBase
{
public:
SketchContainer();
virtual ~SketchContainer() {}
virtual void initialize(); // 初始化画布与所有 LineNode(隐藏但预加入容器)
enum { MAX_LINES = 600 }; // 最多同时存储 600 条线,避免 RAM 爆炸,可根据实际情况调整大小
// 在本容器中拦截触摸事件,以实现绘图
virtual void handleClickEvent(const touchgfx::ClickEvent& event) override;
virtual void handleDragEvent(const touchgfx::DragEvent& event) override;
void clear(); // 清除所有线段并隐藏
void setColor(touchgfx::colortype c); // 设置当前绘图颜色(改变画笔颜色)
protected:
LineNode lines[MAX_LINES]; // 所有预创建的线段,循环使用,避免动态分配内存
int lineCount; // 当前已经绘制的有效线段数量
bool touchActive; // 是否正在绘制(按需使用)
bool fingerDown; // 手指是否按下(用于状态机)
int16_t lastX; // 上一个触摸点 X(用于平滑)
int16_t lastY; // 上一个触摸点 Y(用于平滑)
touchgfx::colortype currentColor; // 画笔颜色
// Canvas 的区域(用户允许绘图的矩形区域)
enum { CANVAS_X = 0 };
enum { CANVAS_Y = 0 };
enum { CANVAS_W = 240 };
enum { CANVAS_H = 260 };
// 判断新线段是否与旧线段太接近(避免重复绘制造成锯齿或污点)
bool willOverlap(int x0, int y0, int x1, int y1);
// 判断当前触摸点是否在画布区域内
bool insideCanvas(int16_t x, int16_t y) const;
};
#endif // SKETCHCONTAINER_HPP
下面是SketchContainer.cpp的内容。
#include <gui/containers/SketchContainer.hpp>
#include <touchgfx/Color.hpp>
/**
* @brief 构造函数,初始化绘图容器的内部状态
*
* @note 默认画笔颜色为黑色,容器触摸功能自动开启。
*/
SketchContainer::SketchContainer()
: lineCount(0),
touchActive(false),
fingerDown(false),
lastX(0),
lastY(0)
{
setTouchable(true); ///< 必须允许接收触摸事件才能手写绘制
currentColor = touchgfx::Color::getColorFromRGB(0,0,0);
}
/**
* @brief 初始化画布与所有线段对象
*
* @note TouchGFX CanvasWidgetRenderer 需要提前 add() 所有 Line 元素。
* @note 线段初始化为不可见,绘制时才设置 Visible。
*/
void SketchContainer::initialize()
{
SketchContainerBase::initialize();
// 设置白色画布
box1.setPosition(CANVAS_X, CANVAS_Y, CANVAS_W, CANVAS_H);
box1.setColor(touchgfx::Color::getColorFromRGB(255,255,255));
// 初始化线段池(静态分配,避免动态内存)
for (int i = 0; i < MAX_LINES; i++)
{
LineNode& node = lines[i];
node.sx = node.sy = 0;
node.ex = node.ey = 0;
node.line.setPosition(CANVAS_X, CANVAS_Y, CANVAS_W, CANVAS_H);
node.painter.setColor(currentColor);
node.line.setPainter(node.painter);
node.line.setLineWidth(2);
node.line.setLineEndingStyle(touchgfx::Line::ROUND_CAP_ENDING);
node.line.setVisible(false);
add(node.line); ///< 加入容器供 CanvasWidgetRenderer 使用
}
}
/**
* @brief 判断触摸点是否处于画布区域内
*
* @param x 屏幕 X 坐标
* @param y 屏幕 Y 坐标
* @return true 触摸点在画布内
* @return false 触摸点不在画布内
*/
bool SketchContainer::insideCanvas(int16_t x, int16_t y) const
{
return (x >= CANVAS_X && x < CANVAS_X + CANVAS_W &&
y >= CANVAS_Y && y < CANVAS_Y + CANVAS_H);
}
/**
* @brief 跳点判定(用于去除异常快速位移)
*
* @param x1 上一点 X
* @param y1 上一点 Y
* @param x2 当前点 X
* @param y2 当前点 Y
* @return true 表示属于跳点,应忽略
*/
static inline bool isJump(int x1, int y1, int x2, int y2)
{
int dx = x2 - x1;
int dy = y2 - y1;
int d2 = dx * dx + dy * dy;
return (d2 > 30 && d2 < 120);
}
/**
* @brief 一阶平滑滤波
*
* @param oldValue 旧数据
* @param newValue 新数据
* @return 平滑后的结果
*/
static inline int smooth(int oldValue, int newValue)
{
return (oldValue + newValue * 3) / 4;
}
/**
* @brief 二阶平滑滤波(未使用)
*
* @param x2 上上帧
* @param x1 上一帧
* @param n 当前帧
* @return 滤波输出值
*/
inline int16_t smooth2(int16_t x2, int16_t x1, int16_t n)
{
return (x2 + x1 * 2 + n) >> 2;
}
/**
* @brief 判断新线段是否会覆盖旧线段
*
* @param x0 旧点 X
* @param y0 旧点 Y
* @param x1 新点 X
* @param y1 新点 Y
* @return true 距离过近,视为覆盖,应禁止绘制
* @return false 无覆盖,可绘制
*/
bool SketchContainer::willOverlap(int x0, int y0, int x1, int y1)
{
const int minDist2 = 9;
for (int i = 0; i < lineCount; i++)
{
const LineNode& LN = lines[i];
int dx = LN.sx - x1;
int dy = LN.sy - y1;
if (dx * dx + dy * dy < minDist2) return true;
dx = LN.ex - x1;
dy = LN.ey - y1;
if (dx * dx + dy * dy < minDist2) return true;
}
return false;
}
/**
* @brief 处理触摸按下与释放事件
*
* @param e TouchGFX ClickEvent 事件
*
* @note 按下点必须在画布内,才能开始绘制。
*/
void SketchContainer::handleClickEvent(const touchgfx::ClickEvent& e)
{
if (e.getType() == touchgfx::ClickEvent::PRESSED)
{
fingerDown = true;
lastX = e.getX();
lastY = e.getY();
if (!insideCanvas(lastX, lastY))
fingerDown = false;
}
else if (e.getType() == touchgfx::ClickEvent::RELEASED)
{
fingerDown = false;
touchActive = false;
}
SketchContainerBase::handleClickEvent(e);
}
/**
* @brief 处理拖动事件,实现连续绘图
*
* @param e TouchGFX DragEvent 事件
*
* @note 逻辑包括:
* - 检查是否在画布内
* - 平滑处理
* - 跳点过滤
* - 覆盖检测
* - 生成线段并渲染
*/
void SketchContainer::handleDragEvent(const touchgfx::DragEvent& e)
{
if (!fingerDown) return;
int rx = e.getNewX();
int ry = e.getNewY();
if (!insideCanvas(rx, ry))
{
fingerDown = false;
return;
}
// 一阶平滑
int x = (lastX + rx * 3) >> 2;
int y = (lastY + ry * 3) >> 2;
// 微小移动忽略
if (abs(x - lastX) < 2 && abs(y - lastY) < 2)
return;
if (willOverlap(lastX, lastY, x, y))
return;
if (lineCount >= MAX_LINES)
return;
LineNode& node = lines[lineCount];
node.sx = lastX;
node.sy = lastY;
node.ex = x;
node.ey = y;
node.painter.setColor(currentColor);
node.line.setPainter(node.painter);
node.line.setLineWidth(2);
node.line.setVisible(true);
node.line.setStart(node.sx - CANVAS_X, node.sy - CANVAS_Y);
node.line.setEnd(node.ex - CANVAS_X, node.ey - CANVAS_Y);
node.line.invalidate();
lastX = x;
lastY = y;
lineCount++;
}
/**
* @brief 清除全部线段,将画布恢复为空白
*
* @note 仅隐藏线段,不释放对象。
*/
void SketchContainer::clear()
{
for (int i = 0; i < lineCount; i++)
{
lines[i].line.setVisible(false);
lines[i].line.invalidate();
}
lineCount = 0;
}
/**
* @brief 设置当前绘图颜色
*
* @param c RGB565 颜色值
*/
void SketchContainer::setColor(touchgfx::colortype c)
{
currentColor = c;
}
SketchPadView
SketchPadView.cpp完全不需要修改,只需要在SketchPadView.hpp中声明并定义下面的函数接口:
SketchContainer& getSketch()
{
return sketchContainer1; //获取绘制窗口名字
}
因为SketchPadView继承于SketchPadViewBase类,所以也具有Base类的成员sketchContainer1。public中声明并定义getSketch()函数,并返回sketchContainer1;其他类(例如SketchPadPresenter)如果拥有SketchPadView类的参数,则可以通过调用getSketch()函数来获取sketchContainer1,并调用SketchContainer.hpp中声明的函数;
SketchPadPresenter
SketchPadPresenter主要提供接口给按钮和滑动控件,实现了对颜色设置、清屏等效果的封装,在SketchPadViewBase文件的交互设计中,通过presenter -> <函数名>进行调用。
SketchPadView.hpp
#ifndef SKETCHPADPRESENTER_HPP
#define SKETCHPADPRESENTER_HPP
#include <gui/model/ModelListener.hpp>
#include <mvp/Presenter.hpp>
using namespace touchgfx;
class SketchPadView;
class SketchPadPresenter : public touchgfx::Presenter, public ModelListener
{
public:
SketchPadPresenter(SketchPadView& v);
virtual void activate();
virtual void deactivate();
virtual ~SketchPadPresenter() {}
//使用Container中提供的清屏和设置颜色的接口
void clearSketch();
void setSketchColor(touchgfx::colortype c);
//用于slider滑动事件的响应
void updateRedValue(int value);
void updateGreenValue(int value);
void updateBlueValue(int value);
//用于modalWindow中的按钮
touchgfx::colortype getColorValue(); //得到实时的r,g,b的颜色,用于调色盘按钮颜色的实时变化
touchgfx::colortype confirmColorValue(); //提交修改完的颜色
private:
SketchPadView& view;
SketchPadPresenter();
int r,g,b; //用于临时存储r,g,b的数据,用于调色盘中到画笔颜色的更新
};
#endif // SKETCHPADPRESENTER_HPP
SketchPadView.cpp
#include <gui/sketchpad_screen/SketchPadView.hpp>
#include <gui/sketchpad_screen/SketchPadPresenter.hpp>
#include <touchgfx/Color.hpp>
/**
* @brief 构造函数,保存 View 引用并初始化 RGB 分量
*
* @param v 当前屏幕对应的 SketchPadView 引用
*/
SketchPadPresenter::SketchPadPresenter(SketchPadView& v)
: view(v),
r(0),
g(0),
b(0)
{
}
/**
* @brief 当该屏幕切入(变为当前活动屏幕)时自动调用
*
* 可以在此处从 Model 中读取状态、初始化颜色等。
*/
void SketchPadPresenter::activate()
{
}
/**
* @brief 当该屏幕切出(不再是当前活动屏幕)时自动调用
*
* 可以在此处将必要状态写回 Model 或做资源释放。
*/
void SketchPadPresenter::deactivate()
{
}
/**
* @brief 清空绘图画布
*
* 通过 View 获取内部的 SketchContainer,并调用其 clear()。
*/
void SketchPadPresenter::clearSketch()
{
view.getSketch().clear();
}
/**
* @brief 设置画布的当前画笔颜色
*
* @param c 要设置的 RGB565 颜色
*
* Presenter 不直接操作容器,而是通过 View 转发到 SketchContainer。
*/
void SketchPadPresenter::setSketchColor(touchgfx::colortype c)
{
view.getSketch().setColor(c);
}
/**
* @brief 更新当前颜色的 R 分量
*
* @param value 0~255 的红色分量值,对应调色板 R Slider
*/
void SketchPadPresenter::updateRedValue(int value)
{
r = value;
}
/**
* @brief 更新当前颜色的 G 分量
*
* @param value 0~255 的绿色分量值,对应调色板 G Slider
*/
void SketchPadPresenter::updateGreenValue(int value)
{
g = value;
}
/**
* @brief 更新当前颜色的 B 分量
*
* @param value 0~255 的蓝色分量值,对应调色板 B Slider
*/
void SketchPadPresenter::updateBlueValue(int value)
{
b = value;
}
/**
* @brief 根据当前 RGB 分量组合出颜色,但不立即应用到画布
*
* @return 由 r/g/b 组合得到的 RGB565 颜色值
*
* 通常用于在调色板中“预览”颜色,例如更新 OK 按钮文本颜色。
*/
touchgfx::colortype SketchPadPresenter::getColorValue()
{
touchgfx::colortype c = touchgfx::Color::getColorFromRGB(r, g, b);
return c;
}
/**
* @brief 确认当前颜色并应用到画布
*
* @return 实际应用到 SketchContainer 的 RGB565 颜色值
*
* 一般在 ColorSetModalWindow 的 OK 按钮点击时调用:
* - 先由 View 把 Slider 的最终值写入 r/g/b
* - 调用本函数生成颜色并设置给 SketchContainer
*/
touchgfx::colortype SketchPadPresenter::confirmColorValue()
{
touchgfx::colortype c = touchgfx::Color::getColorFromRGB(r, g, b);
setSketchColor(c); // 真正把颜色传给容器
return c;
}
TouchGFX设计交互
完成上面部分的内容后,我们回到TouchGFX Designer添加控件的交互内容。此处内容重复性比较强,只展示具有代表性的:
和ModalWindow显示有关的交互
首先是首次进入画面,即Screen Transition begins,应当隐藏ModalWindow;

然后是调色盘按钮ColorChooseBtn按下时,展示ModalWindow;

最后是点击确认按钮ColorConfirmBtn时,隐藏ModalWindow。

颜色按钮部分
颜色按钮都只需要在点击时调用presenter中的setSketchColor()函数:
presenter->setSketchColor(touchgfx::Color::getColorFromRGB(255,10,10));
这行代码设置的是红色,其他颜色只需要将调节得到的色彩值填入括号中即可。


清屏按钮
实现清屏只需要调用presenter中的clearSketch()函数:
presenter->clearSketch();

滑块颜色的更新和显示
滑块的回调函数包含有一个int类型的value,是根据滑块位置映射的数值,范围是我们之前设置的0~255。在滑块的交互设置中将这个参数传递给presenter并存储,即presenter->updateRedValue(value); 则传递给了presenter的 r(或g,b)私有变量。
接着是将value传给文本通配符的缓冲区,使用snprintf函数实现;最后使用invalidate()宣告当前区域无效,需要刷新显示。
Unicode::snprintf(RedValueBuffer, REDVALUE_SIZE, "%d", value);
RedValue.invalidate();
然后为了方便预览颜色效果,我们决定用ColorConfirmBtn展示颜色,需要将按钮的 Box With Border 元素颜色实时地设置为三个滑块表示的颜色,当前颜色使用前面定义的 presenter->getColorValue() 接口获取,返回的是一个 touchgfx::colortype 类型的值;
通过setBoxWithBorderColors()设置按钮颜色:setBoxWithBorderColors()的四个参数,第一个参数是松开状态按钮的颜色,其他三个参数分别是按钮按下、边框松开、边框按下的颜色,任意给一个数值即可;
ColorConfirmBtn.setBoxWithBorderColors(presenter->getColorValue(), touchgfx::Color::getColorFromRGB(50, 50, 50), touchgfx::Color::getColorFromRGB(0, 51, 102), touchgfx::Color::getColorFromRGB(4, 49, 94));
ColorConfirmBtn.invalidate();
完整代码如下:
//Red
presenter->updateRedValue(value);
Unicode::snprintf(RedValueBuffer, REDVALUE_SIZE, "%d", value);
RedValue.invalidate();
ColorConfirmBtn.setBoxWithBorderColors(presenter->getColorValue(), touchgfx::Color::getColorFromRGB(50, 50, 50), touchgfx::Color::getColorFromRGB(0, 51, 102), touchgfx::Color::getColorFromRGB(4, 49, 94));
ColorConfirmBtn.invalidate();
同样的,BlueSlider和GreenSlider的回调函数中也应该加入类似代码:
//Blue
presenter->updateBlueValue(value);
Unicode::snprintf(BlueValueBuffer, BLUEVALUE_SIZE, "%d", value);
BlueValue.invalidate();
ColorConfirmBtn.setBoxWithBorderColors(presenter->getColorValue(), touchgfx::Color::getColorFromRGB(50, 50, 50), touchgfx::Color::getColorFromRGB(0, 51, 102), touchgfx::Color::getColorFromRGB(4, 49, 94));
ColorConfirmBtn.invalidate();
//Green
presenter->updateGreenValue(value);
Unicode::snprintf(GreenValueBuffer, GREENVALUE_SIZE, "%d", value);
GreenValue.invalidate();
ColorConfirmBtn.setBoxWithBorderColors(presenter->getColorValue(), presenter->getColorValue(), presenter->getColorValue(), touchgfx::Color::getColorFromRGB(4, 49, 94));
ColorConfirmBtn.invalidate();

调色盘中的确认按钮
最后是确认按钮的点击事件,除了前面已经添加的隐藏ModalWindow的交互,还需要将颜色上传给SketchContainer中的currentColor,颜色设置才算完成。为了方便在绘制时查看颜色,我们也可以改变调色盘唤出按钮的颜色,即ColorChooseBtn的颜色。
使用presenter->confirmColorValue()将presenter的三个私有变量r, g, b上传为SketchContainer的画笔颜色;
因为confirmColorValue()的返回值是 touchgfx::colortype 类型,所以可以直接放到setBoxWithBorderColors中作为参数。
ColorChooseBtn.setBoxWithBorderColors(presenter->confirmColorValue(), touchgfx::Color::getColorFromRGB(0, 50, 66), touchgfx::Color::getColorFromRGB(84, 84, 84), touchgfx::Color::getColorFromRGB(4, 49, 94));

效果展示
至此所有的交互都已经编写完成,下载到板上看看效果。

工程源文件已经放在Github上:STM32F429-TouchGFX/MyApplication_SketchPad at main · Chiando-1100/STM32F429-TouchGFX