Настало время офигительных историй. Кастомные транзишены в iOS. [2/2]

от автора

В прошлой статье мы реализовали анимацию 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         }     }      }
  1. Чтобы наша анимация была интерактивной и следовала за движением пальца, мы создаем объект percentDrivenInteractiveTransition. А operation отвечает за тип перехода (push или pop).

  2. Добавляем наш жест во view.

  3. Реализуем обработчик нажатия/свайпа.

  4. percent отвечает за прогресс свайпа по экрану в диапазоне от 0 до 1.

  5. В зависимости от состояния жеста конфигурируем наши свойства.

  6. Как только начинается новый жест, создаем свежий экземпляр UIPercentDrivenInteractiveTransition и сообщаем делегату navigationController’а, что мы самостоятельно его реализуем (реализация будет ниже). Если направление свайпа положительное, то мы сохраняем в переменную operation значение .pop, и сообщаем navigationController’у, что мы начали процесс перехода с анимацией .navigationController?.popViewController(animated: true). Аналогично делаем для .push-перехода.

  7. Когда наш свайп уже активен, мы передаем его прогресс в percentDrivenInteractiveTransition.

  8. Если мы просвайпили более половины экрана, или это было сделано с скоростью более 1500, то мы завершаем наш переход percentDrivenInteractiveTransition?.finish(). В противном случае отменяем переход. При этом необходимо очистить percentDrivenInteractiveTransition и navigationController?.delegate.

  9. В случае отмены свайпа мы также отменяем переход и очищаем значения.

Сейчас при начале свайпа нужно сообщить 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     }      }
  1. Этот метод возвращает аниматор для соответствующего перехода.

  2. Возвращаем объект типа 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     }
  1. Получаем view-контроллеры, которые будем анимировать.

  2. Получаем доступ к представлению containerView, на котором происходит анимация (участвующее в переходе).

  3. Закругляем углы наших view при переходе.

  4. width отвечает при анимации за актуальную ширину containerView.

  5. offsetLeft — начальное положение fromViewController.

  6. Конфигурируем начальное положение для экранов.

  7. Перемещаем toViewController.view над/под fromViewController.view, в зависимости от перехода.

  8. Выставляем финальное положение view-контроллеров для анимации и трансформируем их.

  9. Убираем любые возможные трансформации и скругления.

  10. Если переход был отменен, то необходимо удалить всё то, что успели сделать. То есть необходимо удалить toViewController.view из контейнера.

  11. Сообщаем transitionContext о состоянии перехода.

  12. Указываем длительность анимации.

Всё, наш аниматор готов. Теперь запускаем проект и наслаждаемся результатом. Анимации работают.

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

ссылка на оригинал статьи https://habr.com/ru/company/citymobil/blog/549288/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *