Хабрахабр

Не всплывай! Прерываемые транзишены в iOS

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

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

Мы остановились на том, что viewController может показываться и скрываться анимировано:

Теперь научим его реагировать на жест скрытия.

Интерактивный транзишен

Добавляем жест закрытия

Вся работа будет в классе TransitionDriver: Чтобы научить контроллер закрываться интерактивно, нужно добавить жест и обработать его.

class TransitionDriver: UIPercentDrivenInteractiveTransition func link(to controller: UIViewController) private var presentedController: UIViewController? private var panRecognizer: UIPanGestureRecognizer?

Можно присоединить обработчик в месте создания DimmPresentationController, внутри PanelTransition:

private let driver = TransitionDriver() func presentationController(forPresented presented: UIViewController, presenting: UIViewController?, source: UIViewController) -> UIPresentationController? { driver.link(to: presented) let presentationController = DimmPresentationController(presentedViewController: presented, presenting: presenting) return presentationController
}

При этом, нужно указать, что скрытие стало управляемым (мы уже сделали это в прошлой статье):

// PanelTransition.swift func interactionControllerForDismissal(using animator: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? { return driver
}

Обрабатываем жест

У него есть методы update, finish, cancel. Начнём с жеста закрытия: если панель потащить вниз, то начнётся анимация закрытия, и движение пальца будет влиять на степень закрытости. 
UIPercentDrivenInteractiveTransition позволяет перехватить анимацию перехода и управлять ей вручную. Удобно сделать обработку жеста в его сабклассе.

Обработка жеста

