Под этим солнцем и небом, мы тепло приветствуем тебя
Мы ранее уже анимировали объекты, но сегодня мы пойдём дальше и научимся анимировать переходы между представлениями и ограничения.
Синопсис нашего приложения - наша святая Госпожа Анна решила ехать в отпуск на остров Святой Анны. Ей нужно составить правильный рецепт летнего отдыха. Так каким же он будет?
Как обычно начнем с модели данных:
MVC
Model
перенесите файл icons.plist
Icon
для хранения иконок (он будет очень похож на то, что мы уже делали для приложения с любимыми иконками Валерии):
name
imageName
image
, отдающее картинку по названиюimport UIKit
class Icon: Codable {
let name: String!
let imageName: String!
var image: UIImage {
return UIImage(named: imageName)!
}
}
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)
Теперь небольшая самостоятельная работа:
Aspect Fit
imageView.tag = i
imageView.isUserInteractionEnabled = true
imageView.contentMode = .scaleAspectFit
addSubview(imageView)
И ещё одно!:
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
уже вероятно будет этот метод, но здесь мы создадим его самостоятельно:
Bool+Toggle
toggle
extension Bool {
mutating func toggle() {
self = !self
}
}
До выхода Swift 5 Вы вполне можете использовать этот метод в своих проектах.
Теперь на холсте на главный контроллер добавьте UIView
:
Group Table View Background Color
60
точек0
точекНа него в свою очередь добавьте лейбл:
Packing List
titleLabel
21
5
точек вниз относительно горизонтальной оси родительского представленияНа то же представление правее лейбла добавьте кнопку:
+
27
8
точекmenuButton
А также табличное представление:
tableView
0
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
:
54
точкиoverride func viewDidLoad() {
super.viewDidLoad()
makeSlider()
tableView.rowHeight = 54.0
}
Теперь реализуйте в расширении протоколы источника данных и делегата табличного представления;
items
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!
Первое ограничение свяжите с ограничением на высоту меню, а второе - с трейлинг-ограничением кнопки меню.
Теперь мы сможем изменять их из кода:
toggleMenu
от нашей кнопки, но вместо Any
задайте AnyObject
(мы воспольуземся им в дальнейшем и хотим быть уверены, что сможем передать в него лишь объект класса)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
через код:
Y
0.67
для открытого меню и 1
для закрытого5
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
, который на основе индекса вещи, будет создавать для неё следующее графическое представление:
Черный полупрозрачный
10
Masks To Bounds
- true
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()
//Применяем их
Попробуйте самостоятельно создать анимацию:
0.8
0.6
10
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()
})
}
Мы добавили изъятие картинки из представления в блок завершения, чтобы она уничтожалась в памяти. Если этого не сделать, то объект станет невидимым, но продолжит существовать, а это не самая эффективная трата ресурсов.
Правильный ответ - ушки госпожи, бикини и крайне алкогольный коктейль.