Getting Started with SwiftUI and MVVM Architecture
Getting Started with SwiftUI and MVVM Architecture
SwiftUI has revolutionized iOS development with its declarative syntax and powerful features. When combined with the MVVM (Model-View-ViewModel) architecture pattern, you can build highly maintainable and testable applications.
Why MVVM with SwiftUI?
MVVM is particularly well-suited for SwiftUI because of its reactive nature. The ViewModel acts as a bridge between your Model (data) and View (UI), handling business logic and state management.
Key Benefits:
- Separation of Concerns: Clear separation between UI and business logic
- Testability: ViewModels can be tested independently
- Reusability: ViewModels can be shared across multiple views
- Maintainability: Easier to maintain and scale your codebase
- SwiftUI Integration: Works seamlessly with @Published and ObservableObject
Understanding the MVVM Components
Model
The Model represents your data structures and business entities. It should be pure data with no UI dependencies.
swift
// Model Pure data structures
struct User: Identifiable, Codable {
let id: UUID
let name: String
let email: String
let avatarURL: URL?
let joinDate: Date var displayName: String {
name.isEmpty ? "Anonymous" : name
}
}
struct Post: Identifiable, Codable {
let id: UUID
let userId: UUID
let title: String
let content: String
let createdAt: Date
let likes: Int
}
ViewModel
The ViewModel contains presentation logic and acts as the intermediary between Model and View.swift
// ViewModel - Presentation logic and state management
@MainActor
class UserViewModel: ObservableObject {
// Published properties that Views can observe
@Published var users: [User] = []
@Published var isLoading = false
@Published var errorMessage: String?
@Published var searchText = "" // Dependencies injected for testability
private let userService: UserServiceProtocol
private let analytics: AnalyticsService
init(userService: UserServiceProtocol = UserService(),
analytics: AnalyticsService = AnalyticsService.shared) {
self.userService = userService
self.analytics = analytics
}
// Computed property for filtered results
var filteredUsers: [User] {
guard !searchText.isEmpty else { return users }
return users.filter { $0.name.localizedCaseInsensitiveContains(searchText) }
}
// Async operation with error handling
func fetchUsers() async {
isLoading = true
errorMessage = nil
do {
users = try await userService.getUsers()
analytics.logEvent("users_fetched", parameters: ["count": users.count])
} catch {
errorMessage = "Failed to load users: \(error.localizedDescription)"
analytics.logError(error)
}
isLoading = false
}
// User actions
func deleteUser(_ user: User) async throws {
try await userService.deleteUser(id: user.id)
users.removeAll { $0.id == user.id }
}
func refreshData() async {
await fetchUsers()
}
}
View
The View is purely declarative and observes the ViewModel.swift
// View Pure UI with no business logic
struct UserListView: View {
@StateObject private var viewModel = UserViewModel()
@State private var selectedUser: User? var body: some View {
NavigationStack {
ZStack {
if viewModel.isLoading {
ProgressView("Loading users...")
} else {
userListContent
}
}
.navigationTitle("Users")
.searchable(text: $viewModel.searchText)
.refreshable {
await viewModel.refreshData()
}
.task {
await viewModel.fetchUsers()
}
.alert("Error", isPresented: .constant(viewModel.errorMessage != nil)) {
Button("OK") {
viewModel.errorMessage = nil
}
} message: {
if let error = viewModel.errorMessage {
Text(error)
}
}
}
}
private var userListContent: some View {
List {
ForEach(viewModel.filteredUsers) { user in
UserRow(user: user)
.onTapGesture {
selectedUser = user
}
.swipeActions {
Button(role: .destructive) {
Task {
try? await viewModel.deleteUser(user)
}
} label: {
Label("Delete", systemImage: "trash")
}
}
}
}
.sheet(item: $selectedUser) { user in
UserDetailView(user: user)
}
}
}
struct UserRow: View {
let user: User
var body: some View {
HStack {
AsyncImage(url: user.avatarURL) { image in
image.resizable()
} placeholder: {
Color.gray
}
.frame(width: 50, height: 50)
.clipShape(Circle())
VStack(alignment: .leading) {
Text(user.displayName)
.font(.headline)
Text(user.email)
.font(.subheadline)
.foregroundColor(.secondary)
}
}
}
}
Advanced MVVM Patterns
1. Dependency Injection for Testing
swift
// Protocol for dependency injection
protocol UserServiceProtocol {
func getUsers() async throws -> [User]
func deleteUser(id: UUID) async throws
}// Production implementation
class UserService: UserServiceProtocol {
func getUsers() async throws -> [User] {
let url = URL(string: "https://api.example.com/users")!
let (data, _) = try await URLSession.shared.data(from: url)
return try JSONDecoder().decode([User].self, from: data)
}
func deleteUser(id: UUID) async throws {
let url = URL(string: "https://api.example.com/users/\(id)")!
var request = URLRequest(url: url)
request.httpMethod = "DELETE"
_ = try await URLSession.shared.data(for: request)
}
}
// Mock for testing
class MockUserService: UserServiceProtocol {
var mockUsers: [User] = []
var shouldThrowError = false
func getUsers() async throws -> [User] {
if shouldThrowError {
throw NSError(domain: "test", code: -1)
}
return mockUsers
}
func deleteUser(id: UUID) async throws {
mockUsers.removeAll { $0.id == id }
}
}
2. State Management with Combine
swift
import Combine@MainActor
class SearchViewModel: ObservableObject {
@Published var searchQuery = ""
@Published var searchResults: [User] = []
@Published var isSearching = false
private var cancellables = Set()
private let searchService: SearchService
init(searchService: SearchService = SearchService()) {
self.searchService = searchService
setupSearchPipeline()
}
private func setupSearchPipeline() {
$searchQuery
.debounce(for: .milliseconds(500), scheduler: DispatchQueue.main)
.removeDuplicates()
.filter { !$0.isEmpty }
.sink { [weak self] query in
Task {
await self?.performSearch(query: query)
}
}
.store(in: &cancellables)
}
private func performSearch(query: String) async {
isSearching = true
defer { isSearching = false }
do {
searchResults = try await searchService.search(query: query)
} catch {
searchResults = []
}
}
}
3. Handling Complex State
swift
@MainActor
class PostViewModel: ObservableObject {
enum LoadingState {
case idle
case loading
case loaded([Post])
case error(Error)
} @Published var state: LoadingState = .idle
func loadPosts() async {
state = .loading
do {
let posts = try await PostService.shared.fetchPosts()
state = .loaded(posts)
} catch {
state = .error(error)
}
}
}
struct PostListView: View {
@StateObject private var viewModel = PostViewModel()
var body: some View {
Group {
switch viewModel.state {
case .idle:
Color.clear
.task { await viewModel.loadPosts() }
case .loading:
ProgressView()
case .loaded(let posts):
List(posts) { post in
PostRow(post: post)
}
case .error(let error):
ErrorView(error: error) {
Task { await viewModel.loadPosts() }
}
}
}
}
}
Best Practices
1. Keep ViewModels Focused
Each ViewModel should have a single responsibility. Don't create massive "God" ViewModels.2. Use @MainActor
Mark ViewModels with @MainActor to ensure UI updates happen on the main thread.3. Avoid View Logic in ViewModels
Don't import SwiftUI in ViewModels. Keep them UI-agnostic for better testability.4. Proper Error Handling
Always handle errors gracefully and provide meaningful messages to users.5. Memory Management
Use [weak self] in closures to avoid retain cycles.swift
timer.sink { [weak self] _ in
self?.updateData()
}
Testing ViewModels
swift
@MainActor
final class UserViewModelTests: XCTestCase {
var sut: UserViewModel!
var mockService: MockUserService! override func setUp() {
super.setUp()
mockService = MockUserService()
sut = UserViewModel(userService: mockService)
}
func testFetchUsersSuccess() async {
// Given
let expectedUsers = [
User(id: UUID(), name: "John", email: "john@example.com", avatarURL: nil, joinDate: Date())
]
mockService.mockUsers = expectedUsers
// When
await sut.fetchUsers()
// Then
XCTAssertEqual(sut.users.count, 1)
XCTAssertEqual(sut.users.first?.name, "John")
XCTAssertFalse(sut.isLoading)
XCTAssertNil(sut.errorMessage)
}
func testFetchUsersError() async {
// Given
mockService.shouldThrowError = true
// When
await sut.fetchUsers()
// Then
XCTAssertTrue(sut.users.isEmpty)
XCTAssertNotNil(sut.errorMessage)
XCTAssertFalse(sut.isLoading)
}
}
Common Pitfalls to Avoid
1. Putting UI Code in ViewModels: Keep ViewModels UI-agnostic 2. Not Using Dependency Injection: Makes testing difficult 3. Forgetting @MainActor: Can cause UI update crashes 4. Overusing @Published: Only publish what Views actually observe 5. Creating Retain Cycles: Always use [weak self] in closures
Conclusion
MVVM with SwiftUI provides a robust architecture for building maintainable iOS applications. By following these patterns and best practices, you can create apps that are easy to test, maintain, and scale.
Remember: Start simple, add complexity only when needed, and always prioritize code readability and testability.
Happy coding! 🚀