单片机 GUI 设计(十八)- 环形进度条
单片机 GUI 设计(十八)- 环形进度条

单片机 GUI 设计(十八)- 环形进度条

基于 GD32F303ZET6 苹果派开发板

简介

环形进度条如下所示。横向的进度条很容易实现,就是简单的 LCD 填充即可,顶多就是再显示个百分比进度,没什么好讲的。本章中将介绍四种方式实现环形进度条。

方法一:刷图

老方法,准备一百张图片,不,是一百零一张;每张图片唯一对应一个进度值,需要显示哪个进度值,刷新显示对应的图片即可。简单,就是不怎么实用。

方法二:画圆环

方法二的思路也是比较简单的。首先,先锁定显示区域的位置和大小,将它的背景保存下来,方便后续回填。其次,从头到尾扫描整个显示区域,判断哪些点落在圆环区域,并刷新显示。因为圆环的边缘是圆弧形的,所以不能用上一章介绍的多边形填充方式填充,只能是通过当前坐标点与进度条圆心的距离以及夹角确定是否要显示该点。具体实现如下所示。

首先,在驱动头文件中,为了方便用户使用和移植,我们可以创建一个结构体,该结构体包含了进度条的基本信息,如下所示。API 函数只有两个,一个是创建函数,另一个是刷新显示函数。

#include "./LCD/LCD.h"

//控制结构体
typedef struct
{
  unsigned int    x, y, rin, rout; //圆心坐标,内环半径和外环半径
  unsigned int    lastPercent;     //上一个进度
#if (0 != LCD_USE_RGB565)
  unsigned short  color;           //颜色
  unsigned short* background;      //背景缓冲区,初始化时由动态内存自动分配
#else
  unsigned int    color;           //颜色
  unsigned int*   background;      //背景缓冲区,初始化时由动态内存自动分配
#endif

}StructCirProgressBar;

//API 函数
void CreateCirProgressBar(StructCirProgressBar* widget);                    //创建环形进度条
void SetCirProgressBar(StructCirProgressBar* widget, unsigned int percent); //更新环形进度条显示

创建函数比较简单,主要工作就是为环形进度条的背景缓冲区申请动态内存,具体如下所示。在这里要注意,因为是 PC 仿真,所以直接用了 C 语言库函数里边的动态内存分配函数,即 malloc 函数。如果在单片机中,有实现了自己的动态内存管理,一般有外拓 RAM 的项目都会这么干,那么就需要替换成自己的动态内存分配函数。分配好动态内存后,需要预先把背景读入缓冲区,方便后续回填。

#include "CirProgressBar.h"
#include "stdio.h"
#include "stdlib.h"
#include "math.h"

void CreateCirProgressBar(StructCirProgressBar* widget)
{
  unsigned int x, y, x0, y0, x1, y1, width, height, i;

  //计算显示区域大小
  x0 = widget->x - widget->rout;
  x1 = widget->x + widget->rout;
  y0 = widget->y - widget->rout;
  y1 = widget->y + widget->rout;
  width = x1 - x0 + 1;
  height = y1 - y0 + 1;

  //申请动态内存
#if (0 != LCD_USE_RGB565)
  widget->background = (unsigned short*)malloc(width * height * 2);
#else
  widget->background = (unsigned int*)malloc(width * height * 4);
#endif
  if (NULL == widget->background)
  {
    printf("CreateCirProgressBar: Fail to malloc for background\r\n");
    while (1) {}
  }

  //保存背景
  i = 0;
  for (y = y0; y <= y1; y++)
  {
    for (x = x0; x <= x1; x++)
    {
      widget->background[i++] = LCDReadPoint(x, y);
    }
  }

  //设置旋转角度为 0
  widget->lastPercent = 100;
  SetCirProgressBar(widget, 0);
}

更新显示进度函数具体实现如下所示。要注意,这里默认规定竖直向上角度为零度,所有的角度值均使用弧度制。与多边形填充类似,此处也是扫描环形进度条所有的点,依次判断是否需要显示。判断的依据有两点,一是该点与环形进度条圆心的距离要在内环半径和外环半径之间,而是该点与圆心形成的直线与垂直方向的夹角要在百分比的范围内。计算夹角时,默认按照第一项象限,即右上角计算,然后再根据该点落在了哪个象限进行角度补偿。

距离和角度均满足要求后,该点才能在屏幕上显示。

