Порой дизайнеры рисуют необычные переходы между экранами, и 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
.
-
Получаем презентуемый вью-контроллер и снэпшотим его.
-
Получаем
containerView
. В этом контексте будет происходить анимация во время перехода между вью-контроллерами. -
Добавляем view конечного вью-контроллера в контекст и скрываем его.
-
Готовим снэпшот к анимации. Задаем ему frame view, из которого будем показывать.
-
Анимированно меняем размер снэпшота до финального размера.
-
После окончания анимации удаляем снэпшот, отображаем реальную view конечного view-котроллера.
-
Если переход не будет выполнен (например, прерван пользователем), то необходимо удалить конечное view (
toViewController.view
), так как оно не будет отображено. -
И наконец-то сообщаем 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/
Добавить комментарий