В 2019 году Apple представила SwiftUI. На презентации технология выглядела как фреймворк будущего: декларативный синтаксис, живые превью в Xcode, кроссплатформенность. Но со временем стало ясно, что между презентацией и реальностью production-разработки открылась пропасть, которую легко недооценить.

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

В статье iOS-разработчик CleverPumpkin Даниил Апальков разбирает, какие именно проблемы решали, какие обходные решения применили. Покажут варианты подходов и поделятся выводами, в каких ситуациях какие инструменты использовать для контролируемой навигации.

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

Требования к компонентам 

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

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

Навигация. Кроме классического навигационного стека UINavigationController, в ней часто есть другие компоненты:

  • Bottom Sheet, адаптирующиеся под размер контента. Например, шторка выбора опций;

  • Диалоговое окно alert с кастомным дизайном;

  • Overlay, элементы, перекрывающие весь экран. 

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

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

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

  • ортогональный скролл, когда элементы можно прокручивать вертикально и горизонтально;

  • плавные анимации при изменении списка;

  • сохранение позиции скролла, когда при возвращении на экран со списком его положение сохраняется на том же месте, где пользователь оставил его;

  • отсутствие «прыжков» при скролле, когда список не дергается при изменении контента или добавлении новых данных.

Архитектура экрана. Кроме разработки приложений, мы занимаемся их поддержкой, поэтому часто нужно совмещать UIKit- и SwiftUI-подходы. А так как это два разных в работе фреймворка, мы должны продумать архитектуру экранов так, чтобы они подошли для обеих технологий.

Как устроена навигация в SwiftUI и UIKit

Подходы SwiftUI и UIKit различаются кардинально.

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

Дополнительное условие SwiftUI — его мультиплатформенность. Навигация на macOS ощутимо отличается от iOS-подхода. Есть ощущение, что это одна из причин, почему API навигации изначально было ограниченным:

  • NavigationView не позволял полностью настроить внешний вид UINavigationController, который он добавлял в иерархию экрана.

  • NavigationView требовал присутствия NavigationLink, от чего отделить логику навигации от непосредственного представления становилось сложнее.

  • NavigationView не давал доступов к текущему состоянию навигации.

С появлением NavigationStack API поменялось в лучшую сторону: Apple добавили NavigationPath, который привязывается к NavigationStack и позволяет добавить туда любой Hashable-тип, который затем резолвится в navigationDestination

Самый простой пример использования NavigationPath может выглядеть так:

struct ContentView: View {
	
	@State
	private var path = NavigationPath()
	
	var body: some View {
		NavigationStack(path: $path) {
			Button("First Screen") {
				path.append(1)
			}
			.navigationDestination(for: Int.self) { number in
				switch number {
				case 1:
					Text("Second Screen")
				default:
					EmptyView()
				}
			}
		}
	}
}

В этом примере по нажатию на кнопку в NavigationPath добавляется Int, и затем ниже внутри .navigationDestination(for: Int.self) описывается код, который в соответствии определенному значению отдает ту или иную View.

Плюсы видны сразу:

  • Появляется (частичное) состояние навигации.

  • NavigationPath работает с Hashable-типами. Это позволяет написать свою модель, описывающую состояние навигации, условный enum Screens.

  • Доступ к методам append, removeLast() и removeLast(_ k:) у NavigationPath позволяют синхронной последовательностью действий добавить или убрать нужное количество экранов.

Минусы:

  • Состояние навигации относится только к стеку экранов навигации — это то, что лежит внутри UINavigationController.viewControllers. В состоянии навигации не хватает важной части — модальных экранов, шторок, fullScreenCover. Например при работе с диплинками из-за этого придется обрабатывать и NavigationPath, и локальные State-отображения модальных экранов. Поэтому единого источника правды для навигации не получилось.

  • NavigationPath работает с Hashable-типами, но не через Generic, а type-erase до AnyHashable. Это значит, что мы не можем задать NavigationPath единую конкретную модель. В метод append можно передать любой Hashable, а это меньше строгости для разработки и никак не гарантирует обработки этого Hashable.

Как работает с навигацией CleverPumpkin

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

Средства для навигации в UIKit и SwiftUI привязаны к отображению — UIViewController и View соответственно. Как следствие, логика навигации смешана с кодом, который отвечает за отображение интерфейса. Это нарушает принцип единой ответственности и усложняет разработку и поддержку.

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

Паттерн Coordinator

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

Что позволяет сделать Coordinator:

  • Разделить ответственность. UI-компоненты фокусируются только на отображении данных и взаимодействии с пользователем, вся логика переходов и взаимосвязанного поведения уходит в координаторы.

  • Управлять сложными потоками. В приложениях с диплинками встречаются множественные пользовательские сценарии и зависимые переходы между разными экранами. Coordinator превращает запутанный переход из цепочки вложенных контроллеров в четкое дерево flow.

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

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

Пример сложной задачи, которую помогает решить паттерн, — диплинки. При получении диплинка пользователя нужно провести по цепочке зависимых экранов, причем цепочка зависит от контекста навигации. Поэтому нужно уметь «разворачивать» сложные вложенные навигационные стеки и попасть точно на нужный экран. Например, перейти не просто на checkout, а на конкретный order details. Без централизованной навигации это превращается в пучок разрозненных переходов, где логика дублируется на каждом уровне.

Написать на нативном SwiftUI приемлемый Coordinator было нереально до появления NavigationStack в iOS 16. Раньше в API не было «состояния» навигации, и оно внутренне обрабатывалось через NavigationLink. Мы просто добавляли в UI кнопки, которые вызывали навигационные переходы. В принципе, через большое количество state можно было узнать, что сейчас происходит в навигации приложения и понять, на каком экране находится пользователь, но код становился сложным и запутанным из-за отсутствия стабильной возможности получить completion у системных анимаций.

В iOS 16 новое API навигации предоставляет частичное состояние навигации. Стало намного проще реализовать механизм диплинков, но выстроить иерархию навигации все еще сложно. Приходится придумывать, как пробросить dee из определенного родительского координатора в конкретный дочерний. 

Одно из решений — сделать это через environment. Минус в том, что он открывает доступ к линкам для всех дочерних View, а это увеличивает пространство для ошибок. 

Другое решение — работать через Combine, и в конкретных координаторах подписываться на соответствующие диплинки.

Альтернатива NavigationStack

Есть библиотека FlowStacks, которая повторяет структуру, синтаксис и поведение системного API у NavigationStack, но при этом добавляет в состояние навигации sheet и cover. Поддерживает iOS 14 и выше. 

Мы попробовали эту библиотеку в нескольких больших коммерческих приложениях. Оказалось, что мимикрирование под системное API имеет плюсы и минусы. Иметь состояние навигации хорошо, но у анимаций навигационных действий нет параметров completion. Из-за этого в коде управления навигацией в зависимости от версии iOS может появиться много DispatchQueue.main.asyncAfter. Рассмотрим конкретнее, в каких ситуациях могут возникать проблемы синхронизации действий навигации.

Проблема кастомных алертов

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

Для отображения системных алертов в SwiftUI используется модификатор .alert. У таких алертов есть большой плюс — они ведут себя ожидаемо в любом навигационном контексте. 

Для примера возьмём приложение, где есть TabBar и навигационный стек, в который мы пушим экран. На этом экране показываем алерт. Допустим, пользователь получил уведомление, нажал на него и получил диплинк, который открывает модальный экран:

Обратите внимание на 2 вещи:

  • Системный алерт отображается поверх всего контента, включая TabBar, хотя отображён он был на вложенном экране.

  • При показе модального экрана алерт скрывается самостоятельно!

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

Но дизайн и функциональность алерта Apple подходит не всем приложениям. Если мы хотим что-то более кастомное, есть несколько решений ниже.

Варианты готовых кастомных алертов

В сети можно найти несколько статей от iOS-разработчиков, например:

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

Эти реализации часто объединяет один нюанс — View алерта показывается через ZStack или .overlay-модификатор. Так как и ZStack, и .overlay принимают размер View, в которой отображаются, то здесь мы теряем ту самую универсальность решения. Например, для корневого экрана приложения решение сработает как надо, а вот внутри навигации алерт отобразится на контенте экрана, но не перекроет собой NavigationBar или TabBar.

В более продвинутых статьях встречается использование fullScreenCover в комбинации с presentationBackground(.clear) и completion у withAnimation, но такая реализация требует iOS 17.

➡️ Можно взять готовое решение на GitHub, например CustomAlert.

Проблема алертов с GitHub в том, что в лучшем случае они решают проблему универсальности через UIKit и поиск UIWindow-приложения. 

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

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

Наши варианты — кастомные алерты с использованием SwiftUI и UIKit 

По нашим требованиям алерт должен:

  • Быть привязан к экрану, который его отображает.

  • Показываться поверх всего контента.

В прошлом разделе мы рассказали про минусы подхода с отображением на UIWindow. Мы нашли два решения этой проблемы:

  1. Оставить SwiftUI-навигацию. Для алертов находить у UIWindow самый верхний контроллер и отобразить на нём алерт через UIHostingController. Это позволяет при презентации указать свой modalPresentationStyle. Хорошо подходит UIModalPresentationStyle.overFullScreen, который отображает контент контроллера поверх всего экрана. Но это не единственный случай использования. Например, UIModalPresentationStyle.overCurrentContext тоже недоступен из SwiftUI, но может быть полезен. При этом мы работаем с иерархией UIViewController, которая позволяет привязаться к parent-контроллеру.

  2. Использовать навигацию, целиком написанную на UIKit. Отображать модули на UIHostingController.

Так как второй вариант заметно затратнее по времени, рассмотрим детальнее реализацию первого.

В SwiftUI хочется иметь API примерно такого вида:

@MainActor
	func present<Content: View>(
		isPresented: Binding<Bool>,
		style: UIModalPresentationStyle = .automatic,
		animated: Bool = false,
		@ViewBuilder content: @escaping () -> Content
	) -> some View

Чтобы найти верхний контроллер в иерархии окна, мы можем взять rootViewController и пройтись по иерархии руками:

extension UIViewController {	
	var topViewController: UIViewController? {
		if let navigationController = self as? UINavigationController {
			return 
          navigationController.visibleViewController?.topViewController
		}
		
		if let selectedViewController = (self as? UITabBarController)?.selectedViewController {
			return selectedViewController.topViewController
		}
		
		if let presentedViewController {
			return presentedViewController.topViewController
		}
		
		return self
	}
}

После этого пишем PresenterViewModifier для SwiftUI, в котором реализуем такой алгоритм:

  • Ищем UIWindow в иерархии: через UIViewControllerRepresentable, или через готовое решение WindowReader.

  • При выставлении isPresented в true находим topViewController и показываем на нем UIHostingController соответствующим контентом.

  • Записываем показанный UIHostingController в локальный State.

  • При выставлении isPresented в false считываем записанный в State UIHostingController, вызываем на нем dismiss.

Написав PresenterViewModifier, можно создать AlertViewModifier. Он принимает модель алерта и показывает UIHostingController с нашей кастомной анимированной View алерта.

Плюсы и минусы нашего решения

Плюс у нашего решения на SwiftUI один:

  • Работает, позволяет писать любую View алерта с любой моделью.

Минусов получилось больше:

  • До iOS 17 нужно писать сложный код синхронизации SwiftUI-анимации и UIKit-логики отображения контроллера.

    Completion-параметр у withAnimation в SwiftUI добавили только в iOS 17. Для предыдущих версий нужно писать свой Animatable-модификатор и пытаться проверять руками окончание анимации. Механизм SwiftUI-анимаций закрыт и не использует CATransaction, поэтому со временем это может приводить к неочевидным ошибкам. Пример с edge-case: иногда алерт находится в середине анимации, когда его пытаются скрыть. Это нужно правильно обработать, в результате растёт код алерта и количество багов.

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

  • В SwiftUI-координаторах приходится писать много State-переменных и замыканий, которые синхронизируют действия навигации.

Как итог, работа с алертами оказалась одной из весомых причин отказаться от SwiftUI навигации в сторону UIKit-решения.

Динамические шторки

Другой часто встречающийся элемент в наших приложениях — self-sizing bottom-sheet. Это шторка, которая автоматически адаптируется под размер своего содержимого. 

Чаще всего наши требования к шторке такие:

  • У шторки есть заголовок и содержимое.

  • Она принимает размер содержимого внутри и размер заголовка.

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

  • Если в содержимом есть текстовые поля, при появлении клавиатуры нужно предусмотреть корректное поведение интерфейса.

С iOS 15 Apple представила Detents (или детенты) для UISheetPresentationController, а с iOS 16 добавила API для кастомных детентов, где можно указать любой желаемый размер. В этом случае соответствующее SwiftUI API оказалось более чем юзабельно.

Реализация шторок в iOS 16 и новее

Реализовать шторку на SwiftUI концептуально оказалось довольно просто:

struct BottomSheetView<Content: View>: View {
	
	let content: Content
	
	init(@ViewBuilder content: () -> Content) {
		self.content = content()
	}
	
	var body: some View {
		VStack {
			HeaderView()
			
			ViewThatFits(in: .vertical) {
				content
				
				ScrollView {
					content
				}
			}
		}
	}
}

В этом примере SwiftUI API во всех смыслах прекрасно. Кратко, понятно и легко кастомизируется.

Реализация шторок до iOS 16

У нас на поддержке есть приложения со SwiftUI-навигацией до iOS 16. Написать свое решение для них намного сложнее, если не подключать UIKit-библиотеки для шторок, например PanModal.

