UIView animate / 1.1. Анимации ограничений



Под этим солнцем и небом, мы тепло приветствуем тебя

Данмеры

В этом уроке


Core Animation

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

Синопсис нашего приложения - наша святая Госпожа Анна решила ехать в отпуск на остров Святой Анны. Ей нужно составить правильный рецепт летнего отдыха. Так каким же он будет?


Модель данных

Как обычно начнем с модели данных:

  1. Перенесите графические материалы в ассеты
  2. Создайте стандартные группы MVC
  3. В Model перенесите файл icons.plist
  4. В качестве самостоятельного задания создайте класс Icon для хранения иконок (он будет очень похож на то, что мы уже делали для приложения с любимыми иконками Валерии):
    1. Он будет содержать в себе название иконки name
    2. Название картинки imageName
    3. Быть декодируемым
    4. Иметь свойство image, отдающее картинку по названию
    5. Постарайтесь сократить код и обойтись без инициализатора
import UIKit

class Icon: Codable {
    let name: String!
    let imageName: String!

    var image: UIImage {
        return UIImage(named: imageName)!
    }
}
  1. Для этого же класса создайте свойство icons, возвращающее все иконки из файла данных:
extension Icon {
    static let icons: [Icon] = {
        let url = Bundle.main.url(forResource: "icons", withExtension: "plist")!
        let data = try! Data(contentsOf: url)
        return try! PropertyListDecoder().decode([Icon].self, from: data)
    }()
}

Горизонтальное представление

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

Мы унаследуем его от scroll view:

import UIKit

class HorizontalView: UIScrollView {

    var didSelectItem: ((_ index: Int) -> Void)?

    let buttonWidth: CGFloat = 60.0
    let padding: CGFloat = 10.0
}

Мы специально добавили замыкание-свойство, которое будет вызываться при выборе объекта. Это ещё один подход к паттерну делегации, просто более краткий и рискованный (получить цикл сильных ссылок намного проще через этот путь.

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

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

required init(coder aDecoder: NSCoder) {
    fatalError("init(coder:) has not been implemented")
}


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

А обычный конструктор с кадром должен проксировать родительский:

override init(frame: CGRect) {
    super.init(frame: frame)
}


Также реализуйте функцию didTapImage(_:), принимающую распознаватель нажатия, которая будет вызывать наше делегирующее замыкание, если оно есть и передавать в него тэг нажатого объекта (метод должно быть можно вызывать из рантайма objc):

@objc  func didTapImage(_ tap: UITapGestureRecognizer) {
    didSelectItem?(tap.view!.tag)
}


Устали думать самостоятельно? Давайте сделаем работу вместе и набросаем инициализатор нашего собственного конструктора:

convenience init(in view: UIView) {
    let rect = CGRect(x: 0, y: 120.0, width: view.frame.width, height: 80.0)
    self.init(frame: rect)
}

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

Теперь будем последовательно добавлять в него участки кода:

alpha = 1.0
//Делаем полностью видимым

for (i, icon) in Icon.icons.enumerated() {
    let image = icon.image
}
//Обходим все иконки и извлекаем их изображения

Теперь спустимся внутрь цикла:

let imageView = UIImageView(frame: CGRect(x: 0, y: 0, width: kButtonWidth, height: kButtonWidth))
//Создаём представление для картинки
imageView.image = image
//И задаём её

Зададим центр представления для картинки:

imageView.center = CGPoint(x: CGFloat(i) * (kButtonWidth + kPadding) + kButtonWidth / 2, y: kButtonWidth / 2 + kPadding)

Теперь небольшая самостоятельная работа:

  1. Установите тэг представлению, равный шагу цикла
  2. Разрешите взаимодействие с изображением
  3. Режим отображения Aspect Fit
  4. Добавьте в иерархию представлений эту картинку
imageView.tag = i
imageView.isUserInteractionEnabled = true
imageView.contentMode = .scaleAspectFit
addSubview(imageView)

И ещё одно!:

  1. Создайте распознаватель нажатий с селектором с ранее созданным методом
  2. Добавьте его на картинку
let tap = UITapGestureRecognizer(target: self, action: #selector(didTapImage(_:)))
imageView.addGestureRecognizer(tap)

И в заключение в конце метода добавим задание размера контента представления:

contentSize = CGSize(width: (kPadding + kButtonWidth) * CGFloat(Icon.icons.count) - kPadding, height: kButtonWidth + 2 * kPadding)

Он вычисляется как число картинок, умноженное на размер одной, а также + число отступов, меньшее на 1, чем картинок. А высота как ширина картинки и 2 отступа


Основной контроллеръ

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

Нам часто придётся менять булево значение на противоположное. В стандарте Swift 5 уже вероятно будет этот метод, но здесь мы создадим его самостоятельно:

  1. Создайте расширение Bool+Toggle
  2. В нём добавьте мутирующий метод toggle
  3. Он должен менять значение логической величины на противоположное
extension Bool {
    mutating func toggle() {
        self = !self
    }
}


До выхода Swift 5 Вы вполне можете использовать этот метод в своих проектах.

Теперь на холсте на главный контроллер добавьте UIView:

  1. Цвет Group Table View Background Color
  2. Фиксированная высота в 60 точек
  3. Отступы от всех границ родительского представления кроме нижнего в 0 точек

На него в свою очередь добавьте лейбл:

  1. Текст Packing List
  2. Аутлет titleLabel
  3. Выравнивание текста по центру метки
  4. Размер шрифта 21
  5. Выравнивание метки по горизонтали
  6. 5 точек вниз относительно горизонтальной оси родительского представления

На то же представление правее лейбла добавьте кнопку:

  1. Текст +
  2. Размкр текста 27
  3. Отступ от правой грани представления в8 точек
  4. Вертикальное выравнивание по центру лейбра
  5. Аутлет menuButton

А также табличное представление:

  1. Аутлет tableView
  2. Отступы от всех сторон родительского представления и от нашего верхнего в 0
  3. Делегатом и источником данных установите сам контроллер
  4. Его ячейке задайте идентификатор Cell, а режим выделения Default

Теперь добавим в этот класс несколько свойств:

var slider: HorizontalView!
//Наше горизонтальное меню
var isMenuOpen = false
//Открыто ли меню
var items = [5, 6, 7]
//Преполуированные вещи

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

func makeSlider() {
    slider = HorizontalView(in: view)
    //Для слайдера главным представлением будет представление контроллера
    slider.didSelectItem = { index in
        self.items.append(index)
        //Вещь добавляется в массив
        self.tableView.reloadData()
        //Таблица перегружается
    }
    titleLabel.superview?.addSubview(slider)
}

Проявите немного самостоятельности и во viewDidLoad:

  1. Вызовите данный метод
  2. А в конце задайте высоту ячейки табличного представления в 54 точки
override func viewDidLoad() {
    super.viewDidLoad()

    makeSlider()

    tableView.rowHeight = 54.0
}

Теперь реализуйте в расширении протоколы источника данных и делегата табличного представления;

  1. Число объектов равно числу вещей в items
  2. Ячейка должна содержать название вещи и картинку
  3. При нажатии на ячейку с неё должно спадать выделение
extension ViewController: UITableViewDelegate, UITableViewDataSource {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return items.count
    }

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath)

        let icon = Icon.icons[items[indexPath.row]]

        cell.textLabel?.text = icon.name
        cell.imageView?.image = icon.image

        return cell
    }

    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        tableView.deselectRow(at: indexPath, animated: true)
    }
}

Анимирование констант ограничений

Нам сперва нужно создать два аутлета на ограничения (объекты ограничений также могут выступать аутлетами, что довольно круто):

@IBOutlet  weak var menuHeightConstraint: NSLayoutConstraint!
@IBOutlet  weak var buttonMenuTrailingConstraint: NSLayoutConstraint!

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

Теперь мы сможем изменять их из кода:

  1. Создайте метод toggleMenu от нашей кнопки, но вместо Any задайте AnyObject (мы воспольуземся им в дальнейшем и хотим быть уверены, что сможем передать в него лишь объект класса)
  2. Теперь заполним его:
isMenuOpen.toggle()
//Переключаем меню

titleLabel.text = isMenuOpen ? "Select Item" : "Packing List"
//Меняем текст лейбла

menuHeightConstraint.constant = isMenuOpen ? 200.0 : 60.0
//Если меню открыто, то расширяем его до 200 точек или наоборот - сжимаем
buttonMenuTrailingConstraint.constant = isMenuOpen ? 16.0 : 8.0
//А также сдвигаем ближе к центру нашу кнопку

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

Добавим в конце метода анимацию:

UIView.animate(
    withDuration: 1.0,
    delay: 0.0,
    usingSpringWithDamping: 0.4,
    initialSpringVelocity: 10.0,
    options: [.allowUserInteraction],
    //Разрешаем взаимодействие с анимируемыми объектами
    animations: {
        self.view.layoutIfNeeded()
    },
    completion: nil
)

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

И хотя мы задали опции, если бы там были кривые времени, то spring-анимация их бы проигнорировала.

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

let angle: CGFloat = self.isMenuOpen ? .pi / 4 : 0.0
self.buttonMenu.transform = CGAffineTransform(rotationAngle: angle)

Обратите внимание на первую строчку - все повороты выполняются в радианах, как мы помним. Нам необязательно использовать тут Measurement для преобразований. Полная окружность соответствует 2pi, потому наши 45 градусов равны 2pi / 8 = pi / 4 радиан.

Результат:

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

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


Анимирование множителей ограничений

Но проблема не ушла! Мы попробуем исправить ситуацию, но для этого придётся пролить море крови.

Сперва добавим смещение заголовку влево, изменив центрирование по x. Для этого допишем следующее после изменения лейаута заголовка:

//Обходим все ограничения родительского представления
titleLabel.superview?.constraints.forEach{ (constraint) in
//Если в них участвует лейбл и атрибут `центр по X`
if constraint.firstItem === titleLabel &&
    constraint.firstAttribute == .centerX {
    //То смещаем либо возвращаем на место
    constraint.constant = isMenuOpen ? -100.0 : 0.0
    //Мы также хотим, чтобы на этом шаг цикла кончился в любом случае
    return
}

Здесь мы изменяли лишь константу ограничения, но как быть с тем, что нам нужно изменить множитель? Никак. Множитель задан как свойство-только-для-чтения.

Здесь подойдёт решение в лоб - заменить старое ограничение на новое. Это как феникс - наше ограничение умирает и тут же возрождается из пепла в новом обличии.

Сперва найдите наше ограничение центрирования по оси Y и задайте ему идентификатор TitleCenterY.

Теперь в том же цикле forEach добавим другую ветвь условия:

if constraint.identifier == "TitleCenterY" {
    constraint.isActive = false

}

Так мы отключили ограничение и при следующем применении изменений, оно будет убрано.

Нам кажется, мы давно не делали самостоятельных заданий. Исправим это. Создайте ограничение newContstraint через код:

  1. Первый объект - заголовок
  2. Атрибут - центрирование по Y
  3. Отношение равенства
  4. Целевой объект - супервью для заголовка
  5. Атрибут - тот же
  6. Множитель - 0.67 для открытого меню и 1 для закрытого
  7. Константа - 5
  8. После создания присвойте ему идентификатор старого ограничения
  9. И активируйте его
let newConstraint = NSLayoutConstraint(
    item: titleLabel,
    attribute: .centerY,
    relatedBy: .equal,
    toItem: titleLabel.superview!,
    attribute: .centerY,
    multiplier: isMenuOpen ? 0.67 : 1.0,
    constant: 5.0
)
newConstraint.identifier = "TitleCenterY"
newConstraint.isActive = true


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


Анимирование через транзитные переходы

Помимо собственных анимаций, UIView предоставляет несколько готовых анимаций.

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

У таких переходов есть единственная существенная проблема - в них нельзя задать значение задержки выполнения. Решим её!

Сперва небольшое самостоятельное задание (не нойте, у Вас уже много опыта вообще-то!). Нужно создать метод delay(seconds:block:), который будет выполнять переданный блок кода на главном потоке через заданное число секунд:

func delay(seconds: Double, block: @escaping () -> Void) {
    DispatchQueue.main.asyncAfter(deadline: .now() + seconds, execute: block)
}

А теперь наш метод для закрытия меню:

func transitionCloseMenu() {
    delay(seconds: 0.35) {
        self.toggleMenu(self)
    }
    //Сперва мы откладываем переключение меню на 0.35 секунды

    let titleBar = slider.superview!
    //Запоминаем супервью для слайдера

    UIView.transition(
        with: titleBar,
        duration: 0.5,
        options: [.curveEaseOut, .transitionFlipFromBottom],
        animations: {
            self.slider.removeFromSuperview()
            //Удаляем слайдер из супервью с анимацией переворотора
    }) { (_) in
        titleBar.addSubview(self.slider)
        //По заверешнии анимации незаметно его вовзращаем
    }
}

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


Ограничения через анкоры

Последним штрихом нашей программы станет увеличение картинки и вывод её снизу экрана для выбранной ячейки.

Сперва немного самостоятельных заданий - создайте метод makeImageView(index: Int) -> UIImageView, который на основе индекса вещи, будет создавать для неё следующее графическое представление:

  1. Соответствующая картинка
  2. Цвет заднего фона Черный полупрозрачный
  3. Радиус углов - 10
  4. Masks To Bounds - true
  5. Отключенное преобразование масок авторесайзинга в ограничения
func makeImageView(index: Int) -> UIImageView {
    let imageView = UIImageView(image: Icon.icons[items[index]].image)
    imageView.backgroundColor = #colorLiteral(red: 0, green: 0, blue: 0, alpha: 0.5)
    imageView.layer.cornerRadius = 10.0
    imageView.layer.masksToBounds = true
    imageView.translatesAutoresizingMaskIntoConstraints = false
    return imageView
}

Теперь начнём создавать наш основной метод для отображения объекта:

func showItem(index: Int) {
    let imageView = makeImageView(index: index)
    view.addSubview(imageView)

    let constraintX = imageView.centerXAnchor.constraint(equalTo: view.centerXAnchor)
}

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

let constraintBottom = imageView.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: imageView.frame.height)

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

let constraintWidth = imageView.widthAnchor.constraint(equalTo: view.widthAnchor, multiplier: 0.33, constant: -50.0)

Это ограничение свяжет ширину представления-картинки с главным путём её умножения на 0.33 и ещё и вычитания из неё 50 точек.

И последним ограничением мы приравниваем высоту картинки к ширине:

let contraintHeight = imageView.heightAnchor.constraint(equalTo: imageView.widthAnchor)

NSLayoutConstraint.activate([constraintX, constraintWidth, contraintHeight, constraintBottom])
//Активируем ограничения
view.layoutIfNeeded()
//Применяем их

Попробуйте самостоятельно создать анимацию:

  1. Время - 0.8
  2. Демпинг - 0.6
  3. Скорость - 10
  4. Содержимое анимации:
    • Поднять картинку на две её высоты
    • Убрать константу у ограничения на ширину
UIView.animate(
    withDuration: 0.8,
    delay: 0.0,
    usingSpringWithDamping: 0.6,
    initialSpringVelocity: 10.0,
    animations: {
        constraintBottom.constant = -imageView.frame.height / 2
        constraintWidth.constant = 0.0
        self.view.layoutIfNeeded()
    },
    completion: nil
)

И добавьте вызов этого метода в didSelectRow:

showItem(index: indexPath.row)

Теперь всё замечательно. Только картинки собираются в пачку и не исчезают.

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

delay(seconds: 1.2) {
    UIView.transition(
        with: imageView,
        duration: 1.0,
        options: [.transitionCrossDissolve],
        animations: {
            imageView.isHidden = true
        }, completion: { (_) in
            imageView.removeFromSuperview()
    })
}

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


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