Настало время офигительных историй [1/2]

от автора

Порой дизайнеры рисуют необычные переходы между экранами, и UIKit не поддерживает их из коробки. Но их реализация не такая сложная, как может показаться на первый взгляд.

Давайте посмотрим на макеты:

Как вы могли заметить, есть два типа анимаций: переход между историями и закрытие/открытие историй как в Instagram (анимация Zoom In/Zoom Out). Давайте обсудим, как можно реализовать эти анимации.

Анимация Zoom In/Zoom Out

Первый тип анимации, который нам необходим, это открытие/закрытие экрана с историями. Идея в том, чтобы из какого-либо фрейма представлять вью-контроллер, в который он позже и закроется. Реализуем протокол для view, из которой будет представлен экран:

public protocol PreviewStoryViewProtocol: AnyObject {          var endFrame: CGRect { get }     var startFrame: CGRect { get }      }  public class PreviewStoryView: UIView, PreviewStoryViewProtocol {          public var startFrame: CGRect {         return convert(bounds, to: nil)     }          public var endFrame: CGRect {         return convert(bounds, to: nil)     }  }

startFrame и endFrame отвечают за позицию этой view на экране.

Далее реализуем сам экран, отвечающий за истории. Он представляет из себя массив из нескольких контроллеров. Так как UIPageViewController не поддерживает пользовательские анимации при переходах, то реализуем эту логику на базе UINavigationController.

class StoriesNavigationController: UINavigationController {              // MARK: - Private properties     private var previewFrame: PreviewStoryViewProtocol?          // MARK: - Setup     func setup(viewControllers: [UIViewController], previewFrame: PreviewStoryViewProtocol?) {         self.previewFrame = previewFrame         self.viewControllers = viewControllers     }      // MARK: - Lifecycle     convenience init() {         self.init(nibName: nil, bundle: nil)         setupUI()     }      }  extension StoriesNavigationController {          private func setupUI() {         setNavigationBarHidden(true, animated: false)         modalPresentationStyle = .custom     }      }

Функция setup отвечает за конфигурацию нашего NavigationController’а. В нее мы передаем массив вью-контроллеров и делегат previewFrame, через который позже получим необходимые фреймы для начала и окончания анимаций.

Далее перейдем к самому интересному. Каждый UIViewController имеет свой transitioningDelegate, который можно реализовать через UIViewControllerTransitioningDelegate. Каждый раз, когда мы совершаем показ или закрытие, UIKit спрашивает у делегата, какую анимацию ему отобразить. Чтобы заменить стандартную анимацию на свою, мы и реализуем UIViewControllerTransitioningDelegate.

extension StoriesNavigationController: UIViewControllerTransitioningDelegate {          public func animationController(         forPresented presented: UIViewController,         presenting: UIViewController,         source: UIViewController) -> UIViewControllerAnimatedTransitioning?     {         guard let startFrame = previewFrame?.startFrame else { return nil }         return StoriesNavigationPresentAnimator(startFrame: startFrame)     }          public func animationController(         forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning?     {         guard let endFrame = previewFrame?.endFrame else { return nil }         return StoriesNavigationDismissAnimator(endFrame: endFrame)     }      }

И не забудьте в функции setupUI указать transitioningDelegate =**self.**

Эти два метода отвечают за показ и закрытие view-котроллера. Для них мы и должны реализовать два аниматора на базе UIViewControllerAnimatedTransitioning. На эти методы возлагается вся логика анимации.

Рассмотрим первый аниматор StoriesNavigationPresentAnimator, отвечающий за показ.

class StoriesNavigationAnimator: NSObject, UIViewControllerAnimatedTransitioning {          private enum Spec {         static let animationDuration: TimeInterval = 0.3     }          private let startFrame: CGRect      init(startFrame: CGRect) {         self.startFrame = startFrame     }      func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {         return Spec.animationDuration     }      func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {                  // 1         guard let toViewController = transitionContext.viewController(forKey: .to),               let snapshot = toViewController.view.snapshotView(afterScreenUpdates: true)         else {             return         }          // 2         let containerView = transitionContext.containerView                  // 3         containerView.addSubview(toViewController.view)         toViewController.view.isHidden = true          // 4         snapshot.frame = startFrame         snapshot.alpha = 0.0                  containerView.addSubview(snapshot)          UIView.animate(withDuration: Spec.animationDuration, animations: {             // 5             snapshot.frame = (transitionContext.finalFrame(for: toViewController))             snapshot.alpha = 1.0         }, completion: { _ in             // 6             toViewController.view.isHidden = false             snapshot.removeFromSuperview()                          // 7             if transitionContext.transitionWasCancelled {                 toViewController.view.removeFromSuperview()             }                          // 8             transitionContext.completeTransition(!transitionContext.transitionWasCancelled)         })     } }

Первое, что необходимо сделать, это указать время длительности анимации в методе transitionDuration(using:).

Затем мы реализуем саму анимацию внутри метода animateTransition.

