Нижний Лист показался мне трудным и недостижимым.
Это был вызов! Я не понимал, с чего начать.
Возникло много вопросов: использовать представление или контроллер представления? Автоматическая или ручная компоновка? Как анимировать? Как скрыть нижний лист в интерактивном режиме? Но все изменилось после работы над Bottom Sheet для приложения Joom, где он используется повсеместно.
В том числе и в таких критических сценариях, как оплата.
Поэтому могу точно сказать, что мы уверены в этом компоненте.
Так уверенно, что я даже сказал о нем на Подлодке iOS команда #7. Во время семинара я показал, как сделать нижний лист, который может подстраиваться под размер контента, закрывается интерактивно и поддерживает UINavigationController. Подождите, но это Apple предоставил системный Нижний лист .
Зачем писать свое? Действительно, это действительно так, но компонент поддерживается только с iOS 15. Это означает, что полноценно использовать его можно будет только через 2-3 года.
Кроме того, требования дизайнеров часто выходят за рамки стандартных элементов iOS. В рамках статьи хочу развеять туман над Нижним Листом, ответить на вопросы, которые задавал себе, и предложить один из вариантов реализации.
Чтобы в конце вы могли добавить в свое резюме строчку «Я профессионально делаю нижние листы».
Если вам интересно, давайте начнем! Давайте создадим простой нижний лист и обновим его шаг за шагом.
- Давайте научимся подстраиваться под размер контента и закрывать нижний лист.
- Добавим интерактивное закрытие с учетом прокручиваемого контента.
- Давайте поддержим Уинавигатионконтроллер с навигацией внутри нижнего листа.
Часть 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
}
Следим за углами.
Закругленный! Нижний лист теперь соответствует дизайну!
Что мы делали в первой части?
- Делегат перехода системы был переопределен.
- Мы создали контроллер презентаций.
- Добавлена тень, чтобы скрыть нижний лист с помощью обработчика отклонения.
- Реализован анимированный переход с использованием делегата перехода.
- Поддерживается базовый дизайн.
Часть 2. Интерактивное закрытие нижнего листа
Стартовый проект
Как и в первой части, начнем с стартовый проект .В проект добавлена полоса вытягивания, которая будет подсказывать пользователю, что Нижний лист можно скрыть не только щелчком по пустому месту, но и жестом свайпа.
Также в ResizeViewController появился ScrollView в полноэкранном режиме.
Он понадобится нам для экранов списков.
Остальное из первой части.
Запустим приложение.
Теория.
Особенности интерактивного закрытия Мы используем UISwipeGestureRecensorer распознавать жест смахивания.
С его помощью мы начнем закрывать нижний лист. А что, если у представленного контроллера такой жест уже есть? Тогда это может привести к конфликту жестов, поскольку непонятно, какой из них обрабатывать первым.
Но как часто у представленного контроллера может быть такой жест? Вообще-то, постоянно.
В современном приложении 99% экранов представляют собой списки.
Это означает, что у каждого есть UIScrollView или его наследники: УИТаблевиев или UICollectionView , в котором присутствует тот же жест. Как быть? Давайте рассмотрим два случая, когда UIScrollView нет, и он есть.
- Если нет, то все просто — добавьте жест смахивания.
- Если так, то контент можно разместить :
- В полной мере .
Тогда размер нижнего листа станет меньше экрана, и мы сразу же закроем нижний лист жестом смахивания.
- Частично .
Тогда свайп также может означать прокрутку.
Предположим, что пользователь хочет закрыть нижний лист, проведя пальцем вниз, и когда содержимое заканчивается вверху (ноль contentOffset ).
- В полной мере .
Договорились о механике, давайте реализовывать.
Если нет 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 #нижний лист
-
Мейо-Смит, Ричмонд
19 Oct, 24 -
Лед Тронулся: Коллокация Eee Box Из Replay
19 Oct, 24 -
Спам На Корпоративном Уровне
19 Oct, 24 -
Риф, Рит И Cib 2008. Впечатления Участников
19 Oct, 24 -
Разграничение Прав Доступа В Jenkins
19 Oct, 24 -
Swift 4 – Слабые Звенья
19 Oct, 24 -
Персональный Сервер Openid На Perl
19 Oct, 24