Pull to refresh

Элемент управления Grid

Reading time 10 min
Views 17K
Табличные элементы управления (обычно в их названии присутствуют слова Table или Grid) широко используются при разработке GUI. Так получилось, что на работе мы используем С++ и MFC для разработки пользовательского интерфейса. В начале мы использовали CGridCtrl — общедоступную и довольно известную реализацию грида. Но с некоторого времени он перестал нас устраивать и появилась на свет собственная разработка. Идеями, лежащими в основе нашей реализации, я хочу с вами здесь поделиться. Есть задумка сделать open source проект (скорее всего под Qt). Поэтому данную заметку можно рассматривать как «Proof Of Concept». Конструктивная критика и замечания приветствуются.


Причины, по которым меня не устраивают существующие реализации я опущу (это тема для отдельной заметки).
Проекты у нас инженерно-научные, с богатой графикой, и списки и таблицы используются повсеместно. Поэтому новый грид должен был обеспечивать гибкую кастомизацию, хорошее быстродействие и минимальное потребление памяти при показе больших объемов информации. При разработке я старался придерживаться следующим правилом: реализуй функциональность максимально обобщенно и абстрактно, но не во вред удобству использования и оптимальности работы. Конечно, это правило противоречиво, но насколько мне удалось соблюсти баланс — судить вам.

Чтобы с чего-то начать, давайте попробуем дать определение элементу управления grid. Для сохранения общности можно сказать, что grid — это визуальный элемент, который разбивает пространство на строки и столбцы. В результате получается сетка ячеек (место пересечения строк и столбцов), внутри которых отображается некоторая информация. Таким образом у грида можно различить два компонента: структуру и данные. Структура грида определяет как мы будем разбивать пространство на строки и столбцы, а данные описывают, собственно, то, что мы хотим видеть в получившихся ячейках.

Как мы определили выше, структура грида (можно сказать топология) описывается строками и столбцами. Строки и столбцы — объекты очень похожие. Можно сказать неразличимые, только одни разбивают плоскость по горизонтали, а другие по вертикали. Но делают они это одинаковым образом. Здесь мы уже подходим к довольно маленькой и самодостаточной сущности, которую можно оформить в C++ класс. Я назвал такой класс Lines (по русски можно определить как Линии или Полосы). Этот класс будет определять набор линий (строк или столбцов). Углубляться и определять класс для отдельной линии нет необходимости. Класс получится маленьким и нефункциональным. Таким образом Lines будет определять свойства набора строк или стобцов и операции, которые над ними можно производить:
  • Главное свойство Count — количество линий, из которых состоит Lines
  • Каждая линия может менять свой размер (строка высоту, а столбец — ширину)
  • Линии можно переупорядочивать (строки сортировать, столбцам менять порядок)
  • Линии можно скрывать (делать невидимыми для пользователя)
Больше никаких более-менее полезных операций над набором строк или столбцов мне придумать не удалось. Получился небольшой, но полезный класс:
class Lines
{
public:
    Lines(UINT_t count = 0);

    UINT_t GetCount() const { return m_count; }
    void SetCount(UINT_t count);

     UINT_t GetLineSize(UINT_t line) const;
     void SetLineSize(UINT_t line, UINT_t size);

    bool IsLineVisible(UINT_t line) const;
    void SetLineVisible(UINT_t line, bool visible);

    template <typename Pred> void Sort(const Pred& pred);

    const vector<UINT_t>& GetPermutation() const;
    void SetPermutation(const vector<UINT_t>& permutation);

    UINT_t GetAbsoluteLineID(UINT_t visibleLine) const;
    UINT_t GetVisibleLineID(UINT_t absoluteLine) const;

    Event_t<void(const Lines&, unsigned)> changed;

private:
    UINT_t m_count;
    vector<UINT_t> m_linesSize;
    vector<bool> m_linesVisible;
};

Комментарии и некоторые служебные функции и поля опущены для наглядности.
Вы можете заметить, что в классе есть функции GetAbsoluteLineID и GetVisibleLineID. Так как мы позволяем перемешивать и скрывать линии, то абсолютный и видимый индекс линии различаются. Надеюсь картинка наглядно показывает эту ситуацию.

