Нижний Лист, Давай Перейдем На Ты?

Нижний Лист показался мне трудным и недостижимым.

Это был вызов! Я не понимал, с чего начать.

Возникло много вопросов: использовать представление или контроллер представления? Автоматическая или ручная компоновка? Как анимировать? Как скрыть нижний лист в интерактивном режиме? Но все изменилось после работы над Bottom Sheet для приложения Joom, где он используется повсеместно.

В том числе и в таких критических сценариях, как оплата.

Поэтому могу точно сказать, что мы уверены в этом компоненте.

Так уверенно, что я даже сказал о нем на Подлодке iOS команда #7. Во время семинара я показал, как сделать нижний лист, который может подстраиваться под размер контента, закрывается интерактивно и поддерживает UINavigationController. Подождите, но это Apple предоставил системный Нижний лист .

Зачем писать свое? Действительно, это действительно так, но компонент поддерживается только с iOS 15. Это означает, что полноценно использовать его можно будет только через 2-3 года.

Кроме того, требования дизайнеров часто выходят за рамки стандартных элементов iOS. В рамках статьи хочу развеять туман над Нижним Листом, ответить на вопросы, которые задавал себе, и предложить один из вариантов реализации.

Чтобы в конце вы могли добавить в свое резюме строчку «Я профессионально делаю нижние листы».



Нижний Лист, давай перейдем на

Если вам интересно, давайте начнем! Давайте создадим простой нижний лист и обновим его шаг за шагом.

  1. Давайте научимся подстраиваться под размер контента и закрывать нижний лист.
  2. Добавим интерактивное закрытие с учетом прокручиваемого контента.

  3. Давайте поддержим Уинавигатионконтроллер с навигацией внутри нижнего листа.



Часть 1. Адаптация под размер контента.

Закройте нижний лист. Основной дизайн

Во-первых, давайте удостоверимся, что под «нижним листом» мы подразумеваем одно и то же.

Bottom Sheet — компонент, который расположен внизу и адаптируется к размеру контента.

Есть примеры использования в системных приложениях: Apple Карты (поиск), Акции (Новости), Голосовые заметки (запись голоса) и т. д.

Нижний Лист, давай перейдем на

Карты Apple, акции, голосовые заметки

Стартовый проект

Вот ссылка на стартовый проект по адресу Гитхаб .

Проект преследует две цели: НижнийЛистДемо И НижнийЛист - приложение и библиотека с Bottom Sheet. Структура проекта

  
  
  
  
  
  
  
  
  
  
  
  
  
  
  
  
  
  
  
  
  
  
  
  
  
   

BottomSheetDemo Sources/User Interface Screens Resize ResizeViewController.swift Root RootViewController.swift BottomSheet Core BottomSheetModalDismissalHandler.swift BottomSheetPresentationController.swift BottomSheetPresentationController+PullBar.swift BottomSheetTransitioningDelegate.swift Helpers .



Рутвиевконтроллер - Это первый экран в приложении.

У него есть только одна кнопка: «Показать нижний лист».

При нажатии появится ResizeViewController.

@objc private func handleShowBottomSheet() { let viewController = ResizeViewController(initialHeight: 300) present(viewController, animated: true, completion: nil) }

В инициализаторе Ресайзвиевконтроллер занимает высоту содержимого.

Также есть четыре кнопки, меняющие высоту контента: +100 и -100, 2 и 0,5 раза.

Запустим приложение.



Нижний Лист, давай перейдем на



Теория.

Как показать нижний лист?

Нам нужна сущность, которая будет управлять отображением.

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

Это как зона ответственности Уипрезентатионконтроллер .

С момента появления контроллера представления и до момента его скрытия UIKit использует контроллер представления для управления процессом представления.

Чтобы использовать его, вам нужно переопределить модальныйPresentationStyle и передать контроллер презентации через переходный делегат .

Вооружившись этими знаниями, приступим к созданию нижнего листа!

Создадим контроллер презентации

Чтобы отобразить нижний лист, давайте переопределим модальныйPresentationStyle И переходный делегат .

Давайте не будем забывать об этом переходный делегат — это слабое звено, а нам понадобится сильное звено, чтобы не потерять объект.

private var bottomSheetTransitioningDelegate: UIViewControllerTransitioningDelegate? @objc private func handleShowBottomSheet() { let viewController = ResizeViewController(initialHeight: 300) // TODO: bottomSheetTransitioningDelegate = .

viewController.modalPresentationStyle = .

custom viewController.transitioningDelegate = bottomSheetTransitioningDelegate present(viewController, animated: true, completion: nil) }

Давайте создадим реализацию BottomSheetTransitioningDelegate. переходный делегат.

public final class BottomSheetTransitioningDelegate: NSObject, UIViewControllerTransitioningDelegate { private func _presentationController( forPresented presented: UIViewController, presenting: UIViewController?, source: UIViewController ) -> BottomSheetPresentationController { BottomSheetPresentationController( presentedViewController: presented, presenting: presenting ) } }

И контроллер презентации.



public final class BottomSheetPresentationController: UIPresentationController {}

Наконец вернемся к Рутвиевконтроллер и закройте TODO.

// TODO: bottomSheetTransitioningDelegate = .

bottomSheetTransitioningDelegate = BottomSheetTransitioningDelegate()

Запустим приложение.



Нижний Лист, давай перейдем на

Будто стало только хуже.

Контроллер просмотра открывается в полноэкранном режиме и скрывается за строкой состояния.

Мы переопределили системный контроллер представления, который красиво показывал контроллер представления и позиционировал его с учетом SafeArea. В нашем презентационном контроллере ничего подобного нет, положение вью-контроллера мы никак не указываем, давайте подправим.



Принимая во внимание размер контента

Давайте вернемся к ResizeViewController. Поле текущая высота отвечает за текущую высоту.

Чтобы не создавать ненужных протоколов, мы используем предпочтительныйКонтентСизе .

Он покажет текущий желаемый размер нижнего листа.

В контроллере представления мы переопределим ФреймОфПрезентедВиевИнКонтейнерВиев , который отвечает за должность представленПросмотр .

В нашем случае представленПросмотр это просмотр Ресайзвиевконтроллер .

контейнерView это представление, содержащее представленПросмотр и где можно добавить например тень.



public override var frameOfPresentedViewInContainerView: CGRect { targetFrameForPresentedView() } private func targetFrameForPresentedView() -> CGRect { guard let containerView = containerView else { return .

zero } let windowInsets = presentedView?.

window?.

safeAreaInsets ?? .

zero let preferredHeight = presentedViewController.preferredContentSize.height + windowInsets.bottom let maxHeight = containerView.bounds.height - windowInsets.top let height = min(preferredHeight, maxHeight) return .

init( x: 0, y: (containerView.bounds.height - height).

pixelCeiled, width: containerView.bounds.width, height: height.pixelCeiled ) }

Дополнительно мы укажем долженPresentInFullscreen В ЛОЖЬ , потому что нижний лист не покрывает весь экран.



public override var shouldPresentInFullscreen: Bool { false }

Давайте посмотрим, что произошло.



Нижний Лист, давай перейдем на

Исходный размер учитывается, но реакции на его изменения нет.

Реагируем на изменения контента

Давайте рассмотрим Уипрезентатионконтроллер .

Он реализует UIContentContainer , что нас интересует предпочтительноеContentSizeDidChange(forChildContentContainer:) , который вызывается при изменении предпочтительныйКонтентСизе в контроллерах дочерних представлений.



public override func preferredContentSizeDidChange(forChildContentContainer container: UIContentContainer) { updatePresentedViewSize() } private func updatePresentedViewSize() { guard let presentedView = presentedView else { return } let oldFrame = presentedView.frame let targetFrame = targetFrameForPresentedView() if !oldFrame.isAlmostEqual(to: targetFrame) { presentedView.frame = targetFrame } }

Проверяем текущий кадр и тот, который считаем правильным.

Если они разные, то обновите представленView.frame. Запустим приложение.



Нижний Лист, давай перейдем на

Размер меняется неравномерно без анимации.

Почему? Потому что мы никак не обозначаем эту анимацию.

Добавим анимацию изменениям предпочтительныйКонтентСизе В ResizeViewController.

UIView.animate( withDuration: 0.25, animations: { [self] in preferredContentSize = CGSize( width: UIScreen.main.bounds.width, height: newValue ) } )

Давай проверим.



Нижний Лист, давай перейдем на

Работает! Но нам просто не сойдет с рук Bottom Sheet.

Закрытие нижнего листа

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



public protocol BottomSheetModalDismissalHandler { func performDismissal(animated: Bool) }

Мы передаем его инициализатору контроллера представления.



private let dismissalHandler: BottomSheetModalDismissalHandler public init( presentedViewController: UIViewController, presentingViewController: UIViewController?, dismissalHandler: BottomSheetModalDismissalHandler ) { self.dismissalHandler = dismissalHandler super.init(presentedViewController: presentedViewController, presenting: presentingViewController) }

Для удобства создадим фабрику контроллеров презентаций.