//更新进度条显示
//widget:控件控制结构体首地址
//percent:百分比,0-100
void SetCirProgressBar(StructCirProgressBar* widget, unsigned int percent)
{
  const double pi = 3.1415926535;
  unsigned int i;
  double x, y, x0, y0, x1, y1, dx, dy, distance;
  double angle, angle1;

  //限定输入范围
  if (percent > 100)
  {
    percent = 100;
  }

  //角度与记录值相同,直接返回
  if (percent == widget->lastPercent)
  {
    return;
  }

  //锁定屏幕
  LCDLock();

  //统计显示区域大小
  x0 = widget->x - widget->rout;
  x1 = widget->x + widget->rout;
  y0 = widget->y - widget->rout;
  y1 = widget->y + widget->rout;

  //角度比记录值要小,需要重绘背景
  if (percent < widget->lastPercent)
  {
    //背景重绘
    i = 0;
    for (y = y0; y <= y1; y = y + 1)
    {
      for (x = x0; x <= x1; x = x + 1)
      {
        LCDFastDrawPoint(x, y, widget->background[i++]);
      }
    }
  }

  //保存当前进度
  widget->lastPercent = percent;

  //计算角度值(弧度值)
  angle = percent / 100.0;
  angle = angle * 2.0 * pi;

  //遍历所有的点
  for (y = y0; y <= y1; y = y + 1)
  {
    for (x = x0; x <= x1; x = x + 1)
    {
      //计算该点距离圆心的距离
      dx = x - widget->x;
      dy = y - widget->y;
      dy = -dy;
      distance = sqrt(dx * dx + dy * dy);

      //点落在圆环范围内
      if ((distance >= widget->rin) && (distance <= widget->rout))
      {
        //按照第一象限求角度
        angle1 = atan(fabs(dx) / fabs(dy));

        //角度补偿,补偿到 4 个象限
        if ((dx >= 0) && (dy >= 0)) //第一象限
        {
          angle1 = angle1;
        }
        else if ((dx >= 0) && (dy <= 0)) //第二象限
        {
          angle1 = 0.5 * pi + (0.5 * pi - angle1);
        }
        else if ((dx <= 0) && (dy <= 0)) //第三象限
        {
          angle1 = angle1 + pi;
        }
        else //第四象限
        {
          angle1 = angle1 + pi + 2 * (0.5 * pi - angle1);
        }

        //角度也落在这个范围
        if (angle1 <= angle)
        {
          LCDFastDrawPoint(x, y, widget->color);
        }
      }
    }
  }

  //解锁屏幕
  LCDUnlock();
}

测试代码如下所示,每隔 100ms 增加一个百分比显示

#include "CirProgressBar.h"
int main(void)
{
  static StructCirProgressBar s_structCirProgressBar;
  static unsigned int s_iProcessCnt = 0;

  //初始化
  InitLCD();
  InitBMP();

  //创建环形进度条
  s_structCirProgressBar.x = 400;
  s_structCirProgressBar.y = 204;
  s_structCirProgressBar.rin = 80;
  s_structCirProgressBar.rout = 100;
  s_structCirProgressBar.color = LCD_COLOR_GREEN;
  CreateCirProgressBar(&s_structCirProgressBar);
  
  //速度仪表盘显示
  while (1)
  {
    SetCirProgressBar(&s_structCirProgressBar, s_iProcessCnt);
    s_iProcessCnt = (s_iProcessCnt + 1) % 101;
    Sleep(100);
  }
  
  return 0;
}

最终的效果如下所示。读者可以修改 SetCirProgressBar 函数,为圆形进图条增加百分比显示。在这里没有做是因为比较懒,显示百分比还得考虑字体的透明显示,有点麻烦。

方法三:画圆弧

第三个方法则更简单,直接绘制一段圆弧即可。绘制圆弧的代码如下所示,在这里使用了微分的方法,把圆弧细分成很多个小段,每一段都是直线,然后把直线连起来即可。因为使用了三角函数,所以效率会偏低一些。读者可以参考网上,别人的优秀代码。

此处的画线函数可以指定线条宽度,画线函数中的打点函数可以用绘制实心圆代替,这样就可以实现一个简单的、支持宽度的画线函数了。

如果读者觉得绘制的圆弧不够圆,那么可以释放减小角度递增的量,即减小 angle 变量递增的步长,但是这样会使得计算量增加。

//绘制圆弧
//x、y:圆心坐标
//r:半径
//angle0、angle1:起点、终点角度,按顺时针方向
//size:弧线宽度
//color: 弧线颜色
void DrawArc(unsigned int x, unsigned int y, unsigned int r, double angle0, double angle1, unsigned int size, unsigned int color)
{
  double angle, radius;
  unsigned int cirx, ciry, lastX, lastY;

  //记录起点值
  cirx = x;
  ciry = y;

  //记录半径
  radius = r;

  //计算起点
  lastX = cirx + radius * sin(angle0);
  lastY = ciry - radius * cos(angle0);

  //开始绘制
  angle = angle0;
  while (angle <= angle1)
  {
    //计算下一个点的位置
    x = cirx + radius * sin(angle);
    y = ciry - radius * cos(angle);

    //连线
    DrawLine(lastX, lastY, x, y, size, color);

    //保存上一个点的信息
    lastX = x;
    lastY = y;

    //角度递增
    angle = angle + 0.01;
  }

  //确保终点能连起来
  x = cirx + radius * sin(angle1);
  y = ciry - radius * cos(angle1);

  //连线
  DrawLine(lastX, lastY, x, y, size, color);
}