Также нужно сделать пояснение по поводу строки
Event_t<void(const Lines&, unsigned)> changed;
Здесь определён сигнал (так он называется в Qt или boost). С появлением С++11 и std::function, можно легко написать простую реализацию signals/slots, чтобы не зависеть от внешних библиотек. В данном случае мы определили эвент в классе Lines, и к нему можно подключать любую функцию или функтор. Например грид подключается к этому эвенту и получает оповещение, когда экземпляр Lines меняется.

Таким образом структура грида у нас представлена двумя экземплярами Lines:
private:
    Lines m_rows;
    Lines m_columns;

Переходим к данным. Каким образом давать гриду информацию о том, какие данные он будет отображать и как их отображать? Здесь уже всё изобретено до нас — я воспользовался триадой MVC (Model-View-Controller). Начнем с элемента View. Так же как класс Lines определяет не одну линию, а целый набор, определим класс View как нечто, что отображает какие-то однородные данные в некотором подмножестве ячеек грида. Например, у нас в первом столбце будет отображаться текст. Это означает, что мы должны создать объект, который умеет отображать текстовые данные и который умеет говорить, что отображаться эти данные должны в первой колонке. Так как данные у нас могут отображаться разные и в разных местах, то лучше реализовать эти функции в разных классах. Назовем класс, который умеет отображать данные, собственно View, а класс, который умеет говорить где данные отображать Range (набор ячеек). Передавая в грид два экземпляра этих классов, мы как раз указываем что и где отображать.

Давайте подробнее остановимся на классе Range. Это удивительно маленький и мощный класс. Его главная задача — быстро отвечать на вопрос, входит ли определенная ячейка в него или нет. По сути это интерфейс с одной функцией:
class Range
{
public:
    virtual bool HasCell(CellID cell) const = 0;
};

Таким образом можно определять любой набор ячеек. Самыми полезными конечно же будут следующие два:
class RangeAll
{
public:
    bool HasCell(CellID cell) const override { return true; }
};
class RangeColumn
{
public:
    RangeColumn(UINT_t column): m_column(column) {}
    bool HasCell(CellID cell) const override { return cell.column == m_column; }
private:
    UINT_t m_column;
};
Первый класс определяет набор из всех ячеек, а второй — набор из одного конкретного столбца.

Для класса View осталась одна функция — отрисуй данные в ячейке. На самом деле для полноценной работы View должен уметь отвечать еще на пару вопросов:
  • Сколько надо места, что бы отобразить данные (например чтобы колонкам установить ширину, достаточную для отображения текста — режим Fit)
  • Дай текстовое представление данных (чтобы скопировать в буфер обмена как текст или отобразить в tooltip)
class View
{
public:
    virtual void Draw(DrawContext& dc, Rect rect, CellID cell) const = 0;
    virtual Size GetSize(DrawContext& dc, CellID cell) const = 0;
    virtual bool GetText(CellID cell, INTENT intent, String& text) const = 0;
};

А что, если мы хотим отрисовать разные типы данных в одной и той же ячейке? Например нарисовать иконку и рядом текст или нарисовать чекбокс и рядом текст. Не хотелось бы для этих комбинаций реализовывать отдельный тип View. Давайте разрешим в одной ячейке показывать несколько View, только нужен класс, который говорит как разместить конкретный View в ячейке.
class Layout
{
public:
    virtual void LayoutView(DrawContext& dc, const View* view, Rect& cellRect, Rect& viewRect) const = 0;
};

Для наглядности рассмотрим пример в котором в первом столбце отображаются чекбоксы и текст. Во втором столбце представлены радио-кнопки, квадратики с цветом и текстовое представление цвета. И еще в одной ячейке есть звёздочка.

Например для чекбокса мы будем использовать LayoutLeft, который спросит у View его размер и «откусит» прямоугольник нужного размера от прямоугольника ячейки. А для текста мы будем использовать LayoutAll, к которому в параметре cellRect перейдет уже усеченный прямоугольник ячейки. LayoutAll не будет спрашивать размер у своего View, а просто «заберет» все доступное пространство ячейки. Можно напридумывать много разных полезных Layouts, которые будут комбинироваться с любыми View.

