Русский
Русский
English
Статистика
Реклама

Uikit

Qt? ImGUI? wxWidgets? Пишем свое

30.09.2020 22:05:00 | Автор: admin

Привет, хабровчане! Хочу рассказать о своей системе UI, которую я написал для своего игрового движка, на которой делаю редактор для него же. Вот такой:

Итак, вот уже в который раз я начал писать движок, и твердо решил что в этот раз сделаю все хорошо и правильно. Одним из этих "хорошо и правильно" является WYSIWYG редактор а-ля Unity3D. К слову сказать, до этого у меня уже был опыт разработки подобных редакторов, на Qt. И к тому моменту я уже понимал, что задача стоит не простая, если я хочу сделать по-настоящему хороший редактор. И для этого нужна очень хорошая и гибкая система UI, в которой я буду очень хорошо разбираться и знать всякие тонкости. Ведь в таком редакторе будет очень много кастомных виджетов, контролов и т.п. Поэтому не должно быть компромисса между качеством редактора и возможностями UI системы.

При этом в самом движке стояла задача сделать хорошую систему UI. Т.к. движок для 2D игр, и в таких играх бывает очень много интерфейсов (бизнес-логика игр, чаты, кланы, инвентари и т.д.), то и система UI в нем должна быть гибкой и удобной.

"Что ж, почему бы не убить двух зайцев одновременно?" - подумал я.

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

Сейчас система UI непосредственно держится на следующих вещах:

  • Рендер

  • Система обработчиков кликов

  • Иерархия сцены

  • Система UI-виджетов

Остановимся на каждом по отдельности.

Рендер

Все UI-элементы рисуются с помощью двух примитивов: спрайт и текст. Они в свою очередь рисуются мешами из треугольников. То есть каждый спрайт или текст - это набор треугольников на плоскости с наложенной на них текстурой.

Так же для отрисовки UI необходимо отсечение по прямоугольнику - внутри всяких списков, полей ввода и т.д.

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

Спрайт

В простом виде спрайт - это картинка, имеющее какое-то положение на экране. По сути два треугольника, объединенные в квадрат с наложенной текстурой. Однако такой простой спрайт имеет недостаток: если его начинать растягивать, то он начинает плыть, очертания форм нарушаются и скругленные края кнопки уже становятся овальными.

Чтобы избежать этого используются 9-slice спрайты. Это те же спрайты, но разделенные на 9 частей:

В таком спрайте, при изменении размера, углы всегда остаются оригинального размера, зоны по краям растягиваются в одном направлении, а зона посередине тянется во всех направлениях. Это позволяет использовать компактные спрайты для кнопок и других прямоугольных примитивов, а так же растягивать как угодно без потерь качества и формы объектов.

Уже здесь есть нюанс - как должен вести себя спрайт, если его размер стал меньше размера углов? Размеры углов (и сторон) должны пропорционально уменьшаться. Это позволяет вписывать любой спрайт в любые размеры, а так же более-менее адекватно соединять скругленные части. Последнее оказывается очень полезно при отрисовке прогресс-баров с закругленными краями.

Кроме этих двух режимов есть и другие, которые применяются реже, но иногда нужны:

Много гифок с режимами спрайта
Простой спрайт, просто растягиваетсяПростой спрайт, просто растягивается9-slice спрайт, растягивается пропорционально9-slice спрайт, растягивается пропорциональноПоказывает прогресс круговым заполнением Показывает прогресс круговым заполнением Вертикальное заполнениеВертикальное заполнениеГоризонтальное заполнениеГоризонтальное заполнениеПовторение текстурыПовторение текстурыСохранение соотношения сторон и вписываниеСохранение соотношения сторон и вписывание

Генерация меша для одного спрайта - довольно недорогая операция. Однако, если спрайтов тысячи, это становится дорого. Поэтому меши спрайтов кешируются и обновляются только при изменении трансформации спрайта или других его параметров.

Код спрайта можно посмотреть здесь.

Текст

Как уже описано выше, текст - это набор треугольников.

Здесь два основных вопроса:

  • Получить текстуру с глифами символов

  • Сформировать меш

Глифы можно получить двумя путями:

  • Нарисовать самому в графическом редакторе или утилите, и сгруппировать в одной текстуре

  • Рендерить глифы через FreeType из векторного шрифта

У меня в основном используется второй подход, т.к. он гибче и удобнее. Во-первых не нужно заботиться о размере шрифта, можно рендерить глифы любого размера. Во-вторых на глифы можно накладывать эффекты, такие как тень, обводка, градиент или что-то свое. В-третьих глифы рендерятся по мере необходимости, и динамично размещаются в текстуре-атласе. Если в подходе с заранее отрисованными глифами появляется проблема с китайскими языками, где тысячи иероглифов, то в подходе динамичной отрисовки глифов мы можем рендерить только нужные глифы.

Итак, мы так или иначе получили текстуру, в которой лежат глифы для нашего текста. Теперь необходимо сформировать его меш. Эта задача делится на два этапа:

  • Формирование посимвольного описания расположения символов

  • Формирование меша по сгенерированному ранее описанию расположения

Самое интересное конечно же первая часть. Здесь на входе поступает прямоугольник, в который необходимо вписать текст. А так же его форматирование: высота символов, цвет, выравнивание по горизонтали и вертикали, тип обрезания. Ну и конечно же сам текст. Алгоритм построчно размещает символы, начиная с точки соответствующей выравниванию. Учитываются межсимвольные расстояния, кернинг, межстроковые расстояния. Так же, если включен режим обрезания через окончание на "...", проверяется влезет ли следующий символ и производится замена на три точки.

Вторая часть по сути формирует пары из двух треугольников под каждый символ, назначая текстурные координаты соответствующие символу из текстуры.

Здесь важный нюанс - строить новый текст очень дорого и затратно. Поэтому если текст передвинулся на Х пикселей в сторону, текст не перегенерируется заново, достаточно сместить вершины меша на Х пикселей.

Код текста можно посмотреть здесь.

IRectDrawable

Чтобы унифицировать отрисовку спрайта и текста, у них есть общий интерфейс IRectDrawable. Он отображает некую сущность, которая описывается прямоугольником (а точнее матрицей трансформации 2х3), которая может быть нарисована, может быть включена или выключена, и имеет цвет. Сам IRectDrawable наследуется от IDrawable (сущности которая может быть нарисована) и Transform (описывает трансформацию объекта матрицей 2х3, или Basis).

Отсечение

На первый взгляд довольно простая задача, отдал в Graphic API прямоугольник для отсечения, затем сбросил, и все готово. Все и правда так просто, однако в случае вложенных UI виджетов расчет итогового прямоугольника может быть нетривиален. Например, у нас есть прокручиваемая область, у которой есть видимая зона, вне которой все отсекается. Внутри нее может быть еще одна прокручиваемся зона или поле ввода текста, внутри которого тоже есть отсечение. И иногда эти вложенные зоны отсечения выходят за пределы более верхнеуровневого отсечения, и приходиться рассчитывать пересечение этих зон. Самому делать это вручную довольно сложно, нужно постоянно обращаться вверх по иерархии объектов и пытаться узнать у них их зону отсечения, если такая вообще есть.

Но все это можно решать с помощью стека прямоугольников отсечения на уровне системы рендера. Алгоритм прост: мы добавляем прямоугольник в стек, обновляя текущий прямоугольник отсечения. Текущий получается как пересечение предыдущего и нового. И добавлять можем сколько угодно. Затем извлекаем прямоугольники по одному, восстанавливая промежуточные посчитанные прямоугольники.

То есть сначала родительский элемент в начале отрисовки добавляет прямоугольник отсечения, без каких-либо расчётов. Затем рисуются виджеты-дети, которые добавляют в начале отрисовки свои отсечения. На каждом уровне текущий прямоугольник пересчитывается с учетом предыдущего. Затем, в конце отрисовки каждый виджет извлекает из стека свой прямоугольник отсечения.

Получается все просто - каждый виджет заботиться только о своем отсечении, но в итоге получается что они взаимосвязаны.

Забегая вперед скажу еще что интерфейс IDrawable в момент отрисовки запоминает текущий прямоугольник отсечения, чтобы затем корректно отметать клики по этим объектам вне зоны отсечения.

Обработка ввода

Итак, у нас есть возможность что-то нарисовать. Теперь хочется сделать это интерактивным - дать возможность потыкать курсором в эти рисуемые сущности.

На входе у нас есть информация о курсоре (нескольких точнее, для touch-screen), событиях нажатия курсора, движения, отпускания. А так же рисуемые в определенном порядке сущности. Наша задача понять в кого нажимает пользователь.

Здесь есть пара нюансов. Во-первых, как описано выше, отсечение. Если спрайт отсекается, то нажатие по его отсеченной части не должно срабатывать. Во-вторых - это перекрытие спрайтов друг другом. Очевидно нажиматься должно то, что нарисовано выше всех остальных под курсором, собственно что и видит конечный пользователь.

Для обработки кликов от пользователя есть специальный интерфейс CursorAreaEventsListener. В нем есть солидная пачка виртуальных функций, которые вызываются на реакцию пользователя. Здесь клики, вход и выход курсора, для разных курсоров и клавиш мышки. А так же есть система, которая занимается правильной рассылкой этих сообщений EventsSystem, основной частью которой является CursorAreaEventListenersLayer.

Как же правильно понять в кого именно попал курсор? Не отсечен ли этот объект под курсором? Не перекрыт ли другим? Обычно это решается на уровне иерархии. Допустим, пришел ивент о том что пользователь нажал кнопку мыши. Начинаем с верхнеуровневых объектов, спрашиваем не попал ли курсор в них, не попал ли в отсеченную часть. Если попали - идем ниже по иерархии, спрашиваем то же самое у детей, и ниже и ниже, пока не доберемся до последней, которая скажет что в нее попали, и уже у нее вызовем соответствующий ивент на нажатие курсора.

У этого подхода есть ряд минусов. Элементов могут быть тысячи, и на каждый кадр перебирать иерархию довольно дорого. Плюс дети должны быть всегда внутри родителей, иначе ребенок не получит сообщение, т.к. не попали в родителя. Или же придется отказаться от этой оптимизации и просчитывать вообще все дерево, что еще дороже. Здесь еще ряд минусов, такие как сложная реализация "сквозных" ивентов, например когда клик должна принять и прокручиваемая область, и кнопка внутри нее.

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

Поэтому я применил другой подход, нежели обход иерархии. При отрисовке сущности, которая обрабатывает сообщения курсора, она сообщает системе ввода что сущность отрисовалась в данный момент. Система ввода хранит линейный список таких отрисованных сущностей, в порядке их отрисовки. Плюс, как уже писал выше, запоминаем актуальный прямоугольник отсечения для каждой сущности. Так же у каждой такой сущности есть виртуальный метод проверки попадания точки в эту сущность. Этот метод очень простой и работает без учета всяких отсечений и т.п. По сути просто проверка попадания в прямоугольник для IRectDrawable.

Поочередная отрисовка с перекрытиемПоочередная отрисовка с перекрытием

Далее, после отрисовки сцены, происходит "трассирование" каждого курсора. Мы идем с конца списка отрисованных сущностей, от последнего отрисованного к первому, проверяем что курсор попадает в прямоугольник отсечения, проверяем попадание в геометрию. Если все возвращает истину - курсор попадает в эту сущность, алгоритм останавливается. Если нет, берем следующий с конца и снова проверяем.

Далее, имея данные о состоянии нажатия курсора, о том какая сущность была трассирована на предыдущем кадре, мы можем формировать сообщения о кликах, входе или выходе курсора.

Эта система работает быстро и стабильно при большом количестве объектов. При этом пользователю достаточно перегрузить функцию попадания точки в геометрию (что в большинстве случаев даже не нужно, если это IRectDrawable), и в момент отрисовки вызвать функцию IDrawable::OnDrawn(). Далее система автоматически рассылает нужные сообщения.

Иерархия сцены

В своем движке я выбрал типичный подход с иерархичной сценой с компонентами. Базовый элемент - Actor, примитивный объект, у которого есть имя, трансформация, набор компонент, определяющих его поведение, и список дочерних Actor'ов. Они строятся в древовидную структуру на сцене. Сцена - это просто список Actor'ов.

Так же Actor'ы могут быть разных типов. То есть это просто базовых интерфейс, от него можно унаследоваться и реализовать свою логику. Это можно сделать и с помощью компонент, но в отличие в том, что компонента не является главной сущностью, она вспомогательная, и их может быть несколько на Actor'е.

Система UI строиться на базе этой иерархии сцены. То есть все виджеты, все элементы UI - это наследники Actor'а. Но у них есть один общий интерфейс Widget.

Widget

Это самый элементарный "кирпичик" системы интерфейсов. Если Actor описывается простой трансформацией, включающей в себя позицию, поворот, размер и скейл, то положение Widget'а описывается более сложной структурой и уже зависит от родителя.

Положение Widgеt'а описывается структурой WidgetLayout, которая является наследников от ActorTransform. То есть это надстройка над обычными позицией, поворотом, размером и скейлом Actor'а.

Эта надстройка включает в себя относительные якоря и смещения от них. Якоря задаются в процентах и располагаются относительно родителя. Затем к этим якорям прибавляются смещения в пикселях. Это позволяет делать адаптивную верстку. Задавая якоря правильным образом можно добиваться растягивания дочерних элементов вслед за родителем, или наоборот "приклеивать" детей к определенным точкам, углам или центру. Аналогичный принцип применяется в Unity GUI.

WidgetLayer

Помимо обычной структуры дочерних Actor'ов, внутри Widget'а хранится список слоев WidgetLayer. Слой - это очень упрощенный Widget, который имеет на борту IRectDrawable и аналогичную WidgetLayout'у структуру с описанием адаптивного положения слоя WidgetLayerLayout.

