Skip to content

Commit 133ba94

Browse files
authored
Merge pull request #166 from Dorofeev/new-ios-navigation
Update ios specific onboarding and new swiftUI navigation
2 parents a2850b1 + ef0779a commit 133ba94

File tree

6 files changed

+296
-9
lines changed

6 files changed

+296
-9
lines changed

learning/ios/navigation-swiftui.md

Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
1+
# Навигация для SwiftUI
2+
3+
В стандартном `NavigationStack` есть ограничения: нельзя просто очистить стек экранов, неудобно обрабатывать logout, сложно управлять переходами между флоу.
4+
Чтобы это решить, мы используем кастомный навигационный слой, построенный на базе:
5+
- **AppRouter<T: Hashable>** — управляет навигационными событиями (`push`, `pop`, `replace`, `replaceStack`, `popUntil` и др.) через Combine.
6+
- **AppRouterHost<T: Hashable>** — хост для `NavigationStack`, слушает команды роутера и обновляет стек экранов.
7+
- **AppRoute** — перечисление маршрутов приложения (`signIn`, `main`, `detail`).
8+
9+
Главное преимущество такого подхода — полный контроль над стеком навигации, анимациями и маршрутизацией.
10+
11+
## AppRoute
12+
13+
`AppRoute` - это основа навигации. Каждый экран, на который вы хотите перейти, должен быть кейсом этого `enum`.
14+
15+
```swift
16+
import SwiftUI
17+
18+
enum AppRoute: Hashable {
19+
case signIn
20+
case main
21+
case detail(id: Int)
22+
}
23+
24+
```
25+
- Enum обязательно должен реализовывать `Hashable`, чтобы `NavigationStack` мог работать с этим enum'ом.
26+
- Параметры экранов передаются через associated values `(.detail(id: Int))`.
27+
- В сложных проектах можно заводить несколько Route, например AuthRoute для авторизации и AppRoute для основного функционала приложения.
28+
29+
## AppRouter
30+
31+
AppRouter — это "пульт управления" навигацией. Он сам не переключает экраны, а только отправляет команды.
32+
33+
```swift
34+
class AppRouter<T: Hashable>: ObservableObject {
35+
// Приватные сабджекты для управления событиями
36+
private let commandSubject = PassthroughSubject<RouterCommand<T>, Never>()
37+
38+
// Публичные паблишеры
39+
var commandPublisher: AnyPublisher<RouterCommand<T>, Never> {
40+
commandSubject.eraseToAnyPublisher()
41+
}
42+
43+
/// Добавить экран следующим в стеке навигации
44+
func push(_ route: T) {
45+
commandSubject.send(.push(route: route))
46+
}
47+
48+
/// Заменить весь стек навигации на новый роут
49+
func replace(_ route: T) {
50+
commandSubject.send(.replace(route: route))
51+
}
52+
53+
/// Заменить весь стек навигации на другой стек
54+
func replaceStack(_ stack: [T]) {
55+
commandSubject.send(.replaceStack(stack: stack))
56+
}
57+
58+
func popUntil(popIf: @escaping (T) -> Bool) {
59+
commandSubject.send(.popUntil(popIf: popIf))
60+
}
61+
62+
/// Убрать из стека навигации роуты удовлетворяющие условию и добавить новый
63+
func popUntilAndPush(popIf: @escaping (T) -> Bool, pushRoute: T) {
64+
commandSubject.send(.popUntilAndPush(popIf: popIf, pushRoutes: [pushRoute]))
65+
}
66+
67+
/// Убрать из стека навигации роуты удовлетворяющие условию и добавить несколько новых
68+
func popUntilAndPush(popIf: @escaping (T) -> Bool, pushRoutes: [T]) {
69+
commandSubject.send(.popUntilAndPush(popIf: popIf, pushRoutes: pushRoutes))
70+
}
71+
72+
/// Убрать из стека навигации один экран
73+
func pop() {
74+
commandSubject.send(.pop)
75+
}
76+
}
77+
78+
enum RouterCommand<T: Hashable> {
79+
case push(route: T)
80+
case popUntil(popIf: (T) -> Bool)
81+
case popUntilAndPush(popIf: (T) -> Bool, pushRoutes: [T])
82+
case pop
83+
case replace(route: T)
84+
case replaceStack(stack: [T])
85+
}
86+
87+
```
88+
89+
Пример использования:
90+
91+
```swift
92+
@EnvironmentObject var router: AppRouter<AppRoute>
93+
94+
router.push(.main) // перейти на главный экран
95+
router.pop() // вернуться назад
96+
router.replace(.signIn) // очистить стек и перейти на авторизацию
97+
98+
```
99+
Как router передается между экранами:
100+
Роутер удобно получать через `@EnvironmentObject`, так его не нужно вручную передавать во все экраны.
101+
- AppRouterHost создаёт и хранит объект `AppRouter`.
102+
- Все экраны внутри этого хоста получают роутер через `environmentObject(router)`.
103+
- SwiftUI автоматически вкладывает объект в иерархию view, так что любой экран, который находится внутри `AppRouterHost`, может использовать его через @`EnvironmentObject`.
104+
105+
В AppRouterHost:
106+
107+
```swift
108+
@ObservedObject private var router = AppRouter<T>()
109+
110+
var body: some View {
111+
NavigationStack(path: $navigationPath) {
112+
routeView(router, rootRoute)
113+
.navigationDestination(
114+
or: T.self,
115+
destination: { routeView(router, $0) }
116+
)
117+
}.environmentObject(router) // роутер передается всем дочерним экранам
118+
}
119+
120+
```
121+
122+
В любом дочернем экране:
123+
```swift
124+
struct AuthScreen: View {
125+
@EnvironmentObject var router: AppRouter<AppRoute>
126+
127+
var body: some View {
128+
Button("Войти") {
129+
router.replace(.main)
130+
}
131+
}
132+
}
133+
```
134+
135+
## AppRouterHost
136+
137+
`AppRouterHost` связывает `AppRouter` с `NavigationStack`. Он слушает команды роутера и обновляет, какие экраны должны быть показаны.
138+
139+
Основные свойства:
140+
- `rootRoute` — корневой экран (с которого начинается навигация).
141+
- `navigationPath` — стек экранов, которые уже открыты.
142+
- `routeView` - билдер конкретного экрана. В аргумент приходит роут. Когда используем enum для роута - делаем просто switch по вариантам этого enum и возвращаем нужные экраны.
143+
144+
Пример:
145+
146+
```swift
147+
AppRouterHost<AppRoute>(initialRoute: .signIn) { router, route in
148+
switch route {
149+
case .signIn:
150+
AuthScreen()
151+
case .main:
152+
MainScreen()
153+
case let .detail(id):
154+
DetailScreen(id: id)
155+
}
156+
}
157+
```
158+
- При старте открывается signIn.
159+
- Если вызвать router.push(.main) → перейдем на экран main.
160+
- Если вызвать router.pop() → вернемся обратно на signIn.
161+
162+
В итоге у нас получается такая последовательность:
163+
164+
Экран → AppRouter (отправил команду) → AppRouterHost (выполнил) → NavigationStack (обновился)
165+
166+
167+
## RootScreenView
168+
169+
Как видно из названия, это корневой экран приложения, именно он решает какой экран показать в данный момент. Если в приложении несколько роутов, то именно здесь будет происходить переключение между ними
170+
171+
```swift
172+
struct RootScreenView: View {
173+
@State private var root: RootScreen = .splash
174+
175+
var body: some View {
176+
LogoutNavigationHookView(onLogout: {
177+
root = .mainFlow(route: .signIn)
178+
}) {
179+
switch root {
180+
case .splash:
181+
SplashScreen(root: $root)
182+
case let .mainFlow(route):
183+
MainNavigationView(initialRoute: route)
184+
}
185+
}
186+
}
187+
}
188+
```
189+
190+
При запуске приложения `root = .splash`, соответсвенно показываться будет SplashScreen.
191+
После этого сплешскрин определяет, авторизован юзер или нет.
192+
Если не авторизван то меняет значение `root` на `.mainFlow(.signIn)`.
193+
Если авторизован, то меняет значение `root` на `.mainFlow(.main)`.
194+
В этот момент RootScreenView переключает показ на `MainNavigationView`.
195+
Если пользователь нажимает "Выйти", то срабатывает `LogoutNavigationHookView`, и всё сбрасывается на SignIn.
196+
197+
## LogoutNavigationHookView
198+
199+
В приложениях часто нужно сбросить навигацию при выходе пользователя. Для этого используется LogoutNavigationHookView.
200+
Он оборачивает контент и отслеживает события логаута через logoutHandler. Когда происходит событие логаута, вызывается onLogout(), и можно, например, сбросить стек навигации на экран авторизации.
201+
202+
```swift
203+
import Combine
204+
import MultiPlatformLibrary
205+
import SwiftUI
206+
207+
struct LogoutNavigationHookView<Content: View>: View {
208+
209+
// LogoutNavigationHookView слушает события логаута через logoutHandler.
210+
private var logoutHandler: LogoutHandler = Koin.instance.getLogoutHandler()
211+
212+
let onLogout: () -> Void
213+
let content: () -> Content
214+
215+
init(onLogout: @escaping () -> Void, @ViewBuilder content: @escaping () -> Content) {
216+
self.onLogout = onLogout
217+
self.content = content
218+
}
219+
220+
var body: some View {
221+
content()
222+
.onReceive(
223+
logoutHandler.logoutEvents.toPublisher()
224+
.catch { _ in Empty<KotlinUnit, Never>() }
225+
.assertNoFailure()
226+
) { _ in
227+
228+
// При логауте вызывается onLogout(), которое сбрасывает root.
229+
onLogout()
230+
}
231+
}
232+
}
233+
234+
```
235+
236+
В этом примере вызов `onLogout` приводит к сбросу значения root на .mainFlow(.signIn).
237+
`RootScreenView` реагирует на изменение root и показывает экран авторизации.
238+
239+
```swift
240+
struct RootScreenView: View {
241+
@State private var root: RootScreen = .splash
242+
243+
var body: some View {
244+
LogoutNavigationHookView(onLogout: {
245+
246+
// Сбрасываем стек навигации на экран авторизации
247+
root = .mainFlow(route: .signIn)
248+
}) {
249+
switch root {
250+
case .splash:
251+
SplashScreen(root: $root)
252+
case let .mainFlow(route):
253+
MainNavigationView(initialRoute: route)
254+
}
255+
}
256+
}
257+
}
258+
```
259+
Если мы получаем событие logout на любом экране, весь стек навигации автоматически сбрасывается.
260+
261+
- [Навигация для UIKit через координаторы (Архив)](./navigation-uikit.md)
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Навигация
1+
# Навигация UIKit
22

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

