After having spent the better part of two months deep in the trenches of iOS development with Swift, I can comfortably say: Web Development simply is more mature.
Let’s just run over a few things I ran into that’s bad, broken, or made for a poor experience:
@Query
in your View
)Admittedly, these might be trivial gripes for someone that has spent years in the Mobile ecosystem, but they certainly didn’t make me fall in love with it. More importantly, it wasn’t really providing a great user experience either.
….so what’s the alternative?
Here's an overview of what we'll cover:
Before being able to explore the solution space we need to make clear what we’re trying to achieve here, as this will quickly start excluding options.
We want to:
To rule them all?..
This admittedly does not start to limit our choices too much yet, so what do we base our choice on here?
For me personally, I want something performant and ergonomic. To me that essentially boils it down to Rust, which will scale amazingly on the Backend (especially in Serverless), and has very good ergonomics for working in it. It features both a very helpful compiler, a vibrant community, and macros to paper over any boilerplate code.
Rust supports Frontend development via WASM, supported in all major browsers, and there are very good frameworks such as Leptos, Dioxus, and many more.
Good alternatives for a full-stack language would be TypeScript and Node.js/Bun. For reasons we’ll see later, this is not the choice we go with in the end, but for now let’s consider it a viable alternative (it might be for you!).
Now, how do we get our Rust or TypeScript code onto our different deployment targets? Let’s make an overview of how you’d typically do. For good measure, let’s also compare with Dart/Flutter:
Language / Target | Backend Options | Frontend Options | Mobile Options |
---|---|---|---|
Rust | - Native Support | - Leptos (WASM) - Dioxus (WASM) - Yew (WASM) - …etc | - Capacitor (WASM in WebView) - Tauri (Beta, WASM in WebView) |
TypeScript | - Node.js - Bun | - Native Support (React, Vue, Svelte, Solid, etc) | - Capacitor (JS in WebView) |
Dart/Flutter | - Native Support | - Native for Dart - Flutter (HTML or Canvas) | - Flutter |
For Rust there are some interesting projects brewing such as Robius which is quite ambitious and hopefully gains traction.
The choice is not yet super clear here, and all could work so far. One concern I have with Dart/Flutter specifically is the lack of maturity in its Backend ecosystem currently. It you’ll quickly run into missing or unmaintained libraries.
Choice: Rust & WASM
Alternative: TypeScript + Node.js/Bun or Dart/Flutter
It’s all or nothing!
This is were things get tricky. We’re greedy, and we want to be able to have all our options open and not limit ourselves, permanently closing off doors.
Let’s take a concrete example:
If you want to develop an iOS Widget (those little things on your Home/Lock screen), you need to create a new Widget Extension Target in XCode and Embed that Target into your main App. It cannot function on its own, since it inherently is an extension to your App.
Here’s an example of a Widget for a Todo App, providing interactivity from the Home Screen:
This seemingly innocuous example is also where most of our options get limited and we’ll have to get a bit creative with how we solve it.
So how does support look like for our Mobile options? (we’ll simplify for a moment to the iOS ecosystem)
Mobile Options / Target Support | iOS App | macOS App | Widget Extension | watchOS | watchOS Complication |
---|---|---|---|---|---|
Tauri | ✅ (sorta)1 | ✅ (same App as the iOS App)2 | ❌ | ❌ | ❌ |
Capacitor | ✅ (sorta)3 | ✅ (same App as the iOS App)2 | ✅⚠️ (via Native code) | ✅⚠️ (via Native code) | ✅⚠️ (via Native code) |
Flutter | ✅ | ✅ | ✅⚠️ (via Native code) | ✅⚠️ (via Native code) | ✅⚠️ (via Native code) |
Platform support overview here https://v2.tauri.app/plugin/
This is the “Designed for iPad” type of App which is essentially an iOS App that runs completely native on ARM-based Macbooks. Alternatively you can also deploy it as an Electron App using https://github.com/capacitor-community/electron for other targets.
Platform support overview here https://capacitorjs.com/docs/apis
Before continuing, let’s talk about that ✅⚠️ score though—what does that mean exactly?
At this point, no matter what solution we end up with, we will need to drop into XCode to setup our Widget Extensions, watchOS Apps, and watchOS Complications.
But will our chosen framework support that, or will we get completely blocked even trying to do this (no matter how well supported)?
For Tauri, the answer is: We are blocked (#9766 is still open in the Tauri repo), and cannot proceed.
Luckily, for both Capacitor and Dart/Flutter, that’s not entirely the case. We can open XCode and add our own Targets that will build alongside our App, for anything that we want, such as Widgets and our watchOS App.
That looks like this in XCode:
The limitation here is that these additional targets only support native code (i.e. Swift or Kotlin/Java).
Before jumping into the next section where we’ll look at code sharing, how do we actually communicate with and/or use the native capabilities that Capacitor supports? I’ve written up a guide on how exactly to do that in detail here: Using Capacitor Plugins from Rust/WASM.
Choice: Capacitor
Alternative: Dart/Flutter
If we have to, can we at least make it nice?
So, if we must write Native platform code for certain things, can we at least reuse some of our code across from our “core” language into our native language?
This is where we get into the differences in our language choice. Our options are:
We can quickly exclude the JavaScriptCore option as a viable route, it’s simply not gonna be even remotely ergonomic.
You essentially import your JavaScript file, or put the code in a String. Then you evaluate it, and can get the output. There’s not much interop between the host language (Swift) and the guest language (JavaScript), providing for poor ergonomics in our usecase (some examples here and here).
Dart/Flutter does support calling Objective-C/Swift from within Dart, and with a bit of work it does seem to be able to generate FFI bindings via Flutter to go the other way as well, with a few examples given in their documentation.
How does the Rust side of things then look like? Very much like Dart/Flutter actually!
.xcframework
file from the builds.xcframework
into XCodeI’ve written up a guide on how exactly to do that in detail here: Setting up UniFFI for iOS, Simulators, and watchOS.
Once you’ve done the initial project setup (for building the targets and generating the .xcframework
file) you don’t really touch that again, and you only need to concern yourself with which things to expose to Swift.
Let’s make a small example of code using uniffi
would look like:
1 !;
setup_scaffolding 2
3
4 5 6 7 8
9
10
11 12 13 14 15 16 17
How do we call the generated code? As you would any other Swift code!
1 // Calling our Rust function with our Rust enum as an argument.
2 eatFruit(fruit: Fruits.watermelon)
1 // It'll work everywhere you'd expect it to, e.g. in String interpolation here.
2 Text( )
Final result in the iOS and Android Simulators, running our Web App as a Mobile App:
Same application on the Web:
The Widget Extension in the XCode Preview:
The watchOS App in the XCode Preview:
Choice: Rust with UniFFI
Alternative: Dart/Flutter
Let’s revisit our original goals with our final results:
Why not Dart/Flutter? My main holdback stems from the maturity the language and ecosystem has on the Backend, which seems to be quite lacking. Rust definitely has it beat here, with an extremely vibrant ecosystem.
I’m personally pretty happy with the solution. Most people probably won’t need the watchOS and Widgets, so they won’t have to touch Swift code, but it’s at least nice to know that you haven’t closed off that option for yourself down the road, as some options would leave you.