Табличные элементы управления (обычно в их названии присутствуют слова 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 можно отрисовать какую-то ячейку.
- Нужно отфильтровать m_data и оставить только те тройки, для которых наша ячейка попадает в Range
for (auto d: grid.m_data) if (d.range->HasCell(cell)) cell_data.push_back(d);
- Определить прямоугольник для ячейки
Rect cellRect = CalculateCellRect(grid.m_rows, grid.m_columns, cell);
- Определить прямоугольники для всех 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++);
- Отрисовать все 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, как добавить заголовок (фиксированные верхние строки и левые столбцы).
Раскрою небольшой секрет — все что описано в данной статье уже достаточно для реализации вышеперечисленного. Если какую-то функциональность я пропустил, пожалуйста, пишите в комментариях и я опишу их реализацию в следующей статье.
Напоследок покажу несколько примеров, как используется грид у нас в проектах.
Автор: lexxmark