Site icon AppTractor

Навигация на SwiftUI: чего не хватает и как исправить

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

С появлением 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.

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

Минусы:

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

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

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

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

Паттерн Coordinator

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Но дизайн и функциональность алерта 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, в котором реализуем такой алгоритм:

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

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

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

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

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

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

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

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

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

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

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

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

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

Exit mobile version