4、自定义按钮:从Button类继承、自定义皮肤、状态管理

按钮,是嵌入式GUI里最常用的控件。没有之一。

TouchGFX自带的Button类能满足大部分常规需求——点击、弹起、触发回调。但实际项目里,你总会遇到一些「特殊要求」:比如按钮要显示动态图标、按下时要有呼吸灯效果、或者需要同时响应长按和短按。这时候,就得自己动手了。

我个人习惯,只要按钮行为超出标准控件的范围,就毫不犹豫地写子类。别怕继承,TouchGFX的控件体系设计得挺清爽,扩展起来并不复杂。

本章核心:学会从Button类派生自定义按钮,掌握皮肤(外观)的动态切换,以及多状态管理的设计思路。

4.1 为什么需要自定义按钮?

先看几个我在项目中遇到的真实场景:

  • 场景一:设备需要一个「录音按钮」,按下时显示红色圆点+波纹扩散动画,松开后恢复静态图标。标准Button做不到。
  • 场景二:工业平板上有「模式切换键」,需要根据当前模式显示不同颜色的皮肤(绿色=运行、黄色=待机、红色=报警)。标准Button只能换图片,不能联动状态机。
  • 场景三:需要检测「长按超过2秒」触发特殊功能,短按触发普通功能。标准Button只提供clicked回调。

说白了,标准Button是「通用件」,而你的产品需要「专用件」。自己写一个,一劳永逸。

4.2 从Button类继承

TouchGFX里所有控件都继承自Drawable。Button本身继承自ButtonBase,而ButtonBase又继承自Widget。我们自定义时,直接继承Button是最省力的方式——它已经帮你处理了触摸检测、回调触发等底层逻辑。

来看一个最简单的自定义按钮头文件:

#ifndef MY_CUSTOM_BUTTON_HPP
#define MY_CUSTOM_BUTTON_HPP

#include <touchgfx/widgets/Button.hpp>

class MyCustomButton : public touchgfx::Button
{
public:
    MyCustomButton();
    virtual ~MyCustomButton() {}

    // 重写绘制方法
    virtual void draw(const touchgfx::Rect& invalidatedArea) const override;

    // 重写触摸处理
    virtual void handleClickEvent(const touchgfx::ClickEvent& event) override;

    // 新增接口:设置自定义数据
    void setCustomData(uint8_t data);

private:
    uint8_t customData;
    bool isLongPress;
};

#endif

嗯,这里要注意:重写draw()方法时,一定要调用父类的draw(),除非你打算完全自己画。我刚开始做的时候忘了调,结果按钮背景一片黑,排查了半天才发现。

小技巧:如果你只是想在标准按钮基础上加一层装饰(比如边框发光),可以在draw()里先调用Button::draw(),再用Canvas或Renderer画额外内容。这样既保留了原有皮肤,又实现了扩展。

4.3 自定义皮肤:不只是换图片

标准Button的皮肤通过setBitmaps()设置,本质上就是两张图片(按下/弹起)。但自定义按钮可以玩得更花哨。

我习惯把皮肤拆成三层:

层次 内容 控制方式
底层 背景色/背景图 状态机控制
中层 图标/文字 业务逻辑控制
顶层 特效(阴影、高光、动画) 定时器控制

举个例子,实现一个「按下时背景变暗、图标变亮」的按钮:

void MyCustomButton::draw(const touchgfx::Rect& invalidatedArea) const
{
    // 先画背景
    if (getPressedState())
    {
        // 按下状态:深色背景
        touchgfx::Renderer::getInstance().fillRect(
            getRect(), touchgfx::Color::getColorFrom24BitRGB(0x33, 0x33, 0x33));
    }
    else
    {
        // 弹起状态:浅色背景
        touchgfx::Renderer::getInstance().fillRect(
            getRect(), touchgfx::Color::getColorFrom24BitRGB(0x66, 0x66, 0x66));
    }

    // 再画图标(假设图标是24x24的白色位图)
    // 这里可以按状态切换不同颜色的图标
    touchgfx::Bitmap icon = getPressedState() ? 
        touchgfx::Bitmap(BITMAP_ICON_PRESSED_ID) : 
        touchgfx::Bitmap(BITMAP_ICON_RELEASED_ID);
    
    touchgfx::Renderer::getInstance().drawBitmap(
        icon, 
        getX() + (getWidth() - 24) / 2,
        getY() + (getHeight() - 24) / 2,
        24, 24,
        0,  // alpha
        touchgfx::Bitmap::VS_NORMAL);
}
注意:在draw()里直接调用Renderer的绘图API时,一定要确保坐标是相对于屏幕的绝对坐标。我踩过坑——直接用getX()/getY()没问题,但如果按钮被嵌套在容器里,需要加上容器的偏移量。建议用getAbsoluteRect()获取绝对位置。

4.4 状态管理:从两态到多态

标准Button只有两种状态:pressed和released。但实际项目中,按钮往往需要更多状态:

  • 禁用态:灰色显示,不可点击
  • 选中态:高亮边框,表示当前选项
  • 加载态:显示转圈动画,表示操作进行中
  • 错误态:红色闪烁,表示操作失败

我设计了一个通用的状态枚举:

enum class ButtonState : uint8_t
{
    NORMAL      = 0,
    PRESSED     = 1,
    DISABLED    = 2,
    SELECTED    = 3,
    LOADING     = 4,
    ERROR       = 5
};

然后在自定义按钮里维护一个currentState变量。每次状态变化时,调用invalidate()触发重绘。你想想看,这样设计的好处是什么?——皮肤和状态完全解耦了。你想加一个新状态,只需要在枚举里加一项,然后在draw()里加一个case分支就行。

我曾经在一个医疗设备项目里,给按钮设计了7种状态,每种状态对应不同的颜色和图标组合。后期客户要求增加「待确认」状态,我只花了10分钟就改完了。这就是状态管理设计得好的回报。

4.5 完整示例:带状态管理的自定义按钮

下面是一个完整的实现片段,包含了状态切换和皮肤更新:

void MyCustomButton::setState(ButtonState newState)
{
    if (currentState == newState)
        return;  // 状态没变,不处理

    currentState = newState;

    // 根据状态更新外观
    switch (currentState)
    {
    case ButtonState::NORMAL:
        setTouchable(true);
        setBitmaps(Bitmap(BITMAP_BTN_NORMAL_ID), Bitmap(BITMAP_BTN_PRESSED_ID));
        break;

    case ButtonState::DISABLED:
        setTouchable(false);
        setBitmaps(Bitmap(BITMAP_BTN_DISABLED_ID), Bitmap(BITMAP_BTN_DISABLED_ID));
        break;

    case ButtonState::SELECTED:
        setTouchable(true);
        setBitmaps(Bitmap(BITMAP_BTN_SELECTED_ID), Bitmap(BITMAP_BTN_SELECTED_PRESSED_ID));
        break;

    case ButtonState::LOADING:
        setTouchable(false);
        // 加载态使用动画位图,由定时器驱动
        startLoadingAnimation();
        break;

    default:
        break;
    }

    // 触发重绘
    invalidate();
}
设计建议:状态切换时,记得考虑「过渡动画」。比如从NORMAL切换到DISABLED,直接变灰会显得很生硬。我一般会在setState()里启动一个200ms的渐变动画,让颜色平滑过渡。TouchGFX的EasingEquations类提供了现成的缓动函数,用起来很方便。

4.6 知识体系总览

下面这张图概括了本章的核心逻辑:

自定义按钮知识体系 自定义按钮 从Button类继承 自定义皮肤 状态管理 重写draw() 重写handleClickEvent() 三层皮肤架构 动态位图切换 多态枚举设计 状态→皮肤映射 核心原则:继承复用逻辑,皮肤与状态解耦 通过重写draw()和handleClickEvent()实现完全自定义

4.7 避坑指南

最后分享几个我踩过的坑,希望能帮你省点时间:

  • 坑一:忘记调用父类构造函数。自定义按钮的构造函数里,一定要先调用Button(),否则内部的状态机没初始化,点击没反应。
  • 坑二:在draw()里做耗时操作。draw()是在渲染循环里调用的,频率很高。千万别在里面做图片解码、内存分配之类的操作。我曾经在draw()里加载了一张PNG图片,结果帧率直接掉到10fps。
  • 坑三:状态切换后忘记invalidate()。改了状态、换了皮肤,但屏幕没刷新——这种问题排查起来特别隐蔽。我建议在setState()末尾强制调用invalidate(),养成习惯。
  • 坑四:触摸事件处理不当。重写handleClickEvent()时,如果你拦截了事件但没有调用父类方法,按钮的clicked回调就不会触发。要么自己处理完整的事件链,要么在最后调用Button::handleClickEvent(event)。