C++
Qt
Rx
RxCpp
QtDay 2

シングル/ダブルクリック判定

Qt Advent Calendar 2017 2日目です。

はじめに

Qt Widgetsでクリックあるいはダブルクリックのハンドリングをするには、普通はclickedシグナルやMouseButtonDblClickといったイベントを使うと思います。しかし、世の中にはシングルクリックとダブルクリックで異なる動作を排他的にしたい局面というものがあります。

例えば、Windowsのエクスプローラーは次のように振る舞います1

シングルクリック ダブルクリック
選択 名前の変更 開く
非選択 選択 開く

これをやろうとすると、既存のシグナル/イベントでは足りません。ダブルクリックしたときはclickedのemitに続いてMouseButtonDblClickイベントが起きるためです。

シングル/ダブルクリック判定

シンプルな方法

まず考えられるのは、連続でemitされたclickedシグナルを数えることです。

  • クリックしたら連続クリック数を1増やしてタイマーリセット
  • タイムアウトしたら連続クリック数が確定
#include <QApplication>
#include <QWidget>
#include <QVBoxLayout>
#include <QPushButton>
#include <QLabel>
#include <QTimer>

int main(int argc, char *argv[])
{
    QApplication app(argc, argv);

    QWidget widget;
    auto layout = new QVBoxLayout;
    widget.setLayout(layout);

    auto label = new QLabel;

    auto button = new QPushButton("Click me");

    auto timer = new QTimer;
    timer->setParent(&widget);
    timer->setSingleShot(true);

    layout->addWidget(button);
    layout->addWidget(label);

    int count = 0;

    QObject::connect(button, &QPushButton::clicked, [&](){
        if(count == 0) label->setText("");
        count++;
        timer->start(QApplication::doubleClickInterval());
    });

    QObject::connect(timer, &QTimer::timeout, [&](){
        label->setText(QString("%1連続クリック").arg(count));
        count = 0;
    });

    widget.show();
    return app.exec();
}

この方法はn連続クリックを判定できますが、クリックしてから回数が確定するまで必ず待ち時間が発生するという問題があります。

普通はダブルクリックまでで十分なので、次のようになるでしょう。

  • クリックしたら連続クリック数を1増やす
    • 連続クリック数が2なら確定
    • 違えばタイマーリセット
  • タイムアウトしたら連続クリック数が確定
    QObject::connect(button, &QPushButton::clicked, [&](){
        if(count == 0) label->setText("");
        count++;
        timer->stop();
        if(count == 2)
        {
            label->setText("ダブルクリック");
            count = 0;
            return;
        }
        timer->start(QApplication::doubleClickInterval());
    });

    QObject::connect(timer, &QTimer::timeout, [&](){
        label->setText("シングルクリック");
        count = 0;
    });

このようにするとレスポンスが良くなります。

厳密な方法

シンプルな方法では、ダブルクリックが確定するのはボタンを離したときです。

しかし、MouseButtonDblClickイベントの説明に"Mouse press again"とある通り、このイベントは実は2度目にボタンを押した瞬間に起きます。ボタンを離す前に確定するので僅かですがレスポンスが良く見えます。

次のようにclickedの他にpressedを併用して実装します。

    QObject::connect(button, &QPushButton::clicked, [&](){
        if(count == 1)
        {
            timer->start(QApplication::doubleClickInterval());
        }
    });

    QObject::connect(button, &QPushButton::pressed, [&](){
        if(count == 0) label->setText("");
        count++;
        timer->stop();

        if(count == 2)
        {
            label->setText("ダブルクリック");
            count = 0;
        }
    });

    QObject::connect(timer, &QTimer::timeout, [&](){
        label->setText("シングルクリック");
        count = 0;
    });

さらに厳密な方法

待ち時間は厳密にはボタンを離してから次に押すまでではなく、最初に押してから次に押すまでです。例えば、1回目のクリックを非常にゆっくりと行った場合、2回目のクリックを素早く行ってもシングルクリック2回と判定されます。

(注: プラットフォーム依存かもしれない。少なくともWindowsのExplorerはこのような動作をする)

また、厳密に言えば、PushButtonのpressed/releasedシグナルは同名のマウスイベントとは若干異なるタイミングでemitされるので2、マウスイベントでハンドリングする必要があります。