Возвратимся к классу Grid, для которого мы хотели задавать данные. Получается, что хранить мы можем тройки <Range, View, Layout>, которые определяют в каких ячейках, каким образом отображать данные, плюс как эти данные должны быть расположены внутри ячейки. Итак класс Grid у нас выглядит примерно так:
class Grid
{
private:
    Lines m_rows;
    Lines m_columns;
    vector<tuple<Range, View, Layout>> m_data;
};

Вот как выглядит m_data для нашего примера

В сущности, этого достаточно для отрисовки грида. Но информация организована не оптимальным образом — просто список записей, определяющих отображение данных.
Давайте подумаем, как с помощью нашего класса Grid можно отрисовать какую-то ячейку.
  1. Нужно отфильтровать m_data и оставить только те тройки, для которых наша ячейка попадает в Range
    for (auto d: grid.m_data)
        if (d.range->HasCell(cell))
            cell_data.push_back(d);
  2. Определить прямоугольник для ячейки
    Rect cellRect = CalculateCellRect(grid.m_rows, grid.m_columns, cell);
  3. Определить прямоугольники для всех View
    vector<Rect> view_rects(cell_data.size());
    auto view_rect_it = view_rects.begin();
    for (auto d: cell_data)
        d.layout->LayoutView(grid.GetDC(), d.view, cellRect, *view_rect_it++);
  4. Отрисовать все View в рассчитанные для них прямоугольники
    auto view_rect_it = view_rects.begin();
    for (auto d: cell_data)
        d.view->Draw(grid.GetDC(), *view_rect_it++, cell);

Как можно заметить, отрисовка происходит на последнем шаге и для нее нужен лишь список отфильтрованных View и список прямоугольников, куда эти View будут рисовать данные. Можно придумать небольшой класс, который бы кешировал эти данные и его функция отрисовки состояла бы из единственного пункта 4.
class CellCache
{
public:
    CellCache(Grid grid, CellID cell);
    void Draw(DrawContext& dc);
private:
    CellID m_cell;
    Rect m_cellRect;
    vector<pair<View, Rect>> m_cache;
};

Этот класс в конструкторе выполняет первые три пункта и сохраняет результат в m_cache. При этом функция Draw получилась достаточно легковесной. За эту легковесность пришлось заплатить в виде m_cache. Поэтому создавать экземпляры такого класса на каждую ячейку будет накладно (мы ведь договорились не иметь данных, зависящих от общего количества ячеек). Но нам и не надо иметь экземпляры CellCache для всех ячеек, достаточно только для видимых. Как правило в гриде видна небольшая часть всех ячеек и их количество не зависит от общего числа ячеек.

Таким образом у нас появился еще один класс, который управляет видимой областью грида, хранит CellCache для каждой видимой ячейки и умеет быстро рисовать их.
class GridCache
{
public:
    GridCache(Grid grid);
    void SetVisibleRect(Rect visibleRect);
    void Draw(DrawContext& dc);
private:
    Grid m_grid;
    Rect m_visibleRect;
    vector<CellCache> m_cells;
};


Когда пользователь меняет размер грида или скроллирует содержимое, мы просто выставляем новый visibleRect в этом объекте. При этом переформируется m_cells, так чтобы содержать только видимые ячейки. Функциональности GridCache достаточно, что бы реализовать read-only грид.
class GridWindow
{
public:
    Grid GetGrid() { return m_gridCache.GetGrid(); }
    void OnPaint() { m_gridCache.Draw(GetDrawContext()); }
    void OnScroll() { m_gridCache.SetVisibleRect(GetVisibleRect()); }
    void OnSize()  { m_gridCache.SetVisibleRect(GetVisibleRect()); }
private:
    GridCache m_gridCache;
};

