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