Слоев может быть очень много, у слоев могут быть дочерние слои. За счет упрощения достигается оптимизация, т.к. этими более легковесными сущностями гораздо быстрее и проще оперировать.

Их вполне можно заменить дочерними Widget'ами, но это нарушит целостность понятия Widget'а. Например, кнопка воспринимается как кнопка, а не набор дочерних картинок. Окно воспринимается как контейнер других виджетов, и не хочется чтобы фон, заголовок и другие вспомогательные вещи были перемешаны с содержимым окна.

Поэтому слои - это графическая часть Widget'а, отделенная от дочерних Widget'ов.

WidgetState

Интерфейс не должен быть статичен и как-то реагировать на действия пользователя. При наведении курсора кнопка должна подсвечиваться, а при нажатии становиться темной. Списки должны разворачиваться, курсор ввода текста мигать и т.д. Желательно чтобы это все происходило плавно и красиво.

Для этого в Widget'е есть анимированные состояния - WidgetState'ы. Они работают по принципу простой стейт-машины. Каждое состояние - это некая анимация, которая имеет два положения вкл. и выкл. При переводе во вкл. анимация проигрывается вперед, при переключении в выкл. анимация проигрывается назад. Причем, анимация может пойти в другую сторону не дождавшись окончания. За счет этого достигается быстрый отклик на действия пользователя.

Сами анимации вещь довольно сложная и для них можно написать отдельную статью. Но суть у них простая - они могут изменять любой параметр любого объекта внутри иерархии Actor'ов, включая Widget'ы, Layout'ы, WidgetLayer'ы и т.д. Здесь помогает собственная рефлексия, о которой когда-то уже писал. Она позволяет искать нужные параметры по стоковым путям, например children/0/transform/anchor.

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

  • курсор наведен - подсвечиваем кнопку

  • кнопка нажата - затемняем кнопку

  • кнопку в фокусе - показываем рамку вокруг

  • скрытие и показ кнопки - плавное исчезновение и появление

Так как эти состояния работают через анимации, их можно делать сложными и составными, добавлять различные кривые плавности и т.д.

Эти состояния переключаются как изнутри Widget'ов, так и доступны для переключения извне.

Внутренние Widget'ы

Иногда бывает ситуация, когда внутри одного Widget'а может находиться другой Widget, но при этом не хочется чтобы он был виден как дочерний. Например кнопка закрыть в виде крестика на окне. Окно - это отдельный цельный Widget, и кнопка закрыть ощущается как его часть. А дети окна - это уже содержимое собственно окна.

Реализовывать поведение кнопки закрытия можно и через слои, но это сложнее, чем просто добавить кнопку. Поэтому Widget'ы, кроме списка дочерних Widget'ов, имеют список "внутренних" Widget'ов. Которые ведут себя точно так же как и дочерние, разве что не отображаются вместе с реальными дочерними Widget'ами в иерарзии.

Layout-Widget'ы

Это специальные Widget'ы, отвечающие за определенные алгоритмы расположения дочерних Widget'ов. Например, HorizontalLayout раскладывает своих детей в линию по горизонтали. Аналогично работает VerticalLayout, только раскладывает по вертикали. А так же есть GridLayout, который раскладывает равномерной сеткой.

Комбинация Horizontal/VerticalLayoutКомбинация Horizontal/VerticalLayout

У этих Layout'ов можно настраивать точку отсчета (от угла, середины), расстояние между элементами, растягивать ли элементы по горизонтали или вертикали. Эти настройки позволяют покрыть практически все возможные вариации с адаптивной версткой.

Пример адаптивной верстки в редакторе параметровПример адаптивной верстки в редакторе параметров

Алгоритм у этих Layout'ов такой:

  • Рекурсивно рассчитываем размеры дочерних Widget'ов. То есть как бы заглядываем в будущее, какого размера они будут. За основу берем минимальный размер элементов

  • Рассчитываем пространство, которое дочерние элементы могут занять. Берем текущий размер Layout'а, вычитаем все минимальные размеры дочерних элементов, вычитаем промежутки между ними. Получается "пространство, которое можно распределить"

  • Пытаемся распределить это свободное место между элементами. Но если для какого-то элемента этого пространства оказывается слишком много, то есть у него ограничен максимальный размер, он исключается из алгоритма и пространство распределяется уже без него

  • Получив конечные размеры дочерних элементов, им проставляются соответствующие параметры WidgetLayout

Типы Widget'ов

На базе этого простого элемента системы UI строится масса других типов элементов, каждый из них реализует определенную логику. Стоит отметить, что базовый Widget не умеет обрабатывать сообщения ввода. Если Widget должен реагировать на ввод пользователя, он дополнительно наследуется от соответствующих интерфейсов KeyboardEventsListener, CursorAreaEventsListener.

На данный момент поддерживается следующий список типов:

  • Кнопка

  • Checkbox

  • Поле ввода текста, однострочное и многострочное

  • Выпадающий список

  • Список

  • Изображение

  • Надпись

  • Зона прокрутки

  • Горизонтальный/вертикальный progress-bar

  • Горизонтальный/вертикальный scroll bar

  • Спойлер

  • Окно

Попапы

Попап - это часть интерфейса, которая рисуется поверх всего остального. Для них существует отдельный алгоритм отложенной отрисовки.

Все попапы наследуются от общего класса PopupWidget. К попапам так же относятся контекстные меню. Внутри в виде статичной переменной хранится текущий видимый попап. Именно он рисуется в отложенном рендеринге сцены. Так же сам попап может содержать дочерний попап, который будет рисоваться вместе с этим отложенным попапом. Это применяется, например, в контекстных меню: некоторые пункты могут открывать подпункты меню, которые являются дочерними попапами.

Иерархичное контекстное менюИерархичное контекстное меню

Так же попапы умеют автоматически закрываться при клике снаружи, закрывая все свои дочерние попапы.

Бонус: anti-aliased линии

Иногда в редакторе нужно рисовать линии и кривые. Те, кто пользовался OpenGL или D3D, знают, что по умолчанию линии рисуются с "лесенкой". Но я хотел себе гладкие линии.

Есть разные подходы к отрисовке графики а anti-aliasing'ом. Я решил попробовать один простой трюк: рисовать по краям линии полигоны, уходящие в нулевую альфу, то есть в плавно исчезающие. И так как эти края очень маленькие, но все же есть, при растеризации видеокарта выбирает некоторые полупрозрачные пиксели с краев основного тела линии, и получается эффект сглаживания.

Дешево в плане рендеринга, но тяжеловато в расчете таких мешей. Ведь есть еще проблема с острыми углами, и там получается 8 проверок пересечения лучей. Но это уже другая история. В целом результат после "подгона" параметров вполне хорош

Оптимизации

Итак, у нас есть система иерархии элементов, мы можем нарисовать все что угодно, оно умеет обрабатывать ввод и умеет адаптироваться к изменению размера. Графика уже вполне оптимизирована, меши кешируются, и не нужно постоянно перестраивать геометрию. Однако, элементов бывает очень много. Например, в моем редакторе их пара тысяч, плюс по несколько слоев на каждом из них. Да и вообще требования к мобильным играм довольно серьезные, кроме отрисовки и обработки интерфейса еще нужно нарисовать и обработать целую игру, 60 раз в секунду.

При этом и на десктопе редактор должен работать шустро, даже в отладочном режиме. Учитывая что С++ в режиме отладки обычно работает на порядок медленнее.

У меня есть большой опыт работы с Unity3D, и с интерфейсами в нем в частности. Если в Unity3D делать интерфейс "в лоб", то уже довольно скоро все начнет лагать, управление станет не отзывчивым, а процессор перегреется и начнет "троттлить", ухудшая итак плохую производительность. И мне очень хотелось избежать такого же. Моей целью было сделать такую систему, которая при обычных задачах не требовала специальных оптимизаций или разделения на Canvas'ы, чтобы уменьшить перегенерацию мешей, как в Unity3D.

Собственно практически сразу, как я начал делать редактор, я столкнулся с проблемой производительности. И это произошло при разработке окна дерева сцены. Дело в том, что на сцене может быть несколько тысяч Actor'ов, и нужно уметь рисовать иерархию для каждого из них. Первый вариант был простой - для всех развернутых нод дерева создается Widget. Конечно при достижении пары сотен развернутых нод оперирование ими занимало приличное время. Поэтому пришлось отсекать.

Отсечение невидимых Widget'ов

Обычно, если в каком-то месте много Widget'ов, то большинство их них не видны. Это списки, деревья и зоны прокрутки. Очевидно то, что скрыто, можно не рисовать. И даже более - не обсчитывать в некоторых ситуациях.

Для начала отсечение невидимых. Как ни странно, отсечение на уровне графического API решает проблему частично, видеокарта не растеризует отсеченные прямоугольники и пиксели. Однако, мы все еще формируем батчи для них и отсылаем в видеокарту. На этом можно сэкономить.

Чтобы понять, что какой-то Widget скрыт, нужно знать зону отсечения его родителей, а так же свою полную площадь с учетом всех слоев и детей. При изменении трансформации или каких-либо параметров, эта площадь пересчитывается, начиная от самых конечных детей.

Далее, в Widget'е есть специальный метод CheckClipping, который определяет отсечен ли он. Здесь же еще одна оптимизация: проверка на отсечение в основном нужна при прокрутке, то есть при линейных сдвигах. Например, есть зона прокрутки с кучей дочерних Widget'ов. Обычно прокрутка происходит вверх-вниз или влево-вправо, и размеры элементов не меняются. Поэтому достаточно взять площадь элемента и просто сдвинуть ее на величину прокрутки.

Так же в длинных списках или деревьях не создаются Widget'ы под каждый элемент. Создается некий буффер элементов, и при прокрутке показываются только те, что попадают в поле видимости. Это позволяет делать списки с тысячами элементов, без сильных затрат памяти и процессора.

Будущие оптимизации

Сейчас редактор в режиме отладки работает приемлемо, а в релизной сборке практически без лагов. Однако, весь интерфейс перерисовывается каждый кадр, что занимает процессор и видеокарту.

Здесь напрашивается оптимизация на статичные части интерфейса. Их можно отрендерить один раз, и затем кусочно перерисовывать измененные участки. Для этого я планирую применить подход с "грязными" областями. Если Widget как-то изменился, что требует его перерисовки, то он сообщает общей системе что его область загрязнена. Если таких Widget'ов несколько, их площадь суммируется, одним прямоугольников покрывающим все. Далее, если этот итоговый "грязный" прямоугольник нулевой, то просто ничего не рисуем, оставляем на экране предыдущий кадр. Если он не нулевой, то изначально включаем отсечение по этому прямоугольнику, а затем рисуем только те элементы, который с ним пересекаются.

Это будет работать хорошо, когда изменения будут небольшие и не частые, как в редакторе. Но в играх такая оптимизация будет только потреблять накладные ресурсы, т.к. в игровых интерфейсах много эффектов и они более чем вероятно постоянно будут обвновляться.

Редактор

Что было первое, курица или яйцо? Система интерфейсов или редактор этих интерфейсов? Да, редактор написанный на системе интерфейсов позволяет их редактировать. Даже есть отладочный режим, в котором видна иерархия редактора и там можно поисследовать редактор изнутри.

Сейчас редактор умеет практически все что нужно:

  • Отображение иерархии сцены и Widget'ов в том числе. Отображение слоев и внутренних Widget'ов

  • Визуальное окно редактирование верстки

  • Окно настроек параметров Actor'ов

  • Редактор анимаций

Заключение

Сейчас система UI в довольно зрелом состоянии, она хорошо показывает себя в разработке редактора. Но еще много работы по улучшению и правке багов редактора.

В общей сумме на эту систему интерфейсов потрачено примерно 6 человеко-месяцев. Я вполне доволен тем что решил сделать свой велосипед. У меня есть опыт разработки подобного редактора на ImGUI, и могу сказать что чужое решение ограничивает, приходится тратить на его "допиливание" время, сопоставимо с написанием своего велосипеда.

PS: Если кому-то интересно позаниматься разработкой движка для 2D игры, буду рад посотрудничать. Одному получается очень долго :) Репозиторий движка: https://github.com/zenkovich/o2, и тестового проекта на нем: https://github.com/zenkovich/PetStory

Подробнее..

Адаптируем UITableView под MVVM

05.12.2020 16:17:54 | Автор: admin

Введение

UITableView один из самых часто используемых компонентов UIKit. Табличное представление зарекомендовало себя как одно из самых удобных взаимодействий пользователя с контентом представленным на экране смартфона.

На сегодняшний день, каждому iOS разработчику необходимо в совершенстве владеть UITableView, знать тонкости и понимать как его адаптировать под разные архитектуры, чтобы использование не вызывало лишних проблем и трудностей.

В этой статье мы поговорим о том, как адаптировать UITableView под архитектуру Model-View-ViewModel (MVVM). Начнём.

Содержание

  1. Введение

  2. Пример

  3. Реализация

  4. Использование

  5. Результат

  6. Вывод

Пример

В качестве примера я реализовал ячейку с кнопкой, картинкой и текстом.

Реализация

Первым делом создадим подкласс от UITableView и назовем его AdaptedTableView.

class AdaptedTableView: UITableView {    }

Определим метод setup(). Он необходим для конфигурации таблицы. Временно заполним обязательные для реализации методы UITableViewDataSource.

