This post is part of a series on my experience with creating the same mobile game app with three different UI toolkits: Jetpack Compose, SwiftUI, and Flutter. Here are links to the other parts:

The iOS version of MapGame was just released, so I can now detail my experience building with Swift and SwiftUI!

Again, as a reminder, I don’t have much experience working with native iOS apps, and it’s my first time building an app from the ground up with SwiftUI. I only used it oncec before to create the menu bar extra for a macOS app.

As I said before, I come from a web dev background, and have built apps mostly with Flutter. Coming to this, I had the faintest idea of UIKit, Cocoapods, Combine, and other technologies and APIs that are surely familiar to seasoned iOS developers.

So, let’s dive in on my random thoughts and notes, learning Swift and SwiftUI to build a small trivia game for iOS!

SwiftUI presentation from Apple

Learning Swift

Swift is a modern, concise, and powerful language. Coming from similar languages like Dart and Kotlin, I found that I only needed to skim the docs to get a general idea of its syntax, before I was able to start writing Swift code.

Some random features I loved about Swift:

  • Swift, like Dart and Kotlin, is null-safety by default, which is always a plus.
  • Enums in Swift are exceptionally powerful and versatile. They allow you to add custom properties, and also allow for pattern matching and omitting the enum type, resulting in more readable code:

enum LoadingState {
    case loading, success, failed
}
@State private var loadingState = LoadingState.loading
switch loadingState {
case .loading:
    LoadingView()
case .success:
    SuccessView()
case .failed:
    FailedView()
}
  • Handling optional values is so elegant, using if let or guard let statements:

// If there is a matching path id, color it, else do nothing.
// The index variable is passed inside the code block.
if let index = mapPaths.firstIndex(where: { $0.id == countryToGuess }) {
  mapPaths[index].fill = didWin ? highlightColor : notFoundColor
}
  • Swift Protocols are widely used to define behaviors and requirements for implementing objects (classes or structs). It was easy, for example, to make a struct implement the Codable protocol, and make it instantly serializable in JSON format.
  • It took me a while to understand the differences between class and struct, and when each one is more suitable.
  • Closure syntax is clean and concise, as was in Kotlin:

Purchases.shared.getOfferings { offerings, error in
  if let error = error {
    return
  }
  guard let noAdsPackage = offerings?.current?.availablePackages.first else {
    return
  }
  self.noAdsProduct = noAdsPackage
}
  • For concurrency, Swift uses the familiar async/await syntax. I was initially a bit confused, as I saw many tutorials talking about Combine (which is something like Dart’s Streams?), but I found that I only needed to use it once in my app.

Not so fun fact: while researching various iOS development issues, I frequently came across Objective-C code snippets. The syntax was so weird and awkward that I often couldn’t understand what was going on! This really highlights how much language design has advanced over the years.

Meme about Objective-C syntax

Finally, a bit about tooling. You have to use XCode to develop iOS apps with Swift and I surely hate XCode:

  • It’s slow.
  • It takes up a lot of disk space.
  • Updating it means 5-6 hours of waiting.
  • It randomly loses Simulators, so I have to re-download and install them.
  • It takes 2 seconds to statically analyze my file and display errors and warnings, which sounds good, but not so much when you are continuously editing a file.

Meme about how time-consuming it is to use XCode
Courtesy of the best, dedicated XCode memes page on the webs.
  • I can’t easily bring my keyboard shortcuts from VSCode/Sublime Text, unlike Android Studio. To customize certain actions, like dropping multiple cursors, you have to edit some .plist files, buried in Library.
  • I am used to removing lines of code with Command-X (Cut), which is widely supported on all other editors. Couldn’t find a way to do this in XCode.

And I am not the only one (!):

Learning SwiftUI

I read some of the official documentation, and then started building by prompting ChatGPT to help me with the code. Most of the time, asking ChatGPT to convert Compose code to SwiftUI worked wonderfully, and I had to make minor adjustments.

