Personal Website

My Web: MindEchoes.com

Friday, June 8, 2012

Haciendo un Navegador Web Desktop con Scroll Kinetico

Este es un post que tenia pendiente hace rato, la idea es mostrar como en una aplicación desktop podemos tener un Navegador Web donde podamos scrollear apretando con el mouse y moviendo el puntero en alguna dirección, de la misma forma que hacemos tradicionalmente con el dedo en los navegadores de los celulares, y tener ese efecto de Scroll Kinetico, donde el scroll de la página parece tener cierta inercia.

DISCLAIMER: No critiquen mi código en C++ :P, no programo seguido en este lenguaje.

Vamos a empezar de lo general a lo particular.
Primero paso, el punto de entrada de nuestro programa:


#include <QtGui/QApplication>
#include "mainwindow.h"

int main(int argc, char *argv[])
{
    QApplication a(argc, argv);
    MainWindow w;
    w.show();

    return a.exec();
}


Ahora vamos a diseñar la ventana principal, que basicamente va a consistir en un layout con un Widget para renderizar la página web, y una barra de progreso para la carga de la página (podriamos hacerlo más completo, con la barra de direcciones, etc, pero no es la idea de este post, para eso ver: http://www.diegosarmentero.com/2010/09/navegador-web-en-menos-de-100-lineas-de.html)

"mainwindow.h"


#ifndef MAINWINDOW_H
#define MAINWINDOW_H

#include <QtGui>
#include <QtCore>
#include <QtWebKit>
#include "grabdragscroll.h"

class QWidget;
class QVBoxLayout;
class QWebView;
class QProgressBar;
class GrabDragScroll;

class MainWindow : public QWidget
{
    Q_OBJECT

public:
    explicit MainWindow(QWidget *parent = 0);

private:
    QWebView* web;
    QVBoxLayout* v_box;
    QProgressBar* progress;
    GrabDragScroll* grab;
};

#endif // MAINWINDOW_H


Y el código de la clase:


#include "mainwindow.h"

MainWindow::MainWindow(QWidget *parent) :
    QWidget(parent)
{
    v_box = new QVBoxLayout(this);

    this->web = new QWebView(this);
    this->web->load(QUrl("http://diegosarmentero.com.ar"));

    this->grab = new GrabDragScroll(this);
    this->grab->installWidget(this->web, true);
    this->progress = new QProgressBar(this);
    this->v_box->addWidget(this->web);
    this->v_box->addWidget(this->progress);

    this->connect(this->web, SIGNAL(loadProgress(int)), 
                  this->progress, SLOT(setValue(int)));
    this->connect(this->web, SIGNAL(loadFinished(bool)), 
                  this->progress, SLOT(hide()));
    this->connect(this->web, SIGNAL(loadStarted()), 
                  this->progress, SLOT(show()));
}


Como podemos ver en la linea:

    this->grab->installWidget(this->web, true);

Se esta llamando a la función "installWidget" de GrabDragScroll, esta función recibe 2 atributos, el primero es el widget web sobre el cual se va a instalar el Scroll Kinetico, y el segundo argumento es para decirle a GrabDragScroll si queremos que escondas las barras de desplazamiento del Widget Web para que la única opción de scroll sea el mouse.

Lo que va a ser nuestra clase GrabDragScroll es instalar un eventFilter sobre el Widget Web, para que todos los eventos de ese Widget pasen primero por esta clase, y podamos tomar aquellos eventos que son de mouse para hacer el efecto de Scroll kinetico, y dejar pasar el resto de los eventos para que sigan su comportamiento normal.

La siguiente parte no la voy a explicar en detalle :P porque es medio vueltero de explicar y es más fácil entender el código, básicamente lo que hace es fijarse en que estado me encuentro:

STATE { STEADY, PRESSED, MANUAL_SCROLL, AUTO_SCROLL, STOP };

Y en base al estado y el tipo de evento que se produjo con el mouse pasa a un estado u otro y hace los calculos que sean necesarios del offset del mouse para poder animar el scroll.

Se puede ver más abajo que tenemos un método "timerEvent" que se utiliza para hacer la animación de ir desacelerando el movimiento de la página, y por último tenemos los métodos "scrollOffset" y "setScrollOffset" que ejecutan funciones javascript sobre el widget web para obtener la posición en la que se encuentra el scroll o setear una posición del scroll. El efecto de scroll kinetico basicamente consiste en ir seteando distintas posiciones del scroll e ir ampliando/disminuyendo la diferencia entre asignaciones dependiendo del impulso que se le da con el cursor (el cual consiste en calcular la posición cuando se estaba en estado STEADY y la distancia de la posición final del mouse al hacer release del click):


#include "grabdragscroll.h"

struct ScrollData
{
    enum STATE { STEADY, PRESSED, MANUAL_SCROLL, AUTO_SCROLL, 
                 STOP };
    int state;
    QWebView *widget;
    QPoint pressPos;
    QPoint offset;
    QPoint dragPos;
    QPoint speed;
};

GrabDragScroll::GrabDragScroll(QObject *parent) :
    QObject(parent)
{
    this->data = new ScrollData;
    this->data->state = ScrollData::STEADY;
    this->data->pressPos = QPoint(0, 0);
    this->data->offset = QPoint(0, 0);
    this->data->dragPos = QPoint(0, 0);
    this->data->speed = QPoint(0, 0);

    this->timer = new QBasicTimer;
}

void GrabDragScroll::installWidget(QWebView* widget, 
                                   bool withoutBars)
{
    if(withoutBars){
        QWebFrame* frame = widget->page()->mainFrame();
        frame->setScrollBarPolicy(
                    Qt::Vertical, Qt::ScrollBarAlwaysOff);
        frame->setScrollBarPolicy(
                    Qt::Horizontal, Qt::ScrollBarAlwaysOff);
    }
    widget->installEventFilter(this);
    data->widget = widget;
}

bool GrabDragScroll::eventFilter(QObject *obj, QEvent *event)
{
    if(!(obj->isWidgetType())){
        return false;
    }

    QEvent::Type eventType = event->type();
    if(eventType != QEvent::MouseButtonPress &&
       eventType != QEvent::MouseButtonRelease &&
       eventType != QEvent::MouseMove){
        return false;
    }

    QMouseEvent* mouseEvent = dynamic_cast<QMouseEvent*>(
                                            event);
    if(this->data->pressPos.x() == mouseEvent->pos().x() &&
       this->data->pressPos.y() == mouseEvent->pos().y()){
        this->data->state = ScrollData::STEADY;
        return false;
    }
    bool consumed = false;

    if(this->data->state == ScrollData::STEADY &&
       eventType == QEvent::MouseButtonPress &&
       mouseEvent->buttons() == Qt::LeftButton){
            consumed = false;
            data->state = ScrollData::PRESSED;
            data->pressPos = QPoint(mouseEvent->pos());
            data->offset = this->scrollOffset(data->widget);

    }else if(data->state == ScrollData::PRESSED){
        if(eventType == QEvent::MouseButtonRelease){
            consumed = true;
            data->state = ScrollData::STEADY;
            QMouseEvent* event1 = new QMouseEvent(
                        QEvent::MouseButtonPress,
                        data->pressPos, Qt::LeftButton,
                        Qt::LeftButton, Qt::NoModifier);
            QMouseEvent* event2 = new QMouseEvent(*event1);

        }else if(eventType == QEvent::MouseMove){
            consumed = true;
            data->state = ScrollData::MANUAL_SCROLL;
            data->dragPos = QCursor::pos();
            if(!this->timer->isActive()){
                this->timer->start(20, this);
            }
        }

    }else if(data->state == ScrollData::MANUAL_SCROLL){
        if(eventType == QEvent::MouseMove){
            consumed = true;
            const QPoint pos = mouseEvent->pos();
            const QPoint delta = pos - data->pressPos;
            this->setScrollOffset(this->data->widget,
                                  this->data->offset - delta);

        }else if(eventType == QEvent::MouseButtonRelease){
            consumed = true;
            data->state = ScrollData::AUTO_SCROLL;
        }

    }else if(data->state == ScrollData::AUTO_SCROLL){
        if(eventType == QEvent::MouseButtonPress){
            consumed = true;
            data->offset = this->scrollOffset(data->widget);
            data->state = ScrollData::STOP;
            data->speed = QPoint(0, 0);
        }else if(eventType == QEvent::MouseButtonRelease){
            consumed = true;
            data->state = ScrollData::STEADY;
            data->speed = QPoint(0, 0);
        }

    }else if(data->state == ScrollData::STOP){
        if(eventType == QEvent::MouseButtonRelease){
            consumed = true;
            data->state = ScrollData::STEADY;
        }else if(eventType == QEvent::MouseMove){
            consumed = false;
            data->state = ScrollData::MANUAL_SCROLL;
            const QPoint pos = mouseEvent->pos();
            this->setScrollOffset(this->data->widget, 
                                  this->data->offset);
        }
    }

    return consumed;
}

void GrabDragScroll::timerEvent(QTimerEvent *event)
{
    if(data->state == ScrollData::MANUAL_SCROLL){
        QPoint cursorPos = QCursor::pos();
        data->speed = cursorPos - data->dragPos;
        data->dragPos = cursorPos;
    }else if(data->state == ScrollData::AUTO_SCROLL){
        data->speed = this->deaccelerate(this->data->speed);
        QPoint p = this->scrollOffset(data->widget);
        this->setScrollOffset(
                    this->data->widget, p - data->speed);
        if(data->speed == QPoint(0, 0)){
            data->state = ScrollData::STEADY;
        }
    }

    QObject::timerEvent(event);
}

QPoint GrabDragScroll::scrollOffset(
        const QWebView* view) const {
    int x, y = 0;
    QWebFrame* frame = view->page()->mainFrame();
    x = frame->evaluateJavaScript("window.scrollX").toInt();
    y = frame->evaluateJavaScript("window.scrollY").toInt();

    return QPoint(x, y);
}

void GrabDragScroll::setScrollOffset(QWebView* view, 
                                     const QPoint& p)
{
    QWebFrame* frame = view->page()->mainFrame();
    frame->evaluateJavaScript(
     QString("window.scrollTo(%1,%2);").arg(p.x()).arg(p.y()));
}

QPoint GrabDragScroll::deaccelerate(const QPoint &speed, 
                                    const int a, 
                                    const int maxVal)
{
    int x = qBound<int>(-maxVal, speed.x(), maxVal);
    int y = qBound<int>(-maxVal, speed.y(), maxVal);
    if(x > 0){
        x = qMax<int>(0, x - a);
    }else if(x < 0){
        x = qMin<int>(0, x + a);
    }
    if(y > 0){
        y = qMax(0, y - a);
    }else if(y < 0){
        y = qMin(0, y + a);
    }

    return QPoint(x, y);
}


Un pequeño problema que tuve la primera vez que lo hice, fue que como estamos capturando los eventos de mouse, no se podia navegar porque los clicks nunca llegaban a los links, y un navegador que no te deja navegar no tenia mucho chiste, así que como se puede ver al principio del método "eventFilter", agregue que si la posición del mouse es igual a la posición anterior y se estaba en un estado de STEADY, se deja pasar el evento hacia arriba para que lo agarre el widget web.

El código completo de todo el ejemplo puede encontrarse en:
https://github.com/diegosarmentero/Experiments/tree/master/KineticScroll

Para ejecutarlo, deberia ser tan simple como abrir el proyecto con Qt Creator y compilarlo y ejecutarlo desde ahí (y sino siempre esta la consola y qmake).

Espero no haber dejado nada sin explicar... y cualquier cosa el código es bastante corto y simple :D

2 comments:

mandel said...

Y ahora a traducir esto y pedir que lo ponga en el rss de canonical!

Diego Sarmentero said...

Sabes que me saca toda la emocion tener que escribir posts en ingles :P