
В прошлой статье мы реализовали анимацию ZoomIn/ZoomOut для открытия и закрытия экрана с историями.
В этот раз мы прокачаем StoryBaseViewController и реализуем кастомные анимации при переходе между историями.
Навигация между историями
Давайте сделаем анимацию для переходов между историями.

enum TransitionOperation {
case push, pop
}
public class StoryBaseViewController: UIViewController {
// MARK: - Constants
private enum Spec {
static let minVelocityToHide: CGFloat = 1500
enum CloseImage {
static let size: CGSize = CGSize(width: 40, height: 40)
static var original: CGPoint = CGPoint(x: 24, y: 50)
}
}
// MARK: - UI components
private lazy var closeButton: UIButton = {
let button = UIButton(type: .custom)
button.setImage(#imageLiteral(resourceName: "close"), for: .normal)
button.addTarget(self, action: #selector(closeButtonAction(sender:)), for: .touchUpInside)
button.frame = CGRect(origin: Spec.CloseImage.original, size: Spec.CloseImage.size)
return button
}()
// MARK: - Private properties
// 1
private lazy var percentDrivenInteractiveTransition: UIPercentDrivenInteractiveTransition? = nil
private lazy var operation: TransitionOperation? = nil
// MARK: - Lifecycle
public override func loadView() {
super.loadView()
setupUI()
}
}
extension StoryBaseViewController {
private func setupUI() {
// 2
let panGestureRecognizer = UIPanGestureRecognizer(target: self, action: #selector(handlePanGesture(_:)))
panGestureRecognizer.delegate = self
view.addGestureRecognizer(panGestureRecognizer)
view.addSubview(closeButton)
}
@objc
private func closeButtonAction(sender: UIButton!) {
dismiss(animated: true, completion: nil)
}
}
// MARK: UIPanGestureRecognizer
extension StoryBaseViewController: UIGestureRecognizerDelegate {
@objc
func handlePanGesture(_ panGesture: UIPanGestureRecognizer) {
handleHorizontalSwipe(panGesture: panGesture)
}
// 3
private func handleHorizontalSwipe(panGesture: UIPanGestureRecognizer) {
let velocity = panGesture.velocity(in: view)
// 4 Отвечает за прогресс свайпа по экрану, в диапазоне от 0 до 1
var percent: CGFloat {
switch operation {
case .push:
return abs(min(panGesture.translation(in: view).x, 0)) / view.frame.width
case .pop:
return max(panGesture.translation(in: view).x, 0) / view.frame.width
default:
return max(panGesture.translation(in: view).x, 0) / view.frame.width
}
}
// 5
switch panGesture.state {
case .began:
// 6
percentDrivenInteractiveTransition = UIPercentDrivenInteractiveTransition()
percentDrivenInteractiveTransition?.completionCurve = .easeOut
navigationController?.delegate = self
if velocity.x > 0 {
operation = .pop
navigationController?.popViewController(animated: true)
} else {
operation = .push
let nextVC = StoryBaseViewController()
nextVC.view.backgroundColor = UIColor.random
navigationController?.pushViewController(nextVC, animated: true)
}
case .changed:
// 7
percentDrivenInteractiveTransition?.update(percent)
case .ended:
// 8
if percent > 0.5 || velocity.x > Spec.minVelocityToHide {
percentDrivenInteractiveTransition?.finish()
} else {
percentDrivenInteractiveTransition?.cancel()
}
percentDrivenInteractiveTransition = nil
navigationController?.delegate = nil
case .cancelled, .failed:
// 9
percentDrivenInteractiveTransition?.cancel()
percentDrivenInteractiveTransition = nil
navigationController?.delegate = nil
default:
break
}
}
}Чтобы наша анимация была интерактивной и следовала за движением пальца, мы создаем объект
percentDrivenInteractiveTransition. Аoperationотвечает за тип перехода (pushилиpop).Добавляем наш жест во view.
Реализуем обработчик нажатия/свайпа.
percentотвечает за прогресс свайпа по экрану в диапазоне от 0 до 1.В зависимости от состояния жеста конфигурируем наши свойства.
Как только начинается новый жест, создаем свежий экземпляр
UIPercentDrivenInteractiveTransitionи сообщаем делегатуnavigationController’а, что мы самостоятельно его реализуем (реализация будет ниже). Если направление свайпа положительное, то мы сохраняем в переменнуюoperationзначение.pop, и сообщаемnavigationController’у, что мы начали процесс перехода с анимацией.navigationController?.popViewController(animated: true). Аналогично делаем для.push-перехода.Когда наш свайп уже активен, мы передаем его прогресс в
percentDrivenInteractiveTransition.Если мы просвайпили более половины экрана, или это было сделано с скоростью более 1500, то мы завершаем наш переход
percentDrivenInteractiveTransition?.finish(). В противном случае отменяем переход. При этом необходимо очиститьpercentDrivenInteractiveTransitionиnavigationController?.delegate.В случае отмены свайпа мы также отменяем переход и очищаем значения.
Сейчас при начале свайпа нужно сообщить navigationController’у, что мы реализуем делегат navigationController?.delegate = self. Но мы этого так и не сделали. Самое время:
// MARK: UINavigationControllerDelegate
extension StoryBaseViewController: UINavigationControllerDelegate {
// 1
public func navigationController(
_ navigationController: UINavigationController,
animationControllerFor operation: UINavigationController.Operation,
from fromVC: UIViewController,
to toVC: UIViewController
) -> UIViewControllerAnimatedTransitioning? {
switch operation {
case .push:
return StoryBaseAnimatedTransitioning(operation: .push)
case .pop:
return StoryBaseAnimatedTransitioning(operation: .pop)
default:
return nil
}
}
// 2
public func navigationController(
_ navigationController: UINavigationController,
interactionControllerFor animationController: UIViewControllerAnimatedTransitioning
) -> UIViewControllerInteractiveTransitioning? {
return percentDrivenInteractiveTransition
}
}Этот метод возвращает аниматор для соответствующего перехода.
Возвращаем объект типа
UIPercentDrivenInteractiveTransition, который отвечает за прогресс интерактивного перехода.
Аниматор
Наконец-то реализуем аниматор, который непосредственно отвечает за поведение перехода.
Нам необходимы два метода делегата, отвечающие за продолжительность анимации и сам переход.
class StoryBaseAnimatedTransitioning: NSObject {
private enum Spec {
static let animationDuration: TimeInterval = 0.3
static let cornerRadius: CGFloat = 10
static let minimumScale = CGAffineTransform(scaleX: 0.85, y: 0.85)
}
private let operation: TransitionOperation
init(operation: TransitionOperation) {
self.operation = operation
}
}
extension StoryBaseAnimatedTransitioning: UIViewControllerAnimatedTransitioning {
// http://fusionblender.net/swipe-transition-between-uiviewcontrollers/
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
/// 1 Получаем view-контроллеры, которые будем анимировать.
guard
let fromViewController = transitionContext.viewController(forKey: .from),
let toViewController = transitionContext.viewController(forKey: .to)
else {
return
}
/// 2 Получаем доступ к представлению, на котором происходит анимация (которое участвует в переходе).
let containerView = transitionContext.containerView
containerView.backgroundColor = UIColor.clear
/// 3 Закругляем углы наших view при переходе.
fromViewController.view.layer.masksToBounds = true
fromViewController.view.layer.cornerRadius = Spec.cornerRadius
toViewController.view.layer.masksToBounds = true
toViewController.view.layer.cornerRadius = Spec.cornerRadius
/// 4 Отвечает за актуальную ширину containerView
// Swipe progress == width
let width = containerView.frame.width
/// 5 Начальное положение fromViewController.view (текущий видимый VC)
var offsetLeft = fromViewController.view.frame
/// 6 Устанавливаем начальные значения для fromViewController и toViewController
switch operation {
case .push:
offsetLeft.origin.x = 0
toViewController.view.frame.origin.x = width
toViewController.view.transform = .identity
case .pop:
offsetLeft.origin.x = width
toViewController.view.frame.origin.x = 0
toViewController.view.transform = Spec.minimumScale
}
/// 7 Перемещаем toViewController.view над/под fromViewController.view, в зависимости от транзишена
switch operation {
case .push:
containerView.insertSubview(toViewController.view, aboveSubview: fromViewController.view)
case .pop:
containerView.insertSubview(toViewController.view, belowSubview: fromViewController.view)
}
// Так как мы уже определили длительность анимации, то просто обращаемся к ней
let duration = self.transitionDuration(using: transitionContext)
UIView.animate(withDuration: duration, delay: 0, options: .curveEaseIn, animations: {
/// 8. Выставляем финальное положение view-контроллеров для анимации и трансформируем их.
let moveViews = {
toViewController.view.frame = fromViewController.view.frame
fromViewController.view.frame = offsetLeft
}
switch self.operation {
case .push:
moveViews()
toViewController.view.transform = .identity
fromViewController.view.transform = Spec.minimumScale
case .pop:
toViewController.view.transform = .identity
fromViewController.view.transform = .identity
moveViews()
}
}, completion: { _ in
///9. Убираем любые возможные трансформации и скругления
toViewController.view.transform = .identity
fromViewController.view.transform = .identity
fromViewController.view.layer.masksToBounds = true
fromViewController.view.layer.cornerRadius = 0
toViewController.view.layer.masksToBounds = true
toViewController.view.layer.cornerRadius = 0
/// 10. Если переход был отменен, то необходимо удалить всё то, что успели сделать. То есть необходимо удалить toViewController.view из контейнера.
if transitionContext.transitionWasCancelled {
toViewController.view.removeFromSuperview()
}
containerView.backgroundColor = .clear
/// 11. Сообщаем transitionContext о состоянии операции
transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
})
}
// 12. Время длительности анимации
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
return Spec.animationDuration
}Получаем view-контроллеры, которые будем анимировать.
Получаем доступ к представлению
containerView, на котором происходит анимация (участвующее в переходе).Закругляем углы наших view при переходе.
widthотвечает при анимации за актуальную ширинуcontainerView.offsetLeft— начальное положениеfromViewController.Конфигурируем начальное положение для экранов.
Перемещаем
toViewController.viewнад/подfromViewController.view, в зависимости от перехода.Выставляем финальное положение view-контроллеров для анимации и трансформируем их.
Убираем любые возможные трансформации и скругления.
Если переход был отменен, то необходимо удалить всё то, что успели сделать. То есть необходимо удалить
toViewController.viewиз контейнера.Сообщаем
transitionContextо состоянии перехода.Указываем длительность анимации.
Всё, наш аниматор готов. Теперь запускаем проект и наслаждаемся результатом. Анимации работают.

Весь исходный код можете скачать тут. Буду рад вашим комментариям и замечаниям!