Back to Blog

SwiftUI vs Jetpack Compose: A Side-by-Side Comparison From Someone Who Uses Both Daily

Two Frameworks, One App, Every Day

I build and maintain the same product on both iOS and Android. The iOS app is written in SwiftUI, the Android app in Jetpack Compose. Same features, same backend, same design specs — two completely different UI frameworks.

Most comparisons you'll find online are written by people who tried one framework for a weekend. This one comes from shipping the same screens, the same interactions, and the same bugs on both platforms, week after week. Here's what I've learned.

The Core Philosophy: Surprisingly Similar

Both SwiftUI and Compose are declarative UI frameworks. You describe what the UI should look like for a given state, not how to mutate views when things change. If you've used one, the other will feel familiar within a day.

Here's a simple card component on both platforms:

SwiftUI

struct PostCard: View {
    let post: Post

    var body: some View {
        VStack(alignment: .leading, spacing: 8) {
            Text(post.title)
                .font(.headline)
            Text(post.description)
                .font(.subheadline)
                .foregroundColor(.secondary)
            HStack {
                Image(systemName: "heart.fill")
                Text("\(post.likeCount)")
            }
            .font(.caption)
        }
        .padding()
        .background(Color(.systemBackground))
        .cornerRadius(12)
        .shadow(radius: 2)
    }
}

Jetpack Compose

@Composable
fun PostCard(post: Post) {
    Card(
        shape = RoundedCornerShape(12.dp),
        elevation = CardDefaults.cardElevation(2.dp)
    ) {
        Column(
            modifier = Modifier.padding(16.dp),
            verticalArrangement = Arrangement.spacedBy(8.dp)
        ) {
            Text(
                text = post.title,
                style = MaterialTheme.typography.titleMedium
            )
            Text(
                text = post.description,
                style = MaterialTheme.typography.bodyMedium,
                color = MaterialTheme.colorScheme.onSurfaceVariant
            )
            Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
                Icon(Icons.Default.Favorite, contentDescription = null)
                Text(
                    text = "${post.likeCount}",
                    style = MaterialTheme.typography.labelSmall
                )
            }
        }
    }
}

The structure is nearly identical: a vertical stack with text and a horizontal row at the bottom. The syntax is different, the concepts are the same. This pattern holds for about 80% of what you build.

The other 20% is where things get interesting.

State Management: Two Different Worlds

This is the area with the biggest practical difference in day-to-day development.

SwiftUI: Property Wrappers

SwiftUI uses property wrappers (@State, @Binding, @StateObject, @ObservedObject, @EnvironmentObject) to manage state. Each one has a specific use case:

struct CounterView: View {
    @State private var count = 0  // Local state

    var body: some View {
        VStack {
            Text("Count: \(count)")
            Button("Increment") {
                count += 1
            }
        }
    }
}

// ViewModel pattern
class PostListViewModel: ObservableObject {
    @Published var posts: [Post] = []
    @Published var isLoading = false

    func loadPosts() async {
        isLoading = true
        posts = await postService.fetchPosts()
        isLoading = false
    }
}

struct PostListView: View {
    @StateObject private var viewModel = PostListViewModel()

    var body: some View {
        List(viewModel.posts) { post in
            PostCard(post: post)
        }
        .task { await viewModel.loadPosts() }
    }
}

The learning curve is knowing which property wrapper to use. @State for local value types, @StateObject for owning a reference type, @ObservedObject for receiving one. Get it wrong and you'll hit subtle bugs like ViewModels being recreated on every re-render.

Jetpack Compose: Remember + State Hoisting

Compose uses remember and mutableStateOf for local state, and leans heavily on state hoisting — pushing state up to the caller:

@Composable
fun CounterView() {
    var count by remember { mutableStateOf(0) }  // Local state

    Column {
        Text("Count: $count")
        Button(onClick = { count++ }) {
            Text("Increment")
        }
    }
}

// ViewModel pattern
@HiltViewModel
class PostListViewModel @Inject constructor(
    private val postService: PostService
) : ViewModel() {
    private val _uiState = MutableStateFlow(PostListUiState())
    val uiState: StateFlow<PostListUiState> = _uiState.asStateFlow()

    fun loadPosts() {
        viewModelScope.launch {
            _uiState.update { it.copy(isLoading = true) }
            val posts = postService.fetchPosts()
            _uiState.update { it.copy(posts = posts, isLoading = false) }
        }
    }
}

@Composable
fun PostListScreen(viewModel: PostListViewModel = hiltViewModel()) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()

    LazyColumn {
        items(uiState.posts) { post ->
            PostCard(post = post)
        }
    }

    LaunchedEffect(Unit) { viewModel.loadPosts() }
}

Compose has fewer state primitives to learn (remember, mutableStateOf, derivedStateOf), but the ViewModel layer is more verbose. You're writing StateFlow, MutableStateFlow, .update, .collectAsStateWithLifecycle() — it's more boilerplate but also more explicit about what's happening.

My Take

SwiftUI's approach is more concise but has more footguns. The difference between @StateObject and @ObservedObject has caused me more debugging time than I'd like to admit. Compose's approach is more verbose but harder to misuse. I slightly prefer Compose's explicitness for complex screens, and SwiftUI's brevity for simple ones.

Navigation: Night and Day

If state management is where the frameworks feel different, navigation is where they are different.

SwiftUI: NavigationStack

SwiftUI's navigation has improved dramatically since iOS 16. NavigationStack with navigationDestination is actually pleasant to use:

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

    var body: some View {
        NavigationStack(path: $path) {
            PostListView()
                .navigationDestination(for: Post.self) { post in
                    PostDetailView(post: post)
                }
                .navigationDestination(for: UserProfile.self) { profile in
                    ProfileView(profile: profile)
                }
        }
    }
}

It's type-safe, it handles the back stack for you, and deep linking is relatively straightforward with a custom NavigationPath.

Jetpack Compose: NavHost

Compose navigation uses string-based routes, which feels like a step backward from the type safety Kotlin usually offers:

@Composable
fun AppNavGraph(navController: NavHostController) {
    NavHost(navController = navController, startDestination = "posts") {
        composable("posts") {
            PostListScreen(
                onPostClick = { post ->
                    navController.navigate("posts/${post.id}")
                }
            )
        }
        composable(
            route = "posts/{postId}",
            arguments = listOf(navArgument("postId") { type = NavType.StringType })
        ) { backStackEntry ->
            val postId = backStackEntry.arguments?.getString("postId") ?: return@composable
            PostDetailScreen(postId = postId)
        }
    }
}

You're passing strings around, manually extracting arguments from the back stack, and hoping you didn't typo a route name. There are type-safe navigation libraries (like Compose Destinations), but the official solution still feels rough.

My Take

SwiftUI navigation wins this round clearly. Type-safe, concise, and built-in. Compose's string-based routing feels like it belongs to a different era. I use wrapper enums and sealed classes to bring some type safety to our Android navigation, but it's extra work that SwiftUI doesn't require.

Lists and Performance

Both frameworks handle lists well, but the approaches differ.

SwiftUI: List and LazyVStack

// Simple list
List(posts) { post in
    PostCard(post: post)
}

// Custom lazy list with more control
ScrollView {
    LazyVStack(spacing: 12) {
        ForEach(posts) { post in
            PostCard(post: post)
        }
    }
    .padding()
}

SwiftUI's List gives you platform-native styling (inset grouped, plain, sidebar) for free. LazyVStack gives you full control when you need it. Both are lazy by default.

Jetpack Compose: LazyColumn

LazyColumn(
    contentPadding = PaddingValues(16.dp),
    verticalArrangement = Arrangement.spacedBy(12.dp)
) {
    items(
        items = posts,
        key = { it.id }
    ) { post ->
        PostCard(post = post)
    }
}

LazyColumn is Compose's answer to RecyclerView, and it's great. The key parameter is critical for performance — it tells Compose which items moved vs. which are new. SwiftUI handles this through Identifiable conformance, which I find slightly cleaner.

My Take

For basic lists, both are excellent and very similar. For complex lists with multiple item types, sticky headers, and pull-to-refresh, Compose gives you more fine-grained control. SwiftUI's List is faster to set up but harder to customize deeply. I've hit more performance issues with SwiftUI lists than Compose, particularly with large datasets — though each major iOS release improves this.

Animations

This is where both frameworks genuinely shine compared to their imperative predecessors (UIKit/XML Views).

SwiftUI

struct LikeButton: View {
    @State private var isLiked = false

    var body: some View {
        Image(systemName: isLiked ? "heart.fill" : "heart")
            .foregroundColor(isLiked ? .red : .gray)
            .scaleEffect(isLiked ? 1.2 : 1.0)
            .animation(.spring(response: 0.3), value: isLiked)
            .onTapGesture { isLiked.toggle() }
    }
}

Jetpack Compose

@Composable
fun LikeButton() {
    var isLiked by remember { mutableStateOf(false) }
    val scale by animateFloatAsState(
        targetValue = if (isLiked) 1.2f else 1f,
        animationSpec = spring(dampingRatio = 0.4f)
    )

    Icon(
        imageVector = if (isLiked) Icons.Filled.Favorite else Icons.Outlined.FavoriteBorder,
        contentDescription = "Like",
        tint = if (isLiked) Color.Red else Color.Gray,
        modifier = Modifier
            .scale(scale)
            .clickable { isLiked = !isLiked }
    )
}

My Take

SwiftUI animations feel more magical — you slap .animation() on something and it just works. Compose animations are more explicit — you create animated state values and use them directly. I reach for SwiftUI's withAnimation block more often because it's less ceremony. But Compose gives you better control for complex, multi-property animations where you need precise timing.

Previews

Both frameworks support live previews in their respective IDEs. Both are game-changers compared to the "build and run" cycle of UIKit/XML.

SwiftUI Previews (Xcode)

#Preview {
    PostCard(post: .preview)
        .padding()
}

Compose Previews (Android Studio)

@Preview(showBackground = true)
@Composable
fun PostCardPreview() {
    AppTheme {
        PostCard(post = Post.preview())
    }
}