如此一来,环形进度条刷新函数里,只需要计算起点、终点所对应的角度,即可刷新显示,简单方便。

//更新进度条显示
//widget:控件控制结构体首地址
//percent:百分比,0-100
void SetCirProgressBar(StructCirProgressBar* widget, unsigned int percent)
{
  const double pi = 3.1415926535;
  unsigned int x, y, x0, y0, x1, y1, i, size;
  double angle0, angle1;

  //限定输入范围
  if (percent > 100)
  {
    percent = 100;
  }

  //角度与记录值相同,直接返回
  if (percent == widget->lastPercent)
  {
    return;
  }

  //锁定屏幕
  LCDLock();

  //角度比记录值要小,需要重绘背景
  if (percent < widget->lastPercent)
  {
    //计算显示区域大小
    x0 = widget->x - widget->rout;
    x1 = widget->x + widget->rout;
    y0 = widget->y - widget->rout;
    y1 = widget->y + widget->rout;

    //背景重绘
    i = 0;
    for (y = y0; y <= y1; y++)
    {
      for (x = x0; x <= x1; x++)
      {
        LCDFastDrawPoint(x, y, widget->background[i++]);
      }
    }

    //定义上一个角度为 0
    widget->lastPercent = 0;
  }

  //计算起点角度值(弧度制)
  angle0 = widget->lastPercent / 100.0;
  angle0 = angle0 * 2.0 * pi;

  //计算终点角度值(弧度制)
  angle1 = percent / 100.0;
  angle1 = angle1 * 2.0 * pi;

  //计算点直径,正好等于圆环厚度
  size = widget->rout - widget->rin;

  //画弧线
  DrawArc(widget->x, widget->y, (widget->rout + widget->rin) / 2, angle0, angle1, size, widget->color);

  //解锁屏幕
  LCDUnlock();

  //保存当前进度
  widget->lastPercent = percent;
}

最终的效果如下所示。因为画线函数中,画点是通过画实心圆实现的,这就导致了有一些像素点被重复绘制了多次,效率会偏低一些。

方法四:画射线

得益于方法三中绘制圆弧的方法,我们也可以用绘制射线的方式实现环形进度条。具体方法与方法三类似,绘制圆弧时,以圆心为中心,向外绘制射线,由多条射线组成一个完整的圆弧。绘制射线时,只要前后两条射线的角度增量足够小,射线足够密,同样可以达到方法二中的效果。

//更新进度条显示
//widget:控件控制结构体首地址
//percent:百分比,0-100
void SetCirProgressBar(StructCirProgressBar* widget, unsigned int percent)
{
  const double pi = 3.1415926535;
  unsigned int i;
  double x, y, x0, y0, x1, y1;
  double angle, angle0, angle1;

  //限定输入范围
  if (percent > 100)
  {
    percent = 100;
  }

  //角度与记录值相同,直接返回
  if (percent == widget->lastPercent)
  {
    return;
  }

  //锁定屏幕
  LCDLock();

  //统计显示区域大小
  x0 = widget->x - widget->rout;
  x1 = widget->x + widget->rout;
  y0 = widget->y - widget->rout;
  y1 = widget->y + widget->rout;

  //角度比记录值要小,需要重绘背景
  if (percent < widget->lastPercent)
  {
    //背景重绘
    i = 0;
    for (y = y0; y <= y1; y = y + 1)
    {
      for (x = x0; x <= x1; x = x + 1)
      {
        LCDFastDrawPoint(x, y, widget->background[i++]);
      }
    }

    //定义上一个角度为 0
    widget->lastPercent = 0;
  }

  //计算起点角度值(弧度制)
  angle0 = widget->lastPercent / 100.0;
  angle0 = angle0 * 2.0 * pi;

  //计算终点角度值(弧度制)
  angle1 = percent / 100.0;
  angle1 = angle1 * 2.0 * pi;

  //依次扫描所有的角度
  angle = angle0;
  while (angle <= angle1)
  {
    //计算线段的起点
    x0 = widget->x + widget->rin * sin(angle);
    y0 = widget->y - widget->rin * cos(angle);

    //计算线段的终点(因为画线时,使用的画点函数是 3x3 大小,线段的长度可能会多 1 个像素点,所以需要减去)
    x1 = widget->x + widget->rout * sin(angle);
    y1 = widget->y - widget->rout * cos(angle);
    if (x1 > widget->x) { x1 = x1 - 1; } else { x1 = x1 + 1; }
    if (y1 > widget->y) { y1 = y1 - 1; } else { y1 = y1 + 1; }

    //画线
    DrawLine(x0, y0, x1, y1, widget->color);

    //角度递增
    angle = angle + 0.03;
  }

  //解锁屏幕
  LCDUnlock();

  //保存当前进度
  widget->lastPercent = percent;
}

