MinhVo

Minh Vo

rss feed

Slaying code & making it lit fr fr 🔥 tagline

Hey there 👋 I'm an AI Engineer with 7 years of experience building scalable web and mobile applications. Currently at Neurond AI (May 2025 — present), architecting an Enterprise AI Assistant Platform with multi-tenant RAG on pgvector, multi-provider LLM orchestration, and Azure-native infrastructure. Previously spent 5+ years at SNAPTEC (Sep 2019 — Apr 2025), leading SaaS themes, admin dashboards, and e-commerce platforms — earned the Hero of the Year award in 2021. I specialize in TypeScript, React, Next.js, and AI-Native engineering with Claude Code and Cursor.bio

Back to blogs

SwiftUI vs Jetpack Compose: Modern Mobile UI Frameworks

Compare SwiftUI and Compose: declarative UI, state management, and cross-platform strategies.

SwiftUIJetpack ComposeMobileUI

By MinhVo

Introduction

The mobile development landscape has undergone a paradigm shift with the introduction of declarative UI frameworks by the two dominant platform holders. Apple's SwiftUI, launched in 2019, and Google's Jetpack Compose, stable since 2021, both reject the imperative, view-controller-based patterns that dominated mobile development for over a decade. Instead, they embrace a model where the UI is a function of state—change the state, and the framework figures out what to redraw.

This comparison matters because choosing between native iOS with SwiftUI and native Android with Jetpack Compose—or deciding to invest in both versus a cross-platform solution—is one of the most expensive decisions a mobile team makes. The wrong choice can mean months of rework, hiring difficulties, or performance problems that alienate users.

We will examine both frameworks through the lens of real production development: how they handle state management, navigation, animations, accessibility, and testing. We will look at actual code side by side, compare performance characteristics, and discuss the practical trade-offs that emerge when building production applications. By the end, you will have a clear understanding of where each framework excels and where it falls short.

Mobile UI frameworks comparison

Understanding the Frameworks: Core Concepts

SwiftUI Fundamentals

SwiftUI is Apple's declarative UI framework that works across all Apple platforms—iOS, macOS, watchOS, tvOS, and visionOS. It uses a Swift DSL (Domain Specific Language) where views are structs conforming to the View protocol, and the body property returns a description of the view hierarchy.

The key innovation in SwiftUI is its use of a diffing engine that compares the old and new view trees and applies only the necessary changes. This is conceptually similar to React's virtual DOM but implemented at the framework level with deep knowledge of UIKit's rendering pipeline.

SwiftUI integrates tightly with Swift's value types and property wrappers. The @State, @Binding, @ObservedObject, @EnvironmentObject, and @StateObject property wrappers form the backbone of state management, each serving a specific role in the data flow hierarchy.

Jetpack Compose Fundamentals

Jetpack Compose is Google's modern toolkit for building native Android UI. It uses Kotlin's language features—particularly coroutines, extension functions, and sealed classes—to create a concise and powerful UI DSL. Compose functions are annotated with @Composable and describe the UI declaratively.

Compose uses a smart recomposition system powered by the Compose Compiler plugin. The compiler analyzes which parts of the UI depend on which state values and skips recomposition of unaffected composables. This is more granular than SwiftUI's approach and can lead to better performance in complex UIs.

State management in Compose revolves around remember, mutableStateOf, State, MutableState, and the ViewModel integration with LiveData or StateFlow. The pattern is similar to SwiftUI but with more explicit control over when state is remembered across recompositions.

Declarative UI paradigm

Architecture and Design Patterns

View Composition Patterns

Both frameworks encourage breaking UI into small, reusable components, but the syntax and conventions differ significantly.

SwiftUI View Composition:

struct UserCard: View {
    let user: User
    
    var body: some View {
        VStack(alignment: .leading, spacing: 8) {
            AsyncImage(url: user.avatarURL) { image in
                image.resizable().aspectRatio(contentMode: .fill)
            } placeholder: {
                ProgressView()
            }
            .frame(width: 60, height: 60)
            .clipShape(Circle())
            
            Text(user.name)
                .font(.headline)
            Text(user.bio)
                .font(.subheadline)
                .foregroundColor(.secondary)
        }
        .padding()
        .background(Color(.systemBackground))
        .cornerRadius(12)
        .shadow(radius: 4)
    }
}

Jetpack Compose Composition:

@Composable
fun UserCard(user: User) {
    Card(
        modifier = Modifier
            .fillMaxWidth()
            .padding(16.dp),
        elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
    ) {
        Row(
            modifier = Modifier.padding(16.dp),
            verticalAlignment = Alignment.CenterVertically
        ) {
            AsyncImage(
                model = user.avatarUrl,
                contentDescription = "User avatar",
                modifier = Modifier
                    .size(60.dp)
                    .clip(CircleShape)
            )
            Spacer(modifier = Modifier.width(12.dp))
            Column {
                Text(text = user.name, style = MaterialTheme.typography.titleMedium)
                Text(text = user.bio, style = MaterialTheme.typography.bodySmall)
            }
        }
    }
}

State Management Architecture

SwiftUI uses property wrappers for state management:

class UserViewModel: ObservableObject {
    @Published var users: [User] = []
    @Published var isLoading = false
    @Published var error: String?
    
    func fetchUsers() async {
        isLoading = true
        do {
            users = try await apiService.getUsers()
        } catch {
            self.error = error.localizedDescription
        }
        isLoading = false
    }
}
 
struct UserListView: View {
    @StateObject private var viewModel = UserViewModel()
    
    var body: some View {
        List(viewModel.users) { user in
            UserRow(user: user)
        }
        .overlay {
            if viewModel.isLoading {
                ProgressView()
            }
        }
        .task {
            await viewModel.fetchUsers()
        }
    }
}

Jetpack Compose uses StateFlow and collectAsState:

class UserViewModel : ViewModel() {
    private val _uiState = MutableStateFlow(UserUiState())
    val uiState: StateFlow<UserUiState> = _uiState.asStateFlow()
    
    init {
        fetchUsers()
    }
    
    private fun fetchUsers() {
        viewModelScope.launch {
            _uiState.update { it.copy(isLoading = true) }
            try {
                val users = apiService.getUsers()
                _uiState.update { it.copy(users = users, isLoading = false) }
            } catch (e: Exception) {
                _uiState.update { it.copy(error = e.message, isLoading = false) }
            }
        }
    }
}
 
@Composable
fun UserListScreen(viewModel: UserViewModel = hiltViewModel()) {
    val uiState by viewModel.uiState.collectAsState()
    
    Box {
        LazyColumn {
            items(uiState.users) { user ->
                UserRow(user = user)
            }
        }
        if (uiState.isLoading) {
            CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
        }
    }
}

Step-by-Step Implementation

Building a Task Management App in SwiftUI

Start with the data model and view model:

struct Task: Identifiable, Codable {
    let id: UUID
    var title: String
    var isCompleted: Bool
    var dueDate: Date?
    var priority: Priority
    
    enum Priority: String, Codable, CaseIterable {
        case low, medium, high
        
        var color: Color {
            switch self {
            case .low: return .green
            case .medium: return .orange
            case .high: return .red
            }
        }
    }
}
 
class TaskViewModel: ObservableObject {
    @Published var tasks: [Task] = []
    @Published var filter: Filter = .all
    
    enum Filter { case all, active, completed }
    
    var filteredTasks: [Task] {
        switch filter {
        case .all: return tasks
        case .active: return tasks.filter { !$0.isCompleted }
        case .completed: return tasks.filter { $0.isCompleted }
        }
    }
    
    func addTask(title: String, priority: Task.Priority, dueDate: Date?) {
        let task = Task(id: UUID(), title: title, isCompleted: false, 
                       dueDate: dueDate, priority: priority)
        tasks.append(task)
        saveTasks()
    }
    
    func toggleTask(_ task: Task) {
        if let index = tasks.firstIndex(where: { $0.id == task.id }) {
            tasks[index].isCompleted.toggle()
            saveTasks()
        }
    }
    
    private func saveTasks() {
        if let encoded = try? JSONEncoder().encode(tasks) {
            UserDefaults.standard.set(encoded, forKey: "tasks")
        }
    }
}

Build the main list view:

struct TaskListView: View {
    @StateObject private var viewModel = TaskViewModel()
    @State private var showingAddSheet = false
    