Functionally equivalent. The real difference is IDE performance: Android Studio previews have historically been slower and more crash-prone than Xcode previews, though both have improved significantly. Compose previews support interactive mode and multi-preview annotations (@PreviewLightDark, @PreviewScreenSizes), which are nice additions.

Theming and Styling

SwiftUI

SwiftUI leans on system-provided styles. You get dynamic type, dark mode, and accessibility for free when using built-in components. Custom theming requires more manual work — there's no centralized theme object like Material.

Text("Hello")
    .font(.headline)              // System font style
    .foregroundColor(.primary)     // Adapts to dark mode

Jetpack Compose

Compose has Material Design baked in via MaterialTheme. You define a centralized theme with colors, typography, and shapes, and reference it everywhere:

Text(
    text = "Hello",
    style = MaterialTheme.typography.titleMedium,
    color = MaterialTheme.colorScheme.onSurface
)

My Take

Compose's theming is more structured and easier to maintain at scale. When our designer changes the primary color or heading font, I change it in one place on Android and it propagates everywhere. On iOS, I've had to build a custom theme system to get the same centralized control. Material Theme is a genuine advantage for Compose.

Dependency Injection

This is less about the UI framework and more about the ecosystem, but it matters in practice.

On Android, Hilt is the standard. ViewModels get their dependencies injected automatically:

@HiltViewModel
class PostListViewModel @Inject constructor(
    private val postService: PostService,
    private val analyticsTracker: AnalyticsTracker
) : ViewModel() { ... }

On iOS, there's no official DI framework. I use protocol-based dependency injection with a service layer — it works, but requires more manual wiring:

class PostListViewModel: ObservableObject {
    private let postService: PostServiceProtocol
    private let analyticsTracker: AnalyticsTrackerProtocol

    init(
        postService: PostServiceProtocol = PostService(),
        analyticsTracker: AnalyticsTrackerProtocol = AnalyticsTracker()
    ) {
        self.postService = postService
        self.analyticsTracker = analyticsTracker
    }
}

Hilt's compile-time safety and automatic scoping are things I miss every time I switch to the iOS codebase.

The Honest Scorecard

After building the same app on both platforms, here's how I'd score them:

CategorySwiftUIComposeWinner
Learning curveModerateModerateTie
State managementConcise but trickyVerbose but safeCompose (slightly)
NavigationType-safe, cleanString-based, roughSwiftUI
ListsEasy setup, less controlMore control, explicit keysTie
AnimationsMagical, easyExplicit, powerfulSwiftUI (for simplicity)
PreviewsFast in XcodeSlower, more featuresTie
ThemingManualMaterial built-inCompose
DI ecosystemManual / third-partyHilt (first-party feel)Compose
Platform integrationTight with Apple APIsGood with Android APIsTie
MaturityImproving yearlyStable and reliableCompose (slightly)

What I Actually Prefer

If I'm being honest? I enjoy writing SwiftUI more. The syntax is cleaner, the animations feel effortless, and when things click, the code reads beautifully. There's an elegance to SwiftUI that Compose doesn't quite match.

But I trust Compose more. Its state management is harder to misuse, its theming is better organized, and its ecosystem (Hilt, StateFlow, Coroutines) feels more cohesive. When I'm building a complex screen with lots of state and side effects, I'm more confident the Compose version will work correctly on the first try.

The good news? Both are genuinely excellent. Coming from UIKit and XML Views, either one is a massive leap forward. If you're choosing between them for a new project, you'll be happy either way.

And if you're maintaining both like me? You'll develop a deep appreciation for what each framework does well — and a very specific list of complaints for each that you'll bore your colleagues with at lunch.

Practical Tips for Cross-Platform Developers

If you're building the same app on both platforms, here are patterns that have saved me time:

  • Mirror your ViewModel structure. Keep the same screen-level ViewModel pattern on both platforms. It makes feature parity reviews much faster.
  • Use the same naming conventions. PostListViewModel, PostDetailScreen, PostCard — same names, both platforms. When someone says "the PostCard has a bug," everyone knows what file to open.
  • Build a shared design token layer. Define your colors, spacing, and typography as named tokens that map to each platform's theme system. "primaryText" means the same thing everywhere.
  • Write platform-specific code without guilt. Don't force identical implementations. If SwiftUI has a perfect built-in component, use it. If Compose's Material library has exactly what you need, use it. The goal is feature parity, not code parity.
  • Test on both platforms early. Don't build an entire feature on iOS and then "port" it to Android. Build them in parallel — you'll catch design issues and API mismatches much faster.

The Bottom Line

SwiftUI and Jetpack Compose are more alike than they are different. The core mental model — declarative state-driven UI — is the same. The patterns transfer. The skills transfer. If you know one well, you can be productive in the other within a week.

The differences matter at the edges: navigation APIs, state management ergonomics, theming systems, ecosystem tooling. But these are the kinds of differences you learn once and then stop thinking about.

If you're a mobile developer who's only worked on one platform, I'd genuinely encourage you to try the other. Not because you need to ship on both — but because seeing the same problems solved two different ways makes you a better developer on either platform.

Comments (0)