gh-Dimillian-Skills/swiftui-ui-patterns/references/tabview.md
Thomas Ricouard 70a15d08db Add SwiftUI UI patterns skill and references
Introduces the 'swiftui-ui-patterns' skill to docs/skills.json, providing best practices and example-driven guidance for building SwiftUI views and components. Adds SKILL.md and a comprehensive set of reference files covering TabView, NavigationStack, sheets, forms, controls, grids, overlays, haptics, focus handling, media, matched transitions, split views, and more.
2026-01-04 18:26:56 +01:00

3.4 KiB

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.

@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.

@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.