private func handleDismiss(recognizer r: UIPanGestureRecognizer) { switch r.state { case .began: pause() // Pause allows to detect isRunning if !isRunning { presentedController?.dismiss(animated: true) // Start the new one } case .changed: update(percentComplete + r.incrementToBottom(maxTranslation: maxTranslation)) case .ended, .cancelled: if r.isProjectedToDownHalf(maxTranslation: maxTranslation) { finish() } else { cancel() } case .failed: cancel() default: break }
}

Ссылку на контроллер мы сохранили в методе link(to:) .begin
Начать дисмисс самым обычным образом.

Расчёты вынес в экстеншен жеста, чтобы код стал чище. .changed
Посчитать инкремент.

Расчёты жеста

private extension UIPanGestureRecognizer { func isProjectedToDownHalf(maxTranslation: CGFloat) -> Bool { let endLocation = projectedLocation(decelerationRate: .fast) let isPresentationCompleted = endLocation.y > maxTranslation / 2 return isPresentationCompleted } func incrementToBottom(maxTranslation: CGFloat) -> CGFloat { let translation = self.translation(in: view).y setTranslation(.zero, in: nil) let percentIncrement = translation / maxTranslation return percentIncrement }
}

Расчёты опираются на maxTranslation, его мы рассчитываем как высоту показываемого контроллера:

var maxTranslation: CGFloat { return presentedController?.view.frame.height ?? 0
}

Правило завершения: если сместилось больше половины, то закрываем. .end
Смотрим завершенность жеста. Так мы поймём намерение пользователя: он мог не довести до середины, но свайпнуть сильно вниз. При этом смещение надо считать не только по текущей координате, но и учесть velocity. Или наоборот: увести вниз, но свайпнуть вверх для возврата.

Расчёты projectedLocation

extension UIPanGestureRecognizer { func projectedLocation(decelerationRate: UIScrollView.DecelerationRate) -> CGPoint { let velocityOffset = velocity(in: view).projectedOffset(decelerationRate: .normal) let projectedLocation = location(in: view!) + velocityOffset return projectedLocation }
} extension CGPoint { func projectedOffset(decelerationRate: UIScrollView.DecelerationRate) -> CGPoint { return CGPoint(x: x.projectedOffset(decelerationRate: decelerationRate), y: y.projectedOffset(decelerationRate: decelerationRate)) }
} extension CGFloat { // Velocity value func projectedOffset(decelerationRate: UIScrollView.DecelerationRate) -> CGFloat { // Magic formula from WWDC let multiplier = 1 / (1 - decelerationRate.rawValue) / 1000 return self * multiplier }
} extension CGPoint { static func +(left: CGPoint, right: CGPoint) -> CGPoint { return CGPoint(x: left.x + right.x, y: left.y + right.y) }
}

Можно обработать как блок .ended или отменить действие.
.failed– случится, если жест отменится другим жестом. .cancelled – произойдет, если заблокировать экран телефона или если позвонят. Так, например, жест перетаскивания может отменять жест тапа.
.possible – начальное состояние жеста, обычно не требует особой работы.

Так случилось, потому что в TransitionDriver есть свойство wantsInteractiveStart, по умолчанию оно true. Теперь панель можно закрывать и свайпом, но сломалась кнопка dismiss. Для свайпа это нормально, но это блокирует обычный dismiss.

Если жест начался, то это интерактивное закрытие, а если не начинался, то обычное: Разведём поведение на основе состояния жеста.

override var wantsInteractiveStart: Bool { get { let gestureIsActive = panRecognizer?.state == .began return gestureIsActive } set { }
}

Теперь пользователь может управлять скрытием:

Прерываем транзишен

Допустим, мы начали закрывать нашу карточку, но передумали и хотим вернуть. Это просто: в состоянии .began вызываем pause() для остановки.

Но нужно развести два сценария:

  • когда начинаем скрытие от жеста;
  • когда прерываем текущий.

Если не равен 0, то значит скрытие уже началось, достаточно только остановить анимацию: Для этого после остановки проверяем percentComplete: если он равен 0, то мы начинаем закрытие карточки вручную, плюс нужно вызвать dismiss.

case .began: pause() // Pause allows to detect percentComplete if percentComplete == 0 { presentedController?.dismiss(animated: true) }

Нажимаю кнопку и сразу свайпаю верх, чтобы отменить скрытие:

Прерываем показ контроллера

Мы ловим её и отправляем свайпом вниз обратно. Прервать анимацию показа контроллера можно теми же шагами: Обратная ситуация: карточка начала показываться, но нам это не нужно.

  1. Вернуть драйвер в качестве контроллера интерактивного показа:

    func interactionControllerForPresentation(using animator: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? {
    return driver
    }

  2. Обработать жест, но с обратными значениями смещения и завершённости:

    private func handlePresentation(recognizer r: UIPanGestureRecognizer) {
    switch r.state {
    case .began: pause() case .changed: let increment = -r.incrementToBottom(maxTranslation: maxTranslation) update(percentComplete + increment) case .ended, .cancelled: if r.isProjectedToDownHalf(maxTranslation: maxTranslation) { cancel() } else { finish() } case .failed: cancel() default: break
    }
    }

Для разделение показа и скрытия, я ввёл enum с текущим направлением анимации:

enum TransitionDirection { case present, dismiss
}

Свойство хранится в TransitionDriver и влияет на то, какой обработчик жеста будет использован:

var direction: TransitionDirection = .present @objc private func handle(recognizer r: UIPanGestureRecognizer) { switch direction { case .present: handlePresentation(recognizer: r) case .dismiss: handleDismiss(recognizer: r) } }

Контроллер мы не планируем показывать жестом, поэтому возвращаем false для .present: Так же оно влияет на wantsInteractiveStart.

override var wantsInteractiveStart: Bool { get { switch direction { case .present: return false case .dismiss: let gestureIsActive = panRecognizer?.state == .began return gestureIsActive } } set { }
}

Лучшее место – в PresentationController: Ну и осталось сменить направление жеста, когда контроллер был полностью показан.

override func presentationTransitionDidEnd(_ completed: Bool) {
super.presentationTransitionDidEnd(completed) if completed { driver.direction = .dismiss }
}

А можно без enum?

Но они показывают только процесс, а нам нужны ещё и возможные направления: в начале интерактивного закрытия оба значения будут false, а нам уже нужно знать, что это направление к закрытию. Казалось бы, мы можем опираться на свойства контроллера isBeingPresented и isBeingDismissed. Это можно решить дополнительными условиями на проверку иерархии контроллеров, но явное задание через enum, кажется более простым решением. 

Нажимаю кнопку и сразу свайпаю вниз: Теперь можно прервать анимацию показа.

Показывать по жесту

Это работает так же, как интерактивное скрытие, но в жесте вместо dismiss вызываем present
Начнём с конца. Если вы делаете гамбургерное меню для приложения, то, скорее всего, захочется показывать его по жесту. В handlePresentation(recognizer:) покажем контроллер:

case .began: pause() if !isRunning { presentingController?.present(presentedController!, animated: true) }

Разрешим показываться интерактивно:

override var wantsInteractiveStart: Bool { get { switch direction { case .present: let gestureIsActive = screenEdgePanRecognizer?.state == .began return gestureIsActive case .dismiss: …

Передадим их при создании жеста, добавим UIScreenEdgePanGestureRecognizer: Для работы кода не хватает ссылок на presentingController и presentedController.

func linkPresentationGesture(to presentedController: UIViewController, presentingController: UIViewController) { self.presentedController = presentedController self.presentingController = presentingController panRecognizer = UIPanGestureRecognizer(target: self, action: #selector(handle(recognizer:))) presentedController.view.addGestureRecognizer(panRecognizer!) screenEdgePanRecognizer = UIScreenEdgePanGestureRecognizer(target: self, action: #selector(handlePresentation(recognizer:))) screenEdgePanRecognizer!.edges = .bottom presentingController.view.addGestureRecognizer(screenEdgePanRecognizer!)
}

Передать контроллеры можно при создании PanelTransition:

class PanelTransition: NSObject, UIViewControllerTransitioningDelegate { init(presented: UIViewController, presenting: UIViewController) { driver.linkPresentationGesture(to: presented, presentingController: presenting) } private let driver = TransitionDriver()

Осталось правильно создать PanelTransition

  1. Создадим child контроллер во viewDidLoad, так как контроллер может понадобиться нам в любой момент.
  2. Создадим PanelTransition. В его конструкторе жест привяжется к контроллеру. 
  3. Проставим transitioningDelegate для child контроллера.
  4. С помощью preferredScreenEdgesDeferringSystemGestures отключил системный свайп снизу.  Для учебных целей я сделал свайп снизу, но это конфликтует с закрытием приложения на iPhone Х и контрол центром.

    class ParentViewController: UIViewController { private var child: ChildViewController!
    private var transition: PanelTransition! override func viewDidLoad() { super.viewDidLoad() child = ChildViewController() // 1 transition = PanelTransition(presented: child, presenting: self) // 2 // Setup the child child.modalPresentationStyle = .custom child.transitioningDelegate = transition // 3
    } override var preferredScreenEdgesDeferringSystemGestures: UIRectEdge { return .bottom // 4
    }

    Поставим правильный статус после скрытия контроллера в PresentationController: После изменения оказалось, что есть проблема: после первого закрытия панели она навсегда остаётся в статусе TransitionDirection.dismiss.

    override func dismissalTransitionDidEnd(_ completed: Bool) {
    super.dismissalTransitionDidEnd(completed) if completed { driver.direction = .present
    }
    }

    Выглядит так: Код c интерактивным отображением можно посмотреть в отдельной ветке.

Заключение

Это намного приятней, потому что анимация больше не блокирует интерфейс, её можно отменить или даже ускорить. В итоге мы можем показывать контроллер с прерываемой анимацией, а у пользователя появляется контроль над происходящим на экране.

Пример можно посмотреть на github.

Подписывайтесь на канал Dodo Pizza Mobile.

Теги
Показать больше

Похожие статьи

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

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

Кнопка «Наверх»
Закрыть