Building Apps with Skip.tools
I have now pretty much completed development on my first Skip project, which was also my first SwiftUI project. So I thought I’d share my experience on using Skip, through the lenses of a first time SwiftUI dev.
This is not my first time making an app. I have created another app (youProof, end-to-end encrypted digital business cards) using Ionic/Vue + Capacitor. I used a little bit of native code to create a simple QR code widget there, but other than that I have no experience with either Swift/SwiftUI or Kotlin/Jetpack Compose.
A bit of context
My new app is called Vector Bible (see the link in my bio). It’s a Bible reader with a semantic search feature and pretty neat UI/UX (IMO at least). It initially started as a web app, but I pretty soon realized that an additional mobile app was the way to go.
Whenever I build some software, I try to make it compatible with as many devices as possible - but only where it’s still reasonable for a solo dev. Same goes for making the app look and feel native on each platform. However, building youProof, I didn’t enjoy the Ionic developer experience all too much. It felt like I constantly had to fight against the framework-provided styling to get just the look I wanted to. Also, with MD3 (expressive) and now Liquid Glass, Ionic apps simply look outdated on all platforms. So going fully native was the obvious next thing for me to try out.
Still, being a solo dev creating projects in my free time, I always try to keep my development efforts reasonable so I can also finish the projects I started. From the start, it was clear to me that this app should run on iPhone, iPad, and Android devices. Around the same time, I coincidentally saw some posts on X about Skip - a Swift/SwiftUI to Kotlin/Jetpack Compose transpiler. That looked intriguing, so I decided to give it a try.
Skip quickly explained
But what is Skip actually? Skip is basically a toolkit that automatically converts a SwiftUI app into a Compose app. It does that by providing Compose replacement for common SwiftUI components. Swift code is transpiled into Kotlin code, and SwiftUI components are replaced with adjacent Compose components. More recently they’ve also introduced a compile-mode where the Swift code is no longer transpiled but actually compiled for Android targets. That is only available to paid users though, so I didn’t use that.
The basic idea is that you develop the app with Skip from the get-go. You use Skip to create the project, then you continually test it on both iOS and Android as you develop it. I recommend having a physical Android and iOS device at hand at all times. This is more convenient than having to run two virtual devices in addition to Xcode and Android Studio (you need both IDEs running to develop a Skip app).
Skip’s goal is that you just develop an iOS app using iOS tools and get an Android app for free. However, they obviously can’t reasonably support the whole SwiftUI API. So Skip only supports a subset of SwiftUI, focusing on more important parts of the API. This is why adding Skip to an existing SwiftUI app is not optimal or recommended.
My experience
Now, all in all this works pretty well. As long as you follow the mostly decent documentation, you will get an app that looks like an iPhone app on iPhone, an iPad app on iPad and an Android app on Android devices. You can also use compiler directives to make some parts of your code only target Android or only iOS etc. But, it is not at all perfect.
For the next part, please keep in mind that I have almost no prior experience with SwiftUI or Compose.
SwiftUI components can at times express pretty complex behavior (say a search bar that hides and shows different parts of the UI depending on the device and screen size). This means that there are probably a lot of implicit expectations to said behavior. Skip tries to mirror these behaviors and meet these expectations as closely as possible, but that is obviously not always the case. Some things you’d expect to work in pure SwiftUI just don’t work when using Skip. At times this is documented, but often it is not. That can be frustrating - especially since the search function in the Skip docs doesn’t work very well. Quite often, you type in a term, click a suggestion, and end up somewhere unrelated. So it can get really cumbersome to figure out what parts of the SwiftUI API are actually supported.
Some parts of SwiftUI are not supported at all. This might be a bit annoying, but when that happens you just have to use compiler directives to implement some feature using the full SwiftUI on Apple devices and in some other way on Android. Skip also supports creating Jetpack Compose components directly within SwiftUI components. Without any Compose experience, I found this unintuitive, so I mostly avoided it. Instead, I adapted the Android implementation to SwiftUI features Skip supports.
As I am using the transpiler-mode, another thing to consider is that pretty much all Swift libraries won’t be compatible with Skip. That was mostly a non-issue for me, the only exception being the Supabase client libraries. For situations like this, Skip provides a way to use Kotlin libraries with Swift syntax. Once again, this can be a bit difficult due to differences in data types and missing code-completion, parameter info etc. (Remember, Xcode was never made to support calling Kotlin libraries through a Swift syntax) Compile-mode should be a bit less problematic in this regard, as a lot of Swift libraries can just be used as is.
Complexity
All of these things are pretty manageable once you know your way around Skip. What is a bit more problematic is the following: Probably all SwiftUI devs will have had at least some friction points with the Swift type checker - the dreaded “The compiler is unable to type-check this expression in reasonable time; try breaking up the expression into distinct sub-expressions” error. In general, this means you have to split your code into smaller components, which is also a good practice in general and has no other real side effects in SwiftUI.
That’s a bit different for Skip/Compose components though. Let’s say we have a list, and in each element of the list there is a button.
In normal SwiftUI, we can wrap the button inside another View and put that in another file - say, a CustomButton component with some special icons or whatnot.
Nevertheless, the Button still adapts to being inside a list element. Same goes for Android - as long as the Button is placed directly in the list. But once you wrap it inside another View to turn it into a separate component, this may no longer hold true. That might be a non-issue in some situations. Well, it does become problematic when you can, for example, only get the position of a list element inside a ScrollViewReader when that element is a direct child of the List that itself is a direct child of the ScrollViewReader.
In short, this means that the way Skip works sometimes forced my code to get more complex than I wished for. Yes, you can use ViewBuilders to split up code without wrapping stuff in extra views, but that is also not really ideal.
As I mentioned earlier, the Swift compiler - especially its error messages - becomes increasingly useless as SwiftUI constructs get more complex. A simple mistake, like using the wrong parameter name in a method call, might trigger the dreaded ‘The compiler is unable…’ error. That often forced me to comment out segment after segment until my project compiled again, just to localize my mistake.
Another thing: compile times. To test the app, you’ll have to build it. That now takes twice as long because the app must be built for two platforms instead of one, duh. Also, there is no hot reload or stuff like that.
Reminder: It is very useful if you have (quite a bit of) experience with SwiftUI and Compose already. This helps a lot with catching these tiny differences that might result in anything between a slight performance hit and a totally unusable app. I didn’t, so that may very well be one major reason for why I experienced the problems the way I did.
Verdict
Even though there were issues, I think choosing Skip for this project was the right call. It is far more enjoyable to work with than say Ionic, and the results speak for themselves.
Once you know its quirks, you can be pretty effective with it. You actually get two fully native apps for the price (price as in development effort) of 1.2 apps. It still is more work than just making an app for a single platform, but far less work than making two apps, one for each platform. Considering that, the pricing per developer of 29$/month for small businesses and 99$/month for larger businesses seems fair. Also, you just pay for the build tools (i.e. the SkipStone build plugin). The libraries themselves are open source, so you are not forced to keep paying the license just to be allowed to keep your apps online.
Having tried a completely web-based approach with Capacitor+Ionic and now a completely native approach with SwiftUI+Skip, for my next mobile app project I will likely give React Native a try (maybe also Kotlin/Compose Multiplatform, who knows🤷♂️).
But given that Vector Bible (a Vue.js web app plus this native app) took almost a year to build, my next project will probably be less complex. And with Liquid Glass just around the corner, I still need to adapt this app for that too. The aftermath of the whole Vector Bible project, though, is a topic for another time. Probably once the stuff I’ve recently worked on is finally approved and released.
Thanks a lot for reading!