class AdaptedTableView: UITableView {        // MARK: - Public methods        func setup() {        self.dataSource = self    }    }// MARK: - UITableViewDataSourceextension AdaptedTableView: UITableViewDataSource {        func numberOfSections(in tableView: UITableView) -> Int {        .zero    }        func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {        .zero    }        func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {        UITableViewCell()    }    }

Согласно паттерну MVVM, view владеет viewModel. Создадим абстракцию для входных данных и назовем её AdaptedViewModelInputProtocol. AdaptedSectionViewModelProtocol необходим для описания viewModel секции. AdaptedCellViewModelProtocol служит лишь для полиморфизма подтипов наших viewModels для ячеек.

protocol AdaptedCellViewModelProtocol { }protocol AdaptedSectionViewModelProtocol {    var cells: [AdaptedCellViewModelProtocol] { get }}protocol AdaptedViewModelInputProtocol {    var sections: [AdaptedSectionViewModelProtocol] { get }}

Добавляем viewModel. Теперь у нас есть возможность корректно заполнить методы UITableViewDataSource.

class AdaptedTableView: UITableView {        // MARK: - Public properties        var viewModel: AdaptedViewModelInputProtocol?        // MARK: - Public methods        func setup() {        self.dataSource = self    }    }// MARK: - UITableViewDataSourceextension AdaptedTableView: UITableViewDataSource {        func numberOfSections(in tableView: UITableView) -> Int {        viewModel?.sections.count ?? .zero    }        func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {        viewModel?.sections[section].cells.count ?? .zero    }        func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {        guard let cellViewModel = viewModel?.sections[indexPath.section].cells[indexPath.row] else {            return UITableViewCell()        }            // TO DO: - Register cell      // TO DO: - Create cell                return UITableViewCell()    }    }

На данном этапе с AdaptedTableView почти все готов, однако есть еще пару нерешенных вопросов. Регистрация и переиспользование ячеек. Создадим протокол AdaptedCellProtocol, который будут реализовывать все наши подклассы UITableViewCell, добавим метод register(_ tableView:) и reuse(_ tableView:, for indexPath:).

protocol AdaptedCellProtocol {    static var identifier: String { get }    static var nib: UINib { get }    static func register(_ tableView: UITableView)    static func reuse(_ tableView: UITableView, for indexPath: IndexPath) -> Self}extension AdaptedCellProtocol {        static var identifier: String {        String(describing: self)    }        static var nib: UINib {        UINib(nibName: identifier, bundle: nil)    }        static func register(_ tableView: UITableView) {        tableView.register(nib, forCellReuseIdentifier: identifier)    }        static func reuse(_ tableView: UITableView, for indexPath: IndexPath) -> Self {        tableView.dequeueReusableCell(withIdentifier: identifier, for: indexPath) as! Self    }    }

Для порождения ячеек создадим протокол фабричного метода AdaptedCellFactoryProtocol.

protocol AdaptedCellFactoryProtocol {    var cellTypes: [AdaptedCellProtocol.Type] { get }    func generateCell(viewModel: AdaptedCellViewModelProtocol, tableView: UITableView, for indexPath: IndexPath) -> UITableViewCell}

Добавим поле cellFactory и в didSet поместим регистрацию всех ячеек.

class AdaptedTableView: UITableView {        // MARK: - Public properties        var viewModel: AdaptedViewModelInputProtocol?    var cellFactory: AdaptedCellFactoryProtocol? {        didSet {            cellFactory?.cellTypes.forEach({ $0.register(self)})        }    }        ...    }

Исправим метод делегата.

extension AdaptedTableView: UITableViewDataSource {        ...        func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {        guard            let cellFactory = cellFactory,            let cellViewModel = viewModel?.sections[indexPath.section].cells[indexPath.row]        else {            return UITableViewCell()        }                return cellFactory.generateCell(viewModel: cellViewModel, tableView: tableView, for: indexPath)    }    }

Использование

С необходимы абстракциями на этом все, пора перейти к конкретным реализациям.

1. Ячейка

В качестве примера я создам ячейку с лейблом по центру и viewModel к ней. Реализация ячейки с кнопкой и картинкой.

protocol TextCellViewModelInputProtocol {    var text: String { get }}typealias TextCellViewModelType = AdaptedCellViewModelProtocol & TextCellViewModelInputProtocolclass TextCellViewModel: TextCellViewModelType {        var text: String        init(text: String) {        self.text = text    }    }final class TextTableViewCell: UITableViewCell, AdaptedCellProtocol {        // MARK: - IBOutlets        @IBOutlet private weak var label: UILabel!        // MARK: - Public properties        var viewModel: TextCellViewModelInputProtocol? {        didSet {            bindViewModel()        }    }        // MARK: - Private methods        private func bindViewModel() {        label.text = viewModel?.text    }    }

2. Cекция

class AdaptedSectionViewModel: AdaptedSectionViewModelProtocol {        // MARK: - Public properties      var cells: [AdaptedCellViewModelProtocol]        // MARK: - Init        init(cells: [AdaptedCellViewModelProtocol]) {        self.cells = cells    }    }

3. Фабрика

struct MainCellFactory: AdaptedSectionFactoryProtocol {        var cellTypes: [AdaptedCellProtocol.Type] = [        TextTableViewCell.self    ]        func generateCell(viewModel: AdaptedCellViewModelProtocol, tableView: UITableView, for indexPath: IndexPath) -> UITableViewCell {        switch viewModel {        case let viewModel as TextCellViewModelType:            let view = TextTableViewCell.reuse(tableView, for: indexPath)            view.viewModel = viewModel            return view        default:            return UITableViewCell()        }    }    }

Ну и напоследок нам понадобится viewModel самого модуля.

final class MainViewModel: AdaptedSectionViewModelType {        // MARK: - Public properties        var sections: [AdaptedSectionViewModelProtocol]        // MARK: - Init        init() {        self.sections = []                self.setupMainSection()    }        // MARK: - Private methods        private func setupMainSection() {        let section = AdaptedSectionViewModel(cells: [            TextCellViewModel(text: "Hello!"),            TextCellViewModel(text: "It's UITableView with using MVVM")        ])        sections.append(section)    }    }

Все готово, пора добавить UITableView на ViewController, установив в качестве custom class наш AdaptedTableView.

В реальном проекте, MVVM очень часто используют с каким-то паттерном навигации, это может быть координатор или роутер. В зону ответственности таких объектов входит DI (Dependency Injection) внедрение всех необходимых модулю зависимостей. Так как это тестовый проект, я захардкодил viewModel и cellFactory прямо во ViewController.

class ViewController: UIViewController {        // MARK: - IBOutlets        @IBOutlet weak var tableView: AdaptedTableView! {        didSet {            tableView.viewModel = MainViewModel()            tableView.cellFactory = MainCellFactory()                        tableView.setup()        }    }    }

Результат

Вывод

В итоге мы получили решение, которое позволяет удобно использовать UITableView с MVVM. Стало очень просто работать с секциями, настраивать ячейки, писать меньше шаблонного кода. В то же время осталась возможность настройки таблицы и расширения функционала при необходимости.


Весь код представленный в этой статье можно скачать по этой ссылке.

Подробнее..

Подходы к спискам на UICollectionView

14.04.2021 18:22:15 | Автор: admin

Введение

Уже давным давно, во всех известных нам галактиках мобильные приложения представляют информацию в виде списков - будь то доставка еды на Татуине, имперская почта или обычный ежедневник джедая. С незапамятных времен мы писали UI на UITableView и не задумывались.

Копились бесчисленные баги и знания об устройстве этого инструмента и о лучших практиках. И когда мы получили очередной infinite scroll дизайн, мы поняли: пришло время задуматься и дать отпор тирании UITableViewDataSource и UITableViewDelegate.

Почему коллекция?

До сих пор коллекции пребывали в тени, многие побаивались их чрезмерной гибкости или считали их функционал избыточным.

В самом деле, почему бы просто не использовать стек или таблицу? Если для первого мы быстро упремся в низкую производительность, то со вторым в отсутсвие гибкости при реализации лейаута элементов.

Так ли страшны коллекции и какие подводные камни они в себе таят? Мы сравнили.

  • Ячейки в таблице содержат лишние элементы: content view, group editing view, slide actions view, accessory view.

  • Использование UICollectionView дает единообразность при работе с любыми списками объектов, так как ее API в целом схож с UITableView.

  • Коллекция позволяет применять нестандартные виды лейаута, а так же связанные с ним атрибуты анимированных транзишнов.

Так же у нас были некоторые опасения:

  • Возможность использовать Pull to refresh

  • Отсутсвие лагов при отрисовке

