Елемент управління Grid

Табличні елементи керування (зазвичай у їх назві присутні слова Table або Grid) широко використовуються для розробки GUI. Так вийшло, що на роботі ми використовуємо С++ і MFC для розробки інтерфейсу користувача. На початку ми використовувалиCGridCtrl— загальнодоступну та відому реалізацію гриду. Але з деякого часу він перестав нас влаштовувати і з'явилася світ власна технологія. Ідеями, які є основою нашої реалізації, я хочу з вами тут поділитися. Є задум зробити open source проект (швидше за все під Qt). Тому цю замітку можна як «Proof Of Concept». Конструктивна критика та зауваження вітаються. Причини, через які мене не влаштовують існуючі реалізації, я опущу (це тема для окремої замітки). Проекти у нас інженерно-наукові, з багатою графікою, і списки та таблиці використовуються повсюдно. Тому новий грід мав забезпечувати гнучку кастомізацію, гарну швидкодію та мінімальне споживання пам'яті при показі великих обсягів інформації. При розробці я намагався дотримуватися наступного правила: реалізуй функціональність максимально узагальнено та абстрактно, але не на шкоду зручності використання та оптимальності роботи. Звичайно, це правило суперечливе, але наскільки мені вдалося дотриматися балансу — судити вам.

Щоб з чогось почати, спробуємо дати визначення елементу управління grid. Для збереження спільності можна сказати, що grid – це візуальний елемент, який розбиває простір на рядки та стовпці. В результаті виходить сітка осередків (місце перетину рядків та стовпців), усередині яких відображається деяка інформація. Таким чином, у грида можна розрізнити два компоненти: структуру і дані. Структура гриду визначає як ми розбиватимемопростір на рядки і стовпці, а дані описують, власне, те, що ми хочемо бачити в клітинках.

Як ми визначили вище, структура ґріда (можна сказати топологія) описується рядками та стовпцями. Рядки та стовпці - об'єкти дуже схожі. Можна сказати нерозрізнені, тільки одні розбивають площину по горизонталі, інші по вертикалі. Але роблять вони це однаково. Тут ми вже наближаємося до досить маленької і самодостатньої сутності, яку можна оформити в C++ клас. Я назвав такий клас Lines (українською можна визначити як Лінії чи Смуги). Цей клас визначатиме набір ліній (рядків чи стовпців). Поглиблюватися та визначати клас для окремої лінії немає потреби. Клас вийде маленьким і нефункціональним. Таким чином Lines визначатиме властивості набору рядків або стовпців та операції, які над ними можна робити:

  • Головна властивість Count – кількість ліній, з яких складається Lines
  • Кожна лінія може змінювати свій розмір (рядок висоту, а стовпець - ширину)
  • Лінії можна переупорядковувати (рядки сортувати, стовпцям змінювати порядок)
  • Лінії можна приховувати (робити невидимими для користувача)

Більше ніяких корисних операцій над набором рядків чи стовпців мені придумати не вдалося. Вийшов невеликий, але корисний клас:

Коментарі та деякі службові функції та поля опущені для наочності. Ви можете помітити, що в класі є функції GetAbsoluteLineID та GetVisibleLineID. Так як ми дозволяємо перемішувати та приховувати лінії, то абсолютний та видимий індекс лінії різняться. Сподіваюся, картинка наочно показує цю ситуацію. Також потрібно зробити пояснення з приводу рядка

Тут визначено сигнал (так він називається Qt абоboost). З появою С++11 і std::function можна легко написати просту реалізацію signals/slots, щоб не залежати від зовнішніх бібліотек. В даному випадку ми визначили евенти в класі Lines, і до нього можна підключати будь-яку функцію або функцію. Наприклад, грид підключається до цього евенту і отримує оповіщення, коли екземпляр Lines змінюється.

Таким чином структура гриду у нас представлена ​​двома екземплярами Lines:

Переходимо до даних. Яким чином давати гриду інформацію про те, які дані він відображатиме і як їх відображатиме? Тут уже все винайдено до нас – я скористався тріадою MVC (Model-View-Controller). Почнемо з View. Так як клас Lines визначає не одну лінію, а цілий набір, визначимо клас View як щось, що відображає якісь однорідні дані в деякому підмножині осередків гриду. Наприклад, у нас у першому стовпці буде відображатися текст. Це означає, що ми повинні створити об'єкт, який вміє відображати текстові дані і який вміє говорити, що ці дані повинні відображатися в першій колонці. Так як дані у нас можуть відображатися різні і в різних місцях, краще реалізувати ці функції в різних класах. Назвемо клас, який вміє відображати дані, власне View, а клас, який вміє говорити, де дані відображати Range (набір осередків). Передаючи в грид два екземпляри цих класів, ми вказуємо що і де відображати.

Давайте докладніше зупинимося на класі Range. Це напрочуд маленький і потужний клас. Його головне завдання — швидко відповідати на запитання, чи входить певний осередок до нього чи ні. По суті, це інтерфейс з однією функцією:

Таким чином можна визначати будь-який набір осередків. Найкориснішими звичайно будуть наступні два:

Перший клас визначає набір з усіх осередків,а другий - набір з одного конкретного стовпця.

Для класу View залишилася одна функція - намалюй дані в комірці. Насправді для повноцінної роботи View має вміти відповідати ще на кілька запитань:

  • Скільки потрібно місця, щоб відобразити дані (наприклад, щоб колонкам встановити ширину, достатню для відображення тексту - режим Fit)
  • Дай текстове подання даних (щоб скопіювати в буфер обміну як текст або відобразити в інструменті)

