Как UIView мешал слоям анимироваться

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

Заодно разберемся с таким понятием, как неявные анимации.

Если вы не читали мои предыдущие статьи, рекомендую ознакомиться сначала с ними:

1. Как мы создаем анимации в iOS?

Обычно, когда нам нужно что-то анимировать, мы используем стандартные конструкции UIView animate with duration. Если вы чуть более опытный разработчик, возможно вы используете CABaseAnimation или даже CADisplayLink. Но в основе всех этих методов лежит один механизм, который называется транзакциями. Его-то нам и предстоит сегодня разобрать.

2. Транзакции

Как мы знаем, внешним видом нашего интерфейса управляют объекты CALayer. Изменениями их свойств в свою очередь управляют объекты CATransaction. Этот класс не позволяет явно создавать экземпляры, он управляет стеком транзакций. Для создания новых транзакций мы используем методы класса.

CATransaction.begin() начинает новую транзакцию для текущего потока.

CATransaction.commit() фиксирует все изменения текущей транзакции.

CATransaction.flush() отменяет все изменения текущей транзакции.

CATransaction.begin()
layer.cornerRadius = layer.frame.width / 2
CATransaction.commit()

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

CATransaction.animationDuration() -> CFTimeInterval возвращает продолжительность текущей транзакции.

CATransaction.setAnimationDuration(_ dur: CFTimeInterval) устанавливает продолжительность текущей транзакции.

CATransaction.begin()
CATransaction.setAnimationDuration(1)
layer.cornerRadius = layer.frame.width / 2
CATransaction.commit()

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

Эквивалентом предыдущего примера является всем нам хорошо знакомая конструкция:

UIView.animate(withDuration: 1) { self.layer.cornerRadius = self.layer.frame.width / 2
}

Конструкции animate with duration позволяют обрабатывать блок кода непосредственно после завершения анимации:

UIView.animate(withDuration: 1) { self.layer.cornerRadius = self.layer.frame.width / 2
} completion: { completed in print("completed: \(completed)")
}

На уровне транзакций это будет выглядеть так:

CATransaction.begin()
CATransaction.setAnimationDuration(1)
CATransaction.setCompletionBlock { print("completed")
}
layer.cornerRadius = layer.frame.width / 2
CATransaction.commit()

Небольшое отступление: работу всех процессов в нашем приложении обеспечивает Run Loop. Это бесконечный цикл, на котором происходит обработка всех внешних и внутренних событий (тап пальцем по экрану, данные из сети, таймеры или простое обновление UI). Здесь же обрабатываются транзакции слоев. На каждой итерации ранлупа неявно создается новая транзакция. Все свойства слоев, которые мы меняем, попадают в нее.

Подробнее про Run Loop можно почитать здесь.

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

private lazy var myView: UIView = { let view = View() view.frame = CGRect(x: 0, y: 0, width: 100, height: 100) return view
}() view.addSubview(myView) myView.layer.cornerRadius = myView.frame.width / 2

Однако этого не происходит. Может быть дело в транзакциях? И действительно у транзакции есть метод, который запрещает анимации.

CATransaction.setDisableAction(_ flag: Bool)

Давайте создадим новую транзакцию и явно разрешим анимации.

CATransaction.begin()
CATransaction.setAnimationDuration(1)
CATransaction.setDisableActions(false)
myView.layer.cornerRadius = myView.layer.frame.height / 2
CATransaction.commit()

Анимации все равно нет. Будем разбираться, почему. Проведем несколько простых экспериментов:

Эксперимент №1: Создадим новый слой (без вью), добавим его на слой нашей вью и поменяем радиус его углов.

private lazy var myLayer: CALayer = { let layer = CALayer() layer.frame = CGRect(x: 0, y: 0, width: 100, height: 100) return layer
}() view.layer.addSublayer(myLayer) myLayer.cornerRadius = myLayer.frame.width / 2

Мы видим, что в этом случае свойство действительно изменяется анимировано. Логично предположить, что дело во вью, которое создает свой слой (backing layer). Давайте еще раз посмотрим на описание CALayer и увидим, что у него есть свойство delegate (CALayerDelegate). Если мы выведем его в консоль, то обнаружим, что делегатом этого слоя является вью, которое его создает. Это действительно так. Когда вью создает слой, она назначает себя делегатом этого слоя и переопределяет методы, описанные в этом протоколе. 

Эксперимент №2: Сбросим у слоя вью делегата и снова поменяем радиус его углов.

private lazy var myView: UIView = { let view = View() view.frame = CGRect(x: 0, y: 0, width: 100, height: 100) view.layer.delegate = nil return view
}() view.addSubview(myView) myView.layer.cornerRadius = myView.frame.width / 2

Мы видим, что теперь свойства вью тоже меняются анимировано. Значит наше предположение верно. Будем разбираться дальше.

3. CALayerDelegate

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

optional func action(for layer: CALayer, forKey event: String) -> CAAction?

Мы видим, что метод принимает слой и какой-то ивент в виде строки, а возвращает опциональный объект CAAction

CAAction –  это протокол, который описывает только один метод, отвечающий за выполнение действия анимации. Обычно для создания анимаций на уровне Core Animation мы используем класс CABaseAnimatoin. Есть и другие. Также мы можем создавать свои собственные классы и реализовывать этот проток. Но это отдельная большая тема и с ней мы разберемся в следующий раз.

Логично предположить, что базовый класс UIView реализует этот метод и возвращает nil вместо объекта анимации.

Эксперимент №3: Создадим свой класс-наследник UIView и переопределим у него этот метод, возвращая nil.

class View: UIView { override func action(for layer: CALayer, forKey event: String) -> CAAction? { print("event: \(event)") return nil }
} private lazy var myView: View = { let view = View() view.frame = CGRect(x: 0, y: 0, width: 100, height: 100) return view
}() view.addSubview(myView) myView.layer.cornerRadius = myView.frame.width / 2

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

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

Так, например, свойство isHidden:@property(getter=isHidden) BOOLhidden;

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

4. Неявные анимации

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

И вот их полный список:

  • anchorPoint

  • backgroundColor

  • borderColor

  • borderWidth

  • bounds

  • contents

  • contentsRect

  • cornerRadius

  • frame

  • isHidden

  • mask

  • maskToBounds

  • opacity

  • position

  • shadowColor

  • shadowOffset

  • shadowOpacity

  • shadowPath

  • shadowRadius

  • sublayers

  • sublayerTransform

  • transform

  • zPosition

Как говорит нам документация, есть целый алгоритм поиска экшена для свойства слоя:

  1. Если у слоя есть делегат, то вызывается метод делегата action for key. Он должен выполнить одно из следующих действий:

    • Вернуть объект действия для данного ивента.

    • Вернуть nil, в этом случае поиск продолжается дальше.

    • Вернуть NSNull и в этом случае поиск немедленно прекращается, а объект NSNull будет преобразован в nil.

  2. Дальше слой ищет экшен по ключу (название свойства) в словаре actions: [String: CAAction]?

  3. Если на предыдущем этапе не найден экшен, то поиск рекурсивно продолжается в словаре style: [String, Any]?. Рекурсивно, потому что значением может быть любой тип, даже словарь, который будет иметь ключ style. 

  4. Наконец, если объект анимации так и не был найден, вызывается метод класса, который возвращает дефолтную анимацию для текущего свойства слоя.

class func defaultAction(forKey event: String) -> CAAction?

Вывод

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

Оказывается, слои не просто так принадлежат Core Animation. Буквально за пару строк кода весь наш интерфейс становится анимированным. Теперь мы легко можем управлять этим процессом, переопределить поведение для отдельных свойств или всех сразу, либо даже создать новые и придумать для них интересные и нетривиальные анимации. Создавайте красивые интерфейсы!

Читайте так же: