Initial commit
This commit is contained in:
473
skills/expertise/iphone-apps/references/navigation-patterns.md
Normal file
473
skills/expertise/iphone-apps/references/navigation-patterns.md
Normal file
@@ -0,0 +1,473 @@
|
||||
# Navigation Patterns
|
||||
|
||||
NavigationStack, deep linking, and programmatic navigation for iOS apps.
|
||||
|
||||
## NavigationStack Basics
|
||||
|
||||
### Value-Based Navigation
|
||||
|
||||
```swift
|
||||
struct ContentView: View {
|
||||
@State private var path = NavigationPath()
|
||||
|
||||
var body: some View {
|
||||
NavigationStack(path: $path) {
|
||||
List(items) { item in
|
||||
NavigationLink(value: item) {
|
||||
ItemRow(item: item)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Items")
|
||||
.navigationDestination(for: Item.self) { item in
|
||||
ItemDetail(item: item, path: $path)
|
||||
}
|
||||
.navigationDestination(for: Category.self) { category in
|
||||
CategoryView(category: category)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Programmatic Navigation
|
||||
|
||||
```swift
|
||||
struct ContentView: View {
|
||||
@State private var path = NavigationPath()
|
||||
|
||||
var body: some View {
|
||||
NavigationStack(path: $path) {
|
||||
VStack {
|
||||
Button("Go to Settings") {
|
||||
path.append(Route.settings)
|
||||
}
|
||||
|
||||
Button("Go to Item") {
|
||||
path.append(items[0])
|
||||
}
|
||||
|
||||
Button("Deep Link") {
|
||||
// Push multiple screens
|
||||
path.append(Route.settings)
|
||||
path.append(SettingsSection.account)
|
||||
}
|
||||
}
|
||||
.navigationDestination(for: Route.self) { route in
|
||||
switch route {
|
||||
case .settings:
|
||||
SettingsView(path: $path)
|
||||
case .profile:
|
||||
ProfileView()
|
||||
}
|
||||
}
|
||||
.navigationDestination(for: Item.self) { item in
|
||||
ItemDetail(item: item)
|
||||
}
|
||||
.navigationDestination(for: SettingsSection.self) { section in
|
||||
SettingsSectionView(section: section)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func popToRoot() {
|
||||
path.removeLast(path.count)
|
||||
}
|
||||
|
||||
func popOne() {
|
||||
if !path.isEmpty {
|
||||
path.removeLast()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum Route: Hashable {
|
||||
case settings
|
||||
case profile
|
||||
}
|
||||
|
||||
enum SettingsSection: Hashable {
|
||||
case account
|
||||
case notifications
|
||||
case privacy
|
||||
}
|
||||
```
|
||||
|
||||
## Tab-Based Navigation
|
||||
|
||||
### TabView with NavigationStack per Tab
|
||||
|
||||
```swift
|
||||
struct MainTabView: View {
|
||||
@State private var selectedTab = Tab.home
|
||||
@State private var homePath = NavigationPath()
|
||||
@State private var searchPath = NavigationPath()
|
||||
@State private var profilePath = NavigationPath()
|
||||
|
||||
var body: some View {
|
||||
TabView(selection: $selectedTab) {
|
||||
NavigationStack(path: $homePath) {
|
||||
HomeView()
|
||||
}
|
||||
.tabItem {
|
||||
Label("Home", systemImage: "house")
|
||||
}
|
||||
.tag(Tab.home)
|
||||
|
||||
NavigationStack(path: $searchPath) {
|
||||
SearchView()
|
||||
}
|
||||
.tabItem {
|
||||
Label("Search", systemImage: "magnifyingglass")
|
||||
}
|
||||
.tag(Tab.search)
|
||||
|
||||
NavigationStack(path: $profilePath) {
|
||||
ProfileView()
|
||||
}
|
||||
.tabItem {
|
||||
Label("Profile", systemImage: "person")
|
||||
}
|
||||
.tag(Tab.profile)
|
||||
}
|
||||
.onChange(of: selectedTab) { oldTab, newTab in
|
||||
// Pop to root when re-tapping current tab
|
||||
if oldTab == newTab {
|
||||
switch newTab {
|
||||
case .home: homePath.removeLast(homePath.count)
|
||||
case .search: searchPath.removeLast(searchPath.count)
|
||||
case .profile: profilePath.removeLast(profilePath.count)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum Tab {
|
||||
case home, search, profile
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Deep Linking
|
||||
|
||||
### URL Scheme Handling
|
||||
|
||||
Configure in Info.plist:
|
||||
```xml
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>myapp</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
```
|
||||
|
||||
Handle in App:
|
||||
```swift
|
||||
@main
|
||||
struct MyApp: App {
|
||||
@State private var appState = AppState()
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
.environment(appState)
|
||||
.onOpenURL { url in
|
||||
handleDeepLink(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleDeepLink(_ url: URL) {
|
||||
// myapp://item/123
|
||||
// myapp://settings/account
|
||||
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: true) else { return }
|
||||
|
||||
let pathComponents = components.path.split(separator: "/").map(String.init)
|
||||
|
||||
switch pathComponents.first {
|
||||
case "item":
|
||||
if let id = pathComponents.dropFirst().first {
|
||||
appState.navigateToItem(id: id)
|
||||
}
|
||||
case "settings":
|
||||
let section = pathComponents.dropFirst().first
|
||||
appState.navigateToSettings(section: section)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Observable
|
||||
class AppState {
|
||||
var selectedTab: Tab = .home
|
||||
var homePath = NavigationPath()
|
||||
|
||||
func navigateToItem(id: String) {
|
||||
selectedTab = .home
|
||||
homePath.removeLast(homePath.count)
|
||||
if let item = findItem(id: id) {
|
||||
homePath.append(item)
|
||||
}
|
||||
}
|
||||
|
||||
func navigateToSettings(section: String?) {
|
||||
selectedTab = .profile
|
||||
// Navigate to settings
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Universal Links
|
||||
|
||||
Configure in `apple-app-site-association` on your server:
|
||||
```json
|
||||
{
|
||||
"applinks": {
|
||||
"apps": [],
|
||||
"details": [
|
||||
{
|
||||
"appID": "TEAMID.com.yourcompany.app",
|
||||
"paths": ["/item/*", "/user/*"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Add Associated Domains entitlement:
|
||||
```xml
|
||||
<key>com.apple.developer.associated-domains</key>
|
||||
<array>
|
||||
<string>applinks:example.com</string>
|
||||
</array>
|
||||
```
|
||||
|
||||
Handle same as URL schemes with `onOpenURL`.
|
||||
|
||||
## Modal Presentation
|
||||
|
||||
### Sheet Navigation
|
||||
|
||||
```swift
|
||||
struct ContentView: View {
|
||||
@State private var selectedItem: Item?
|
||||
@State private var showingNewItem = false
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List(items) { item in
|
||||
Button(item.name) {
|
||||
selectedItem = item
|
||||
}
|
||||
}
|
||||
.toolbar {
|
||||
Button {
|
||||
showingNewItem = true
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
}
|
||||
}
|
||||
// Item-based presentation
|
||||
.sheet(item: $selectedItem) { item in
|
||||
NavigationStack {
|
||||
ItemDetail(item: item)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Done") {
|
||||
selectedItem = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Boolean-based presentation
|
||||
.sheet(isPresented: $showingNewItem) {
|
||||
NavigationStack {
|
||||
NewItemView()
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") {
|
||||
showingNewItem = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Full Screen Cover
|
||||
|
||||
```swift
|
||||
.fullScreenCover(isPresented: $showingOnboarding) {
|
||||
OnboardingFlow()
|
||||
}
|
||||
```
|
||||
|
||||
### Detents (Sheet Sizes)
|
||||
|
||||
```swift
|
||||
.sheet(isPresented: $showingOptions) {
|
||||
OptionsView()
|
||||
.presentationDetents([.medium, .large])
|
||||
.presentationDragIndicator(.visible)
|
||||
}
|
||||
```
|
||||
|
||||
## Navigation State Persistence
|
||||
|
||||
### Codable Navigation Path
|
||||
|
||||
```swift
|
||||
struct ContentView: View {
|
||||
@State private var path: [Route] = []
|
||||
|
||||
var body: some View {
|
||||
NavigationStack(path: $path) {
|
||||
// Content
|
||||
}
|
||||
.onAppear {
|
||||
loadNavigationState()
|
||||
}
|
||||
.onChange(of: path) { _, newPath in
|
||||
saveNavigationState(newPath)
|
||||
}
|
||||
}
|
||||
|
||||
private func saveNavigationState(_ path: [Route]) {
|
||||
if let data = try? JSONEncoder().encode(path) {
|
||||
UserDefaults.standard.set(data, forKey: "navigationPath")
|
||||
}
|
||||
}
|
||||
|
||||
private func loadNavigationState() {
|
||||
guard let data = UserDefaults.standard.data(forKey: "navigationPath"),
|
||||
let savedPath = try? JSONDecoder().decode([Route].self, from: data) else {
|
||||
return
|
||||
}
|
||||
path = savedPath
|
||||
}
|
||||
}
|
||||
|
||||
enum Route: Codable, Hashable {
|
||||
case item(id: UUID)
|
||||
case settings
|
||||
case profile
|
||||
}
|
||||
```
|
||||
|
||||
## Navigation Coordinator
|
||||
|
||||
For complex apps, centralize navigation logic:
|
||||
|
||||
```swift
|
||||
@Observable
|
||||
class NavigationCoordinator {
|
||||
var homePath = NavigationPath()
|
||||
var searchPath = NavigationPath()
|
||||
var selectedTab: Tab = .home
|
||||
|
||||
enum Tab {
|
||||
case home, search, profile
|
||||
}
|
||||
|
||||
func showItem(_ item: Item) {
|
||||
selectedTab = .home
|
||||
homePath.append(item)
|
||||
}
|
||||
|
||||
func showSearch(query: String) {
|
||||
selectedTab = .search
|
||||
searchPath.append(SearchQuery(text: query))
|
||||
}
|
||||
|
||||
func popToRoot() {
|
||||
switch selectedTab {
|
||||
case .home:
|
||||
homePath.removeLast(homePath.count)
|
||||
case .search:
|
||||
searchPath.removeLast(searchPath.count)
|
||||
case .profile:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
func handleDeepLink(_ url: URL) {
|
||||
// Parse and navigate
|
||||
}
|
||||
}
|
||||
|
||||
// Inject via environment
|
||||
@main
|
||||
struct MyApp: App {
|
||||
@State private var coordinator = NavigationCoordinator()
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
.environment(coordinator)
|
||||
.onOpenURL { url in
|
||||
coordinator.handleDeepLink(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Search Integration
|
||||
|
||||
### Searchable Modifier
|
||||
|
||||
```swift
|
||||
struct ItemList: View {
|
||||
@State private var searchText = ""
|
||||
@State private var searchScope = SearchScope.all
|
||||
|
||||
var filteredItems: [Item] {
|
||||
items.filter { item in
|
||||
searchText.isEmpty || item.name.localizedCaseInsensitiveContains(searchText)
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List(filteredItems) { item in
|
||||
NavigationLink(value: item) {
|
||||
ItemRow(item: item)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Items")
|
||||
.searchable(text: $searchText, prompt: "Search items")
|
||||
.searchScopes($searchScope) {
|
||||
Text("All").tag(SearchScope.all)
|
||||
Text("Recent").tag(SearchScope.recent)
|
||||
Text("Favorites").tag(SearchScope.favorites)
|
||||
}
|
||||
.navigationDestination(for: Item.self) { item in
|
||||
ItemDetail(item: item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum SearchScope {
|
||||
case all, recent, favorites
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Search Suggestions
|
||||
|
||||
```swift
|
||||
.searchable(text: $searchText) {
|
||||
ForEach(suggestions) { suggestion in
|
||||
Text(suggestion.text)
|
||||
.searchCompletion(suggestion.text)
|
||||
}
|
||||
}
|
||||
```
|
||||
Reference in New Issue
Block a user