learning/kotlin-native/swift-interop.md

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,33 @@ sidebar_position: 3
44

55
# Kotlin/Swift interop
66

7-
Полезные ссылки:
7+
## Top-level функции в Kotlin Multiplatform и их вызов из Swift
8+
9+
В Kotlin мы можем объявлять глобальные функции (top-level functions), то есть функции вне классов и объектов:
10+
```kotlin
11+
fun log(message: String) {
12+
println(message)
13+
}
14+
```
15+
16+
На Android такие функции вызываются напрямую. Но при интеграции с iOS возникает ощущение, что это функция не видна в Swift коде.
17+
Дело в том, что Swift получает доступ к Kotlin-функциям через Objective-C. В Objective-C глобальных функций нет, поэтому компилятор Kotlin при экспорте в iOS “упаковывает” top-level функции в специальные классы.
18+
Имя этого класса формируется из названия файла, где функция была определена. Если, например, функция log находится в Logger.kt, то в Swift её нужно вызвать так:
19+
```swift
20+
LoggerKt.log("Hello from iOS")
21+
```
22+
То есть доступ к функции осуществляется не напрямую, а через сгенерированный класс LoggerKt.
23+
24+
Однако при использовании [SKIE](https://skie.touchlab.co/intro) всё работает так, как ожидается - глобальные функции становятся настоящими глобальными функциями в Swift.
25+
26+
И тогда на swift мы сможем писать так:
27+
```swift
28+
log("Hello from iOS")
29+
```
30+
31+
Подробнее: https://skie.touchlab.co/features/global-functions
32+
33+
## Полезные ссылки:
834

935
- [Interoperability with Swift/Objective-C](https://kotlinlang.org/docs/native-objc-interop.html)
1036
- [Russell Wolf - The Kotlin/Swift boundary](https://vimeo.com/625847664)

onboarding/ios-specific.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,11 @@ sidebar_position: 7
66

77
Важные особенности для iOS разработчиков:
88

9-
- [Проблемы с Release сборками под iOS](../learning/problem-solving/kotlin-native-release-build-failed)
10-
- [Влияние Kotlin/Native на размер бинарника](../learning/kotlin-native/size_impact)
11-
- [Как читать Stacktrace Kotlin/Native на iOS](../learning/kotlin-native/stacktraces)
12-
- [Разница Extension'ов в Kotlin и Swift](../learning/kotlin-native/swift-extensions)
139
- [Как Kotlin будет виден со стороны Swift](../learning/kotlin-native/swift-interop)
1410
- [Как Kotlin попадает в Xcode через Cocoapods](../learning/ios/pods)
1511
- [Как мы работаем на проектах с конфигурациями](../learning/ios/configuration)
16-
- [Как мы делаем навигацию](../learning/ios/navigation)
12+
- [Как мы делаем навигацию](../learning/ios/navigation-swiftui)
13+
- [Разница Extension'ов в Kotlin и Swift](../learning/kotlin-native/swift-extensions)
14+
- [Влияние Kotlin/Native на размер бинарника](../learning/kotlin-native/size_impact)
15+
- [Как читать Stacktrace Kotlin/Native на iOS](../learning/kotlin-native/stacktraces)
16+

onboarding/project-inside.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -915,7 +915,7 @@ class AppComponent {
915915
916916
Мы поняли что является отправной точкой нашего приложения, а теперь нам нужно понять как построена навигация в iOS приложение и какие подходы при работе с ней мы используем.
917917
918-
Для этого можете ознакомиться со [статьей в разделе обучения](../learning/ios/navigation).
918+
Для этого можете ознакомиться со [статьей в разделе обучения](../learning/ios/navigation-swiftui).
919919
920920
## master.sh
921921

university/4-icerock-basics/navigation.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ sidebar_position: 8
1111

1212
## iOS
1313

14-
Для понимания того, как будет реализована навигация в `iOS` приложениях на проектах, ознакомьтесь сначала с [видео-разбором](https://www.youtube.com/watch?v=Pt9TGFzLVzc) использования `ApplicationCoordinator` для навигации между экранами, а затем со [статьей](../../learning/ios/navigation) и материалами из нее.
14+
Для понимания того, как будет реализована навигация в `iOS` приложениях на проектах, ознакомьтесь сначала с [видео-разбором](https://www.youtube.com/watch?v=Pt9TGFzLVzc) использования `ApplicationCoordinator` для навигации между экранами, а затем со [статьей](../../learning/ios/navigation-swiftui) и материалами из нее.
1515

1616
В наших проектах, для верстки и навигации на iOS мы больше не будем использовать `.storyboard`, вместо этого мы будем пользоваться следующими инструментами:
1717
- `AppCoordinator` - главный координатор приложения, который будет запускать другие координаторы в зависимости от входных данных

0 commit comments

Comments
 (0)