ChatGPT session asking to convert Kotlin code to SwiftUI
ChatGPT shines when using it for grunt work.

As time went on, I found the official documentation a bit lacking; most of the tutorials are based on videos or project-based long-form guides, like this one for SwiftUI lists, which I find hard to follow. Google does a better job in this for Flutter and Compose, providing examples and tutorials in a better format (here is the corresponding one for Compose lists).

Overall, when googling around for guidance, I found myself preferring various Swift/SwiftUI blogs like Hacking with Swift, Swift by Sundell (great podcast too!), SwiftLee, and others, to learn how to properly do things in SwiftUI.

Developing an app with SwiftUI

And now for the random notes I jotted down while building the MapGame iOS app with Swift UI:

I miss Hot Reload (again)

As was the case with Jetpack Compose, I dearly missed Flutter’s Hot Reload capabilities. It definitely speeds up development and makes it easier to try out things while working on your app.

In SwiftUI, you can use the #Preview annotations to create Previews that run inside XCode and let you preview (😮‍💨) your UI:

Screenshot from XCode previewing a button component
Previewing a button in XCode.

I indeed used this sometimes for simpler components, but I found it slow to launch and prone to crashes or weird errors. Also, it’s harder to set up for more complex components, which might need a mock database class or ViewModel to work.

(To underscore my previous point: while researching this section, I googled “SwiftUI preview.” The first result was an unhelpful, almost auto-generated official documentation page. In contrast, the third result, a SwiftLee article, was far more informative, providing clear and detailed guidance on how to effectively use SwiftUI Previews.)

Meme about SwiftUI Previews not working.

Once more, I resorted to mostly using Command-R to rebuild my app every time that I made a meaningful change.

The cleanest syntax

In the latest entry to this series, I declared myself impressed with how clean and less verbose Jetpack Compose feels, compared to Flutter. Now, I can confidently state the same for SwiftUI: it’s by far the cleanest syntax of the three.

Here is an example from the app that combines state variables, environment objects, and user-defined settings from AppStorage, to render a section of the UI:

struct LivesTimerView: View {
  @EnvironmentObject var viewModel: GameViewModel
  @State private var timerString: String = "00:000"
  @AppStorage("shouldHideTimer") var shouldHideTimer = false
  var body: some View {
    HStack(alignment: .bottom) {
      HStack(spacing: 4) {
        ForEach(0 ..< viewModel.lives, id: \.self) { _ in
          Image("heart")
            .resizable()
            .scaledToFit()
        }
      }
      Spacer()
      if !shouldHideTimer {
        HStack(
          alignment: .center,
          spacing: 1
        ) {
          Text(timerString)
            .onReceive(viewModel.$millisPassed) { millis in
              timerString = formatTime(millis)
            }
          Image("timer")
            .resizable()
            .scaledToFit()
        }
      }
    }
  }
}

This code renders the lives and timer section of the UI:

Screenshot from the lives and timer section of the app.

You can immediately see how readable and straightforward SwiftUI code is, thanks to its use of trailing closures and modifier-like syntax (e.g., .resizable(), .padding()). Declaring state and environment variables with property wrappers is quick and easy, enhancing code clarity and maintainability.

Also, while building various screens with SwiftUI, I didn’t even once encounter any layout issues when combining or nesting VStacks and HStacks, which is a frequent source of frustration with Flutter (errors like “Vertical viewport was given unbounded height.” or similar). Once you get the usefulness of Spacer widgets, setting complex layouts becomes a lot easier.

SwiftUI provides all the native iOS components you need to build a comprehensive app, including buttons, lists, alerts, and more. The allow for basic customizations and integrate seamlessly into the app’s design. For a great resource, check out the Interactful app, which offers demos of every SwiftUI component along with code snippets. This app is an excellent reference for seeing how each component works and how to implement them in your own projects:

Screenshot from the Interactful app.
From the excellent Interactful app.

Tied to the OS version

