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

от автора

Табличные элементы управления (обычно в их названии присутствуют слова 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, как добавить заголовок (фиксированные верхние строки и левые столбцы).
Раскрою небольшой секрет — все что описано в данной статье уже достаточно для реализации вышеперечисленного. Если какую-то функциональность я пропустил, пожалуйста, пишите в комментариях и я опишу их реализацию в следующей статье.

Напоследок покажу несколько примеров, как используется грид у нас в проектах.

ссылка на оригинал статьи http://habrahabr.ru/post/203968/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *