Deploying SwiftUI on the Web
I recently released a web version of a SwiftUI app. However, instead of building out a separate webapp (in something like React), I brought my SwiftUI code onto the web using SwiftWasm.
The App
The app I ported is a game designed for SharePlay called Shhh!. The concept should be familiar if you’ve played any social deduction games like Mafia or SpyFall before. Essentially, all but one player know a secret location. Their goal is to keep this hidden from that spy, while also deducing who the spy is through a series of questions.
The Problem
I wanted to make sure that the game could be played by as many people as possible, even though it is mainly intended to be played over FaceTime. Because Apple introduced the web version of FaceTime, there was the very real possibility of players being on a call with Android/Windows users as well. To accommodate for this, I wanted to launch a web version alongside the app.
The Solution
Developing and maintaining a separate web app would’ve been too time consuming if I wanted to finish the game by the time SharePlay launched. So, I decided to reuse the majority of my codebase on the web using Tokamak, a SwiftUI-compatible framework for building browser apps with WebAssembly.
So, how did it go?
The website is almost entirely written in Swift! 🎉
The landing page uses TokamakStaticHTML
to generate its markup, and when you tap “Join a Game” you are taken to the SwiftWasm web app.
To make this work, the app was split into a few different pieces:
- ShhhShared — Swift package with shared code such as models, game logic, UI, etc.
- Shhh — The Xcode project for the iOS and macOS apps
- ShhhWeb — Swift package for the Tokamak web app
The entirety of the game logic and UI is contained in ShhhShared
, with Shhh
and ShhhWeb
providing only pieces of UI and extra bits required for their platforms. For instance, they contain their own code for interfacing with Firebase, Shhh
using the Swift package, and ShhhWeb
using the JavaScript library (with a slim wrapper for Swift using JavaScriptKit
). Shhh
also has a Multipeer game backend for playing local games, and, of course, the SharePlay backend when playing with only other iOS 15.1 users. When you generate a web join code in the app from a local game or SharePlay, it is moved to Firebase so the web app can access it.
What Works
The app successfully runs on the web with the majority of code shared between platforms, so anyone with a modern browser can play. I have around 40 #if os(WASI)
checks in the project, which isn’t bad considering I have around 20 #if os(macOS)
/os(iOS)
checks.
You may need to keep a version of Xcode 13.0 around, as I do face some issues with building SwiftWasm projects with 13.1. Just
xcode-select --switch /Applications/Xcode-13_0.app
when you are working on the web side.
Hot reload with Carton worked great during development, and I was able to use carton bundle
to easily package up my app and deploy with Netlify.
The resulting .wasm
file is definitely larger than a JavaScript app would typically be. Hopefully this can be reduced in the future. I used a --custom-index-page
to show a loading screen while the file is downloading and the app is starting up.
What Doesn’t Work (Yet)
While web and native players have very similar experiences, some trade-offs had to be made.
Performance
Some complex UI elements needed to be simplified so the web app would run fast enough. For instance, the location list on native uses Canvas
to create custom animated graphics for each location. The web version does not have any animated graphics here, and is instead a list of toggle-able buttons.
The web app still has animated graphics for every location, but many of them are simpler than their native counterpart, and they're only shown on the main game screen.
SF Symbols
Tokamak understandably has no support for SF Symbols. I was able to work around this by extending Image
to have a system name initializer that redirects to a .png
with the same name:
extension Image {
init(systemName: String) {
self.init("\(systemName).png")
}
}
Then I can just supply a custom image for each symbol I use in the app.
Accessibility
Tokamak does not yet have support for any of SwiftUI’s accessibility modifiers. I was able to stub them out with inert modifiers, but having a properly accessible web app would be much better.
Layout Issues
Layout on the web is notoriously hard, and Tokamak does have issues with certain layouts. Thankfully, we can dip into HTML/CSS when needed. I used the HTML
view to manually work around some of the layout problems:
#if os(WASI)
HTML("div", ["style": "some custom styling"]) {
content
}
#else
content
#endif
In other cases, I opted to just simplify the UI:
There's clearly room for improvement, but the core functionality is still present, and the site will improve as Tokamak's layout approaches SwiftUI's.
Missing Views & Modifiers
Here’s a list of everything I built custom replacements for until they are added to Tokamak. Some of these additions are simply inert modifiers added to get it building.
An "inert" modifier looks like this:
#if os(WASI)
public extension View {
func symbolRenderingMode(_: SymbolRenderingMode) -> some View {
self
}
}
#endif
alert
- the view it's attached to is replaced with the alert content when presented. An official implementation would probably use the JavaScript alert.allowsHitTesting
- inert- All the accessibility modifiers, as mentioned
contentShape
- inertdisabled
- just removes the elementfixedSize
- inertignoresSafeArea
- inertmask
- inertonChange
- inertsafeAreaInset
- defers to VStack + HStacksymbolRenderingMode
- inertsymbolVariant
- inertTabView
(tabItem
) - working implementation (I will make a PR after some polishing)
Not too bad overall, especially considering how many didn’t absolutely need an implementation.
Recommendations
If you're developing a SwiftUI experience that you want more people to use, give Tokamak a try! See how far you get before you hit a missing piece, then drop in an inert modifier, or submit an issue. It's pretty fun to see your app running in the browser.
Thanks for reading this quick breakdown!