  1. Получаем презентуемый вью-контроллер и снэпшотим его.

  2. Получаем containerView. В этом контексте будет происходить анимация во время перехода между вью-контроллерами.

  3. Добавляем view конечного вью-контроллера в контекст и скрываем его.

  4. Готовим снэпшот к анимации. Задаем ему frame view, из которого будем показывать.

  5. Анимированно меняем размер снэпшота до финального размера.

  6. После окончания анимации удаляем снэпшот, отображаем реальную view конечного view-котроллера.

  7. Если переход не будет выполнен (например, прерван пользователем), то необходимо удалить конечное view (toViewController.view), так как оно не будет отображено.

  8. И наконец-то сообщаем UIKit’у через transitionContext о состоянии перехода.

Теперь ваш аниматор готов к использованию!

Аналогично реализуем аниматор для закрытия, который делает всё то же самое, но наоборот.

class StoriesNavigationDismissAnimator: NSObject, UIViewControllerAnimatedTransitioning {          private enum Spec {         static let animationDuration: TimeInterval = 0.3     }      private let endFrame: CGRect      init(endFrame: CGRect) {         self.endFrame = endFrame     }      func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {         return Spec.animationDuration     }      func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {                  guard let fromViewController = transitionContext.viewController(forKey: .from),               let snapshot = fromViewController.view.snapshotView(afterScreenUpdates: true)         else {             return         }                  let containerView = transitionContext.containerView         containerView.addSubview(snapshot)                  fromViewController.view.isHidden = true                  UIView.animate(withDuration: Spec.animationDuration, delay: 0, options: .curveEaseOut, animations: {             snapshot.frame = self.endFrame             snapshot.alpha = 0         }, completion: { _ in             fromViewController.view.isHidden = false             snapshot.removeFromSuperview()             transitionContext.completeTransition(!transitionContext.transitionWasCancelled)         })     }  }

Чтобы посмотреть результат, реализуем простой StoryBaseViewController, который отвечает за экран с одной историей.

class StoryBaseViewController: UIViewController {          // MARK: - Constants     private enum Spec {         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: "closeImage"), 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: - Lifecycle     public override func loadView() {         super.loadView()         view.addSubview(closeButton)     }          @objc     private func closeButtonAction(sender: UIButton!) {         dismiss(animated: true, completion: nil)     }      }

Завершающий этап — реализация view на стартовом ViewController’е, из которой происходит показ историй. Для этого необходимо создать массив историй (StoryBaseViewController) и отобразить в StoriesNavigationController.

class ViewController: UIViewController {          // MARK: - UI components     private lazy var previewView: PreviewStoryView = {         let preview = PreviewStoryView()         preview.frame.size = CGSize(width: 200, height: 200)         preview.backgroundColor = .black         preview.layer.cornerRadius = 10         preview.center = view.center         return preview     }()          private lazy var showButton: UIButton = {         let button = UIButton()         button.setTitle("Show", for: .normal)         button.addTarget(self, action: #selector(handleButtonAction), for: .touchUpInside)         button.frame = CGRect(origin: CGPoint(x: 0, y: 0), size: CGSize(width: 200, height: 200))         return button     }()          // MARK: - Lifecycle     override func viewDidLoad() {         super.viewDidLoad()         setupUI()     }      }  extension ViewController {          private func setupUI() {         view.backgroundColor = .darkGray         view.addSubview(previewView)         previewView.addSubview(showButton)                  let panGesture = UIPanGestureRecognizer(target: self, action: #selector(handlePanGesture(gesture:)))         previewView.addGestureRecognizer(panGesture)     }      }  extension ViewController {          @objc     func handleButtonAction(sender: UIButton!) {                  var storyViewControllers: [UIViewController] {                          let vc1 = StoryBaseViewController()             vc1.view.backgroundColor = .red                          let vc2 = StoryBaseViewController()             vc2.view.backgroundColor = .green                          let vc3 = StoryBaseViewController()             vc3.view.backgroundColor = .blue                          return [vc1, vc2, vc3]         }                  let storiesVC = StoriesNavigationController()         storiesVC.setup(viewControllers: storyViewControllers, previewFrame: previewView)                  present(storiesVC, animated: true, completion: nil)     }          @objc     func handlePanGesture(gesture: UIPanGestureRecognizer) {                  let stateIsValidate = gesture.state == .began || gesture.state == .changed                  if let gestureView = gesture.view, stateIsValidate {             let translation = gesture.translation(in: self.view)             let newXPosition = gestureView.center.x + translation.x             let newYPosition = gestureView.center.y + translation.y                          gestureView.center = CGPoint(x: newXPosition, y: newYPosition)             gesture.setTranslation(.zero, in: self.view)         }     }      }

Обратите внимание, что previewView выступает делегатом для StoriesNavigationController и передает startFrame и endFrame. Можно интерактивно перемещать view, и показ экрана с историями будет происходить из нового местоположения на экране.

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

Весь исходный код этой статьи можете скачать тут.

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


Комментарии

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

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