Qt-时间轴1插件开发

通过前面一节提到的 Qt 自定义插件的开发,开发出一个类似于统计一天不同时间段的插件,这种插件在摄像头的计划中能很频繁看到,例如0点到9点做录像任务, 9点到下午5点做行为检测任务, 晚上10点到12点继续做录像计划这种场景。而这篇文章主要记录一下自己开发过程中的一些思路和简单的代码解释,以及目前还存在问题。

思路逻辑

明确需求

  1. 一天24小时,精确范围到分钟的时间轴
  2. 支持在时间轴上通过鼠标点击、移动和释放生成时间段,或者支持输入起始时间生成时间段
  3. 对已有的时间段在时间轴上移动,不同时间段不能重合
  4. 对已有时间段通过拖拽,拉伸或者编辑改变其时间范围
  5. 删除已有时间段
  6. 提供时间段统计输出的接口

简单的逻辑

时间精度问题

时间轴上时间段显示的范围是00:00-23:59,以录像为例,00:00-23:59时间段代表全天录像,录像时间从开始时间到结束时间向后1分钟。 不录象,不用创建时间段。
接下来是时间精度的问题,一天1440(60*24)分钟,精确到分钟, 在屏幕上展示的时候, 如果1个像素代表1分钟,至少需要1440个像素,可是实际上一般不会存在1440px宽度的插件,在开发过程中,我会选择720宽度。

时间轴绘图

Qt 中的 paintEvent(QPaintEvent *) 支持画图,在此函数中,绘制时间轴刻度

时间轴

widget_graduation 是容纳时间轴刻度的 QWidget,在它上面直接画刻度
widget_timeaxis 是容纳时间段的 QWdiget, 仅仅设置一个渐变色的样式, 和widget_graduation布局为垂直布局

ui->widget_timeaxis->setStyleSheet("background-color:qlineargradient(x1:0,y1:0,x2:0,y2:1,stop:0 rgb(120,120,120),stop:1 rgb(140,140,140));")
int start_position = ui->widget_graduation->pos().x();
int height = ui->widget_graduation->pos().y() + ui->widget_graduation->height()-1;
int width = ui->widget_graduation->width();

QPainter painter(this);
QPen hor_pen(Qt::black, 2);
painter.setPen(hor_pen);
// 首先绘制一条水平线
painter.drawLine(start_position,height,width+start_position,height);

QPen ver_pen(Qt::black, 1);
painter.setPen(ver_pen);
QFont font("宋体", 8);
painter.setFont(font);

// 绘制刻度
int scale = ui->widget_graduation->width() / 24;
for (int i = 0; i<=24 ; ++i ) {
    int x = i * scale;
    painter.drawLine(x+start_position, height - 5, x+start_position , height);

    int remainder = i%2 ;
    if (remainder == 0)
        painter.drawText(x+start_position-3,height - 8, QString::number(i));
}

时间段的对象选择

时间段需要支持事件的捕捉,所以我选择用 QWidget,不同类型的事件,比如之前举例录像任务,行为检测任务等等,可以通过给 QWidget 设置不同的样式进行区分,每一个 QWidget 在移动和创建的过程中需要统计它能使用的最大和最小范围,避免出现和其他时间段重合的问题。

Qt 其实就是事件驱动的框架, 它提供了很多事件的接口, 可以重新实现这些事件接口,来实现创建、移动、拉伸时间段的功能

常见的事件接口有以下几个:

// 鼠标移动事件
void mouseMoveEvent(QMouseEvent *e);

// 鼠标点击事件
// 其中通过e->type()判断是左击还是右击事件或者其他
// 因为需要有删除时间段的操作,所以我的设计是左击为正常操作, 右击是删除操作  
void mousePressEvent(QMouseEvent *e);   

// 鼠标释放事件    
void mouseReleaseEvent(QMouseEvent *e); 

其实真正开发时候我并没有用这几个接口,我是直接使用了 eventFilter接口

bool eventFilter(QObject *target, QEvent *event);

主要是因为,我在插件类中需要监听其中的 QWidget(时间段)的事件,并对 QWidget做一些自己业务上的处理。

代码逻辑

创建新的时间段

创建

创建时间段的鼠标操作过程分为3步

  • 鼠标点击到一个位置,创建一个新的时间段
  • 鼠标移动, 时间段跟随鼠标变化
  • 鼠标释放时,确定时间段的时间范围

其中有几个需要重视的点

  • 点击一个位置后,需要计算创建的新的时间段的时间范围,需要存储一些事件段的基本信息,比如这个时间段的起始位置,以及能取到的最大和最小的范围
  • 创建的过程鼠标可以向左或者向右移动,最后生成的大小, 需要根据最后的鼠标的位置算出 QWidget的长度。然后将 QWidget 移动到指定的位置
    • 鼠标落地在点击位置的右边,此时 QWidget的长度是 鼠标落点位置-起始位置,QWidget移动到的位置为 起始位置
    • 鼠标落点在点击位置的左边,此时 QWidget的长度是 起始位置-鼠标落点位置,QWidget移动到的位置为 鼠标落点位置

上面提到 widget_timeaxis 是容纳时间段的 QWdiget的,widget_graduation 是容纳时间轴刻度的
直接粘贴一下代码了,代码中有详细的描述

// 存储了QWidget的一些基本信息
// 当前起始位置,时间段的宽度(基本没用),能移动的最小最大值,QWidget的颜色
struct WidgetInfo
{
    int currentposition;
    int width;
    int minlength;
    int maxlength;
    QString color;
};

bool TimelinePlugin::eventFilter(QObject *target, QEvent *event)
{
    // 鼠标停留位置是上方绘制的时间条时, 返回
    QWidget *object = dynamic_cast<QWidget *>(target);
    if (object == ui->widget_graduation)
        return false;

    // 鼠标停留位置是时间轴空白位置, 需要生成新的 timecell
    if (object == ui->widget_timeaxis) {
        // 鼠标点击时, 记录timecell的开始位置, 准备生成新的timecell
        if (event->type() == QMouseEvent::MouseButtonPress) {
            QMouseEvent *mouse_press = static_cast<QMouseEvent *>(event);
            if (mouse_press->button() == Qt::LeftButton) {
                // m_pTimeSelector 这个是显示时间段的时间的一个UI,在创建过程中暂时隐藏了
                m_pTimeSelector->hide();
                // 时间段存在上限
                if (m_listTimeCell.count() >= TIMECELL_ACCOUNT) {
                    m_pCurrentWidget = nullptr;
                    return true;
                }
                // m_widgetStartposition 记录点击的其实位置
                m_widgetStartposition = mouse_press->pos().x();
                WidgetInfo info;
                // getCanMoveRange 函数是计算当前位置下,创建时间段的有效的最大最小值,直接写入WidgetInfo中
                getCanMoveRange(NULL, m_widgetStartposition, info);
                QWidget *new_timecell = new QWidget(ui->widget_timeaxis);
                new_timecell->resize(0, this->height());
                new_timecell->show();
                QString background = QString("background-color:") +m_createWidgetColor;
                new_timecell->setStyleSheet(background);
                new_timecell->installEventFilter(this);
                new_timecell->setMouseTracking(true);

                info.color = m_createWidgetColor;
                // 点击之后, 需要用 m_pCurrentWidget 记录当前生成的时间段
                m_pCurrentWidget = new_timecell;
                // m_listTimeCell 记录所有的时间段
                m_listTimeCell.append(new_timecell);
                // m_mapWidgetInfos 记录时间段和它对应信息
                m_mapWidgetInfos[new_timecell] = info;
                // 这里其实有点重复,m_listTimeCell 和 m_mapWidgetInfos都记录了时间段
            }
        }
        // 鼠标移动过程中, 需要改变m_pCurrentWidget的大小以及位置
        if (event->type() == QMouseEvent::MouseMove) {
            QMouseEvent * mousemove = static_cast<QMouseEvent *> (event);
            int widget_end_postion = mousemove->pos().x();

            if (m_pCurrentWidget != nullptr) {
                WidgetInfo info = m_mapWidgetInfos.value(m_pCurrentWidget);
                // 鼠标移动过程中的位置, 需要在新生成时间段的有效范围内
                if (info.maxlength >= widget_end_postion && info.minlength <= widget_end_postion) {
                    int width =  widget_end_postion - m_widgetStartposition;
                    // 由于鼠标相对于其实位置, 可能向左或者向右移动, width大小正负都有可能
                    m_pCurrentWidget->resize( qAbs(width), this->height());
                    // timecell 的位置需要重新设置, 计算一下新的起始位置
                    int new_startpostion = width>0?m_widgetStartposition:widget_end_postion;

                    info.currentposition = new_startpostion;
                    m_mapWidgetInfos[m_pCurrentWidget] = info;
                    m_pCurrentWidget->move(new_startpostion, 0);
                    m_pCurrentWidget->show();
                }
            }
        }
        // 鼠标释放时, 代表新的timecell生成完毕
        if (event->type() == QMouseEvent::MouseButtonRelease ) {
            QMouseEvent * mouse_release = static_cast<QMouseEvent *> (event);
            if (mouse_release->button() == Qt::LeftButton) {
                if (m_pCurrentWidget) {
                    WidgetInfo info = m_mapWidgetInfos.value(m_pCurrentWidget);
                    info.currentposition = m_pCurrentWidget->pos().x();
                    info.width = m_pCurrentWidget->width();
                    m_mapWidgetInfos[m_pCurrentWidget] = info;
                }

                // 如果m_pCurrentWidget的宽度过小, 认为生成的timecell无效, 删除new的widget, 清除记录
                if (m_pCurrentWidget && m_pCurrentWidget->width() <= MIN_DISTANCE) {
                    delete  m_pCurrentWidget;
                    m_listTimeCell.removeAll(m_pCurrentWidget);
                    m_mapWidgetInfos.remove(m_pCurrentWidget);
                }
                m_pCurrentWidget = nullptr;
            }
        }
    }
}

