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

в 14:36, , рубрики: Dodo IS, Dodo Pizza Engineering, interruptible transition, UINavigationControllerDelegate, UIPresentationController, UIViewAnimateTransitioning, UIViewPropertyAnimator, Блог компании Dodo Pizza Engineering, интерфейсы, разработка мобильных приложений, разработка под iOS

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

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

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

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

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

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

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

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

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

class TransitionDriver: UIPercentDrivenInteractiveTransition
    func link(to controller: UIViewController) {
        presentedController = controller
        panRecognizer = UIPanGestureRecognizer(target: self, action: #selector(handle(recognizer:)))
        presentedController?.view.addGestureRecognizer(panRecognizer!)
    }

    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
}

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

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

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

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
    }
}

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

.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)
    }
}

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

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

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

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

    set { }
}

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

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

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

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

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

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

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

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

Нажимаю кнопку и сразу свайпаю верх, чтобы отменить скрытие:
Не всплывай! Прерываемые транзишены в iOS - 4

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

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

  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)
        }
    }

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

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?

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

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

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

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

Если вы делаете гамбургерное меню для приложения, то, скорее всего, захочется показывать его по жесту. Это работает так же, как интерактивное скрытие, но в жесте вместо 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:
                …

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

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

    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
    }

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

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

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

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

Заключение

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

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

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

Автор: Рубанов Михаил

Источник


* - обязательные к заполнению поля