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