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