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

Как бы мы не любили UIKit, это всего лишь еще один UI фреймворк позволяющий облегчить для разработчиков процесс создания интерфейса для взаимодействия с пользователем. По сути, это I/O фреймворк для диалога с пользователем вашего приложения.

🎓

Эта заметка написана по мотивам сессии «Устройство UI в iOS» проходившей в рамках второго сезона Podlodka iOS Crew спикерами которой были @a_rychkov и @ antonsergeev88, которые в свою очередь вдохновлялись книжкой iOS Core Animation Advanced Techniques.

Вы можете 🎥 купить доступ к видео с сессиями этого сезона или присоедениться к новому.

Ребята — топ, подлодка — топ, книжка — топ. Рекомендую!

Input

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

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

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

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

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

☝️

Чтобы таймер работал корректно, его нужно создавать не с помощью class-метода Timer.scheduledTimer, а обычным созданием объекта и добавлением его в RunLoop в специальном режиме .commonModes:

let timer = Timer(timeInterval: 1.0, ...) RunLoop.current.add(timer, forMode: .commonModes)

Output

С «выходом» у UIKit все чуть попроще. Нам доступен экран и 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 спозиционировать элементы используя относительные координаты и указывать позицию элемента в процентах от родительской.

Drawings

Расположить прямоугольники в нужном порядке и в нужном месте — только половина дела. Теперь в них нужно еще и что-то нарисовать. Для решения этой задачи 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.

💡

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

Animations

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

Implicit Animations

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

Explicit

Помимо неявных анимаций, конечно же существуют и явные, которыми мы можем управлять более гибко и удобно. За создание и управление явными анимациями ответственен абстрактный класс 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 удаляются, поэтому свернув приложение в середине анимации, вы увидите объект в том состоянии, в котором он был до начала анимации.

Управление анимациями

Анимациями в Core Animations можно управлять. Базовым кейсом является постановка анимации на паузу и возобновление, но таких методов «из коробки» CA не предоставляет. Взамен этого, разработчикам доступно управление временем и скоростью анимаций. Установив скорость слоя в 0, мы можем поставить анимацию на паузу, вернув исходное значение — продолжить её выполнение.

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

Помимо скорости у анимации есть тайминг-функция. Эта функция определяет как прирастает виртуальное время относительно реального.

Разработчикам доступны несколько видов тайминг-функций «из коробки».

Тайминг-функции, можно создавать самостоятельно, описывая контрольные точки кривой Безье, но стоит помнить, что для тайминг-функции она может содержать только 2 перегиба, что не позволяет сделать сложные эффекты, типа пружинной анимации. И именно поэтому в CA существует отдельный класс для такой анимации.

Share
Send
Pin