Skip to content

DOMAIN:IOS_DEVELOPMENT:SWIFTUI_VS_UIKIT

OWNER: martijn
ALSO_USED_BY: valentin
UPDATED: 2026-03-24
SOURCE: Apple Developer Documentation, WWDC 2023-2025 sessions


FRAMEWORK:DECISION_MATRIX

Default: SwiftUI-First (2026 State)

RULE: new GE projects use SwiftUI as primary framework
RULE: UIKit used only for specific capabilities SwiftUI lacks
RULE: minimum deployment target iOS 17+ for SwiftUI-first projects
RULE: IF client requires iOS 16 support THEN evaluate SwiftUI limitations at that version

When SwiftUI Is Sufficient (Use It)

Feature SwiftUI Status (2026) Notes
Standard lists/grids Mature List, LazyVGrid, LazyHGrid
Navigation Mature NavigationStack, NavigationSplitView (iOS 16+)
Tab bar Mature TabView with customization (iOS 18+)
Forms/settings Mature Form, Toggle, Picker, Stepper
Sheets/modals Mature .sheet, .fullScreenCover, presentationDetents
Alerts/dialogs Mature .alert, .confirmationDialog
Images Mature AsyncImage, resizable, aspect ratio
Maps Mature MapKit for SwiftUI (iOS 17+)
Charts Mature Swift Charts (iOS 16+)
Animations Mature withAnimation, .animation, .transition, PhaseAnimator, KeyframeAnimator
Gestures Mature DragGesture, TapGesture, MagnifyGesture
Accessibility Mature .accessibilityLabel, .accessibilityHint, .accessibilityAction
Dark mode Mature Automatic with semantic colors
Widgets Required WidgetKit is SwiftUI-only
App Intents Required Shortcuts/Siri integration is SwiftUI-native
Live Activities Required SwiftUI-only

When UIKit Is Needed (Use UIViewRepresentable)

Feature Why UIKit Pattern
Complex text editing SwiftUI TextField/TextEditor limited for rich text UITextView via UIViewRepresentable
Camera/photo capture No native SwiftUI camera UIImagePickerController or AVCaptureSession
Advanced collection layouts Compositional layouts, complex diffable data source UICollectionView via UIViewControllerRepresentable
WKWebView No SwiftUI equivalent UIViewRepresentable
PDF rendering PDFKit UIViewRepresentable
MapKit advanced (pre-iOS 17) Full annotation customization MKMapView via UIViewRepresentable
Complex gesture recognizers Multi-touch, force touch edge cases UIKit gesture recognizers
Video playback (custom controls) AVPlayerViewController UIViewControllerRepresentable
Document picker UIDocumentPickerViewController UIViewControllerRepresentable

FRAMEWORK:SWIFTUI_ARCHITECTURE

// iOS 17+ with @Observable macro
@Observable
class ItemListViewModel {
    var items: [Item] = []
    var isLoading = false
    var errorMessage: String?

    private let apiService: APIServiceProtocol

    init(apiService: APIServiceProtocol = APIService()) {
        self.apiService = apiService
    }

    func loadItems() async {
        isLoading = true
        defer { isLoading = false }
        do {
            items = try await apiService.fetchItems()
        } catch {
            errorMessage = error.localizedDescription
        }
    }
}

struct ItemListView: View {
    @State private var viewModel = ItemListViewModel()

    var body: some View {
        List(viewModel.items) { item in
            ItemRow(item: item)
        }
        .overlay {
            if viewModel.isLoading {
                ProgressView()
            }
        }
        .task {
            await viewModel.loadItems()
        }
    }
}

RULE: use @Observable macro (iOS 17+) — NOT ObservableObject + @Published (legacy)
RULE: use @State for view-owned state, @Environment for injected dependencies
RULE: view models own async logic, views own presentation
RULE: use protocols for dependency injection (testability)

ANTI_PATTERN: putting business logic in View body
FIX: extract to view model. Views should only describe UI.

ANTI_PATTERN: using @ObservedObject for view-owned state
FIX: use @State for owned state, @Environment for shared state


FRAMEWORK:NAVIGATION

struct AppRootView: View {
    @State private var path = NavigationPath()

    var body: some View {
        NavigationStack(path: $path) {
            ItemListView()
                .navigationDestination(for: Item.self) { item in
                    ItemDetailView(item: item)
                }
                .navigationDestination(for: Category.self) { category in
                    CategoryView(category: category)
                }
        }
    }
}

RULE: use NavigationPath for type-erased navigation stack
RULE: use .navigationDestination(for:) to declare destination views
RULE: do NOT use NavigationLink(destination:) (deprecated pattern)
RULE: deep linking: push values onto NavigationPath programmatically

struct AppRootView: View {
    @State private var selectedCategory: Category?
    @State private var selectedItem: Item?

    var body: some View {
        NavigationSplitView {
            CategorySidebar(selection: $selectedCategory)
        } content: {
            if let category = selectedCategory {
                ItemList(category: category, selection: $selectedItem)
            }
        } detail: {
            if let item = selectedItem {
                ItemDetailView(item: item)
            } else {
                ContentUnavailableView("Select an item", systemImage: "doc")
            }
        }
    }
}