  • Возможность скролла в ячейках

Но в ходе реализации все они развеялись.

Избавившись от класса таблицы, мы смогли написать расширяемый для целого семейства списков адаптер с возможностью в любой момент безболезненно вернуться к таблице под капотом.

Адаптеры

Коллекции это, конечно, хорошо, но пробовали ли вы избавиться от привычного боилерплейта с датасорсами и делегатами, чтобы создание экранного списка занимало не больше 10 строк? Для сравнения, вспомним классическую реализацию экрана со списком на UITableView.

final class CurrencyViewController: UIViewController {    var tableView = UITableView()    var items: [ViewModel] = []    func setup() {        tableView.delegate = self        tableView.dataSource = self        tableView.backgroundColor = .white    tableView.rowHeight = 72.0                        tableView.contentInset = .init(top: Constants.topSpacing, left: 0, bottom: Constants.bottomSpacing, right: 0)        tableView.reloadData()    }}extension CurrencyViewController: UITableViewDelegate {    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {        output.didSelectBalance(at: indexPath.row)    }}extension CurrencyViewController: UITableViewDataSource {    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {        items.count    }    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {                let cell = tableView.dequeueReusable(cell: object.cellClass, at: indexPath)        cell.setup(with: object)                return cell    }}extension UITableView {    func dequeueReusable(cell type: UITableViewCell.Type, at indexPath: IndexPath) -> UITableViewCell {        if let cell: UITableViewCell = self.dequeueReusableCell(withIdentifier: type.name()) {            return cell        }        self.register(cell: type)        let cell: UITableViewCell = self.dequeueReusableCell(withIdentifier: type.name(), for: indexPath)        return cell    }    private func register(cell type: UITableViewCell.Type) {        let identifier: String = type.name()                self.register(type, forCellReuseIdentifier: identifier)     }}

Приходят на помощь джедаи адаптеры.

Напомним, что паттерн адаптер наделяет исходный объект новым интерфейсом, с которым в данном контексте удобно работать. Наш адаптер конечно лишь этим не ограничился.

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

private let listAdapter = CurrencyVerticalListAdapter()private let collectionView = UICollectionView(    frame: .zero,    collectionViewLayout: UICollectionViewFlowLayout())private var viewModel: BalancePickerViewModelfunc setup() {    listAdapter.setup(collectionView: collectionView)    collectionView.backgroundColor = .c0    collectionView.contentInset = .init(top: Constants.topSpacing, left: 0, bottom: Constants.bottomSpacing, right: 0)    listAdapter.onSelectItem = output.didSelectBalance    listAdapter.heightMode = .fixed(height: 72.0)    listAdapter.spacing = 8.0    listAdapter.reload(items: viewModel.items)}

Однако внутри адаптер представляет собой даже не один класс.

Рассмотрим для начала базовый (и вообще говоря абстрактный) класс адаптера списков:

public class ListAdapter<Cell> : NSObject, ListAdapterInput, UICollectionViewDataSource, UICollectionViewDelegate, UICollectionViewDragDelegate, UICollectionViewDropDelegate, UIScrollViewDelegate where Cell : UICollectionViewCell, Cell : DesignKit.AnimatedConfigurableView, Cell : DesignKit.RegistrableView {    public typealias Model = Cell.Model    public typealias ResizeCallback = (_ insertions: [Int], _ removals: [Int], _ skipNext: Bool) -> Void    public typealias SelectionCallback = ((Int) -> Void)?    public typealias ReadyCallback = () -> Void    public enum DragAndDropStyle {        case reorder        case none    }    public var dragAndDropStyle: DragAndDropStyle { get set }    internal var headerModel: ListHeaderView.Model?    public var spacing: CGFloat    public var itemSizeCacher: UICollectionItemSizeCaching?    public var onSelectItem: ((Int) -> Void)?    public var onDeselectItem: ((Int) -> Void)?    public var onWillDisplayCell: ((Cell) -> Void)?    public var onDidEndDisplayingCell: ((Cell) -> Void)?    public var onDidScroll: ((CGPoint) -> Void)?    public var onDidEndDragging: ((CGPoint) -> Void)?    public var onWillBeginDragging: (() -> Void)?    public var onDidEndDecelerating: (() -> Void)?    public var onDidEndScrollingAnimation: (() -> Void)?    public var onReorderIndexes: (((Int, Int)) -> Void)?    public var onWillBeginReorder: ((IndexPath) -> Void)?    public var onReorderEnter: (() -> Void)?    public var onReorderExit: (() -> Void)?    internal func subscribe(_ subscriber: AnyObject, onResize: @escaping ResizeCallback)    internal func unsubscribe(fromResize subscriber: AnyObject)    internal func subscribe(_ subscriber: AnyObject, onReady: @escaping ReadyCallback)    internal func unsubscribe(fromReady subscriber: AnyObject)    internal weak var collectionView: UICollectionView?    public internal(set) var items: [Model] { get set }    public func setup(collectionView: UICollectionView)    public func setHeader(_ model: ListHeaderView.Model)    public subscript(index: Int) -> Model? { get }    public func reload(items: [Model], needsRedraw: Bool = true)    public func insertItem(_ item: Model, at index: Int, allowDynamicModification: Bool = true)    public func appendItem(_ item: Model, allowDynamicModification: Bool = true)    public func deleteItem(at index: Int, allowDynamicModification: Bool = true)    public func deleteItemsIfNeeded(at range: PartialRangeFrom<Int>)    public func deleteItems(at indexes: [Int], allowDynamicModification: Bool = true)    public func updateItem(_ item: Model, at index: Int, allowDynamicModification: Bool = true)    public func reloadItems(_ newItems: [Model], at range: PartialRangeFrom<Int>, allowDynamicModification: Bool = true)    public func reloadItems(_ newItems: [Model], at indexes: [Int], allowDynamicModification: Bool = true)    public func reloadItems(_ newItems: [(index: Int, element: Model)], allowDynamicModification: Bool = true)    public func moveItem(at index: Int, to newIndex: Int)    public func performBatchUpdates(updates: @escaping (ListAdapter) -> Void, completion: ((Bool) -> Void)?)    public func performBatchUpdates(updates: () -> Void, completion: ((Bool) -> Void)?)    }public typealias ListAdapterCellConstraints = UICollectionViewCell & RegistrableView & AnimatedConfigurableViewpublic typealias VerticalListAdapterCellConstraints = ListAdapterCellConstraints & HeightMeasurableViewpublic typealias HorizontalListAdapterCellConstraints = ListAdapterCellConstraints & WidthMeasurableView

Таким образом, внутри конкретного экрана нужно выполнить только минимальную настройку. Благодаря этому код станет проще для восприятия.

Как можно увидеть из примера выше: сначала идёт блок typealias'ов для того, чтобы определить ограничения на используемые типы.

DragAndDropStyle отвечает за возможность менять местами ячейки внутри коллекции.

headerModel - модель, которая представляет заголовок коллекции

spacing - расстояние между элементами

Дальше идёт блок замыканий, которые позволяют подписаться на определённые изменения в коллекции.

Методы для подписки onReady и onResize позволяют понять, когда коллекция адаптера стала готова к работе, и когда изменился размер коллекции из-за добавления или удаления объектов, соответственно.

collectionView, setup(collectionView:) - непосредственно используемый экземпляр коллекции и метод для её установки

items - набор моделей для отображения

setHeader - метод для установки заголовка коллекции

itemSizeCacher - класс, реализующий кеширование размеров элементов списка. Дефолтная реализация представлена ниже:

final class DefaultItemSizeCacher: UICollectionItemSizeCaching {        private var sizeCache: [IndexPath: CGSize] = [:]        func itemSize(cachedAt indexPath: IndexPath) -> CGSize? {        sizeCache[indexPath]    }        func cache(itemSize: CGSize, at indexPath: IndexPath) {        sizeCache[indexPath] = itemSize    }        func invalidateItemSizeCache(at indexPath: IndexPath) {        sizeCache[indexPath] = nil    }        func invalidate() {        sizeCache = [:]    }    }

Остальную часть интерфейса представляют методы для обновления элементов.

Также есть конкретные реализации, которые, например, заточены под определенное расположение ячеек по оси.

AnyListAdapter

До тех пор, пока мы работаем с динамическим контентом, все хорошо. Но во введении мы не зря говорили про infinite-scroll дизайн. Что делать, если в таблице нужно одновременно отображать и ячейки динамического контента(данные из сети) и статические вью? Для этого нам послужит AnyListAdapter.

public typealias AnyListSliceAdapter = ListSliceAdapter<AnyListCell>public final class AnyListAdapter : ListAdapter<AnyListCell>, UICollectionViewDelegateFlowLayout {    public var dimensionCalculationMode: DesignKit.AnyListAdapter.DimensionCalculationMode    public let axis: Axis    public init<Cell>(dynamicCellType: Cell.Type) where Cell : UICollectionViewCell, Cell : DesignKit.AnimatedConfigurableView, Cell : DesignKit.HeightMeasurableView, Cell : DesignKit.RegistrableView    public init<Cell>(dynamicCellType: Cell.Type) where Cell : UICollectionViewCell, Cell : DesignKit.AnimatedConfigurableView, Cell : DesignKit.RegistrableView, Cell : DesignKit.WidthMeasurableView}public extension AnyListAdapter {    convenience public init<C1, C2>(dynamicCellTypes: (C1.Type, C2.Type)) where C1 : UICollectionViewCell, C1 : DesignKit.AnimatedConfigurableView, C1 : DesignKit.HeightMeasurableView, C1 : DesignKit.RegistrableView, C2 : UICollectionViewCell, C2 : DesignKit.AnimatedConfigurableView, C2 : DesignKit.HeightMeasurableView, C2 : DesignKit.RegistrableView    convenience public init<C1, C2, C3>(dynamicCellTypes: (C1.Type, C2.Type, C3.Type)) where C1 : UICollectionViewCell, C1 : DesignKit.AnimatedConfigurableView, C1 : DesignKit.HeightMeasurableView, C1 : DesignKit.RegistrableView, C2 : UICollectionViewCell, C2 : DesignKit.AnimatedConfigurableView, C2 : DesignKit.HeightMeasurableView, C2 : DesignKit.RegistrableView, C3 : UICollectionViewCell, C3 : DesignKit.AnimatedConfigurableView, C3 : DesignKit.HeightMeasurableView, C3 : DesignKit.RegistrableView    convenience public init<C1, C2>(dynamicCellTypes: (C1.Type, C2.Type)) where C1 : UICollectionViewCell, C1 : DesignKit.AnimatedConfigurableView, C1 : DesignKit.RegistrableView, C1 : DesignKit.WidthMeasurableView, C2 : UICollectionViewCell, C2 : DesignKit.AnimatedConfigurableView, C2 : DesignKit.RegistrableView, C2 : DesignKit.WidthMeasurableView    convenience public init<C1, C2, C3>(dynamicCellTypes: (C1.Type, C2.Type, C3.Type)) where C1 : UICollectionViewCell, C1 : DesignKit.AnimatedConfigurableView, C1 : DesignKit.RegistrableView, C1 : DesignKit.WidthMeasurableView, C2 : UICollectionViewCell, C2 : DesignKit.AnimatedConfigurableView, C2 : DesignKit.RegistrableView, C2 : DesignKit.WidthMeasurableView, C3 : UICollectionViewCell, C3 : DesignKit.AnimatedConfigurableView, C3 : DesignKit.RegistrableView, C3 : DesignKit.WidthMeasurableView}public extension AnyListAdapter {    public enum Axis {        case horizontal        case vertical    }    public enum DimensionCalculationMode {        case automatic        case fixed(constant: CGFloat? = nil)    }}

Как не трудно догадаться, AnyListAdapter абстрагируется от конкретного типа ячейки. Его можно проинициализировать несколькими типами ячеек, но они все должны быть либо для горизонтального лейаута, либо вертикального. Условием здесь выступает удовлетворение протоколу HeightMeasurableView и WidthMeasurableView.

public protocol HeightMeasurableView where Self: ConfigurableView {    static func calculateHeight(model: Model, width: CGFloat) -> CGFloat    func measureHeight(model: Model, width: CGFloat) -> CGFloat   }public protocol WidthMeasurableView where Self: ConfigurableView {    static func calculateWidth(model: Model, height: CGFloat) -> CGFloat    func measureWidth(model: Model, height: CGFloat) -> CGFloat}

У списка так же фиксируется алгоритм подсчета высоты:

  • фиксированный(константа или статический метод расчета по модели)

