UIKit ты вообще про UI?
Спойлер — нет! Ну, не совсем. Мы привыкли воспринимать UI как визуальную составляющую, но ведь UI — это User Interface. Так вот, интерфейс — это то, с помощью чего пользователь взаимодействует с нашим приложением. В случае с графическим интерфейсом пользователь его видел и воспринимает информацию. Однако он не интерактивный и, когда пользователь хочет взаимодействовать с ним, он использует другие интерфейсы: тачскрин, клавиатуру или мышку. Да, это тоже интерфейсы. И UIKit как раз таки отвечает не за графический интерфейс, а за распознавание пользовательских жестов и их обработку.
Когда я начинал писать эту статью, хотелось рассказать много фундаментальных вещей. Одновременно с этим хотелось, чтобы она была понятна всем, поэтому я начал с описательной части. Со временем понял, что материала получается слишком много, и я решил разбить ее на несколько частей. Возможно какие-то вещи для вас покажутся совсем простыми и очевидными, но они нужны для того, чтобы хорошо разобраться и ориентироваться, как же все-таки устроен UI.
Так как же он устроен? У нас же есть базовый класс UIView и куча его стандартных наследников. Мы можем сами создавать свои вью и как угодно их кастомизировать. И все это видим на экране. Почему тогда UIKit и UIView — это не про графический интерфейс? Давайте разбираться.
1. Исторический экскурс
Вспомним, что же было до появления iPhone. Были маки — компьютеры на операционной системе macOS. Сейчас для нас очевидно, что это 2 разных пользовательских опыта — тыкать пальцем в экран и елозить мышкой по столу + стучать по клавишам на клавиатуре, но тогда перед инженерами Apple стояла задача взять механизм отрисовки интерфейса из мака и научить его в «Мультитач». И вот, что получилось:
Четкое разделение ответственности (привет, SOLID) позволило не дублировать код, а просто добавить новую надстройку, которая будет распознавать пользовательские жесты и обрабатывать их. А слой, который отвечает за отрисовку, остался общим. Круто? Круто!
Итак, мы видим, что верхним уровнем в случае iOS является UIKit, в случае macOS — AppKit. Этот уровень отвечает за распознавание пользовательских активностей (тач пальцем в область экрана или наведение курсора мышки на какой-то элемент). Ниже лежит Core Animation, вот он-то и отвечает за то, что мы видим на экране и каким будет каждый элемент.
Кстати, до версий Mac OS X 10.5 и iPhoneOS 2.0 CoreAnimation носил менее ориентированное на анимацию название — LayerKit.
2. Core Graphics & Metal
Несмотря на то, что Core Animation отвечает за графический интерфейс, отрисовкой контента занимается не он. Эту функцию выполняют более низкоуровневые фреймворки Core Graphics и Metal, с той лишь разницей, что Core Graphics для вычислений использует CPU, а Metal — GPU.
Грубо говоря, Core Animation отвечает на вопрос «Что рисовать?», а Core Graphics и Metal — «Как рисовать?». Можем ли мы работать напрямую с этими фреймворками? Можем. Это даже положительно скажется на производительности нашего приложения, но очень сильно увеличит стоимость разработки. То, что мы можем сделать в UIKit или Core Animation за пару строк кода, в Core Graphics или Metal может занять десятки. Важно понимать, как это устроено, однако использование не всегда оправданно.
3. Responder Chain
Часто можно услышать, что базовым классом UIKit является UIView. Однако, если мы посмотрим на его реализацию, то увидим, что он является наследником UIResponder, что говорит о многом в назначении UIKit.
Респондер — это тот, кто отвечает на пользовательские жесты. Но как наша вью узнает, что пользователь нажал именно на нее и что ей нужно обработать этот экшн? Здесь нам на помощь приходит механизм Responder Chain.
Когда пользователь нажимает на экран это событие попадает в наше приложение (объект UIApplication). Дальше оно отправляется в UIWindow, где и запускается цепочка поиска firstResponder«а, в границах которого и было произведено нажатие. Цепочка запускается рекурсивным вызовом метода по всей иерархии дочерних вью:
open func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView?
Метод, который банально проверяет, находится ли точка в границах вью:
open func point(inside point: CGPoint, with event: UIEvent?) -> Bool
Если точка находится внутри вью, поиск продолжается уже среди своих дочерних вью, вызывая метод hitTest у них. Так продолжается пока не будет найдена самая нижняя в иерархии (самая верхняя на экране) вью, в которую попадает нажатие.
Если точка не находится внутри вью — возвращается nil.
Таким образом, наше корневое окно (объект UIWindow) находит вью-ферстреспондера и вызывает у него соответствующие методы:
open func touchesBegan(_ touches: Set, with event: UIEvent?)
open func touchesMoved(_ touches: Set, with event: UIEvent?)
open func touchesEnded(_ touches: Set, with event: UIEvent?)
open func touchesCancelled(_ touches: Set, with event: UIEvent?)
Отдельно стоит отметить объекты UIGestureRecognizer. Они имеют больший приоритет, чем Responder Chain, и обрабатываются на этапе «погружения».
Кстати, у слоев тоже есть методы определения, попадает ли точка в границы слоя:
open func hitTest(_ p: CGPoint) -> CALayer?
open func contains(_ p: CGPoint) -> Bool
Подробнее про Responder Chain можно прочитать в статье.
4. UIView и CALayer
Если вью — это не про графический интерфейс, для чего у него так много свойств для управления внешним видом? Все просто: вью является контейнером слоя и предоставляет более высокоуровневое API для работы с внешним видом.
Мы привыкли визуализировать интерфейс в виде иерархии вью (дерево). А теперь давайте представим, что таких дерева 2 — дерево вью и дерево слоев. На самом деле их 4 (дерево слоев представлено 3 деревьями), но об это этом позже. Сейчас нам важны только 2.
Точно так же, как и вью, мы можем добавлять слои друг на друга и выстраивать целые иерархии. Все они складываются в свое дерево, не ограничиваясь той вью, которой они принадлежат. Обратите внимание, что все вью обязательно содержат слой, а вот слои могут существовать без привязки к какому-то вью. Это тоже очень важный момент, который мы разберем в разделе с анимацией.
4.1 frame/bounds
CALayer | UIView |
frame | frame |
bounds | bounds |
position | center |
Вопрос, который ставит в тупик большинство не очень опытных разработчиков: чем отличаются frame и bounds. Оба эти свойства определяются 4 значениями x, y, width, height, т.е. размеры и точка ориджин (левый верхний угол). В обычной ситуации для вью размеры frame и bounds будут совпадать (забегая вперед, скажу, что это не всегда так), а вот точка ориджин скорее всего будет отличаться. Дело в том, что frame — это размеры и координаты вью относительно его родителя, а вот bounds — это собственная система координат, поэтому его ориджин равен (0.0,0.0) (опять же есть случаи, когда это не так, и мы рассмотрим их дальше).
Вообще как сами слои, так и вью описываются тремя основными свойствами frame, bounds и position/center для слоев и вью соответственно. С frame и bounds понятно, а position и center встречаются гораздо реже. Оба эти свойства определяют центральную точку. frame высчитывается из bounds + center + transform (трансформации разберем отдельно в следующий раз). Таким образом, размеры frame и bounds могут не совпадать.
В этом случае размер frame станет в два раза меньше, чем размер bounds.
Второй момент, про который здесь стоит знать, это как себя ведет bounds на UIScrollView и его наследниках. Размер скролящегося контента, как правило, превышает размеры самого вью. Поэтому когда мы скролим, изменяется видимая часть контента (меняется contentOffset) и как следствие внутренняя система координат, т.е изменяется точка ориджин (фактически bounds.origin == contentOffset).
4.2 Content
CALayer | UIView |
contents | |
contentsGravity | contentMode |
contentsScale | contentScaleFactor |
contentsRect | |
contentsCenter | |
maskToBounds | clipToBounds |
сontents — это непосредственно содержимое слоя, которое будет отрисовано в интерфейсе. Несмотря на то, что свойство имеет тип Any, если вы попытаетесь поместить туда что-либо кроме CGImage, вы получите пустой слой. Это обусловлено наследием из macOS, где в contents вы можете присвоить как CGImage, так и NSImage.
Pixel — единица реального размера изображения.
Point — виртуальные (логические) пиксели. На Retina экранах один поинт содержит два и более физических пикселя. Именно эти поинты чаще всего используются в системе координат iOS. Узнать соотношение pixel/point на вашем устройстве можно UIScreen.main.nativeScale.
Unit — относительное значения в диапазоне от 0 до 1.
contentsGravity – это свойство, которое определяет, как контент должен располагаться в границах слоя. Принцип работы аналогичен contentMode у UIImageView (должен быть хорошо вам знаком).
contentsScale достаточно непонятное свойство. Дефолтное значение 1. Оно указывает, сколько физических пикселей картинки помещается в одном поинте слоя. Стало понятнее? Кажется нет. Хорошо, давайте рассмотрим на примере. Возьмем картинку с разрешением 30×30 пикселей и разместим ее в слое, размером 10×10 поинтов. Она растянута по размерам слоя. Дело в том, что по дефолту свойство contentsGravity имеет значение .resize, которое автоматически растягивает контент под размер слоя. Но если мы зададим значение, например .center, то увидим, что изображение не помещается в рамки нашего слоя. Это происходит, потому что в каждом поинте слоя размещается всего 1 пиксель картинки. Давайте зададим contentsScale значение равно 3 и посмотрим, что получится.
contentsRect — еще одно свойство, позволяющее растягивать и обрезать контент внутри слоя. В отличие от contentsGravity позволяет это делать более гибко. Измеряется в Unit. Дефолтное значение (x: 0.0, y: 0.0, width: 1.0, height: 1.0).
contentsCenter определяет растягиваемую область слоя и также измеряется в Unit. Дефолтное значение (x: 0.0, y: 0.0, width: 1.0, height: 1.0).
Кстати, если вы любите пользоваться Interface builder (Storyboard/Xib) и не знали, что это за блок, в нем как раз и задаются значения contentsCenter.
4.3 Coordinate System
Мы знаем, что слои, как и вью, располагаются в деревьях, выстраивая иерархию относительно своих родителей. Поэтому при перемещении какого-либо слоя в иерархии вместе с ним перемещаются все его подслои (перемещается поддерево). Но иногда бывает полезно узнать положение слоя в системе координат другого слоя, отличного от его родителя (например, в системе координат самого корневого на экране). Для этого у нас есть несколько методов, которые преобразуют точку или некоторую площадь, определенную в одной системе координат, в точку/площадь в системе координат другого слоя.
open func convert(_ p: CGPoint, from l: CALayer?) -> CGPoint
open func convert(_ p: CGPoint, to l: CALayer?) -> CGPoint
open func convert(_ r: CGRect, from l: CALayer?) -> CGRect
open func convert(_ r: CGRect, to l: CALayer?) -> CGRect
Думаю, вы знаете, что в iOS нулевой точкой является левый верхний угол. В macOS левый нижний.
Этим свойством мы тоже можем управлять.
isGeometryFliped для iOS дефолтное значение false. Если это свойство слоя изменить на true, то все его подслои окажутся перевернуты по вертикали и будут расположены относительно нижнего левого угла.
anchorPoint — точка, вокруг которой происходят изменения. Измеряется в Unit и по дефолту совпадает с position. Важная особенность: при изменении этой точки меняется position слоя и соответственно его положение в пространстве. Когда будете переносить anchorPoint, не забывайте пропорционально сдвигать слой в противоположную сторону.
Со слоями можно работать в трехмерном пространстве. Это позволяет производить разные сложные манипуляции над ними и создавать объемные фигуры. Поэтому слои имеют два дополнительных свойства.
zPosition — увеличивая и уменьшая значение этого свойства, мы можем управлять положением слоя в иерархии. Так как слои можно рассматривать, как бесконечно плоские объекты, изменения достаточно на совсем незначительные величины (например, 0.001). Также важно учитывать, что перемещение слоев в иерархии с помощью этого свойства не влияет на обработку Responder Chain.
anchorPointZ – точка на оси Z, вокруг которой происходят трехмерные трансформации. В отличие от anchorPoint это значение CGFloat. Подробнее поговорим об этом когда будем разбираться с трансформациями.
4.4 Visual Effects
CALayer | UIView |
backgroundColor | backgroundColor |
opacity | alpha |
isHidden | isHidden |
isOpaque | isOpaque |
cornerRadius | |
maskedCorners | |
borderWidth | |
borderColor |
Для удобства работы с цветом, буду использовать расширение.
extension UIColor {
convenience init(hex: Int, alpha: CGFloat = 1) {
let r = CGFloat((hex & 0xff0000) >> 16) / 255
let g = CGFloat((hex & 0x00ff00) >> 8) / 255
let b = CGFloat(hex & 0x0000ff) / 255
self.init(red: r, green: g, blue: b, alpha: alpha)
}
}
backgroundColor аналогичен backgroundColor UIView.
opacity аналогичен alpha UIView. Распространяется на всю иерархию подслоев. Обратите внимание, что прозрачность применяется для всего слоя, включая всю иерархию его подслоев, как для единого контента. Есть свойство, которое управляет этим поведением allowsGroupOpacity (по дефолту имеет значение true). Если установить false прозрачность для каждого дочернего слоя будет устанавливаться отдельно.
isHidden аналогичен isHidden UIView.
isOpaque аналогичен isOpaque UIView. Это вспомогательное логическое свойство, которое определяет, может ли слой иметь прозрачность. Дефолтное значение false значит, что контент слоя может иметь альфа компонент. Рендер слоев, которые имеют прозрачность — дорогостоящая операция. Если задать значение true, мы гарантируем, что весь контент слоя полностью не прозрачен. Это позволяет системе оптимизировать поведение отрисовки, что положительно влияет на наш любимый перфоманс (Core Animation пропускает значение альфа канала при вычислениях).
cornerRadius устанавливает значение кривизны углов. Важно, что это значение влияет только на цвет слоя, но не влияет на contents и на дочерние слои. Чтобы ограничить внутренний контент слоя закругленными углами, используем свойство maskToBounds. Также можно скруглить отдельно один или несколько углов, с помощью свойства maskedCorners:
layerMinXMinYCorner
layerMaxXMinYCorner
layerMinXMaxYCorner
layerMaxXMaxYCorner
borderWidth & borderColor — два свойства, которые управляют шириной и цветом рамки слоя. Цвет слоя имеет тип CGColor.
4.5 Shadow
shadowOpacity — свойство, управляющее видимостью тени. Принимает значения от 0 до 1 (по дефолту 0). Если мы установим значение, отличное от 0, то увидим небольшую тень, направленную вверх (система координат в iOS же перевернутая).
shadowColor, как вы можете догадаться, управляет цветом тени. Как и borderColor имеет тип CGColor.
shadowOffset задает направление и сдвиг тени по осям координат. По гайдам Apple тени должны быть направлены вертикально вниз, а мы помним, что в iOS перевернутая система координат, поэтому по дефолту она направлена вверх. Тип CGSize.
shadowRadius — это свойство контролирует размытость тени. Чем больше это значение, тем сильнее размытие. Если мы хотим явно показать, что один слой находится выше другого в иерархии, то для него стоит указать большее значение, а для того, что ниже — меньшее.
Тень определяется не границами слоя, а его содержимым (contents & sublayers).
shadowPath – процесс отрисовки тени тоже является очень дорогим с точки зрения производительности, поэтому если нам нужна сложная форма слоя или особое поведение тени, чтобы облегчить вычисления в рантайме и улучшить перфоманс, мы можем указать путь для тени явно.
4.6 Mask
maskToBounds — аналог свойства clipToBounds у UIView. Обрезает контент слоя по его границам. Учитывает свойство cornerRadius. Но так как тень находится за пределами границ слоя, она будет не видна.
mask — это свойство само по себе является отдельным слоем и он имеет все те же самые свойства управления слоем. Но он не отображается, как обычный подслой, а определяет видимую часть родительского слоя своими границами.
4.7 Scale
Работа с изображениями одна из самых дорогостоящих с точки зрения нагрузки на GPU. Иногда нам необходимо отобразить изображение в большем или меньшем размере фактического. Хранить отдельные копии для каждого возможного размера непрактично, поэтому слой предоставляет нам на выбор 3 фильтра масштабирования:
nearest (фильтрация ближним соседом) выбирает ближайший одиночный пиксель и не выполняет смешивание цветов.
linear (билинейная фильтрация) работает путем выборки нескольких пикселей.
trilinear (трилинейная фильтрация) сохраняет изображения в нескольких размерах (MIP-mapping), а затем комбинирует пиксели из большего и меньшего размеров. Результат очень похож на билинейную фильтрацию, но более производительный.
minificatorFilter используется при уменьшении размера контента.
magnificatorFilter используется при увеличении размера контента.
Вывод
На самом деле статья получилась больше про Core Animation, чем про UIKit, но рассказать хотелось именно про UI и дополнительные возможности работы с ним. Здесь мы рассмотрели базовые возможности слоев и как они связаны с вью.
Итак, подводя итоги первой части, хочется, чтобы появилось понимание зоны ответственности разных фреймворков. Несмотря на то, что большую часть времени работы с интерфейсом мы используем объекты UIView, на самом деле все атрибуты внешнего вида мы настраиваем через слои, даже если не подозреваем об этом. Работая напрямую со слоями, мы имеем гораздо больше возможностей для настройки внешнего вида интерфейса.
Дальше разберемся, как еще можно изменять слои, какие они бывают и как вью мешает анимироваться слоям.