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

в 16:55, , рубрики: core animation, iOS, swift, UI, uikit, разработка под iOS

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

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

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

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

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

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

Может показаться, что знание 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, под капотом происходит добавление дочернего слоя на родительский. Все изменения frameboundscenterbackgroundColor и многих прочих просто проксируются в CALayer.

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

Таким образом 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 контент. Для решения более сложных задач существуют специализированные слои, такие как CAShapeLayerCATextLayerCAGradientLayer и другие. Эти типы слоёв позволяют решить ту или иную задачу эффективным способом, проводя рисование на GPU.

Тут стоит прояснить разницу между использованием специализированных слоёв и рисованием произвольной графики, используя метод UIView draw(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

Автор: Boris Dipner

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js