Меню для телефона. Скролл пальцем. QGraphicsScene

      Комментарии к записи Меню для телефона. Скролл пальцем. QGraphicsScene отключены

В этой теме 0 ответов, 1 участник, последнее обновление  Васильев Владимир Сергеевич 4 мес., 1 неделя назад.

  • Автор
    Сообщения
  • #3170

    Еще когда я писал свою первую игрушку под Android (по ссылке описание разработки и процесса публикации на маркете) возникли проблемы с организацией меню. Под меню я понимаю ввиду окошко, позволяющее пользователю выбрать игровой уровень. Я бы хотел разметить все уровни на одном виджете, однако, помучившись я бросил эту затею и сделал загрузку уровней по 9 штук на одном экране и смену экранов кнопками.

    Сейчас я заканчиваю новую игрушку и решил решить этот вопрос. Кроме того, недавно мне попался заказ, в котором также требовалось подобное меню, но с дополнительными требованиями — например, нужно было отслеживать какие именно элементы меню находятся на экране в данный момент, а также отслеживать достижение скроллом определенных отметок.

    Итак, что может меню, которое у меня получилось:

    1. отображать заданные элементы (виджеты/иконки/…), располагая их по сетке (как в таблице);
    2. прокручиваться пальцем (а еще указателем мыши или колесиком на десктопе);
    3. масштабироваться на всю ширину окна, изменяя размер иконок, но не искажая их пропорций;
    4. вырабатывать сигналы при клике по иконке (но не в процессе перетаскивания, скроллирования);
    5. «переворачиваться» при изменении пропорций окна (горизонтальное/вертикальное), например при перевороте телефона;

    Мое меню для разных ориентаций экрана выглядит следующим образом:
    menu-qt

    qt-menu

    У меня иконка уровня рисуется нетривиальным образом (именно поэтому он наследует GameWidget) — я для нее отдельный класс виджета:

    class TaskIcon : public GameWidget {
        Q_OBJECT
    public:
        explicit TaskIcon(const Level &level, QWidget *parent = 0);
        int level();
        void set_completed();
    private:
        const int m_level;
        QLabel *m_completedLabel;
    };

    В детали его реализации я вдаваться не буду, отмечу лишь что: level() возвращает номер уровня (m_level), соответствующего иконке (используется после того, как пользователь кликнет по иконке); set_completed() помечает уровень пройденным, добавляя на m_completedLabel изображение с зеленой галочкой.

    Иконки я решил разместить на графической сцене (QGraphicsScene) — это очень гибкий вариант. Если иконок много — мы можем установить для показа только нужную часть сцены, затем мы можем настроить QGraphicsView так, чтобы содержимое сцены растягивалось на все окно без искажения размеров (Qt::KeepAspectRatio):

    #include <QGraphicsScene>
    #include <QGraphicsView>
    #include <QMap>
    
    class TaskIcon;
    
    class LevelMenu : public QGraphicsView {
        Q_OBJECT
    public:
        LevelMenu(QWidget *parent = 0);
    signals:
        void selected(int levelNumber);
    public slots:
        void load();
        void set_icon_completed(int level);
    protected:
        virtual void resizeEvent(QResizeEvent *event);
        void disposeIcons();
    
        void mousePressEvent(QMouseEvent *event) override;
        void mouseReleaseEvent(QMouseEvent *event) override;
    
        QGraphicsScene m_scene;
    private:
        QPoint m_clickPos;
        QMap<int, TaskIcon*> m_icons;
    
        const int IconSize = 150;
        const int NColumn = 3;
    };

    Наш класс наследует QGraphicsView (который, в свою очередь, является виджетом) и хранит внутри себя QGraphicsScene, а также константы — количество колонок (в которые надо упаковать иконки уровней) и размер иконки в координатах сцены.

    Чтобы обработать выбор пользователем уровня и прокрутку меню мышью, определены методы mousePressEvent(QMouseEvent *event) и mouseReleaseEvent(QMouseEvent *event). Чтобы различать прокрутку и выбор уровня нужна позиция, в которой произошло нажатие (m_clickPos). Если пользователь выбирает уровень, программа устанавливает уровень. Для этого наш виджет должен передать сигнал selected(int levelNumber); классу, отвечающему за смену экранов (например, использующему QStackedLayout).

    Метод load() выполняет добавление иконок на сцену, set_icon_completed(int level) вызывает функцию set_completed() для иконки, соответствующей уровню. Чтобы сделать это — иконки помещаются в QMap, упорядоченные по номеру уровня (m_icons). Функция void disposeIcons(); выполняет размещение иконок на сцене с учетом пропорций виджета и заданного в конструкторе числа столбцов.

    Чтобы реализовать перетаскивание сцены мы должны запретить передачу событий элементам сцены (цитата из документации):

    This behavior only affects mouse clicks that are not handled by any item. You can define a custom behavior by creating a subclass of QGraphicsView and reimplementing mouseMoveEvent().

    Для этого мы вызываем setInteractive() и setDragMode(). Мы отключаем оба скролл-бара, т.к. они плохо смотрятся на телефоне. На самом деле скроллы останутся, мы будем ими пользоваться, но они не будут отображаться. Наконец, конструктор вызывает метод load():

    LevelMenu::LevelMenu(QWidget *parent)
        : QGraphicsView(parent), m_scene(this) {
        setInteractive(false);
        setDragMode(QGraphicsView::DragMode::ScrollHandDrag);
    
        setViewportMargins(0, 0, 0, 0);
    
        setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
        setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
    
        load();
    }

    Метод load() добавляет иконки на сцену и в QMap, затем вызывает метод disposeIcons(). В моем примере иконка рисуется на основе данных, полученных из базы данных, а для создания глобальной точки доступа к БД используется шаблон проектирования Singleton. В вашем коде это может выглядеть проще (например, данные могут передаваться в качестве аргумента функции, но я думаю, что это не принципиально).

    void LevelMenu::load() {
        setScene(&m_scene);
        int levelsAmount = LEVEL_DB.levelsAmount();
    
        for (int i = 0; i < levelsAmount; ++i) {
            Level level(LEVEL_DB.level(i));
            TaskIcon *icon = new TaskIcon(level);
    
            m_icons[i] = icon;
    
            m_scene.addWidget(icon);
            icon->show();
        }
    
        disposeIcons();
    }

    В методе disposeIcons() получилось немного математики, но я думаю, что при желании в ней не сложно разобраться. Тут рассчитывается число строк, а затем иконки перемещаются по сцене с учетом ориентации (горизонтальной или вертикальной) окна. Если мы не будем задавать новую область сцены для отображения (вызовом setSceneRect), то после переворота экрана сцена будет больше чем надо (ее размер будет позволять вместить все элементы как по горизонтали, так и по вертикали) — за счет этого, пользователь сможет скроллировать экран в неправильных направлениях.

    void LevelMenu::disposeIcons() {
        const int n = m_icons.size();
    
        const int NRow = n/NColumn + (n%NColumn?1:0);
    
        for (int i = 0; i < n; ++i) {
            int iRow = i/NColumn;
            int iColumn = i%NColumn;
    
            if (height() > width()) {
                std::swap(iRow, iColumn);
            }
    
            m_icons[i]->move(iRow*IconSize, iColumn*IconSize);
            m_icons[i]->resize(IconSize, IconSize);
        }
    
        if (height() > width()) {
            m_scene.setSceneRect(0, 0, IconSize*NColumn, IconSize*NRow);
        }
        else {
            m_scene.setSceneRect(0, 0, IconSize*NRow, IconSize*NColumn);
        }
        fitInView(0, 0, IconSize*NColumn, IconSize*NColumn, Qt::KeepAspectRatio);
    }

    Надо четко понимать разницу между setSceneRect, который устанавливает область сцены, доступную для отображения в QGraphicsView, и функцией fitInView, вписывающей область сцены в текущие размеры экрана.

    Метод disposeIcons() вызывается при каждом изменении размера экрана:

    void LevelMenu::resizeEvent(QResizeEvent *event) {
        if (event->size() != event->oldSize())
            disposeIcons();
    }

    При клике по окну запоминаются координаты события:

    void LevelMenu::mousePressEvent(QMouseEvent *event) {
        m_clickPos = event->pos();
        QGraphicsView::mousePressEvent(event);
    }

    Наконец, метод mouseReleaseEvent() должен запустить выбранный пользователем уровень, если событие не связано с перетаскиванием. Я помещал на сцену виджеты, а не QGraphicsItem, поэтому фактически там хранятся proxy-объекты (представляющими виджеты на графической сцене). Я получаю элемент, находящийся в заданной координате экрана с помощью функции itemAt(). Если он может быть преобразован в QGraphicsProxyWidget, то с помощью метода widget() из прокси получаю реальный виджет. В моем случае виджет всегда является наследником класса TaskIcon), я выполняю преобразование типа с помощью dynamic_cast. Если получилось выполнить все преобразования, то вычисляю расстояние между текущей позицией курсора и сохраненной в mousePressEvent(), если оно меньше некоторого числа — я полагаю, что пользователь выполнял клик, а не перетаскивание. Поэтому меню вырабатывает сигнал selected(int levelNumber):

    void LevelMenu::mouseReleaseEvent(QMouseEvent *event) {
        auto proxyWidget = dynamic_cast<QGraphicsProxyWidget*>(itemAt(event->pos()));
        if (proxyWidget) {
            auto taskIcon = dynamic_cast<TaskIcon*>(proxyWidget->widget());
            if (taskIcon) {
                if ((m_clickPos - event->pos()).manhattanLength() < 10)
                    emit selected(taskIcon->level());
            }
        }
    
        QGraphicsView::mouseReleaseEvent(event);
    }

    Окно экрана с меню я набросал мышкой в QtDesigner:
    menu-screen-qtdesigner
    Для этого я разместил на виджете QHBoxLayout (горизонтальную раскладку), на нее положил QLabel с надписью и кнопку, после чего добавил 3 HorizontalSpacer чтобы надпись и кнопка не растягивались на всю ширину и располагались ближе к центру окна. Потом я перетащил на форму QGraphicsView (т.к. это базовый класс моего меню) и установил форме вертикальный компоновщик (кликнул правой кнопкой по форме и выбрал Компоновка->Скомпоновать по Вертикали.

    Теперь форма выглядит как надо, но на ней лежит QGraphicsView, а не наш LevelMenu. Однако, мы можем выполнить преобразование виджета. Примеры того, как это делается можно посмотреть в теме «Вывод справки в html формате«.

    Посмотреть полный исходный код меню вы можете в репозитории.

Для ответа в этой теме необходимо авторизоваться.