- PVSM.RU - https://www.pvsm.ru -
Вас тоже бесят всплывающие окна в приложениях? В этой статье я покажу, как интерактивно скрывать и показывать всплывающие окна, делать анимацию прерываемой и не бесить своих клиентов.
В предыдущей статье [1] я разобрал, как можно анимировать отображение нового контроллера.
Мы остановились на том, что viewController
может показываться и скрываться анимировано:
Теперь научим его реагировать на жест скрытия.
Чтобы научить контроллер закрываться интерактивно, нужно добавить жест и обработать его. Вся работа будет в классе 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
. Так мы поймём намерение пользователя: он мог не довести до середины, но свайпнуть сильно вниз. Или наоборот: увести вниз, но свайпнуть вверх для возврата.
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 { }
}
Теперь пользователь может управлять скрытием:
Допустим, мы начали закрывать нашу карточку, но передумали и хотим вернуть. Это просто: в состоянии .began
вызываем pause()
для остановки.
Но нужно развести два сценария:
Для этого после остановки проверяем percentComplete:
если он равен 0, то мы начинаем закрытие карточки вручную, плюс нужно вызвать dismiss
. Если не равен 0, то значит скрытие уже началось, достаточно только остановить анимацию:
case .began:
pause() // Pause allows to detect percentComplete
if percentComplete == 0 {
presentedController?.dismiss(animated: true)
}
Нажимаю кнопку и сразу свайпаю верх, чтобы отменить скрытие:
Обратная ситуация: карточка начала показываться, но нам это не нужно. Мы ловим её и отправляем свайпом вниз обратно. Прервать анимацию показа контроллера можно теми же шагами:
func interactionControllerForPresentation(using animator: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? {
return driver
}
Обработать жест, но с обратными значениями смещения и завершённости:
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
}
}
Казалось бы, мы можем опираться на свойства контроллера isBeingPresented
и isBeingDismissed
. Но они показывают только процесс, а нам нужны ещё и возможные направления: в начале интерактивного закрытия оба значения будут false
, а нам уже нужно знать, что это направление к закрытию. Это можно решить дополнительными условиями на проверку иерархии контроллеров, но явное задание через 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:
…
Для работы кода не хватает ссылок на 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
:
child
контроллер во viewDidLoad
, так как контроллер может понадобиться нам в любой момент.PanelTransition
. В его конструкторе жест привяжется к контроллеру. Для учебных целей я сделал свайп снизу, но это конфликтует с закрытием приложения на 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 интерактивным отображением можно посмотреть в отдельной ветке [2]. Выглядит так:
В итоге мы можем показывать контроллер с прерываемой анимацией, а у пользователя появляется контроль над происходящим на экране. Это намного приятней, потому что анимация больше не блокирует интерфейс, её можно отменить или даже ускорить.
Пример можно посмотреть на github. [3]
Подписывайтесь на канал Dodo Pizza Mobile. [4]
Автор: Рубанов Михаил
Источник [5]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/interfejsy/328529
Ссылки в тексте:
[1] В предыдущей статье: https://habr.com/ru/company/dodopizzaio/blog/463527/
[2] в отдельной ветке: https://github.com/akaDuality/InterruptibleTransition/tree/interactivePresentation
[3] на github.: https://github.com/akaDuality/InterruptibleTransition
[4] на канал Dodo Pizza Mobile.: https://telegram.im/dodomobile
[5] Источник: https://habr.com/ru/post/465073/?utm_source=habrahabr&utm_medium=rss&utm_campaign=465073
Нажмите здесь для печати.