    var body: some View {
        NavigationStack {
            Picker("Filter", selection: $viewModel.filter) {
                Text("All").tag(TaskViewModel.Filter.all)
                Text("Active").tag(TaskViewModel.Filter.active)
                Text("Completed").tag(TaskViewModel.Filter.completed)
            }
            .pickerStyle(.segmented)
            .padding()
            
            List {
                ForEach(viewModel.filteredTasks) { task in
                    TaskRow(task: task) {
                        viewModel.toggleTask(task)
                    }
                }
                .onDelete { indexSet in
                    viewModel.tasks.remove(atOffsets: indexSet)
                }
            }
            .navigationTitle("Tasks")
            .toolbar {
                Button(action: { showingAddSheet = true }) {
                    Image(systemName: "plus")
                }
            }
            .sheet(isPresented: $showingAddSheet) {
                AddTaskView(viewModel: viewModel)
            }
        }
    }
}

Building the Same App in Jetpack Compose

data class Task(
    val id: String = UUID.randomUUID().toString(),
    val title: String,
    val isCompleted: Boolean = false,
    val dueDate: Long? = null,
    val priority: Priority = Priority.MEDIUM
) {
    enum class Priority { LOW, MEDIUM, HIGH }
}
 
@HiltViewModel
class TaskViewModel @Inject constructor(
    private val taskRepository: TaskRepository
) : ViewModel() {
    private val _tasks = MutableStateFlow<List<Task>>(emptyList())
    private val _filter = MutableStateFlow(Filter.ALL)
    
    val uiState: StateFlow<TaskUiState> = combine(_tasks, _filter) { tasks, filter ->
        TaskUiState(
            tasks = when (filter) {
                Filter.ALL -> tasks
                Filter.ACTIVE -> tasks.filter { !it.isCompleted }
                Filter.COMPLETED -> tasks.filter { it.isCompleted }
            },
            filter = filter
        )
    }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), TaskUiState())
    
    fun addTask(title: String, priority: Task.Priority) {
        viewModelScope.launch {
            taskRepository.insert(Task(title = title, priority = priority))
        }
    }
    
    fun toggleTask(task: Task) {
        viewModelScope.launch {
            taskRepository.update(task.copy(isCompleted = !task.isCompleted))
        }
    }
    
    enum class Filter { ALL, ACTIVE, COMPLETED }
}
 
@Composable
fun TaskListScreen(viewModel: TaskViewModel = hiltViewModel()) {
    val uiState by viewModel.uiState.collectAsState()
    var showAddDialog by remember { mutableStateOf(false) }
    
    Scaffold(
        topBar = {
            TopAppBar(title = { Text("Tasks") })
        },
        floatingActionButton = {
            FloatingActionButton(onClick = { showAddDialog = true }) {
                Icon(Icons.Default.Add, "Add task")
            }
        }
    ) { padding ->
        Column(modifier = Modifier.padding(padding)) {
            FilterChipRow(
                selectedFilter = uiState.filter,
                onFilterSelected = viewModel::setFilter
            )
            LazyColumn {
                items(uiState.tasks, key = { it.id }) { task ->
                    TaskRow(
                        task = task,
                        onToggle = { viewModel.toggleTask(task) }
                    )
                }
            }
        }
    }
    
    if (showAddDialog) {
        AddTaskDialog(
            onDismiss = { showAddDialog = false },
            onAdd = { title, priority ->
                viewModel.addTask(title, priority)
                showAddDialog = false
            }
        )
    }
}

Mobile app implementation

Real-World Use Cases and Case Studies

Use Case 1: Financial Dashboard App

SwiftUI excels for iOS-only financial apps where tight integration with Apple's ecosystem (Keychain, HealthKit, Apple Pay) is essential. The framework's smooth animations and native feel create a premium experience that financial users expect. However, the limited customization options in early SwiftUI versions sometimes require wrapping UIKit components.

Use Case 2: Social Media Platform

Jetpack Compose's LazyColumn with its efficient recomposition system handles the complex, variable-height feeds common in social media apps well. The ability to use Kotlin coroutines for smooth infinite scrolling and the rich animation API for like/share interactions make Compose a strong choice for content-heavy Android apps.

Use Case 3: Cross-Platform Business App

When targeting both platforms, teams face a dilemma: build natively with SwiftUI and Compose (doubling development effort) or use a cross-platform solution like Kotlin Multiplatform (shared logic, native UI) or Flutter/React Native (shared everything). The decision depends on whether UI fidelity or development speed is prioritized.

Use Case 4: IoT Device Companion App

