В первом посте мы разбили функциональность грида на несколько классов. Давайте еще раз быстренько их опишем:
- Lines — представляет набор колонок или строк.
- Range — oписывает любую совокупность ячеек.
- Layout — позволяет размещать данные внутри ячейки.
- Model — определяет интерфейс доступа к данным для View и Controller.
- View — показывает информацию в ячейке.
- Controller — позволяет пользователю менять данные.
- CacheCell — кеширует данные для видимой ячейки.
- CacheGrid — кеширует данные для видимой части грида.
- GridWindow — специальный контрол.
Так же мы описали модели и вью для текстовых данных (ModelText, ModelTextCallback. ViewText). Давайте попробуем создать грид и привязать к нему текстовые данные. Новую функциональность, которая нужна для работы стандартного грида будем добавлять в виде специальных Model/View/Controller.
Для краткости буду опускать в коде указатели, shared_ptr и т.п. Итак, поехали…
int main()
{
Application app;
// создаем окно
GridWindow grid_window("Demo");
// создаем модель текста
ModelTextCallback m;
m.getCallback = [](CellID cell)->String {
return String.Format("Item[%d, %d]", cell.row, cell.column);
};
// указываем гриду где, что и как рисовать
grid_window.AddData(RangeAll(), ViewText(m), LayoutAll());
// выставляем количество строк и столбцов
grid_window.SetRows(10);
grid_window.SetColumns(10);
// показываем окно
grid_window.Show(100, 100, 300, 300);
app.run();
}
Неплохо, только хочется как-то обособлять ячейки: рисовать либо через-полосицу, либо сетку. Давайте реализуем один, а потом другой вариант. Надеюсь код говорит сам за себя.
class ViewOddRowBackground: public View
{
public:
void Draw(DrawContext& dc, Rect rect, CellID cell) const override
{
// закрасить ячейку, если строка чётная
if (cell.row%2 == 0)
dc.FillRect(rect, ColorRGB(200, 200, 255));
}
};
void plugOddRowBackgroud(Grid& grid)
{
grid.AddData(RangeAll(), ViewOddRowBackground(), LayoutAll(Transparent));
}
Заметьте, мы в LayoutAll передали параметр Transparent, он говорит о том, что этот layout не будет модифицировать прямоугольник ячейки. Помните, что по умолчанию LayoutAll «забирает» всю свободную область ячейки и зануляет её. В режиме Transparent, он этого делать не будет и следующий за ним ViewText получит тот же оригинальный прямоугольник.
Осталось добавить вызов новой функции в main
GridWindow grid_window("Demo");
plugOddRowBackgroud(grid_window.GetGrid());
Теперь реализуем сетку.
class ViewCellBounds: public View
{
public:
void Draw(DrawContext& dc, Rect rect, CellID cell) const override
{
// нарисовать линию внизу ячейки
dc.Line(rect.BottomLeft(), rect.BottomRight());
// нарисовать линию у правого края ячейки
dc.Line(rect.TopRight(), rect.BottomRight());
}
};
void plugCellBounds(Grid& grid)
{
grid.AddData(RangeAll(), ViewCellBounds(), LayoutAll(Transparent));
}
...
GridWindow grid_window("Demo");
plugCellBounds(grid_window.GetGrid());
Следующим общим местом для всех гридов является понятие выделенных ячеек — selection. Предыдущие представления (views) не хранили никаких данных, поэтому модели для них мы не создавали. Здесь ситуация немного сложнее. Что же должно входить в ModelSelection? Во-первых, набор выделенных ячеек, а, во-вторых, координаты активной ячейки (это та ячейка, которая обычно выделена рамочкой, и с помощью клавиатуры мы работаем именно с этой ячейкой). Пишем код:
class ModelSelection: public Model
{
public:
Range GetSelectedRange() const { return m_selected_range; }
void SetSelectedRange(Range new_selected_range)
{
m_selected_range = new_selected_range;
changed.invoke(*this);
}
CellID GetActiveCell() const { return m_active_cell; }
void SetActiveCell(CellID new_active_cell)
{
m_active_cell = new_active_cell;
changed.invoke(*this);
}
private:
Range m_selected_range;
CellID m_active_cell;
};
class ViewSelection: public View
{
public:
ViewSelection(ModelSelection selection)
: m_selection(selection)
{}
void Draw(DrawContext& dc, Rect rect, CellID cell) const override
{
// если ячейка активная -> рисуем рамку
if (m_selection.GetActiveCell() == cell)
dc.DrawFrame(rect);
// если ячейка выделена -> закрашиваем область
// и меняем текущий цвет для текста
if (m_selection.GetSelectedRange().HasCell(cell))
{
dc.FillRect(rect, ColorSelectedBackground);
dc.SetTextColor(ColorSelectedText);
}
}
private:
ModelSelection m_selection;
};
ModelSelection plugSelection(Grid& grid)
{
ModelSelection selection;
grid.AddData(RangeAll(), ViewSelection(selection), LayoutAll(Transparent));
return selection;
}
...
GridWindow grid_window("Demo");
plugCellBounds(grid_window.GetGrid());
plugSelection(grid_window.GetGrid());
Реализацию контроллера я опускаю ради экономии места. Поверьте, там тоже всё просто: по нажатию левой клавиши мыши меняем активную ячейку на ту, что под мышкой. При перемещении мыши и отпускании левой клавиши — создаем специальный Range, который описывает прямоугольный блок ячеек от активной (где мы зажали кнопку), до текущей. Задаем этот Range в selection. Еще надо учитывать состояние клавиш Shift и Ctrl, но это детали. В итоге получаем следующую картинку.
Что бы дать возможность пользователю менять размер строк и столбцов, нам нужно реализовать специальный контроллер, который, по нажатию кнопки мышки около края ячейки, запомнит положение мыши, а при отжатии мыши изменит ширину колонки на разницу между начальной точной и текущей. Надеюсь идея понятна. Стоит отметить, что контроллеры у нас «живут» в представлениях (views), поэтому нам надо создать фиктивный View, который ничего не рисует, а лишь определяем прямоугольник, в котором будет активизироваться контроллер.
Довольно часто гриды имеют заголовок — часть грида, которая всегда остается в верхней части окна. Иногда есть похожая конструкция слева (обычно там пишут номер строки). При скроллинге эти области остаются на своих местах. Давайте посмотрим, как заголовок может выглядеть у нас:
Очень похоже на грид, состоящий из одной строки и такого же количества столбцов, что и оригинальный грид. Тогда можно сказать, что заголовок — это грид, колонки которого синхронизированы с колонками оригинального грида (по сути в двух гридах используется один и тот же экземпляр Lines для колонок). Этот грид расположен в верхней части окна и скроллируется только по горизонтали. Подобное утверждение можно сказать про левую фиксированную часть, только там синхронизируются строки. Таким образом у нас в классе GridWindow живут четыре CacheGrid вместо одного.
Пойдем еще дальше и скажем, что фиксированные области могут быть с любых сторон.
Итак, класс GridWindow у нас усложнился — вместо одного CacheGrid, у нас появилось их девять штук. Функции отрисовки, скроллирования, обработки мышиных событий должны тоже усложниться. Я предлагаю здесь остановиться и внимательно посмотреть на последний рисунок. Издалека он похож на область, разбитую на девять под-областей в три строки и три колонки. Похоже на грид с тремя строками и колонками, в ячейках которого отображаются другие гриды. По определению у нас View настолько универсальный, что может отображать любую сущность. Так давайте создадим View, который в ячейке отображает некоторый грид. Для нашего случая с девятью гридами мы получим примерно следующие классы:
class ModelGrid: public Model
{
public:
ModelGrid();
CacheGrid GetGrid(CellID cell) const { return m_grids[cell.row][cell.column]; }
private:
CacheGrid m_grids[3][3];
};
class ViewGrid: public View
{
public:
ViewGrid(ModelGrid model)
:m_model(model)
{}
void Draw(DrawContext& dc, Rect rect, CellID cell) const override
{
CacheGrid cacheGrid = m_model.GetGrid(cell);
cacheGrid.Draw(dc);
}
};
В конструкторе ModelGrid создаются девять объектов CacheGrid и синхронизируются строки и столбцы. Так же не трудно реализуется контроллер. Если мы добавим ViewGrid к нашему старому классу GridWindow, который имел только один объект CacheGrid, то нам нет нужды создавать новый тип GridWindow. Единственное отличие — позицию скроллбаров нам нужно передавать в подгриды: игнорировать позицию скроллов будут угловые гриды (Top/Left, Top/Right, Botton/Right, Bottom/Left), по горизонтали будут скроллироваться Top и Bottom гриды, по вертикали — Left и Right. Ну а центральный Client грид будет скроллироваться по всем сторонам. При этом код отрисовки и взаимодействия с мышью у нас останется прежним.
Здесь опять остановимся и посмотрим, какой манёвр мы только что совершили? В начале у нас было утверждение — грид это набор ячеек, а ячейка — это место, где отображается всё что угодно. Теперь получается, что ячейка может отображать всё что угодно, включая грид. Цепочка замкнулась — грид рисует ячейки, ячейки рисуют грид. Тут возникает философский вопрос о курице и яйце. Что первично? Грид, который состоит из ячеек, или ячейка, которая может представлять всё что угодно, включая грид. Похоже, что понятие ячейки более универсально. Помните класс CellCache из первой статьи — в нем первым делом тройки <Range, View, Layout> фильтровались по Range, то есть по сути ячейка должна потом хранить не тройки, а двойки <View, Layout> — которые описывают, что и где в ячейке рисовать. CellCache умеет себя рисовать и обрабатывать события мышки (передавая их в нужный контроллер), при этом нигде не упоминается Grid.
А что, если мы создадим класс окна, который будет рисовать не CacheGrid, а CacheCell? Тогда мы получим не класс окна грида, а более универсальный элемент управления, в который мы просто, в виде двоек <View, Layout>, задаём что и где рисовать. Например, комбинация {<ViewCheck, LayoutLeft>, <ViewText, LayoutAll>} нам даёт стандартный контрол управления чекбокс. Другие комбинации дадут другие типы контролов, причем разные Layouts дают нам возможность располагать Views в любой конфигурации. Например чекбокс справа текста, снизу, сверху — как угодно.
Давайте назовем такое окно ItemWindow (ячейка — это понятие тесно связанное с гридом, item — более нейтрален). У меня получилась иерархия из трех классов:
- ItemWindow — универсальный контрол, содержит в себе один экземпляр CacheCell. По сути всё окно представляет из себя одну ячейку.
- ListWindow — наследник ItemWindow. В экземпляр CacheCell добавляет ViewGrid, который будет рисовать в ячейке один грид. Этот вариант хорош для простых списков без фиксированных областей (заголовок и т.п.)
- GridWindow — наследник ListWindow. Делает грид, объявленный в ListWindow, с тремя строками и колонками. Задаёт на этом гриде еще один ViewGrid, который рисует девять подгридов. Это контрол для тех, кому нужны заголовки
Как правило, когда разрабатывают библиотеку визуальных компонентов, то определяют сначала простые типы окон, потом переходят к более сложным (item based). В этот момент базовые понятия окна уже зафиксированны и не учитывают специфику item-based окон. Поэтому для них придумывают какие-то другие абстракции, никак не связанные с базовыми понятиями окна. В результате не получается органично использовать простые и сложные контролы вместе. Мы же пошли от сложного контрола и пришли в итоге к простым окнам. Но их структура немного сложнее, чем в обычных оконных фреймворках. Зато мы получаем универсальность — Views и Layouts у нас используются и в простых контролах (ItemWindow) и в более сложных. Получается один раз реализовав ViewCheck, мы можем использовать его как простой контрол и как подэлемент грида.
Статья получилась довольно длинной и сложной, поэтому пока закончу. Если что-то вам покажется непонятным или неправильным, прошу писать комментарии, я подредактирую статью. Таким образом статья для всех станет проще и понятнее.
В следующей статье я опишу, как можно сделать introspection для нашего окна. Любой инструмент, способный посылать сообщения окну и интерпретировать последовательность байт будет способен «общаться» с гридом. Это позволит нам сделать байндинг к гриду на питоне и делать автоматическое тестирование нашего контрола. Так же расскажу как в моей схеме реализуются всякие «вкусняшки» из devexpress. Как я уже говорил, работающий прототип данной архитектуры уже существует, так что реализовать её в open source будет не сложно.
Автор: lexxmark