diff --git a/learning/ios/navigation-swiftui.md b/learning/ios/navigation-swiftui.md new file mode 100644 index 000000000..bc1270384 --- /dev/null +++ b/learning/ios/navigation-swiftui.md @@ -0,0 +1,261 @@ +# Навигация для SwiftUI + +В стандартном `NavigationStack` есть ограничения: нельзя просто очистить стек экранов, неудобно обрабатывать logout, сложно управлять переходами между флоу. +Чтобы это решить, мы используем кастомный навигационный слой, построенный на базе: +- **AppRouter** — управляет навигационными событиями (`push`, `pop`, `replace`, `replaceStack`, `popUntil` и др.) через Combine. +- **AppRouterHost** — хост для `NavigationStack`, слушает команды роутера и обновляет стек экранов. +- **AppRoute** — перечисление маршрутов приложения (`signIn`, `main`, `detail`). + +Главное преимущество такого подхода — полный контроль над стеком навигации, анимациями и маршрутизацией. + +## AppRoute + +`AppRoute` - это основа навигации. Каждый экран, на который вы хотите перейти, должен быть кейсом этого `enum`. + +```swift +import SwiftUI + +enum AppRoute: Hashable { + case signIn + case main + case detail(id: Int) +} + +``` +- Enum обязательно должен реализовывать `Hashable`, чтобы `NavigationStack` мог работать с этим enum'ом. +- Параметры экранов передаются через associated values `(.detail(id: Int))`. +- В сложных проектах можно заводить несколько Route, например AuthRoute для авторизации и AppRoute для основного функционала приложения. + +## AppRouter + +AppRouter — это "пульт управления" навигацией. Он сам не переключает экраны, а только отправляет команды. + +```swift +class AppRouter: ObservableObject { + // Приватные сабджекты для управления событиями + private let commandSubject = PassthroughSubject, Never>() + + // Публичные паблишеры + var commandPublisher: AnyPublisher, Never> { + commandSubject.eraseToAnyPublisher() + } + + /// Добавить экран следующим в стеке навигации + func push(_ route: T) { + commandSubject.send(.push(route: route)) + } + + /// Заменить весь стек навигации на новый роут + func replace(_ route: T) { + commandSubject.send(.replace(route: route)) + } + + /// Заменить весь стек навигации на другой стек + func replaceStack(_ stack: [T]) { + commandSubject.send(.replaceStack(stack: stack)) + } + + func popUntil(popIf: @escaping (T) -> Bool) { + commandSubject.send(.popUntil(popIf: popIf)) + } + + /// Убрать из стека навигации роуты удовлетворяющие условию и добавить новый + func popUntilAndPush(popIf: @escaping (T) -> Bool, pushRoute: T) { + commandSubject.send(.popUntilAndPush(popIf: popIf, pushRoutes: [pushRoute])) + } + + /// Убрать из стека навигации роуты удовлетворяющие условию и добавить несколько новых + func popUntilAndPush(popIf: @escaping (T) -> Bool, pushRoutes: [T]) { + commandSubject.send(.popUntilAndPush(popIf: popIf, pushRoutes: pushRoutes)) + } + + /// Убрать из стека навигации один экран + func pop() { + commandSubject.send(.pop) + } +} + +enum RouterCommand { + case push(route: T) + case popUntil(popIf: (T) -> Bool) + case popUntilAndPush(popIf: (T) -> Bool, pushRoutes: [T]) + case pop + case replace(route: T) + case replaceStack(stack: [T]) +} + +``` + +Пример использования: + +```swift +@EnvironmentObject var router: AppRouter + +router.push(.main) // перейти на главный экран +router.pop() // вернуться назад +router.replace(.signIn) // очистить стек и перейти на авторизацию + +``` +Как router передается между экранами: +Роутер удобно получать через `@EnvironmentObject`, так его не нужно вручную передавать во все экраны. +- AppRouterHost создаёт и хранит объект `AppRouter`. +- Все экраны внутри этого хоста получают роутер через `environmentObject(router)`. +- SwiftUI автоматически вкладывает объект в иерархию view, так что любой экран, который находится внутри `AppRouterHost`, может использовать его через @`EnvironmentObject`. + +В AppRouterHost: + +```swift +@ObservedObject private var router = AppRouter() + +var body: some View { + NavigationStack(path: $navigationPath) { + routeView(router, rootRoute) + .navigationDestination( + or: T.self, + destination: { routeView(router, $0) } + ) + }.environmentObject(router) // роутер передается всем дочерним экранам +} + +``` + +В любом дочернем экране: +```swift +struct AuthScreen: View { + @EnvironmentObject var router: AppRouter + + var body: some View { + Button("Войти") { + router.replace(.main) + } + } +} +``` + +## AppRouterHost + +`AppRouterHost` связывает `AppRouter` с `NavigationStack`. Он слушает команды роутера и обновляет, какие экраны должны быть показаны. + +Основные свойства: +- `rootRoute` — корневой экран (с которого начинается навигация). +- `navigationPath` — стек экранов, которые уже открыты. +- `routeView` - билдер конкретного экрана. В аргумент приходит роут. Когда используем enum для роута - делаем просто switch по вариантам этого enum и возвращаем нужные экраны. + +Пример: + +```swift +AppRouterHost(initialRoute: .signIn) { router, route in + switch route { + case .signIn: + AuthScreen() + case .main: + MainScreen() + case let .detail(id): + DetailScreen(id: id) + } +} +``` +- При старте открывается signIn. +- Если вызвать router.push(.main) → перейдем на экран main. +- Если вызвать router.pop() → вернемся обратно на signIn. + +В итоге у нас получается такая последовательность: + +Экран → AppRouter (отправил команду) → AppRouterHost (выполнил) → NavigationStack (обновился) + + +## RootScreenView + +Как видно из названия, это корневой экран приложения, именно он решает какой экран показать в данный момент. Если в приложении несколько роутов, то именно здесь будет происходить переключение между ними + +```swift +struct RootScreenView: View { + @State private var root: RootScreen = .splash + + var body: some View { + LogoutNavigationHookView(onLogout: { + root = .mainFlow(route: .signIn) + }) { + switch root { + case .splash: + SplashScreen(root: $root) + case let .mainFlow(route): + MainNavigationView(initialRoute: route) + } + } + } +} +``` + +При запуске приложения `root = .splash`, соответсвенно показываться будет SplashScreen. +После этого сплешскрин определяет, авторизован юзер или нет. +Если не авторизван то меняет значение `root` на `.mainFlow(.signIn)`. +Если авторизован, то меняет значение `root` на `.mainFlow(.main)`. +В этот момент RootScreenView переключает показ на `MainNavigationView`. +Если пользователь нажимает "Выйти", то срабатывает `LogoutNavigationHookView`, и всё сбрасывается на SignIn. + +## LogoutNavigationHookView + +В приложениях часто нужно сбросить навигацию при выходе пользователя. Для этого используется LogoutNavigationHookView. +Он оборачивает контент и отслеживает события логаута через logoutHandler. Когда происходит событие логаута, вызывается onLogout(), и можно, например, сбросить стек навигации на экран авторизации. + +```swift +import Combine +import MultiPlatformLibrary +import SwiftUI + +struct LogoutNavigationHookView: View { + + // LogoutNavigationHookView слушает события логаута через logoutHandler. + private var logoutHandler: LogoutHandler = Koin.instance.getLogoutHandler() + + let onLogout: () -> Void + let content: () -> Content + + init(onLogout: @escaping () -> Void, @ViewBuilder content: @escaping () -> Content) { + self.onLogout = onLogout + self.content = content + } + + var body: some View { + content() + .onReceive( + logoutHandler.logoutEvents.toPublisher() + .catch { _ in Empty() } + .assertNoFailure() + ) { _ in + + // При логауте вызывается onLogout(), которое сбрасывает root. + onLogout() + } + } +} + +``` + +В этом примере вызов `onLogout` приводит к сбросу значения root на .mainFlow(.signIn). +`RootScreenView` реагирует на изменение root и показывает экран авторизации. + +```swift +struct RootScreenView: View { + @State private var root: RootScreen = .splash + + var body: some View { + LogoutNavigationHookView(onLogout: { + + // Сбрасываем стек навигации на экран авторизации + root = .mainFlow(route: .signIn) + }) { + switch root { + case .splash: + SplashScreen(root: $root) + case let .mainFlow(route): + MainNavigationView(initialRoute: route) + } + } + } +} +``` +Если мы получаем событие logout на любом экране, весь стек навигации автоматически сбрасывается. + +- [Навигация для UIKit через координаторы (Архив)](./navigation-uikit.md) diff --git a/learning/ios/navigation.md b/learning/ios/navigation-uikit.md similarity index 99% rename from learning/ios/navigation.md rename to learning/ios/navigation-uikit.md index 500dd7a34..f7b9ab9bc 100644 --- a/learning/ios/navigation.md +++ b/learning/ios/navigation-uikit.md @@ -1,4 +1,4 @@ -# Навигация +# Навигация UIKit В основе навигации лежат координаторы. Каждый координатор покрывает логически связанный блок функционала, который чаще всего состоит из нескольких экранов. При этом между собой они независимы и diff --git a/learning/kotlin-native/swift-interop.md b/learning/kotlin-native/swift-interop.md index 98a7c1565..1fde35644 100644 --- a/learning/kotlin-native/swift-interop.md +++ b/learning/kotlin-native/swift-interop.md @@ -4,7 +4,33 @@ sidebar_position: 3 # Kotlin/Swift interop -Полезные ссылки: +## Top-level функции в Kotlin Multiplatform и их вызов из Swift + +В Kotlin мы можем объявлять глобальные функции (top-level functions), то есть функции вне классов и объектов: +```kotlin +fun log(message: String) { + println(message) +} +``` + +На Android такие функции вызываются напрямую. Но при интеграции с iOS возникает ощущение, что это функция не видна в Swift коде. +Дело в том, что Swift получает доступ к Kotlin-функциям через Objective-C. В Objective-C глобальных функций нет, поэтому компилятор Kotlin при экспорте в iOS “упаковывает” top-level функции в специальные классы. +Имя этого класса формируется из названия файла, где функция была определена. Если, например, функция log находится в Logger.kt, то в Swift её нужно вызвать так: +```swift +LoggerKt.log("Hello from iOS") +``` +То есть доступ к функции осуществляется не напрямую, а через сгенерированный класс LoggerKt. + +Однако при использовании [SKIE](https://skie.touchlab.co/intro) всё работает так, как ожидается - глобальные функции становятся настоящими глобальными функциями в Swift. + +И тогда на swift мы сможем писать так: +```swift +log("Hello from iOS") +``` + +Подробнее: https://skie.touchlab.co/features/global-functions + +## Полезные ссылки: - [Interoperability with Swift/Objective-C](https://kotlinlang.org/docs/native-objc-interop.html) - [Russell Wolf - The Kotlin/Swift boundary](https://vimeo.com/625847664) diff --git a/onboarding/ios-specific.md b/onboarding/ios-specific.md index 8d4125892..affb4dc37 100644 --- a/onboarding/ios-specific.md +++ b/onboarding/ios-specific.md @@ -6,11 +6,11 @@ sidebar_position: 7 Важные особенности для iOS разработчиков: -- [Проблемы с Release сборками под iOS](../learning/problem-solving/kotlin-native-release-build-failed) -- [Влияние Kotlin/Native на размер бинарника](../learning/kotlin-native/size_impact) -- [Как читать Stacktrace Kotlin/Native на iOS](../learning/kotlin-native/stacktraces) -- [Разница Extension'ов в Kotlin и Swift](../learning/kotlin-native/swift-extensions) - [Как Kotlin будет виден со стороны Swift](../learning/kotlin-native/swift-interop) - [Как Kotlin попадает в Xcode через Cocoapods](../learning/ios/pods) - [Как мы работаем на проектах с конфигурациями](../learning/ios/configuration) -- [Как мы делаем навигацию](../learning/ios/navigation) +- [Как мы делаем навигацию](../learning/ios/navigation-swiftui) +- [Разница Extension'ов в Kotlin и Swift](../learning/kotlin-native/swift-extensions) +- [Влияние Kotlin/Native на размер бинарника](../learning/kotlin-native/size_impact) +- [Как читать Stacktrace Kotlin/Native на iOS](../learning/kotlin-native/stacktraces) + diff --git a/onboarding/project-inside.md b/onboarding/project-inside.md index a9e044ee0..b97f389c1 100644 --- a/onboarding/project-inside.md +++ b/onboarding/project-inside.md @@ -915,7 +915,7 @@ class AppComponent { Мы поняли что является отправной точкой нашего приложения, а теперь нам нужно понять как построена навигация в iOS приложение и какие подходы при работе с ней мы используем. -Для этого можете ознакомиться со [статьей в разделе обучения](../learning/ios/navigation). +Для этого можете ознакомиться со [статьей в разделе обучения](../learning/ios/navigation-swiftui). ## master.sh diff --git a/university/4-icerock-basics/navigation.md b/university/4-icerock-basics/navigation.md index 26ce8d9a1..560411913 100644 --- a/university/4-icerock-basics/navigation.md +++ b/university/4-icerock-basics/navigation.md @@ -11,7 +11,7 @@ sidebar_position: 8 ## iOS -Для понимания того, как будет реализована навигация в `iOS` приложениях на проектах, ознакомьтесь сначала с [видео-разбором](https://www.youtube.com/watch?v=Pt9TGFzLVzc) использования `ApplicationCoordinator` для навигации между экранами, а затем со [статьей](../../learning/ios/navigation) и материалами из нее. +Для понимания того, как будет реализована навигация в `iOS` приложениях на проектах, ознакомьтесь сначала с [видео-разбором](https://www.youtube.com/watch?v=Pt9TGFzLVzc) использования `ApplicationCoordinator` для навигации между экранами, а затем со [статьей](../../learning/ios/navigation-swiftui) и материалами из нее. В наших проектах, для верстки и навигации на iOS мы больше не будем использовать `.storyboard`, вместо этого мы будем пользоваться следующими инструментами: - `AppCoordinator` - главный координатор приложения, который будет запускать другие координаторы в зависимости от входных данных