画线时,不能直接使用 LCD 模块的画线函数,否则圆环上会不可避免的出现缺口,需要特殊的画线函数,具体如下。这个画线函数与 LCD 模块中的画线函数类似,只不过画点函数变成了一次性绘制九个点,这样就可以有效避免缺口的出现。

//画直线
//x0、y0:起点坐标
//x1、y1:终点坐标
//color:线条颜色
static void DrawLine(unsigned int x0, unsigned int y0, unsigned int x1, unsigned int y1, unsigned int color)
{
  int x, y, dx, dy, dx2, dy2, xStep, yStep, swap, sum;

  //计算x、y方向增量
  dx = x1 - x0;
  dy = y1 - y0;
  if (dx < 0) { dx = -dx; }
  if (dy < 0) { dy = -dy; }
  dx2 = dx << 1;
  dy2 = dy << 1;

  //斜率小于等于1,以横坐标增长方向计算
  if (dx >= dy)
  {
    //修正绘制方向(按增长方向绘制)
    if (x0 > x1)
    {
      swap = x0; x0 = x1; x1 = swap;
      swap = y0; y0 = y1; y1 = swap;
    }

    //判断y增长方向
    if (y1 > y0) { yStep = 1; }
    else { yStep = -1; }

    x = x0;
    y = y0;
    sum = -dx;
    while (x <= x1)
    {
      //画点(3x3)
      LCDFastDrawPoint(x - 1, y - 1, color);
      LCDFastDrawPoint(x + 0, y - 1, color);
      LCDFastDrawPoint(x + 1, y - 1, color);
      LCDFastDrawPoint(x - 1, y + 0, color);
      LCDFastDrawPoint(x + 0, y + 0, color);
      LCDFastDrawPoint(x + 1, y + 0, color);
      LCDFastDrawPoint(x - 1, y + 1, color);
      LCDFastDrawPoint(x + 0, y + 1, color);
      LCDFastDrawPoint(x + 1, y + 1, color);

      //横坐标自增
      x = x + 1;

      //纵坐标判别式
      sum = sum + dy2;

      //纵坐标自增
      if (sum >= 0)
      {
        sum = sum - dx2;
        y = y + yStep;
      }
    }
  }

  //斜率大于1,以纵坐标增长方向计算
  else
  {
    //修正绘制方向(按增长方向绘制)
    if (y0 > y1)
    {
      swap = x0; x0 = x1; x1 = swap;
      swap = y0; y0 = y1; y1 = swap;
    }

    //判断x增长方向
    if (x1 > x0) { xStep = 1; }
    else { xStep = -1; }

    x = x0;
    y = y0;
    sum = -dy;
    while (y <= y1)
    {
      //画点(3x3)
      LCDFastDrawPoint(x - 1, y - 1, color);
      LCDFastDrawPoint(x + 0, y - 1, color);
      LCDFastDrawPoint(x + 1, y - 1, color);
      LCDFastDrawPoint(x - 1, y + 0, color);
      LCDFastDrawPoint(x + 0, y + 0, color);
      LCDFastDrawPoint(x + 1, y + 0, color);
      LCDFastDrawPoint(x - 1, y + 1, color);
      LCDFastDrawPoint(x + 0, y + 1, color);
      LCDFastDrawPoint(x + 1, y + 1, color);

      //纵坐标自增
      y = y + 1;

      //横坐标判别式
      sum = sum + dx2;

      //横坐标自增
      if (sum >= 0)
      {
        sum = sum - dy2;
        x = x + xStep;
      }
    }
  }
}

最终实现的效果如下所示。这个圆环有点粗糙,没有方法二中的效果好,不过胜在效率略高,不用扫描所有的像素点。

如果修改 SetCirProgressBar 函数中角度的增量,每次增加的弧度为 0.1,如下所示。

    //角度递增
    angle = angle + 0.1;

这时候会出现意想不到的效果,如下所示。

源码

本章节中的源码请参考《单片机 GUI 设计(零)- 大纲