public protocol BottomSheetPresentationControllerFactory { func makeBottomSheetPresentationController( presentedViewController: UIViewController, presentingViewController: UIViewController? ) -> BottomSheetPresentationController }

который будет использоваться внутри BottomSheetTransitioningDelegate.

private let factory: BottomSheetPresentationControllerFactory public init(factory: BottomSheetPresentationControllerFactory) { self.factory = factory } public func presentationController( forPresented presented: UIViewController, presenting: UIViewController?, source: UIViewController ) -> UIPresentationController? { factory.makeBottomSheetPresentationController( presentedViewController: presented, presentingViewController: presenting ) }

В Рутвиевконтроллер Мы реализуем фабрику и обработчик замыкания.

Мы прячемся представленViewController , потому что это нижний лист.

extension RootViewController: BottomSheetPresentationControllerFactory { func makeBottomSheetPresentationController( presentedViewController: UIViewController, presentingViewController: UIViewController? ) -> BottomSheetPresentationController { .

init( presentedViewController: presentedViewController, presentingViewController: presentingViewController, dismissalHandler: self ) } } extension RootViewController: BottomSheetModalDismissalHandler { func performDismissal(animated: Bool) { presentedViewController?.

dismiss(animated: animated, completion: nil) } }

Теперь в контроллере презентации мы настроим тень с помощью обработчика скрытия.

Добавьте тень перед началом перехода и удалите ее после окончания.

Во-первых, нам нужно отслеживать состояние контроллера представления.

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

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

Жизненный цикл контроллера презентации

// MARK: - Nested types private enum State { case dismissed case presenting case presented case dismissing } // MARK: - Private properties private var state: State = .

dismissed // MARK: - UIPresentationController public override func presentationTransitionWillBegin() { state = .

presenting } public override func presentationTransitionDidEnd(_ completed: Bool) { if completed { state = .

presented } else { state = .

dismissed } } public override func dismissalTransitionWillBegin() { state = .

dismissing } public override func dismissalTransitionDidEnd(_ completed: Bool) { if completed { state = .

dismissed } else { state = .

presented } }

Следующий вопрос – в какой момент добавлять и удалять тень.

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



public override func presentationTransitionWillBegin() { state = .

presenting addSubviews() } public override func dismissalTransitionDidEnd(_ completed: Bool) { if completed { removeSubviews() state = .

dismissed } else { state = .

presented } }

Осталось реализовать addSubviews() И удалитьПодвиды().

Добавление и удаление тени — addSubviews() и RemoveSubviews().