Проблема навигации шторки такая же, как у алертов: невозможно силами SwiftUI отследить окончание анимации отображения или скрытия шторки. Родной модификатор .sheet не дает такой возможности. 

Почему это важно: до окончания анимации шторки не уходят из иерархии показывающего их ViewController и занимают у своего родителя место как presentedViewController. Теперь, если попробовать отобразить другую шторку или алерт во время анимации, мы получим ошибку presentation is in progress. Система видит, что ViewController уже занят, и не разрешает показать что-то ещё.

Тут мы опять заходим на UIKit территорию: для корректной работы нужен доступ к UIViewController, которого нет. Приходится использовать topViewController, от которого возникает большое количество проблем, багов и костылей.

В случае с встраиванием UIKit-шторок в SwiftUI-навигацию эта проблема ощущается особенно остро. Мы хотим максимально использовать SwiftUI, и в идеале контент шторки должен быть SwiftUI View. Но приходится использовать UIHostingController из UIKit, чтобы встроить SwiftUI-View, при этом нужно точно рассчитывать актуальный размер его содержимого.

Обсудим UIKit-решение по порядку, и сначала обратим внимание на то, как стоит правильно брать размеры у UIHostingController:

Решая эту задачу, опытный UIKit разработчик может воспользоваться методом systemLayoutSizeFitting с параметром UIView.layoutFittingExpandedSize. Но у этого метода есть два нюанса:

  • Для UIHostingController его стоит использовать в случаях, когда нужно получить размер View с учетом всех заданных правил Constraints, которые влияют на UIHostingController. Если хочется узнать желаемый «идеальный» размер View, нужно использовать метод sizeThatFits на UIHostingController.

  • Нужно держать в голове, что у UIHostingController есть параметр sizingOptions, который при встраивании в UIKit стоит выставлять в UIHostingControllerSizingOptions.intrinsicContentSize. Так viewController будет отслеживать изменения в размерах SwiftUI View и выставлять свой размер, равный intrinsicContentSize, что хорошо работает с Auto-Layout. Без выставления настройки sizeThatFits в некоторых случаях может отдавать некорректные размеры.

В итоге, до iOS 16 при работе со шторками нам снова пришлось использовать UIKit для динамических шторок, от чего также возникла потребность синхронизации анимаций, которые критичны для сложной логики навигации. 

Выводы по навигации

Для наших задач родная SwiftUI-навигация оказалась слишком тяжелой для использования в iOS до 16-й версии. С появлением NavigationStack ситуация стала значительно лучше, но наш опыт показывает, что его все еще недостаточно для больших enterprise-приложений. Основные минусы такие:

  • Нельзя встроить кастомные алерты в механизмы SwiftUI.

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

  • Необходимость вслепую обращаться в UIKit-контекст, что приводит к большому количеству проблем при разработке и поддержке приложений.

По нашему опыту при выборе стека технологий есть три варианта действий.

1. Можно использовать NavigationStack, если:

  • Доступна iOS 16.

  • В приложении не планируются диплинки, или не планируются шторки и кастомные алерты.

2. Можно использовать FlowStacks, если:

  • Доступна iOS 14, планируются диплинки, но не планируются динамические шторки и кастомные алерты.

  • Доступна iOS 16, планируются диплинки и динамические шторки, но не планируются кастомные алерты.

  • Доступна iOS 17, планируются диплинки, динамические шторки, кастомные алерты

Стоит использовать UIKit, если:

  • Не доступна iOS 17, планируются диплинки, динамические шторки, кастомные алерты и сложная навигация, где требуется высокий уровень контроля.

Выбор каждого варианта зависит от потребностей отдельно взятого приложения. Главный минус UIKit — это времязатратность и, возможно, малая степень знакомства новых iOS-разработчиков с этим фреймворком. Но это самое устойчивое к поддержке и масштабированию решение, которое даёт необходимые инструменты для любых сценариев навигации.

Если в проекте не нужны сложные инструменты, подходящим решением может стать FlowStacks. Он повторяет системное API NavigationStack, добавляет в состояние данные о показанных шторках, и подходит для реализации диплинков.


Это блог CleverPumpkin. Мы разрабатываем и развиваем цифровые продукты – веб-сервисы, сайты и мобильные приложения. Специализируемся на сложных проектах сфер финтех и e-commerce. На Хабре рассказываем про наши кейсы, сложности разработки, лайфхаки и инструменты. Если вам нужна консультация по вашему проекту — напишите нам в Telegram.

Комментарии (0)