MouseReleaseイベントはボタンの外でも発生するので、範囲外で発生した場合はクリックではないことに注意して、状態遷移表で書くと次のようになります。

状態 Press Release(中) Release(外) Timer
S0 タイマー開始/
S1
- - -
S1 - S3 S0 S2
S2 - シングル確定/
S0
S0 -
S3 ダブル確定/
S0
- - シングル確定/
S0

S0は開始状態、S1はボタンを押している状態、S2は押したままタイムアウトした状態、S3はボタンを離した状態です。

これをイベントフィルターで実装すると次のようになります。

class DoubleClickEventFilter : public QObject {
    Q_OBJECT
public:
    explicit DoubleClickEventFilter(QWidget* target)
        :target(target)
        ,timer(new QTimer(this))
    {
        timer->setSingleShot(true);
        timer->installEventFilter(this);

        setParent(target);
    }

    bool eventFilter(QObject* watched, QEvent* event) override
    {
        if(event->type() == QEvent::MouseButtonPress || event->type() == QEvent::MouseButtonDblClick)
        {
            QMouseEvent* mouseEvent = static_cast<QMouseEvent*>(event);
            if(mouseEvent->button() == Qt::LeftButton)
            {
                if(state == 0)
                {
                    setState(1);
                    timer->start(QApplication::doubleClickInterval());
                }
                else if(state == 3)
                {
                    emit doubleClicked();
                    setState(0);
                }
            }
        }
        else if(event->type() == QEvent::MouseButtonRelease)
        {
            QMouseEvent* mouseEvent = static_cast<QMouseEvent*>(event);
            if(mouseEvent->button() == Qt::LeftButton)
            {
                if(target->rect().contains(mouseEvent->pos()))
                {
                    if(state == 1)
                    {
                        setState(3);
                    }
                    else if(state == 2)
                    {
                        emit singleClicked();
                        setState(0);
                    }
                }
                else
                {
                    setState(0);
                }
            }
        }
        else if(event->type() == QEvent::Timer)
        {
            if(state == 1)
            {
                setState(2);
            }
            else if(state == 3)
            {
                setState(0);
                emit singleClicked();
            }
        }
        return QObject::eventFilter(watched, event);
    }

signals:
    void singleClicked();
    void doubleClicked();

private:
    void setState(int s)
    {
        state = s;
    }

    int state = 0;
    QTimer* timer = nullptr;
    QWidget* target = nullptr;
};

Rxを用いる方法

RxCppRxQtを用いると、QtでRx(Reactive Extensions)が使えるようになります。

Rxは時間に関するオペレーターを豊富に備えています。
クリックの連打判定には、一定時間イベントが起きなくなるまで待つdebounceが使えます。

n連続クリックの判定をRxで実装すると次のようになります。

#include <QApplication>
#include <QWidget>
#include <QVBoxLayout>
#include <QPushButton>
#include <QLabel>
#include <rxqt.hpp>

using namespace std::chrono;

int main(int argc, char *argv[])
{
    QApplication app(argc, argv);

    QWidget widget;
    auto layout = new QVBoxLayout;
    widget.setLayout(layout);

    auto label = new QLabel;

    auto button = new QPushButton("Click me");

    layout->addWidget(button);
    layout->addWidget(label);

    int count = 0;

    rxqt::from_signal(button, &QPushButton::clicked)
        .map([&](const auto&){ return count += 1; })
        .debounce(milliseconds(QApplication::doubleClickInterval()))
        .tap([&](int){ count = 0; })
        .subscribe([&](int x){ label->setText(QString("%1連続クリック").arg(x)); });

    widget.show();
    return app.exec();
}

最後に

本稿では簡単のためシグナルを使いましたが、このような特別な処理を実装するウィジェットはボタン系ではないかもしれません。
clickedやpressedなどのシグナルがないウィジェットでは、最後の例のようにマウスイベントでハンドリングします。


  1. 正確には、選択はマウスプレスで起きるのでシングルクリックではありません。 

  2. 具体的には、シグナルの方はマウスボタンを押したままボタンを出入りするだけでもemitされます。イベントの方はボタンを離すまで次のイベントが発生しません。