private func addSubviews() { guard let containerView = containerView else { assertionFailure() return } setupShadingView(containerView: containerView) } private func setupShadingView(containerView: UIView) { let shadingView = UIView() containerView.addSubview(shadingView) shadingView.backgroundColor = UIColor.black.withAlphaComponent(0.6) shadingView.frame = containerView.bounds let tapGesture = UITapGestureRecognizer() shadingView.addGestureRecognizer(tapGesture) tapGesture.addTarget(self, action: #selector(handleShadingViewTapGesture)) self.shadingView = shadingView } @objc private func handleShadingViewTapGesture() { dismissIfPossible() } private func removeSubviews() { shadingView?.

removeFromSuperview() shadingView = nil } private func dismissIfPossible() { let canBeDismissed = state == .

presented if canBeDismissed { dismissalHandler.performDismissal(animated: true) } }

Давайте посмотрим, что произошло.



Нижний Лист, давай перейдем на

Отлично, теперь у нас добавлена тень, и при нажатии на нее нижний лист закрывается! Но тень появляется и исчезает без анимации.



Анимированный переход

Что я должен делать? Тень принадлежит переходу и должна анимироваться вместе с ним.

Поэтому нам нужно встроить в переходный делегат. Реализация протокола UIViewControllerAnimatedTransitioning , в котором будет логика анимированного поднятия и опускания шторки.

Точно так же, как и система, но мы дополнительно добавим анимацию затухания тени.

Поднимите и опустите занавес, используя анимированный переход. Для простоты мы реализуем протокол внутри контроллера представления, поскольку он уже имеет доступ к необходимым элементам пользовательского интерфейса.



extension BottomSheetPresentationController: UIViewControllerAnimatedTransitioning { public func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval { 0.3 } public func animateTransition(using transitionContext: UIViewControllerContextTransitioning) { guard let sourceViewController = transitionContext.viewController(forKey: .

from), let destinationViewController = transitionContext.viewController(forKey: .

to), let sourceView = sourceViewController.view, let destinationView = destinationViewController.view else { return } let isPresenting = destinationViewController.isBeingPresented let presentedView = isPresenting ? destinationView : sourceView let containerView = transitionContext.containerView if isPresenting { containerView.addSubview(destinationView) destinationView.frame = containerView.bounds } sourceView.layoutIfNeeded() destinationView.layoutIfNeeded() let frameInContainer = frameOfPresentedViewInContainerView let offscreenFrame = CGRect( origin: CGPoint( x: 0, y: containerView.bounds.height ), size: sourceView.frame.size ) presentedView.frame = isPresenting ? offscreenFrame : frameInContainer pullBar?.

frame.origin.y = presentedView.frame.minY - Style.pullBarHeight + pixelSize shadingView?.

alpha = isPresenting ? 0 : 1 let animations = { presentedView.frame = isPresenting ? frameInContainer : offscreenFrame self.pullBar?.

frame.origin.y = presentedView.frame.minY - Style.pullBarHeight + pixelSize self.shadingView?.

alpha = isPresenting ? 1 : 0 } let completion = { (completed: Bool) in transitionContext.completeTransition(completed && !transitionContext.transitionWasCancelled) } let options: UIView.AnimationOptions = transitionContext.isInteractive ? .

curveLinear : .

curveEaseInOut let transitionDurationValue = transitionDuration(using: transitionContext) UIView.animate(withDuration: transitionDurationValue, delay: 0, options: options, animations: animations, completion: completion) } }

И не забудьте реализовать соответствующие методы в BottomSheetTransitioningDelegate.

// MARK: - UIViewControllerTransitioningDelegate public func animationController( forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController ) -> UIViewControllerAnimatedTransitioning? { presentationController } public func animationController( forDismissed dismissed: UIViewController ) -> UIViewControllerAnimatedTransitioning? { presentationController }

Давайте убедимся, что анимация появилась.



Нижний Лист, давай перейдем на

Осталось только добавить закругленные края, и наш Нижний Лист готов!

Закругление краев

Через уголРадиус в представленViewController в контроллере презентации.

Нам нужно сделать это до начала перехода на презентацияTransitionWillBegin().



private func applyStyle() { guard presentedViewController.isViewLoaded else { return } presentedViewController.view.clipsToBounds = true presentedViewController.view.layer.cornerRadius = cornerRadius }

Следим за углами.



Нижний Лист, давай перейдем на

Закругленный! Нижний лист теперь соответствует дизайну!

Что мы делали в первой части?

  1. Делегат перехода системы был переопределен.

  2. Мы создали контроллер презентаций.

  3. Добавлена тень, чтобы скрыть нижний лист с помощью обработчика отклонения.

  4. Реализован анимированный переход с использованием делегата перехода.

  5. Поддерживается базовый дизайн.



Часть 2. Интерактивное закрытие нижнего листа



Стартовый проект

Как и в первой части, начнем с стартовый проект .

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

Также в ResizeViewController появился ScrollView в полноэкранном режиме.

Он понадобится нам для экранов списков.

Остальное из первой части.

Запустим приложение.



Нижний Лист, давай перейдем на



Теория.

Особенности интерактивного закрытия

Мы используем UISwipeGestureRecensorer распознавать жест смахивания.

С его помощью мы начнем закрывать нижний лист. А что, если у представленного контроллера такой жест уже есть? Тогда это может привести к конфликту жестов, поскольку непонятно, какой из них обрабатывать первым.

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

В современном приложении 99% экранов представляют собой списки.

Это означает, что у каждого есть UIScrollView или его наследники: УИТаблевиев или UICollectionView , в котором присутствует тот же жест. Как быть? Давайте рассмотрим два случая, когда UIScrollView нет, и он есть.

  1. Если нет, то все просто — добавьте жест смахивания.

  2. Если так, то контент можно разместить :
    1. В полной мере .

      Тогда размер нижнего листа станет меньше экрана, и мы сразу же закроем нижний лист жестом смахивания.

    2. Частично .

      Тогда свайп также может означать прокрутку.

      Предположим, что пользователь хочет закрыть нижний лист, проведя пальцем вниз, и когда содержимое заканчивается вверху (ноль contentOffset ).

Если доступно UIScrollView подписаться на изменения контентСмещение, и по ним мы понимаем, в какой момент может начаться интерактивное закрытие.

Договорились о механике, давайте реализовывать.



Если нет UIScrollView

Затем добавьте жест панорамирования представленПросмотр .

В какой момент мне следует это сделать? Этот жест инициирует интерактивное закрытие.

А Нижний Лист можно закрыть только в том случае, если он полностью находится на экране.

Поэтому разумно добавить жест завершения отображения в презентацияTransitionDidEnd(_:).



public override func presentationTransitionDidEnd(_ completed: Bool) { if completed { setupGesturesForPresentedView() state = .

presented } else { state = .

dismissed } } private func setupGesturesForPresentedView() { setupPanGesture(for: presentedView) }

И давайте напишем функцию, которая добавляет жест панорамирования к данному представлению.



private func setupPanGesture(for view: UIView?) { guard let view = view else { return } let panRecognizer = UIPanGestureRecognizer(target: self, action: #selector(handlePanGesture(_:))) view.addGestureRecognizer(panRecognizer) } @objc private func handlePanGesture(_ panGesture: UIPanGestureRecognizer) { switch panGesture.state { case .

began: processPanGestureBegan(panGesture) case .

changed: processPanGestureChanged(panGesture) case .

ended: processPanGestureEnded(panGesture) case .

cancelled: processPanGestureCancelled(panGesture) default: break } }

Разберем каждое состояние жеста.

начал — пользователь только начал двигать пальцем, и этот жест был определен как жест панорамирования.

Мы инициируем закрытие Bottom Sheet. измененный — пользователь непрерывно перемещает палец по экрану.

Скрываем Нижний лист пропорционально расстоянию, которое прошел палец по экрану.

закончился — пользователь убрал палец с экрана.

Решаем, закрыть Нижний Лист или вернуть его в исходное положение.

отменен — жест был отменен.

Возвращение нижнего листа в исходное состояние.

Дополнительно мы будем использовать UIPercentDrivenInteractiveTransition для передачи состояния перехода переходящему делегату.



// BottomSheetPresentationController.swift private var interactionController: UIPercentDrivenInteractiveTransition?

Начнем с государства начал .

Это хорошее время для запуска интерактивного закрытия, поскольку это состояние возникает только один раз.

Мы также вызываем увольнение представлениеViewController чтобы уведомить UIKit о намерении закрыть нижний лист.

private func processPanGestureBegan(_ panGesture: UIPanGestureRecognizer) { startInteractiveTransition() } private func startInteractiveTransition() { interactionController = UIPercentDrivenInteractiveTransition() presentingViewController.dismiss(animated: true) { [weak self] in guard let self = self else { return } if self.presentingViewController.presentedViewController !== self.presentedViewController { self.dismissalHandler.performDismissal(animated: true) } } }

Далее идет состояние измененный .

Изменение положения представленПросмотр пропорционально движению пальца по экрану.

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

Далее мы вычисляем ход перехода относительно высоты контроллера представления контента, т.е.

представленView.

private func processPanGestureChanged(_ panGesture: UIPanGestureRecognizer) { let translation = panGesture.translation(in: nil) updateInteractionControllerProgress(verticalTranslation: translation.y) } private func updateInteractionControllerProgress(verticalTranslation: CGFloat) { guard let presentedView = presentedView else { return } let progress = verticalTranslation / presentedView.bounds.height interactionController?.

update(progress) }

Когда пользователь отпускает палец, жест переходит в состояние закончился .

Необходимо определить намерение пользователя: прокрутка контента или желание закрыть Нижний Лист. Если пользователь резко опустил палец вниз, пройдя минимальное расстояние, то, скорее всего, он хотел закрыть Нижний Лист. Возможна и другая ситуация, если пользователь прошел большое расстояние по экрану и в последний момент быстро отпустил палец от экрана вверх.

В такой ситуации Нижний лист должен вернуться в исходное положение.

Это предполагает расчет некоего импульса, учитывающего ускорение и направление.

Немного физики.

Представим, что существует некое тело, которое движется с постоянной скоростью.



Нижний Лист, давай перейдем на

.

Потом на него повлияло замедление на дистанции

Нижний Лист, давай перейдем на

.

Вопрос в том, где остановится тело? Вывод формулы расстояния для тела, находящегося при торможении Напишем формулу скорости

Нижний Лист, давай перейдем на

При отрицательном ускорении скорость станет равной нулю, обозначим этот момент времени

Нижний Лист, давай перейдем на

.

Давайте заменим

Нижний Лист, давай перейдем на

в формулу скорости:

Нижний Лист, давай перейдем на

Далее мы заменяем

Нижний Лист, давай перейдем на

Теги: #iOS #Разработка iOS #Разработка мобильных приложений #Swift #нижний лист

Вместе с данным постом часто просматривают:

Автор Статьи


Зарегистрирован: 2019-12-10 15:07:06
Баллов опыта: 0
Всего постов на сайте: 0
Всего комментарий на сайте: 0
Dima Manisha

Dima Manisha

Эксперт Wmlog. Профессиональный веб-мастер, SEO-специалист, дизайнер, маркетолог и интернет-предприниматель.