  • автоматический (на основе лейаута).

Сила вся внутри ячейки-контейнера AnyListCell спрятана.

public class AnyListCell: ListAdapterCellConstraints {        // MARK: - ConfigurableView        public enum Model {        case `static`(UIView)        case `dynamic`(DynamicModel)    }        public func configure(model: Model, animated: Bool, completion: (() -> Void)?) {        switch model {        case let .static(view):            guard !contentView.subviews.contains(view) else { return }                        clearSubviews()            contentView.addSubview(view)            view.layout {                $0.pin(to: contentView)            }        case let .dynamic(model):            model.configure(cell: self)        }        completion?()    }        // MARK: - RegistrableView        public static var registrationMethod: ViewRegistrationMethod = .class        public override func prepareForReuse() {        super.prepareForReuse()                clearSubviews()    }        private func clearSubviews() {        contentView.subviews.forEach {            $0.removeFromSuperview()        }    }    }

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

Первая как раз отвечает за отображение в списке обычных вью.

Вторая же оборачивает в себя модель, конфигуратор и подсчет высоты, стирая при этом сам тип ячейки. В действительности отсюда и префикс в названии как ячейки, так и самого адаптера: Any.

struct DynamicModel {    public init<Cell>(model: Cell.Model,                    cell: Cell.Type) {            // ...    }    func dequeueReusableCell(from collectionView: UICollectionView, for indexPath: IndexPath) -> UICollectionViewCell    func configure(cell: UICollectionViewCell)    func calcucalteDimension(otherDimension: CGFloat) -> CGFloat    func measureDimension(otherDimension: CGFloat) -> CGFloat}

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

private let listAdapter = AnyListAdapter(    dynamicCellTypes: (CommonCollectionViewCell.self, OperationCell.self))func configureSearchResults(with model: OperationsSearchViewModel) {    var items: [AnyListCell.Model] = []    model.sections.forEach {        let header = VerticalSectionHeaderView().configured(with: $0.header)        items.append(.static(header))        switch $0 {        case .tags(nil), .operations(nil):            items.append(                .static(OperationsNoResultsView().configured(with: Localisation.feed_search_no_results))            )        case let .tags(models?):            items.append(                contentsOf: models.map {                    .dynamic(.init(                        model: $0,                        cell: CommonCollectionViewCell.self                    ))                }            )        case .operations(let models?):            items.append(                contentsOf: models.map {                    .dynamic(.init(                        model: $0,                        cell: OperationCell.self                    ))                }            )        }    }    UIView.performWithoutAnimation {        listAdapter.deleteItemsIfNeeded(at: 0...)        listAdapter.reloadItems(items, at: 0...)    }}

Таким образом, стало легко строить экраны, на которых весь контент группируется в бесконечном списке, при этом не теряя производительности переиспользования ячеек.

Список по кускам

Только что мы рассмотрели экран, который натуральным образом поделен на секции. Возникает вопрос, на сколько удобно работать с секциями в плане индексации.

Сам по себе AnyListAdapter не дает удобного решения. Очень легко наткнуться на NSInternalInconsistencyException или удалить элемент не из той секции. Поиск причины этой ошибки может занять время.

Для того, чтобы обезопасить себя при работе с вставкой/удалением/обновлением элементов, мы используем концепцию слайсов по аналогии с ArraySlice, представленным в стандартной библиотеке языка Swift.

Целью было сделать похожий интерфейс для работы с секциями списка изолированно, например, в своем собственном контроллере.

Приведем пример сложного экрана.

let subjectsSectionHeader = SectionHeaderView(title: "Subjects")let pocketsSectionHeader = SectionHeaderView(title: "Pockets")let cardsSectionHeader = SectionHeaderView(title: "Cards")let categoriesHeader = SectionHeaderView(title: "Categories")let list = AnyListAdapter()listAdapter.reloadItems([    .static(subjectsSectionHeader),    .static(pocketsSectionHeader)    .static(cardsSectionHeader),    .static(categoriesHeader)])

Теперь распределим эти секции по контроллерам. Для простоты рассмотрим лишь один, так как остальные будут похожими на него.

class PocketsViewController: UIViewController {    var listAdapter: AnyListSliceAdapter! {        didSet {reload()        }    }    var pocketsService = PocketsService()    func reload() {        pocketsService.fetch { pockets, error in            guard let pocket = pockets else { return }            listAdapter.reloadItems(                pockets.map { .dynamic(.init(model: $0, cell: PocketCell.self)) },                at: 1...            )        }    }    func didTapRemoveButton(at index: Int) {listAdapter.deleteItemsIfNeeded(at: index)    }}let subjectsVC = PocketsViewController()subjectsVC.listAdapter = list[1..<2]

На последней строчке мы и получаем кусок списка: в этот момент происходит определение его границ и привязка к событиям родительского списка.

public extension ListAdapter {    subscript(range: Range<Int>) -> ListSliceAdapter<Cell> {        .init(listAdapter: self, range: range)    }    init(listAdapter: ListAdapter<Cell>,               range: Range<Int>) {        self.listAdapter = listAdapter        self.sliceRange = range        let updateSliceRange: ([Int], [Int], Bool) -> Void = { [unowned self] insertions, removals, skipNextResize in            self.handleParentListChanges(insertions: insertions, removals: removals)            self.skipNextResize = skipNextResize        }        let enableWorkingWithSlice = { [weak self] in            self?.onReady?()            return        }        listAdapter.subscribe(self, onResize: updateSliceRange)        listAdapter.subscribe(self, onReady: enableWorkingWithSlice)    }}

Теперь работать с секцией списка можно ничего не зная об оригинальном списке и не беспокоясь о правильности индексации.

Кроме данных о рендже слайса, интерфейс слайс адаптера мало чем отличается от оригинального ListAdapter.

public final class ListSliceAdapter<Cell> : ListAdapterInput where Cell : UICollectionViewCell, Cell : ConfigurableView, Cell : RegistrableView {    public var items: [Model] { get }    public var onReady: (() -> Void)?    internal private(set) var sliceRange: Range<Int> { get set }    internal init(listAdapter: ListAdapter<Cell>, range: Range<Int>)    convenience internal init(listAdapter: ListAdapter<Cell>, index: Int)    public subscript(index: Int) -> Model? { get }    public func reload(items: [Model], needsRedraw: Bool = true)    public func insertItem(_ item: Model, at index: Int, allowDynamicModification: Bool = true)    public func appendItem(_ item: Model, allowDynamicModification: Bool = true)    public func deleteItem(at index: Int, allowDynamicModification: Bool = true)    public func deleteItemsIfNeeded(at range: PartialRangeFrom<Int>)    public func deleteItems(at indexes: [Int], allowDynamicModification: Bool = true)    public func updateItem(_ item: Model, at index: Int, allowDynamicModification: Bool = true)    public func reloadItems(_ newItems: [Model], at range: PartialRangeFrom<Int>, allowDynamicModification: Bool = true)    public func reloadItems(_ newItems: [Model], at indexes: [Int], allowDynamicModification: Bool = true)    public func reloadItems(_ newItems: [(index: Int, element: Model)], allowDynamicModification: Bool = true)    public func moveItem(at index: Int, to newIndex: Int)    public func performBatchUpdates(updates: () -> Void, completion: ((Bool) -> Void)?)}

Нетрудно догадаться, что внутри проксирующих методов происходит математика индексов.

public func deleteItemsIfNeeded(at range: PartialRangeFrom<Int>) {    guard canDelete(index: range.lowerBound) else { return }    let start = globalIndex(of: range.lowerBound)    let end = sliceRange.upperBound - 1    listAdapter.deleteItems(at: Array(start...end))}

При этом ключевую роль играет поддержка кусков внутри самого ListAdapter.

public class ListAdapter {    // ...    var resizeSubscribers = NSMapTable<AnyObject, NSObjectWrapper<ResizeCallback>>.weakToStrongObjects()}extension ListAdapter {public func appendItem(_ item: Model) {        let index = items.count               let changes = {            self.items.append(item)            self.handleSizeChange(insert: self.items.endIndex)            self.collectionView?.insertItems(at: [IndexPath(item: index, section: 0)])        }                if #available(iOS 13, *) {            changes()        } else {            performBatchUpdates(updates: changes, completion: nil)        }    }    func handleSizeChange(removal index: Int) {        notifyAboutResize(removals: [index])    }    func handleSizeChange(insert index: Int) {        notifyAboutResize(insertions: [index])    }    func notifyAboutResize(insertions: [Int] = [], removals: [Int] = [], skipNextResize: Bool = false) {        resizeSubscribers            .objectEnumerator()?            .allObjects            .forEach {                ($0 as? NSObjectWrapper<ResizeCallback>)?.object(insertions, removals, skipNextResize)            }    }    func shiftSubscribers(after index: Int, by shiftCount: Int) {        guard shiftCount > 0 else { return }        notifyAboutResize(            insertions: Array(repeating: index, count: shiftCount),            skipNextResize: true        )    }}

То есть при каждом добавлении и удалении элемента из исходного списка мы уведомляем всех подписчиков твиттера совета джедаев об изменении размера коллекции.

Выводы

Не помешает убедиться, что все это было не зря, поэтому освежим в памяти полученные бенифиты. Во-первых, мы получили единый интерфейс для разных видов списков. В том числе с разным лейаутом: горизонтальный и вертикальный. Если под капотом нас вдруг не устроит производительность (или баги новой iOS) у UICollectionView, то легко сможем поддержать тот же протокол и для таблиц.

И, что для ленивых самое важное - сетап экрана со списком занимает меньше 10 строк кода.

Если мы раньше боялись усложнять экран работой с таблицей для отображения разнородных данных, то сейчас смело пишем каждый третий экран( ~30%) на списках, вооружившись одним из нашего обширного арсенала адаптеров. А если хотите в модульную декомпозицию - то к вашим услугам адаптеры для куска списка.

Теперь вы с легкостью найдете любимый фрукт джогон в удобном поиске приложения доставки еды на Татуине, а если вы джедай - то без задержек доскролите до конца список заданий от магистра Йоды.

Подробнее..

Устройство UI в iOS

26.09.2020 20:04:42 | Автор: admin

Всем все еще 404, сегодня мы ныряем в наш всеми любимый U, а если быть точнее в Фреймворк UIKit. Кратко, UIKit - UI фреймворк позволяющий облегчить для разработчиков процесс создания интерфейса для взаимодействия с пользователем.Но несмотря на то, что UIKit содержит в себе огромное кол-во функциональности, его размер исчисляется в десятках килобайт. Причиной тому является факт, что UIKit в современном iOS это по сутиumbrella header, предоставляющий единую точку импорта.

Ввод, как он есть.

UIKit содержит в себе все необходимые компоненты для предоставления доступа к девайсам, через которые пользователь общается с вашим приложением. Это и акселероментры, и хардварные кнопки, внешние клавиатуры, специальные устройства ввода для людей с ограниченными способностями, мыши и карандаши (Apple Pencil).

Не стоит забывать, что помимо перечисленных выше устройстов ввода, UIKit получает и обрабатывает массу информации от системы, начиная с низкоуровневых событий жизненного цикла приложения и memory warnings, заканчивая Push-уведомлениями уровнем повыше.

Для того чтобы эффективно обслуживать такое большое количество входящих источников событий UIKit'у нужен Event Loop, который мы привыкли называтьRunLoop. Тут UIKit вводит понятие главного потока, последовательного обслуживающего входящие источники и наш код. Принято считать, что главный поток это что-то неотъемлемое, что есть у приложения, но на самом деле это абстракция, которую вводит и предоставляет именно UIKit.

Может показаться, что знание RunLoop'а это что-то хардкорное и вовсе не нужное обычным разработчикам знание, но это не так. Понимание того, как UIKit обслуживает входящие события и открисовку UI важно для некоторых оптимизаций. Например, довольно частой задачей может быть добавление таймера для некоторых целей. Опытные разработчики могли встречаться таким эффектом, что таймер работает корректно и отсчитывает время до тех пор, пока пользователь не начинит скролить таблицу. В этот момент таймер просто перестаёт работать. Дело тут вовсе не в нехватке ресурсов девайса, а в том, что все таймеры обслуживаются RunLoop'ом, который в момент скрола переводится UIKit'ом в режимUI Tracking Mode. В этом режиме он отдает приоритет отрисовке UI, оставляя в очереди события из некоторых источников.

Но как там с выводом, то?

Нам доступен экран и Haptic. Следуя определению UI фреймворка можно было бы возразить, что пользователь может взаимодействовать с приложением и с помощью звука, и было бы логично отнести эту часть взаимодействия тоже в UIKit. Но в силу сложности работы с аудио, разработчики Apple выделили это в отдельный фреймворк Core Audio.

Основную часть времени работы над пользовательским интерфейсом разработчик, так или иначе тратит на графический интерфейс. Работая с графикой в iOS, как и на большинстве других платформ, мы имеем дело 2D пространством и прямоугольниками, которые как-то комбинируются и располагаются на экране. Сама абстракция прямоугольных областей очень удобна: с одной стороны это очень понятная схема для разработчиков, с другой стороны, очень понятная хардварной части и GPU. Работая с такими прямоугольниками перед разработчиком всегда стоит две задачи:расположитьэти элементы на экране инарисоватьих.

Сказал А, говори Layout.

Для решения первой задачи UIKit использует тривиальную и очень удобную структуру данных дерево. Ни для кого не секрет, что view'шки можно организовывать в иерархию, бесконечно добавляя subview для subview. В конечном мы будем иметь дело с обычным деревом. Его легко и просто обходить, а использование такой структуры нам дает классную возможность верстать относительно, указывая значения координат родительского элемента, а не абсолютное значение на экране.

Здесь можно возразить и сказать, что мы уже давно не верстаем на фреймах, а используем autolayout или 3rd-party библиотеки для верстки. С этим трудно не согласиться, но все системы верстки для iOS сводятся к одному в конечном итоге они проставляют фреймы, разница только в томкогда они их считаютив какой момент присваиваютэлементам.

Помимо ручного расчета абсолютных величин для фреймов или использования autolayout для версткисуществует еще и третий встроенный в iOS метод верстки. Если спуститься на уровень ниже от UIView касаемо отрисовки элементов, мы попадем на слой Core Animation, который позволяет c помощью свойстваanchorPointспозиционировать элементы используя относительные координаты и указывать позицию элемента в процентах от родительской.

Расположили. Теперь порисуем.

Расположить прямоугольники в нужном порядке и в нужном месте только половина дела. Теперь в них нужно еще и что-то нарисовать. Для решения этой задачи Apple разработала целый фреймворк, первоначально назвав егоLayerKit, а после переименовала вCore Animation.

НазваниеCore Animationчасто вводит в заблуждение многие думают что этот фреймворк исключительно для создания анимаций, но это не так. Дело в том, что он ответственный вообще за любое отображение всего и вся на экране устройства, но спроектирован таким образом, что по-умолчанию любые изменения пытается анимировать. В отличии от большинства других, где нужно писать дополнительный код чтобы сделать что-то анимировано, в Core Animation нужно писать код, чтобы изменения прошли без анимации.

Core Animation предоставляет абстракцию, называемую слои. Почти любая UIView содержит в себе слой CALayer, который используется для отрисовки графического интерфейса. Слои, как и view, организуются в иерархию. Тут следует уточнить: несмотря на то, что принято считать именно UIView строительным кирпичиком UI, она по сути является фасадом для CALayer. При добавлении дочерней view, под капотом происходит добавление дочернего слоя на родительский. Все измененияframe,bounds,center,backgroundColorи многих прочих просто проксируются в CALayer.

Таким образом UIView разделяет отвественности: иерархия UIView ответственна за User Interaction, а иерархия CALayer за графическое представление.

Core Animation используется не только на iOS с UIKit дляUIView, но и на macOS с AppKit с еёNSView. В macOS система коодинат отличается от iOS: начало ее коодинат нижний левый угол, против верхнего левого в iOS. Для кросплатформенной работы Core Animation Apple предоставляет свойствоgeometryFlippedу CALayer. Система коодинат macOS является системой по умолчанию, а UIKit проставляетgeometryFlipped = trueвсем слоям при создании. Но возможны случае, когда созданому слою нужно будет указать значение этого свойства вручную, например, при добавлении слоёв на слой с видеоплеером.

Как уже говорилось ранее, Core Animation вводит понятие слоёв, из которых можно собрать визуальное представление программы. Самый базовый класс, CALayer позволяет только закрасить себя каким-то цветом или отобразить CoreGraphics контент. Для решения более сложных задач существуют специализированные слои, такие какCAShapeLayer,CATextLayer,CAGradientLayerи другие. Эти типы слоёв позволяют решить ту или иную задачу эффективным способом, проводя рисование на GPU.

Тут стоит прояснить разницу между использованием специализированных слоёв и рисованием произвольной графики, используя метод UIViewdraw(in:). Как уже было сказано ранее, специализированные слои позволяют отрисовать контент оптимизированным способом на GPU, в то время как используяdraw(in:)разработчик будет прибегать к рисованию с помощьюCoreGraphics, который работает на CPU. Такой подход может приводить к фризам UI. Конечно, CoreGraphics можно пользоваться не из главного потока (не забывая то, что он не потокобезопасный), но стоит всегда помнить что он загружает CPU.

Осталось самое сладкое - анимации

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

Неявное становится явным

Так происходит, потому что CoreAnimation запускает неявные анимации, делая это автоматически, без каких-либо усилий разработчика. Чтобы понять почему так происходит, нужно сначала рассказать про CATransaction. CATransaction это контейнер, который инкапсулирует группу анимаций, управляет их длительностью и таймингом. UIKit создает корневой CATransaction в начале каждого вращения RunLoop'а, а в конце отправляет его на рендер. Именно по этому, любое изменение свойств слоёв упаковано в анимацию. Довольно часто стандартная анимация может не подходить разработчику, в том случае можно создать свой CATransaction, настроить скорость и указать тайминг функцию.

Описанная логика работы CALayer идет вразрез факту о том, что UIView является всего-лишь прокси для слоя. Ведь при измененииframeу UIView его положение и размер меняются мгновенно, не анимированно, а по логике должно перекинуться на слой и тот должен санимироваться. Тут дело в том, что корневой слой UIView ссылается на этот view как на делегата. И при любом изменении свойства, слой спрашивает нужно ли ему анимировать это свойство, вызывая метод делегатаaction(for:forKey:) View будет отвечать nil'ом на все изменения, выполняемые не в блоке анимацииUIView.animate(...), таким образом блокируя анимации при простановки различных свойств.

Если у слоя нет делегата, то он обратиться за анимацией к собственному словарюactions, в котором предустановлены стандартные анимации со стандартными длительностями и тайминг-функциями для различных свойств.

Мы можем из кода создать дочерней слой, добавить его к основному черезaddSublayer()и после санимировать UIView черезUIView.animate(withDuration:5). При этом будет наблюдаться различие в анимациях: изменения на корневом слое будут длиться 5 секунд, в то время как его дочерний (созданный нами) будет анимироваться куда быстрее. Это необходимо помнить и понимать чтобы сэкономить часы на отладке.

UIView может быть делегатом только у одного слоя. Несмотря на то, что мы можем из кода задать эту view делегатом у дочернего слоя,работать это не будети очень скоро приведет к падению приложения.

Явное - это гибко и удобно

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

  • [CABasicAnimation] обычная анимация, интерполирующая значение междуfromPointиtoPoint

  • [CAKeyFrameAnimation] анимация, интерполирующая значения между двумя ключевыми кадрами, заданные с помощью массивовvaluesиkeyTimes

  • [CASpringAnimation] пружинная анимация

Анимация будет изменятьpresentationLayerанимируемого слоя. Это копия этого слоя, которая отражает его состояние в конкретный момент времени. Если вы хоть раз применяли анимации к слою, то вероятно знаете, что пока слой анимируется, значения анимируемых свойств у слоя не меняются. Дело в том, что спрашивая у слоя значения свойств мы спрашиваем значения свойств его модели. Использование жеpresentationLayerпозволяет узнать эти значения в конкретный период, такие, какие они сейчас на экране. Это может быть полезно для нескольких кейсов:

  • Остановка анимации с сохранением текущего состояния (просто удалив анимацию слой вернется к значениям из его модельного представления)

  • Бесшовная смена анимации (для старта новой анимации нужны значенияfromValueиз presentation слоя)