А що, якщо ми хочемо відмалювати різні типи даних в одному і тому ж осередку? Наприклад, намалювати іконку і поруч текст або намалювати чекбокс і текст. Не хотілося б цих комбінацій реалізовувати окремий тип View. Давайте дозволимо в одному осередку показувати кілька View, тільки потрібен клас, який говорить як розмістити конкретний View в осередку.

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

Повернімося до класу Grid, для якого ми хотіли задавати дані. Виходить, що зберігати ми можемо трійки, які визначають у яких осередках, як відображати дані, плюс як ці дані повинні бутирозташовані всередині комірки. Отже клас Grid у нас виглядає приблизно так:

Ось як виглядає m_data для нашого прикладу По суті, цього достатньо для малювання гриду. Але інформація організована не оптимально — просто список записів, що визначають відображення даних. Давайте подумаємо, як за допомогою нашого класу Grid можна відмалювати якийсь осередок.

  1. Потрібно відфільтрувати m_data і залишити тільки ті трійки, для яких наш осередок потрапляє в Range
  2. Визначити прямокутник для комірки
  3. Визначити прямокутники для всіх
  4. Відобразити все View у розраховані для них прямокутники

Як можна помітити, зображення відбувається на останньому кроці і для неї потрібен лише список відфільтрованих View і список прямокутників, куди ці View будуть малювати дані. Можна придумати невеликий клас, який кешував ці дані та його функція відтворення складалася б з єдиного пункту 4.

Цей клас у конструкторі виконує перші три пункти та зберігає результат у m_cache. При цьому функція Draw вийшла досить легкою. За цю легковагу довелося заплатити у вигляді m_cache. Тому створювати екземпляри такого класу на кожен осередок буде накладно (адже ми домовилися не мати даних, що залежать від загальної кількості осередків). Але нам і не треба мати екземпляри CellCache для всіх осередків, достатньо лише для видимих. Як правило, у гриді видно невелику частину всіх осередків та їх кількість не залежить від загальної кількості осередків.

Таким чином, у нас з'явився ще один клас, який керує видимою областю гриду, зберігає CellCache для кожного видимого осередку і вміє швидко малювати їх.

Коли користувач змінює розмір гриду або скролює вміст, ми просто виставляємо новийvisibleRect у цьому об'єкті. При цьому переформується m_cells, щоб містити тільки видимі осередки. Функціональності GridCache достатньо, щоб реалізувати read-only грид.

Поділ класів Grid та GridCache дуже корисно. Воно дозволяє, наприклад, створювати кілька GridCache для одного екземпляра Grid. Це може бути використане для реалізації посторінкового друку вмісту гриду або експорту грида у файл у вигляді зображення. При цьому об'єкт GridWindow ніяким чином не модифікується - просто осторонь створюється GridCache, що посилається на той самий екземпляр Grid, в циклі нового GridCache виставляється visibleRect для поточної сторінки і роздруковується.

Як додати інтерактивності? Тут першому плані виходить Controller. На відміну від інших класів, цей клас визначає інтерфейс із багатьма функціями. Але лише тому, що самих мишачих подій досить багато.

Так само як для малювання, для роботи з мишею нам потрібні тільки видимі осередки. Додамо до класу GridCache функції обробки миші. За положенням курсору миші визначимо яка комірка (CacheCell) знаходиться під нею. Далі в комірці для всіх View, у чий прямокутник потрапила миша, забираємо Controller і викликаємо у нього відповідний метод. Якщо метод повернув true – припиняємо обхід Views. Ця схема працює досить швидко. При цьому нам довелося до класу View додати посилання на Controller.

Залишилося розібратися із класом Model. Він потрібний як шаблон адаптера. Його основна мета – надати дані для View у «зручному» вигляді. Давайте розглянемо приклад. У нас є ViewText, який вміє малювати текст. Щоб його намалювати в конкретному осередку, цей текст треба для осередку запросити в об'єкта ModelText, який, у свою чергу, лише інтерфейс, а його конкретна реалізація знаєзвідки взяти текст. Ось приблизна реалізація класу ViewText:

Таким чином нескладно вгадати який інтерфейс має бути у ModelText:

Зверніть увагу, ми додали сеттер для того, щоб ним міг скористатися контролер. На практиці найчастіше використовується реалізація ModelTextCallback

Ця модель дозволяє при ініціалізації гриду призначити лямбду функції доступу до даних. Ну а що ж спільного у моделей для різних даних: ModelText, ModelInt, ModelBool. Загалом нічого, єдине, що про них усіх можна сказати, що вони повинні інформувати всі зацікавлені об'єкти про те, що дані змінилися. Таким чином базовий клас Model у нас набуде наступного вигляду:

У результаті наш грід розбився на безліч невеликих класів, кожен із яких виконує чітко визначене невелике завдання. З одного боку, може здатися, що для реалізації гриду представлено дуже багато класів. Але, з іншого боку, класи отримали маленькими і простими, з чіткими взаємозв'язками, що спрощує розуміння коду та зменшує його складність. При цьому всілякі комбінації спадкоємців класів Range, Layout, View, Controller та Model дають дуже велику варіативність. Використання лямбда функцій для ModelCallback дозволяє легко та швидко зв'язувати грид з даними.

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

">