RULE: collapses to single column on iPhone automatically
RULE: use columnVisibility to control column presentation
RULE: iPad shows 2 or 3 columns based on available width

Tab-Based Navigation (iOS 18+ Enhanced)

struct ContentView: View {
    var body: some View {
        TabView {
            Tab("Home", systemImage: "house") {
                NavigationStack { HomeView() }
            }
            Tab("Search", systemImage: "magnifyingglass") {
                NavigationStack { SearchView() }
            }
            Tab("Profile", systemImage: "person") {
                NavigationStack { ProfileView() }
            }
        }
    }
}

RULE: each tab wraps its own NavigationStack
RULE: in iOS 18+, use Tab initializer (replaces .tabItem)
RULE: tab selection state persists when switching tabs


FRAMEWORK:INTEROP_PATTERNS

UIViewRepresentable (UIKit View in SwiftUI)

struct WebView: UIViewRepresentable {
    let url: URL

    func makeUIView(context: Context) -> WKWebView {
        WKWebView()
    }

    func updateUIView(_ webView: WKWebView, context: Context) {
        webView.load(URLRequest(url: url))
    }
}

RULE: implement makeUIView (create once) and updateUIView (update on state change)
RULE: use Coordinator for delegates and data sources
RULE: call context.coordinator to access coordinator in update method

UIHostingController (SwiftUI View in UIKit)

let swiftUIView = MySwiftUIView(viewModel: viewModel)
let hostingController = UIHostingController(rootView: swiftUIView)
navigationController?.pushViewController(hostingController, animated: true)

RULE: use when embedding SwiftUI in existing UIKit app
RULE: hosting controller size is determined by SwiftUI view's ideal size
RULE: pass data via view's initializer or environment

Migration Strategy (UIKit → SwiftUI)

Phase 1: New screens in SwiftUI (UIHostingController wrapped)
Phase 2: Replace simple UIKit screens with SwiftUI
Phase 3: SwiftUI app lifecycle (replace UIApplicationDelegate with @main App)
Phase 4: Replace remaining complex UIKit with SwiftUI + UIViewRepresentable

RULE: do NOT rewrite entire app at once — incremental migration
RULE: shared view models work across both frameworks


FRAMEWORK:SWIFTUI_KNOWN_LIMITATIONS

NOTE: as of Swift 5.10+ / iOS 18 / Xcode 16 (2025-2026 state)

Limitation Workaround Expected Fix
Rich text editing UITextView via UIViewRepresentable No ETA
Precise scroll position control ScrollViewReader + proxy, UIScrollView for complex cases Improving yearly
Complex collection layouts UICollectionView compositional layout via representable SwiftUI improving
Custom keyboard management UIKit keyboard handling + NotificationCenter Partial in newer iOS
PDF viewing/annotation PDFKit via UIViewRepresentable No ETA
Advanced camera/AR AVFoundation / ARKit via UIViewRepresentable Partial (RealityKit)
Toolbar customization (pre-iOS 18) Limited compared to UIKit Improved in iOS 18+
Performance with 10,000+ items LazyVStack/LazyHGrid help, but UICollectionView faster Improving

RULE: when hitting a SwiftUI limitation, use UIViewRepresentable — do NOT fight the framework
RULE: check WWDC sessions yearly for resolved limitations
RULE: file Feedback Assistant reports for SwiftUI issues (Apple tracks these)


FRAMEWORK:TESTING

XCTest (Unit Tests)

@testable import MyApp

final class ItemViewModelTests: XCTestCase {
    var sut: ItemListViewModel!
    var mockAPI: MockAPIService!

    override func setUp() {
        mockAPI = MockAPIService()
        sut = ItemListViewModel(apiService: mockAPI)
    }

    func testLoadItems_success() async {
        mockAPI.mockItems = [Item(id: "1", title: "Test")]
        await sut.loadItems()
        XCTAssertEqual(sut.items.count, 1)
        XCTAssertFalse(sut.isLoading)
    }
}

XCUITest (UI Tests)

final class ItemListUITests: XCTestCase {
    let app = XCUIApplication()

    override func setUp() {
        continueAfterFailure = false
        app.launchArguments = ["--uitesting"]
        app.launch()
    }

    func testItemListDisplaysItems() {
        XCTAssertTrue(app.cells["item-row-1"].waitForExistence(timeout: 5))
    }
}

RULE: unit test view models, services, and business logic
RULE: UI test critical user flows (onboarding, purchase, key features)
RULE: use launch arguments to set up test state (mock data, skip auth)
RULE: use accessibility identifiers for UI test element matching

SwiftUI Previews

RULE: use #Preview macro for rapid iteration
RULE: provide multiple preview variants (light/dark, sizes, data states)

#Preview("Normal") { ItemRow(item: .mock) }
#Preview("Long Title") { ItemRow(item: .mockLongTitle) }
#Preview("Dark Mode") { ItemRow(item: .mock).preferredColorScheme(.dark) }