Разделение классов Grid и GridCache очень полезно. Оно позволяет, например, создавать несколько GridCache для одного экземпляра Grid. Это может использоваться для реализации постраничной печати содержимого грида или экспорта грида в файл в виде изображения. При этом объект GridWindow никаким образом не модифицируется — просто в стороне создается GridCache, ссылающийся на тот же экземпляр Grid, в цикле новому GridCache выставляется visibleRect для текущей страницы и распечатывается.

Как же добавить интерактивности? Здесь на первый план выходит Controller. В отличие от остальных классов, этот класс определяет интерфейс со многими функциями. Но лишь потому, что самих мышиных событий достаточно много.
class Controller
{
public:
    virtual bool OnLBttnDown(CellID cell, Point p) = 0;
    virtual bool OnLBttnUp(CellID cell, Point p) = 0;
        ...
};

Так же как и для отрисовки, для работы с мышью нам нужны только видимые ячейки. Добавим в класс GridCache функции обработки мыши. По положению курсора мыши определим какая ячейка (CacheCell) находится под ней. Далее в ячейке для всех View, в чей прямоугольник попала мышь, забираем Controller и вызываем у него соответствующий метод. Если метод возвратил true — прекращаем обход Views. Данная схема работает достаточно быстро. При этом нам пришлось в класс View добавить ссылку на Controller.

Осталось разобраться с классом Model. Он нужен как шаблон адаптер. Его основная цель — предоставить данные для View в «удобном» виде. Давайте рассмотрим пример. У нас есть ViewText который умеет рисовать текст. Что бы его нарисовать в конкретной ячейке, этот текст надо для ячейки запросить у объекта ModelText, который, в свою очередь, лишь интерфейс, а его конкретная реализация знает откуда текст взять. Вот примерная реализация класса ViewText:
class ViewText: public View
{
public:
    ViewText(ModelText model): m_model(model) {}
    void Draw(DrawContext& dc, Rect rect, CellID cell) const override
    {
         const String& text = model->GetText(cell);
         dc.DrawText(text, rect);
    }
private:
    ModelText m_model;
};

Таким образом несложно угадать какой интерфейс должен быть у ModelText:
class ModelText: public Model
{
public:
    virtual const String& GetText(CellID cell) const = 0;
    virtual void SetText(CellID cell, const String& text) = 0;
};

Обратите внимание, мы добавили сеттер для того, что бы им мог воспользоваться контроллер. На практике наиболее часто используется реализация ModelTextCallback
class ModelTextCallback: public ModelText
{
public:
    function<const String&(CellID)> getCallback;
    function<void(CellID, const String&)> setCallback;

    const String& GetText(CellID cell) const override { return getCallback(cell); }
    void SetText(CellID cell, const String& text) override { if (setCallback) setCallback(cell, text); }
};

Эта модель позволяет при инициализации грида назначить лямбда функции доступа к настоящим данным.
Ну а что же общего у моделей для разных данных: ModelText, ModelInt, ModelBool ...? В общем-то ничего, единственное, что про них всех можно сказать, что они должны информировать все заинтересованные объекте о том, что данные изменились. Таким образом базовый класс Model у нас примет следующий вид:
class Model
{
public:
    virtual ~Model() {}
    Event_t<void(Model)> changed;
};


В итоге наш грид разбился на множество небольших классов, каждый из которых выполняет четко определенную небольшую задачу. С одной стороны может показаться, что для реализации грида представлено слишком много классов. Но, с другой стороны, классы получились маленькими и простыми, с четкими взаимосвязями, что упрощает понимание кода и уменьшает его сложность. При этом всевозможные комбинации наследников классов Range, Layout, View, Controller и Model дают очень большую вариативность. Использование лямбда функций для ModelCallback позволяют легко и быстро связывать грид с данными.

В следующей заметке я опишу как реализовать стандартную функциональность грида: selection, sorting, column/row resize, printing, как добавить заголовок (фиксированные верхние строки и левые столбцы).
Раскрою небольшой секрет — все что описано в данной статье уже достаточно для реализации вышеперечисленного. Если какую-то функциональность я пропустил, пожалуйста, пишите в комментариях и я опишу их реализацию в следующей статье.
Tags:
Hubs:
+19
Comments 16
Comments Comments 16

Articles