
В прошлой статье мы реализовали анимацию 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о состоянии перехода.Указываем длительность анимации.
Всё, наш аниматор готов. Теперь запускаем проект и наслаждаемся результатом. Анимации работают.

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