115 lines
3.4 KiB
Markdown
115 lines
3.4 KiB
Markdown
# TabView
|
|
|
|
## Intent
|
|
|
|
Use this pattern for a scalable, multi-platform tab architecture with:
|
|
- a single source of truth for tab identity and content,
|
|
- platform-specific tab sets and sidebar sections,
|
|
- dynamic tabs sourced from data,
|
|
- an interception hook for special tabs (e.g., compose).
|
|
|
|
## Core architecture
|
|
|
|
- `AppTab` enum defines identity, labels, icons, and content builder.
|
|
- `SidebarSections` enum groups tabs for sidebar sections.
|
|
- `AppView` owns the `TabView` and selection binding, and routes tab changes through `updateTab`.
|
|
|
|
## Example: custom binding with side effects
|
|
|
|
Use this when tab selection needs side effects, like intercepting a special tab to perform an action instead of changing selection.
|
|
|
|
```swift
|
|
@MainActor
|
|
struct AppView: View {
|
|
@Binding var selectedTab: AppTab
|
|
|
|
var body: some View {
|
|
TabView(selection: .init(
|
|
get: { selectedTab },
|
|
set: { updateTab(with: $0) }
|
|
)) {
|
|
ForEach(availableSections) { section in
|
|
TabSection(section.title) {
|
|
ForEach(section.tabs) { tab in
|
|
Tab(value: tab) {
|
|
tab.makeContentView(
|
|
homeTimeline: $timeline,
|
|
selectedTab: $selectedTab,
|
|
pinnedFilters: $pinnedFilters
|
|
)
|
|
} label: {
|
|
tab.label
|
|
}
|
|
.tabPlacement(tab.tabPlacement)
|
|
}
|
|
}
|
|
.tabPlacement(.sidebarOnly)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func updateTab(with newTab: AppTab) {
|
|
if newTab == .post {
|
|
// Intercept special tabs (compose) instead of changing selection.
|
|
presentComposer()
|
|
return
|
|
}
|
|
selectedTab = newTab
|
|
}
|
|
}
|
|
```
|
|
|
|
## Example: direct binding without side effects
|
|
|
|
Use this when selection is purely state-driven.
|
|
|
|
```swift
|
|
@MainActor
|
|
struct AppView: View {
|
|
@Binding var selectedTab: AppTab
|
|
|
|
var body: some View {
|
|
TabView(selection: $selectedTab) {
|
|
ForEach(availableSections) { section in
|
|
TabSection(section.title) {
|
|
ForEach(section.tabs) { tab in
|
|
Tab(value: tab) {
|
|
tab.makeContentView(
|
|
homeTimeline: $timeline,
|
|
selectedTab: $selectedTab,
|
|
pinnedFilters: $pinnedFilters
|
|
)
|
|
} label: {
|
|
tab.label
|
|
}
|
|
.tabPlacement(tab.tabPlacement)
|
|
}
|
|
}
|
|
.tabPlacement(.sidebarOnly)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
## Design choices to keep
|
|
|
|
- Centralize tab identity and content in `AppTab` with `makeContentView(...)`.
|
|
- Use `Tab(value:)` with `selection` binding for state-driven tab selection.
|
|
- Route selection changes through `updateTab` to handle special tabs and scroll-to-top behavior.
|
|
- Use `TabSection` + `.tabPlacement(.sidebarOnly)` for sidebar structure.
|
|
- Use `.tabPlacement(.pinned)` in `AppTab.tabPlacement` for a single pinned tab; this is commonly used for iOS 26 `.searchable` tab content, but can be used for any tab.
|
|
|
|
## Dynamic tabs pattern
|
|
|
|
- `SidebarSections` handles dynamic data tabs.
|
|
- `AppTab.anyTimelineFilter(filter:)` wraps dynamic tabs in a single enum case.
|
|
- The enum provides label/icon/title for dynamic tabs via the filter type.
|
|
|
|
## Pitfalls
|
|
|
|
- Avoid adding ViewModels for tabs; keep state local or in `@Observable` services.
|
|
- Do not nest `@Observable` objects inside other `@Observable` objects.
|
|
- Ensure `AppTab.id` values are stable; dynamic cases should hash on stable IDs.
|
|
- Special tabs (compose) should not change selection.
|