  • Корректная обработка нажатий на анимируемый элемент (во время анимацииhitTest(_:with:) ( точнееpoint(inside:with:)) будет опираться на значения фрейма из модельного представления, и чтобы верно обрабатывать нажатия, необходимо будет переопределитьpoint(inside:with:)для работы с презентационным слоем)

Именно по этой причине после окончания анимации слой возвращается к исходным значениями, если не сменить у анимации значение свойстваisRemovedOnCompletion. При установке этого свойства вfalseконечные значения анимируемых свойств сохранятся в модельном представлении слоя.

Стоит помнить о том, что анимации зависят от жизненного цикла приложения и самого слоя. При уходе приложения в бекграунд или удалении слоя изsuperviewанимацииCAAnimationудаляются, поэтому свернув приложение в середине анимации, вы увидите объект в том состоянии, в котором он был до начала анимации.

А вот и сказочке конец

Надеюсь вы нашли что-то новое или разложили уже имеющиеся знания по полочкам, хотя бы немного. Всем интересных проектов и чистого кода :D

Подробнее..

MFS паттерн построения UI в iOS приложениях

26.01.2021 12:18:23 | Автор: admin

Логика развития мобильных приложений заключается в постепенном усложнении функциональной нагрузки на пользовательский интерфейс.
Что, в свою очередь, приводит к росту кодовой базы и затруднению ее обслуживания.

MFS - позволяет создавать современный дизайн приложений и при этом избежать такого явления как MassiveViewController.

Фото: 10 years of the App Store: The design evolution of the earliest apps - 9to5Mac

Причины создания паттерна

Времена когда весь интерфейс контроллера настраивался целиком в методеviewDidLoadдавно закончились.
Настройка графических компонентов и пользовательскогоlayoutстала занимать сотни строк кода.
Помимо массивной настройки и инициализации, встает второй немало важный вопрос поддержки и управления созданными активами.

О существующих трудностях знают в Купертино, на изменение требований рынка компания отреагировала выпуском перспективной технологиейSwiftUI.
Которая, к сожалению, имеет ряд серьезных ограничений, например поддержка отiOS 13 и выше.
Что на данный момент - абсолютно неприемлемо для большинства солидных приложений, которые стараются охватить максимально большую аудиторию пользователей.

MFS - же напротив ориентирован на поддержку и разгрузку уже существующих приложений.
А набор используемых технологий позволяет внедрять паттерн на самую большую аудиторию приложений.

Архитектурный паттернMFS(Managment-Frames-Styles) был разработан, для того чтобы соответствовать духу времени и его потребностям.

Кому может понадобиться MFS ?

В первую очередь паттерн рекомендуется использовать тем разработчикам, которые занимаются строительством сложных интерфейсов, в особенности если дизайн значительно различается в зависимости от ориентации устройства.

Если же ваше приложение имеет крайне простой интерфейс, то возможно более оптимальным будет использование стандартногоStoryboard иAutolayout.

Отношение к Autolayout и другим подобным технологиям

Поскольку исторически паттерн создавался именно для реализации сложных дизайнов, поэтому в стандартной своей реализацииMFS- имеет ручное вычисление всехframe, без использования каких-либо сторонних библиотек.

Подобный подход позволяет крайне гибко реализовывать любые требования дизайнера, чего к примеру не позволяет делать или же серьезно ограничиваетAutolayout.
Однако, использование технологииAutolayout не запрещено, поскольку паттерн имеет высокую декомпозированность, пользуясь которой, вы можете заменить ручной расчет координат на созданиеconstraints.

Обзор паттерна

ПаттернMFS призван равномерно распределить обязанности между категориями класса, с целью избежания возникновенияMassiveViewController.

Название категории

Обязанности

+Managment

Содержит методы дополнительного жизненного цикла интерфейса, которые инициализируют, добавляют на экран, наполняют контентом и совершают прочие действия.

+Frames

Содержит методы вычисляющие размеры и координаты subviews.

+Styles

Содержит методы графической конфигурации subviews.
Например задает цвет,закругление,шрифт, и т.д.
Как правило, для каждого property существует отдельный метод настройки.

Как стало вам понятно, категория+Managment является главенствующей, она содержит дополнительные методы, которые полностью забирают на себя все хлопоты по созданию и обслуживанию интерфейса контроллера.
Остальные же категории содержат так называемыечистые функции, которые более широко известны под названиемpure function.

Функция является "чистой" если она мутирует только те значения, которые создала самостоятельно или же приняла из собственных параметров.


То есть, если функция изменяет некие глобальные переменные, которые не были переданы ей в параметры, то "чистой" она не является.

Обратите внимание, на то, что методы категории+Frames ВСЕГДА должны быть чистыми.
Методы же категории+Stylesмогут быть чистыми по усмотрению пользователя, поскольку это не так критично.

На главный файл имплементации контроллера (ViewController.m) ложится обязанность выполнять протоколы различных представлений (напр.:UITableViewDelegate,UITableViewDataSource), а также содержать методыIBAction.


Выше были перечисленны основные обязанности, но в случае острой необходимости, вы должны самостоятельно принимать решение - размещать некий функционал в файле имплементации или же вынести его в отдельную категорию.
Также стоит заметить, что вся бизнес-логика содержится во вьюМодели вашего контроллера или же во вьюМоделях егоsubviews.

Подобная целенаправленная политика позволяет умещать такие сложные контроллеры, как профиль пользователя и его стену, всего в около~300 строчек кода наObjC и вероятно еще меньше - наSwift.

В свою очередь, такая лаконичность полностью исключает побочный эффект в виде "бесконечного скроллинга" и поиска нужного метода.

Все методы распределены строго по категориям и если нам, например, потребуется изменить размер шрифта или поменятьlayout, мы с легкостью откроем соответствующий файл и не будем бесконечно скролить основной код контроллера.

Порядок вызова методов построения UI

На схеме ниже показаны методы и порядок их вызовов для построенияUI вUIViewController.
Как мы можем увидеть процесс построенияUI начинается из методаviewDidAppear, который вызывает методprepareUI.

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

На самых простых представлениях, после переворота экрана, нам потребуется вызывать только методresizeSubviews, на более сложных, где нужно, например скрывать некоторые элементы для определенных ориентаций, там может понадобиться и вызовupdateStyles или жеbindDataFrom.

Обзор контроллера

Подобный обзор паттерна будет проводиться на примере демо приложения.
Данный контроллер входа в приложение имеет различную верстку для разных ориентаций.

В портретной ориентации кнопки находятся друг под другом, а в горизонтальной находятся на одной высоте, с разных сторон экрана.

Даже такой минималистичный дизайн является частным случаем труднореализуемого интерфейса с помощью применения стандартногоAutolayout.

Ниже представлены.h/.m контроллера.
Обратите внимание, что помимо стандартного набор проперти, наш контроллер имеет две достаточно необычных, в привычном понимании, переменных.

@interface LoginController : UIViewController// ViewModel@property (nonatomic, strong, nullable) LoginViewModel* viewModel;@property (nonatomic, strong, nullable) LoginViewModel* oldViewModel;// UI@property (nonatomic, strong, nullable) UIImageView* logoImgView;@property (nonatomic, strong, nullable) UIButton* signInButton;@property (nonatomic, strong, nullable) UIButton* signUpButton;@property (nonatomic, strong, nullable) CAGradientLayer *gradient;@property (nonatomic, assign) CGSize oldSize;#pragma mark - Actions- (void) signUpBtnAction:(UIButton*)sender;- (void) signInBtnAction:(UIButton*)sender;#pragma mark - Initialization+ (LoginController*) initWithViewModel:(nullable LoginViewModel*)viewModel;@end


Речь идет оoldViewModel иoldSize - эти проперти помогают избегать лишних перерисовок и вставок данных.
Подробней о них будет рассказано в разборах отдельных категорий.

@interface LoginController ()@end@implementation LoginController#pragma mark - Life cycle- (void) viewDidAppear:(BOOL)animated{    [super viewDidAppear:animated];    [self prepareUI];}- (void)viewWillTransitionToSize:(CGSize)size      withTransitionCoordinator:(id<UIViewControllerTransitionCoordinator>)coordinator{    __weak LoginController* weak = self;    [coordinator animateAlongsideTransition:nil             completion:^(id<UIViewControllerTransitionCoordinatorContext> context) {        [UIView animateWithDuration:0.3                               delay:0                            options:UIViewAnimationOptionCurveEaseOut  animations:^{            [weak resizeSubviews:weak.viewModel];        } completion:nil];    }];}#pragma mark - Action- (void) signUpBtnAction:(UIButton*)sender{    [self.viewModel signUpBtnAction];}- (void) signInBtnAction:(UIButton*)sender{    [self.viewModel signInBtnAction];}#pragma mark - Getters/Setters- (void)setViewModel:(LoginViewModel *)viewModel{    _viewModel = viewModel;      if ((!self.oldViewModel) &amp;&amp; (self.view)){         [self prepareUI];    } else if ((self.oldViewModel) &amp;&amp; (self.view)){        [self bindDataFrom:viewModel];        [self resizeSubviews:viewModel];    }}#pragma mark - Initialization+ (LoginController*) initWithViewModel:(nullable LoginViewModel*)viewModel{    LoginController* vc = [[LoginController alloc] init];    if (vc) {        vc.viewModel = (viewModel) ? viewModel : [LoginViewModel defaultMockup];    }    return vc;}@end

Просмотрев код расположенный выше, можно сказать, что в результате декомпозиции основной файл контроллера остается крайне лаконичным.

Обзор методов категории +Managment

Имя метода

Принимает ли вьюМодель

Предназначение

prepareUI

Главный метод построения UI, вызывает нужную последовательность методов.
Данную функцию рекомендуется вызывать из viewDidAppear.

removeSubviews

Удаляет все subviews с superView.
А также обнуляет все проперти на UI элементы.

initSubviews

Инициализирует нужные subviews.

updateStyles

Вызывает индивидуальные методы настройки для каждого subviews, куда также передает вьюМодель, на основании данных которой может быть принято решение относительно стилей.
Например, задает разный цвет для плашек сообщений в зависимости от пола пользователя.

bindDataFrom

Вставляет данные из вьюМодели в subviews.

resizeSubviews

Вызывает индивидуальные методы расчета размеров и координат для каждой subviews.

addSubviewsToSuperView

Добавляет subviews на superView если те были проинициализированы и не добавлены на родительское представление ранее.

postUIsetting

Здесь должна происходить настройкаsubviews для которых не было создано уникальных методов конфигурации по причине их ненадобности.
Например, тут будет настраиватьсяstatusBar,gestures,allowSelectation и тд.

Из выше перечисленных методов явно прослеживается виденье того, как должен строиться UI в приложении:

  1. Удаление всехsubviewsи обнуление всех проперти наUIэлементы.
    (Если того требует ситуация).

  2. Инициализация нужныхsubviews.

  3. Обновление стилейsubviews (цвет/размер шрифта итд).

  4. Вставка данных вsubviews.

  5. Расчет и установка корректныхframesдляsubviews.