For hardware companion apps, both frameworks offer Bluetooth and peripheral integration, but the APIs differ significantly. SwiftUI's CoreBluetooth integration is more mature, while Compose's companion app patterns are still evolving. Battery optimization and background processing also differ between iOS and Android, making the platform choice dependent on the hardware ecosystem.

Best Practices for Production

  1. Keep views small and focused: Both SwiftUI and Compose perform better when individual view/composable functions are simple. Complex bodies lead to excessive recomposition in Compose and slow diffing in SwiftUI.

  2. Use @StateObject over @ObservedObject for owned state: In SwiftUI, @StateObject ensures the view model survives view recreation, while @ObservedObject does not. Use @StateObject when the view owns the data source.

  3. Leverage derivedStateOf in Compose: When you have state that depends on other state, use derivedStateOf to avoid unnecessary recompositions. This is equivalent to computing derived values efficiently.

  4. Test with the accessibility inspector: Both frameworks support accessibility out of the box, but you must provide proper labels, hints, and traits. Test with VoiceOver (iOS) and TalkBack (Android) early and often.

  5. Use preview providers for rapid iteration: SwiftUI's #Preview macro and Compose's @Preview annotation enable rapid visual feedback without running on a device. Use them extensively during development.

  6. Profile with Instruments (iOS) and Layout Inspector (Android): Use platform-specific profiling tools to identify recomposition hotspots, unnecessary redraws, and memory leaks.

  7. Implement proper error handling: Use Swift's Result type and Kotlin's sealed class for representing UI states that include loading, success, and error conditions.

  8. Adopt MVVM or MVI patterns consistently: Both frameworks work well with Model-View-ViewModel. Keep business logic in ViewModels and use unidirectional data flow for predictable state management.

Common Pitfalls and Solutions

PitfallImpactSolution
Putting business logic in SwiftUI ViewsHard to test, tight couplingMove to ObservableObject ViewModels
Forgetting remember in ComposeState resets on every recompositionUse remember { mutableStateOf() } for local state
Deeply nested view hierarchiesSlow rendering, hard to debugExtract into smaller components
Not using LazyColumn/List for long listsPoor memory usage, jankAlways use lazy lists for scrollable content
Ignoring platform differencesJarring UX on each platformFollow Material Design on Android, Human Interface Guidelines on iOS
Overusing @Published propertiesExcessive UI updatesOnly publish state that the UI actually observes
Missing key parameters in listsIncorrect animations, state bugsAlways provide stable, unique keys for list items

Performance Optimization

Both frameworks use intelligent recomposition/redrawing, but developers must understand the optimization mechanisms:

// SwiftUI: Use EquatableView for expensive views
struct ExpensiveChart: View, Equatable {
    let data: [DataPoint]
    
    static func == (lhs: Self, rhs: Self) -> Bool {
        lhs.data == rhs.data
    }
    
    var body: some View {
        // Complex chart rendering
        Chart(data) { point in
            LineMark(x: .value("X", point.x), y: .value("Y", point.y))
        }
    }
}
// Compose: Use key and derivedStateOf for optimal recomposition
@Composable
fun ExpensiveChart(data: List<DataPoint>) {
    val chartState = remember(data) {
        calculateChartState(data)
    }
    
    Canvas(modifier = Modifier.fillMaxSize()) {
        drawChart(chartState)
    }
}

Comparison with Alternatives

FeatureSwiftUIJetpack ComposeFlutterReact Native
LanguageSwiftKotlinDartJavaScript/TypeScript
PlatformApple onlyAndroid onlyCross-platformCross-platform
MaturityGrowing rapidlyStable, active devVery matureVery mature
Native FeelPerfectPerfectGood (custom render)Good (native bridge)
Hot ReloadPreview onlyPreview + Live EditFull hot reloadFast refresh
Learning CurveLow (for Swift devs)Low (for Kotlin devs)MediumMedium
AnimationBuilt-in, smoothBuilt-in, powerfulVery powerfulReanimated lib
CommunityLargeLarge, fast-growingVery largeVery large
PerformanceNativeNativeNear-nativeGood

Advanced Patterns and Techniques

Custom Animations in SwiftUI

struct SpringAnimation: View {
    @State private var isExpanded = false
    
    var body: some View {
        VStack {
            RoundedRectangle(cornerRadius: 12)
                .fill(.blue)
                .frame(
                    width: isExpanded ? 300 : 100,
                    height: isExpanded ? 200 : 100
                )
                .animation(.spring(response: 0.5, dampingFraction: 0.6), value: isExpanded)
                .onTapGesture { isExpanded.toggle() }
        }
    }
}

Shared Element Transitions in Compose

@Composable
fun SharedElementExample() {
    var showDetail by remember { mutableStateOf(false) }
    
    AnimatedContent(
        targetState = showDetail,
        transitionSpec = {
            fadeIn() + expandVertically() togetherWith fadeOut() + shrinkVertically()
        }
    ) { isDetail ->
        if (isDetail) {
            DetailScreen(onBack = { showDetail = false })
        } else {
            ListScreen(onSelect = { showDetail = true })
        }
    }
}

Testing Strategies

Both frameworks support UI testing, but the approaches and tooling differ:

// SwiftUI Testing with XCTest
import XCTest
@testable import MyApp
 
final class TaskViewModelTests: XCTestCase {
    func testAddTask() {
        let viewModel = TaskViewModel()
        viewModel.addTask(title: "Test Task", priority: .high, dueDate: nil)
        
        XCTAssertEqual(viewModel.tasks.count, 1)
        XCTAssertEqual(viewModel.tasks.first?.title, "Test Task")
        XCTAssertEqual(viewModel.tasks.first?.priority, .high)
    }
    
    func testFilterCompleted() {
        let viewModel = TaskViewModel()
        viewModel.addTask(title: "Task 1", priority: .low, dueDate: nil)
        viewModel.addTask(title: "Task 2", priority: .low, dueDate: nil)
        viewModel.toggleTask(viewModel.tasks[0])
        
        viewModel.filter = .completed
        XCTAssertEqual(viewModel.filteredTasks.count, 1)
    }
}
// Compose Testing
@RunWith(AndroidJUnit4::class)
class TaskListScreenTest {
    @get:Rule
    val composeTestRule = createComposeRule()
    
    @Test
    fun displayTasks() {
        val tasks = listOf(
            Task(title = "Buy groceries", isCompleted = false),
            Task(title = "Clean house", isCompleted = true)
        )
        
        composeTestRule.setContent {
            TaskListContent(tasks = tasks, onToggle = {})
        }
        
        composeTestRule.onNodeWithText("Buy groceries").assertIsDisplayed()
        composeTestRule.onNodeWithText("Clean house").assertIsDisplayed()
    }
}

Future Outlook

SwiftUI continues to close the gap with UIKit with each WWDC. iOS 17 introduces @Observable, a macro-based observation system that replaces ObservableObject with better performance and simpler syntax. The new NavigationStack and NavigationPath provide type-safe, programmatic navigation. Apple's visionOS platform is SwiftUI-first, ensuring the framework's long-term investment.

Jetpack Compose is becoming the recommended way to build Android UIs. Compose Multiplatform extends it to desktop and web, allowing shared UI code across platforms. The Compose Compiler moving to Kotlin-first (using K2 compiler plugin) improves build times and removes the version coupling that previously required matching Compose Compiler and Kotlin versions.

Both frameworks are converging on similar patterns: improved animation systems, better performance through compiler optimizations, and expanded platform support. The native mobile UI framework debate will increasingly focus on ecosystem maturity and platform-specific integration rather than fundamental capability differences.

Conclusion

SwiftUI and Jetpack Compose represent the future of native mobile UI development on their respective platforms:

  1. Choose SwiftUI when building exclusively for Apple platforms: The framework's tight integration with iOS, macOS, watchOS, and visionOS provides capabilities that cross-platform solutions cannot match, including deep system integrations and Apple's design language.

  2. Choose Jetpack Compose for Android-first development: Compose's smart recomposition system, Kotlin language features, and growing multiplatform story make it the clear choice for modern Android development.

  3. Both frameworks are production-ready: Major apps from Apple, Google, Netflix, Uber, and others use these frameworks in production. The early stability issues are resolved, and the tooling is mature.

  4. Invest in learning both if you build for both platforms: The concepts are similar enough that learning one makes learning the other straightforward. Focus on understanding declarative UI principles, unidirectional data flow, and platform-specific best practices.

  5. Consider cross-platform alternatives if speed to market matters most: If you need to ship on both platforms quickly and the UI can be platform-agnostic, Flutter or Kotlin Multiplatform may be more practical than maintaining two separate native codebases.

The era of imperative UI programming is ending. Whether you choose SwiftUI or Jetpack Compose, you are investing in the right paradigm for the next decade of mobile development.