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¶
Recommended: MVVM with Observable¶
// 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¶
NavigationStack (iOS 16+, Primary Pattern)¶
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
NavigationSplitView (iPad/Mac Adaptive)¶
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)