  6. Добавление полностью готовыхsubviews на родительское представление.

Реализация методов категории +Managment

Для того чтобы внутриviewDidAppear не вызывать целый набор методов, был придуман метод-оберткаprepareUI,который вызывает методы категории в нужной последовательности.

Обратите внимание, на методыresizeSubviews иbindDataFrom, порядок их вызовов в некоторых ситуациях может быть прямо противоположенный.

Например, некоторые библиотеки, кэширующие изображения из интернета, возвращают картинку не в полном разрешении, а уже заранее подготовленную под размер вашегоUIImageView, тогда, если вы сначала попытаетесь вставить картинку в рамку размером0x0, у вас может произойти ошибка.

В классическом сценарии сначалаsubviews наполняются данными, а потом производится расчет размеров и координат.

/*----------------------------------------------------------------------  Основной метод построения интерфейса.   Вызывает нужную последовательность методов ----------------------------------------------------------------------*/- (void) prepareUI{    if (self.view){        [self removeSubviews];        [self initSubviews:self.viewModel];        [self updateStyles:self.viewModel];        [self bindDataFrom:self.viewModel];        [self resizeSubviews:self.viewModel];        [self addSubviewsToSuperView];        [self postUIsetting];    }}


Метод удаления всехsubviews с родительского представления.
При работе с контроллером нужен - только в исключительных случаях.

Например, если данные во viewModel могут динамически меняться и набор с расположениемsubviews зависит от вариативностиviewModel, то есть для одной вьюМодели у вас будет один наборsubviews, в определенном месте, а для вьюМодел с другим набором данных, будут иные subviews с прочимиUIэффектами.

Тогда при сменеviewModel имеет смысл вызывать неbindDataFrom иresizeSubviews, а полноценный методprepareUI, потому что он вызовет всю цепочку, которая прежде всего удалит все старые представления.

/*---------------------------------------------------------------------- Удаляем все `subviews` и обнуляем все проперти на UI элементы. ----------------------------------------------------------------------*/- (void) removeSubviews{    // removing subviews from superview    for (UIView* subview in self.view.subviews){        [subview removeFromSuperview];    }    // remove sublayers from superlayer    for (CALayer* sublayer in self.view.layer.sublayers) {        [sublayer removeFromSuperlayer];    }    self.logoImgView   = nil;    self.signInButton  = nil;    self.signUpButton  = nil;    self.gradient      = nil;}


Обратите внимание, что в этом методе происходит чистая инициализация, без каких-либо настроек.

/*---------------------------------------------------------------------- Инициализирует нужные subviews на основе данных из viewModel ----------------------------------------------------------------------*/- (void) initSubviews:(LoginViewModel*)viewModel{  if (self.view)  {   if (!self.logoImgView)  self.logoImgView  = [[UIImageView alloc] init];   if (!self.signInButton) self.signInButton = [UIButton buttonWithType:UIButtonTypeCustom];   if (!self.signUpButton) self.signUpButton = [UIButton buttonWithType:UIButtonTypeCustom];  }}


МетодupdateStyles вызывает индивидуальные методы для каждого изsubviews, с целью настроить их внешний вид.

/*----------------------------------------------------------------------  Задает стили для subviews. Цвета/размера шрифта/селекторы для кнопок ----------------------------------------------------------------------*/- (void) updateStyles:(LoginViewModel*)viewModel{    if (!viewModel) return;    if (self.logoImgView)  [self styleFor_logoImgView:self.logoImgView   vm:viewModel];    if (self.signInButton) [self styleFor_signInButton:self.signInButton vm:viewModel];    if (self.signUpButton) [self styleFor_signUpButton:self.signUpButton vm:viewModel];}


Во время декларации.hфайла контроллера, фигурировало пропертиoldViewModel.
В данном случае оно понадобилось нам для осуществления проверки на идентичность моделей.
Если вьюМодели идентичны, то биндинга данных не произойдет.

Традиционно подобная конструкция чаще используется при работе с ячейками, но в некоторых случаях может потребоваться и при работе с контроллером.

/*---------------------------------------------------------------------- Связывает данные из вьюМодели в subviews ----------------------------------------------------------------------*/- (void) bindDataFrom:(LoginViewModel*)viewModel{    // Если модели идентичны, то биндинга данных не происходит    if (([self.oldViewModel isEqualToModel:viewModel]) || (!viewModel)){        return;    }    [self.logoImgView setImage:[UIImage imageNamed:viewModel.imageName]];    [self.signInButton setTitle:viewModel.signInBtnTitle forState:UIControlStateNormal];    [self.signUpButton setTitle:viewModel.signUpBtnTitle forState:UIControlStateNormal];    self.oldViewModel = viewModel;}


МетодisEqualToModel в каждом отдельном случае имеет разную реализацию.
Например, может возникнуть ситуация, когда в ваш контроллер устанавливается новая вьюМодель, но основные данные, критически важные данные не отличаются, а были изменены только второстепенные проперти, которые не отображаются в вашемUI.

Тогда методisEqualToModel должен вернуть значениеNO, чтобы избежать повторного биндинга данных.

В нашем случае он имеет подобную реализацию:

/*---------------------------------------------------------------------- Сравнивает модели данных на индетичность. ----------------------------------------------------------------------*/- (BOOL) isEqualToModel:(LoginViewModel*)object{    BOOL isEqual = YES;    if (![object.imageName isEqualToString:self.imageName]){        return NO;    }    if (![object.signInBtnTitle isEqualToString:self.signInBtnTitle]){        return NO;    }    if (![object.signUpBtnTitle isEqualToString:self.signUpBtnTitle]){        return NO;    }    return isEqual;}

Так же как и вbindDataFrom, методresizeSubviews в самом начале имеет условие проверки, которое не позволяет повторно вычислять размеры и координаты дляsubviews, если модель данных или размер родительского представления не был изменен.

/*---------------------------------------------------------------------- Вызывает индивидуальные методы расчета размеров и координат для subviews.  После изменения ориентации или после первой инициализации. ----------------------------------------------------------------------*/- (void) resizeSubviews:(LoginViewModel*)viewModel{    // Выходим если модель данных и размеры одни и те же    if ((([self.oldViewModel isEqualToModel:self.viewModel]) &&        (CGSizeEqualToSize(self.oldSize, self.view.frame.size))) || (!viewModel)) {        return;    }    if (self.view){      if (self.logoImgView)  self.logoImgView.frame  = [LoginController rectFor_logoImgView:viewModel  parentFrame:self.view.frame];      if (self.signInButton) self.signInButton.frame = [LoginController rectFor_signInButton:viewModel parentFrame:self.view.frame];      if (self.signUpButton) self.signUpButton.frame = [LoginController rectFor_signUpButton:viewModel parentFrame:self.view.frame];      if (self.gradient)     self.gradient.frame     =  self.view.bounds;    }    self.oldSize = self.view.frame.size;}


Добавляемsubviews на родительскоеview.

/*---------------------------------------------------------------------- Добавляет subviews на superView ----------------------------------------------------------------------*/- (void) addSubviewsToSuperView{    if (self.view){        if ((self.logoImgView)  &amp;&amp; (!self.logoImgView.superview))   [self.view addSubview:self.logoImgView];        if ((self.signInButton) &amp;&amp; (!self.signInButton.superview))  [self.view addSubview:self.signInButton];        if ((self.signUpButton) &amp;&amp; (!self.signUpButton.superview))  [self.view addSubview:self.signUpButton];    }}


На этом этапе все методы категории+Managment были разобраны и остался единственный метод пост-настройки, который принадлежит категории+Styles.

В методеpostUIsetting мы настраиваемUI компоненты, для которых не создали индивидуальных методов.
Например, в нем можно добавлятьgestures, настраивать таблицу, устанавливать цвет статус бара и т.д.

- (void) postUIsetting{    UIColor* firstColor  =    [UIColor colorWithRed: 0.54 green: 0.36 blue: 0.79 alpha: 1.00];       UIColor* secondColor =    [UIColor colorWithRed: 0.41 green: 0.59 blue: 0.88 alpha: 1.00];;    self.gradient = [CAGradientLayer layer];    self.gradient.frame      = self.view.bounds;    self.gradient.startPoint = CGPointZero;    self.gradient.endPoint   = CGPointMake(1, 1);    self.gradient.colors     = [NSArray arrayWithObjects:(id)firstColor.CGColor,                           (id)secondColor.CGColor, nil];    [self.view.layer insertSublayer:self.gradient atIndex:0];}

Реализация методов категории +Styles

В отличии от категории+Managment,+Styles не имеет системных методов, а лишь содержит индивидуальные методы настройкиUI компонентов.
Ниже будет приведен один из методов.

- (void) styleFor_logoImgView:(UIImageView*)imgView   vm:(LoginViewModel*)viewModel{    if (!imgView.isStylized){        imgView.contentMode = UIViewContentModeScaleAspectFit;        imgView.backgroundColor = [UIColor clearColor];        imgView.opaque = YES;        imgView.clipsToBounds       = YES;        imgView.layer.masksToBounds = YES;        imgView.alpha      = 1.0f;        imgView.isStylized = YES;    }}


Обратите внимание на некое пропертиisStylized, оно было добавлено категорией к каждому наследнику классаUIView.

Традиция использовать данную переменную при настройкеUIэлементов пришла от опыта работы с ячейками в таблице.

Создана была для того, чтобы при переиспользовании однотипных ячеек не производилась повторная настройка (уже настроенных элементов), то есть, чтобы еще раз не добавлялись тени, блюры и т.д.

Реализация методов категории +Frames

Категория+Frames также как и+Styles не имеет системных методов, но может иметь словари класса, в которых могут быть расположены закэшированные размеры и координатыsubviews.

Подробный листинг данных методов публиковать не имеет смысла из-за их громоздкости, по сути, там не происходит ничего интересного, стандартное ручное вычисление координат и размеров.

Но стоит также обратить внимание, что по сравнению с другими категориями,+FramesсодержитИСКЛЮЧИТЕЛЬНОметоды класса (+).

Данная традиция также пришла из опыта работы с ячейками, первоначально методы класса были созданы для того, чтобы в фоне можно было заранее вычислить координаты и размеры для всехsubviewsне имея фактического объекта ячейки, а имея только лишь ее вьюМодель.

+ (CGRect) rectFor_signUpButton:(LoginViewModel*)viewModel                     parentFrame:(CGRect)parentFrame{    if (CGRectEqualToRect(CGRectZero, parentFrame)) return CGRectZero;    // Calculating...    return rect;}

Советы и рекомендации

По собственному опыту использования могу сказать, что имплементироватьMFSможно выборочно.

Например, как правило, имеет смысл создавать подобную архитектуру в тех случаях, когда мы имеем сложныйUIViewController(UIдля которого ввиду его сложности мы создаем кодом), или же когда имеем сложные ячейки таблицы.

То есть, для контроллера классаUITableViewController, за исключением особых случаев - смысла имплементировать данное решение нет.

Заключение

В данной статье вы имели возможность ознакомиться с паттерномMFS на примере работы с вьюКонтроллерами.

Во второй части статьи мы поговорим о примененииMFSпри работе с ячейками таблицы, как обеспечивать60FPSпри быстром скроллинге сложных таблиц на старых девайсах.

Подробнее..

Цвета в Swift UIColor

19.02.2021 00:11:18 | Автор: admin

UIColor дает возможность настроить цвет для Dark Mode, дает семантический набор цветов "от Apple", а также дает возможность задать не только цвет, но и паттерн заполнения пространства, что как бы кричит нам, что UIColor это вовсе не цвет. Давайте слегка заглянем в эти чертоги UIColor.

UIColor это класс который хранит данные о цвете и прозрачности. Цвет в iOS может быть представлен в разных цветовых пространствах, самое известное из которых RGB. На самом деле, сам цвет хранится в свойстве cgColor класса CGColor. В iOS существуют несколько сущностей, которые могут хранить информацию о цвете - каждый для своего фреймворка:

  • для UIKit это класс UIColor,

  • для SwiftUI это структура Color,

  • для Core Graphics это класс CGColor,

  • для Core Image это класс CIColor

В данной статье я постараюсь рассказать о классе UIColor, который является частью фреймворка UIKit.

Системные цвета

В UIKit есть некоторые предопределенные цвета, которые вы можете использовать в своем проекте. Например, зелёный UIColor.green или часто достаточно написать просто .green. Написав так, будет использован конкретный цвет со значениями RGB 0.0, 1.0, 0.0 и уровнем прозрачности 1.0. Но в UIColor, определен также цвет UIColor.systemGreen. Чем он отличается от обычного UIColor.green?

Начиная с iOS 13.0 появился Dark Mode. Пользователь может выбрать в настройках системы в каком цветовом теме ему комфортнее работать - в светлой теме или в тёмной. И зеленый цвет для этих режимов будет немного отличаться. Более того, пользователь для комфортного восприятия может выбрать высококонтрастный режим, повышающий контрастность между элементами интерфейса и фона. И зеленый цвет для этого режима тоже будет другим.

Нам, разработчикам, зачастую без разницы какой оттенок одного цвета используется, но специалисты Apple, глубоко проработали этот вопрос с учетом сочетаемости цветов в разных режимах и состояний экрана, и с точки зрения восприятия цветов для людей с проблемами по зрению. Недооценивать возможность использовать системные цвета не стоит - и при отсутствии дизайнеров в проекте, которые способны подобрать цвета для проекта, при прочих равных лучше выбирать цвета начинающиеся на "system...":

  • systemBlue

  • systemIndigo

  • systemOrange

  • systemPink

  • systemPurple

  • systemRed

  • systemTeal

  • systemYellow

Замечу - эти системные цвета меняют только оттенок основного цвета - зеленый цвет не сможет стать синим, он будет либо чуть светлым зеленым, либо чуть темным зеленым.

Кроме того, есть несколько системных оттенков серых цветов, которые вы можете использовать для второстепенных элементов интерфейса - разделители, тени, границы и т.п.

  • systemGray

  • systemGray2

  • systemGray3

  • systemGray4

  • systemGray5

  • systemGray6

Еще раз, повторю, все эти системные цвета способны адаптировать под различные настройки или состояния интерфейса. Как подобные цвета создавать самому мы посмотрим далее.

Как вы могли заметить среди этих цветов нет цветов Black или White, которые в основном как раз используются для текста или фона. Тут как раз приходят на помощь семантические цвета.

Семантические цвета

Концепция семантических цветов позволяет абстрагироваться от конкретных цветов, сделав акцент на предназначении цвета. В UIKit нам знаком такой цвет как tintColor, который обозначает цвет, который призывает пользователя к действию - например, это может быть цвет системных кнопок, ссылок, вкладок в UISegmentControl и т.п. В вашем приложении может быть определен брендированный цвет - например, мы можем назвать его как brandColor или primaryBrandColor/secondaryBrandColor. Или это может быть свой уникальный цвет всех текстовых элементов.

Смысл использования семантических цветов в том, чтобы определить их в одном месте и дальше ссылаться из всех частей приложения только на одно место - место где вы определили семантические цвета. К примеру, вы хотите использовать красный цвет для всех UILabel в вашем приложении. Вместо того, чтобы в каждом UILabel задать тексту цвет .red, гораздо удобнее определить свой семантический цвет:

let primaryTextColor = UIColor.red

и во всех UILabel его использовать:

label.textColor = primaryTextColor

Конечно красный цвет - сомнительная история для использования как цвет текста, поэтому к вам обязательно придет дизайнер или заказчик и попросит поменять цвет на другой. В таком случае вы это сможете сделать не более чем за минуту просто переопределив свой primaryTextColor.

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

Семантические цвета в UIKit

Apple предлагает свою систему семантических цветов, которую вы можете использовать в приложении. Более того, цвета из этой системы вы будете неосознанно использовать если не будете менять цвета стандартных контролов. В UIKit предлагаются следующие цвета.

Цвета текста:

  • label - для обозначения основного текста

  • secondaryLabel - вторичный текст

  • tertiaryLabel - текст третьей значимости

  • quaternaryLabel - текст четвертой значимости

Вы, к примеру, цвет secondaryLabel можете использовать для подзаголовков, а tertiaryLabel для сносок или примечаний в тексте.

Цвета для заполнения:

  • systemFill

  • secondarySystemFill

  • tertiarySystemFill

  • quaternarySystemFill

Apple предлагает использовать эти цвета для наполнения разных фигур/контролов типа слайдеров, полей поиска, кнопок и т.п.

Цвет для плейсхолдера:

  • placeholderText

Цвета для фона:

  • systemBackground

  • secondarySystemBackground

  • tertiarySystemBackground

Использовать для разного вида фонов.

Цвета для фонов сгруппированных элементов:

  • systemGroupedBackground

  • secondarySystemGroupedBackground

  • tertiarySystemGroupedBackground

Используются для таблиц со стилем .grouped для фона сгруппированных элементов.

Цвета для разделителей:

  • separator

  • opaqueSeparator

Цвет для ссылок:

  • link

Отдельно цвета для текста, которые не адаптируются под темную или светлую тему:

  • darkText

  • lightText

Используя эти семантические цвета UIKit для целей, которые обозначены в их описании, будет обеспечиваться их сочетаемость нативным образом.

Подробнее про эти цвета можно почитать на сайте Apple.

Как создать цвет, который будет адаптироваться под Dark или Light режимы

Первый вариант - в XCode в Assets проекта добавить для каждого цвета Color Set, в котором можно отдельно задать цвета для Dark и Light Mode в настройке Appearances. Там же можно задать, что вы хотите для данного цвета задать параметры для высококонтрастного режима.

Где можно найти настроки цвета в Assets в XCodeГде можно найти настроки цвета в Assets в XCode

В дальнейшем к этому цвету можно обращаться так:

let color = UIColor(named: "ColorName")

этот цвет автоматически будет меняться "на лету" при изменении режима настройки в iOS.

Второй вариант - задать условия и значения цвета в коде, в зависимости от текущих параметров системы.

Делается это в конструкторе UIColor:

init(dynamicProvider: @escaping (UITraitCollection) -> UIColor)

UITraitCollection содержит в себе основные параметры относящиеся к экрану - вертикальный или горизонтальный режим, Dark или Light режим, контрастность и т.п. Нас прежде всего интересует userInterfaceStyle которое говорит о текущем режиме.

Пример:

let color = UIColor { traitCollection -> UIColor in

switch traitCollection.userInterfaceStyle {

case .light, .unspecified: return .white

case .dark: return .black

}

}

Какие еще есть возможности для создания цвета

Конструкторы для создания UIColor можно разделить на три типа:

  • для создания в разных пространствах цветов HSB, RGB

  • для создания UIColor на основе цветов из других нативных фреймворков

  • для создания на основе паттерна картинки

    интересный вариант - создать UIColor на основе картинки ...

init(patternImage image: UIImage)

Данный конструктор создает UIColor, при этом мы не задаем цвет, а передаем в качестве основы картинку. В итоге назначив этот цвет, например, тексту UILabel, вы получете интересный эффект - текст будет отображен при помощи паттерна картинки(см. обложку статьи).

Это что же получается - цвет это может быть вовсе не цвет, а паттерн. Получается что цвет в данном случае это скорее "как нам наполнить пространство", чем "каким цветом тут покрасить".

Вывод

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

Подробнее..

Дизайн-система что это, для чего и как создать

22.10.2020 12:07:47 | Автор: admin

Всем привет!

Я рад вернуться к вам, дорогие читатели сообщества, и поделиться опытом и знаниями, полученными благодаря работе в IT-компании Omega.

Тема дизайн-систем, казалось бы, уже рассмотрена со всех сторон: можно найти множество статей, постов, докладов и других материалов. Несмотря на это, у каждого, кто сталкивается с этим термином, возникает множество вопросов. Я постараюсь подробно ответить на главные из них:

  • Что такое дизайн-система?

  • Для чего она нужна?

  • Как её создать?

Что такое дизайн-система?

Давайте разбираться по порядку. Дизайн-система представляет собой совокупность трех сущностей:

  1. Визуальный язык то, что мы видим.

  2. Framework библиотека визуального языка, его код.

  3. Guidelines правила, как должно все выглядеть и каким образом применяться.

Можно считать, что дизайн-система является отдельным продуктом внутри любого IT-продукта.

Визуальный язык

Визуальная составляющая дизайн-системы включает в себя множество элементов, основные из которых:

  • Цвета

  • Шрифты

  • Пространство

  • Формы объектов

  • Иконки

  • Изображения

  • Взаимодействия

  • Анимации

  • UI-компоненты

  • Звуки

Задача визуального языка передать ценности бренда потребителю. Например, чтобы передать домашнюю атмосферу, можно использовать теплые тона и соответствующие изображения. Чтобы передать какой-то другой характер бренда, например, строгость и честность, использовать определенные шрифты и формы.

Какой образ приходит вам в голову, когда слышите или видите названия таких банков, как Альфа-Банк, ВТБ, Сбербанк, Тинькофф и других? Какие эмоции вы испытываете? Визуальный язык является одним из фактором того, каким мы запомним тот или иной продукт.

Framework

Фреймворк это, по сути, код визуального языка. Благодаря нему все визуальные элементы можно взять из библиотеки и легко применить во всех своих продуктах.

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

Что, если у нас фреймворка нет? Тогда данную кнопку необходимо создать заново, пусть даже путем копирования существующего. А если мы решили изменить цвет кнопки, то нам придется вручную менять цвет во всех подобных кнопках? А если таких кнопок десятки, сотни или больше?

Поэтому нужна централизованная библиотека элементов визуального языка, которая позволит создавать и переиспользовать каждую деталь продукта.

Guidelines

Чтобы все, кто взаимодействуют с дизайн-системой, разговаривали на одном языке, необходимо выработать правила.

Например, один дизайнер считает, что кнопка недостаточно яркая и нужно изменить оттенок ближе к малиновому. Другой уверен, что кнопка должна быть темно-красного оттенка. В то же время разработчики уже неделю ругаются на тему идеального кода для реализации формы поиска. Все это ведет к хаосу в компании и продукте.

Казалось бы, условностей много. Как описать все так, чтобы не запутаться в правилах? Опишите только то, что нужно для конкретного элемента:

  • Что это за элемент?

  • Где он используется?

  • Какие задачи решает?

Также для UI-компонентов можно описывать анатомию: обозначьте структуру и все параметры, из которых они строятся.

Таким образом, правила применения элементов дизайн-системы позволяют упорядочить процесс работы для всех. Они задают определенные рамки для полета фантазии и формируют понимание, что любое изменение дизайн-системы может затронуть множество экранов и сценариев взаимодействия пользователя в продукте. Это развивает чувство ответственности на каждом участке работы над любой, даже самой мелкой, деталью продукта.

Для чего нужна дизайн-система?

Как и для любого IT-продукта, важно ответить на вопрос: для чего нужна дизайн-система? Или, как говорят некоторые коллеги по цеху чтобы что?

Ответ на него даст понять ценность продукта. Отчасти ее уже можно увидеть из того, что я описывал выше. Но давайте разложим по полочкам и рассмотрим глубже.

Автоматизация

Автоматизация это самая очевидная ценность. Дизайн-система позволит автоматизировать процессы и выиграть время на другие задачи.

Например, дизайнеры смогут сформировать библиотеку компонентов в графическом редакторе Figma и применять их во всех необходимых макетах. Разработчики, в свою очередь, просто используют ранее реализованные компоненты из фреймворка.

Итеративность

Возможность наследования и автоматического применения изменений в элементах дизайн-системы является мощным инструментом для продуктовой команды. Вы можете легко внедрить косметические или даже серьезные правки в одном месте. Таким образом шаг за шагом можно улучшать продукт бесконечно.

Однако любое внесение изменений это большая ответственность. Поэтому любая коррекция должна быть сознательной и аргументированной.

Консистентность

Консистентность говорит о том, что объекты и сущности, которые мы используем в продукте и которые выполняют одну и ту же работу, должны стремиться к унификации, стремиться быть похожими.

Когда мы пользуемся в продукте похожими функциями, он становится привычным и удобным: мы знаем интуитивно, как работает и выглядит кнопка, где она расположена.

Синхронизация

Все элементы в IT-продукте должны обладать единым языком общения на основе четких правил.

С ними новые члены продуктовой команды выполняют задачи в рамках продукта, не теряя времени на раскачку: дизайнеры создают новые элементы на основе заданного визуального языка, а разработчики точно знают, каким образом их реализовать и использовать.

Больше времени на UX

Эта ценность является, по сути, совокупностью всех предыдущих преимуществ. Ручная работа с визуальным языком, на которую раньше тратилось много времени, становится максимально автоматизированной.

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

Скорость прототипирования

Дизайн-система позволяет быстро собрать прототип из готовых элементов и проверить гипотезу. Это, в свою очередь, экономит бюджет проекта и дает быстрые результаты.

Недостатки дизайн-системы

Все вышеописанное можно отнести к плюсам дизайн-систем, но у них, конечно, есть минусы.

Во-первых, дизайн-система это дорого. Нужны ресурсы для ее создания и непрерывной поддержки. Дизайн-система должна развиваться вместе с продуктом. В противном случае между ними возникнет явная рассинхронизация: продукт будет развиваться, а дизайн-система будет постепенно отставать от продукта и терять свою актуальность.

Если бренд сформулировал новые правила визуального и вербального взаимодействия с клиентами, то визуальный язык дизайн-системы необходимо адаптировать. Дизайн-система должна идти в ногу с бизнесом и нести правильную идеологию бренда, развиваясь и трансформируясь вместе с ним.

Однако хочется отметить, что поддержка дизайн-системы в какой-то степени упрощается благодаря использованию современных инструментов, например, редактора Figma. У него достаточно функционала, чтобы дизайнеры могли с комфортом формировать и развивать UI-kit. К тому же, с помощью Figma можно легко перенять опыт коллег по цеху, используя готовые библиотеки

Как создать дизайн-систему?

Есть множество подходов для создания дизайн-системы. Я рекомендую рассмотреть атомарную систему, главным преимуществом которой является наследуемость. Пожалуй, тема атомарной дизайн системы может потянуть на отдельную статью. Про это можно почитать в переводе статьи Брэда Фроста (Brad Frost) Atomic Web Design.

Примеры того, как сделаны дизайн системы:

После создания дизайн-системы немаловажно разобраться в том, как ее внедрить в практику компании. Во второй статье я расскажу о том, как пошагово выстроить процесс внедрения дизайн-системы в любой компании.

Я надеюсь, что каждый почерпнул для себя ценное из статьи. А я еще раз убедился в том, что работа над статьей структурирует знания и мотивирует на дополнительное изучение.

Спасибо за внимание!

Подробнее..

The Layout Engine в Swift

05.01.2021 10:07:35 | Автор: admin

Многие iOS разработчики не задумываются как работает механизм отрисовки элементов, установки и обновлении constraints в Auto Layout'e. В этой статье я пробую подробно заглянуть внутрь работы Layout Engine

The Layout Pass

The Layout Pass - это когда Auto Layout Engine обходит иерархию представлений, но вызывает метод ViewWillLayoutSubviews на всех viewController и метод layoutSubviews для всех view. Метод layoutSubviews обновляет свойство Frame каждого subview

Когда мы добавляем, изменяем или удаляем constraint'ы, то они не обновляются сразу. Пересчет layout'a и обновление отрисовки при каждом изменении было бы неэффективным. Вместо этого ваши изменения планируют, что механизм layout'a, принадлежащий window, запускает обновления layout'a при следующей возможности в runloop'e.

Типичный layout cycle состоит из нескольких этапов:

  1. Trigger: Вы меняете input в layout engine. Это может быть добавление или удаление subviews, изменение внутреннего размера контента, активация / деактивация contstraints или изменение приоритета или постоянного значения constraint.

  2. Update Model: layout engine имеет внутреннюю модель размера и положения каждого view и уравнения, описывающие отношения между этими views. При изменении входных данных the layout engine обновляет свою внутреннюю модель и решает уравнения для получения новых значений размера и положения каждого view.

    На данный момент изменилась только внутренняя модель. Views, которые теперь имеют новый размер или положение в модели, вызывают setNeedsLayout() в своем superview, который планирует отложенный layout pass для запуска через некоторое время в цикле runloop.

  3. Deferred Layout Pass: Когда выполняется the layout pass он запускает два прохода по view hierarchy. Первый проход дает вам последнюю возможность обновить любые constraints. Второй проход, наконец, вызывает layoutSubviews() для каждого view, позволяя им обновлять размер и положение своих subviews, чтобы они соответствовали внутренней модели, перемещая любые views по мере необходимости.

Есть несколько методов, которые вы можете переопределить как во viewController, так и во view для взаимодействия с layout engine во время двух проходов компоновки:

  • При обновлении констрейнт проход layout engine вызывает метод вьюконтроллера updateViewConstraints, а для view ожидает обновление метода updateConstraints.

  • the layout pass подает сигнал layout engine и он вызывает viewWillLayoutSubviews и viewDidLayoutSubviews методы, а также layoutSubviews всех вью нуждающих в обновлении layout

Обновление Constraints

Первый проход по view hierarchy осуществляется снизу вверх, чтобы вы могли изменить constraint'ы до того, как layout engine изменит положение views. Механизм компоновки вызывает updateConstraintsIfNeeded для каждого view, чтобы проверить наличие последних constraints. Для view, помеченных как требующие обновления своих constraints, layout engine вызывает их метод updateConstraints, в котором вы можете внести изменения. Вызовите setNeedsUpdateConstraints, чтобы запросить передачу ограничений обновления для представления. Если вы хотите, чтобы the layout engine обновлял свою модель, немедленно вызовите метод updateConstraintsIfNeeded.

Репозиция Views

Второй проход по иерархии представлений - это переход сверху вниз для репозиции view. Только во время этого прохода the layout engine обновляет view frames в соответствии со своей внутренней моделью.

Для каждого представления, требующего layout, поведение layoutSubviews () по умолчанию устанавливает границы и центр каждого subviews на новые значения из модели layout engine.

Вызовите setNeedsLayout или layoutIfNeeded, чтобы запросить layout pass для view. Ключевое различие между этими методами заключается в том, что layout pass обновляет view вот так:

  • setNeedsLayout: немедленно возвращается без обновления layout. Вместо этого он отмечает layout во view как измененный и планирует отложенный layout pass для запуска в application run loop.

  • layoutIfNeeded: вызывает layoutSubviews на приемнике, если есть ожидающие изменения, чтобы заставить layout engine немедленно обновить размер и положение subviews из своей внутренней модели

Выводы:

  • Активация, изменение или деактивация constraints не обновляет напрямую фреймы вьюх. Вместо этого он обновляет модель the layout engine и планирует передачу макета, которая выполняется позже в цикле выполнения приложения.

  • В иерархии представлений есть два прохода макета. Первый проход позволяет вам обновить constraints. Второй проход обновляет view layout, изменяя размер и положение представлений, чтобы они соответствовали значениям из модели the layout engine.

  • Вызовите setNeedsLayout, чтобы вручную запланировать этап обновления layout'a. Вызов layoutIfNeeded для принудительного немедленного обновления view frames из модели.

Подробнее..
Категории: Swift , Uikit , Auto layout , Views

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru