《ESP32-Arduino》LVGL之输入设备详解及实例

前言: 好久没写博客了,一方面是平时着实没有时间,另一方面是知识还是欠缺,实在没啥技术拿得出手(其实更主要的还是懒!!!)最近玩的比较多的就是LVGL了,自己也是做了几个小项目(后续考虑开源),考虑到网上LVGL入门教程还是比较少,特此出来写篇博客。

对于LVGL就不过多介绍了,能点进来的应该都知道LVGL是什么吧,本篇博客不讲UI中的相关组件,而侧重于讲解对于LVGL中的输入设备,什么是输入设备呢?对于LVGL来说,输入设备有:

  • LV_INDEV_TYPE_POINTER:触摸板或鼠标
  • LV_INDEV_TYPE_KEYPAD: 键盘
  • LV_INDEV_TYPE_ENCODER:编码器
  • LV_INDEV_TYPE_BUTTON:外部虚拟按钮

而对于大多数项目来说,用触摸屏,实体按键,编码器的比较多(打死我也不说是其他的我都没用过),那么如何将这些设备与LVGL中的组件相关联就是本篇博客的主要目的。

前期准备

环境

  • VScode+Platformio+LVGL工程(相关教程见【传送门】)

注:本博客虽是基于Arduino写的,但掌握原理,在其他平台一样能使用

硬件

  • ESP32
  • 带触摸或不带触摸的TFT显示屏
  • 物理按键 / 编码器(如EC11)/ 多功能按键(如SLLB120200)

知识储备

  • 对应的LVGL文档页面

流程讲解

在摆代码之前,先过一下流程,因为所有的输入设备都是基于这一套流程走的,代码都大同小异
image-20230629123828017
这里面my_indev_read是一个函数名,其作用就是不断读取输入设备的状态,当检测到输入设备的状态与之前的状态不同时就会更新状态并做出相应的响应,响应事件见【传送门

实例

所有的实例都是基于LVGL官方模板所写的,模板详见【传送门】,以下实例的使用方法就是将其建成一个.cpp文件,并新建一个同名.h文件,.h文件用于函数声明且和在其他文件中调用,.c文件用于函数定义(这些都是c语言的基础知识,实在不懂的自行百度吧),这里的输入设备初识化只需要在你工程初识化的地方调用lv_port_indev_init()函数即可

触摸屏

触摸屏反馈及初识化函数用的是第三方库【TFT_eSPI】中的相关内容

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
#include <lvgl.h>
#include <TFT_eSPI.h>

// 触摸屏状态更新函数
static void my_touchpad_read( lv_indev_drv_t * indev_driver, lv_indev_data_t * data )
{
uint16_t touchX, touchY;

bool touched = tft.getTouch( &touchX, &touchY, 600 );

if( !touched )
{
data->state = LV_INDEV_STATE_REL;
}
else
{
data->state = LV_INDEV_STATE_PR;

/*Set the coordinates*/
data->point.x = touchX;
data->point.y = touchY;

//Serial.print( "Data x " );
//Serial.println( touchX );
//Serial.print( "Data y " );
//Serial.println( touchY );
}
}

// 触摸屏初识化函数
static void my_touchpad_init()
{
// 以下两种方式的功能都是触摸屏的校准,TFT_eSPI用的是第一种,而LVGL官方用的是第二种,我也不知道那种方式更好
// Calibrate the touch screen and retrieve the scaling factors
touch_calibrate();

/*
// Replace above line with the code sent to Serial Monitor
// once calibration is complete, e.g.:
uint16_t calData[5] = { 286, 3534, 283, 3600, 6 };
tft.setTouch(calData);
*/
}

// 输入设备初识化函数
void lv_port_indev_init(void)
{
// 初识化触摸屏
my_touchpad_init();
// 注册输入设备
static lv_indev_drv_t indev_drv;
lv_indev_drv_init( &indev_drv );
indev_drv.type = LV_INDEV_TYPE_POINTER;
indev_drv.read_cb = my_touchpad_read;
lv_indev_drv_register( &indev_drv );
}

实体按键

实体按键可以使用第三方库【MD_UISwitch】作为按键的反馈(支持长按阶段性反馈,即长按的话隔一段时间就切换下一个组件,但这样就不能响应LV_EVENT_LONG_PRESSED中的长按响应事件了),也可以直接使用Arduino中digitalWrite()读取引脚电平或者其他单片机中读取引脚电平的方法(这种方式支持LV_EVENT_LONG_PRESSED中的长按响应事件,但不能长按阶段性反馈),看各位取舍吧,我的建议就是一般导航键都有3个按键,左右及确定键,左右键使用MD_UISwitch,中间确定键使用digitalWrite()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
#include <lvgl.h>
#include "MD_UISwitch.h"

#define PinA 1 // 左键引脚
#define PinB 2 // 右键引脚
#define PinC 3 // 确定键引脚

MD_UISwitch_Digital Key_L(PinA, (uint8_t)LOW);
MD_UISwitch_Digital Key_R(PinB, (uint8_t)LOW);

// 按键扫描函数,用于区分哪个按键按下了
uint8_t Key_Scan()
{
if (digitalRead(PinC) == LOW)
{
//Serial.Println("[Navigation] click");
return 1;
}
MD_UISwitch::keyResult_t k_r = Key_R.read();
MD_UISwitch::keyResult_t k_l = Key_L.read();

if (k_l == MD_UISwitch::KEY_PRESS)
{
//Serial.Println("[Navigation] prev");
return 2;
}
else if (k_r == MD_UISwitch::KEY_PRESS)
{
//Serial.Println("[Navigation] next");
return 3;
}

return 0;// 没按下返回0
}

// 按键状态更新函数
static void my_key_read( lv_indev_drv_t * indev_driver, lv_indev_data_t * data )
{
static uint32_t last_key = 0;
uint8_t act_enc = Key_Scan();

if(act_enc != 0) {
switch(act_enc) {
case 1:
act_enc = LV_KEY_ENTER;
data->state = LV_INDEV_STATE_PRESSED;
break;
case 2:
act_enc = LV_KEY_RIGHT;
data->state = LV_INDEV_STATE_RELEASED;
data->enc_diff++;
break;
case 3:
act_enc = LV_KEY_LEFT;
data->state = LV_INDEV_STATE_RELEASED;
data->enc_diff--;
break;
}
last_key = (uint32_t)act_enc;
}
data->key = last_key;
}

// 按键初识化函数
static void my_key_init()
{
Key_L.enableDoublePress(false); // 是否允许双击
Key_L.enableLongPress(false); // 是否允许长按
Key_R.enableDoublePress(false); // 是否允许双击
Key_R.enableLongPress(false); // 是否允许长按
pinMode(PinC, INPUT_PULLUP);
}

// 输入设备初识化函数
void lv_port_indev_init(void)
{
// 初识化按键
my_key_init();
// 注册输入设备
static lv_indev_drv_t indev_drv;
lv_indev_drv_init( &indev_drv );
// 说一下这里的类型为什么要设置为编码器类型,因为我试过很多遍LV_INDEV_TYPE_KEYPAD都没成功,
// 我觉得是这里的KEYPAD可能指的是键盘鼠标的那种键盘,所以就没成功,当然也可能单纯是我菜
indev_drv.type = LV_INDEV_TYPE_ENCODER;
indev_drv.read_cb = my_key_read;
lv_indev_drv_register( &indev_drv );
}

多功能按键(或叫波轮按键)

多功能按键并不属于编码器类型,虽然它们之间有些形状类似,但原理截然不同,多功能按键本质上就是按键
image-20230629123925089
多功能按键一共有6个引脚,其中C为公共脚,一般接地或接VCC
image-20230629124010790
当多功能按键中间键按下时,C脚和T脚接通,因此C+T脚可以组成一个按键开关
image-20230629124030837
如图,图中黑色部分表示接通,如当波轮开关顺时针旋转11°时1,C脚接通,当波轮开关继续旋转到21.5°时1,2,C脚接通,因此1,2都可以分别和C脚组成一个开关,当转动角度不同时,1,2引脚被相继置为低电平或高电平(根据C引脚决定)

知道了多功能按键的原理,使用起来也非常简单,只需要把多功能按键看做按键使用,将1,4,T(或2,3,T)引脚分别接到单片机IO口上,C引脚接地就能组成上述实体按键一样的效果,代码就不展示了,稍微变通一下就行

编码器

编码器的类型有各式各样,但原理都相同(原理我也没怎么明白,就不丢人了),其都有ABC及S四个引脚(有些有多个S引脚),A,B引脚为左右旋的时候触发,C引脚为按下时触发,因此可以将S引脚接地,AB及C引脚接单片机IO口组成导航键,这里的编码器反馈用到了第三方库【MD_REncoder】,没办法,Arduino好就好在第三方库多,完全不用懂原理就能用(窃喜)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
#include <lvgl.h>
#include "MD_REncoder.h"

#define PinA 1 // 左键引脚
#define PinB 2 // 右键引脚
#define PinC 3 // 确定键引脚

static MD_REncoder R = MD_REncoder(PinA, PinB); //旋转编码器

// 编码器扫描函数,用于判断左右及按下状态
uint8_t Encoder_Scan()
{
if (digitalRead(PinC) == LOW)
{
//Serial.Println("[Navigation] click");
return 1;
}
uint8_t x = R.read();
if (x)
{
// x == DIR_CW;
if (x == DIR_CW ) {
//Serial.println("[Navigation] next");
return 2;
}
else
{
//Serial.println("[Navigation] prev");
return 3;
}
}
return 0;// 没按下返回0
}

// 编码器状态更新函数
static void my_encoder_read( lv_indev_drv_t * indev_driver, lv_indev_data_t * data )
{
static uint32_t last_key = 0;
uint8_t act_enc = Encoder_Scan();

if(act_enc != 0) {
switch(act_enc) {
case 1:
act_enc = LV_KEY_ENTER;
data->state = LV_INDEV_STATE_PRESSED;
break;
case 2:
act_enc = LV_KEY_RIGHT;
data->state = LV_INDEV_STATE_RELEASED;
data->enc_diff++;
break;
case 3:
act_enc = LV_KEY_LEFT;
data->state = LV_INDEV_STATE_RELEASED;
data->enc_diff--;
break;
}
last_key = (uint32_t)act_enc;
}
data->key = last_key;
}

// 按键初识化函数
static void my_encoder_init()
{
R.begin();
pinMode(PinC, INPUT_PULLUP);
}

// 输入设备初识化函数
void lv_port_indev_init(void)
{
// 初识化编码器
my_encoder_init();
// 注册输入设备
static lv_indev_drv_t indev_drv;
lv_indev_drv_init( &indev_drv );
indev_drv.type = LV_INDEV_TYPE_ENCODER;
indev_drv.read_cb = my_encoder_read;
lv_indev_drv_register( &indev_drv );
}

将输入设备与组件绑定

输入设备初识化成功后如何将其与LVGL中的组件进行绑定呢?这是很多教程中没有说明的,也是我踩了很多坑之后才知道的。这里就需要知道一个lv_group_t的概念,group是LVGL中很重要的一部分,其作用就是将许多LVGL中的组件划分为不同的组,输入设备可以通过切换绑定的组从而控制不同组中的组件,这里介绍几个group中常用的函数,更多函数见【传送门

  • lv_group_t * lv_group_create(void)
    • 作用:创建一个lv_group_t实例,如 lv_group_t* group = lv_group_create()
  • void lv_group_del(lv_group_t* group)
    • 作用:删除一个lv_group_t实例
  • void lv_group_set_default(lv_group_t * group)
    • 作用:设置一个group实例为默认组
  • lv_group_t * lv_group_get_default(void)
    • 作用:获取默认组
  • void lv_group_add_obj(lv_group_t * group, lv_obj_t * obj)
    • 作用:group填加组件,只有在group中添加的组件才能受到控制
  • void lv_group_remove_obj(struct _lv_obj_t * obj)
    • 作用:group移除组件
  • void lv_group_remove_all_objs(lv_group_t * group)
    • 作用:group移除所有组件
  • void lv_group_set_editing(lv_group_t * group, bool edit)
    • 作用:设置group为编辑模式或者导航模式,这里的编辑模式只对如下拉列表,按键矩阵等有二级控件时有用,一般来说这些在导航模式都需要先点击确定才能编辑,而在编辑模式下,无需确定即可编辑
  • void lv_indev_set_group(lv_indev_t * indev, lv_group_t * group)
    • 作用:将输入设备与group相绑定,这里的输入设备指lv_indev_drv_register()返回的值,这个最重要了, 前面初识化,添加组件都弄了,要是最后没绑定,一切都白搭

这里给个例子吧

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
lv_obj_t* btn1 = lv_btn_create(NULL);
lv_obj_t* btn2 = lv_btn_create(NULL);

// group可以在void lv_port_indev_init()函数中就创建,而在窗口中通过
// lv_group_t* group = lv_group_get_default();
// lv_group_add_obj(group, obj);
// 的方式添加组件,更多的自己慢慢探索吧
group = lv_group_create();
lv_group_set_default(group);
lv_group_remove_all_objs(group);
lv_group_add_obj(group, btn1);
lv_group_add_obj(group, btn2);
// 这里的indev_encoder是lv_indev_drv_register( &indev_drv )的返回值
// 案例中均没有使用到这个值
lv_indev_set_group(indev_encoder, group);

最后

LVGL官方最近出了一个图形化工具,根据简单的拖拽即可导出UI代码,支持最新的LVGL8.2版本,不过目前支持的组件不多,而且是付费应用(有30天试用期),感兴趣的见【传送门】。
我还是建议自己敲代码,尽管这样会踩很多坑,但毕竟踩坑才能进步,才能成长。
最后,看反馈不定期更下一期!