移动时间段

移动

移动时间段时,鼠标的操作过程

  • 鼠标点击一个时间段, 此时需要记录一个鼠标点击的位置全局位置
  • 鼠标移动, 时间段跟随鼠标一起移动
  • 鼠标释放, 结束移动

移动时间段,需要注意的几个细节

  • 记录鼠标点击的全局位置,移动过程中的全局位置,那么时间段移动的位置计算是 移动过程中全局位置-鼠标点击时的全局位置+时间段的初始位置(这个信息记录在对应的时间段的WidgetInfo)
  • 鼠标在移动过程中需要改变鼠标的形状
    • 在时间段最左测一定像素或者最右边的一定像素,鼠标形状改成 拖拽拉伸 Qt::SizeHorCursor 的样式
    • 未点击状态时,鼠标移动到中间位置时, 鼠标形状为 手 Qt::OpenHandCursor 的形状
    • 点击状态(按住)时, 鼠标形状变成 抓住 Qt::ClosedHandCursor 的形状

改变时间范围

改变timecell

改变时间段时,鼠标的操作过程

  • 鼠标移动到时间段, 最左侧或者最右侧一定像素的时候,鼠标变成 拖拽拉伸 Qt::SizeHorCursor 的形状,此时点击按住
  • 鼠标移动, 时间段跟随鼠标位置改变大小
  • 鼠标释放, 事件结束

改变事件范围,需要注意的几个细节

  • 对于鼠标最左侧和最右侧,需要区分一下
    • 鼠标在时间段左侧点击时,鼠标移动的范围是:时间段最左侧可移动的最小值和时间段右边位置(时间段原来起点+宽度), 此时时间段移动的位置是: 鼠标的位置; 时间段的长度是:时间段右边的位置-鼠标的位置
    • 鼠标在时间段右边点击时,鼠标移动的范围时:时间段原来的起点和时间段最右侧可移动的最大值, 此时时间段移动的位置是: 时间段原来起点; 时间段的长度是:鼠标的位置-时间段原来起点
  • 此时改变时间范围的鼠标移动需要和移动时间段的鼠标移动进行区分

这一部分和移动时间段的代码比较多,这里不粘贴了,想了解的可以去github上直接看工程,链接在blog最后

手动添加和删除

添加和删除

删除操作,比较简单,之前自己定的实现时, 鼠标右键时删除, 这个很容易实现

手动添加的意思这里是, 提供一个写入时间段的接口

struct TimecellDetail
{
    QTime start_time;
    QTime end_time;
    QString color;
};

void setTimeCell(const TimecellDetail &detail);
QList<TimecellDetail> getTimeCell();

不过这里需要做一些判断

  • 第一步判断输入的开始时间和结束时间转换成距离是否合法
  • 第二部判断输入的时间是否和其他已有的时间段有冲突
  • 都合理之后直接创建新的时间段

其实上面的所有操作都需要在 时间段 可移动的范围内移动或者拉伸

尚未解决的问题与扩展

尚未解决的问题

  • 目前在通过起始位置和时间段宽度计算时,存在一些小问题,正常计算的时候会算出24:00, 然而QTimeEdit插件的范围是 00:00-23:59, 目前的解决方案是当算出是 24:00时, 直接写成23:59展示
  • 关于跟随时间轴一起移动的下方显示数据的小UI也存在问题,目前此UI的父对象不是插件,是初始化的时候插件的父对象传入的,当时间段滑动到最右侧时,此UI会远远超出时间轴的位置,需要插件父对象预留一些位置,防止被遮盖。 如图
    timcell_error1.png timcell_error2.png
  • 鼠标快速移动的时候,有的时候时间段跟不上鼠标一起移动或者拉伸,这个目前没有想到好的解决办法

可扩展

  • Demo中只实现了一天的时间轴, 可以重新封装一下, 变成一周的时间轴插件,在此基础上还可以有一些其他比较实用的开发
    • 一键删除所有时间段
    • 复制其中一天的配置到其他天,根据已有的接口已经和容易实现
  • WidgetInfoTimecellDetail 结构体中 QString color 可以根据实际情况做一个映射表,比如红色是报警, 黄色是警告之类的, 时间段中也可以存其他的信息,用作一些额外新的记录
  • 目前对时间段编辑只有开始时间和结束时间,也可以添加一些额外信息的编辑,比如这里的颜色信息

代码地址

虽然我知道根本没人看, 但是还是求 Star

github 地址 :https://github.com/catcheroftime/TimelinePlugin