In Flutter and Jetpack Compose, you have the flexibility to target various OS (Android or Android/iOS for Flutter) versions by choosing the appropriate framework versions during development. This allows you to leverage the latest features and improvements while still supporting older OS versions.

Meme about iOS deployment versions.


With SwiftUI, the situation is different because it is integrated into the iOS operating system itself. This means I can only use SwiftUI features that are available in the iOS version I am targeting. Consequently, MapGame only supports the latest two major iOS versions (iOS 16 and 17) at the time of writing.

The good news is that iOS users tend to update more frequently than Android users, as Apple supports longer OS update windows. According to Apple, iOS 16 and 17 account for almost 92% of iOS devices. However, it is a bit frustrating to be unable to use the latest SwiftUI improvements and API changes immediately upon their release.

Additionally, I had to add some version-checking code to ensure the app works correctly on both iOS 16 and 17.

Modifier Syntax

SwiftUI’s modifier syntax, like .frame(), .padding(), .onAppear(), and others, is one of its standout features.

It makes it easy to understand what’s happening with every component. I feel it’s a bit cleaner than Jetpack Compose and I personally prefer it to Flutter’s “everything is a widget” approach.

Text(guess)
  .bold()
  .lineLimit(1)
  .truncationMode(.tail)
  .frame(maxWidth: .infinity, alignment: .leading)

However, chaining numerous modifiers can sometimes feel a bit awkward, and Xcode’s autocomplete can be lacking, which slows down development. Despite these minor drawbacks, the modifier syntax greatly enhances the overall coding experience in SwiftUI.

Stellar state management

SwiftUI shines in the area of state management and dependency injection, solving this pain point more effectively than other frameworks (looking at you, Flutter 😡).

You can use:

  • The @State property wrapper for locally owned state, such as setting a component’s visibility.
  • @StateObject for view models that are owned by the view and need to be instantiated and managed by SwiftUI.
  • @ObservedObject for view models that are owned elsewhere but need to be observed for changes.
  • @Binding to create a two-way connection between a view and its underlying data, allowing changes in one place to propagate to the other.

Here is a simple component, which uses @State:

struct ContentView: View {
  // Declare a state variable to manage the visibility of a component
  @State private var isVisible: Bool = true
  var body: some View {
      VStack {
          // Conditionally display a text view based on the state variable
          if isVisible {
              Text("Hello, SwiftUI!")
                  .padding()
          }
          // A button to toggle the visibility state
          Button(action: {
              isVisible.toggle()
          }) {
              Text(isVisible ? "Hide Text" : "Show Text")
                  .background(Color.green)
                  .foregroundColor(.white)
          }
      }
      .padding()
  }
}

Notice how I don’t need to set up a StatefulWidget and call setState(), or how more straightforward it looks compared to Compose’s remember API.

For my app, I set up a view model as an @ObservableObject with @Published variables. I then passed this view model to the Environment in my initial MenuScreen. This allowed me to easily access and use it in any component throughout the app:

class GameViewModel: ObservableObject {
  @Published var score: Int = 0
  // Other properties and methods
}
// In my main app file:
@main
struct MapGameApp: App {
  @StateObject var viewModel = GameViewModel()
 var body: some Scene {
    WindowGroup {
      MenuScreen()
        .environmentObject(viewModel)
    }
 }
}
// Usage in any component
struct SomeView: View {
  @EnvironmentObject var gameViewModel: GameViewModel
  var body: some View {
      Text("Score: \(gameViewModel.score)")
  }
}

This approach makes handling state management and data flow reasonable and efficient, improving the developer experience in SwiftUI. I am also eager to use the improvements available in iOS 17, where Observation is supported.

Drawing the map

This is the main feature of the app, of course, where we draw aν SVG-sourced world map on the screen. The user needs to be able to zoom in and out of the map, drag it around, and tap to guess, so it needs to be efficient and responsive.

First of all, there is no Canvas/CustomPaint element in SwiftUI, like there is in Compose/Flutter. Instead, I am drawing the Paths directly on the screen:

ZStack {
  ForEach(viewModel.mapPaths, id: \.id) { mapPath in
      Path(mapPath.path) // Converted from SVG's "d" property
        .fill(Color(mapPath.fill))
        .stroke(Color(mapPath.stroke), lineWidth: mapPath.strokeWidth)
        .transformEffect(transform)
        .onTapGesture {
          viewModel.handleUserGuess(country: mapPath.country)
        }
        ...

The great thing about this is that detecting tap gestures on the paths is handled directly by SwiftUI, i.e. I don’t need to implement any custom hit-testing, like I did in Compose.

I then needed a way to zoom in/out and pan the map, responding to user interaction. Unfortunately, simultaneously scaling and panning a component in SwiftUI is challenging. After a lot of research, I had to resort to using UIKit’s ScrollView to achieve this functionality. This also meant that I had to learn how to inject UIKit components in SwiftUI (using UIViewRepresentable), how to pass data between the two, and more stuff, that would be unnecessary if SwiftUI would support multiple gestures at the same time.

It’s what took me the most time to get right, but I am delighted with the result, as the map is snappy and fun to move around and tap.

Ease of Adding Images

Adding images to a project is simpler in SwiftUI. You can drag and drop an image into the Assets.xcassets folder and use it directly in your code. In Compose, handling Drawables, especially SVGs, was more cumbersome.

Compiler failures

OK, so here is one thing that I haven’t encountered in any other framework: sometimes the compiler fails to statically analyze my SwiftUI component, and instead spits out this rather unhelpful error:

“The compiler is unable to type-check this expression in reasonable time; try breaking up the expression into distinct sub-expressions”.

Screenshot from XCode with a compiler error.

What kind of hell is this? It should directly find the error, which is that the getTimeString function returns a Text, which I then erroneously wrap in another Text in line 234. Just tell me that, instead of giving me a cryptic error message.

I lost a lot of time debugging these kinds of errors, where simple type-checking didn’t work, and wouldn’t even provide a meaningful trace to work with. I haven’t ever stumbled upon anything similar while using Flutter or Compose.

Theming Challenges

Theming in SwiftUI is not very user-friendly. Unlike Jetpack Compose, where you can easily use MaterialTheme.colors.primary to apply a theme across your app, SwiftUI doesn’t have a simple way to do this. To change the color of a component based on a theme, you have to create custom view modifiers, which can be tedious and complicated.

This is especially frustrating when you want, for example, to change the color of all bottom sheets in your app. Instead of having a centralized theme system, you need to manually change the colors for each component, making the process more difficult and time-consuming.

This lack of easy theming support in SwiftUI is a significant downside. Maybe there is a better way and I haven’t found it? Let me know, please!

Navigation Issues

Navigation in SwiftUI is still a work in progress. The API recently changed, recommending the use of NavigationStack instead of NavigationView, making many tutorials outdated. There is no comprehensive official guide on navigating between screens. While there is a sample project in the documentation, most guidance comes from blogs and Medium posts. I ended up using ChatGPT to help me figure it out.

Some features are also broken, such as losing the swipe back gesture when using a custom back button for navigation. Stack Overflow discussions often mention various Xcode and iOS versions where this might or might not work. To solve this, I had to use a UIKit NavigationController (SO solution).

Navigation improved once I used an environment wrapper for my navigation manager, but it’s still not as seamless as it could be.

Markdown support in Text

This works out of the box and makes me happy:

struct MarkdownTextView: View {
  var body: some View {
      VStack {
          Text("**Bold Text** and *Italic Text*")
          
          Text("This is a [link](https://www.example.com) in Markdown.")
          
          Text("""
                ### SwiftUI Markdown Support
                - **Bold**
                - *Italic*
                - [Link](https://www.example.com)
                """)
      }
  }
}
Using Markdown inside Text components.
No need to combine RichText widgets or AnnotatedStrings like in Flutter and Compose.

AppStorage for preferences: ⭐️⭐️⭐️⭐️⭐️

Dealing with data persistence in SwiftUI is refreshingly simple, especially when using @AppStorage. This property wrapper allows you to easily save and retrieve user preferences or small pieces of data without needing to write extensive code. For example, you can use @AppStorage to store a user’s settings, such as a preferred theme or a high score, and SwiftUI will handle the rest.

Here’s a quick example from the app’s settings screen, to illustrate how @AppStorage works:

struct SettingsScreen: View {
  @AppStorage("shouldDisableConfetti") var shouldDisableConfetti = false
  @AppStorage("shouldHideTimer") var shouldHideTimer = false
  var body: some View {
    VStack {
      Toggle(isOn: $shouldDisableConfetti) {
        Text("Disable Confetti:")
          .tvcdFont(size: 14)
          .textCase(.uppercase)
      }
      Toggle(isOn: $shouldHideTimer) {
        Text("Hide Timer:")
          .tvcdFont(size: 14)
          .textCase(.uppercase)
      }
      Spacer()
    }
    .padding()
    ...
  

This displays this section:

Settings screenshot from the app


That’s it! No need to set up SharedPreferences, create a separate manager class for it, and manually handle saving and restoring data.

This makes it incredibly easy to implement features like settings toggles, or any other user-specific data that needs to be persisted across app launches.

Stability

The app is now published, and no major bugs or crashes have occurred after hundreds of downloads. This is both a testament to SwiftUI’s readiness for production and a reflection of the stability often found in iOS development.

Unlike Android, where you have to account for a vast array of devices with different screen sizes and hardware capabilities, iOS development benefits from a more controlled ecosystem. You only need to test on a few iPhone and iPad models, and iOS versions, to be confident your app works well.

Package Management and Integration

Swift Package Manager

While package management is better than Compose’s Gradle (known also as every Android developer’s hell on earth), it still falls short of Flutter’s pub.dev. Swift Package Manager simplifies adding dependencies, but finding and using third-party libraries can be challenging.

Firebase Integration

Adding Firebase libraries to SwiftUI was straightforward, without the issues I frequently encounter with Flutter’s Firebase libraries for iOS.

Ads and In-app Purchases

I used RevenueCat once again for managing in-app purchases and Google’s mobile ads APIs for displaying interstitial ads. The integration was smooth and functioned as expected.

Universal Links

In the app, I use Universal Links to let users import their progress and stats from the website to the app seamlessly:

Implementing this feature in SwiftUI was trickier than expected due to the requirements for the verification file.

The verification file must be named apple-app-site-association without any extension, although it’s just a simple JSON file! This meant I had to configure my Apache server to serve this file with the correct MIME type, so I had to add a custom rule to ensure it was served as application/json:

<Files "apple-app-site-association">
    AddType application/json .json
</Files>

StackOverflow is full of questions from frustrated developers about this. Simply allowing this to have a .json extension would make things easier.

Final Thoughts

Despite the frustrations with Xcode and some limitations of SwiftUI, I found the experience largely positive.

Swift is a powerful, full-fledged language that makes development enjoyable. SwiftUI’s concise syntax and stellar state management contribute significantly to a pleasant coding experience.

screenshot of XCode and an iPad Simulator running MapGame

There are areas where SwiftUI could improve. Official documentation feels lacking and knowledge is scattered around the internet in blogs and forums. The lack of robust theming support means developers often have to create custom view modifiers to achieve consistent styling, which adds unnecessary complexity. Additionally, the evolving navigation API, with recent shifts from NavigationView to NavigationStack, has led to confusion and outdated tutorials.

In the end, building a fully-fledged app from scratch with SwiftUI was a great experience.

The app is now ready for production, offering excellent performance and usability. I would highly recommend SwiftUI to anyone looking to get into iOS development.

Keep an eye out for my next post in this series, where I’ll share the final verdict on the three UI frameworks, and give some recommendations on what to choose and when!